This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 85d95678a6a087a16e4c02f54a8d6f8d20d87012 Author: James Bognar <[email protected]> AuthorDate: Wed Aug 19 17:45:47 2026 -0400 TODO-432: Add composition-time var(--jc-name) Theme references, resolved to literals at build() A Theme token value may now be a var(--jc-name) reference to another known token (own map first, falling back to Theme.OPEN). Theme.Builder recognizes references one layer above CssValueGrammar and resolves each to a concrete literal at build() time -- own tokens shadowing OPEN -- so the substring "var(" never appears in any getTokens() value; only the re-validated resolved literal is ever emitted. Resolution is fail-closed with distinct messages for an unknown reference, a cycle, or a chain past the depth cap, and a failed build() leaves the builder's own map unresolved and retryable. CssValueGrammar splits its normalization belt (comment-stripping, control-char/url() rejection) out of normalizeAndValidate() into a shared normalize(), so the reference recognizer runs the exact same belt as the grammar with no second normalization pass to keep in sync. The six allowed-shape productions in isAllowedShape() are unchanged, and var() is still never a grammar value shape -- it stays Theme-layer syntax handled one layer above CssValueGrammar. --- .../rest/server/console/ConsoleChromeMixin.java | 15 +- .../rest/server/console/CssValueGrammar.java | 35 +++- .../apache/juneau/rest/server/console/Theme.java | 133 +++++++++++++- .../server/console/ConsoleChromeMixin_Test.java | 28 +++ .../rest/server/console/CssValueGrammar_Test.java | 32 +++- .../server/console/Theme_VarReferences_Test.java | 191 +++++++++++++++++++++ 6 files changed, 413 insertions(+), 21 deletions(-) diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java index ff93efb567..e378989ba8 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java @@ -132,11 +132,16 @@ public class ConsoleChromeMixin { * * <p> * These aliases are <b>permanent</b> (no deprecation window) and are deliberately <i>not</i> - * {@link Theme#OPEN} tokens: {@code CssValueGrammar} rejects {@code var()} in every spelling, so a - * {@code var(--jc-*)} alias expressed as a {@code Theme} token would throw at class-initialization time. This - * is framework-authored literal text and never passes through that grammar (which exists to validate - * <i>consumer</i> input, not the framework's own stylesheet). {@code Theme.OPEN} owns leaf values; this block - * owns derived values; no token name is declared by both. + * {@link Theme#OPEN} tokens. A {@code var(--jc-*)}-valued token IS legal Theme-layer syntax — + * {@code Theme.Builder} recognizes it as a reference and resolves it to a concrete literal at {@code build()} + * time — but that resolution scope deliberately excludes these role aliases, which are appended here + * outside {@code Theme.OPEN}'s token map. Making them {@code Theme.OPEN} tokens instead would resolve each alias + * to a <i>fixed literal</i> at composition time, snapshotting the live CSS cascade (the dark-mode / + * user-agent overrides that reach these role tokens at use time) into a frozen value — so + * {@code Theme.OPEN} is kept all-literal and the aliases stay here as framework-authored literal text that is + * emitted verbatim (never routed through {@code CssValueGrammar}, which exists to validate <i>consumer</i> + * input, not the framework's own stylesheet). {@code Theme.OPEN} owns leaf values; this block owns derived + * values; no token name is declared by both. */ static final String OPEN_ROLE_ALIASES = String.join("", "--jc-surface:var(--jc-white);", diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java index 935c8807dd..088cf6d2fa 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java @@ -62,15 +62,23 @@ final class CssValueGrammar { private static final Pattern URL_REJECT = Pattern.compile("(?i)url\\s*\\("); /** - * Normalizes and validates a token value, returning the normalized (comment-stripped, trimmed) value if it is - * accepted. + * Runs the normalization belt on a token value and returns the normalized (comment-stripped, trimmed) value, + * <b>without</b> the allowlist-grammar shape check. + * + * <p> + * This is the shared belt that both {@link #normalizeAndValidate(String)} and the {@code Theme}-layer + * {@code var(--jc-name)} reference recognizer (see {@link Theme.Builder#token(String, String)}) run on, so the + * reference recognizer sees exactly the same control-character-rejected, comment-stripped, {@code url(}-rejected + * string as the grammar does — there is no second, reference-specific normalization pass to keep in sync. + * The belt does <b>not</b> recognize or accept {@code var()}: that is Theme-layer syntax handled one layer above + * this class. * * @param value The raw candidate value. - * @return The normalized value. - * @throws IllegalArgumentException If the value is <jk>null</jk>, contains a control character, contains a - * {@code url(} production in any spelling, or does not match one of the allowed CSS value shapes. + * @return The normalized (comment-stripped, trimmed) value. + * @throws IllegalArgumentException If the value is <jk>null</jk>, contains a control character, or contains a + * {@code url(} production in any spelling. */ - static String normalizeAndValidate(String value) { + static String normalize(String value) { if (value == null) throw iaex("Theme token value must not be null."); @@ -89,6 +97,21 @@ final class CssValueGrammar { if (URL_REJECT.matcher(stripped).find()) throw iaex("Theme token value must not contain a url() production."); + return stripped; + } + + /** + * Normalizes and validates a token value, returning the normalized (comment-stripped, trimmed) value if it is + * accepted. + * + * @param value The raw candidate value. + * @return The normalized value. + * @throws IllegalArgumentException If the value is <jk>null</jk>, contains a control character, contains a + * {@code url(} production in any spelling, or does not match one of the allowed CSS value shapes. + */ + static String normalizeAndValidate(String value) { + var stripped = normalize(value); + if (isAllowedShape(stripped)) return stripped; diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java index f8e058ba44..2ceb74c70b 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java @@ -19,6 +19,7 @@ package org.apache.juneau.rest.server.console; import static org.apache.juneau.commons.utils.Shorts.*; import java.util.*; +import java.util.regex.*; /** * An immutable, named set of CSS custom-property ("theme token") overrides for the admin-console chrome. @@ -37,6 +38,14 @@ import java.util.*; * {@code ConsoleChromeMixin}). {@link #OPEN} is a placeholder empty theme in this revision; its real token set * lands together with {@code chrome.css}. * + * <p> + * A token value may also be a {@code var(--jc-name)} <b>reference</b> to another known token. References are + * <i>not</i> a {@code CssValueGrammar} value shape: {@code Theme.Builder} recognizes them one layer above the + * grammar and resolves each to a concrete literal at {@link Builder#build() build()} time (own tokens shadowing + * {@link #OPEN}'s), so the substring {@code var(} never appears in any {@link #getTokens()} value — only the + * resolved literal, which is itself re-validated by the grammar, is ever emitted. An unknown reference, a cycle, + * or a chain longer than the resolution depth cap is a loud {@code build()} failure, never a silent fallback. + * * <h5 class='section'>Example:</h5> * <p class='bjava'> * Theme <jv>salesforce</jv> = Theme.<jsm>create</jsm>(<js>"salesforce"</js>) @@ -55,6 +64,24 @@ public final class Theme { /** Anchored (full-string) guard for a CSS custom-property token name. */ private static final String TOKEN_NAME_PATTERN = "^--jc-[a-z0-9-]+$"; + /** + * Anchored recognizer for a {@code var(--jc-name)} Theme-layer reference, run on the post-belt (control-char + * rejected, comment-stripped, trimmed) value. + * + * <p> + * Fully anchored ({@code ^...$}) so a reference can only ever be the <b>entire</b> value, never a prefix or + * suffix of some larger value: {@code linear-gradient(var(--jc-a), #fff)} never matches this and is rejected by + * the unchanged grammar. There is no fallback branch — {@code var(--jc-x, #fff)} simply fails to match + * (comma inside the parens) and falls through to grammar rejection. + */ + private static final Pattern VAR_REFERENCE = Pattern.compile("^(?i:var)\\(\\s*(--jc-[a-z0-9-]+)\\s*\\)$"); + + /** + * The maximum number of reference hops resolved before {@code build()} fails, enforced independently of cycle + * detection so a long <i>acyclic</i> chain is bounded too. + */ + private static final int MAX_REFERENCE_HOPS = 8; + /** * The default, "open" theme. * @@ -176,32 +203,120 @@ public final class Theme { /** * Adds (or overrides) a single CSS custom-property token. * + * <p> + * The value may be either a <b>literal</b> or a {@code var(--jc-name)} <b>reference</b> to another known + * token: + * <ul class='spaced-list'> + * <li>A literal is validated eagerly here by {@code CssValueGrammar}'s accept-known-safe allowlist grammar + * after normalization (trim → reject C0/C1/DEL → strip comments → reject {@code url(} in + * any spelling → grammar). + * <li>A value that, after that same normalization belt, matches {@code var(--jc-name)} is recognized as a + * reference and stored <i>unresolved</i> — it is resolved to a concrete literal at {@link #build()} + * time (its target may not be defined yet, e.g. a forward reference). {@code var()} is never a + * {@code CssValueGrammar} value shape; it is Theme-layer syntax recognized one layer above the grammar. + * </ul> + * Escaping happens later, at emission time, via {@code CssValueEscaper} (see {@code ConsoleChromeMixin}). + * * @param name The token name. Must not be <jk>null</jk> and must match {@code ^--jc-[a-z0-9-]+$}. * @param value - * The CSS value. Validated (not escaped) by {@code CssValueGrammar}'s accept-known-safe allowlist - * grammar after normalization (trim → reject C0/C1/DEL → strip comments → reject - * {@code url(} in any spelling → grammar). Escaping happens later, at emission time, via - * {@code CssValueEscaper} (see {@code ConsoleChromeMixin}). + * The CSS value — a literal, or a {@code var(--jc-name)} reference. * @return This object. * @throws IllegalArgumentException * If the name is not in the legal shape (full-string {@code String.matches(...)}, not {@code find(...)} * — {@code "--jc-foo;--bar"} must REJECT even though {@code "--jc-foo"} matches as a leading - * substring), or if the value is not one of the allowlisted CSS value shapes. + * substring), if the value contains a control character or a {@code url(} production, or if the value is + * neither a {@code var(--jc-name)} reference nor one of the allowlisted CSS value shapes. A reference whose + * target is unknown, cyclic, or too deeply chained is instead reported at {@link #build()} time. */ public Builder token(String name, String value) { if (name == null || ! name.matches(TOKEN_NAME_PATTERN)) throw iaex("Invalid theme token name: '%s'. Must match %s.", name, TOKEN_NAME_PATTERN); - tokens.put(name, CssValueGrammar.normalizeAndValidate(value)); + // Recognition runs on the SAME post-belt string the grammar would see (one shared belt, no second + // comment-stripping pass). A reference is stored unresolved; a literal is validated eagerly, exactly + // as before this feature existed. + var normalized = CssValueGrammar.normalize(value); + if (referencedName(normalized) != null) + tokens.put(name, normalized); + else + tokens.put(name, CssValueGrammar.normalizeAndValidate(value)); return this; } /** - * Builds the immutable {@link Theme}. + * Builds the immutable {@link Theme}, resolving every {@code var(--jc-name)} reference to a concrete literal. + * + * <p> + * Resolution scope is this builder's own tokens, shadowing {@link Theme#OPEN}'s tokens (exact shadowing: a + * name defined on this builder wins outright, even if resolving that entry then fails — there is no + * silent fall-through to {@code Theme.OPEN}'s value for a shadowed name). Resolution runs on a copy of the + * token map, so a failed {@code build()} leaves this builder unchanged and retryable. * - * @return A new {@link Theme}. + * @return A new {@link Theme} whose every token value is a resolved six-shape literal (the substring + * {@code var(} appears in none of them). + * @throws IllegalArgumentException + * If a reference names an unknown token, forms a cycle (the message carries the cycle path), or exceeds the + * resolution depth cap. */ public Theme build() { - return new Theme(name, tokens); + return new Theme(name, resolveReferences()); + } + + /** Resolves every reference in a copy of the token map, preserving declaration order; never mutates {@link #tokens}. */ + private Map<String,String> resolveReferences() { + var resolved = new LinkedHashMap<String,String>(); + for (var e : tokens.entrySet()) { + var target = referencedName(e.getValue()); + resolved.put(e.getKey(), target == null ? e.getValue() : resolveReference(e.getKey(), target)); + } + return resolved; + } + + /** + * Iteratively walks a reference chain from {@code firstTarget} to the literal it names, with exact shadowing + * (own tokens win over {@link Theme#OPEN}'s), cycle detection, and an independent hop cap; re-validates the + * resolved literal against the grammar as defense-in-depth. + */ + private String resolveReference(String definingName, String firstTarget) { + // Theme.OPEN is null only while Theme.OPEN itself is being built - and it has no references, so this + // fallback map is never actually consulted during that construction. + var openTokens = Theme.OPEN == null ? Collections.<String,String>emptyMap() : Theme.OPEN.getTokens(); + var visited = new LinkedHashSet<String>(); + visited.add(definingName); + var target = firstTarget; + var hops = 0; + while (true) { + if (visited.contains(target)) + throw iaex("Theme token '%s' contains a cyclic reference: %s.", definingName, cyclePath(visited, target)); + if (++hops > MAX_REFERENCE_HOPS) + throw iaex("Theme token '%s' exceeds the maximum reference depth of %d hops.", definingName, MAX_REFERENCE_HOPS); + visited.add(target); + + String targetValue; + if (tokens.containsKey(target)) + targetValue = tokens.get(target); // own map wins outright (exact shadowing) + else if (openTokens.containsKey(target)) + targetValue = openTokens.get(target); + else + throw iaex("Theme token '%s' references unknown token '%s'.", definingName, target); + + var next = referencedName(targetValue); + if (next == null) + // The resolved literal - and only that literal, with no var() present - is re-validated by the + // unchanged grammar as defense-in-depth against a bad value hiding under a referenceable name. + return CssValueGrammar.normalizeAndValidate(targetValue); + target = next; + } + } + + /** Returns the {@code --jc-name} a {@code var(--jc-name)} reference points at, or <jk>null</jk> if {@code value} is not a reference. */ + private static String referencedName(String value) { + var m = VAR_REFERENCE.matcher(value); + return m.matches() ? m.group(1) : null; + } + + /** Renders a cycle path like {@code --jc-a -> --jc-b -> --jc-a} for a build-failure message. */ + private static String cyclePath(Set<String> visited, String repeated) { + return String.join(" -> ", visited) + " -> " + repeated; } } } diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java index afcef00020..e5fe833842 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java @@ -849,6 +849,34 @@ class ConsoleChromeMixin_Test extends TestBase { assertEquals(bodyOf(MockRestClient.buildLax(DefaultHost.class)), bodyOf(MockRestClient.buildLax(DefaultHost.class))); } + //----------------------------------------------------------------------------------------------------------------- + // p) var(--jc-name) reference resolution reaches the served body as a literal + //----------------------------------------------------------------------------------------------------------------- + + /** + * Mirrors the {@code ReleaseManagerTheme} acceptance case: a derived token expressed as + * {@code var(--jc-danger)} on the SAME builder as its target, so the served override block carries the resolved + * literal, never the unresolved reference. + */ + private static final Theme DERIVED_THEME = Theme.create("derived") + .token("--jc-danger", "#c23934") + .token("--jc-tag-red-text", "var(--jc-danger)") + .build(); + + @Rest(mixins=ConsoleChromeMixin.class) + public static class DerivedHost extends BasicRestServlet { + private static final long serialVersionUID = 1L; + @Bean public ConsoleChromeMixin console() { return ConsoleChromeMixin.create().theme(DERIVED_THEME).build(); } + } + + @Test void p01_varReference_isResolvedToItsLiteral_inTheServedOverrideBlock() throws Exception { + var body = bodyOf(MockRestClient.buildLax(DerivedHost.class)); + assertTrue(body.contains("--jc-tag-red-text:#c23934;"), () -> "reference not resolved to its literal in the served body:\n" + body); + // The DECLARATION must be the literal, not a var() reference (chrome.css legitimately uses var(--jc-danger) + // at use-sites, so we pin the declaration form rather than a blanket substring). + assertFalse(body.contains("--jc-tag-red-text:var("), () -> "unresolved var() reference leaked into the served declaration:\n" + body); + } + //----------------------------------------------------------------------------------------------------------------- // Test helpers //----------------------------------------------------------------------------------------------------------------- diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java index 57cde15f82..d937646d2d 100644 --- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java +++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java @@ -25,7 +25,16 @@ import org.junit.jupiter.params.provider.*; /** * Phase 2 gate: {@code CssValueGrammar}'s allowlist-grammar accept/reject sweep, tested through - * {@link Theme.Builder#token(String, String)} (the only call site). + * {@link Theme.Builder#token(String, String)}. + * + * <p> + * {@code token(...)} is no longer the <i>only</i> production call site for {@code CssValueGrammar}: with the + * {@code var(--jc-name)} Theme-layer reference feature, {@code Theme.Builder.build()} re-validates each resolved + * literal too (defense-in-depth). {@code var()} itself is never a grammar value shape — it is recognized and + * resolved one layer above the grammar — so the {@code var()} reject vectors below still fail here exactly as + * any other out-of-grammar value: a malformed {@code var(...)} (fallback, non-{@code --jc-} target, empty, or + * nested inside a gradient) never matches the anchored reference recognizer and falls straight through to grammar + * rejection at {@code token()} time. * * <p> * The GREEN bypass-vector sweep below is the B1 close-out: it must beat every named vector the plan-review @@ -157,4 +166,25 @@ class CssValueGrammar_Test extends TestBase { assertEquals("NONE", Theme.create("x").token("--jc-page-bg", "NONE").build().getTokens().get("--jc-page-bg")); assertEquals("None", Theme.create("x").token("--jc-page-bg", "None").build().getTokens().get("--jc-page-bg")); } + + //----------------------------------------------------------------------------------------------------------------- + // GREEN: malformed var(...) forms are NOT the anchored var(--jc-name) reference, so they never reach the + // reference recognizer and REJECT at the grammar layer at token() time, exactly like any other non-shape value. + // A well-formed var(--jc-name) reference is deliberately absent here - it is ACCEPTED (deferred) at token() and + // is exercised by Theme_VarReferences_Test instead; it must NOT be added to a07's positive (verbatim) sweep. + //----------------------------------------------------------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = { + "var(--jc-x, #fff)", // fallback branch - comma inside parens, never matches the recognizer + "VAR(--jc-x, red)", // case-insensitive fallback - still a fallback, still rejected + "var(--not-jc-x)", // target is not a --jc- name + "var()", // no argument + "var(--jc-x) #fff", // reference is not the ENTIRE value (recognizer is fully anchored) + "linear-gradient(var(--jc-a), #fff)", // nested var() - grammar-layer reject (var not in the nested allowlist) + }) + void a12_malformedOrNestedVar_rejectedAtGrammarLayer(String payload) { + var b = Theme.create("x"); + assertThrows(IllegalArgumentException.class, () -> b.token("--jc-accent", payload)); + } } diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_VarReferences_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_VarReferences_Test.java new file mode 100644 index 0000000000..1f5bcef010 --- /dev/null +++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_VarReferences_Test.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.console; + +import static org.junit.jupiter.api.Assertions.*; + +import org.apache.juneau.*; +import org.junit.jupiter.api.*; + +/** + * {@code var(--jc-name)} Theme-layer reference recognition and composition-time ({@code build()}) resolution. + * + * <p> + * The load-bearing security property here is that {@code var()} is <b>never</b> a {@code CssValueGrammar} value + * shape: it is recognized one layer above the grammar and resolved to a concrete literal at {@code build()} time, + * so {@code getTokens()} never carries the substring {@code var(} (see {@code d02}). Resolution is fail-closed: + * an unknown reference, a cycle, or a chain past the depth cap is a loud {@code build()} failure with its own + * distinct message, never a silent fall-through (see the {@code c} group). + */ +class Theme_VarReferences_Test extends TestBase { + + //----------------------------------------------------------------------------------------------------------------- + // a) Recognition + resolution positives + //----------------------------------------------------------------------------------------------------------------- + + @Test void a01_ownMapReference_resolvesToTheReferencedLiteral() { + // The acceptance case (mirrors ReleaseManagerTheme): both tokens on the SAME builder, so this exercises + // own-map resolution, not the Theme.OPEN fallback. + var theme = Theme.create("derived") + .token("--jc-danger", "#c23934") + .token("--jc-tag-red-text", "var(--jc-danger)") + .build(); + assertEquals("#c23934", theme.getTokens().get("--jc-tag-red-text")); + assertEquals("#c23934", theme.getTokens().get("--jc-danger")); + } + + @Test void a02_forwardReference_resolvesRegardlessOfDeclarationOrder() { + // Reference declared BEFORE its target - only resolvable at build(), the whole point of deferring. + var theme = Theme.create("fwd") + .token("--jc-a", "var(--jc-b)") + .token("--jc-b", "#abcabc") + .build(); + assertEquals("#abcabc", theme.getTokens().get("--jc-a")); + } + + @Test void a03_fallbackToThemeOpen_resolvesAgainstTheFrameworkTokenSet() { + // Custom theme references a --jc-* name it does not itself define; resolution finds it on Theme.OPEN. + var theme = Theme.create("fb").token("--jc-a", "var(--jc-danger)").build(); + assertEquals("#c23934", theme.getTokens().get("--jc-a")); + } + + @Test void a04_multiHopChain_withinCap_resolvesToTheTerminalLiteral() { + var theme = Theme.create("hop") + .token("--jc-a", "var(--jc-b)") + .token("--jc-b", "var(--jc-c)") + .token("--jc-c", "#0a0b0c") + .build(); + assertEquals("#0a0b0c", theme.getTokens().get("--jc-a")); + assertEquals("#0a0b0c", theme.getTokens().get("--jc-b")); + } + + @Test void a05_recognitionVariants_shareTheSameNormalizationBelt() { + // Comment-stripping, case-insensitivity, and interior whitespace are handled by the SAME belt every other + // value runs through - there is no second, reference-specific normalization pass. + var theme = Theme.create("rec") + .token("--jc-x", "#123456") + .token("--jc-comment", "var/**/(--jc-x)") + .token("--jc-inner-comment", "var(--jc-/**/x)") + .token("--jc-upper", "VAR(--jc-x)") + .token("--jc-space", "var( --jc-x )") + .build(); + assertEquals("#123456", theme.getTokens().get("--jc-comment")); + assertEquals("#123456", theme.getTokens().get("--jc-inner-comment")); + assertEquals("#123456", theme.getTokens().get("--jc-upper")); + assertEquals("#123456", theme.getTokens().get("--jc-space")); + } + + //----------------------------------------------------------------------------------------------------------------- + // b) Deferred timing / atomicity + //----------------------------------------------------------------------------------------------------------------- + + @Test void b01_referenceIsAcceptedAtTokenTime_evenWhenTargetIsNotYetDefined() { + // A well-formed reference must NOT throw at token() time (its target may be a forward reference). + assertDoesNotThrow(() -> Theme.create("t").token("--jc-a", "var(--jc-later)")); + } + + @Test void b02_failedBuild_leavesBuilderUnchanged_andRetryable() { + // Resolution runs on a COPY: a failed build() must not half-resolve the builder's own map. + var b = Theme.create("atom").token("--jc-a", "var(--jc-missing)"); + assertThrows(IllegalArgumentException.class, b::build); + // The reference survived intact, so defining its target and rebuilding now succeeds. + var theme = b.token("--jc-missing", "#123123").build(); + assertEquals("#123123", theme.getTokens().get("--jc-a")); + } + + //----------------------------------------------------------------------------------------------------------------- + // c) Fail-closed resolution: unknown / cycle / depth-cap, each with a DISTINCT message + //----------------------------------------------------------------------------------------------------------------- + + @Test void c01_unknownReference_isABuildFailure_namingBothTokens() { + var ex = assertThrows(IllegalArgumentException.class, + () -> Theme.create("u").token("--jc-a", "var(--jc-missing)").build()); + assertTrue(ex.getMessage().contains("--jc-a"), () -> "message must name the defining token: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("--jc-missing"), () -> "message must name the missing target: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("unknown"), () -> "message: " + ex.getMessage()); + // Must NOT reuse the pre-existing "not one of the allowed CSS value shapes" message. + assertFalse(ex.getMessage().contains("allowed CSS value shapes"), () -> "message: " + ex.getMessage()); + } + + @Test void c02_shadowedBrokenOverride_failsInsteadOfSilentlyFallingBackToThemeOpen() { + // --jc-danger EXISTS on Theme.OPEN (#c23934). Shadowing it here with a broken reference must FAIL the build, + // NOT silently resolve through to Theme.OPEN's value - the exact bug the reference feature exists to catch. + var ex = assertThrows(IllegalArgumentException.class, + () -> Theme.create("shadow").token("--jc-danger", "var(--jc-nonexistent)").build()); + assertTrue(ex.getMessage().contains("--jc-nonexistent"), () -> "message: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("unknown"), () -> "message: " + ex.getMessage()); + } + + @Test void c03_selfCycle_isABuildFailure_withTheCyclePath() { + var ex = assertThrows(IllegalArgumentException.class, + () -> Theme.create("cyc").token("--jc-a", "var(--jc-a)").build()); + assertTrue(ex.getMessage().contains("cyclic"), () -> "message: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("--jc-a -> --jc-a"), () -> "message must carry the cycle path: " + ex.getMessage()); + } + + @Test void c04_twoNodeCycle_isABuildFailure_withTheFullCyclePath() { + var ex = assertThrows(IllegalArgumentException.class, + () -> Theme.create("cyc2") + .token("--jc-a", "var(--jc-b)") + .token("--jc-b", "var(--jc-a)") + .build()); + assertTrue(ex.getMessage().contains("cyclic"), () -> "message: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("--jc-a -> --jc-b -> --jc-a"), () -> "message must carry the full cycle path: " + ex.getMessage()); + } + + @Test void c05_depthCapBreach_isADistinctFailure_fromCycleAndUnknown() { + // A long ACYCLIC chain (all names distinct, terminal token defined) must still be bounded by the hop cap - + // so this fails for depth, not cycle (no repeat) and not unknown (--jc-c10 exists). + var b = Theme.create("deep"); + for (var i = 0; i < 10; i++) + b.token("--jc-c" + i, "var(--jc-c" + (i + 1) + ")"); + b.token("--jc-c10", "#000000"); + var ex = assertThrows(IllegalArgumentException.class, b::build); + assertTrue(ex.getMessage().contains("maximum reference depth"), () -> "message: " + ex.getMessage()); + assertFalse(ex.getMessage().contains("cyclic"), () -> "depth-cap message must be distinct from the cycle message: " + ex.getMessage()); + assertFalse(ex.getMessage().contains("unknown"), () -> "depth-cap message must be distinct from the unknown-name message: " + ex.getMessage()); + } + + //----------------------------------------------------------------------------------------------------------------- + // d) Defense-in-depth + post-build invariant + //----------------------------------------------------------------------------------------------------------------- + + @Test void d01_resolvedLiteralThatTheGrammarRejects_wouldFailTheReValidation() { + // The resolved literal (never an intermediate var()) is re-validated by the unchanged grammar. Through the + // public API every literal is already grammar-validated at token() time, so this path cannot be driven to a + // failure here - it is exercised (every resolved value passes back through normalizeAndValidate) rather than + // tripped. This test pins that a reference to a plain literal round-trips byte-for-byte through that + // re-validation. + var theme = Theme.create("dd") + .token("--jc-a", "linear-gradient(135deg, rgb(21,137,238), rgba(0,0,0,0.2))") + .token("--jc-b", "var(--jc-a)") + .build(); + assertEquals("linear-gradient(135deg, rgb(21,137,238), rgba(0,0,0,0.2))", theme.getTokens().get("--jc-b")); + } + + @Test void d02_afterBuild_noTokenValueContainsTheVarSubstring() { + var theme = Theme.create("inv") + .token("--jc-danger", "#c23934") + .token("--jc-tag-red-text", "var(--jc-danger)") // own-map reference + .token("--jc-a", "var(--jc-accent)") // Theme.OPEN fallback reference + .token("--jc-b", "var(--jc-danger)") // chained onto another own token + .token("--jc-lit", "#1589EE") // plain literal, untouched + .build(); + for (var v : theme.getTokens().values()) + assertFalse(v.contains("var("), () -> "resolved token value still contains 'var(': " + v); + } +}
