This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 7865789f6d GROOVY-12018: allow format to be case insensitive for 
groovysh: /print -s FORMAT
7865789f6d is described below

commit 7865789f6d92647c75fe28b0a7ce1d56b5454d63
Author: Paul King <[email protected]>
AuthorDate: Mon May 18 20:47:02 2026 +1000

    GROOVY-12018: allow format to be case insensitive for groovysh: /print -s 
FORMAT
---
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |   4 +-
 .../groovy/groovysh/jline/GroovyCommands.groovy    |   2 +-
 .../groovy/groovysh/jline/GroovyPrinter.groovy     | 260 +++++++++++++++++++++
 .../groovy/groovysh/jline/GroovyPrinterTest.groovy | 107 +++++++++
 4 files changed, 370 insertions(+), 3 deletions(-)

diff --git 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index 026921c06f..6b7b90abe7 100644
--- 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -26,6 +26,7 @@ import org.apache.groovy.groovysh.jline.GroovyConsoleEngine
 import org.apache.groovy.groovysh.jline.GroovyEngine
 import org.apache.groovy.groovysh.jline.GroovyPosixCommands
 import org.apache.groovy.groovysh.jline.GroovyPosixContext
+import org.apache.groovy.groovysh.jline.GroovyPrinter
 import org.apache.groovy.groovysh.jline.GroovySystemRegistry
 import org.apache.groovy.groovysh.util.DocFinder
 import org.codehaus.groovy.tools.shell.util.MessageSource
@@ -41,7 +42,6 @@ import org.jline.console.CommandMethods
 import org.jline.console.CommandRegistry
 import org.jline.console.ConsoleEngine
 import org.jline.console.Printer
-import org.jline.console.impl.DefaultPrinter
 import org.jline.console.impl.JlineCommandRegistry
 import org.jline.console.impl.SystemHighlighter
 import org.jline.keymap.KeyMap
@@ -420,7 +420,7 @@ class Main {
             }
             def interpreterMode = 
Boolean.parseBoolean(System.getProperty("groovysh.interpreterMode", "true"))
             scriptEngine.put('GROOVYSH_OPTIONS', [interpreterMode: 
interpreterMode])
-            Printer printer = new DefaultPrinter(scriptEngine, configPath)
+            Printer printer = new GroovyPrinter(scriptEngine, configPath)
 
             scriptEngine.put(GroovyEngine.NANORC_VALUE, rootURL.toString())
             Path jnanorc = root.resolve('jnanorc')
diff --git 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
index 1ff0c38d55..1457b0d447 100644
--- 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
+++ 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
@@ -76,7 +76,7 @@ import java.util.stream.Stream
  * Registers the Groovy-specific commands exposed by the groovysh JLine 
