From 9c2d9120ea8e7a6e0abd04ed379e3db0c571a994 Mon Sep 17 00:00:00 2001 From: David Waltermire Date: Sun, 19 Apr 2026 12:58:32 -0400 Subject: [PATCH 1/2] chore: replace jline:jansi-core with internal Ansi builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a minimal internal ANSI escape-code builder (dev.metaschema.cli.processor.ansi.Ansi) that covers the subset of jline:jansi functionality used by the CLI — fluent text/color appending, bold/boldOff, reset, fgRed/fgBrightRed/fgBrightYellow/fgBrightBlue/fgBrightCyan, fgBright(Color), and a global setEnabled toggle. The existing Ansi import is renamed to the new package across the three call sites: - cli-processor/CLIProcessor (version banner formatting and --no-color toggle) - cli-processor/CallingContext (sub-command list rendering; jline's render("@|bold ...|@ ...") is replaced with explicit fluent bold()/boldOff() calls) - metaschema-cli/LoggingValidationHandler (severity-colored log preambles and findings) The jline:jansi-core dependency is removed from the parent POM's dependencyManagement, cli-processor/pom.xml, and cli-processor/module-info.java. The new ansi package is exported from cli-processor for metaschema-cli to consume. Motivation: jline 4.0.0 (dependabot PR #677) is a breaking upgrade requiring Maven 4, complete JPMS migration, and JNA removal. The CLI's use of jansi is limited and well-isolated, so replacing it with ~230 lines of maintained code is lower-risk and eliminates the dependency entirely. Test coverage: 22 unit tests in AnsiTest covering color codes, bold, reset, chaining, format(), and the setEnabled(false) suppression path. --- cli-processor/pom.xml | 4 - .../cli/processor/CLIProcessor.java | 4 +- .../cli/processor/CallingContext.java | 13 +- .../metaschema/cli/processor/ansi/Ansi.java | 238 ++++++++++++++++++ cli-processor/src/main/java/module-info.java | 2 +- .../cli/processor/ansi/AnsiTest.java | 165 ++++++++++++ .../cli/util/LoggingValidationHandler.java | 6 +- pom.xml | 6 - 8 files changed, 418 insertions(+), 20 deletions(-) create mode 100644 cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java create mode 100644 cli-processor/src/test/java/dev/metaschema/cli/processor/ansi/AnsiTest.java diff --git a/cli-processor/pom.xml b/cli-processor/pom.xml index 9134544f02..6009ca67b8 100644 --- a/cli-processor/pom.xml +++ b/cli-processor/pom.xml @@ -41,10 +41,6 @@ commons-cli commons-cli - - org.jline - jansi-core - nl.talsmasoftware lazy4j diff --git a/cli-processor/src/main/java/dev/metaschema/cli/processor/CLIProcessor.java b/cli-processor/src/main/java/dev/metaschema/cli/processor/CLIProcessor.java index 5a36bd2406..bddae6b46c 100644 --- a/cli-processor/src/main/java/dev/metaschema/cli/processor/CLIProcessor.java +++ b/cli-processor/src/main/java/dev/metaschema/cli/processor/CLIProcessor.java @@ -5,7 +5,7 @@ package dev.metaschema.cli.processor; -import static org.jline.jansi.Ansi.ansi; +import static dev.metaschema.cli.processor.ansi.Ansi.ansi; import org.apache.commons.cli.Option; import org.apache.logging.log4j.Level; @@ -15,7 +15,6 @@ import org.apache.logging.log4j.core.config.Configuration; import org.apache.logging.log4j.core.config.LoggerConfig; import org.eclipse.jdt.annotation.NotOwning; -import org.jline.jansi.Ansi; import java.io.PrintStream; import java.util.Arrays; @@ -25,6 +24,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import dev.metaschema.cli.processor.ansi.Ansi; import dev.metaschema.cli.processor.command.CommandService; import dev.metaschema.cli.processor.command.ICommand; import dev.metaschema.core.util.CollectionUtil; diff --git a/cli-processor/src/main/java/dev/metaschema/cli/processor/CallingContext.java b/cli-processor/src/main/java/dev/metaschema/cli/processor/CallingContext.java index f6f94135e7..aec844ea0f 100644 --- a/cli-processor/src/main/java/dev/metaschema/cli/processor/CallingContext.java +++ b/cli-processor/src/main/java/dev/metaschema/cli/processor/CallingContext.java @@ -5,7 +5,7 @@ package dev.metaschema.cli.processor; -import static org.jline.jansi.Ansi.ansi; +import static dev.metaschema.cli.processor.ansi.Ansi.ansi; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.DefaultParser; @@ -431,9 +431,14 @@ private String buildHelpFooter(int terminalWidth) { String wrappedDesc = wrapText(command.getDescription(), descWidth, continuationIndent); builder.append( ansi() - .render(String.format(" @|bold %-" + commandColWidth + "s|@ %s%n", - command.getName(), - wrappedDesc))); + .a(" ") + .bold() + .format("%-" + commandColWidth + "s", command.getName()) + .boldOff() + .a(' ') + .a(wrappedDesc) + .a(System.lineSeparator()) + .toString()); } builder .append(System.lineSeparator()) diff --git a/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java b/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java new file mode 100644 index 0000000000..993a2f03ec --- /dev/null +++ b/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java @@ -0,0 +1,238 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.cli.processor.ansi; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A minimal ANSI escape-code builder used by the CLI to emit colored and + * formatted terminal output. + *

+ * Emits a subset of + * Select + * Graphic Rendition codes for foreground colors, bold, and reset. When + * globally disabled via {@link #setEnabled(boolean)}, escape sequences are + * suppressed while appended literal text is preserved so output remains + * readable on terminals that do not interpret ANSI codes. + */ +public final class Ansi { + private static final String ESC = "\u001B["; + private static final String RESET_SEQ = ESC + "0m"; + private static final String BOLD_SEQ = ESC + "1m"; + private static final String BOLD_OFF_SEQ = ESC + "22m"; + + private static volatile boolean enabled = true; + + @NonNull + private final StringBuilder buffer = new StringBuilder(); + + private Ansi() { + // use ansi() factory + } + + /** + * Create a new builder. + * + * @return a fresh builder with empty contents + */ + @NonNull + public static Ansi ansi() { + return new Ansi(); + } + + /** + * Globally enable or disable emission of ANSI escape codes. + *

+ * When disabled, all color and style methods are no-ops; literal appended text + * is still emitted. + * + * @param enable + * {@code true} to emit escape sequences, {@code false} to suppress + * them + */ + public static void setEnabled(boolean enable) { + enabled = enable; + } + + /** + * Indicates whether ANSI escape code emission is currently enabled. + * + * @return {@code true} if enabled + */ + public static boolean isEnabled() { + return enabled; + } + + @NonNull + private Ansi emit(@NonNull String sequence) { + if (enabled) { + buffer.append(sequence); + } + return this; + } + + /** + * Append a single literal character. + * + * @param ch + * the character to append + * @return {@code this} for chaining + */ + @NonNull + public Ansi a(char ch) { + buffer.append(ch); + return this; + } + + /** + * Append a literal string. + * + * @param text + * the text to append + * @return {@code this} for chaining + */ + @NonNull + public Ansi a(@NonNull CharSequence text) { + buffer.append(text); + return this; + } + + /** + * Append formatted text using {@link String#format(String, Object...)} + * semantics. + * + * @param format + * the format string + * @param args + * the format arguments + * @return {@code this} for chaining + */ + @NonNull + public Ansi format(@NonNull String format, Object... args) { + buffer.append(String.format(format, args)); + return this; + } + + /** + * Emit the ANSI reset sequence, clearing any active color or style. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi reset() { + return emit(RESET_SEQ); + } + + /** + * Enable bold rendering for subsequent appended text. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi bold() { + return emit(BOLD_SEQ); + } + + /** + * Disable bold rendering for subsequent appended text. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi boldOff() { + return emit(BOLD_OFF_SEQ); + } + + /** + * Set the foreground color to red. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi fgRed() { + return emit(ESC + "31m"); + } + + /** + * Set the foreground color to bright red. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi fgBrightRed() { + return emit(ESC + "91m"); + } + + /** + * Set the foreground color to bright yellow. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi fgBrightYellow() { + return emit(ESC + "93m"); + } + + /** + * Set the foreground color to bright blue. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi fgBrightBlue() { + return emit(ESC + "94m"); + } + + /** + * Set the foreground color to bright cyan. + * + * @return {@code this} for chaining + */ + @NonNull + public Ansi fgBrightCyan() { + return emit(ESC + "96m"); + } + + /** + * Set the foreground color to the bright variant of the supplied color. + * + * @param color + * the base color + * @return {@code this} for chaining + */ + @NonNull + public Ansi fgBright(@NonNull Color color) { + return emit(ESC + (90 + color.ordinal()) + "m"); + } + + @Override + public String toString() { + return buffer.toString(); + } + + /** + * Standard 8 ANSI foreground colors. Ordinals align with the standard color + * codes (0-7) so bright variants are derived by adding 90. + */ + public enum Color { + /** Black (code 30, bright 90). */ + BLACK, + /** Red (code 31, bright 91). */ + RED, + /** Green (code 32, bright 92). */ + GREEN, + /** Yellow (code 33, bright 93). */ + YELLOW, + /** Blue (code 34, bright 94). */ + BLUE, + /** Magenta (code 35, bright 95). */ + MAGENTA, + /** Cyan (code 36, bright 96). */ + CYAN, + /** White (code 37, bright 97). */ + WHITE; + } +} diff --git a/cli-processor/src/main/java/module-info.java b/cli-processor/src/main/java/module-info.java index bc4b4e6ea8..4aa1a815f3 100644 --- a/cli-processor/src/main/java/module-info.java +++ b/cli-processor/src/main/java/module-info.java @@ -20,13 +20,13 @@ requires static org.eclipse.jdt.annotation; requires static com.github.spotbugs.annotations; - requires org.jansi.core; requires nl.talsmasoftware.lazy4j; requires org.apache.logging.log4j; requires org.apache.logging.log4j.core; requires org.apache.logging.log4j.jul; exports dev.metaschema.cli.processor; + exports dev.metaschema.cli.processor.ansi; exports dev.metaschema.cli.processor.command; exports dev.metaschema.cli.processor.command.impl; exports dev.metaschema.cli.processor.completion; diff --git a/cli-processor/src/test/java/dev/metaschema/cli/processor/ansi/AnsiTest.java b/cli-processor/src/test/java/dev/metaschema/cli/processor/ansi/AnsiTest.java new file mode 100644 index 0000000000..6831a0fa71 --- /dev/null +++ b/cli-processor/src/test/java/dev/metaschema/cli/processor/ansi/AnsiTest.java @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package dev.metaschema.cli.processor.ansi; + +import static dev.metaschema.cli.processor.ansi.Ansi.ansi; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Execution(ExecutionMode.SAME_THREAD) +class AnsiTest { + private static final String ESC = "\u001B"; + + @BeforeEach + void ensureEnabled() { + Ansi.setEnabled(true); + } + + @AfterEach + void restoreEnabled() { + Ansi.setEnabled(true); + } + + @Test + void emptyBuilderRendersEmpty() { + assertEquals("", ansi().toString()); + } + + @Test + void appendStringRendersPlainText() { + assertEquals("hello", ansi().a("hello").toString()); + } + + @Test + void appendCharRendersSingleCharacter() { + assertEquals("x", ansi().a('x').toString()); + } + + @Test + void formatUsesPrintfSemantics() { + assertEquals("count=5", ansi().format("count=%d", 5).toString()); + } + + @Test + void resetEmitsResetSequence() { + assertEquals(ESC + "[0m", ansi().reset().toString()); + } + + @Test + void boldEmitsBoldSequence() { + assertEquals(ESC + "[1m", ansi().bold().toString()); + } + + @Test + void boldOffEmitsBoldOffSequence() { + assertEquals(ESC + "[22m", ansi().boldOff().toString()); + } + + @Test + void fgRedEmitsRedSequence() { + assertEquals(ESC + "[31m", ansi().fgRed().toString()); + } + + @Test + void fgBrightRedEmitsBrightRedSequence() { + assertEquals(ESC + "[91m", ansi().fgBrightRed().toString()); + } + + @Test + void fgBrightYellowEmitsBrightYellowSequence() { + assertEquals(ESC + "[93m", ansi().fgBrightYellow().toString()); + } + + @Test + void fgBrightBlueEmitsBrightBlueSequence() { + assertEquals(ESC + "[94m", ansi().fgBrightBlue().toString()); + } + + @Test + void fgBrightCyanEmitsBrightCyanSequence() { + assertEquals(ESC + "[96m", ansi().fgBrightCyan().toString()); + } + + @Test + void fgBrightWhiteViaColorEnum() { + assertEquals(ESC + "[97m", ansi().fgBright(Ansi.Color.WHITE).toString()); + } + + @Test + void fgBrightMagentaViaColorEnum() { + assertEquals(ESC + "[95m", ansi().fgBright(Ansi.Color.MAGENTA).toString()); + } + + @Test + void chainingColorTextReset() { + assertEquals( + ESC + "[31mCRITICAL" + ESC + "[0m", + ansi().fgRed().a("CRITICAL").reset().toString()); + } + + @Test + void chainingBoldAndText() { + assertEquals( + ESC + "[1mname" + ESC + "[22m", + ansi().bold().a("name").boldOff().toString()); + } + + @Test + void fluentReassignmentPreservesSingleBuilder() { + // Mirrors caller pattern: ansi = ansi.format(...) + Ansi ansi = ansi(); + ansi = ansi.a("a"); + ansi = ansi.format(" %s", "b"); + assertEquals("a b", ansi.toString()); + } + + @Test + void toStringIsIdempotent() { + Ansi ansi = ansi().fgRed().a("x").reset(); + String first = ansi.toString(); + String second = ansi.toString(); + assertEquals(first, second); + } + + @Test + void preservesLiteralPercentInText() { + assertEquals("50%", ansi().a("50%").toString()); + } + + @Nested + class DisabledMode { + @Test + void setEnabledFalseSuppressesEscapeCodes() { + Ansi.setEnabled(false); + assertEquals( + "CRITICAL", + ansi().fgRed().a("CRITICAL").reset().toString()); + } + + @Test + void setEnabledFalsePreservesPlainText() { + Ansi.setEnabled(false); + assertEquals( + "hello world", + ansi().a("hello ").bold().a("world").boldOff().toString()); + } + + @Test + void setEnabledFalseSuppressesFormattedColorOutput() { + Ansi.setEnabled(false); + String out = ansi().fgBrightYellow().format("x=%d", 1).reset().toString(); + assertEquals("x=1", out); + assertTrue(!out.contains(ESC), "No escape sequences expected when disabled"); + } + } +} diff --git a/metaschema-cli/src/main/java/dev/metaschema/cli/util/LoggingValidationHandler.java b/metaschema-cli/src/main/java/dev/metaschema/cli/util/LoggingValidationHandler.java index ddb447efdd..4f995f7e50 100644 --- a/metaschema-cli/src/main/java/dev/metaschema/cli/util/LoggingValidationHandler.java +++ b/metaschema-cli/src/main/java/dev/metaschema/cli/util/LoggingValidationHandler.java @@ -5,19 +5,19 @@ package dev.metaschema.cli.util; -import static org.jline.jansi.Ansi.ansi; +import static dev.metaschema.cli.processor.ansi.Ansi.ansi; import org.apache.logging.log4j.LogBuilder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.jline.jansi.Ansi; -import org.jline.jansi.Ansi.Color; import org.xml.sax.SAXParseException; import java.net.URI; import java.util.Set; import java.util.stream.Collectors; +import dev.metaschema.cli.processor.ansi.Ansi; +import dev.metaschema.cli.processor.ansi.Ansi.Color; import dev.metaschema.core.metapath.format.IPathFormatter; import dev.metaschema.core.model.constraint.ConstraintValidationFinding; import dev.metaschema.core.model.constraint.IConstraint.Level; diff --git a/pom.xml b/pom.xml index 8b1a6d248f..88aac83d86 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,6 @@ 2.3.34 1.0.0 2.21.1 - 3.30.6 4.0.0 2.13.1 20251224 @@ -317,11 +316,6 @@ ipaddress 5.6.1 - - org.jline - jansi-core - ${dependency.jline.version} - org.apache.logging.log4j log4j-bom From 04cb3a1610606c81dac0fddf4088b7ecdefcc54c Mon Sep 17 00:00:00 2001 From: David Waltermire Date: Sun, 19 Apr 2026 15:26:10 -0400 Subject: [PATCH 2/2] chore: use AtomicBoolean for Ansi.enabled flag PMD's AvoidUsingVolatile rule flags the volatile boolean; AtomicBoolean is idiomatic for thread-safe flag flipping and passes the same tests. --- .../java/dev/metaschema/cli/processor/ansi/Ansi.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java b/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java index 993a2f03ec..a9a929047a 100644 --- a/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java +++ b/cli-processor/src/main/java/dev/metaschema/cli/processor/ansi/Ansi.java @@ -5,6 +5,8 @@ package dev.metaschema.cli.processor.ansi; +import java.util.concurrent.atomic.AtomicBoolean; + import edu.umd.cs.findbugs.annotations.NonNull; /** @@ -24,7 +26,7 @@ public final class Ansi { private static final String BOLD_SEQ = ESC + "1m"; private static final String BOLD_OFF_SEQ = ESC + "22m"; - private static volatile boolean enabled = true; + private static final AtomicBoolean ENABLED = new AtomicBoolean(true); @NonNull private final StringBuilder buffer = new StringBuilder(); @@ -54,7 +56,7 @@ public static Ansi ansi() { * them */ public static void setEnabled(boolean enable) { - enabled = enable; + ENABLED.set(enable); } /** @@ -63,12 +65,12 @@ public static void setEnabled(boolean enable) { * @return {@code true} if enabled */ public static boolean isEnabled() { - return enabled; + return ENABLED.get(); } @NonNull private Ansi emit(@NonNull String sequence) { - if (enabled) { + if (ENABLED.get()) { buffer.append(sequence); } return this;