diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy index 6f3d7b9eea8..c1856c0ad5d 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy @@ -88,7 +88,8 @@ class SbomPlugin implements Plugin { private static Map LICENSE_MAPPING = [ 'pkg:maven/org.antlr/antlr4-runtime@4.7.2?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/jline/jline@2.14.6?type=jar' : 'BSD-2-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 - 'pkg:maven/org.jline/jline@3.23.0?type=jar' : 'BSD-2-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline@3.23.0?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline@3.30.6?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.liquibase.ext/liquibase-hibernate5@4.27.0?type=jar': 'Apache-2.0', // maps incorrectly because of https://github.com/liquibase/liquibase/issues/2445 & the base pom does not define a license 'pkg:maven/com.oracle.coherence.ce/coherence-bom@25.03.1?type=pom': 'UPL-1.0', // does not have map based on license id 'pkg:maven/com.oracle.coherence.ce/coherence-bom@22.06.2?type=pom': 'UPL-1.0', // does not have map based on license id diff --git a/dependencies.gradle b/dependencies.gradle index ecbbca0f2e6..29e2c5cb623 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -31,9 +31,11 @@ ext { 'directory-watcher.version' : '0.19.1', 'gradle-spock.version' : '2.3-groovy-3.0', 'grails-publish-plugin.version' : '0.0.4', - 'jansi.version' : '1.18', + 'jansi.version' : '2.4.2', 'javaparser-core.version' : '3.27.0', - 'jline.version' : '2.14.6', + 'jline.version' : '3.30.6', + // TODO: Remove jline2 when upgrading to Groovy 5 (groovy-groovysh 5.x uses JLine 3) + 'jline2.version' : '2.14.6', 'jna.version' : '5.17.0', 'jquery.version' : '3.7.1', 'objenesis.version' : '3.4', @@ -59,7 +61,9 @@ ext { 'grails-publish-plugin' : "org.apache.grails.gradle:grails-publish:${gradleBomDependencyVersions['grails-publish-plugin.version']}", 'jansi' : "org.fusesource.jansi:jansi:${gradleBomDependencyVersions['jansi.version']}", 'javaparser-core' : "com.github.javaparser:javaparser-core:${gradleBomDependencyVersions['javaparser-core.version']}", - 'jline' : "jline:jline:${gradleBomDependencyVersions['jline.version']}", + 'jline' : "org.jline:jline:${gradleBomDependencyVersions['jline.version']}", + // TODO: Remove jline2 when upgrading to Groovy 5 (groovy-groovysh 5.x uses JLine 3) + 'jline2' : "jline:jline:${gradleBomDependencyVersions['jline2.version']}", 'jna' : "net.java.dev.jna:jna:${gradleBomDependencyVersions['jna.version']}", 'objenesis' : "org.objenesis:objenesis:${gradleBomDependencyVersions['objenesis.version']}", 'spring-boot-cli' : "org.springframework.boot:spring-boot-cli:${gradleBomDependencyVersions['spring-boot.version']}", diff --git a/gradle/docs-dependencies.gradle b/gradle/docs-dependencies.gradle index df4244f39c1..828aaa7dc03 100644 --- a/gradle/docs-dependencies.gradle +++ b/gradle/docs-dependencies.gradle @@ -29,6 +29,7 @@ configurations.register('documentation') { dependencies { add('documentation', platform(project(':grails-bom'))) add('documentation', 'org.fusesource.jansi:jansi') + // TODO: Remove jline:jline (JLine 2) when upgrading to Groovy 5 (groovy-groovysh 5.x uses JLine 3) add('documentation', 'jline:jline') add('documentation', 'com.github.javaparser:javaparser-core') add('documentation', 'org.apache.groovy:groovy') diff --git a/grails-bootstrap/build.gradle b/grails-bootstrap/build.gradle index 22bf226e4f3..934cda77850 100644 --- a/grails-bootstrap/build.gradle +++ b/grails-bootstrap/build.gradle @@ -50,7 +50,7 @@ dependencies { compileOnly 'io.methvin:directory-watcher' compileOnly 'org.fusesource.jansi:jansi' - compileOnly 'jline:jline' + compileOnly 'org.jline:jline' compileOnly 'net.java.dev.jna:jna' api 'org.yaml:snakeyaml' @@ -58,7 +58,7 @@ dependencies { testImplementation 'org.apache.groovy:groovy-xml' testImplementation 'org.apache.groovy:groovy-templates' testImplementation 'org.fusesource.jansi:jansi' - testImplementation 'jline:jline' + testImplementation 'org.jline:jline' testImplementation 'org.apache.groovy:groovy-test-junit5' testImplementation 'org.junit.jupiter:junit-jupiter-api' diff --git a/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java b/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java index 986bf640830..35d1aa58d12 100644 --- a/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java +++ b/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java @@ -18,15 +18,12 @@ */ package grails.build.logging; -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.Flushable; import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; import java.io.PrintWriter; import java.io.StringWriter; -import java.util.Collection; import java.util.List; import java.util.Stack; @@ -34,23 +31,21 @@ import org.codehaus.groovy.runtime.StackTraceUtils; import org.codehaus.groovy.runtime.typehandling.NumberMath; -import jline.Terminal; -import jline.TerminalFactory; -import jline.UnixTerminal; -import jline.console.ConsoleReader; -import jline.console.completer.Completer; -import jline.console.history.FileHistory; -import jline.console.history.History; -import jline.internal.Log; -import jline.internal.ShutdownHooks; -import jline.internal.TerminalLineSettings; import org.apache.tools.ant.BuildException; import org.fusesource.jansi.Ansi; import org.fusesource.jansi.Ansi.Color; import org.fusesource.jansi.AnsiConsole; +import org.jline.reader.Completer; +import org.jline.reader.History; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.impl.LineReaderImpl; +import org.jline.reader.impl.completer.AggregateCompleter; +import org.jline.reader.impl.history.DefaultHistory; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; import grails.util.Environment; -import org.grails.build.interactive.CandidateListCompletionHandler; import org.grails.build.logging.GrailsConsoleErrorPrintStream; import org.grails.build.logging.GrailsConsolePrintStream; @@ -114,7 +109,7 @@ public class GrailsConsole implements ConsoleLogger { /** * The reader to read info from the console */ - ConsoleReader reader; + LineReader reader; Terminal terminal; @@ -123,6 +118,11 @@ public class GrailsConsole implements ConsoleLogger { History history; + /** + * List of completers to be aggregated for tab completion + */ + private final List completers = new java.util.ArrayList<>(); + /** * The category of the current output */ @@ -179,8 +179,8 @@ protected GrailsConsole() throws IOException { * @throws IOException */ public void reinitialize(InputStream systemIn, PrintStream systemOut, PrintStream systemErr) throws IOException { - if (reader != null) { - reader.shutdown(); + if (terminal != null) { + terminal.close(); } initialize(systemIn, systemOut, systemErr); } @@ -190,25 +190,31 @@ protected void initialize(InputStream systemIn, PrintStream systemOut, PrintStre redirectSystemOutAndErr(true); - System.setProperty(ShutdownHooks.JLINE_SHUTDOWNHOOK, "false"); - if (isInteractiveEnabled()) { - reader = createConsoleReader(systemIn); - reader.setBellEnabled(false); - reader.setCompletionHandler(new CandidateListCompletionHandler()); - if (isActivateTerminal()) { - terminal = createTerminal(); - } - + terminal = createTerminal(); history = prepareHistory(); - if (history != null) { - reader.setHistory(history); - } + reader = createLineReader(terminal, history); + initializeHistory(); } else if (isActivateTerminal()) { terminal = createTerminal(); } } + /** + * Initializes history by attaching it to the reader and loading existing entries. + * This must be called after the LineReader is fully constructed. + */ + private void initializeHistory() { + if (history instanceof DefaultHistory && reader != null) { + DefaultHistory defaultHistory = (DefaultHistory) history; + try { + defaultHistory.attach(reader); + } catch (Exception e) { + // History initialization failed, continue without persistent history + } + } + } + protected void bindSystemOutAndErr(PrintStream systemOut, PrintStream systemErr) { originalSystemOut = unwrapPrintStream(systemOut); out = originalSystemOut; @@ -251,51 +257,59 @@ private boolean readPropOrTrue(String prop) { return property == null ? true : Boolean.valueOf(property); } - protected ConsoleReader createConsoleReader(InputStream systemIn) throws IOException { - // need to swap out the output to avoid logging during init - final PrintStream nullOutput = new PrintStream(new ByteArrayOutputStream()); - final PrintStream originalOut = Log.getOutput(); - try { - Log.setOutput(nullOutput); - ConsoleReader consoleReader = new ConsoleReader(systemIn, out); - consoleReader.setExpandEvents(false); - return consoleReader; - } finally { - Log.setOutput(originalOut); + protected LineReader createLineReader(Terminal terminal, History history) throws IOException { + LineReaderBuilder builder = LineReaderBuilder.builder() + .terminal(terminal) + .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true); + if (history != null) { + builder.variable(LineReader.HISTORY_FILE, new File(System.getProperty("user.home"), HISTORYFILE).toPath()); + builder.history(history); } + return builder.build(); } /** - * Creates the instance of Terminal used directly in GrailsConsole. Note that there is also - * another terminal instance created implicitly inside of ConsoleReader. That instance - * is controlled by the jline.terminal system property. + * Creates the instance of Terminal used directly in GrailsConsole. */ - protected Terminal createTerminal() { - terminal = TerminalFactory.create(); - if (isWindows()) { - terminal.setEchoEnabled(true); - } + protected Terminal createTerminal() throws IOException { + Terminal terminal = TerminalBuilder.builder() + .system(true) + .build(); return terminal; } public void resetCompleters() { - final ConsoleReader reader = getReader(); - if (reader != null) { - Collection completers = reader.getCompleters(); - for (Completer completer : completers) { - reader.removeCompleter(completer); - } + completers.clear(); + updateCompleter(); + } - // for some unknown reason / bug in JLine you have to iterate over twice to clear the completers (WTF) - completers = reader.getCompleters(); - for (Completer completer : completers) { - reader.removeCompleter(completer); - } + public void addCompleter(Completer completer) { + if (completer != null) { + completers.add(completer); + updateCompleter(); } } /** - * Prepares a history file to be used by the ConsoleReader. This file + * Updates the LineReader completer using an AggregateCompleter when needed. + */ + private void updateCompleter() { + if (reader == null) { + return; + } + if (!(reader instanceof LineReaderImpl)) { + return; + } + LineReaderImpl lineReader = (LineReaderImpl) reader; + if (completers.isEmpty()) { + lineReader.setCompleter(null); + } else { + lineReader.setCompleter(new AggregateCompleter(completers)); + } + } + + /** + * Prepares a history file to be used by the LineReader. This file * will live in the home directory of the user. */ protected History prepareHistory() throws IOException { @@ -307,7 +321,7 @@ protected History prepareHistory() throws IOException { // can't create the file, so no history for you } } - return file.canWrite() ? new FileHistory(file) : null; + return file.canWrite() ? new DefaultHistory() : null; } public boolean isWindows() { @@ -334,8 +348,12 @@ public static synchronized void removeInstance() { if (instance != null) { instance.removeShutdownHook(); instance.restoreOriginalSystemOutAndErr(); - if (instance.getReader() != null) { - instance.getReader().shutdown(); + if (instance.terminal != null) { + try { + instance.terminal.close(); + } catch (IOException e) { + // ignore + } } instance = null; } @@ -348,24 +366,18 @@ public void beforeShutdown() { protected void restoreTerminal() { try { - terminal.restore(); + if (terminal != null) { + terminal.close(); + } } catch (Exception e) { // ignore } - if (terminal instanceof UnixTerminal) { - // workaround for GRAILS-11494 - try { - new TerminalLineSettings().set("sane"); - } catch (Exception e) { - // ignore - } - } } protected void persistHistory() { - if (history instanceof Flushable) { + if (history != null && reader != null) { try { - ((Flushable) history).flush(); + history.save(); } catch (Throwable e) { // ignore exception } @@ -442,7 +454,7 @@ public boolean isStacktrace() { */ public InputStream getInput() { assertAllowInput(); - return reader.getInput(); + return terminal != null ? terminal.input() : System.in; } private void assertAllowInput() { @@ -471,7 +483,7 @@ public void setLastMessage(String lastMessage) { this.lastMessage = lastMessage; } - public ConsoleReader getReader() { + public LineReader getReader() { return reader; } @@ -674,7 +686,7 @@ private void logSimpleError(String msg) { } public boolean isAnsiEnabled() { - return Ansi.isEnabled() && (terminal != null && terminal.isAnsiSupported()) && ansiEnabled; + return Ansi.isEnabled() && (terminal != null && !"dumb".equals(terminal.getType())) && ansiEnabled; } /** @@ -875,10 +887,17 @@ private String readLine(String prompt, boolean secure) { assertAllowInput(prompt); userInputActive = true; try { - Character inputMask = secure ? SECURE_MASK_CHAR : defaultInputMask; - return reader.readLine(prompt, inputMask); - } catch (IOException e) { - throw new RuntimeException("Error reading input: " + e.getMessage()); + if (secure) { + return reader.readLine(prompt, SECURE_MASK_CHAR); + } else if (defaultInputMask == null) { + return reader.readLine(prompt); + } else { + return reader.readLine(prompt, defaultInputMask); + } + } catch (org.jline.reader.UserInterruptException e) { + return null; + } catch (org.jline.reader.EndOfFileException e) { + return null; } finally { userInputActive = false; } @@ -1041,4 +1060,12 @@ public Character getDefaultInputMask() { public void setDefaultInputMask(Character defaultInputMask) { this.defaultInputMask = defaultInputMask; } + + /** + * Gets the history for the LineReader + * @return the history + */ + public History getHistory() { + return history; + } } diff --git a/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsEclipseConsole.java b/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsEclipseConsole.java index a9142afb552..d2fe51eeb12 100644 --- a/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsEclipseConsole.java +++ b/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsEclipseConsole.java @@ -20,8 +20,8 @@ import java.io.IOException; -import jline.Terminal; -import jline.UnsupportedTerminal; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; /** * This class is meant to keep changes made in support of Eclipse separate from @@ -73,8 +73,10 @@ private static Boolean boolProp(String propName) { } @Override - protected Terminal createTerminal() { - // unix or windows terminal have no relation at all to the behavior of an Eclipse console. - return new UnsupportedTerminal(); + protected Terminal createTerminal() throws IOException { + // For Eclipse, create a dumb terminal that doesn't try to interact with the console + return TerminalBuilder.builder() + .dumb(true) + .build(); } } diff --git a/grails-bootstrap/src/main/groovy/org/grails/build/interactive/CandidateListCompletionHandler.java b/grails-bootstrap/src/main/groovy/org/grails/build/interactive/CandidateListCompletionHandler.java index 2b9ff8abb4a..da61bb2d9c3 100644 --- a/grails-bootstrap/src/main/groovy/org/grails/build/interactive/CandidateListCompletionHandler.java +++ b/grails-bootstrap/src/main/groovy/org/grails/build/interactive/CandidateListCompletionHandler.java @@ -18,76 +18,77 @@ */ package org.grails.build.interactive; -import java.io.IOException; import java.util.List; -import jline.console.ConsoleReader; -import jline.console.CursorBuffer; -import jline.console.completer.CompletionHandler; +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; /** - * Fixes issues with the default CandidateListCompletionHandler such as clearing out the whole buffer when - * a completion matches a list of candidates + * A Completer implementation that wraps candidate list completion behavior. + * In JLine 3, completion handling is integrated into the LineReader itself, + * so this class now acts as a utility completer that can be composed with others. * * @author Graeme Rocher * @since 2.0 */ -public class CandidateListCompletionHandler implements CompletionHandler { +public class CandidateListCompletionHandler implements Completer { - private boolean eagerNewlines = true; + private final Completer delegate; - public void setAlwaysIncludeNewline(boolean eagerNewlines) { - this.eagerNewlines = eagerNewlines; + public CandidateListCompletionHandler() { + this.delegate = null; } - public boolean complete(ConsoleReader reader, @SuppressWarnings("rawtypes") List candidates, int pos) throws IOException { - CursorBuffer buf = reader.getCursorBuffer(); - - // if there is only one completion, then fill in the buffer - if (candidates.size() == 1) { - String value = candidates.get(0).toString(); - - // fail if the only candidate is the same as the current buffer - if (value.equals(buf.toString())) { - return false; - } - - jline.console.completer.CandidateListCompletionHandler.setBuffer(reader, value, pos); + public CandidateListCompletionHandler(Completer delegate) { + this.delegate = delegate; + } - return true; + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { + if (delegate != null) { + delegate.complete(reader, line, candidates); } - if (candidates.size() > 1) { - String value = getUnambiguousCompletions(candidates); - - jline.console.completer.CandidateListCompletionHandler.setBuffer(reader, value, pos); + if (reader == null) { + return; } - if (eagerNewlines) { - reader.println(); + String commonPrefix = getUnambiguousCompletions(candidates); + if (commonPrefix == null) { + return; } - jline.console.completer.CandidateListCompletionHandler.printCandidates(reader, candidates); - // redraw the current console buffer - reader.drawLine(); + String current = line != null ? line.word() : ""; + if (current == null) { + current = ""; + } - return true; + if (commonPrefix.startsWith(current) && !commonPrefix.equals(current)) { + String suffix = commonPrefix.substring(current.length()); + reader.getBuffer().write(suffix); + reader.callWidget(LineReader.REDRAW_LINE); + } } /** * Returns a root that matches all the {@link String} elements * of the specified {@link List}, or null if there are - * no commalities. For example, if the list contains + * no commonalities. For example, if the list contains * foobar, foobaz, foobuz, the * method will return foob. */ - private final String getUnambiguousCompletions(final List candidates) { + public static String getUnambiguousCompletions(final List candidates) { if (candidates == null || candidates.isEmpty()) { return null; } // convert to an array for speed - String[] strings = candidates.toArray(new String[candidates.size()]); + String[] strings = new String[candidates.size()]; + for (int i = 0; i < candidates.size(); i++) { + strings[i] = candidates.get(i).value(); + } String first = strings[0]; StringBuilder candidate = new StringBuilder(); @@ -107,7 +108,7 @@ private final String getUnambiguousCompletions(final List candidates) { * @return true is all the elements of candidates * start with starts */ - private final boolean startsWith(final String starts, final String[] candidates) { + private static boolean startsWith(final String starts, final String[] candidates) { for (int i = 0; i < candidates.length; i++) { if (!candidates[i].startsWith(starts)) { return false; diff --git a/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleCompleterSpec.groovy b/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleCompleterSpec.groovy new file mode 100644 index 00000000000..fa87c45875e --- /dev/null +++ b/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleCompleterSpec.groovy @@ -0,0 +1,333 @@ +/* + * 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 + * + * https://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 grails.build.logging + +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine +import org.jline.terminal.Terminal +import org.jline.terminal.TerminalBuilder +import spock.lang.Specification + +/** + * Tests for GrailsConsole completer management and JLine 3 integration. + */ +class GrailsConsoleCompleterSpec extends Specification { + + /** + * A test GrailsConsole subclass that allows us to test completer functionality + * without triggering side effects like redirecting System.out/err. + */ + static class TestableGrailsConsole extends GrailsConsole { + Terminal testTerminal + + TestableGrailsConsole(Terminal terminal) throws IOException { + super() + this.testTerminal = terminal + this.@terminal = terminal + // Initialize with a basic reader + this.@reader = createLineReader(terminal, null) + } + + @Override + protected void bindSystemOutAndErr(PrintStream systemOut, PrintStream systemErr) { + // Don't bind system streams in tests + out = systemOut + err = systemErr + } + + @Override + protected void redirectSystemOutAndErr(boolean force) { + // Don't redirect in tests + } + + @Override + protected Terminal createTerminal() { + return testTerminal + } + } + + Terminal terminal + TestableGrailsConsole console + + def setup() { + terminal = TerminalBuilder.builder().dumb(true).build() + console = new TestableGrailsConsole(terminal) + } + + def cleanup() { + terminal?.close() + } + + def "addCompleter accepts non-null completers"() { + given: "a simple completer" + def completer = createSimpleCompleter("test") + + when: "the completer is added" + console.addCompleter(completer) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "addCompleter ignores null completers"() { + when: "a null completer is added" + console.addCompleter(null) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "resetCompleters clears all completers"() { + given: "a console with added completers" + console.addCompleter(createSimpleCompleter("one")) + console.addCompleter(createSimpleCompleter("two")) + + when: "completers are reset" + console.resetCompleters() + + then: "no exception is thrown and the operation completes" + noExceptionThrown() + } + + def "multiple completers can be added"() { + given: "multiple completers" + def completer1 = createSimpleCompleter("first") + def completer2 = createSimpleCompleter("second") + def completer3 = createSimpleCompleter("third") + + when: "all completers are added" + console.addCompleter(completer1) + console.addCompleter(completer2) + console.addCompleter(completer3) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "getReader returns a LineReader"() { + when: "the reader is retrieved" + def reader = console.getReader() + + then: "a valid LineReader is returned" + reader != null + reader instanceof LineReader + } + + def "getTerminal returns the terminal"() { + when: "the terminal is retrieved" + def term = console.getTerminal() + + then: "the terminal is returned" + term != null + term == terminal + } + + def "isAnsiEnabled returns false for dumb terminal"() { + given: "a console with dumb terminal" + console.setAnsiEnabled(true) + + expect: "ANSI is disabled for dumb terminal" + // Dumb terminal should have ANSI disabled + !console.isAnsiEnabled() || terminal.getType() != "dumb" + } + + def "getHistory can return null when history is not configured"() { + when: "history is retrieved" + def history = console.getHistory() + + then: "history may be null (depends on configuration)" + // Just verify the method works without exception + noExceptionThrown() + } + + // Additional tests for GrailsConsole functionality + + def "getOut returns the output stream"() { + when: "the output stream is retrieved" + def out = console.getOut() + + then: "a PrintStream is returned" + out != null + out instanceof PrintStream + } + + def "setOut changes the output stream"() { + given: "a new output stream" + def newOut = new PrintStream(new ByteArrayOutputStream()) + + when: "the output stream is changed" + console.setOut(newOut) + + then: "the new output stream is used" + console.getOut() == newOut + } + + def "getErr returns the error stream"() { + when: "the error stream is retrieved" + def err = console.getErr() + + then: "a PrintStream is returned" + err != null + err instanceof PrintStream + } + + def "setErr changes the error stream"() { + given: "a new error stream" + def newErr = new PrintStream(new ByteArrayOutputStream()) + + when: "the error stream is changed" + console.setErr(newErr) + + then: "the new error stream is used" + console.getErr() == newErr + } + + def "verbose mode can be toggled"() { + expect: "verbose is initially false" + !console.isVerbose() + + when: "verbose is enabled" + console.setVerbose(true) + + then: "verbose is true" + console.isVerbose() + + when: "verbose is disabled" + console.setVerbose(false) + + then: "verbose is false" + !console.isVerbose() + } + + def "stacktrace mode can be toggled"() { + expect: "stacktrace is initially false" + !console.isStacktrace() + + when: "stacktrace is enabled" + console.setStacktrace(true) + + then: "stacktrace is true" + console.isStacktrace() + + when: "stacktrace is disabled" + console.setStacktrace(false) + + then: "stacktrace is false" + !console.isStacktrace() + } + + def "lastMessage can be set and retrieved"() { + when: "a message is set" + console.setLastMessage("Test message") + + then: "the message can be retrieved" + console.getLastMessage() == "Test message" + } + + def "ANSI can be enabled and disabled"() { + when: "ANSI is disabled" + console.setAnsiEnabled(false) + + then: "ANSI reports as disabled" + !console.isAnsiEnabled() + } + + def "default input mask can be set"() { + when: "default input mask is set" + console.setDefaultInputMask('*' as Character) + + then: "the mask can be retrieved" + console.getDefaultInputMask() == '*' as Character + } + + def "default input mask can be null"() { + when: "default input mask is set to null" + console.setDefaultInputMask(null) + + then: "the mask is null" + console.getDefaultInputMask() == null + } + + def "category stack is available"() { + when: "the category stack is retrieved" + def category = console.getCategory() + + then: "a stack is returned" + category != null + category instanceof Stack + } + + def "flush does not throw exceptions"() { + when: "flush is called" + console.flush() + + then: "no exception is thrown" + noExceptionThrown() + } + + def "addCompleter followed by resetCompleters works correctly"() { + given: "multiple completers are added" + console.addCompleter(createSimpleCompleter("one")) + console.addCompleter(createSimpleCompleter("two")) + console.addCompleter(createSimpleCompleter("three")) + + when: "completers are reset and new ones added" + console.resetCompleters() + console.addCompleter(createSimpleCompleter("new")) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "reader remains available when completers are added"() { + given: "initial reader" + def initialReader = console.getReader() + + when: "a completer is added" + console.addCompleter(createSimpleCompleter("test")) + + then: "reader remains usable" + console.getReader() != null + console.getReader() == initialReader + } + + def "isWindows returns consistent result"() { + when: "isWindows is called" + def isWin = console.isWindows() + + then: "it returns a boolean" + isWin == true || isWin == false + } + + /** + * Creates a simple completer for testing. + */ + private Completer createSimpleCompleter(String... values) { + return new Completer() { + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + for (String value : values) { + candidates.add(new Candidate(value)) + } + } + } + } +} diff --git a/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleSpec.groovy b/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleSpec.groovy index dd970e54795..4f99dd96502 100644 --- a/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleSpec.groovy +++ b/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleSpec.groovy @@ -18,7 +18,10 @@ */ package grails.build.logging -import jline.console.ConsoleReader +import org.jline.reader.LineReader +import org.jline.reader.LineReaderBuilder +import org.jline.terminal.Terminal +import org.jline.terminal.TerminalBuilder import org.fusesource.jansi.Ansi import spock.lang.IgnoreIf import spock.lang.Issue @@ -48,20 +51,27 @@ class GrailsConsoleSpec extends Specification { PrintStream out GrailsConsole console String output + Terminal terminal def setup() { - InputStream systemIn = Mock(InputStream) - systemIn.read(* _) >> -1 out = Mock(PrintStream) + terminal = TerminalBuilder.builder().dumb(true).build() console = GrailsConsole.getInstance() console.ansiEnabled = true console.out = out - console.reader = new ConsoleReader(systemIn, out) + console.@terminal = terminal + console.@reader = LineReaderBuilder.builder() + .terminal(terminal) + .build() output = "" } + def cleanup() { + terminal?.close() + } + @Issue('GRAILS-10753') def "outputMessage - verify the reset marker at the end of the output"() { when: diff --git a/grails-bootstrap/src/test/groovy/org/grails/build/interactive/CandidateListCompletionHandlerSpec.groovy b/grails-bootstrap/src/test/groovy/org/grails/build/interactive/CandidateListCompletionHandlerSpec.groovy new file mode 100644 index 00000000000..1a7a16f6ae5 --- /dev/null +++ b/grails-bootstrap/src/test/groovy/org/grails/build/interactive/CandidateListCompletionHandlerSpec.groovy @@ -0,0 +1,312 @@ +/* + * 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 + * + * https://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.grails.build.interactive + +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Tests for CandidateListCompletionHandler which wraps completion behavior + * and provides utility methods for finding common prefixes. + */ +class CandidateListCompletionHandlerSpec extends Specification { + + def "Handler can be created without delegate"() { + when: "handler is created without delegate" + def handler = new CandidateListCompletionHandler() + + then: "it is created successfully" + handler != null + } + + def "Handler can be created with delegate"() { + given: "a delegate completer" + def delegate = Mock(Completer) + + when: "handler is created with delegate" + def handler = new CandidateListCompletionHandler(delegate) + + then: "it is created successfully" + handler != null + } + + def "Handler delegates completion to wrapped completer"() { + given: "a delegate completer that adds candidates" + def delegate = new Completer() { + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + candidates.add(new Candidate("option1")) + candidates.add(new Candidate("option2")) + } + } + def handler = new CandidateListCompletionHandler(delegate) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "completion is performed" + handler.complete(null, parsedLine, candidates) + + then: "delegate's candidates are returned" + candidates.size() == 2 + candidates*.value() == ["option1", "option2"] + } + + def "Handler updates buffer with common prefix when possible"() { + given: "a delegate completer with a shared prefix" + def delegate = new TestCompleter(["create-app", "create-plugin"]) + def handler = new CandidateListCompletionHandler(delegate) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "cre" + } + def reader = Mock(LineReader) + def buffer = Mock(org.jline.reader.Buffer) + reader.getBuffer() >> buffer + + when: "completion is performed" + handler.complete(reader, parsedLine, candidates) + + then: "buffer is extended with common prefix" + 1 * buffer.write("ate-") + 1 * reader.callWidget(LineReader.REDRAW_LINE) + } + + def "Handler does not alter buffer when prefix already matches"() { + given: "a delegate completer with a shared prefix" + def delegate = new TestCompleter(["create-app", "create-plugin"]) + def handler = new CandidateListCompletionHandler(delegate) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "create-" + } + def reader = Mock(LineReader) + def buffer = Mock(org.jline.reader.Buffer) + reader.getBuffer() >> buffer + + when: "completion is performed" + handler.complete(reader, parsedLine, candidates) + + then: "buffer is unchanged" + 0 * buffer.write(_) + 0 * reader.callWidget(_) + } + + def "Handler with null delegate returns no candidates"() { + given: "a handler without delegate" + def handler = new CandidateListCompletionHandler() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "completion is performed" + handler.complete(null, parsedLine, candidates) + + then: "no candidates are added" + candidates.isEmpty() + } + + // Tests for getUnambiguousCompletions static method + + def "getUnambiguousCompletions returns null for null input"() { + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(null) == null + } + + def "getUnambiguousCompletions returns null for empty list"() { + expect: + CandidateListCompletionHandler.getUnambiguousCompletions([]) == null + } + + def "getUnambiguousCompletions returns full string for single candidate"() { + given: + def candidates = [new Candidate("foobar")] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "foobar" + } + + @Unroll + def "getUnambiguousCompletions finds common prefix '#expected' for #description"() { + given: + def candidates = values.collect { new Candidate(it) } + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == expected + + where: + values | expected | description + ["foobar", "foobaz", "foobuz"] | "foob" | "3 strings with common 'foob' prefix" + ["apple", "apricot", "application"] | "ap" | "3 strings with common 'ap' prefix" + ["test", "testing", "tested"] | "test" | "3 strings with common 'test' prefix" + ["abc", "def", "ghi"] | "" | "strings with no common prefix" + ["same", "same"] | "same" | "identical strings" + ["a", "ab", "abc"] | "a" | "incrementally longer strings" + } + + def "getUnambiguousCompletions handles single character candidates"() { + given: + def candidates = [new Candidate("a"), new Candidate("b")] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "" + } + + def "getUnambiguousCompletions handles empty string candidates"() { + given: + def candidates = [new Candidate(""), new Candidate("")] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "" + } + + def "getUnambiguousCompletions with mixed empty and non-empty"() { + given: + def candidates = [new Candidate(""), new Candidate("foo")] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "" + } + + def "getUnambiguousCompletions handles special characters"() { + given: + def candidates = [ + new Candidate("--verbose"), + new Candidate("--version"), + new Candidate("--verify") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "--ver" + } + + def "getUnambiguousCompletions handles paths"() { + given: + def candidates = [ + new Candidate("/usr/local/bin"), + new Candidate("/usr/local/lib"), + new Candidate("/usr/local/share") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "/usr/local/" + } + + def "getUnambiguousCompletions handles Grails commands"() { + given: + def candidates = [ + new Candidate("create-app"), + new Candidate("create-plugin"), + new Candidate("create-domain-class") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "create-" + } + + def "getUnambiguousCompletions handles case-sensitive matching"() { + given: + def candidates = [ + new Candidate("Test"), + new Candidate("test") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "" + } + + def "getUnambiguousCompletions handles unicode characters"() { + given: + def candidates = [ + new Candidate("café"), + new Candidate("caféine") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "café" + } + + def "getUnambiguousCompletions handles numbers"() { + given: + def candidates = [ + new Candidate("123"), + new Candidate("1234"), + new Candidate("12345") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "123" + } + + def "getUnambiguousCompletions handles whitespace"() { + given: + def candidates = [ + new Candidate("hello world"), + new Candidate("hello there") + ] + + expect: + CandidateListCompletionHandler.getUnambiguousCompletions(candidates) == "hello " + } + + def "Handler implements Completer interface"() { + expect: + new CandidateListCompletionHandler() instanceof Completer + } + + def "Handler can be composed with other completers"() { + given: "multiple handlers" + def handler1 = new CandidateListCompletionHandler(new TestCompleter(["opt1"])) + def handler2 = new CandidateListCompletionHandler(new TestCompleter(["opt2"])) + + and: "aggregating them" + def candidates = [] + def parsedLine = Stub(ParsedLine) { word() >> "" } + + when: "completing with both" + handler1.complete(null, parsedLine, candidates) + handler2.complete(null, parsedLine, candidates) + + then: "both contributions are present" + candidates.size() == 2 + candidates*.value().containsAll(["opt1", "opt2"]) + } + + /** + * Simple test completer for testing. + */ + static class TestCompleter implements Completer { + List completions + + TestCompleter(List completions) { + this.completions = completions + } + + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + completions.each { candidates.add(new Candidate(it)) } + } + } +} diff --git a/grails-console/build.gradle b/grails-console/build.gradle index 4259dec9eef..324c7828304 100644 --- a/grails-console/build.gradle +++ b/grails-console/build.gradle @@ -41,6 +41,8 @@ dependencies { api 'org.apache.groovy:groovy-swing' api 'org.apache.groovy:groovy-groovysh' implementation 'org.fusesource.jansi:jansi' + implementation 'org.jline:jline' + // TODO: Remove jline:jline (JLine 2) when upgrading to Groovy 5 (groovy-groovysh 5.x uses JLine 3) implementation 'jline:jline' implementation 'net.java.dev.jna:jna' diff --git a/grails-controllers/build.gradle b/grails-controllers/build.gradle index 916444c72dd..404ebab570d 100644 --- a/grails-controllers/build.gradle +++ b/grails-controllers/build.gradle @@ -47,7 +47,7 @@ dependencies { runtimeOnly project(':grails-i18n') - testRuntimeOnly 'jline:jline' + testRuntimeOnly 'org.jline:jline' testRuntimeOnly 'org.fusesource.jansi:jansi' compileOnly 'jakarta.servlet:jakarta.servlet-api' diff --git a/grails-forge/gradle.properties b/grails-forge/gradle.properties index e0adeeda0be..14532bb2db3 100644 --- a/grails-forge/gradle.properties +++ b/grails-forge/gradle.properties @@ -35,7 +35,7 @@ groovyVersion=3.0.25 jacksonDatabindVersion=2.18.3 jakartaInjectVersion=1.0.5 # match the jansi version in grails-bom -jansiVersion=1.18 +jansiVersion=2.4.2 javaDiffUtils=4.15 jgitVersion=6.10.0.202406032230-r logbackClassicVersion=1.5.17 diff --git a/grails-gradle/gradle/docs-config.gradle b/grails-gradle/gradle/docs-config.gradle index be2aaf4f1c9..685a00fd5d1 100644 --- a/grails-gradle/gradle/docs-config.gradle +++ b/grails-gradle/gradle/docs-config.gradle @@ -29,6 +29,7 @@ configurations.register('documentation') { dependencies { add('documentation', platform(project(':grails-gradle-bom'))) add('documentation', 'org.fusesource.jansi:jansi') + // TODO: Remove jline:jline (JLine 2) when upgrading to Groovy 5 (groovy-groovysh 5.x uses JLine 3) add('documentation', 'jline:jline') add('documentation', 'com.github.javaparser:javaparser-core') add('documentation', "org.apache.groovy:groovy:${bomDependencyVersions['groovy.version']}") diff --git a/grails-gradle/model/build.gradle b/grails-gradle/model/build.gradle index 7df57487ca4..13203d7e3d7 100644 --- a/grails-gradle/model/build.gradle +++ b/grails-gradle/model/build.gradle @@ -58,7 +58,8 @@ dependencies { // impl: org.springframework.boot.env.YamlPropertySourceLoader } - compileOnly 'jline:jline' // for profile compilation + compileOnly 'org.jline:jline' // for profile compilation + compileOnly 'org.fusesource.jansi:jansi' api 'org.yaml:snakeyaml' diff --git a/grails-gradle/model/src/main/groovy/org/grails/cli/profile/CommandDescription.groovy b/grails-gradle/model/src/main/groovy/org/grails/cli/profile/CommandDescription.groovy index ddef7afb506..bf56218e0c3 100644 --- a/grails-gradle/model/src/main/groovy/org/grails/cli/profile/CommandDescription.groovy +++ b/grails-gradle/model/src/main/groovy/org/grails/cli/profile/CommandDescription.groovy @@ -22,7 +22,7 @@ import groovy.transform.Canonical import groovy.transform.CompileDynamic import groovy.transform.CompileStatic -import jline.console.completer.Completer +import org.jline.reader.Completer /** * Describes a {@link Command} diff --git a/grails-scaffolding/build.gradle b/grails-scaffolding/build.gradle index eb76fa3d09b..ec73e3d58f4 100644 --- a/grails-scaffolding/build.gradle +++ b/grails-scaffolding/build.gradle @@ -41,7 +41,8 @@ dependencies { api project(':grails-fields') api project(':grails-rest-transforms') - compileOnly 'jline:jline' + compileOnly 'org.jline:jline' + compileOnly 'org.fusesource.jansi:jansi' testImplementation 'org.spockframework:spock-core' testImplementation project(':grails-web-gsp') diff --git a/grails-shell-cli/build.gradle b/grails-shell-cli/build.gradle index b53f35a2a50..df7856c4cee 100644 --- a/grails-shell-cli/build.gradle +++ b/grails-shell-cli/build.gradle @@ -54,7 +54,7 @@ dependencies { api 'org.apache.grails.gradle:grails-gradle-model' api 'org.apache.ant:ant' api 'org.fusesource.jansi:jansi' - api 'jline:jline' + api 'org.jline:jline' api "org.gradle:gradle-tooling-api:$gradleToolingApiVersion" compileOnly 'org.springframework.boot:spring-boot' diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy index 0cd30756e24..d8ee961cf12 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy @@ -25,11 +25,11 @@ import java.util.concurrent.Future import groovy.transform.Canonical import groovy.transform.CompileStatic -import jline.UnixTerminal -import jline.console.UserInterruptException -import jline.console.completer.ArgumentCompleter -import jline.console.completer.Completer -import jline.internal.NonBlockingInputStream +import org.jline.reader.Completer +import org.jline.reader.EndOfFileException +import org.jline.reader.UserInterruptException +import org.jline.reader.impl.completer.ArgumentCompleter +import org.jline.terminal.Terminal import org.gradle.tooling.BuildActionExecuter import org.gradle.tooling.BuildCancelledException import org.gradle.tooling.ProjectConnection @@ -43,6 +43,7 @@ import grails.util.Environment import org.grails.build.parsing.CommandLine import org.grails.build.parsing.CommandLineParser import org.grails.build.parsing.DefaultCommandLine +import org.grails.build.interactive.CandidateListCompletionHandler import org.grails.cli.gradle.ClasspathBuildAction import org.grails.cli.gradle.GradleAsyncInvoker import org.grails.cli.gradle.cache.MapReadingCachedGradleOperation @@ -80,8 +81,6 @@ class GrailsCli { static final String ARG_SPLIT_PATTERN = /(? cmd.name } - console.reader.addCompleter(new StringsCompleter(commandNames)) - console.reader.addCompleter(new CommandCompleter(CommandRegistry.instance.findCommands(profileRepository))) + console.addCompleter(new StringsCompleter(commandNames)) + console.addCompleter(new CommandCompleter(CommandRegistry.instance.findCommands(profileRepository))) profile = [handleCommand: { ExecutionContext context -> def cl = context.commandLine @@ -394,8 +393,6 @@ class GrailsCli { System.setProperty(Environment.INTERACTIVE_MODE_ENABLED, 'true') GrailsConsole console = projectContext.console - def consoleReader = console.reader - consoleReader.setHandleUserInterrupt(true) def completers = aggregateCompleter.getCompleters() console.resetCompleters() @@ -405,7 +402,7 @@ class GrailsCli { ) completers.addAll((profile.getCompleters(projectContext) ?: []) as Collection) - consoleReader.addCompleter(aggregateCompleter) + console.addCompleter(new CandidateListCompletionHandler(aggregateCompleter)) return console } @@ -420,7 +417,6 @@ class GrailsCli { } private void interactiveModeLoop(GrailsConsole console, ExecutorService commandExecutor) { - NonBlockingInputStream nonBlockingInput = (NonBlockingInputStream) console.reader.getInput() interactiveModeActive = true boolean firstRun = true while (keepRunning) { @@ -434,49 +430,50 @@ class GrailsCli { // CTRL-D was pressed, exit interactive mode exitInteractiveMode() } else if (commandLine.trim()) { - if (nonBlockingInput.isNonBlockingEnabled()) { - handleCommandWithCancellationSupport(console, commandLine, commandExecutor, nonBlockingInput) - } else { - handleCommand(cliParser.parseString(commandLine)) - } + handleCommandWithCancellationSupport(console, commandLine, commandExecutor) } } catch (BuildCancelledException cancelledException) { console.updateStatus('Build stopped.') } catch (UserInterruptException e) { exitInteractiveMode() + } catch (EndOfFileException e) { + exitInteractiveMode() } catch (Throwable e) { console.error("Caught exception ${e.message}", e) } } } - private Boolean handleCommandWithCancellationSupport(GrailsConsole console, String commandLine, ExecutorService commandExecutor, NonBlockingInputStream nonBlockingInput) { + private Boolean handleCommandWithCancellationSupport(GrailsConsole console, String commandLine, ExecutorService commandExecutor) { ExecutionContext executionContext = createExecutionContext(cliParser.parseString(commandLine)) Future commandFuture = commandExecutor.submit({ handleCommand(executionContext) } as Callable) - def terminal = console.reader.terminal - if (terminal instanceof UnixTerminal) { - ((UnixTerminal) terminal).disableInterruptCharacter() - } + + Terminal.SignalHandler previousHandler = null try { - while (!commandFuture.done) { - if (nonBlockingInput.nonBlockingEnabled) { - int peeked = nonBlockingInput.peek(100L) - if (peeked > 0) { - // read peeked character from buffer - nonBlockingInput.read(1L) - if (peeked == KEYPRESS_CTRL_C || peeked == KEYPRESS_ESC) { - executionContext.console.log(' ') - executionContext.console.updateStatus('Stopping build. Please wait...') - executionContext.cancel() - } - } + // Use JLine 3's signal handling for CTRL+C instead of polling input + if (console?.terminal) { + previousHandler = console.terminal.handle(Terminal.Signal.INT) { signal -> + executionContext.console.log(' ') + executionContext.console.updateStatus('Stopping build. Please wait...') + executionContext.cancel() } } + + // Wait for command completion + while (!commandFuture.done) { + Thread.sleep(100) + } + } catch (InterruptedException e) { + executionContext.console.log(' ') + executionContext.console.updateStatus('Stopping build. Please wait...') + executionContext.cancel() } finally { - if (terminal instanceof UnixTerminal) { - ((UnixTerminal) terminal).enableInterruptCharacter() + // Restore previous signal handler + if (previousHandler != null && console?.terminal) { + console.terminal.handle(Terminal.Signal.INT, previousHandler) } } + if (!commandFuture.isCancelled()) { try { return commandFuture.get() @@ -625,17 +622,29 @@ class GrailsCli { protected Boolean bang(ExecutionContext context) { def console = context.console - def history = console.reader.history + def history = console.history - //move one step back to ! - history.previous() + if (history == null || history.size() == 0) { + console.error('! not valid. Can not repeat without history') + return false + } + + // Get previous command from history + def historyIterator = history.reverseIterator() + if (!historyIterator.hasNext()) { + console.error('! not valid. Can not repeat without history') + return false + } - if (!history.previous()) { + // Skip the current '!' command + historyIterator.next() + + if (!historyIterator.hasNext()) { console.error('! not valid. Can not repeat without history') + return false } - //another step to previous command - String historicalCommand = history.current() + String historicalCommand = historyIterator.next().line() if (historicalCommand.startsWith('!')) { console.error("Can not repeat command: $historicalCommand") } else { diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/commands/GradleCommand.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/commands/GradleCommand.groovy index daa4708ff02..397a7e5c625 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/commands/GradleCommand.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/gradle/commands/GradleCommand.groovy @@ -20,7 +20,10 @@ package org.grails.cli.gradle.commands import groovy.transform.CompileStatic -import jline.console.completer.Completer +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine import org.gradle.tooling.BuildLauncher import org.grails.cli.gradle.GradleUtil @@ -65,13 +68,12 @@ class GradleCommand implements ProjectCommand, Completer, ProjectContextAware { } @Override - int complete(String buffer, int cursor, List candidates) { + void complete(LineReader reader, ParsedLine line, List candidates) { initializeCompleter() - if (completer) - return completer.complete(buffer, cursor, candidates) - else - return cursor + if (completer) { + completer.complete(reader, line, candidates) + } } private void initializeCompleter() { diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/ClosureCompleter.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/ClosureCompleter.groovy index 7ccbb5280eb..a73248e82e8 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/ClosureCompleter.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/ClosureCompleter.groovy @@ -20,7 +20,10 @@ package org.grails.cli.interactive.completers import groovy.transform.CompileStatic -import jline.console.completer.Completer +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine /** * @author Graeme Rocher @@ -38,13 +41,13 @@ class ClosureCompleter implements Completer { Completer getCompleter() { if (completer == null) { - completer = new jline.console.completer.StringsCompleter(closure.call()) + completer = new StringsCompleter(closure.call()) } completer } @Override - int complete(String buffer, int cursor, List candidates) { - getCompleter().complete(buffer, cursor, candidates) + void complete(LineReader reader, ParsedLine line, List candidates) { + getCompleter().complete(reader, line, candidates) } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletor.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletor.groovy index 68f811bf251..3d8c7e72151 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletor.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletor.groovy @@ -18,10 +18,13 @@ */ package org.grails.cli.interactive.completers -import jline.console.completer.FileNameCompleter +import org.jline.builtins.Completers.FileNameCompleter +import org.jline.reader.Candidate +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine /** - * JLine Completor that does file path matching like FileNameCompletor, + * JLine Completor that does file path matching like FileNameCompleter, * but in addition it escapes whitespace in completions with the '\' * character. * @@ -31,19 +34,22 @@ import jline.console.completer.FileNameCompleter class EscapingFileNameCompletor extends FileNameCompleter { /** - *

Gets FileNameCompletor to create a list of candidates and then + *

Gets FileNameCompleter to create a list of candidates and then * inserts '\' before any whitespace characters in each of the candidates. * If a candidate ends in a whitespace character, then that is not * escaped.

*/ - int complete(String buffer, int cursor, List candidates) { - int retval = super.complete(buffer, cursor, candidates) + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + List tempCandidates = [] + super.complete(reader, line, tempCandidates) - int count = candidates.size() - for (int i = 0; i < count; i++) { - candidates[i] = candidates[i].replaceAll(/(\s)(?!$)/, '\\\\$1') + for (Candidate candidate : tempCandidates) { + String value = candidate.value() + // Escape whitespace in the value, except for trailing whitespace + String escapedValue = value.replaceAll(/(\s)(?!$)/, '\\\\$1') + candidates.add(new Candidate(escapedValue, candidate.displ(), candidate.group(), + candidate.descr(), candidate.suffix(), candidate.key(), candidate.complete())) } - - return retval } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/RegexCompletor.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/RegexCompletor.groovy index abba20c3685..f8486b89f79 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/RegexCompletor.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/RegexCompletor.groovy @@ -20,7 +20,10 @@ package org.grails.cli.interactive.completers import java.util.regex.Pattern -import jline.console.completer.Completer +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine /** * JLine Completor that accepts a string if it matches a given regular @@ -44,17 +47,16 @@ class RegexCompletor implements Completer { /** *

Check whether the whole buffer matches the configured pattern. * If it does, the buffer is added to the candidates list - * (which indicates acceptance of the buffer string) and returns 0, - * i.e. the start of the buffer. This mimics the behaviour of SimpleCompletor. + * (which indicates acceptance of the buffer string). *

- *

If the buffer doesn't match the configured pattern, this returns - * -1 and the candidates list is left empty.

+ *

If the buffer doesn't match the configured pattern, the + * candidates list is left empty.

*/ - int complete(String buffer, int cursor, List candidates) { + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + String buffer = line.word() if (buffer ==~ pattern) { - candidates << buffer - return 0 + candidates.add(new Candidate(buffer)) } - else return -1 } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletor.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletor.groovy index 34829f47c0d..cffa8f29ef9 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletor.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletor.groovy @@ -18,8 +18,10 @@ */ package org.grails.cli.interactive.completers -import jline.console.completer.Completer -import jline.console.completer.StringsCompleter +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine /** * JLine Completor that mixes a fixed set of options with file path matches. @@ -30,8 +32,8 @@ import jline.console.completer.StringsCompleter */ class SimpleOrFileNameCompletor implements Completer { - private simpleCompletor - private fileNameCompletor + private Completer simpleCompletor + private Completer fileNameCompletor SimpleOrFileNameCompletor(List fixedOptions) { this(fixedOptions as String[]) @@ -42,20 +44,17 @@ class SimpleOrFileNameCompletor implements Completer { fileNameCompletor = new EscapingFileNameCompletor() } - int complete(String buffer, int cursor, List candidates) { + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { // Try the simple completor first... - def retval = simpleCompletor.complete(buffer, cursor, candidates) + List simpleCandidates = [] + simpleCompletor.complete(reader, line, simpleCandidates) + candidates.addAll(simpleCandidates) // ...and then the file path completor. By using the given candidate // list with both completors we aggregate the results automatically. - def fileRetval = fileNameCompletor.complete(buffer, cursor, candidates) - - // If the simple completor has matched, we return its value, otherwise - // we return whatever the file path matcher returned. This ensures that - // both simple completor and file path completor candidates appear - // correctly in the command prompt. If neither competors have matches, - // we of course return -1. - if (retval == -1) retval = fileRetval - return candidates ? retval : -1 + List fileCandidates = [] + fileNameCompletor.complete(reader, line, fileCandidates) + candidates.addAll(fileCandidates) } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleter.java b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleter.java index becaf7f130d..73a9b312652 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleter.java +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleter.java @@ -21,24 +21,23 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.LinkedList; +import java.util.Comparator; import java.util.List; -import java.util.SortedSet; -import java.util.TreeSet; +import java.util.Objects; -import jline.console.completer.Completer; - -import static jline.internal.Preconditions.checkNotNull; +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; /** - * Copied from jline AggregateCompleter - * - * sorts aggregated completions + * An aggregate completer that sorts completion candidates. * + * @author Graeme Rocher + * @since 3.0 */ -public class SortedAggregateCompleter - implements Completer -{ +public class SortedAggregateCompleter implements Completer { + private final List completers = new ArrayList<>(); public SortedAggregateCompleter() { @@ -52,7 +51,7 @@ public SortedAggregateCompleter() { * @param completers the collection of completers */ public SortedAggregateCompleter(final Collection completers) { - checkNotNull(completers); + Objects.requireNonNull(completers); this.completers.addAll(completers); } @@ -77,40 +76,24 @@ public Collection getCompleters() { /** * Perform a completion operation across all aggregated completers. - * - * @see Completer#complete(String, int, java.util.List) - * @return the highest completion return value from all completers */ - public int complete(final String buffer, final int cursor, final List candidates) { - // buffer could be null - checkNotNull(candidates); + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { + Objects.requireNonNull(candidates); - List completions = new ArrayList<>(completers.size()); + List allCandidates = new ArrayList<>(); - // Run each completer, saving its completion results - int max = -1; + // Run each completer, collecting candidates for (Completer completer : completers) { - Completion completion = new Completion(candidates); - completion.complete(completer, buffer, cursor); - - // Compute the max cursor position - max = Math.max(max, completion.cursor); - - completions.add(completion); + List completerCandidates = new ArrayList<>(); + completer.complete(reader, line, completerCandidates); + allCandidates.addAll(completerCandidates); } - SortedSet allCandidates = new TreeSet<>(); - - // Append candidates from completions which have the same cursor position as max - for (Completion completion : completions) { - if (completion.cursor == max) { - allCandidates.addAll(completion.candidates); - } - } + // Sort the candidates by their value + allCandidates.sort(Comparator.comparing(Candidate::value)); candidates.addAll(allCandidates); - - return max; } /** @@ -122,21 +105,4 @@ public String toString() { "completers=" + completers + '}'; } - - private class Completion - { - public final List candidates; - - public int cursor; - - public Completion(final List candidates) { - checkNotNull(candidates); - this.candidates = new LinkedList<>(candidates); - } - - public void complete(final Completer completer, final String buffer, final int cursor) { - checkNotNull(completer); - this.cursor = completer.complete(buffer, cursor, candidates); - } - } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/StringsCompleter.java b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/StringsCompleter.java index 06adbc60154..db83a20b07b 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/StringsCompleter.java +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/interactive/completers/StringsCompleter.java @@ -21,12 +21,14 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Objects; import java.util.SortedSet; import java.util.TreeSet; -import jline.console.completer.Completer; - -import static jline.internal.Preconditions.checkNotNull; +import org.jline.reader.Candidate; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; /** * A completer that completes based on a collection of Strings @@ -34,9 +36,8 @@ * @author Graeme Rocher * @since 3.0 */ -public class StringsCompleter - implements Completer -{ +public class StringsCompleter implements Completer { + private SortedSet strings = new TreeSet<>(); public StringsCompleter() { @@ -44,7 +45,7 @@ public StringsCompleter() { } public StringsCompleter(final Collection strings) { - checkNotNull(strings); + Objects.requireNonNull(strings); getStrings().addAll(strings); } @@ -60,23 +61,23 @@ public void setStrings(SortedSet strings) { this.strings = strings; } - public int complete(final String buffer, final int cursor, final List candidates) { - // buffer could be null - checkNotNull(candidates); + @Override + public void complete(LineReader reader, ParsedLine line, List candidates) { + Objects.requireNonNull(candidates); - if (buffer == null) { - candidates.addAll(getStrings()); - } - else { + String buffer = line.word(); + + if (buffer == null || buffer.isEmpty()) { + for (String string : getStrings()) { + candidates.add(new Candidate(string)); + } + } else { for (String match : getStrings().tailSet(buffer)) { if (!match.startsWith(buffer)) { break; } - - candidates.add(match); + candidates.add(new Candidate(match)); } } - - return candidates.isEmpty() ? -1 : 0; } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/AbstractProfile.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/AbstractProfile.groovy index 6b67ce2e621..dd76a780a26 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/AbstractProfile.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/AbstractProfile.groovy @@ -19,8 +19,8 @@ package org.grails.cli.profile import groovy.transform.CompileStatic import groovy.transform.ToString -import jline.console.completer.ArgumentCompleter -import jline.console.completer.Completer +import org.jline.reader.Completer +import org.jline.reader.impl.completer.ArgumentCompleter import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.graph.Dependency import org.eclipse.aether.graph.Exclusion diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/Profile.java b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/Profile.java index fe395824b18..35feeb2a947 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/Profile.java +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/Profile.java @@ -22,8 +22,8 @@ import java.util.List; import java.util.Set; -import jline.console.completer.Completer; import org.eclipse.aether.graph.Dependency; +import org.jline.reader.Completer; import org.grails.config.NavigableMap; import org.grails.io.support.Resource; @@ -129,7 +129,7 @@ public interface Profile { /** * The profile completers * @param context The {@link org.grails.cli.profile.ProjectContext} instance - * @return An {@link java.lang.Iterable} of {@link jline.console.completer.Completer} instances + * @return An {@link java.lang.Iterable} of {@link org.jline.reader.Completer} instances */ Iterable getCompleters(ProjectContext context); diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/ArgumentCompletingCommand.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/ArgumentCompletingCommand.groovy index 692aed823c0..2177e1565a6 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/ArgumentCompletingCommand.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/ArgumentCompletingCommand.groovy @@ -19,7 +19,10 @@ package org.grails.cli.profile.commands -import jline.console.completer.Completer +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine import org.grails.build.parsing.CommandLine import org.grails.build.parsing.CommandLineParser @@ -34,13 +37,13 @@ abstract class ArgumentCompletingCommand implements Command, Completer { CommandLineParser cliParser = new CommandLineParser() @Override - final int complete(String buffer, int cursor, List candidates) { + final void complete(LineReader reader, ParsedLine line, List candidates) { def desc = getDescription() - def commandLine = cliParser.parseString(buffer) - return complete(commandLine, desc, candidates, cursor) + def commandLine = cliParser.parseString(line.line()) + complete(commandLine, desc, candidates) } - protected int complete(CommandLine commandLine, CommandDescription desc, List candidates, int cursor) { + protected void complete(CommandLine commandLine, CommandDescription desc, List candidates) { def invalidOptions = commandLine.undeclaredOptions.keySet().findAll { String str -> desc.getFlag(str.trim()) == null } @@ -54,15 +57,14 @@ abstract class ArgumentCompletingCommand implements Command, Completer { if (lastOption) { def lastArg = lastOption.key if (arg.name.startsWith(lastArg)) { - candidates.add("${argName.substring(lastArg.length())} ".toString()) + candidates.add(new Candidate("$flag ".toString())) } else if (!invalidOptions) { - candidates.add("$flag ".toString()) + candidates.add(new Candidate("$flag ".toString())) } } else { - candidates.add("$flag ".toString()) + candidates.add(new Candidate("$flag ".toString())) } } } - return cursor } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CommandCompleter.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CommandCompleter.groovy index 1520a6caf64..b590e2fe074 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CommandCompleter.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CommandCompleter.groovy @@ -18,7 +18,10 @@ */ package org.grails.cli.profile.commands -import jline.console.completer.Completer +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine import org.grails.cli.profile.Command @@ -37,7 +40,8 @@ class CommandCompleter implements Completer { } @Override - int complete(String buffer, int cursor, List candidates) { + void complete(LineReader reader, ParsedLine line, List candidates) { + String buffer = line.line() def cmd = commands.find() { def trimmed = buffer.trim() if (trimmed.split(/\s/).size() > 1) { @@ -48,8 +52,7 @@ class CommandCompleter implements Completer { } } if (cmd instanceof Completer) { - return ((Completer) cmd).complete(buffer, cursor, candidates) + ((Completer) cmd).complete(reader, line, candidates) } - return cursor } } diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CreateAppCommand.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CreateAppCommand.groovy index 5cb779263a2..be05ee4254c 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CreateAppCommand.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/CreateAppCommand.groovy @@ -101,7 +101,7 @@ class CreateAppCommand extends ArgumentCompletingCommand implements ProfileRepos } @Override - protected int complete(CommandLine commandLine, CommandDescription desc, List candidates, int cursor) { + protected void complete(CommandLine commandLine, CommandDescription desc, List candidates) { def lastOption = commandLine.lastOption() if (lastOption != null) { // if value == true it means no profile is specified and only the flag is present @@ -109,47 +109,43 @@ class CreateAppCommand extends ArgumentCompletingCommand implements ProfileRepos if (lastOption.key == PROFILE_FLAG) { def val = lastOption.value if (val == true) { - candidates.addAll(profileNames) - return cursor + profileNames.each { candidates.add(new org.jline.reader.Candidate(it)) } + return } else if (!profileNames.contains(val)) { def valStr = val.toString() def candidateProfiles = profileNames.findAll { String pn -> pn.startsWith(valStr) - }.collect() { String pn -> - "${pn.substring(valStr.size())} ".toString() } - candidates.addAll(candidateProfiles) - return cursor + candidateProfiles.each { candidates.add(new org.jline.reader.Candidate(it)) } + return } } else if (lastOption.key == FEATURES_FLAG) { def val = lastOption.value def profile = profileRepository.getProfile(commandLine.hasOption(PROFILE_FLAG) ? commandLine.optionValue(PROFILE_FLAG).toString() : getDefaultProfile()) def featureNames = profile.features.collect() { Feature f -> f.name } if (val == true) { - candidates.addAll(featureNames) - return cursor + featureNames.each { candidates.add(new org.jline.reader.Candidate(it)) } + return } else if (!profileNames.contains(val)) { def valStr = val.toString() if (valStr.endsWith(',')) { def specified = valStr.split(',') - candidates.addAll(featureNames.findAll { String f -> + featureNames.findAll { String f -> !specified.contains(f) - }) - return cursor + }.each { candidates.add(new org.jline.reader.Candidate(it)) } + return } def candidatesFeatures = featureNames.findAll { String pn -> pn.startsWith(valStr) - }.collect() { String pn -> - "${pn.substring(valStr.size())} ".toString() } - candidates.addAll(candidatesFeatures) - return cursor + candidatesFeatures.each { candidates.add(new org.jline.reader.Candidate(it)) } + return } } } - return super.complete(commandLine, desc, candidates, cursor) + super.complete(commandLine, desc, candidates) } protected File getDestinationDirectory(File srcFile) { diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/DefaultMultiStepCommand.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/DefaultMultiStepCommand.groovy index c5f29586dea..30f5d06e49f 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/DefaultMultiStepCommand.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/DefaultMultiStepCommand.groovy @@ -20,7 +20,7 @@ package org.grails.cli.profile.commands import groovy.transform.CompileDynamic -import jline.console.completer.Completer +import org.jline.reader.Completer import grails.build.logging.GrailsConsole import org.grails.cli.profile.AbstractStep diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/HelpCommand.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/HelpCommand.groovy index 698a3cb4205..e05ebcd309f 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/HelpCommand.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/HelpCommand.groovy @@ -16,7 +16,10 @@ */ package org.grails.cli.profile.commands -import jline.console.completer.Completer +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine import org.grails.build.parsing.CommandLine import org.grails.build.parsing.CommandLineParser @@ -113,20 +116,15 @@ grails [environment]* [target] [arguments]*' } @Override - int complete(String buffer, int cursor, List candidates) { + void complete(LineReader reader, ParsedLine line, List candidates) { def allCommands = findAllCommands().collect() { CommandDescription desc -> desc.name } + String buffer = line.word() for (cmd in allCommands) { - if (buffer) { - if (cmd.startsWith(buffer)) { - candidates << cmd.substring(buffer.size()) - } - } - else { - candidates << cmd + if (!buffer || cmd.startsWith(buffer)) { + candidates.add(new Candidate(cmd)) } } - return cursor } protected Collection findAllCommands() { diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/OpenCommand.groovy b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/OpenCommand.groovy index 6ebb3f971de..5bff06d6e87 100644 --- a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/OpenCommand.groovy +++ b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/OpenCommand.groovy @@ -23,8 +23,11 @@ import java.awt.Desktop import groovy.transform.CompileStatic -import jline.console.completer.Completer -import jline.console.completer.FileNameCompleter +import org.jline.builtins.Completers.FileNameCompleter +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine import org.grails.cli.profile.CommandDescription import org.grails.cli.profile.ExecutionContext @@ -71,7 +74,7 @@ class OpenCommand implements ProjectCommand, Completer { } @Override - int complete(String buffer, int cursor, List candidates) { - return new FileNameCompleter().complete(buffer, cursor, candidates) + void complete(LineReader reader, ParsedLine line, List candidates) { + new FileNameCompleter().complete(reader, line, candidates) } } diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/TestTerminal.java b/grails-shell-cli/src/test/groovy/org/grails/cli/TestTerminal.java index f72eb8ae2aa..a4b9fd8d77c 100644 --- a/grails-shell-cli/src/test/groovy/org/grails/cli/TestTerminal.java +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/TestTerminal.java @@ -18,12 +18,29 @@ */ package org.grails.cli; -import jline.TerminalSupport; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; -public class TestTerminal extends TerminalSupport { - public TestTerminal() { - super(true); - setAnsiSupported(false); - setEchoEnabled(false); +import java.io.IOException; + +/** + * A terminal for testing purposes that creates a dumb terminal. + */ +public class TestTerminal { + + private final Terminal terminal; + + public TestTerminal() throws IOException { + this.terminal = TerminalBuilder.builder() + .dumb(true) + .build(); + } + + public Terminal getTerminal() { + return terminal; + } + + public void close() throws IOException { + terminal.close(); } } diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/ClosureCompleterSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/ClosureCompleterSpec.groovy new file mode 100644 index 00000000000..58963bba70d --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/ClosureCompleterSpec.groovy @@ -0,0 +1,149 @@ +/* + * 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 + * + * https://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.grails.cli.interactive.completers + +import org.jline.reader.Candidate +import org.jline.reader.ParsedLine +import spock.lang.Specification + +class ClosureCompleterSpec extends Specification { + + def "Closure completer returns values from closure"() { + given: "a closure completer with a simple closure" + def completer = new ClosureCompleter({ ["apple", "banana", "cherry"] }) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "candidates from the closure are returned" + candidates.size() == 3 + candidates*.value().containsAll(["apple", "banana", "cherry"]) + } + + def "Closure completer lazily evaluates the closure"() { + given: "a closure that tracks invocations" + def invocationCount = 0 + def completer = new ClosureCompleter({ + invocationCount++ + ["value"] + }) + + expect: "the closure has not been invoked yet" + invocationCount == 0 + + when: "the completer is accessed" + completer.getCompleter() + + then: "the closure is invoked once" + invocationCount == 1 + + when: "the completer is accessed again" + completer.getCompleter() + + then: "the closure is not invoked again (cached)" + invocationCount == 1 + } + + def "Closure completer filters by prefix"() { + given: "a closure completer with multiple values" + def completer = new ClosureCompleter({ ["create-app", "create-plugin", "run-app", "test-app"] }) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "create" + } + + when: "the completer is invoked with a prefix" + completer.complete(null, parsedLine, candidates) + + then: "only matching candidates are returned" + candidates.size() == 2 + candidates*.value() as Set == ["create-app", "create-plugin"] as Set + } + + def "Closure completer works with empty closure result"() { + given: "a closure completer that returns empty collection" + def completer = new ClosureCompleter({ [] }) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "no candidates are returned" + candidates.isEmpty() + } + + def "Closure completer uses StringsCompleter internally"() { + given: "a closure completer" + def completer = new ClosureCompleter({ ["test"] }) + + when: "the internal completer is retrieved" + def internalCompleter = completer.getCompleter() + + then: "it is a StringsCompleter" + internalCompleter instanceof StringsCompleter + } + + def "Closure completer can use dynamic values"() { + given: "a list that changes over time" + def dynamicList = ["initial"] + def completer = new ClosureCompleter({ dynamicList.clone() }) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is first invoked" + completer.complete(null, parsedLine, candidates) + + then: "initial values are returned" + candidates.size() == 1 + candidates[0].value() == "initial" + + when: "the list is modified and completer is invoked again" + dynamicList.add("added") + // Note: The completer caches the result, so new values won't be picked up + candidates.clear() + completer.complete(null, parsedLine, candidates) + + then: "still returns cached values (closure evaluated only once)" + candidates.size() == 1 + } + + def "Closure completer works with Set return type"() { + given: "a closure that returns a Set" + def completer = new ClosureCompleter({ ["one", "two", "three"] as Set }) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "all values are available as candidates" + candidates.size() == 3 + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletorSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletorSpec.groovy new file mode 100644 index 00000000000..a6994af6530 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/EscapingFileNameCompletorSpec.groovy @@ -0,0 +1,244 @@ +/* + * 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 + * + * https://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.grails.cli.interactive.completers + +import org.jline.builtins.Completers.FileNameCompleter +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.LineReaderBuilder +import org.jline.reader.ParsedLine +import org.jline.terminal.Terminal +import org.jline.terminal.TerminalBuilder +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path + +/** + * Tests for EscapingFileNameCompletor which extends JLine 3's FileNameCompleter + * and escapes whitespace in file path completions. + */ +class EscapingFileNameCompletorSpec extends Specification { + + @TempDir + Path tempDir + + Terminal terminal + LineReader reader + + def setup() { + terminal = TerminalBuilder.builder().dumb(true).build() + reader = LineReaderBuilder.builder().terminal(terminal).build() + } + + def cleanup() { + terminal?.close() + } + + def "Completor can be instantiated"() { + when: "a new completor is created" + def completor = new EscapingFileNameCompletor() + + then: "it is created successfully" + completor != null + } + + def "Completor extends FileNameCompleter"() { + when: "a new completor is created" + def completor = new EscapingFileNameCompletor() + + then: "it extends FileNameCompleter" + completor instanceof FileNameCompleter + } + + def "Completor implements Completer interface"() { + when: "a new completor is created" + def completor = new EscapingFileNameCompletor() + + then: "it implements Completer" + completor instanceof Completer + } + + def "Completor escapes spaces in file names"() { + given: "a file with spaces in its name" + def fileWithSpaces = tempDir.resolve("file with spaces.txt") + Files.createFile(fileWithSpaces) + + and: "the completor" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "file" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "file").length() + cursor() >> (tempDir.toString() + File.separator + "file").length() + line() >> tempDir.toString() + File.separator + "file" + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "the candidate has escaped spaces" + candidates.size() >= 1 + // The escaped file name should have backslashes before spaces + candidates.any { it.value().contains('\\ ') || it.value().contains('file') } + } + + def "Completor handles files without spaces normally"() { + given: "a file without spaces" + def normalFile = tempDir.resolve("normalfile.txt") + Files.createFile(normalFile) + + and: "the completor" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "normal" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "normal").length() + cursor() >> (tempDir.toString() + File.separator + "normal").length() + line() >> tempDir.toString() + File.separator + "normal" + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "the candidate is returned without escaping" + candidates.size() >= 1 + candidates.any { it.value().contains("normalfile") } + } + + def "Completor handles directories"() { + given: "a directory" + def subDir = tempDir.resolve("subdir") + Files.createDirectory(subDir) + + and: "the completor" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "sub" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "sub").length() + cursor() >> (tempDir.toString() + File.separator + "sub").length() + line() >> tempDir.toString() + File.separator + "sub" + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "directory candidates are returned" + candidates.size() >= 1 + } + + def "Completor handles non-existent directory gracefully"() { + given: "a non-existent directory path" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def nonExistentPath = tempDir.toString() + File.separator + "nonexistent_subdir" + File.separator + "file" + def parsedLine = Stub(ParsedLine) { + word() >> nonExistentPath + wordIndex() >> 0 + wordCursor() >> nonExistentPath.length() + cursor() >> nonExistentPath.length() + line() >> nonExistentPath + } + + when: "completion is performed on non-existent path" + completor.complete(reader, parsedLine, candidates) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "Completor handles multiple files with spaces"() { + given: "multiple files with spaces" + Files.createFile(tempDir.resolve("my file 1.txt")) + Files.createFile(tempDir.resolve("my file 2.txt")) + + and: "the completor" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "my" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "my").length() + cursor() >> (tempDir.toString() + File.separator + "my").length() + line() >> tempDir.toString() + File.separator + "my" + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "multiple candidates are returned" + candidates.size() >= 2 + } + + def "Completor escapes tabs in file names"() { + given: "a file with tabs in its name (if allowed by OS)" + def completor = new EscapingFileNameCompletor() + + expect: "the completor can be created and used" + completor != null + } + + def "Completor preserves candidate metadata"() { + given: "a file for completion" + def file = tempDir.resolve("metadata_test.txt") + Files.createFile(file) + + and: "the completor" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "meta" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "meta").length() + cursor() >> (tempDir.toString() + File.separator + "meta").length() + line() >> tempDir.toString() + File.separator + "meta" + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "candidates have proper structure" + candidates.every { it instanceof Candidate } + } + + def "Completor works with empty temp directory"() { + given: "an empty temp directory and the completor" + def completor = new EscapingFileNameCompletor() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator).length() + cursor() >> (tempDir.toString() + File.separator).length() + line() >> tempDir.toString() + File.separator + } + + when: "completion is performed on empty directory" + completor.complete(reader, parsedLine, candidates) + + then: "no exception is thrown" + noExceptionThrown() + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/RegexCompletorSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/RegexCompletorSpec.groovy index 01800e8234e..d44e9eeafd2 100644 --- a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/RegexCompletorSpec.groovy +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/RegexCompletorSpec.groovy @@ -18,41 +18,183 @@ */ package org.grails.cli.interactive.completers +import org.jline.reader.Candidate +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine import spock.lang.* class RegexCompletorSpec extends Specification { - @Unroll("String '#source' is not matching") + + @Unroll("String '#source' is matching") def "Simple pattern matches"() { given: "a regex completor and an empty candidate list" def completor = new RegexCompletor(/!\w+/) def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> source + } when: "the completor is invoked for a given string" - def retval = completor.complete(source, 0, candidateList) + completor.complete(null, parsedLine, candidateList) - then: "that string is the sole candidate and the return value is 0" + then: "that string is the sole candidate" candidateList.size() == 1 - candidateList[0] == source - retval == 0 + candidateList[0].value() == source where: source << [ "!ls", "!test_stuff" ] } - @Unroll("String '#source' is incorrectly matching") + @Unroll("String '#source' is not matching") def "Non matching strings"() { given: "a regex completor and an empty candidate list" def completor = new RegexCompletor(/!\w+/) def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> source + } when: "the completor is invoked for a given (non-matching) string" - def retval = completor.complete(source, 0, candidateList) + completor.complete(null, parsedLine, candidateList) - then: "the candidate list is empty and the return value is -1" + then: "the candidate list is empty" candidateList.size() == 0 - retval == -1 where: source << [ "!ls ls", "!", "test", "" ] } + + // Additional edge case tests + + def "Completor can be created with different patterns"() { + given: "various regex patterns" + def patterns = [/\d+/, /[a-z]+/, /.*test.*/, /^prefix/] + + expect: "all can be instantiated" + patterns.every { new RegexCompletor(it) != null } + } + + def "Completor matches numeric patterns"() { + given: "a numeric regex completor" + def completor = new RegexCompletor(/\d+/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> "12345" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "numeric string matches" + candidateList.size() == 1 + candidateList[0].value() == "12345" + } + + def "Completor handles complex patterns"() { + given: "a complex regex completor for email-like patterns" + def completor = new RegexCompletor(/\w+@\w+\.\w+/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> "test@example.com" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "complex pattern matches" + candidateList.size() == 1 + candidateList[0].value() == "test@example.com" + } + + def "Completor handles anchored patterns"() { + given: "a regex with start anchor" + def completor = new RegexCompletor(/^grails-.*/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> "grails-core" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "anchored pattern matches" + candidateList.size() == 1 + } + + def "Completor with null word returns no candidates"() { + given: "a regex completor" + def completor = new RegexCompletor(/\w+/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> null + } + + when: "the completor is invoked with null" + completor.complete(null, parsedLine, candidateList) + + then: "no candidates are returned" + candidateList.isEmpty() + } + + def "Completor candidates are proper Candidate objects"() { + given: "a regex completor" + def completor = new RegexCompletor(/test/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> "test" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "candidates are Candidate instances" + candidateList.every { it instanceof Candidate } + } + + def "Completor handles patterns with groups"() { + given: "a regex with capture groups" + def completor = new RegexCompletor(/(create|run|test)-app/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> "create-app" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "pattern with groups matches" + candidateList.size() == 1 + candidateList[0].value() == "create-app" + } + + def "Completor handles unicode in patterns"() { + given: "a regex that matches unicode" + def completor = new RegexCompletor(/café\w*/) + def candidateList = [] + def parsedLine = Stub(ParsedLine) { + word() >> "cafébar" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "unicode pattern matches" + candidateList.size() == 1 + } + + def "Completor preserves existing candidates"() { + given: "a regex completor and pre-existing candidates" + def completor = new RegexCompletor(/match/) + def candidateList = [new Candidate("existing")] + def parsedLine = Stub(ParsedLine) { + word() >> "match" + } + + when: "the completor is invoked" + completor.complete(null, parsedLine, candidateList) + + then: "both existing and new candidates are present" + candidateList.size() == 2 + candidateList*.value().containsAll(["existing", "match"]) + } } diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletorSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletorSpec.groovy new file mode 100644 index 00000000000..269c9b72240 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/SimpleOrFileNameCompletorSpec.groovy @@ -0,0 +1,278 @@ +/* + * 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 + * + * https://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.grails.cli.interactive.completers + +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.LineReaderBuilder +import org.jline.reader.ParsedLine +import org.jline.terminal.Terminal +import org.jline.terminal.TerminalBuilder +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path + +/** + * Tests for SimpleOrFileNameCompletor which combines fixed string options + * with file name completion. + */ +class SimpleOrFileNameCompletorSpec extends Specification { + + @TempDir + Path tempDir + + Terminal terminal + LineReader reader + + def setup() { + terminal = TerminalBuilder.builder().dumb(true).build() + reader = LineReaderBuilder.builder().terminal(terminal).build() + } + + def cleanup() { + terminal?.close() + } + + def "Completor can be constructed with String array"() { + when: "completor is created with String array" + def completor = new SimpleOrFileNameCompletor(["option1", "option2"] as String[]) + + then: "it is created successfully" + completor != null + } + + def "Completor can be constructed with List"() { + when: "completor is created with List" + def completor = new SimpleOrFileNameCompletor(["option1", "option2"]) + + then: "it is created successfully" + completor != null + } + + def "Completor implements Completer interface"() { + when: "completor is created" + def completor = new SimpleOrFileNameCompletor(["option1"]) + + then: "it implements Completer" + completor instanceof Completer + } + + def "Completor returns fixed options when matching"() { + given: "a completor with fixed options" + def completor = new SimpleOrFileNameCompletor(["create-app", "create-plugin", "run-app"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "create" + line() >> "create" + wordIndex() >> 0 + wordCursor() >> 6 + cursor() >> 6 + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "fixed options matching the prefix are returned" + candidates.any { it.value() == "create-app" } + candidates.any { it.value() == "create-plugin" } + } + + def "Completor returns all fixed options when buffer is empty"() { + given: "a completor with fixed options" + def completor = new SimpleOrFileNameCompletor(["option1", "option2", "option3"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + line() >> "" + wordIndex() >> 0 + wordCursor() >> 0 + cursor() >> 0 + } + + when: "completion is performed with empty buffer" + completor.complete(reader, parsedLine, candidates) + + then: "all fixed options are returned (plus file completions)" + candidates.any { it.value() == "option1" } + candidates.any { it.value() == "option2" } + candidates.any { it.value() == "option3" } + } + + def "Completor combines fixed options with file completions"() { + given: "a file in the temp directory" + def testFile = tempDir.resolve("testfile.groovy") + Files.createFile(testFile) + + and: "a completor with fixed options" + def completor = new SimpleOrFileNameCompletor(["test-option"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "test" + line() >> tempDir.toString() + File.separator + "test" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "test").length() + cursor() >> (tempDir.toString() + File.separator + "test").length() + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "file completions are included" + candidates.any { it.value().contains("testfile") } + } + + def "Completor returns only file completions when no fixed options match"() { + given: "a file in the temp directory" + def uniqueFile = tempDir.resolve("uniquefile.txt") + Files.createFile(uniqueFile) + + and: "a completor with non-matching fixed options" + def completor = new SimpleOrFileNameCompletor(["option1", "option2"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> tempDir.toString() + File.separator + "unique" + line() >> tempDir.toString() + File.separator + "unique" + wordIndex() >> 0 + wordCursor() >> (tempDir.toString() + File.separator + "unique").length() + cursor() >> (tempDir.toString() + File.separator + "unique").length() + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "only file completions are returned" + candidates.any { it.value().contains("uniquefile") } + !candidates.any { it.value() == "option1" } + !candidates.any { it.value() == "option2" } + } + + def "Completor handles empty fixed options list"() { + given: "a completor with empty options" + def completor = new SimpleOrFileNameCompletor([]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + line() >> "" + wordIndex() >> 0 + wordCursor() >> 0 + cursor() >> 0 + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "Completor preserves order: fixed options before file completions"() { + given: "a file and a matching fixed option" + def alphaFile = tempDir.resolve("alpha.txt") + Files.createFile(alphaFile) + + and: "a completor with a fixed option that sorts after the file" + def completor = new SimpleOrFileNameCompletor(["alpha-option"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "alpha" + line() >> "alpha" + wordIndex() >> 0 + wordCursor() >> 5 + cursor() >> 5 + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "fixed option appears in results" + candidates.any { it.value() == "alpha-option" } + } + + def "Completor handles special characters in fixed options"() { + given: "a completor with special character options" + def completor = new SimpleOrFileNameCompletor(["--verbose", "--help", "-v"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "--" + line() >> "--" + wordIndex() >> 0 + wordCursor() >> 2 + cursor() >> 2 + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "options with special characters are returned" + candidates.any { it.value() == "--verbose" } + candidates.any { it.value() == "--help" } + } + + def "Completor handles case-sensitive matching for fixed options"() { + given: "a completor with mixed case options" + def completor = new SimpleOrFileNameCompletor(["CreateApp", "createPlugin"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "Create" + line() >> "Create" + wordIndex() >> 0 + wordCursor() >> 6 + cursor() >> 6 + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "only matching case option is returned" + candidates.any { it.value() == "CreateApp" } + !candidates.any { it.value() == "createPlugin" } + } + + def "Completor works with Grails-style commands"() { + given: "a completor with Grails commands" + def completor = new SimpleOrFileNameCompletor([ + "create-app", + "create-plugin", + "create-domain-class", + "run-app", + "test-app", + "generate-all" + ]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "create-" + line() >> "create-" + wordIndex() >> 0 + wordCursor() >> 7 + cursor() >> 7 + } + + when: "completion is performed" + completor.complete(reader, parsedLine, candidates) + + then: "all create commands are returned" + candidates.any { it.value() == "create-app" } + candidates.any { it.value() == "create-plugin" } + candidates.any { it.value() == "create-domain-class" } + !candidates.any { it.value() == "run-app" } + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleterSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleterSpec.groovy new file mode 100644 index 00000000000..3ba20baa7fa --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/SortedAggregateCompleterSpec.groovy @@ -0,0 +1,184 @@ +/* + * 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 + * + * https://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.grails.cli.interactive.completers + +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine +import spock.lang.Specification + +class SortedAggregateCompleterSpec extends Specification { + + def "Empty aggregate completer returns no candidates"() { + given: "an empty aggregate completer" + def completer = new SortedAggregateCompleter() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "no candidates are returned" + candidates.isEmpty() + } + + def "Aggregate completer combines results from multiple completers"() { + given: "an aggregate completer with two string completers" + def completer1 = new StringsCompleter("apple", "apricot") + def completer2 = new StringsCompleter("banana", "blueberry") + def aggregateCompleter = new SortedAggregateCompleter(completer1, completer2) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the aggregate completer is invoked" + aggregateCompleter.complete(null, parsedLine, candidates) + + then: "candidates from all completers are combined and sorted" + candidates.size() == 4 + candidates*.value() == ["apple", "apricot", "banana", "blueberry"] + } + + def "Aggregate completer sorts candidates alphabetically"() { + given: "completers that would return unsorted results" + def completer1 = new StringsCompleter("zebra", "mango") + def completer2 = new StringsCompleter("apple", "kiwi") + def aggregateCompleter = new SortedAggregateCompleter(completer1, completer2) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the aggregate completer is invoked" + aggregateCompleter.complete(null, parsedLine, candidates) + + then: "all candidates are sorted alphabetically" + candidates*.value() == ["apple", "kiwi", "mango", "zebra"] + } + + def "Aggregate completer can be constructed with a collection"() { + given: "an aggregate completer constructed with a list of completers" + def completers = [ + new StringsCompleter("one"), + new StringsCompleter("two"), + new StringsCompleter("three") + ] + def aggregateCompleter = new SortedAggregateCompleter(completers) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the aggregate completer is invoked" + aggregateCompleter.complete(null, parsedLine, candidates) + + then: "candidates from all completers are available" + candidates.size() == 3 + candidates*.value() == ["one", "three", "two"] + } + + def "getCompleters returns the internal completer collection"() { + given: "an aggregate completer with some completers" + def completer1 = new StringsCompleter("a") + def completer2 = new StringsCompleter("b") + def aggregateCompleter = new SortedAggregateCompleter(completer1, completer2) + + when: "getCompleters is called" + def completers = aggregateCompleter.getCompleters() + + then: "the internal collection is returned" + completers.size() == 2 + completers.contains(completer1) + completers.contains(completer2) + } + + def "Completers can be added dynamically"() { + given: "an empty aggregate completer" + def aggregateCompleter = new SortedAggregateCompleter() + + when: "a completer is added via getCompleters()" + aggregateCompleter.getCompleters().add(new StringsCompleter("dynamic")) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + aggregateCompleter.complete(null, parsedLine, candidates) + + then: "the new completer's candidates are included" + candidates.size() == 1 + candidates[0].value() == "dynamic" + } + + def "Aggregate completer respects individual completer filtering"() { + given: "completers with different strings" + def completer1 = new StringsCompleter("create-app", "create-plugin") + def completer2 = new StringsCompleter("run-app", "test-app") + def aggregateCompleter = new SortedAggregateCompleter(completer1, completer2) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "create" + } + + when: "the aggregate completer is invoked with a prefix" + aggregateCompleter.complete(null, parsedLine, candidates) + + then: "only matching candidates from all completers are returned" + candidates.size() == 2 + candidates*.value() == ["create-app", "create-plugin"] + } + + def "toString returns a meaningful representation"() { + given: "an aggregate completer" + def completer = new SortedAggregateCompleter(new StringsCompleter("test")) + + when: "toString is called" + def result = completer.toString() + + then: "it contains the class name and completers info" + result.contains("SortedAggregateCompleter") + result.contains("completers=") + } + + def "Aggregate completer works with custom Completer implementations"() { + given: "an aggregate completer with a custom completer" + def customCompleter = new Completer() { + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + candidates.add(new Candidate("custom-value")) + } + } + def stringsCompleter = new StringsCompleter("strings-value") + def aggregateCompleter = new SortedAggregateCompleter(customCompleter, stringsCompleter) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the aggregate completer is invoked" + aggregateCompleter.complete(null, parsedLine, candidates) + + then: "candidates from both completers are combined" + candidates.size() == 2 + candidates*.value() == ["custom-value", "strings-value"] + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/StringsCompleterSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/StringsCompleterSpec.groovy new file mode 100644 index 00000000000..40f0e580c45 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/interactive/completers/StringsCompleterSpec.groovy @@ -0,0 +1,355 @@ +/* + * 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 + * + * https://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.grails.cli.interactive.completers + +import org.jline.reader.Candidate +import org.jline.reader.ParsedLine +import spock.lang.Specification +import spock.lang.Unroll + +class StringsCompleterSpec extends Specification { + + def "Empty completer returns no candidates"() { + given: "an empty strings completer" + def completer = new StringsCompleter() + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "no candidates are returned" + candidates.isEmpty() + } + + def "Completer returns all strings when buffer is empty"() { + given: "a strings completer with some values" + def completer = new StringsCompleter("apple", "banana", "cherry") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked with empty buffer" + completer.complete(null, parsedLine, candidates) + + then: "all strings are returned as candidates" + candidates.size() == 3 + candidates*.value() == ["apple", "banana", "cherry"] + } + + def "Completer returns all strings when buffer is null"() { + given: "a strings completer with some values" + def completer = new StringsCompleter("apple", "banana", "cherry") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> null + } + + when: "the completer is invoked with null buffer" + completer.complete(null, parsedLine, candidates) + + then: "all strings are returned as candidates" + candidates.size() == 3 + } + + @Unroll("Prefix '#prefix' matches #expectedMatches") + def "Completer filters strings by prefix"() { + given: "a strings completer with various values" + def completer = new StringsCompleter("create-app", "create-plugin", "create-domain-class", "run-app", "test-app") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> prefix + } + + when: "the completer is invoked with a prefix" + completer.complete(null, parsedLine, candidates) + + then: "only matching strings are returned" + candidates*.value() == expectedMatches + + where: + prefix | expectedMatches + "create" | ["create-app", "create-domain-class", "create-plugin"] + "run" | ["run-app"] + "test" | ["test-app"] + "xyz" | [] + "create-a" | ["create-app"] + } + + def "Completer can be constructed with a collection"() { + given: "a strings completer constructed with a list" + def completer = new StringsCompleter(["one", "two", "three"]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "all strings from the collection are available" + candidates.size() == 3 + candidates*.value().containsAll(["one", "two", "three"]) + } + + def "Strings can be modified via getStrings()"() { + given: "a strings completer" + def completer = new StringsCompleter("initial") + + when: "strings are added via getStrings()" + completer.getStrings().add("added") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + completer.complete(null, parsedLine, candidates) + + then: "the new string is included in completions" + candidates.size() == 2 + candidates*.value().containsAll(["initial", "added"]) + } + + def "Strings are sorted alphabetically"() { + given: "a strings completer with unsorted input" + def completer = new StringsCompleter("zebra", "apple", "mango") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "candidates are sorted" + candidates*.value() == ["apple", "mango", "zebra"] + } + + def "setStrings replaces all strings"() { + given: "a strings completer with initial values" + def completer = new StringsCompleter("old1", "old2") + + when: "strings are replaced" + def newStrings = new TreeSet() + newStrings.addAll(["new1", "new2", "new3"]) + completer.setStrings(newStrings) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + completer.complete(null, parsedLine, candidates) + + then: "only new strings are available" + candidates.size() == 3 + candidates*.value() == ["new1", "new2", "new3"] + } + + // Additional edge case tests + + def "Completer handles duplicates in input"() { + given: "a strings completer with duplicate values" + def completer = new StringsCompleter("duplicate", "duplicate", "unique") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "duplicates are removed (TreeSet behavior)" + candidates.size() == 2 + candidates*.value() == ["duplicate", "unique"] + } + + def "Completer handles special characters"() { + given: "a strings completer with special characters" + def completer = new StringsCompleter("--verbose", "--help", "-v", "!shell") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "--" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "special character strings are matched" + candidates*.value() == ["--help", "--verbose"] + } + + def "Completer is case-sensitive"() { + given: "a strings completer with mixed case" + def completer = new StringsCompleter("Apple", "apple", "APPLE") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "app" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "only lowercase match is returned" + candidates.size() == 1 + candidates[0].value() == "apple" + } + + def "Completer handles unicode strings"() { + given: "a strings completer with unicode" + def completer = new StringsCompleter("café", "naïve", "résumé") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "caf" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "unicode strings are matched correctly" + candidates.size() == 1 + candidates[0].value() == "café" + } + + def "Completer handles very long strings"() { + given: "a strings completer with a very long string" + def longString = "a" * 1000 + def completer = new StringsCompleter(longString, "short") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "a" * 500 + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "long string is matched" + candidates.size() == 1 + candidates[0].value() == longString + } + + def "Completer handles strings with whitespace"() { + given: "a strings completer with whitespace in strings" + def completer = new StringsCompleter("hello world", "hello there", "goodbye") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "hello" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "strings with whitespace are matched" + candidates.size() == 2 + candidates*.value().containsAll(["hello there", "hello world"]) + } + + def "Completer handles numeric strings"() { + given: "a strings completer with numbers" + def completer = new StringsCompleter("123", "1234", "456", "12abc") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "12" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "numeric strings are matched by prefix" + candidates.size() == 3 + candidates*.value() == ["123", "1234", "12abc"] + } + + def "Completer handles exact match"() { + given: "a strings completer" + def completer = new StringsCompleter("exact", "exactly", "exactness") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "exact" + } + + when: "the completer is invoked with exact match" + completer.complete(null, parsedLine, candidates) + + then: "exact match and extensions are returned" + candidates.size() == 3 + candidates*.value() == ["exact", "exactly", "exactness"] + } + + def "Completer handles single character strings"() { + given: "a strings completer with single characters" + def completer = new StringsCompleter("a", "b", "c", "ab", "abc") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "a" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "single and multi-char strings are matched" + candidates.size() == 3 + candidates*.value() == ["a", "ab", "abc"] + } + + def "Completer does not add to candidate list if no match"() { + given: "a strings completer" + def completer = new StringsCompleter("alpha", "beta", "gamma") + def candidates = [new Candidate("existing")] + def parsedLine = Stub(ParsedLine) { + word() >> "nomatch" + } + + when: "the completer is invoked with no matching prefix" + completer.complete(null, parsedLine, candidates) + + then: "existing candidates are preserved, no new ones added" + candidates.size() == 1 + candidates[0].value() == "existing" + } + + def "Completer candidate objects have correct type"() { + given: "a strings completer" + def completer = new StringsCompleter("test") + def candidates = [] + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked" + completer.complete(null, parsedLine, candidates) + + then: "candidates are Candidate instances" + candidates.every { it instanceof Candidate } + } + + def "Completer throws on null candidates list"() { + given: "a strings completer" + def completer = new StringsCompleter("test") + def parsedLine = Stub(ParsedLine) { + word() >> "" + } + + when: "the completer is invoked with null candidates" + completer.complete(null, parsedLine, null) + + then: "NullPointerException is thrown" + thrown(NullPointerException) + } +} diff --git a/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/CommandCompleterSpec.groovy b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/CommandCompleterSpec.groovy new file mode 100644 index 00000000000..3d7045acfc7 --- /dev/null +++ b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/CommandCompleterSpec.groovy @@ -0,0 +1,282 @@ +/* + * 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 + * + * https://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.grails.cli.profile.commands + +import org.grails.cli.profile.Command +import org.grails.cli.profile.CommandDescription +import org.grails.cli.profile.ExecutionContext +import org.jline.reader.Candidate +import org.jline.reader.Completer +import org.jline.reader.LineReader +import org.jline.reader.ParsedLine +import spock.lang.Specification + +/** + * Tests for CommandCompleter which provides tab completion for Grails commands. + */ +class CommandCompleterSpec extends Specification { + + def "CommandCompleter can be instantiated with empty commands"() { + when: "a completer is created with empty command list" + def completer = new CommandCompleter([]) + + then: "it is created successfully" + completer != null + completer.commands.isEmpty() + } + + def "CommandCompleter can be instantiated with commands"() { + given: "some mock commands" + def cmd1 = createMockCommand("create-app") + def cmd2 = createMockCommand("run-app") + + when: "a completer is created" + def completer = new CommandCompleter([cmd1, cmd2]) + + then: "it contains the commands" + completer.commands.size() == 2 + } + + def "CommandCompleter delegates to command if it implements Completer"() { + given: "a command that implements Completer" + def completingCommand = createCompletingCommand("create-app", ["--verbose", "--help"]) + def completer = new CommandCompleter([completingCommand]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "create-app " + word() >> "" + } + + when: "completion is performed on that command" + completer.complete(null, parsedLine, candidates) + + then: "the command's completer is invoked" + candidates.size() == 2 + candidates*.value().containsAll(["--verbose", "--help"]) + } + + def "CommandCompleter finds command by exact name match"() { + given: "a completing command" + def cmd = createCompletingCommand("run-app", ["--port"]) + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "run-app" + word() >> "run-app" + } + + when: "completion is performed" + completer.complete(null, parsedLine, candidates) + + then: "the matching command is found and completion delegated" + candidates.size() == 1 + candidates[0].value() == "--port" + } + + def "CommandCompleter finds command by prefix with arguments"() { + given: "a completing command" + def cmd = createCompletingCommand("create-domain-class", ["--package"]) + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "create-domain-class MyClass" + word() >> "MyClass" + } + + when: "completion is performed" + completer.complete(null, parsedLine, candidates) + + then: "the command is found and completion delegated" + candidates.size() == 1 + } + + def "CommandCompleter returns nothing for non-completing command"() { + given: "a command that does not implement Completer" + def cmd = createMockCommand("simple-command") + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "simple-command" + word() >> "simple-command" + } + + when: "completion is performed" + completer.complete(null, parsedLine, candidates) + + then: "no candidates are returned" + candidates.isEmpty() + } + + def "CommandCompleter returns nothing for unknown command"() { + given: "a completer with specific commands" + def cmd = createMockCommand("known-command") + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "unknown-command" + word() >> "unknown-command" + } + + when: "completion is performed for unknown command" + completer.complete(null, parsedLine, candidates) + + then: "no candidates are returned" + candidates.isEmpty() + } + + def "CommandCompleter handles multiple commands"() { + given: "multiple completing commands" + def cmd1 = createCompletingCommand("create-app", ["--app-option"]) + def cmd2 = createCompletingCommand("create-plugin", ["--plugin-option"]) + def completer = new CommandCompleter([cmd1, cmd2]) + + and: "parsed line for first command" + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "create-app " + word() >> "" + } + + when: "completion is performed" + completer.complete(null, parsedLine, candidates) + + then: "correct command's completer is used" + candidates.size() == 1 + candidates[0].value() == "--app-option" + } + + def "CommandCompleter handles empty line"() { + given: "a completer" + def cmd = createCompletingCommand("test-cmd", ["option"]) + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "" + word() >> "" + } + + when: "completion is performed on empty line" + completer.complete(null, parsedLine, candidates) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "CommandCompleter handles whitespace-only line"() { + given: "a completer" + def cmd = createCompletingCommand("test-cmd", ["option"]) + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> " " + word() >> "" + } + + when: "completion is performed on whitespace line" + completer.complete(null, parsedLine, candidates) + + then: "no exception is thrown" + noExceptionThrown() + } + + def "CommandCompleter finds first matching command"() { + given: "commands with similar prefixes" + def cmd1 = createCompletingCommand("create", ["--general"]) + def cmd2 = createCompletingCommand("create-app", ["--specific"]) + def completer = new CommandCompleter([cmd1, cmd2]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "create " + word() >> "" + } + + when: "completion is performed" + completer.complete(null, parsedLine, candidates) + + then: "the first matching command is used" + candidates.size() == 1 + candidates[0].value() == "--general" + } + + def "CommandCompleter handles command with multiple arguments"() { + given: "a completing command" + def cmd = createCompletingCommand("generate-all", ["--arg1", "--arg2"]) + def completer = new CommandCompleter([cmd]) + def candidates = [] + def parsedLine = Stub(ParsedLine) { + line() >> "generate-all Domain --arg1 value" + word() >> "value" + } + + when: "completion is performed" + completer.complete(null, parsedLine, candidates) + + then: "completion is delegated" + candidates.size() == 2 + } + + /** + * Creates a mock command that does not implement Completer. + */ + private Command createMockCommand(String name) { + return Stub(Command) { + getName() >> name + getDescription() >> Stub(CommandDescription) { + getName() >> name + } + } + } + + /** + * Creates a command that implements Completer and returns the given completions. + */ + private Command createCompletingCommand(String name, List completions) { + return new TestCompletingCommand(name, completions) + } + + /** + * Test command that implements both Command and Completer interfaces. + */ + static class TestCompletingCommand implements Command, Completer { + final String name + final List completions + + TestCompletingCommand(String name, List completions) { + this.name = name + this.completions = completions + } + + @Override + CommandDescription getDescription() { + return Stub(CommandDescription) { + getName() >> name + } + } + + @Override + boolean handle(ExecutionContext executionContext) { + return true + } + + @Override + void complete(LineReader reader, ParsedLine line, List candidates) { + completions.each { candidates.add(new Candidate(it)) } + } + } +} diff --git a/grails-test-core/build.gradle b/grails-test-core/build.gradle index 6d21f9a0e3c..ac147627d24 100644 --- a/grails-test-core/build.gradle +++ b/grails-test-core/build.gradle @@ -51,7 +51,7 @@ dependencies { api 'org.apache.groovy:groovy' // command line requirements - api 'jline:jline' + api 'org.jline:jline' api 'org.fusesource.jansi:jansi' // Ant diff --git a/grails-web-url-mappings/build.gradle b/grails-web-url-mappings/build.gradle index 44461d1d85f..3b2aa68bf75 100644 --- a/grails-web-url-mappings/build.gradle +++ b/grails-web-url-mappings/build.gradle @@ -46,7 +46,7 @@ dependencies { compileOnly 'org.fusesource.jansi:jansi' testRuntimeOnly 'org.fusesource.jansi:jansi' - compileOnly 'jline:jline' + compileOnly 'org.jline:jline' implementation 'com.github.ben-manes.caffeine:caffeine'