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 &mdash;
+        * {@code Theme.Builder} recognizes it as a reference and resolves it 
to a concrete literal at {@code build()}
+        * time &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; {@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 &rarr; reject 
C0/C1/DEL &rarr; strip comments &rarr; reject {@code url(} in
+                *              any spelling &rarr; 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> &mdash; 
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 &rarr; reject 
C0/C1/DEL &rarr; strip comments &rarr; reject
-                *      {@code url(} in any spelling &rarr; grammar).  Escaping 
happens later, at emission time, via
-                *      {@code CssValueEscaper} (see {@code 
ConsoleChromeMixin}).
+                *      The CSS value &mdash; 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(...)}
                 *      &mdash; {@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 &mdash; 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 &mdash; it is recognized and
+ * resolved one layer above the grammar &mdash; 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);
+       }
+}

Reply via email to