001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.freemarker;
018
019import static java.nio.charset.StandardCharsets.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.nio.file.*;
024import java.util.*;
025import java.util.function.UnaryOperator;
026import java.util.regex.*;
027import java.util.stream.*;
028
029import org.apache.juneau.commons.collections.Maps;
030import org.apache.juneau.rest.server.staticfile.WebJarResolver;
031import org.apache.juneau.console.views.*;
032
033import freemarker.core.*;
034import freemarker.template.*;
035
036/**
037 * Static lint for console FreeMarker templates. Scans each template for console directive calls and reports unknown
038 * directives, unknown attributes, dangling template references, duplicate nav and card ids, non-strict booleans,
039 * removed card types and globals, a chart card whose ECharts WebJar is missing (E-70), and a wrong {@code <@main/>} count, without rendering any page.
040 *
041 * <p>
042 * The attribute sets are the same ones the directives enforce at render time. Interpolated values
043 * ({@code visible="${x}"}) and unquoted expressions ({@code visible=flag}) are skipped; the render-time errors cover
044 * them. Macros defined in the template, in literally {@code <#include>}d templates and in the
045 * {@link #chromeTemplate(String) chrome template} are in scope, as are {@code <@ns.x>} calls on an
046 * {@code <#import … as ns>} namespace.
047 *
048 * <h5 class='section'>Example:</h5>
049 * <p class='bjava'>
050 *    <ja>@Test</ja> <jk>void</jk> templatesLint() {
051 *       List&lt;ConsoleTemplateValidator.Finding&gt; <jv>f</jv> = ConsoleTemplateValidator.<jsm>create</jsm>()
052 *          .templateRoot(Paths.<jsm>get</jsm>(<js>"src/main/resources/templates"</js>))
053 *          .chromeTemplate(<js>"base.ftlh"</js>)
054 *          .allowDirective(<js>"pageHero"</js>)
055 *          .validateAll();
056 *       <jsm>assertTrue</jsm>(<jv>f</jv>.isEmpty(), <jv>f</jv>::toString);
057 *    }
058 * </p>
059 *
060 * @since 10.0.0
061 */
062@SuppressWarnings({
063   "java:S1075", // "/" is the template-name separator, not a file-system path.
064   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
065})
066public final class ConsoleTemplateValidator {
067
068   /**
069    * One lint finding.
070    *
071    * @param template The template name, relative to the root, with {@code /} separators.
072    * @param line The 1-based line.
073    * @param column The 1-based column.
074    * @param rule The rule id, for example {@code unknown-attribute}.
075    * @param message The human-readable message.
076    */
077   public record Finding(String template, int line, int column, String rule, String message) {
078      @Override
079      public String toString() {
080         return template + ":" + line + ":" + column + ": [" + rule + "] " + message;
081      }
082   }
083
084   @SuppressWarnings({
085      "java:S5843", // Tag grammar needs this complexity; it is one regex by design.
086      "java:S5998", // Lint runs on authored templates at build time, never untrusted input.
087      "java:S8786" // Same: the lazy attribute scan is linear on well-formed tags.
088   })
089   private static final Pattern TAG = Pattern.compile("<(/?)@([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)?)((?:\"[^\"]*\"|'[^']*'|[^>\"'])*?)(/?)>");
090   private static final Pattern ATTR = Pattern.compile("([A-Za-z_]\\w*)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'/>]+))");
091   private static final Pattern MACRO = Pattern.compile("<#macro\\s+([A-Za-z_]\\w*)");
092   private static final Pattern INCLUDE = Pattern.compile("<#include\\s+\"([^\"]+)\"");
093   private static final Pattern IMPORT = Pattern.compile("<#import\\s+\"[^\"]+\"\\s+as\\s+([A-Za-z_]\\w*)");
094   private static final Pattern COMMENT = Pattern.compile("(?s)<#--.*?-->");
095   private static final Pattern GLOBAL = Pattern.compile("\\b(pageBody|pageTab|pageInit|pageCss|pageToolkitCss|pageToolkitJs|pageToolkit|pageThemeCss)\\b");
096   private static final Pattern VIEW_KEY = Pattern.compile("\\bview\\s*:\\s*\\{");
097   private static final Pattern VIEW_VERSION = Pattern.compile("^\\s*[\"']?contractVersion[\"']?\\s*:\\s*[\"']([^\"']*)[\"']");
098   private static final Pattern ASSET_REF = Pattern.compile("\\b(?:src|href)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')");
099   private static final Pattern JUNEAU_ASSET = Pattern.compile("(?:^|/)(?:juneau-[\\w-]+\\.(?:js|css|svg)|juneau-console/chrome\\.css)(?:[?#].*)?$");
100   private static final Set<String> REMOVED_TYPES = Set.of("js", "json", "calendar");
101   private static final Map<String,Set<String>> STRICT = Map.of(NodeDirectiveModel.NAME, Set.of("visible", "selected"), ConsoleDirectiveModel.NAME, Set.of("chrome", "schemeToggle"));
102   private static final Map<String,Set<String>> DIRECTIVES = directives();
103
104   private static Map<String,Set<String>> directives() {
105      Maps<String,Set<String>> m = mb();
106      m.add(PageDirectiveModel.NAME, PageDirectiveModel.ATTRS);
107      m.add(ConsoleDirectiveModel.NAME, ConsoleDirectiveModel.ATTRS);
108      m.add(MainDirectiveModel.NAME, MainDirectiveModel.ATTRS);
109      m.add(NavigationDirectiveModel.NAME, NavigationDirectiveModel.ATTRS);
110      m.add(NodeDirectiveModel.NAME, NodeDirectiveModel.ATTRS);
111      m.add(ThemeDirectiveModel.NAME, ThemeDirectiveModel.ATTRS);
112      m.add(TokenDirectiveModel.NAME, TokenDirectiveModel.ATTRS);
113      m.add(CardDirectiveModel.NAME, CardDirectiveModel.ATTRS);
114      m.add(TopicDirectiveModel.NAME, TopicDirectiveModel.ATTRS);
115      m.add(BridgeDirectiveModel.NAME, BridgeDirectiveModel.ATTRS);
116      m.add(BadgeDirectiveModel.NAME, BadgeDirectiveModel.ATTRS);
117      m.add(FactsDirectiveModel.NAME, Set.of());
118      for (var s : ConsoleSlotDirectiveModel.SLOT_NAMES)
119         m.add(s, new ConsoleSlotDirectiveModel(s).attrs());
120      return u(m.build());
121   }
122
123   private static final Configuration PARSE_CFG = parseConfig();
124
125   private static Configuration parseConfig() {
126      var c = new Configuration(Configuration.VERSION_2_3_34);
127      c.setRecognizeStandardFileExtensions(true);
128      return c;
129   }
130
131   private Path templateRoot;
132   private String classpathRoot;
133   private String chromeTemplate;
134   private final Set<String> allowed = new TreeSet<>();
135   private UnaryOperator<String> webJarProbe = WebJarResolver::resolvePath;
136   private final ToolkitPackRegistry packs = new ToolkitPackRegistry();
137   private String echartsProblem;         // cached E-70 (or invalid-pack-graph) message; null when the probe passed
138   private String echartsRule;            // the rule id the cached problem is reported under
139   private boolean echartsProbed;
140
141   private ConsoleTemplateValidator() {}
142
143   /** @return A new validator with no roots. */
144   public static ConsoleTemplateValidator create() {
145      return new ConsoleTemplateValidator();
146   }
147
148   /**
149    * @param dir The file-system directory templates are resolved against and {@link #validateAll()} walks.
150    * @return This object.
151    */
152   public ConsoleTemplateValidator templateRoot(Path dir) {
153      templateRoot = dir;
154      return this;
155   }
156
157   /**
158    * @param prefix The classpath prefix templates are resolved against when not found under the template root, for
159    *    example {@code "/templates/"}.
160    * @return This object.
161    */
162   public ConsoleTemplateValidator classpathRoot(String prefix) {
163      classpathRoot = prefix.endsWith("/") ? prefix : prefix + "/";
164      return this;
165   }
166
167   /**
168    * @param name The chrome template the {@code <@page>} directive renders. Its macros are in scope for every template.
169    * @return This object.
170    */
171   public ConsoleTemplateValidator chromeTemplate(String name) {
172      chromeTemplate = name;
173      return this;
174   }
175
176   /**
177    * @param name An adopter-defined directive or macro name to accept, for example {@code "pageHero"}.
178    * @return This object.
179    */
180   public ConsoleTemplateValidator allowDirective(String name) {
181      allowed.add(name);
182      return this;
183   }
184
185   // Package-private (no production caller; the mixin owns its own registry).  Names packs the app's chrome already
186   // loads; a provided "echarts" pack suppresses the missing-webjar rule.
187   // @throws IllegalArgumentException If a name is not a registered pack.
188   ConsoleTemplateValidator providedPacks(String...names) {
189      var list = Arrays.asList(names);
190      packs.requireKnown(list, "providedPacks");
191      packs.provide(list);
192      echartsProbed = false;
193      return this;
194   }
195
196   // Package-private: registers an app-supplied toolkit pack; a replacement "echarts" pack (for example one served
197   // from a CDN) suppresses the missing-webjar rule.
198   // The mixin's registerToolkitPack(String, List, List) overload is deliberately absent: it builds a pack from asset
199   // lists, which cannot change whether the "charts" WebJars resolve, so no lint rule depends on it.
200   ConsoleTemplateValidator registerToolkitPack(ToolkitPack pack) {
201      packs.register(rnn(pack));
202      echartsProbed = false;
203      return this;
204   }
205
206   // Package-private: tests simulate an absent WebJar.
207   ConsoleTemplateValidator webJarProbe(UnaryOperator<String> probe) {
208      webJarProbe = rnn(probe);
209      echartsProbed = false;
210      return this;
211   }
212
213   // The E-70 message when the WebJars the "charts" pack reaches are missing, or null.  Probes the pack the chart card
214   // resolves at runtime (so a replaced "charts" or "echarts" pack is honoured) through the registry's own provided and
215   // replaced-pack knowledge; probed at most once per configuration, not once per chart card.  A broken pack graph
216   // (unknown dependsOn, cycle) is reported as an invalid-pack-graph finding instead of crashing the lint.
217   private String echartsProblem() {
218      if (! echartsProbed) {
219         echartsProblem = null;
220         echartsRule = "missing-webjar";
221         try {
222            packs.checkWebJars(List.of(ToolkitPackRegistry.PACK_CHARTS), webJarProbe);
223         } catch (IllegalStateException e) {
224            echartsProblem = e.getMessage();
225         } catch (IllegalArgumentException e) {
226            echartsProblem = e.getMessage();
227            echartsRule = "invalid-pack-graph";
228         }
229         echartsProbed = true;
230      }
231      return echartsProblem;
232   }
233
234   /**
235    * @param templateName The template to lint, relative to the template root or classpath root.
236    * @return The findings, sorted by line and column. Empty when the template is clean.
237    * @throws IllegalArgumentException If the template cannot be found.
238    */
239   public List<Finding> validate(String templateName) {
240      var src = load(templateName);
241      if (n(src))
242         throw iaex("Template '%s' not found under templateRoot or classpathRoot.", templateName);
243      return validateSource(templateName, src);
244   }
245
246   /**
247    * Lints every {@code *.ftlh} and {@code *.ftl} file under the template root.
248    *
249    * @return The findings, sorted by template, line and column. Empty when every template is clean.
250    * @throws IllegalStateException If no template root is set.
251    */
252   public List<Finding> validateAll() {
253      if (n(templateRoot))
254         throw isex("validateAll() requires templateRoot(Path); a classpath root cannot be listed.");
255      try (Stream<Path> s = Files.walk(templateRoot)) {
256         var names = s.filter(Files::isRegularFile)
257            .map(p -> templateRoot.relativize(p).toString().replace(File.separatorChar, '/'))
258            .filter(n -> n.endsWith(".ftlh") || n.endsWith(".ftl"))
259            .sorted()
260            .toList();
261         var out = new ArrayList<Finding>();
262         for (var n : names)
263            out.addAll(validate(n));
264         return out;
265      } catch (IOException e) {
266         throw new UncheckedIOException(e);
267      }
268   }
269
270   // Package-private: lints a source string as if it were the template with the given name (unit tests).
271   @SuppressWarnings({
272      "java:S3776" // One pass over the directive tags; splitting it would scatter the lint rules.
273   })
274   List<Finding> validateSource(String name, String source) {
275      var out = new ArrayList<Finding>();
276      var lines = lineStarts(source);
277      parse(name, source, out);
278      var text = blankComments(source);
279
280      var macros = new HashSet<String>(allowed);
281      var namespaces = new HashSet<String>();
282      collectScope(name, text, macros, namespaces, new HashSet<>());
283      if (nn(chromeTemplate) && neq(chromeTemplate, name)) {
284         var chrome = load(chromeTemplate);
285         if (n(chrome))
286            throw iaex("Chrome template '%s' not found under templateRoot or classpathRoot.", chromeTemplate);
287         collectScope(chromeTemplate, blankComments(chrome), macros, namespaces, new HashSet<>());
288      }
289
290      var siblings = new ArrayDeque<Set<String>>();
291      siblings.push(new HashSet<>());
292      var cardIds = new HashSet<String>();
293      var navDepth = 0;
294      var templateRefs = new ArrayList<Object[]>();   // each entry pairs a template name with its source offset
295      var consoleAt = -1;
296      var mains = 0;
297
298      var m = TAG.matcher(text);
299      while (m.find()) {
300         var closing = ! m.group(1).isEmpty();
301         var dname = m.group(2);
302         var selfClosing = ! m.group(4).isEmpty();
303         var at = m.start();
304         var known = closing ? null : DIRECTIVES.get(dname);
305         if (closing) {
306            if ((dname.equals(NodeDirectiveModel.NAME) || dname.equals(NavigationDirectiveModel.NAME)) && siblings.size() > 1)
307               siblings.pop();
308            if (dname.equals(NavigationDirectiveModel.NAME) && navDepth > 0)
309               navDepth--;
310         } else if (n(known)) {
311            var dot = dname.indexOf('.');
312            var ok = dot > 0 ? namespaces.contains(dname.substring(0, dot)) : macros.contains(dname);
313            if (! ok)
314               add(out, name, lines, at, "unknown-directive", f("<@%s> is not a console directive, a macro in scope, or allow-listed.", dname));
315         } else {
316            var attrs = attrs(m.group(3));
317            for (var a : attrs.keySet())
318               if (! known.contains(a))
319                  add(out, name, lines, at, "unknown-attribute", f("<@%s> unknown attribute '%s'.", dname, a));
320            for (var a : STRICT.getOrDefault(dname, Set.of())) {
321               var v = attrs.get(a);
322               if (nn(v) && v.literal && !eqa(v.value, "true", "false"))
323                  add(out, name, lines, at, "strict-boolean", f("<@%s> %s= must be true or false; got '%s'.", dname, a, v.value));
324            }
325            var id = attrs.get("id");
326            switch (dname) {
327               case NavigationDirectiveModel.NAME -> {
328                  if (! selfClosing) {
329                     siblings.push(new HashSet<>());
330                     navDepth++;
331                  }
332               }
333               case NodeDirectiveModel.NAME -> {
334                  if (nn(id) && id.literal && ! siblings.peek().add(id.value))
335                     add(out, name, lines, at, "duplicate-id", f("<@node id='%s'> duplicates a sibling id.", id.value));
336                  if (navDepth > 0 && attrs.containsKey("under"))
337                     add(out, name, lines, at, "unknown-attribute", f("<@%s> unknown attribute '%s'.", dname, "under"));
338                  if (! selfClosing)
339                     siblings.push(new HashSet<>());
340               }
341               case CardDirectiveModel.NAME -> {
342                  if (nn(id) && id.literal && ! cardIds.add(id.value))
343                     add(out, name, lines, at, "duplicate-id", f("<@card id='%s'> duplicates an existing card id.", id.value));
344                  var type = attrs.get("type");
345                  if (nn(type) && type.literal && REMOVED_TYPES.contains(type.value))
346                     add(out, name, lines, at, "removed-card-type", f(
347                        "<@card id='%s'> type='%s' was removed in 10.0.0; use type='html' with a <template>, or a registered card type.",
348                        n(id) ? "" : id.value, type.value));
349                  if (nn(type) && type.literal && eq(type.value, "chart")) {
350                     var problem = echartsProblem();
351                     if (nn(problem))
352                        add(out, name, lines, at, echartsRule, problem);
353                  }
354                  var tpl = attrs.get("template");
355                  if (nn(tpl) && tpl.literal)
356                     templateRefs.add(new Object[]{tpl.value, at});
357                  if (nn(type) && type.literal && eq(type.value, "datatables") && ! selfClosing)
358                     checkViewVersion(out, name, lines, text, m.end(), n(id) ? "" : id.value);
359                  if (! selfClosing)
360                     checkCardBody(out, name, lines, at, text, m.end(), id, attrs.get("src"), attrs.get("type"));
361               }
362               case ConsoleDirectiveModel.NAME -> {
363                  if (consoleAt < 0)
364                     consoleAt = at;
365               }
366               case MainDirectiveModel.NAME -> mains++;
367               default -> { /* slots, theme, token, page: nothing beyond the attribute checks above */ }
368            }
369         }
370      }
371
372      for (var r : templateRefs) {
373         var v = (String)r[0];
374         if (! cardIds.contains(v) && ! v.startsWith("header.") && ! v.startsWith("footer."))
375            add(out, name, lines, (Integer)r[1], "dangling-template", f("<@card> template='%s' names no <@card id> or slot in this template.", v));
376      }
377      if (consoleAt >= 0 && mains != 1)
378         add(out, name, lines, consoleAt, "main-count", f("<@console> requires exactly one <@main/>; found '%s'.", mains));
379
380      var g = GLOBAL.matcher(text);
381      while (g.find()) {
382         add(out, name, lines, g.start(), "removed-global", f("'%s' was removed in 10.0.0; %s", g.group(1),
383            eq(g.group(1), "pageToolkit") ? "use jcHasToolkit(\"views\")." : "<@page> captures the page into the contract."));
384      }
385
386      var a = ASSET_REF.matcher(text);
387      while (a.find()) {
388         var url = a.group(nn(a.group(1)) ? 1 : 2);
389         if (! url.contains("${") && ! url.contains("<#") && JUNEAU_ASSET.matcher(url).find())
390            add(out, name, lines, a.start(), "hardcoded-asset-url", f(
391               "Hard-coded Juneau asset URL '%s' is unversioned and goes stale in browser caches; use viewAssetUrl(...), consoleJsUrl(...), chromeCssUrl(...) or themeAssetUrl(...) so it carries ?v= (an adopter's own assets can use assetUrl(...)).", url));
392      }
393
394      out.sort(Comparator.comparingInt(Finding::line).thenComparingInt(Finding::column));
395      return out;
396   }
397
398   /**
399    * Flags a literal {@code view.contractVersion} that differs from {@link ViewsMixin#CONTRACT_VERSION} in a
400    * {@code type="datatables"} card body. An omitted version is fine (the runtime injects it); only a pinned, stale
401    * value is reported. Scans the {@code view:{...}} object at its own nesting depth so nested {@code contractVersion}
402    * keys (quickStats, detail, ...) are never mistaken for it.
403    */
404   private static void checkViewVersion(List<Finding> out, String name, int[] lines, String text, int bodyStart, String cardId) {
405      var end = text.indexOf("</@card", bodyStart);
406      var body = text.substring(bodyStart, end < 0 ? text.length() : end);
407      var vm = VIEW_KEY.matcher(body);
408      if (! vm.find())
409         return;
410      var depth = 1;
411      for (var i = vm.end(); i < body.length() && depth > 0; i++) {
412         var c = body.charAt(i);
413         if (c == '{' || c == '[')
414            depth++;
415         else if (c == '}' || c == ']')
416            depth--;
417         else if (depth == 1 && c == 'c') {
418            var v = VIEW_VERSION.matcher(body.substring(i));
419            if (v.find() && neq(v.group(1), ViewsMixin.CONTRACT_VERSION)) {
420               add(out, name, lines, bodyStart + i, "view-contract-version", f(
421                  "<@card id='%s'> view.contractVersion is '%s' but the runtime is '%s'; omit it to track the runtime automatically.",
422                  cardId, v.group(1), ViewsMixin.CONTRACT_VERSION));
423               return;
424            }
425         }
426      }
427   }
428
429   private record Value(String value, boolean literal) {}
430
431   // Mirrors CardTypeRegistry.BASE_KEYS (E-23); visibleWhen is deliberately excluded, same as there.
432   private static final Set<String> RESERVED_BODY_KEYS = Set.of("id", "type", "title", "src", "template", "ref");
433
434   /**
435    * The C2 card-body rules: {@code card-src-and-body} and {@code card-reserved-key}.
436    * Reads the body the same way {@link #checkViewVersion} does, up to the next {@code </@card}.
437    */
438   @SuppressWarnings({
439      "java:S107" // Internal helper whose parameters are the render/validation context.
440   })
441   private static void checkCardBody(List<Finding> out, String name, int[] lines, int at, String text, int bodyStart, Value id, Value src, Value type) {
442      var end = text.indexOf("</@card", bodyStart);
443      var body = text.substring(bodyStart, end < 0 ? text.length() : end);
444      var cardId = n(id) ? "" : id.value;
445      var trimmed = body.trim();
446      // A chart card legitimately takes src= (remote rows) plus a body (kind and options).
447      var chart = nn(type) && type.literal && "chart".equals(type.value);
448      if (! trimmed.isEmpty() && nn(src) && src.literal && ! chart)
449         add(out, name, lines, at, "card-src-and-body", f(
450            "<@card id='%s'> has both src='%s' and a body; use exactly one.", cardId, src.value));
451      if (trimmed.isEmpty() || body.contains("${"))
452         return;
453      var keys = topLevelJson5Keys(trimmed);
454      for (var k : keys)
455         if (RESERVED_BODY_KEYS.contains(k))
456            add(out, name, lines, at, "card-reserved-key", f(
457               "<@card id='%s'> body key '%s' is reserved; set it as an attribute.", cardId, k));
458   }
459
460   // Hand-rolled, like attrs() below: finds JSON5 object keys at brace-depth 0, skipping quoted strings (backslash escapes honored). Not a
461   // general parser — enough to catch literal top-level keys for the two card-body lint rules above.
462   private static Set<String> topLevelJson5Keys(String body) {
463      var s = body.trim();
464      if (s.startsWith("{") && s.endsWith("}"))
465         s = s.substring(1, s.length() - 1);
466      var keys = new LinkedHashSet<String>();
467      var depth = 0;
468      var i = 0;
469      while (i < s.length()) {
470         var c = s.charAt(i);
471         if (c == '"' || c == '\'') {
472            // Honor backslash escapes in both quote styles: skip the escaped character wholesale.
473            var j = i + 1;
474            while (j < s.length() && s.charAt(j) != c)
475               j += s.charAt(j) == '\\' ? 2 : 1;
476            i = j >= s.length() ? s.length() : j + 1;
477         } else if (c == '{' || c == '[') {
478            depth++;
479            i++;
480         } else if (c == '}' || c == ']') {
481            depth--;
482            i++;
483         } else if (depth == 0 && (Character.isLetter(c) || c == '_')) {
484            var start = i;
485            while (i < s.length() && (Character.isLetterOrDigit(s.charAt(i)) || s.charAt(i) == '_'))
486               i++;
487            var word = s.substring(start, i);
488            var j = i;
489            while (j < s.length() && Character.isWhitespace(s.charAt(j)))
490               j++;
491            if (j < s.length() && s.charAt(j) == ':')
492               keys.add(word);
493         } else {
494            i++;
495         }
496      }
497      return keys;
498   }
499
500   private static Map<String,Value> attrs(String s) {
501      var out = new LinkedHashMap<String,Value>();
502      var m = ATTR.matcher(s);
503      while (m.find()) {
504         String v;
505         boolean literal;
506         if (nn(m.group(2)) || nn(m.group(3))) {
507            v = m.group(nn(m.group(2)) ? 2 : 3);
508            literal = ! v.contains("${");
509         } else {
510            v = m.group(4);
511            literal = eqa(v, "true", "false");
512         }
513         out.put(m.group(1), new Value(v, literal));
514      }
515      return out;
516   }
517
518   private void collectScope(String name, String text, Set<String> macros, Set<String> namespaces, Set<String> seen) {
519      if (! seen.add(name))
520         return;
521      var m = MACRO.matcher(text);
522      while (m.find())
523         macros.add(m.group(1));
524      var i = IMPORT.matcher(text);
525      while (i.find())
526         namespaces.add(i.group(1));
527      var inc = INCLUDE.matcher(text);
528      while (inc.find()) {
529         var target = resolve(name, inc.group(1));
530         var src = load(target);
531         if (nn(src))
532            collectScope(target, blankComments(src), macros, namespaces, seen);
533      }
534   }
535
536   private static String resolve(String from, String path) {
537      if (path.startsWith("/"))
538         return path;
539      var slash = from.lastIndexOf('/');
540      return slash < 0 ? path : from.substring(0, slash + 1) + path;
541   }
542
543   // Template root first, then the classpath root, then (for absolute names) the classpath itself.
544   private String load(String name) {
545      var rel = name.startsWith("/") ? name.substring(1) : name;
546      try {
547         if (nn(templateRoot)) {
548            var p = templateRoot.resolve(rel);
549            if (Files.isRegularFile(p))
550               return Files.readString(p, UTF_8);
551         }
552         if (nn(classpathRoot)) {
553            var s = resource(classpathRoot + rel);
554            if (nn(s))
555               return s;
556         }
557         return name.startsWith("/") ? resource(name) : null;
558      } catch (IOException e) {
559         throw new UncheckedIOException(e);
560      }
561   }
562
563   private static String resource(String absPath) throws IOException {
564      var cl = Thread.currentThread().getContextClassLoader();
565      if (n(cl))
566         cl = ConsoleTemplateValidator.class.getClassLoader();
567      try (var in = cl.getResourceAsStream(absPath.substring(1))) {
568         return n(in) ? null : new String(in.readAllBytes(), UTF_8);
569      }
570   }
571
572   @SuppressWarnings({
573      "unused" // the Template is built only so its constructor parses (and may throw)
574   })
575   private static void parse(String name, String source, List<Finding> out) {
576      try {
577         new Template(name, new StringReader(source), PARSE_CFG);
578      } catch (ParseException e) {
579         out.add(new Finding(name, e.getLineNumber(), e.getColumnNumber(), "parse-error", e.getEditorMessage()));
580      } catch (IOException e) {
581         throw new UncheckedIOException(e);
582      }
583   }
584
585   // Replaces each <#-- … --> with spaces (newlines kept), so offsets still map to the original line and column.
586   private static String blankComments(String s) {
587      var sb = new StringBuilder(s);
588      var m = COMMENT.matcher(s);
589      while (m.find())
590         for (var i = m.start(); i < m.end(); i++)
591            if (sb.charAt(i) != '\n')
592               sb.setCharAt(i, ' ');
593      return sb.toString();
594   }
595
596   @SuppressWarnings({
597      "javabugs:S2259" // False positive: validate() throws on a null load() result via n(src), which Sonar's flow analysis does not track as a null guard.
598   })
599   private static int[] lineStarts(String s) {
600      var starts = new ArrayList<Integer>();
601      starts.add(0);
602      for (var i = 0; i < s.length(); i++)
603         if (s.charAt(i) == '\n')
604            starts.add(i + 1);
605      return starts.stream().mapToInt(Integer::intValue).toArray();
606   }
607
608   private static void add(List<Finding> out, String name, int[] lines, int offset, String rule, String message) {
609      var i = Arrays.binarySearch(lines, offset);
610      var line = i >= 0 ? i : -i - 2;
611      out.add(new Finding(name, line + 1, offset - lines[line] + 1, rule, message));
612   }
613}