console.
  */
 class GroovyCommands extends JlineCommandRegistry implements CommandRegistry {
-    private static final String DEFAULT_NANORC_VALUE = 'gron'
+    private static final String DEFAULT_NANORC_VALUE = 'GRON'
     private final GroovyEngine engine
     private final Printer printer
     private final SyntaxHighlighter highlighter
diff --git 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPrinter.groovy
 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPrinter.groovy
new file mode 100644
index 0000000000..ebf04144fa
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyPrinter.groovy
@@ -0,0 +1,260 @@
+/*
+ *  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.groovy.groovysh.jline
+
+import groovy.transform.CompileStatic
+import groovy.transform.PackageScope
+import org.jline.builtins.ConfigurationPath
+import org.jline.console.Printer
+import org.jline.console.ScriptEngine
+import org.jline.console.impl.DefaultPrinter
+
+import java.nio.file.DirectoryStream
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+/**
+ * A {@link DefaultPrinter} that resolves nanorc highlight-style names
+ * case-insensitively.
+ *
+ * JLine matches the {@code /prnt -s STYLE} value (and the
+ * {@code valueStyle}/{@code valueStyleAll} options) against the
+ * {@code syntax "<NAME>"} header of each nanorc grammar with a
+ * case-sensitive {@code String.equals} (in
+ * {@code org.jline.builtins.SyntaxHighlighter}), so {@code -s json}
+ * would not match {@code syntax "JSON"}.
+ *
+ * Rather than blindly transforming the requested style (which would
+ * break selecting a user's existing mixed-case grammars copied into
+ * {@code ~/.groovy} per the groovysh docs), this resolves the requested
+ * name <em>case-insensitively against the actually configured syntax
+ * names</em> — the same {@code jnanorc} {@link DefaultPrinter} itself
+ * uses (user-config first, via {@link ConfigurationPath}) plus the
+ * grammars it {@code include}s. An exact match always wins; a unique
+ * case-insensitive match is rewritten to the real name; an unknown or
+ * an <em>ambiguous</em> name (where two grammars differ only by case)
+ * is passed through untouched so JLine's own behaviour is preserved.
+ * Discovery is failsafe per file and overall, the resolved set is
+ * cached, and the cache is cleared on {@link #refresh()} so a
+ * {@code /highlighter} switch or a nanorc edit takes effect without a
+ * restart.
+ */
+@CompileStatic
+class GroovyPrinter extends DefaultPrinter {
+
+    private static final List<String> STYLE_KEYS = [Printer.STYLE, 
Printer.VALUE_STYLE, Printer.VALUE_STYLE_ALL]
+    private static final Pattern SYNTAX_HEADER = 
Pattern.compile(/(?m)^\s*syntax\s+"([^"]+)"/)
+    private static final Pattern INCLUDE_LINE = 
Pattern.compile(/(?m)^\s*include\s+(\S+)/)
+
+    private final ConfigurationPath configPath
+    private Names names // cached resolved syntax names; built lazily, cleared 
on refresh
+
+    GroovyPrinter(ScriptEngine engine, ConfigurationPath configPath) {
+        super(engine, configPath)
+        this.configPath = configPath
+    }
+
+    @Override
+    void println(Map<String, Object> options, Object object) {
+        super.println(normalizeStyles(options), object)
+    }
+
+    @Override
+    protected void highlightAndPrint(Map<String, Object> options, Throwable 
exception) {
+        super.highlightAndPrint(normalizeStyles(options), exception)
+    }
+
+    /**
+     * Clears the cached syntax-name set before delegating, so a
+     * {@code /highlighter} theme switch or an edited/copied nanorc is
+     * picked up on the next print rather than after a restart.
+     */
+    @Override
+    boolean refresh() {
+        synchronized (this) {
+            names = null
+        }
+        return super.refresh()
+    }
+
+    /**
+     * Returns a copy of {@code options} with style-name values resolved to the
+     * actual configured syntax name (case-insensitively), or the original map
+     * untouched when there is nothing to change (so an immutable or shared
+     * caller map is not mutated). Failsafe: any problem returns {@code 
options}
+     * unchanged.
+     */
+    private Map<String, Object> normalizeStyles(Map<String, Object> options) {
+        if (options == null || options.isEmpty()) {
+            return options
+        }
+        Names resolved
+        try {
+            resolved = syntaxNames()
+        } catch (Exception ignored) {
+            return options
+        }
+        if (resolved.actual.isEmpty()) {
+            return options
+        }
+        Map<String, Object> copy = null
+        for (String key : STYLE_KEYS) {
+            Object value = options.get(key)
+            if (value instanceof CharSequence && (value as 
CharSequence).length() > 0) {
+                String requested = value.toString()
+                String style = resolveStyle(requested, resolved)
+                if (style != requested) {
+                    if (copy == null) {
+                        copy = new LinkedHashMap<String, Object>(options)
+                    }
+                    copy.put(key, style)
+                }
+            }
+        }
+        copy != null ? copy : options
+    }
+
+    /**
+     * Pure resolution (package-scoped for testing). An exact match (any
+     * configured spelling) is returned unchanged — so a user's exact
+     * {@code -s json} still selects their {@code syntax "json"} even when a
+     * {@code "JSON"} also exists. Otherwise a unique case-insensitive match is
+     * returned; an unknown or ambiguous name is returned unchanged.
+     *
+     * @param requested the user-supplied style name
+     * @param names the resolved configured syntax names
+     * @return the name JLine should be given
+     */
+    @PackageScope
+    static String resolveStyle(String requested, Names names) {
+        if (requested == null || requested.isEmpty()) {
+            return requested
+        }
+        if (names.actual.contains(requested)) {
+            return requested // exact match always wins (also covers 
user-config names)
+        }
+        String unique = 
names.uniqueByLower.get(requested.toLowerCase(Locale.ROOT))
+        unique != null ? unique : requested // unique CI hit, else 
(unknown/ambiguous) passthrough
+    }
+
+    /** Resolved configured syntax names; cached, cleared by {@link 
#refresh()}. */
+    private synchronized Names syntaxNames() {
+        if (names != null) {
+            return names
+        }
+        Map<String, Set<String>> raw = new LinkedHashMap<>()
+        Path jnanorc = configPath?.getConfig('jnanorc')
+        if (jnanorc != null && Files.isReadable(jnanorc)) {
+            collectSyntaxNames(jnanorc, raw)
+        }
+        names = buildNames(raw)
+        return names
+    }
+
+    /**
+     * Scans the jnanorc itself and every file it {@code include}s for
+     * {@code syntax "NAME"} headers, accumulating each lower-cased name to the
+     * set of actual spellings seen. Per-file and per-include failsafe.
+     */
+    @PackageScope
+    static void collectSyntaxNames(Path jnanorc, Map<String, Set<String>> raw) 
{
+        scanForSyntax(jnanorc, raw) // jnanorc may declare syntaxes directly
+        String text
+        try {
+            text = Files.readString(jnanorc) // UTF-8 per repository 
.editorconfig
+        } catch (Exception ignored) {
+            return
+        }
+        Path dir = jnanorc.toAbsolutePath().parent
+        Matcher m = INCLUDE_LINE.matcher(text)
+        while (m.find()) {
+            String glob = m.group(1)
+            try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, 
glob)) {
+                for (Path p : stream) {
+                    scanForSyntax(p, raw) // per-file failsafe inside
+                }
+            } catch (Exception ignored) {
+                // one bad include directive must not stop the others
+            }
+        }
+    }
+
+    /** Builds the resolved view: every spelling is exact-matchable; a 
lower-cased
+     *  key resolves only when exactly one spelling was seen for it. */
+    @PackageScope
+    static Names buildNames(Map<String, Set<String>> raw) {
+        Set<String> actual = new LinkedHashSet<>()
+        Map<String, String> uniqueByLower = new LinkedHashMap<>()
+        for (Map.Entry<String, Set<String>> e : raw.entrySet()) {
+            actual.addAll(e.value)
+            if (e.value.size() == 1) {
+                uniqueByLower.put(e.key, e.value.iterator().next())
+            }
+            // size > 1: ambiguous (e.g. both "JSON" and "json") -> no CI 
rewrite
+        }
+        new Names(actual, uniqueByLower)
+    }
+
+    /** Convenience builder (testing/clarity) from a flat collection of names. 
*/
+    @PackageScope
+    static Names buildNames(Collection<String> syntaxNames) {
+        Map<String, Set<String>> raw = new LinkedHashMap<>()
+        for (String n : syntaxNames) {
+            raw.computeIfAbsent(n.toLowerCase(Locale.ROOT), { k -> new 
LinkedHashSet<String>() }).add(n)
+        }
+        buildNames(raw)
+    }
+
+    /**
+     * Per-file failsafe: a single malformed/unreadable nanorc never breaks
+     * discovery of the rest.
+     */
+    private static void scanForSyntax(Path file, Map<String, Set<String>> raw) 
{
+        try {
+            if (file == null || !Files.isReadable(file)) {
+                return
+            }
+            String content = Files.readString(file) // UTF-8 per repository 
.editorconfig
+            Matcher m = SYNTAX_HEADER.matcher(content)
+            while (m.find()) {
+                String name = m.group(1)
+                raw.computeIfAbsent(name.toLowerCase(Locale.ROOT), { k -> new 
LinkedHashSet<String>() }).add(name)
+            }
+        } catch (Exception ignored) {
+            // skip just this file
+        }
+    }
+
+    /** Immutable resolved syntax-name view. */
+    @PackageScope
+    @CompileStatic
+    static final class Names {
+        /** Every configured spelling (for O(1) exact match). */
+        final Set<String> actual
+        /** Lower-cased name -&gt; actual spelling, only where unambiguous. */
+        final Map<String, String> uniqueByLower
+
+        Names(Set<String> actual, Map<String, String> uniqueByLower) {
+            this.actual = actual
+            this.uniqueByLower = uniqueByLower
+        }
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyPrinterTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyPrinterTest.groovy
new file mode 100644
index 0000000000..dc192c1e7e
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyPrinterTest.groovy
@@ -0,0 +1,107 @@
+/*
+ *  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.groovy.groovysh.jline
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Unit tests for {@link GroovyPrinter}'s case-insensitive style resolution.
+ * Pure logic — no system/printer fixture needed.
+ */
+class GroovyPrinterTest {
+
+    // names as a user might actually have them (mixed case, incl. from a
+    // pre-existing ~/.groovy copy): JSON, Groovy, sql, GRON
+    private static final GroovyPrinter.Names NAMES =
+        GroovyPrinter.buildNames(['JSON', 'Groovy', 'sql', 'GRON'])
+
+    @Test
+    void exactMatchIsLeftUnchanged() {
+        // user-config exact name must keep working (the regression Copilot 
flagged)
+        assert GroovyPrinter.resolveStyle('Groovy', NAMES) == 'Groovy'
+        assert GroovyPrinter.resolveStyle('JSON', NAMES) == 'JSON'
+        assert GroovyPrinter.resolveStyle('sql', NAMES) == 'sql'
+    }
+
+    @Test
+    void caseInsensitiveHitResolvesToActualName() {
+        assert GroovyPrinter.resolveStyle('groovy', NAMES) == 'Groovy'
+        assert GroovyPrinter.resolveStyle('GROOVY', NAMES) == 'Groovy'
+        assert GroovyPrinter.resolveStyle('gRoOvY', NAMES) == 'Groovy'
+        assert GroovyPrinter.resolveStyle('json', NAMES) == 'JSON'
+        assert GroovyPrinter.resolveStyle('Json', NAMES) == 'JSON'
+        assert GroovyPrinter.resolveStyle('SQL', NAMES) == 'sql'
+        assert GroovyPrinter.resolveStyle('gron', NAMES) == 'GRON'   // 
DEFAULT_NANORC_VALUE
+    }
+
+    @Test
+    void unknownOrEmptyNameIsPassedThrough() {
+        assert GroovyPrinter.resolveStyle('does-not-exist', NAMES) == 
'does-not-exist'
+        assert GroovyPrinter.resolveStyle('', NAMES) == ''
+        assert GroovyPrinter.resolveStyle(null, NAMES) == null
+    }
+
+    @Test
+    void caseOnlyCollisionPreservesExactAndDoesNotRewriteAmbiguous() {
+        // both "JSON" and "json" configured (user misconfig / overlay)
+        def names = GroovyPrinter.buildNames(['JSON', 'json', 'Groovy'])
+        assert GroovyPrinter.resolveStyle('json', names) == 'json'  // exact 
wins, not rewritten to JSON
+        assert GroovyPrinter.resolveStyle('JSON', names) == 'JSON'  // exact 
wins
+        assert GroovyPrinter.resolveStyle('Json', names) == 'Json'  // 
ambiguous -> passthrough (JLine decides)
+        assert GroovyPrinter.resolveStyle('groovy', names) == 'Groovy' // 
unaffected sibling still resolves
+    }
+
+    @Test
+    void collectSyntaxNamesSpansJnanorcAndIncludes(@TempDir Path dir) {
+        Files.writeString(dir.resolve('jnanorc'),
+            'theme dark.nanorctheme\nsyntax "DIRECT" "\\.x$"\ninclude 
*.nanorc\n')
+        Files.writeString(dir.resolve('groovy.nanorc'), 'syntax "Groovy" 
"\\.groovy$"\nKEYWORD: "def"\n')
+        Files.writeString(dir.resolve('json.nanorc'), 'syntax "JSON" 
"\\.json$"\n')
+
+        Map<String, Set<String>> raw = [:]
+        GroovyPrinter.collectSyntaxNames(dir.resolve('jnanorc'), raw)
+        def names = GroovyPrinter.buildNames(raw)
+
+        assert GroovyPrinter.resolveStyle('direct', names) == 'DIRECT' // 
declared directly in jnanorc
+        assert GroovyPrinter.resolveStyle('GROOVY', names) == 'Groovy' // via 
include, original case
+        assert GroovyPrinter.resolveStyle('json', names) == 'JSON'
+    }
+
+    @Test
+    void oneBadNanorcDoesNotBreakTheOthers(@TempDir Path dir) {
+        Files.writeString(dir.resolve('jnanorc'), 'include *.nanorc\n')
+        Files.writeString(dir.resolve('good.nanorc'), 'syntax "Groovy" 
"\\.groovy$"\n')
+        Files.writeString(dir.resolve('other.nanorc'), 'syntax "JSON" 
"\\.json$"\n')
+        // a directory entry matching the include glob: readString throws,
+        // exercising the per-file failsafe without aborting discovery
+        Files.createDirectory(dir.resolve('weird.nanorc'))
+
+        Map<String, Set<String>> raw = [:]
+        GroovyPrinter.collectSyntaxNames(dir.resolve('jnanorc'), raw)
+        def names = GroovyPrinter.buildNames(raw)
+
+        assert GroovyPrinter.resolveStyle('groovy', names) == 'Groovy'
+        assert GroovyPrinter.resolveStyle('json', names) == 'JSON'
+        assert names.actual as Set == ['Groovy', 'JSON'] as Set // bad entry 
skipped, not fatal
+    }
+}

Reply via email to