Skip to content

Commit 9dcdeaa

Browse files
feat: replace Jansi with JLine to resolve Java 21+ native access warnings
Resolves #603 by migrating from org.fusesource.jansi:jansi 2.4.2 to org.jline:jline 3.28.0. JLine 3.25+ includes the Jansi codebase directly, providing the same ANSI console functionality under the org.jline.jansi package. Key Changes: - Replace Jansi dependency with JLine in parent and cli-processor POMs - Remove AnsiConsole.systemInstall() - this call triggers native library loading which causes warnings on JDK 22+ (JEP 454) and JDK 24+ (JEP 472) - Use System.out directly instead of AnsiConsole.out() - modern terminals (Windows 10+, Linux, macOS) support ANSI escape codes natively - Replace AnsiPrintStream.getTerminalWidth() with COLUMNS environment variable lookup (falls back to 80 columns) - Use Ansi.setEnabled(false) for --no-color mode instead of AnsiConsole.JANSI_MODE_STRIP property - Update imports from org.fusesource.jansi to org.jline.jansi Tests: - CLIProcessorTest: Version output tests (7), No-color mode tests (3) - LoggingValidationHandlerTest: Factory method and configuration tests (11) - All 113 CLI tests pass This approach eliminates native access entirely, avoiding warnings on all Java versions without requiring --enable-native-access flags.
1 parent 05bd02e commit 9dcdeaa

11 files changed

Lines changed: 496 additions & 34 deletions

File tree

.lycheeignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# broken plugin and dependency references
2+
https://github.com/jline/jline3/jline
23
https://bytebuddy.net/byte-buddy
34
https://checkstyle.org/checks/indentation/indentation.html
45
https://chronicle.software/java-parent-pom/compiler

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ docker pull ghcr.io/metaschema-framework/metaschema-cli:latest
105105
docker run -it ghcr.io/metaschema-framework/metaschema-cli:latest --version
106106
```
107107

108+
### CLI Usage Notes
109+
110+
#### Disabling Color Output
111+
112+
The CLI uses ANSI escape codes for colored output, which is supported by most modern terminals including Windows 10+, Linux, and macOS. If you are using a legacy console that does not support ANSI escape codes (e.g., older Windows cmd.exe, certain CI/CD environments, or when redirecting output to a file), you may see raw escape sequences in the output.
113+
114+
To disable colored output, use the `--no-color` flag:
115+
116+
```sh
117+
metaschema-cli --no-color <command>
118+
```
119+
108120
## Relationship to prior work
109121

110122
The contents of this repository is based on work from the [Metaschema Java repository](https://github.com/usnistgov/metaschema-java/) maintained by the National Institute of Standards and Technology (NIST), the [contents of which have been dedicated in the worldwide public domain](https://github.com/usnistgov/metaschema-java/blob/1a496e4bcf905add6b00a77a762ed3cc31bf77e6/LICENSE.md) using the [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) public domain dedication. This repository builds on this prior work, maintaining the [CCO license](https://github.com/metaschema-framework/metaschema-java/blob/main/LICENSE.md) on any new works in this repository.

cli-processor/pom.xml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,9 @@
4242
<artifactId>commons-cli</artifactId>
4343
</dependency>
4444
<dependency>
45-
<groupId>org.fusesource.jansi</groupId>
46-
<artifactId>jansi</artifactId>
45+
<groupId>org.jline</groupId>
46+
<artifactId>jline</artifactId>
4747
</dependency>
48-
<!-- <dependency> <groupId>org.jline</groupId>
49-
<artifactId>jline-terminal-jansi</artifactId>
50-
</dependency> -->
5148
<dependency>
5249
<groupId>nl.talsmasoftware</groupId>
5350
<artifactId>lazy4j</artifactId>

cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/CLIProcessor.java

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
package gov.nist.secauto.metaschema.cli.processor;
77

8-
import static org.fusesource.jansi.Ansi.ansi;
8+
import static org.jline.jansi.Ansi.ansi;
99

1010
import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
1111
import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
@@ -21,7 +21,7 @@
2121
import org.apache.logging.log4j.core.config.Configuration;
2222
import org.apache.logging.log4j.core.config.LoggerConfig;
2323
import org.eclipse.jdt.annotation.NotOwning;
24-
import org.fusesource.jansi.AnsiConsole;
24+
import org.jline.jansi.Ansi;
2525

2626
import java.io.PrintStream;
2727
import java.util.Arrays;
@@ -172,12 +172,9 @@ public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> ver
172172
@Nullable @NotOwning PrintStream outputStream) {
173173
this.exec = exec;
174174
this.versionInfos = versionInfos;
175-
if (outputStream == null) {
176-
AnsiConsole.systemInstall();
177-
this.outputStream = ObjectUtils.notNull(AnsiConsole.out());
178-
} else {
179-
this.outputStream = outputStream;
180-
}
175+
// Use System.out directly - modern terminals (Windows 10+, Linux, macOS)
176+
// support ANSI natively without requiring native terminal detection
177+
this.outputStream = outputStream != null ? outputStream : ObjectUtils.notNull(System.out);
181178
}
182179

183180
/**
@@ -273,8 +270,8 @@ protected final Map<String, ICommand> getTopLevelCommandsByName() {
273270
}
274271

275272
static void handleNoColor() {
276-
System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
277-
AnsiConsole.systemUninstall();
273+
// Disable ANSI escape sequences - the Ansi class will output plain text
274+
Ansi.setEnabled(false);
278275
}
279276

280277
/**

cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/CallingContext.java

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
package gov.nist.secauto.metaschema.cli.processor;
77

8-
import static org.fusesource.jansi.Ansi.ansi;
8+
import static org.jline.jansi.Ansi.ansi;
99

1010
import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
1111
import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
@@ -23,8 +23,6 @@
2323
import org.apache.commons.cli.help.HelpFormatter;
2424
import org.apache.commons.cli.help.OptionFormatter;
2525
import org.apache.commons.cli.help.TextHelpAppendable;
26-
import org.fusesource.jansi.AnsiPrintStream;
27-
2826
import java.io.IOException;
2927
import java.io.PrintStream;
3028
import java.io.PrintWriter;
@@ -399,10 +397,12 @@ private static String buildHelpHeader() {
399397
/**
400398
* Callback for providing a help footer.
401399
*
402-
* @return the footer or {@code null}
400+
* @param terminalWidth
401+
* the terminal width for text wrapping
402+
* @return the footer or an empty string if no subcommands
403403
*/
404404
@NonNull
405-
private String buildHelpFooter() {
405+
private String buildHelpFooter(int terminalWidth) {
406406
ICommand targetCommand = getTargetCommand();
407407
Collection<ICommand> subCommands;
408408
if (targetCommand == null) {
@@ -421,16 +421,23 @@ private String buildHelpFooter() {
421421
.append("The following are available commands:")
422422
.append(System.lineSeparator());
423423

424-
int length = subCommands.stream()
424+
int commandColWidth = subCommands.stream()
425425
.mapToInt(command -> command.getName().length())
426426
.max().orElse(0);
427427

428+
// Calculate description column width: terminal - 3 (leading spaces) -
429+
// commandCol - 1 (space)
430+
int prefixWidth = 3 + commandColWidth + 1;
431+
int descWidth = Math.max(terminalWidth - prefixWidth, 20);
432+
String continuationIndent = " ".repeat(prefixWidth);
433+
428434
for (ICommand command : subCommands) {
435+
String wrappedDesc = wrapText(command.getDescription(), descWidth, continuationIndent);
429436
builder.append(
430437
ansi()
431-
.render(String.format(" @|bold %-" + length + "s|@ %s%n",
438+
.render(String.format(" @|bold %-" + commandColWidth + "s|@ %s%n",
432439
command.getName(),
433-
command.getDescription())));
440+
wrappedDesc)));
434441
}
435442
builder
436443
.append(System.lineSeparator())
@@ -540,6 +547,85 @@ private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
540547
return builder;
541548
}
542549

550+
private static final int DEFAULT_TERMINAL_WIDTH = 80;
551+
552+
/**
553+
* Get the terminal width from environment or use a default.
554+
* <p>
555+
* This method avoids native terminal detection which triggers Java 21+
556+
* restricted method warnings. Instead, it uses the COLUMNS environment variable
557+
* which is set by most shells.
558+
*
559+
* @return the terminal width in characters
560+
*/
561+
private static int getTerminalWidth() {
562+
String columns = System.getenv("COLUMNS");
563+
if (columns != null) {
564+
try {
565+
int width = Integer.parseInt(columns);
566+
if (width > 0) {
567+
return width;
568+
}
569+
} catch (NumberFormatException e) {
570+
// Ignore and use default
571+
}
572+
}
573+
return DEFAULT_TERMINAL_WIDTH;
574+
}
575+
576+
/**
577+
* Wrap text to fit within the specified width, with proper indentation for
578+
* continuation lines.
579+
*
580+
* @param text
581+
* the text to wrap
582+
* @param maxWidth
583+
* the maximum line width
584+
* @param indent
585+
* the indentation string for continuation lines
586+
* @return the wrapped text
587+
*/
588+
@NonNull
589+
static String wrapText(@NonNull String text, int maxWidth, @NonNull String indent) {
590+
if (text.length() <= maxWidth) {
591+
return text;
592+
}
593+
594+
StringBuilder result = new StringBuilder(text.length() + 32);
595+
int lineStart = 0;
596+
boolean firstLine = true;
597+
int effectiveWidth = maxWidth;
598+
599+
while (lineStart < text.length()) {
600+
if (!firstLine) {
601+
result.append(System.lineSeparator()).append(indent);
602+
effectiveWidth = maxWidth - indent.length();
603+
}
604+
605+
int remaining = text.length() - lineStart;
606+
if (remaining <= effectiveWidth) {
607+
result.append(text.substring(lineStart));
608+
break;
609+
}
610+
611+
// Find last space within the width limit
612+
int lineEnd = lineStart + effectiveWidth;
613+
int lastSpace = text.lastIndexOf(' ', lineEnd);
614+
615+
if (lastSpace <= lineStart) {
616+
// No space found, force break at width
617+
result.append(text, lineStart, lineEnd);
618+
lineStart = lineEnd; // Continue from break point (no space to skip)
619+
} else {
620+
result.append(text, lineStart, lastSpace);
621+
lineStart = lastSpace + 1; // Skip the space
622+
}
623+
firstLine = false;
624+
}
625+
626+
return ObjectUtils.notNull(result.toString());
627+
}
628+
543629
/**
544630
* Output the help text to the console.
545631
*
@@ -548,9 +634,9 @@ private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
548634
*/
549635
public void showHelp() {
550636
PrintStream out = cliProcessor.getOutputStream();
551-
int terminalWidth = (out instanceof AnsiPrintStream)
552-
? ((AnsiPrintStream) out).getTerminalWidth()
553-
: 80;
637+
// Get terminal width from environment variable COLUMNS, or default to 80
638+
// This avoids native terminal detection which triggers Java 21+ warnings
639+
int terminalWidth = getTerminalWidth();
554640

555641
try (PrintWriter writer = new PrintWriter( // NOPMD not owned
556642
AutoCloser.preventClose(out),
@@ -562,19 +648,24 @@ public void showHelp() {
562648
HelpFormatter formatter = HelpFormatter.builder()
563649
.setHelpAppendable(appendable)
564650
.setOptionFormatBuilder(OptionFormatter.builder().setOptArgSeparator("="))
651+
.setShowSince(false)
565652
.get();
566653

567654
try {
655+
// Print main help (syntax, header, options) through the formatter
568656
formatter.printHelp(
569657
buildHelpCliSyntax(),
570658
buildHelpHeader(),
571659
toOptions(),
572-
buildHelpFooter(),
660+
"", // Empty footer - we print it directly below
573661
false);
574662
} catch (IOException ex) {
575663
throw new UncheckedIOException("Failed to write help output", ex);
576664
}
577665

666+
// Print footer directly to bypass TextHelpAppendable's text wrapping,
667+
// which doesn't account for ANSI escape sequence lengths
668+
writer.print(buildHelpFooter(terminalWidth));
578669
writer.flush();
579670
}
580671
}

cli-processor/src/test/java/gov/nist/secauto/metaschema/cli/processor/CLIProcessorTest.java

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,115 @@ void testQuietOption() {
7878
}
7979
}
8080

81+
@Nested
82+
@DisplayName("Version Output Tests")
83+
class VersionOutputTests {
84+
85+
@Test
86+
@DisplayName("version output contains app name")
87+
void testVersionOutputContainsAppName() {
88+
processor.process("--version");
89+
90+
String output = outputCapture.toString(StandardCharsets.UTF_8);
91+
assertTrue(output.contains("test-cli"), "Version output should contain app name");
92+
}
93+
94+
@Test
95+
@DisplayName("version output contains version number")
96+
void testVersionOutputContainsVersion() {
97+
processor.process("--version");
98+
99+
String output = outputCapture.toString(StandardCharsets.UTF_8);
100+
assertTrue(output.contains("1.0.0-test"), "Version output should contain version number");
101+
}
102+
103+
@Test
104+
@DisplayName("version output contains build timestamp")
105+
void testVersionOutputContainsBuildTimestamp() {
106+
processor.process("--version");
107+
108+
String output = outputCapture.toString(StandardCharsets.UTF_8);
109+
assertTrue(output.contains("2025-01-01"), "Version output should contain build timestamp");
110+
}
111+
112+
@Test
113+
@DisplayName("version output contains git branch")
114+
void testVersionOutputContainsGitBranch() {
115+
processor.process("--version");
116+
117+
String output = outputCapture.toString(StandardCharsets.UTF_8);
118+
assertTrue(output.contains("test-branch"), "Version output should contain git branch");
119+
}
120+
121+
@Test
122+
@DisplayName("version output contains git commit")
123+
void testVersionOutputContainsGitCommit() {
124+
processor.process("--version");
125+
126+
String output = outputCapture.toString(StandardCharsets.UTF_8);
127+
assertTrue(output.contains("abc1234"), "Version output should contain git commit");
128+
}
129+
130+
@Test
131+
@DisplayName("version output contains git origin URL")
132+
void testVersionOutputContainsGitOriginUrl() {
133+
processor.process("--version");
134+
135+
String output = outputCapture.toString(StandardCharsets.UTF_8);
136+
assertTrue(output.contains("https://example.com/test.git"),
137+
"Version output should contain git origin URL");
138+
}
139+
140+
@Test
141+
@DisplayName("version output contains descriptive text")
142+
void testVersionOutputContainsDescriptiveText() {
143+
processor.process("--version");
144+
145+
String output = outputCapture.toString(StandardCharsets.UTF_8);
146+
assertTrue(output.contains("built at"), "Version output should contain 'built at'");
147+
assertTrue(output.contains("from branch"), "Version output should contain 'from branch'");
148+
}
149+
}
150+
151+
@Nested
152+
@DisplayName("No-Color Mode Tests")
153+
class NoColorModeTests {
154+
155+
@Test
156+
@DisplayName("--no-color option is accepted with command")
157+
void testNoColorOptionAccepted() {
158+
processor.addCommandHandler(new TestCommand());
159+
160+
ExitStatus status = processor.process("--no-color", "test-cmd");
161+
162+
assertEquals(ExitCode.OK, status.getExitCode());
163+
}
164+
165+
@Test
166+
@DisplayName("--no-color with --help produces output")
167+
void testNoColorWithHelp() {
168+
// Note: --help must come first for phase 1 parsing to recognize it
169+
ExitStatus status = processor.process("--help", "--no-color");
170+
171+
String output = outputCapture.toString(StandardCharsets.UTF_8);
172+
assertAll(
173+
() -> assertEquals(ExitCode.OK, status.getExitCode()),
174+
() -> assertTrue(output.contains("--help"), "Output should contain '--help'"));
175+
}
176+
177+
@Test
178+
@DisplayName("--no-color with --version produces output")
179+
void testNoColorWithVersion() {
180+
// Note: --version must come first for phase 1 parsing to recognize it
181+
ExitStatus status = processor.process("--version", "--no-color");
182+
183+
String output = outputCapture.toString(StandardCharsets.UTF_8);
184+
assertAll(
185+
() -> assertEquals(ExitCode.OK, status.getExitCode()),
186+
() -> assertTrue(output.contains("test-cli"), "Output should contain app name"));
187+
}
188+
}
189+
81190
@Nested
82191
@DisplayName("Command Execution")
83192
class CommandExecutionTests {

0 commit comments

Comments
 (0)