diff --git a/src/main/java/sqlline/AbstractCommandHandler.java b/src/main/java/sqlline/AbstractCommandHandler.java index 204134d1..91380778 100644 --- a/src/main/java/sqlline/AbstractCommandHandler.java +++ b/src/main/java/sqlline/AbstractCommandHandler.java @@ -39,9 +39,7 @@ public AbstractCommandHandler(SqlLine sqlLine, String[] names, this.parameterCompleters = Collections.singletonList(new NullCompleter()); } else { - List c = new ArrayList<>(completers); - c.add(new NullCompleter()); - this.parameterCompleters = c; + this.parameterCompleters = new ArrayList<>(completers); } } diff --git a/src/main/java/sqlline/Application.java b/src/main/java/sqlline/Application.java index 80c9c4a4..11468d98 100644 --- a/src/main/java/sqlline/Application.java +++ b/src/main/java/sqlline/Application.java @@ -265,6 +265,10 @@ public Collection getCommandHandlers(SqlLine sqlLine) { TableNameCompleter tableCompleter = new TableNameCompleter(sqlLine); List empty = Collections.emptyList(); final Map outputFormats = getOutputFormats(sqlLine); + final Map> customPropertyCompletions = + new HashMap<>(); + customPropertyCompletions + .put(BuiltInProperty.OUTPUT_FORMAT, outputFormats.keySet()); final CommandHandler[] handlers = { new ReflectiveCommandHandler(sqlLine, empty, "quit", "done", "exit"), new ReflectiveCommandHandler(sqlLine, @@ -314,9 +318,10 @@ public Collection getCommandHandlers(SqlLine sqlLine) { new ReflectiveCommandHandler(sqlLine, empty, "rollback"), new ReflectiveCommandHandler(sqlLine, empty, "help", "?"), new ReflectiveCommandHandler(sqlLine, - getOpts(sqlLine).optionCompleters(), "set"), + getOpts(sqlLine).setOptionCompleters(customPropertyCompletions), + "set"), new ReflectiveCommandHandler(sqlLine, - getOpts(sqlLine).optionCompleters(), "reset"), + getOpts(sqlLine).resetOptionCompleters(), "reset"), new ReflectiveCommandHandler(sqlLine, empty, "save"), new ReflectiveCommandHandler(sqlLine, empty, "scan"), new ReflectiveCommandHandler(sqlLine, empty, "sql"), @@ -357,7 +362,7 @@ private Set getMetadataMethodNames() { } } - private List getIsolationLevels() { + List getIsolationLevels() { return Arrays.asList( "TRANSACTION_NONE", "TRANSACTION_READ_COMMITTED", diff --git a/src/main/java/sqlline/BuiltInProperty.java b/src/main/java/sqlline/BuiltInProperty.java index 1b4f9666..082d7588 100644 --- a/src/main/java/sqlline/BuiltInProperty.java +++ b/src/main/java/sqlline/BuiltInProperty.java @@ -12,6 +12,10 @@ package sqlline; import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import org.jline.reader.LineReader; import org.jline.reader.impl.history.DefaultHistory; @@ -26,7 +30,8 @@ public enum BuiltInProperty implements SqlLineProperty { AUTO_COMMIT("autoCommit", Type.BOOLEAN, true), AUTO_SAVE("autoSave", Type.BOOLEAN, false), - COLOR_SCHEME("colorScheme", Type.STRING, DEFAULT), + COLOR_SCHEME("colorScheme", Type.STRING, DEFAULT, + true, false, new Application().getName2HighlightStyle().keySet()), COLOR("color", Type.BOOLEAN, false), CSV_DELIMITER("csvDelimiter", Type.STRING, ","), @@ -41,19 +46,21 @@ public enum BuiltInProperty implements SqlLineProperty { HISTORY_FILE("historyFile", Type.STRING, new File(SqlLineOpts.saveDir(), "history").getAbsolutePath()), INCREMENTAL("incremental", Type.BOOLEAN, true), - ISOLATION("isolation", Type.STRING, "TRANSACTION_REPEATABLE_READ"), - MAX_COLUMN_WIDTH("maxColumnWidth", Type.INTEGER, 15), + ISOLATION("isolation", Type.STRING, "TRANSACTION_REPEATABLE_READ", + true, false, new HashSet<>(new Application().getIsolationLevels())), + MAX_COLUMN_WIDTH("maxColumnWidth", Type.INTEGER, -1), // don't save maxheight, maxwidth: it is automatically set based on // the terminal configuration - MAX_HEIGHT("maxHeight", Type.INTEGER, 80, false, false), - MAX_WIDTH("maxWidth", Type.INTEGER, 80, false, false), + MAX_HEIGHT("maxHeight", Type.INTEGER, 80, false, false, null), + MAX_WIDTH("maxWidth", Type.INTEGER, 80, false, false, null), MAX_HISTORY_ROWS("maxHistoryRows", Type.INTEGER, DefaultHistory.DEFAULT_HISTORY_SIZE), MAX_HISTORY_FILE_ROWS("maxHistoryFileRows", Type.INTEGER, DefaultHistory.DEFAULT_HISTORY_FILE_SIZE), - MODE("mode", Type.STRING, LineReader.EMACS), + MODE("mode", Type.STRING, LineReader.EMACS, true, + false, new HashSet<>(Arrays.asList(LineReader.EMACS, "vi"))), NUMBER_FORMAT("numberFormat", Type.STRING, DEFAULT), NULL_VALUE("nullValue", Type.STRING, DEFAULT), SILENT("silent", Type.BOOLEAN, false), @@ -72,16 +79,18 @@ public enum BuiltInProperty implements SqlLineProperty { TRIM_SCRIPTS("trimScripts", Type.BOOLEAN, true), USE_LINE_CONTINUATION("useLineContinuation", Type.BOOLEAN, true), VERBOSE("verbose", Type.BOOLEAN, false), - VERSION("version", Type.STRING, new Application().getVersion(), false, true); + VERSION("version", + Type.STRING, new Application().getVersion(), false, true, null); private final String propertyName; private final Type type; private final Object defaultValue; private final boolean couldBeStored; private final boolean isReadOnly; + private final Set availableValues; BuiltInProperty(String propertyName, Type type, Object defaultValue) { - this(propertyName, type, defaultValue, true, false); + this(propertyName, type, defaultValue, true, false, null); } BuiltInProperty( @@ -89,12 +98,15 @@ public enum BuiltInProperty implements SqlLineProperty { Type type, Object defaultValue, boolean couldBeStored, - boolean isReadOnly) { + boolean isReadOnly, + Set availableValues) { this.propertyName = propertyName; this.type = type; this.defaultValue = defaultValue; this.isReadOnly = isReadOnly; this.couldBeStored = couldBeStored; + this.availableValues = availableValues == null + ? Collections.emptySet() : Collections.unmodifiableSet(availableValues); } @Override public String propertyName() { @@ -117,6 +129,10 @@ public enum BuiltInProperty implements SqlLineProperty { return type; } + @Override public Set getAvailableValues() { + return availableValues; + } + /** Returns the built-in property with the given name, or null if not found. * * @param propertyName Property name diff --git a/src/main/java/sqlline/SqlLineCommandCompleter.java b/src/main/java/sqlline/SqlLineCommandCompleter.java index c88a431a..ba367136 100644 --- a/src/main/java/sqlline/SqlLineCommandCompleter.java +++ b/src/main/java/sqlline/SqlLineCommandCompleter.java @@ -14,6 +14,7 @@ import java.util.LinkedList; import java.util.List; +import org.jline.builtins.Completers; import org.jline.reader.Completer; import org.jline.reader.impl.completer.AggregateCompleter; import org.jline.reader.impl.completer.ArgumentCompleter; @@ -26,16 +27,23 @@ class SqlLineCommandCompleter extends AggregateCompleter { SqlLineCommandCompleter(SqlLine sqlLine) { super(new LinkedList<>()); - List completers = new LinkedList<>(); + List completers = new LinkedList<>(); for (CommandHandler commandHandler : sqlLine.getCommandHandlers()) { for (String cmd : commandHandler.getNames()) { List compl = new LinkedList<>(); - compl.add(new StringsCompleter(SqlLine.COMMAND_PREFIX + cmd)); - compl.addAll(commandHandler.getParameterCompleters()); - compl.add(new NullCompleter()); // last param no complete - - completers.add(new ArgumentCompleter(compl)); + final List parameterCompleters = + commandHandler.getParameterCompleters(); + if (parameterCompleters.size() == 1 + && parameterCompleters.iterator().next() + instanceof Completers.RegexCompleter) { + completers.add(parameterCompleters.iterator().next()); + } else { + compl.add(new StringsCompleter(SqlLine.COMMAND_PREFIX + cmd)); + compl.addAll(parameterCompleters); + compl.add(new NullCompleter()); // last param no complete + completers.add(new ArgumentCompleter(compl)); + } } } diff --git a/src/main/java/sqlline/SqlLineCompleter.java b/src/main/java/sqlline/SqlLineCompleter.java index 7c1dec62..05353ce4 100644 --- a/src/main/java/sqlline/SqlLineCompleter.java +++ b/src/main/java/sqlline/SqlLineCompleter.java @@ -32,7 +32,7 @@ class SqlLineCompleter @Override public void complete(LineReader reader, ParsedLine line, List candidates) { - String bufferStr = reader.getBuffer().substring(0); + final String bufferStr = reader.getBuffer().substring(0).trim(); if (bufferStr.startsWith(SqlLine.COMMAND_PREFIX) && !bufferStr.startsWith(SqlLine.COMMAND_PREFIX + "all") && !bufferStr.startsWith(SqlLine.COMMAND_PREFIX + "sql")) { diff --git a/src/main/java/sqlline/SqlLineOpts.java b/src/main/java/sqlline/SqlLineOpts.java index 57ce298e..7c70e086 100644 --- a/src/main/java/sqlline/SqlLineOpts.java +++ b/src/main/java/sqlline/SqlLineOpts.java @@ -19,6 +19,7 @@ import java.util.*; import java.util.stream.Collectors; +import org.jline.builtins.Completers; import org.jline.keymap.KeyMap; import org.jline.reader.Binding; import org.jline.reader.Candidate; @@ -115,10 +116,84 @@ public SqlLineOpts(SqlLine sqlLine, Properties props) { loadProperties(props); } - public List optionCompleters() { + public List resetOptionCompleters() { return Collections.singletonList(this); } + /** + * Builds and returns {@link org.jline.builtins.Completers.RegexCompleter} for + * !set command based on + * (in decreasing order of priority) + *
    + *
  • Customizations via {@code customCompletions}
  • + *
  • Available values defined in {@link BuiltInProperty}
  • + *
  • {@link Type} of property. + * Currently there is completion only for boolean type
  • + *
+ * + * @param customCompletions defines custom completions values per property + * @return a singleton list with a built RegexCompleter */ + public List setOptionCompleters( + Map> customCompletions) { + Map comp = new HashMap<>(); + final String start = "START"; + comp.put(start, new StringsCompleter("!set")); + Collection booleanProperties = new ArrayList<>(); + Collection withDefinedAvailableValues = new ArrayList<>(); + StringBuilder sb = new StringBuilder(start + " ("); + for (BuiltInProperty property : BuiltInProperty.values()) { + if (customCompletions.containsKey(property)) { + continue; + } else if (!property.getAvailableValues().isEmpty()) { + withDefinedAvailableValues.add(property); + } else if (property.type() == Type.BOOLEAN) { + booleanProperties.add(property); + } else { + sb.append(property.name()).append(" | "); + comp.put(property.name(), + new StringsCompleter(property.propertyName())); + } + } + // all boolean properties without defined available values and + // not customized via {@code customCompletions} have values + // for autocompletion specified in SqlLineProperty.BOOLEAN_VALUES + final String booleanTypeString = Type.BOOLEAN.toString(); + sb.append(booleanTypeString); + comp.put(booleanTypeString, + new StringsCompleter(booleanProperties + .stream() + .map(BuiltInProperty::propertyName) + .toArray(String[]::new))); + final String booleanPropertyValueKey = booleanTypeString + "_value"; + comp.put(booleanPropertyValueKey, + new StringsCompleter(BuiltInProperty.BOOLEAN_VALUES)); + sb.append(" ").append(booleanPropertyValueKey); + // If a property has defined values they will be used for autocompletion + for (BuiltInProperty property : withDefinedAvailableValues) { + final String propertyName = property.propertyName(); + sb.append(" | ").append(propertyName); + comp.put(propertyName, new StringsCompleter(propertyName)); + final String propertyValueKey = propertyName + "_value"; + comp.put(propertyValueKey, + new StringsCompleter( + property.getAvailableValues().toArray(new String[0]))); + sb.append(" ").append(propertyValueKey); + } + for (Map.Entry> mapEntry + : customCompletions.entrySet()) { + final String propertyName = mapEntry.getKey().propertyName(); + comp.put(propertyName, new StringsCompleter(propertyName)); + final String propertyValueKey = propertyName + "_value"; + comp.put(propertyValueKey, + new StringsCompleter(mapEntry.getValue().toArray(new String[0]))); + sb.append("| ").append(propertyName).append(" ") + .append(propertyValueKey); + } + sb.append(") "); + return Collections.singletonList( + new Completers.RegexCompleter(sb.toString(), comp::get)); + } + /** * The save directory if HOME/.sqlline/ on UNIX, and HOME/sqlline/ on * Windows. diff --git a/src/main/java/sqlline/SqlLineProperty.java b/src/main/java/sqlline/SqlLineProperty.java index a06019e2..70436dfa 100644 --- a/src/main/java/sqlline/SqlLineProperty.java +++ b/src/main/java/sqlline/SqlLineProperty.java @@ -11,6 +11,8 @@ */ package sqlline; +import java.util.Set; + /** * Definition of property that may be specified for SqlLine. * @@ -18,6 +20,9 @@ */ public interface SqlLineProperty { String DEFAULT = "default"; + String[] BOOLEAN_VALUES = { + Boolean.TRUE.toString(), Boolean.FALSE.toString()}; + String propertyName(); Object defaultValue(); @@ -28,6 +33,8 @@ public interface SqlLineProperty { Type type(); + Set getAvailableValues(); + /** Property writer. */ @FunctionalInterface interface Writer { @@ -38,8 +45,8 @@ interface Writer { enum Type { BOOLEAN, CHAR, - STRING, - INTEGER; + INTEGER, + STRING; } } diff --git a/src/test/java/sqlline/CompletionTest.java b/src/test/java/sqlline/CompletionTest.java new file mode 100644 index 00000000..98604a1c --- /dev/null +++ b/src/test/java/sqlline/CompletionTest.java @@ -0,0 +1,290 @@ +/* +// Licensed to Julian Hyde under one or more contributor license +// agreements. See the NOTICE file distributed with this work for +// additional information regarding copyright ownership. +// +// Julian Hyde licenses this file to you under the Modified BSD License +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/BSD-3-Clause +*/ +package sqlline; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +import org.jline.reader.Candidate; +import org.jline.reader.LineReader; +import org.jline.reader.impl.LineReaderImpl; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +/** + * Test cases for Completions. + */ +public class CompletionTest { + private final SqlLine sqlLine = new SqlLine(); + + @Before + public void setUp() { + System.setProperty(TerminalBuilder.PROP_DUMB, + Boolean.TRUE.toString()); + } + + @After + public void tearDown() { + System.setProperty(TerminalBuilder.PROP_DUMB, + Boolean.FALSE.toString()); + } + + @Test + public void testCommandCompletions() { + final LineReaderCompletionImpl lineReader = getDummyLineReader(); + + TreeSet commandSet = getCommandSet(sqlLine); + + // check completions of ! + one symbol + for (char c = 'a'; c <= 'z'; c++) { + final Set expectedSubSet = filterSet(commandSet, "!" + c); + final Set actual = getLineReaderCompletedSet(lineReader, "!" + c); + assertEquals("Completion for command !" + c, expectedSubSet, actual); + } + + // check completions of ! + one symbol + for (char c = 'a'; c <= 'z'; c++) { + final Set expectedSubSet = filterSet(commandSet, "!" + c); + final Set actual = + getLineReaderCompletedSet(lineReader, " \t\t !" + c); + assertEquals("Completion for command !" + c, expectedSubSet, actual); + } + + // check completions of ! + one symbol + for (char c = 'a'; c <= 'z'; c++) { + final Set expectedSubSet = filterSet(commandSet, "!" + c); + final Set actual = + getLineReaderCompletedSet(lineReader, " \t\t !" + c); + assertEquals("Completion for command !" + c, expectedSubSet, actual); + } + + // check completions if the whole command is finished + final Set quitExpected = filterSet(commandSet, "!quit"); + final Set quitActual = + getLineReaderCompletedSet(lineReader, "!quit"); + assertEquals(quitExpected, quitActual); + } + + @Test + public void testResetPropertyCompletions() { + final LineReaderCompletionImpl lineReader = getDummyLineReader(); + final String resetCommand = "!reset "; + + for (BuiltInProperty property: BuiltInProperty.values()) { + final String command = resetCommand + + property.propertyName().toLowerCase(Locale.ROOT); + final Set actual = + getLineReaderCompletedSet(lineReader, command + " "); + assertEquals("Completion for command '" + + resetCommand + property.propertyName() + "'", + Collections.singleton(command), actual); + } + } + + @Test + public void testSetPropertyCompletions() { + final LineReaderCompletionImpl lineReader = getDummyLineReader(); + final Set actual = + getLineReaderCompletedSet(lineReader, "!set verbose tr"); + assertEquals("!set verbose true", actual.iterator().next()); + + final Set viModeActual = + getLineReaderCompletedSet(lineReader, "!set mode v"); + assertEquals(1, viModeActual.size()); + assertEquals("!set mode vi", viModeActual.iterator().next()); + + final Set emacsModeActual = + getLineReaderCompletedSet(lineReader, "!set mode e"); + assertEquals(1, emacsModeActual.size()); + assertEquals("!set mode emacs", emacsModeActual.iterator().next()); + + final Set jsonActual = + getLineReaderCompletedSet(lineReader, "!set outputFormat js"); + assertEquals(1, jsonActual.size()); + assertEquals("!set outputFormat json", jsonActual.iterator().next()); + + final Set xmlelActual = + getLineReaderCompletedSet(lineReader, "!set outputFormat xmlel"); + assertEquals(1, xmlelActual.size()); + assertEquals("!set outputFormat xmlelements", + xmlelActual.iterator().next()); + + final Set chesterActual = + getLineReaderCompletedSet(lineReader, "!set colorScheme che"); + assertEquals(1, chesterActual.size()); + assertEquals("!set colorScheme chester", chesterActual.iterator().next()); + + final Set solarizedActual = + getLineReaderCompletedSet(lineReader, "!set colorScheme sol"); + assertEquals(1, solarizedActual.size()); + assertEquals("!set colorScheme solarized", + solarizedActual.iterator().next()); + } + + @Test + public void testSqlCompletions() { + try { + SqlLine sqlLine = new SqlLine(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + SqlLine.Status status = begin(sqlLine, os, false); + // Here the status is SqlLine.Status.OTHER + // because of EOF as the result of InputStream which + // is not used in the current test so it is ok + assertThat(status, equalTo(SqlLine.Status.OTHER)); + DispatchCallback dc = new DispatchCallback(); + sqlLine.runCommands(Collections.singletonList("!set maxwidth 80"), dc); + sqlLine.runCommands( + Collections.singletonList("!connect " + + SqlLineArgsTest.ConnectionSpec.H2.url + " " + + SqlLineArgsTest.ConnectionSpec.H2.username + " " + + "\"\""), dc); + os.reset(); + + LineReader lineReader = sqlLine.getLineReader(); + + LineReaderCompletionImpl lineReaderCompletion = + new LineReaderCompletionImpl(lineReader.getTerminal()); + lineReaderCompletion.setCompleter(new SqlLineCompleter(sqlLine)); + final Set lowCaseActual = + getLineReaderCompletedSet(lineReaderCompletion, "sel"); + assertEquals(1, lowCaseActual.size()); + assertEquals("select", lowCaseActual.iterator().next()); + + final Set upperCaseActual = + getLineReaderCompletedSet(lineReaderCompletion, "SEL"); + assertEquals(1, upperCaseActual.size()); + assertEquals("SELECT", upperCaseActual.iterator().next()); + } catch (Exception e) { + // fail + throw new RuntimeException(e); + } + } + + private LineReaderCompletionImpl getDummyLineReader() { + try { + TerminalBuilder terminalBuilder = TerminalBuilder.builder(); + final Terminal terminal = terminalBuilder.build(); + + final LineReaderCompletionImpl lineReader = + new LineReaderCompletionImpl(terminal); + lineReader.setCompleter(sqlLine.getCommandCompleter()); + lineReader.option(LineReader.Option.DISABLE_EVENT_EXPANSION, true); + return lineReader; + } catch (Exception e) { + // fail + throw new RuntimeException(e); + } + } + + private TreeSet getCommandSet(SqlLine sqlLine) { + TreeSet commandSet = new TreeSet<>(); + for (CommandHandler ch: sqlLine.getCommandHandlers()) { + for (String name: ch.getNames()) { + commandSet.add("!" + name); + } + commandSet.add("!" + ch.getName()); + } + return commandSet; + } + + private Set getLineReaderCompletedSet( + LineReaderCompletionImpl lineReader, String s) { + return lineReader.complete(s).stream() + .map(Candidate::value).collect(Collectors.toCollection(TreeSet::new)); + } + + private Set filterSet(TreeSet commandSet, String s2) { + Set subset = commandSet.stream() + .filter(s -> s.startsWith(s2)) + .collect(Collectors.toCollection(TreeSet::new)); + if (subset.isEmpty()) { + Set result = new TreeSet<>(commandSet); + result.add(s2); + return result; + } else { + return subset; + } + } + + /** LineReaderImpl extension to test completion*/ + private class LineReaderCompletionImpl extends LineReaderImpl { + List list; + + LineReaderCompletionImpl(Terminal terminal) throws IOException { + super(terminal); + } + + List complete(final String line) { + buf.write(line); + buf.cursor(line.length()); + doComplete(CompletionType.Complete, true, true); + final String buffer = buf.toString(); + buf.clear(); + return list == null || buffer.length() > line.length() + ? Collections.singletonList(new Candidate(buffer.trim())) + : list; + } + + protected boolean doMenu( + List original, + String completed, + BiFunction escaper) { + list = original; + return true; + } + } + + private static SqlLine.Status begin( + SqlLine sqlLine, OutputStream os, boolean saveHistory, String... args) { + try { + PrintStream beelineOutputStream = getPrintStream(os); + sqlLine.setOutputStream(beelineOutputStream); + sqlLine.setErrorStream(beelineOutputStream); + final InputStream is = new ByteArrayInputStream(new byte[0]); + return sqlLine.begin(args, is, saveHistory); + } catch (Throwable t) { + // fail + throw new RuntimeException(t); + } + } + + private static PrintStream getPrintStream(OutputStream os) { + try { + return new PrintStream(os, false, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + // fail + throw new RuntimeException(e); + } + } +} + +// End CompletionTest.java