Skip to content

Commit

Permalink
Add CompletionMatcher in order to allow customize completion matchers
Browse files Browse the repository at this point in the history
  • Loading branch information
mattirn committed Dec 19, 2020
1 parent 28868af commit f163d40
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 140 deletions.
48 changes: 48 additions & 0 deletions reader/src/main/java/org/jline/reader/CompletionMatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2002-2020, the original author or authors.
*
* This software is distributable under the BSD license. See the terms of the
* BSD license in the documentation provided with this software.
*
* https://opensource.org/licenses/BSD-3-Clause
*/
package org.jline.reader;

import java.util.List;
import java.util.Map;

public interface CompletionMatcher {

/**
* Compiles completion matcher functions
*
* @param options LineReader options
* @param prefix invoked by complete-prefix or expand-or-complete-prefix widget
* @param line The parsed line within which completion has been requested
* @param caseInsensitive if completion is case insensitive or not
* @param errors number of errors accepted in matching
* @param originalGroupName value of JLineReader variable original-group-name
*/
void compile(Map<LineReader.Option, Boolean> options, boolean prefix, CompletingParsedLine line
, boolean caseInsensitive, int errors, String originalGroupName);

/**
*
* @param candidates list of candidates
* @return a map of candidates that completion matcher matches
*/
List<Candidate> matches(List<Candidate> candidates);

/**
*
* @return a candidate that have exact match, null if no exact match found
*/
Candidate exactMatch();

/**
*
* @return a common prefix of matched candidates
*/
String getCommonPrefix();

}
8 changes: 7 additions & 1 deletion reader/src/main/java/org/jline/reader/LineReader.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2002-2019, the original author or authors.
* Copyright (c) 2002-2020, the original author or authors.
*
* This software is distributable under the BSD license. See the terms of the
* BSD license in the documentation provided with this software.
Expand Down Expand Up @@ -388,6 +388,7 @@ public interface LineReader {

enum Option {
COMPLETE_IN_WORD,
COMPLETE_MATCHER_CAMELCASE,
DISABLE_EVENT_EXPANSION,
HISTORY_VERIFY,
HISTORY_IGNORE_SPACE(true),
Expand Down Expand Up @@ -467,6 +468,11 @@ enum Option {
this.def = def;
}

public final boolean isSet(Map<Option, Boolean> options) {
Boolean b = options.get(this);
return b != null ? b : this.isDef();
}

public boolean isDef() {
return def;
}
Expand Down
11 changes: 10 additions & 1 deletion reader/src/main/java/org/jline/reader/LineReaderBuilder.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2002-2018, the original author or authors.
* Copyright (c) 2002-2020, the original author or authors.
*
* This software is distributable under the BSD license. See the terms of the
* BSD license in the documentation provided with this software.
Expand Down Expand Up @@ -36,6 +36,7 @@ public static LineReaderBuilder builder() {
Highlighter highlighter;
Parser parser;
Expander expander;
CompletionMatcher completionMatcher;

private LineReaderBuilder() {
}
Expand Down Expand Up @@ -103,6 +104,11 @@ public LineReaderBuilder expander(Expander expander) {
return this;
}

public LineReaderBuilder completionMatcher(CompletionMatcher completionMatcher) {
this.completionMatcher = completionMatcher;
return this;
}

public LineReader build() {
Terminal terminal = this.terminal;
if (terminal == null) {
Expand Down Expand Up @@ -133,6 +139,9 @@ public LineReader build() {
if (expander != null) {
reader.setExpander(expander);
}
if (completionMatcher != null) {
reader.setCompletionMatcher(completionMatcher);
}
for (Map.Entry<LineReader.Option, Boolean> e : options.entrySet()) {
reader.option(e.getKey(), e.getValue());
}
Expand Down
214 changes: 214 additions & 0 deletions reader/src/main/java/org/jline/reader/impl/CompletionMatcherImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2002-2020, the original author or authors.
*
* This software is distributable under the BSD license. See the terms of the
* BSD license in the documentation provided with this software.
*
* https://opensource.org/licenses/BSD-3-Clause
*/
package org.jline.reader.impl;

import org.jline.reader.Candidate;
import org.jline.reader.CompletingParsedLine;
import org.jline.reader.CompletionMatcher;
import org.jline.reader.LineReader;
import org.jline.utils.AttributedString;

import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class CompletionMatcherImpl implements CompletionMatcher {
protected Predicate<String> exact;
protected List<Function<Map<String, List<Candidate>>, Map<String, List<Candidate>>>> matchers;
private Map<String, List<Candidate>> matching;
private boolean caseInsensitive;

public CompletionMatcherImpl() {
}

protected void reset(boolean caseInsensitive) {
this.caseInsensitive = caseInsensitive;
exact = s -> false;
matchers = new ArrayList<>();
matching = null;
}

@Override
public void compile(Map<LineReader.Option, Boolean> options, boolean prefix, CompletingParsedLine line
, boolean caseInsensitive, int errors, String originalGroupName) {
reset(caseInsensitive);
defaultMatchers(options, prefix, line, caseInsensitive, errors, originalGroupName);
if (LineReader.Option.COMPLETE_MATCHER_CAMELCASE.isSet(options)) {
matchers.add(simpleMatcher(candidate -> camelMatch(line.word(), 0, candidate, 0)));
}
}

@Override
public List<Candidate> matches(List<Candidate> candidates) {
matching = Collections.emptyMap();
Map<String, List<Candidate>> sortedCandidates = sort(candidates);
for (Function<Map<String, List<Candidate>>,
Map<String, List<Candidate>>> matcher : matchers) {
matching = matcher.apply(sortedCandidates);
if (!matching.isEmpty()) {
break;
}
}
return !matching.isEmpty() ? matching.entrySet().stream().flatMap(e -> e.getValue().stream()).collect(Collectors.toList())
: new ArrayList<>();
}

@Override
public Candidate exactMatch() {
if (matching == null) {
throw new IllegalStateException();
}
return matching.values().stream().flatMap(Collection::stream)
.filter(Candidate::complete)
.filter(c -> exact.test(c.value()))
.findFirst().orElse(null);
}

@Override
public String getCommonPrefix() {
if (matching == null) {
throw new IllegalStateException();
}
String commonPrefix = null;
for (String key : matching.keySet()) {
commonPrefix = commonPrefix == null ? key : getCommonStart(commonPrefix, key, caseInsensitive);
}
return commonPrefix;
}

/**
* Default JLine matchers
*/
protected void defaultMatchers(Map<LineReader.Option, Boolean> options, boolean prefix, CompletingParsedLine line
, boolean caseInsensitive, int errors, String originalGroupName) {
// Find matchers
// TODO: glob completion
if (prefix) {
String wd = line.word();
String wdi = caseInsensitive ? wd.toLowerCase() : wd;
String wp = wdi.substring(0, line.wordCursor());
matchers = new ArrayList<>(Arrays.asList(
simpleMatcher(s -> (caseInsensitive ? s.toLowerCase() : s).startsWith(wp)),
simpleMatcher(s -> (caseInsensitive ? s.toLowerCase() : s).contains(wp)),
typoMatcher(wp, errors, caseInsensitive, originalGroupName)
));
exact = s -> caseInsensitive ? s.equalsIgnoreCase(wp) : s.equals(wp);
} else if (LineReader.Option.COMPLETE_IN_WORD.isSet(options)) {
String wd = line.word();
String wdi = caseInsensitive ? wd.toLowerCase() : wd;
String wp = wdi.substring(0, line.wordCursor());
String ws = wdi.substring(line.wordCursor());
Pattern p1 = Pattern.compile(Pattern.quote(wp) + ".*" + Pattern.quote(ws) + ".*");
Pattern p2 = Pattern.compile(".*" + Pattern.quote(wp) + ".*" + Pattern.quote(ws) + ".*");
matchers = new ArrayList<>(Arrays.asList(
simpleMatcher(s -> p1.matcher(caseInsensitive ? s.toLowerCase() : s).matches()),
simpleMatcher(s -> p2.matcher(caseInsensitive ? s.toLowerCase() : s).matches()),
typoMatcher(wdi, errors, caseInsensitive, originalGroupName)
));
exact = s -> caseInsensitive ? s.equalsIgnoreCase(wd) : s.equals(wd);
} else {
String wd = line.word();
String wdi = caseInsensitive ? wd.toLowerCase() : wd;
if (LineReader.Option.EMPTY_WORD_OPTIONS.isSet(options) || wd.length() > 0) {
matchers = new ArrayList<>(Arrays.asList(
simpleMatcher(s -> (caseInsensitive ? s.toLowerCase() : s).startsWith(wdi)),
simpleMatcher(s -> (caseInsensitive ? s.toLowerCase() : s).contains(wdi)),
typoMatcher(wdi, errors, caseInsensitive, originalGroupName)
));
} else {
matchers = Collections.singletonList(simpleMatcher(s -> !s.startsWith("-")));
}
exact = s -> caseInsensitive ? s.equalsIgnoreCase(wd) : s.equals(wd);
}
}

protected Function<Map<String, List<Candidate>>,
Map<String, List<Candidate>>> simpleMatcher(Predicate<String> predicate) {
return m -> m.entrySet().stream()
.filter(e -> predicate.test(e.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}

protected Function<Map<String, List<Candidate>>,
Map<String, List<Candidate>>> typoMatcher(String word, int errors, boolean caseInsensitive, String originalGroupName) {
return m -> {
Map<String, List<Candidate>> map = m.entrySet().stream()
.filter(e -> ReaderUtils.distance(word, caseInsensitive ? e.getKey() : e.getKey().toLowerCase()) < errors)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
if (map.size() > 1) {
map.computeIfAbsent(word, w -> new ArrayList<>())
.add(new Candidate(word, word, originalGroupName, null, null, null, false));
}
return map;
};
}

protected boolean camelMatch(String word, int i, String candidate, int j) {
if (word.length() <= i) {
return true;
} else {
char c = word.charAt(i);
if (candidate.length() <= j) {
return false;
}
if (c == candidate.charAt(j)) {
if (camelMatch(word, i + 1, candidate, j + 1)) {
return true;
}
}
for (int j1 = j; j1 < candidate.length(); j1++) {
if (Character.isUpperCase(candidate.charAt(j1))) {
if (Character.toUpperCase(c) == candidate.charAt(j1)) {
if (camelMatch(word, i + 1, candidate, j1 + 1)) {
return true;
}
}
}
}
return false;
}
}

private Map<String, List<Candidate>> sort(List<Candidate> candidates) {
// Build a list of sorted candidates
Map<String, List<Candidate>> sortedCandidates = new HashMap<>();
for (Candidate candidate : candidates) {
sortedCandidates
.computeIfAbsent(AttributedString.fromAnsi(candidate.value()).toString(), s -> new ArrayList<>())
.add(candidate);
}
return sortedCandidates;
}

private String getCommonStart(String str1, String str2, boolean caseInsensitive) {
int[] s1 = str1.codePoints().toArray();
int[] s2 = str2.codePoints().toArray();
int len = 0;
while (len < Math.min(s1.length, s2.length)) {
int ch1 = s1[len];
int ch2 = s2[len];
if (ch1 != ch2 && caseInsensitive) {
ch1 = Character.toUpperCase(ch1);
ch2 = Character.toUpperCase(ch2);
if (ch1 != ch2) {
ch1 = Character.toLowerCase(ch1);
ch2 = Character.toLowerCase(ch2);
}
}
if (ch1 != ch2) {
break;
}
len++;
}
return new String(s1, 0, len);
}

}
Loading

0 comments on commit f163d40

Please sign in to comment.