Line data Source code
1 : // SPDX-FileCopyrightText: 2013-2025 Paul Colby <git@colby.id.au>
2 : // SPDX-License-Identifier: LGPL-3.0-or-later
3 :
4 : #include "generator.h"
5 : #include "renderer.h"
6 :
7 : #include <qtsmithy/model.h>
8 :
9 : #include <QDebug>
10 : #include <QDirIterator>
11 : #include <QJsonParseError>
12 : #include <QRegularExpression>
13 :
14 : #define QSL(str) QStringLiteral(str) // Shorten the QStringLiteral macro for readability.
15 :
16 : class ScopedContext
17 : {
18 : public:
19 : ScopedContext(Renderer * renderer, const QVariantHash &hash) : renderer(renderer)
20 : {
21 0 : renderer->push(hash);
22 0 : }
23 :
24 : ScopedContext() {
25 : renderer->pop();
26 : }
27 :
28 : private:
29 : Renderer * renderer;
30 : };
31 :
32 :
33 : const QRegularExpression Generator::servicePattern{
34 : QSL("(?<type>Service)(?<seperator>[^a-zA-Z0-9]*)(?<id>Title|Id|sdkId)"),
35 : QRegularExpression::CaseInsensitiveOption
36 : };
37 :
38 : const QRegularExpression Generator::operationPattern{
39 : QSL("(?<type>Operation)(?<seperator>[^a-zA-Z0-9]*)(?<id>Name)"),
40 : QRegularExpression::CaseInsensitiveOption
41 : };
42 :
43 0 : Generator::Generator(const smithy::Model * const model, Renderer * const renderer)
44 0 : : model(model), renderer(renderer)
45 : {
46 : Q_ASSERT(model->isValid());
47 : Q_ASSERT(renderer);
48 0 : }
49 :
50 :
51 0 : int Generator::expectedFileCount() const
52 : {
53 0 : const QHash<smithy::ShapeId, smithy::Shape> services = model->shapes(smithy::Shape::Type::Service);
54 0 : const int operations = std::accumulate(services.constBegin(), services.constEnd(), 0,
55 0 : [](const int a, const smithy::Shape &shape) { return a + shape.operations().size();
56 0 : });
57 0 : const QStringList templates = renderer->templatesNames();
58 0 : return std::accumulate(templates.constBegin(), templates.constEnd(), 0,
59 0 : [&](const int a, const QString &name) {
60 0 : return a + (servicePattern.match(name).hasMatch() ?
61 0 : (operationPattern.match(name).hasMatch() ? operations : services.size()) : 1);
62 0 : });
63 0 : }
64 :
65 0 : bool Generator::generate(const QString &outputDir, ClobberMode clobberMode)
66 : {
67 : // Add initial context.
68 0 : QVariantMap servicesMap;
69 0 : const QHash<smithy::ShapeId, smithy::Shape> services = model->shapes(smithy::Shape::Type::Service);
70 0 : for (const smithy::Shape &service: services) {
71 0 : servicesMap.insert(service.id().toString(), toContext(service));
72 : }
73 0 : const ScopedContext context(renderer, { { QSL("services"), servicesMap } });
74 :
75 : // Build the list of template names.
76 0 : const QStringList templatesNames = renderer->templatesNames();
77 0 : QStringList serviceTemplateNames, operationTemplateNames, plainTemplateNames;
78 0 : for (const QString &name: templatesNames) {
79 0 : ((servicePattern.match(name).hasMatch()) ? (operationPattern.match(name).hasMatch() ?
80 0 : operationTemplateNames : serviceTemplateNames) : plainTemplateNames).append(name);
81 : }
82 : Q_ASSERT(serviceTemplateNames.size() + operationTemplateNames.size()
83 : + plainTemplateNames.size() == templatesNames.size());
84 :
85 : // Render all of the services.
86 0 : if (!std::all_of(services.constBegin(), services.constEnd(), [&](const smithy::Shape &service) {
87 0 : return renderService(service, serviceTemplateNames, operationTemplateNames, outputDir, clobberMode);
88 : })) {
89 0 : return false;
90 : }
91 :
92 : // Render all of the one-off (not per-service) files.
93 0 : qCDebug(lc).noquote() << tr("Rendering %n unary template/s", nullptr, plainTemplateNames.size());
94 0 : return std::all_of(plainTemplateNames.constBegin(), plainTemplateNames.constEnd(),
95 0 : [&](const QString &templateName){
96 0 : const QString outputPathName = outputDir + QLatin1Char('/') + templateName;
97 0 : return render(templateName, outputPathName, clobberMode);
98 0 : });
99 0 : }
100 :
101 0 : QStringList Generator::renderedFiles() const
102 : {
103 0 : return renderedPathNames;
104 : }
105 :
106 0 : QStringList Generator::skippedFiles() const
107 : {
108 0 : return skippedPathNames;
109 : }
110 :
111 0 : bool Generator::renderService(const smithy::Shape &service,
112 : const QStringList &serviceTemplateNames,
113 : const QStringList &operationTemplateNames,
114 : const QString &outputDir, ClobberMode &clobberMode)
115 : {
116 0 : qCDebug(lc).noquote() << tr("Rendering templates for service %1").arg(service.id().toString());
117 :
118 : // Add renderer context for this service.
119 0 : const QVariantHash serviceContext = toContext(service);
120 : // cppcheck-suppress unreadVariable
121 0 : const ScopedContext context(renderer, { { QSL("service"), serviceContext } });
122 :
123 : // Render each service template.
124 0 : for (const QString &templateName: serviceTemplateNames) {
125 0 : const QString outputPathName = makeOutputPathName(templateName, servicePattern, {
126 0 : { QSL("name"), service.id().shapeName() },
127 0 : { QSL("id"), serviceContext.value(QSL("canonicalId")).toString() },
128 0 : { QSL("sdkid"), serviceContext.value(QSL("sdkId")).toString() },
129 0 : }, outputDir);
130 0 : if (!render(templateName, outputPathName, clobberMode)) {
131 : return false;
132 : }
133 0 : }
134 :
135 : // Render each of the service's operation.
136 0 : const QVariantMap operations = serviceContext.value(QSL("operations")).toMap();
137 0 : for (auto iter = operations.constBegin(); iter != operations.constEnd(); ++iter) {
138 0 : const smithy::Shape operation = model->shape(iter->toHash().value(QSL("shapeId")).toString());
139 0 : if (!operation.isValid()) {
140 0 : qCCritical(lc).noquote() << tr("Failed to find shape for %1 operation in %2 service")
141 0 : .arg(iter.key(), service.id().toString());
142 0 : return false;
143 : }
144 0 : if (!renderOperation(operation, serviceContext, operationTemplateNames, outputDir, clobberMode)) {
145 : return false;
146 : }
147 0 : }
148 : return true;
149 0 : }
150 :
151 0 : bool Generator::renderOperation(const smithy::Shape &operation, const QVariantHash serviceContext,
152 : const QStringList &templateNames, const QString &outputDir,
153 : ClobberMode &clobberMode)
154 : {
155 0 : qCDebug(lc).noquote() << tr("Rendering templates for operation %1").arg(operation.id().toString());
156 :
157 : // Add renderer context for this operation.
158 : // cppcheck-suppress unreadVariable
159 0 : const ScopedContext context(renderer, { { QSL("operation"), toContext(operation) } });
160 0 : for (const QString &templateName: templateNames) {
161 0 : QString outputPathName = makeOutputPathName(templateName, servicePattern, {
162 0 : { QSL("name"), serviceContext.value(QSL("shapeName")).toString() },
163 0 : { QSL("id"), serviceContext.value(QSL("canonicalId")).toString() },
164 0 : { QSL("sdkid"), serviceContext.value(QSL("sdkId")).toString() },
165 0 : });
166 0 : outputPathName = makeOutputPathName(outputPathName, operationPattern, {
167 0 : { QSL("name"), operation.id().shapeName() },
168 : }, outputDir);
169 0 : if (!render(templateName, outputPathName, clobberMode)) {
170 : return false;
171 : }
172 0 : }
173 : return true;
174 0 : }
175 :
176 0 : bool Generator::render(const QString &templateName, const QString &outputPathName,
177 : ClobberMode &clobberMode)
178 : {
179 0 : if (QFile::exists(outputPathName)) {
180 0 : switch (clobberMode) {
181 0 : case ClobberMode::Overwrite:
182 0 : qCDebug(lc) << tr("Overwriting existing file: %1").arg(outputPathName);
183 0 : break;
184 0 : case ClobberMode::Prompt:
185 0 : if (promptToOverwrite(outputPathName, clobberMode)) {
186 : break; // User said yes, so jump out of case statement.
187 : }
188 : __attribute__((fallthrough));
189 : case ClobberMode::Skip:
190 0 : qCDebug(lc) << tr("Skipping existing output file: %1").arg(outputPathName);
191 0 : skippedPathNames.append(outputPathName);
192 0 : return true;
193 : }
194 : }
195 0 : if (!renderer->render(templateName, outputPathName)) {
196 : return false;
197 : }
198 0 : renderedPathNames.append(outputPathName);
199 0 : return true;
200 : }
201 :
202 : // Turn shape into a context hash. Note, only service and operation shapes supported for now.
203 0 : QVariantHash Generator::toContext(const smithy::Shape &shape) const
204 : {
205 0 : if (shape.type() == smithy::Shape::Type::Service) {
206 0 : QVariantHash hash = shape.rawAst().toVariantHash();
207 0 : hash.insert(QSL("shapeName"), shape.id().shapeName());
208 0 : hash.insert(QSL("canonicalId"), canonicalServiceId(shape.traits()
209 0 : .value(QSL("aws.api#service")).toObject().value(QSL("sdkId")).toString()));
210 0 : hash.insert(QSL("sdkId"), shape.traits().value(QSL("aws.api#service")).toObject()
211 0 : .value(QSL("sdkId")).toString());
212 0 : hash.insert(QSL("documentation"), formatHtmlDocumentation(
213 0 : shape.traits().value(QSL("smithy.api#documentation")).toString()));
214 0 : QVariantMap operations;
215 0 : const smithy::Shape::ShapeReferences operationRefs = shape.operations();
216 0 : for (const smithy::Shape::ShapeReference &operationRef: operationRefs) {
217 0 : operations.insert(operationRef.target.shapeName(),
218 0 : toContext(model->shape(operationRef.target)));
219 : }
220 : Q_ASSERT(operationRefs.size() == operations.size());
221 0 : const smithy::Shape::ShapeReferences resourceRefs = shape.resources();
222 0 : for (const smithy::Shape::ShapeReference &resourceRef: resourceRefs) {
223 0 : const QVariantHash resourceContext = toContext(model->shape(resourceRef.target));
224 : // resourceContext will contain the raw AST, and probably other things too over time,
225 : // but for now (to replicate historical aws-sdk-qt codegen behaviour) we just want to
226 : // add the resource operations to the service operations list.
227 0 : const QVariantMap resourceOperations = resourceContext.value(QSL("operations")).toMap();
228 0 : for (auto iter = resourceOperations.constBegin(); iter != resourceOperations.constEnd(); ++iter) {
229 0 : operations.insert(iter.key(), iter.value());
230 : }
231 0 : }
232 0 : hash.insert(QSL("operations"), operations);
233 : return hash;
234 0 : }
235 :
236 0 : if (shape.type() == smithy::Shape::Type::Operation) {
237 0 : QVariantHash hash = shape.rawAst().toVariantHash();
238 0 : hash.insert(QSL("name"), shape.id().shapeName());
239 0 : hash.insert(QSL("shapeId"), shape.id().toString());
240 0 : hash.insert(QSL("documentation"), formatHtmlDocumentation(
241 0 : shape.traits().value(QSL("smithy.api#documentation")).toString()));
242 : return hash;
243 0 : }
244 :
245 0 : if (shape.type() == smithy::Shape::Type::Resource) {
246 0 : QVariantHash hash = shape.rawAst().toVariantHash();
247 0 : QVariantMap operations;
248 : #define QTSMITHY_IF_VALID_INSERT(action) { \
249 : const smithy::ShapeId action##TargetId = shape.action().target; \
250 : if (action##TargetId.isValid()) { \
251 : operations.insert(action##TargetId.shapeName(), \
252 : toContext(model->shape(action##TargetId))); \
253 : } \
254 : }
255 0 : QTSMITHY_IF_VALID_INSERT(create)
256 0 : QTSMITHY_IF_VALID_INSERT(put)
257 0 : QTSMITHY_IF_VALID_INSERT(read)
258 0 : QTSMITHY_IF_VALID_INSERT(update)
259 0 : QTSMITHY_IF_VALID_INSERT(Delete)
260 0 : QTSMITHY_IF_VALID_INSERT(list)
261 : #undef QTSMITHY_IF_VALID_INSERT
262 :
263 : #define QTSMITHY_ADD_SHAPES(property) { \
264 : const smithy::Shape::ShapeReferences refs = shape.property(); \
265 : for (const smithy::Shape::ShapeReference &ref: refs) { \
266 : operations.insert(ref.target.shapeName(), toContext(model->shape(ref.target))); \
267 : } \
268 : }
269 0 : QTSMITHY_ADD_SHAPES(operations)
270 0 : QTSMITHY_ADD_SHAPES(collectionOperations)
271 : #undef QTSMITHY_ADD_SHAPES
272 :
273 0 : const smithy::Shape::ShapeReferences resourceRefs = shape.resources();
274 0 : for (const smithy::Shape::ShapeReference &resourceRef: resourceRefs) {
275 0 : const QVariantHash resourceContext = toContext(model->shape(resourceRef.target));
276 : // resourceContext will contain the raw AST, and probably other things too over time,
277 : // but for now (to replicate historical aws-sdk-qt codegen behaviour) we just want to
278 : // add the resource operations to the service operations list.
279 0 : const QVariantMap resourceOperations = resourceContext.value(QSL("operations")).toMap();
280 0 : for (auto iter = resourceOperations.constBegin(); iter != resourceOperations.constEnd(); ++iter) {
281 0 : operations.insert(iter.key(), iter.value());
282 : }
283 0 : }
284 :
285 0 : hash.insert(QSL("operations"), operations);
286 : return hash;
287 0 : }
288 :
289 0 : qCCritical(lc).noquote() << tr("Cannot generate context for shape type 0x%1")
290 0 : .arg((int)shape.type(), 0, 16);
291 0 : return QVariantHash{};
292 : }
293 :
294 :
295 0 : const QString Generator::canonicalServiceId(const QString &sdkId)
296 : {
297 : // Handle some hard-coded special cases that are hard to automate without a dictionary.
298 : const QMap<QString,QString> specialCases{
299 0 : { QSL("identitystore" ), QSL("IdentityStore") },
300 0 : { QSL("ivschat" ), QSL("IvsChat") },
301 0 : { QSL("forecastquery" ), QSL("ForecastQuery") },
302 0 : { QSL("resiliencehub" ), QSL("ResilienceHub") },
303 0 : { QSL("savingsplans" ), QSL("SavingsPlans") },
304 0 : { QSL("codeartifact" ), QSL("CodeArtifact") },
305 0 : { QSL("imagebuilder" ), QSL("ImageBuilder") },
306 0 : { QSL("billingconductor"), QSL("BillingConductor") },
307 0 : };
308 0 : for (const auto iter = specialCases.constFind(sdkId); iter != specialCases.constEnd(); ) {
309 : return iter.value();
310 : }
311 :
312 : // Start with the sdkId.
313 : QString id = sdkId;
314 :
315 : // Convert runs of upercase letters with first-upper, and the rest lower.
316 0 : auto iter = QRegularExpression(QSL("[A-Z]{2,}([^a-zA-Z]|$)")).globalMatch(id);
317 0 : while (iter.hasNext()) {
318 0 : const auto match = iter.next();
319 0 : id.replace(match.capturedStart()+1, match.capturedLength()-1,
320 0 : match.captured().mid(1).toLower());
321 0 : }
322 :
323 : // If the sdkId was all lowercase, then uppercase the first letter of each word.
324 : #if (QT_VERSION < QT_VERSION_CHECK(5, 12, 0))
325 : if (id == id.toLower()) {
326 : #else
327 0 : if (id.isLower()) { // QString::isLower() added in Qt 5.12.
328 : #endif
329 0 : const QStringList words = id.split(QRegularExpression(QSL("[^a-zA-Z0-9]+")));
330 0 : id.clear();
331 0 : for (const QString &word: words) {
332 0 : id.append(word.at(0).toUpper() + word.mid(1));
333 : }
334 : }
335 :
336 : // Strip all non-alphanumeric characters, and any instances of "Amazon" and "AWS".
337 0 : id.replace(QRegularExpression(QSL("[^a-zA-Z0-9]|Amazon|AWS"),
338 0 : QRegularExpression::PatternOption::CaseInsensitiveOption),QString());
339 :
340 : // Remove any trailing instances of "API", "Client" and "Service".
341 0 : if (id == QSL("ConfigService")) return id; // Skip dropping the Service for this one.
342 0 : if (id == QSL("DirectoryService")) return id; // Skip dropping the Service for this one.
343 0 : id.replace(QRegularExpression(QSL("(API|Client|Service)$"),
344 0 : QRegularExpression::PatternOption::CaseInsensitiveOption),QString());
345 : return id;
346 0 : }
347 :
348 0 : bool Generator::promptToOverwrite(const QString &pathName, ClobberMode &clobberMode)
349 : {
350 : Q_ASSERT(clobberMode == ClobberMode::Prompt);
351 : while (true) {
352 0 : qCWarning(lc).noquote() << tr("Overwrite %1 [y,n,a,s,q,?]? ").arg(pathName);
353 0 : QTextStream stream(stdin);
354 0 : const QString response = stream.readLine().toLower();
355 0 : if (response == QSL("y")) {
356 : return true;
357 0 : } else if (response == QSL("n")) {
358 : return false;
359 0 : } else if (response == QSL("a")) {
360 0 : clobberMode = ClobberMode::Overwrite;
361 0 : return true;
362 0 : } else if (response == QSL("s")) {
363 0 : clobberMode = ClobberMode::Skip;
364 0 : return false;
365 0 : } else if (response == QSL("q")) {
366 0 : QCoreApplication::exit(EXIT_FAILURE);
367 : } else {
368 0 : qCInfo(lc).noquote() << tr("y - overwrite this file");
369 0 : qCInfo(lc).noquote() << tr("n - do not overwrite this file");
370 0 : qCInfo(lc).noquote() << tr("a - overwrite this, and all remaining files");
371 0 : qCInfo(lc).noquote() << tr("s - do not overwrite this, or any remaining files");
372 0 : qCInfo(lc).noquote() << tr("q - quit now, without writing any further files");
373 0 : qCInfo(lc).noquote() << tr("? - print help");
374 : }
375 0 : }
376 : Q_UNREACHABLE();
377 : }
378 :
379 0 : Generator::Capitalisation Generator::getCase(const QString &first, const QString &second)
380 : {
381 : #if (QT_VERSION < QT_VERSION_CHECK(5, 12, 0))
382 : if ((first == first.toLower()) && (second == second.toLower())) {
383 : #else
384 0 : if (first.isLower() && second.isLower()) {
385 : #endif
386 : return Capitalisation::lowercase;
387 : }
388 : #if (QT_VERSION < QT_VERSION_CHECK(5, 12, 0))
389 : if ((first == first.toUpper()) && (second == second.toUpper())) {
390 : #else
391 0 : if (first.isUpper() && second.isUpper()) {
392 : #endif
393 : return Capitalisation::UPPERCASE;
394 : }
395 : #if (QT_VERSION < QT_VERSION_CHECK(5, 12, 0))
396 : if ((first.at(0) == first.at(0).toLower()) && (second.at(0) == second.at(0).toUpper())) {
397 : #else
398 0 : if (first.front().isLower() && second.front().isUpper()) {
399 : #endif
400 0 : return Capitalisation::camelCase;
401 : }
402 : #if (QT_VERSION < QT_VERSION_CHECK(5, 12, 0))
403 : if ((first.at(0) == first.at(0).isUpper()) && (second.at(0) == second.at(0).toLower())) {
404 : #else
405 0 : if (first.front().isUpper() && second.front().isUpper()) {
406 : #endif
407 0 : return Capitalisation::CamelCase;
408 : }
409 : return Capitalisation::NoChange;
410 : }
411 :
412 0 : QString Generator::makeCase(const QString &string, const Capitalisation &capitalisation)
413 : {
414 0 : switch (capitalisation) {
415 : case Capitalisation::NoChange:
416 : return string;
417 0 : case Capitalisation::lowercase:
418 : return string.toLower();
419 : case Capitalisation::camelCase:
420 0 : Q_UNIMPLEMENTED();
421 0 : break;
422 : case Capitalisation::CamelCase:
423 0 : Q_UNIMPLEMENTED();
424 0 : break;
425 0 : case Capitalisation::UPPERCASE:
426 : return string.toUpper();
427 : }
428 0 : qCCritical(lc).noquote() << tr("Unknown capitalisation %1 for %2")
429 0 : .arg((int)capitalisation).arg(string);
430 : return QString{};
431 : }
432 :
433 0 : QString Generator::makeOutputPathName(const QString &templateName, const QRegularExpression &pattern,
434 : const QHash<QString,QString> &ids, const QString &outputDir)
435 : {
436 : QString outputPathName = templateName;
437 0 : for (auto iter = pattern.globalMatch(templateName); iter.hasNext(); ) {
438 0 : const QRegularExpressionMatch match = iter.next();
439 0 : const QString type = match.captured(QSL("type"));
440 0 : const QString sep = match.captured(QSL("separator"));
441 0 : const QString id = match.captured(QSL("id"));
442 0 : const QString newId = ids.value(id.toLower());
443 0 : if (newId.isNull()) {
444 0 : qCCritical(lc).noquote() << tr("No pathname ID value for %1").arg(id);
445 : return QString{};
446 : }
447 0 : const QString label = makeCase(newId, getCase(type, id)).replace(QLatin1Char(' '), sep);
448 0 : outputPathName.replace(type+sep+id, label);
449 0 : }
450 0 : if (!outputDir.isEmpty()) {
451 0 : outputPathName.prepend(outputDir + QLatin1Char('/'));
452 : }
453 : return outputPathName;
454 0 : }
455 :
456 : /// \todo This is often dropping the last word of a sentence... won't fix yet so we can maintain
457 : /// the old aws-sdk-qt codegen behaviour until the swithover to smithy-qt templating is complete
458 : /// (ie to minimise the initial change-over diff).
459 0 : QStringList Generator::formatHtmlDocumentation(const QString &html)
460 : {
461 : QString content(html);
462 :
463 : /// @todo There's much more we can do here, indeed the documentation
464 : /// conversation still needs a lot of love. But its a start, and no point
465 : /// prioritising it yet, since its more important we get the code structure
466 : /// right first.
467 :
468 0 : content.replace(QSL("<function>"), QSL("<code>"));
469 0 : content.replace(QSL("</function>"), QSL("</code>"));
470 :
471 0 : content.replace(QSL("<important>"), QSL("<b>"));
472 0 : content.replace(QSL("</important>"), QSL("</b>"));
473 :
474 : // Replace /* and */ with * versions to avoid breaking comment blocks.
475 0 : content.replace(QSL("/*"), QSL("/*"));
476 0 : content.replace(QSL("*/"), QSL("*/"));
477 :
478 0 : QStringList lines;
479 0 : QString line;
480 : #if (QT_VERSION > QT_VERSION_CHECK(5, 14, 0))
481 : #define SKIP_EMPTY_PARTS Qt::SkipEmptyParts // Introduced in Qt 5.14.
482 : #else
483 : #define SKIP_EMPTY_PARTS QString::SkipEmptyParts // Deprecated in Qt 5.15.
484 : #endif
485 0 : for (QString word: content.split(QRegularExpression(QSL("\\s+")), SKIP_EMPTY_PARTS)) {
486 0 : if (word.startsWith(QSL("<p>")) || word.endsWith(QSL("</p>"))) {
487 0 : lines.append(line);
488 0 : line.clear();
489 0 : if (!lines.last().isEmpty()) {
490 0 : lines.append(QString()); // A blank line.
491 : }
492 0 : if (word.startsWith(QSL("<p>"))) {
493 0 : word.remove(0,3);
494 : }
495 0 : if (word.endsWith(QSL("</p>"))) {
496 0 : word.remove(word.size()-5,4);
497 : }
498 : }
499 :
500 0 : if (line.isEmpty()) {
501 : line += word;
502 0 : } else if (line.size() + word.size() < 120) {
503 0 : line += QLatin1Char(' ') + word;
504 : } else {
505 0 : lines.append(line);
506 0 : line = word;
507 : }
508 0 : }
509 :
510 : // Remove leading and trailing blank lines.
511 0 : while ((!lines.isEmpty()) && (lines.first().isEmpty())) {
512 0 : lines.removeFirst();
513 : }
514 0 : while ((!lines.isEmpty()) && (lines.last().isEmpty())) {
515 0 : lines.removeLast();
516 : }
517 0 : return lines;
518 0 : }
|