jline / jline3

JLine is a Java library for handling console input.
Other
1.46k stars 215 forks source link

Nested ArgumentCompleter not working as expected #35

Closed loetermann closed 7 years ago

loetermann commented 7 years ago

I tried to create a completer for the following command syntax: Command1 [Param1 (Option1 | Option2) | Param2 | Param3] Here is what I tried (JLine Version 3.0.1):

Terminal terminal = TerminalBuilder.builder().build();
Completer completer = new ArgumentCompleter(
        new StringsCompleter("Command1"),
        new AggregateCompleter(new ArgumentCompleter(
                new StringsCompleter("Param1"),
                new StringsCompleter("Option1", "Option2")
        ),
                new StringsCompleter("Param2", "Param3")
        )
);
LineReader lineReader = LineReaderBuilder.builder()
        .terminal(terminal)
        .completer(completer).build();
while (true) {
    lineReader.readLine();
}

Com + tab is completed correctly to Command1. Command1 Par + tab is completed to Param2 Param3 but I expected Param1 to be in the list as well. Command1 Param1 + tab is completed to Param2 Param3, so it is somehow parsed correctly as the completion still works (in the unexpected way). I expected Option1 Option2 in this case and that Param2 Param3 is only proposed again when an option is specified. Command1 Param1 Opt + tab does not result in any completion proposals.

In case I misunderstood the architecture and this result is the expected one, I would appreciate some help how to implement my desired completer.

gnodet commented 7 years ago

This is not possible out of the box.

You could leverage the org.jline.builtins.Completers helpers but it only support options (starting with '-') with a following string, not arguments with a following string. Another option is to write your own completer:

    completer = (reader, line, candidates) -> {
        if (line.wordIndex() == 0) {
            candidates.add(new Candidate("Command1"));
        } else if (line.words().get(0).equals("Command1")) {
            if (line.words().get(line.wordIndex() - 1).equals("Option1")) {
                candidates.add(new Candidate("Param1"));
                candidates.add(new Candidate("Param2"));
            } else {
                if (line.wordIndex() == 1) {
                    candidates.add(new Candidate("Option1"));
                }
                if (!line.words().contains("Option2")) {
                    candidates.add(new Candidate("Option2"));
                }
                if (!line.words().contains("Option3")) {
                    candidates.add(new Candidate("Option3"));
                }
            }
        }
    };
loetermann commented 7 years ago

Thanks for the answer! I took a look into the implementation of the ArgumentCompleter and understood that it's sub-completers are supposed to return Candidates which represent exactly one word. I wonder if this is true for Candidates in general, but I will put that into a separate issue.

The Completers class looks almost like what I need. My goal is to develop a dynamic Completer or a Completer Factory based on JCommander objects (see http://jcommander.org/). Basically I think I only have to change the '-' into a variable character and add support for subcommands, like git add ... (this could become complicated again).

I still wonder if a general completer like I had in mind in the beginning would be possible. I think the problem is the ParsedLine. If I could derive a subclass (FakeLine) from a ParsedLine with the features to move the cursor and to discard everything before the cursor, I think the following code would work (more or less):

public class SequenceCompleter implements Completer {

    private final List<Completer> completers;

    public SequenceCompleter(List<Completer> completers) {
        this.completers = completers;
    }

    @Override
    public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
        // Derive a FakeLine from any ParsedLine
        FakeLine fakeLine = new FakeParsedLine(line);
        // create a new FakeLine with a cursor moved by one
        fakeLine = fakeLine.setCursor(0);
        complete(reader, line, candidates, fakeLine, 0);
    }

    private void complete(LineReader reader, ParsedLine line, List<Candidate> candidates,
            FakeLine fakeLine, int currentCompleterIndex, int currentIndex) {
        if (currentCompleterIndex >= completers.size() || currentIndex > line.cursor()) {
            return;
        }
        Completer currentCompleter = completers.get(currentCompleterIndex);
        String parsedText = fakeLine.line().substring(0, fakeLine.cursor()));
        List<Candidate> subCandidates = Lists.newArrayList();
        currentCompleter.complete(reader, fakeLine, subCandidates);
        for (Candidate subCandidate : subCandidates) {
            if(line.cursor() == currentIndex) {
                if(subCandidate.complete()
                        && completers.size() > currentCompleterIndex + 1) {
                    // the following two line would simply create a copy of
                    // the candidate with some modifications
                    subCandidate.append(" ");
                    subCandidate.setComplete(false);
                    candidates.add(subCandidate);
                } else {
                    candidates.add(subCandidate);
                }
            }else if (subCandidate.complete() 
                    && subCandidate.value().equals(parsedText) {
                // create a new FakeLine from the currentIndex till the end
                FakeLine newFakeLine = fakeLine.subLine(fakeLine.cursor());
                complete(reader, line, candidates,
                            newFakeLine, currentCompleterIndex+1, currentIndex+1);
            }
        }
        if(!subCandidates.isEmpty()) {
            // create a new ParsedLine with a cursor moved by one
            FakeLine newFakeLine = fakeLine.setCursor(fakeLine.cursor()+1);
            complete(reader, line, candidates,
                           fakeLine, currentCompleterIndex, currentIndex+1);
        }
    }
}

The problem with moving the cursor is updating the wordCursor/Index accordingly. I thought about generating a new ParsedLine by feeding a substring of the unparsed line into the Parser but this could be problematic if the substring is not well formed, e.g. if command "composed parameter" -option is split within the quotes.

I'm aware that the runtime of this method can grow rapidly due to the recursion within the loop but it should be fine in the normal cases.

I would appreciate any further help on this topic. Otherwise this issue can be closed.

gnodet commented 7 years ago

We do have a similar use case in Karaf, though the annotations are slightly different. We use the following ArgumentCompleter. Note that each option or argument on the command may have its own simple completer and we combine them. At first glance, your proposal is quite similar. For verifying individual arguments, we use a ParsedLine which contains a single argument, see the code below

    protected boolean verifyCompleter(Session session, Completer completer, String argument) {
        List<Candidate> candidates = new ArrayList<>();
        completer.completeCandidates(session, new ArgumentCommandLine(argument, argument.length()), candidates);
        return !candidates.isEmpty();
    }

Note that the API is slightly different as Karaf provides its own API, but it's really close.