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<ConsoleTemplateValidator.Finding> <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}