diff --git a/builtins/src/main/java/org/jline/builtins/Commands.java b/builtins/src/main/java/org/jline/builtins/Commands.java index fe117baee..11808b4ed 100644 --- a/builtins/src/main/java/org/jline/builtins/Commands.java +++ b/builtins/src/main/java/org/jline/builtins/Commands.java @@ -12,6 +12,8 @@ import java.io.InputStream; import java.io.PrintStream; import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -24,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.regex.Pattern; import java.util.Set; import java.util.TreeSet; import java.util.function.Consumer; @@ -31,6 +34,7 @@ import java.util.function.Supplier; import org.jline.builtins.Completers.CompletionData; +import org.jline.builtins.Options; import org.jline.builtins.Source.StdInSource; import org.jline.builtins.Source.URLSource; import org.jline.keymap.KeyMap; @@ -159,46 +163,113 @@ public static void less(Terminal terminal, InputStream in, PrintStream out, Prin } public static void history(LineReader reader, PrintStream out, PrintStream err, - String[] argv) throws IOException { + String[] argv) throws IOException, IllegalArgumentException { final String[] usage = { "history - list history of commands", - "Usage: history [OPTIONS]", + "Usage: history [-dnrfEi] [-m match] [first] [last]", + " history -ARWI [filename]", + " history --clear", + " history --save", " -? --help Displays command help", " --clear Clear history", " --save Save history", - " -d Print timestamps for each event"}; - + " -m match If option -m is present the first argument is taken as a pattern", + " and only the history events matching the pattern will be shown", + " -d Print timestamps for each event", + " -f Print full time-date stamps in the US format", + " -E Print full time-date stamps in the European format", + " -i Print full time-date stamps in ISO8601 format", + " -n Suppresses command numbers", + " -r Reverses the order of the commands", + " -A Appends the history out to the given file", + " -R Reads the history from the given file", + " -W Writes the history out to the given file", + " -I If added to -R, only the events that are not contained within the internal list are added", + " If added to -W/A, only the events that are new since the last incremental operation to", + " the file are added", + " [first] [last] These optional arguments are numbers. A negative number is", + " used as an offset to the current history event number"}; Options opt = Options.compile(usage).parse(argv); if (opt.isSet("help")) { opt.usage(err); return; } - if (!opt.args().isEmpty()) { - err.println("usage: history [OPTIONS]"); - return; - } - History history = reader.getHistory(); + boolean done = true; + boolean increment = opt.isSet("I") ? true : false; if (opt.isSet("clear")) { history.purge(); - } - if (opt.isSet("save")) { + } else if (opt.isSet("save")) { history.save(); + } else if (opt.isSet("A")) { + Path file = opt.args().size() > 0 ? Paths.get(opt.args().get(0)) : null; + history.append(file, increment); + } else if (opt.isSet("R")) { + Path file = opt.args().size() > 0 ? Paths.get(opt.args().get(0)) : null; + history.read(file, increment); + } else if (opt.isSet("W")) { + Path file = opt.args().size() > 0 ? Paths.get(opt.args().get(0)) : null; + history.write(file, increment); + } else { + done = false; } - if (opt.isSet("clear") || opt.isSet("save")) { + if (done) { return; } + int argId = 0; + Pattern pattern = null; + if (opt.isSet("m")) { + if (opt.args().size() == 0) { + throw new IllegalArgumentException(); + } + String sp = opt.args().get(argId++); + pattern = Pattern.compile(sp.toString()); + } + int firstId = opt.args().size() > argId ? parseInteger(opt.args().get(argId++)) : -17; + int lastId = opt.args().size() > argId ? parseInteger(opt.args().get(argId++)) : -1; + firstId = historyId(firstId, history.size() - 1); + lastId = historyId(lastId, history.size() - 1); + if (firstId > lastId) { + throw new IllegalArgumentException(); + } + int tot = lastId - firstId + 1; + int listed = 0; final Highlighter highlighter = reader.getHighlighter(); - for (History.Entry entry : history) { + Iterator iter = null; + if (opt.isSet("r")) { + iter = history.reverseIterator(lastId); + } else { + iter = history.iterator(firstId); + } + while (iter.hasNext() && listed < tot) { + History.Entry entry = iter.next(); + listed++; + if (pattern != null && !pattern.matcher(entry.line()).matches()) { + continue; + } AttributedStringBuilder sb = new AttributedStringBuilder(); - sb.append(" "); - sb.styled(AttributedStyle::bold, String.format("%3d", entry.index() + 1)); - if (opt.isSet("d")) { + if (!opt.isSet("n")) { sb.append(" "); - LocalTime lt = LocalTime.from(entry.time().atZone(ZoneId.systemDefault())) - .truncatedTo(ChronoUnit.SECONDS); - DateTimeFormatter.ISO_LOCAL_TIME.formatTo(lt, sb); + sb.styled(AttributedStyle::bold, String.format("%3d", entry.index())); + } + if (opt.isSet("d") || opt.isSet("f") || opt.isSet("E") || opt.isSet("i")) { + sb.append(" "); + if (opt.isSet("d")) { + LocalTime lt = LocalTime.from(entry.time().atZone(ZoneId.systemDefault())) + .truncatedTo(ChronoUnit.SECONDS); + DateTimeFormatter.ISO_LOCAL_TIME.formatTo(lt, sb); + } else { + LocalDateTime lt = LocalDateTime.from(entry.time().atZone(ZoneId.systemDefault()) + .truncatedTo(ChronoUnit.MINUTES)); + String format = "yyyy-MM-dd hh:mm"; + if (opt.isSet("f")) { + format = "MM/dd/yy hh:mm"; + } else if (opt.isSet("E")) { + format = "dd.MM.yyyy hh:mm"; + } + DateTimeFormatter.ofPattern(format).formatTo(lt, sb); + } } sb.append(" "); sb.append(highlighter.highlight(reader, entry.line())); @@ -206,7 +277,28 @@ public static void history(LineReader reader, PrintStream out, PrintStream err, } } - public static void complete(LineReader reader, PrintStream out, PrintStream err, + private static int historyId(int id, int maxId) { + int out = id; + if (id < 0) { + out = maxId + id + 1; + } + if (out < 0) { + out = 0; + } else if (out > maxId) { + out = maxId; + } + return out; + } + + private static int parseInteger(String s) throws IllegalArgumentException { + try { + return Integer.parseInt(s); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException(); + } + } + + public static void complete(LineReader reader, PrintStream out, PrintStream err, Map> completions, String[] argv) { final String[] usage = { diff --git a/builtins/src/main/java/org/jline/builtins/Options.java b/builtins/src/main/java/org/jline/builtins/Options.java index e49b4ec51..d07889f80 100644 --- a/builtins/src/main/java/org/jline/builtins/Options.java +++ b/builtins/src/main/java/org/jline/builtins/Options.java @@ -398,7 +398,7 @@ else if (needArg != null) { needArg = null; needOpt = null; } - else if (!arg.startsWith("-") || "-".equals(oarg)) { + else if (!arg.startsWith("-") || (arg.length() > 1 && Character.isDigit(arg.charAt(1))) || "-".equals(oarg)) { if (optionsFirst) endOpt = true; xargs.add(oarg); diff --git a/builtins/src/test/java/org/jline/example/Example.java b/builtins/src/test/java/org/jline/example/Example.java index ed0be2fc4..febdacb38 100644 --- a/builtins/src/test/java/org/jline/example/Example.java +++ b/builtins/src/test/java/org/jline/example/Example.java @@ -17,6 +17,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import org.jline.builtins.Commands; import org.jline.builtins.Completers; import org.jline.builtins.Completers.TreeCompleter; import org.jline.keymap.KeyMap; @@ -134,7 +135,7 @@ public static void main(String[] args) throws IOException { case "brackets": prompt = "long-prompt> "; DefaultParser p2 = new DefaultParser(); - p2.eofOnUnclosedBracket(Bracket.CURLY,Bracket.ROUND,Bracket.SQUARE); + p2.setEofOnUnclosedBracket(Bracket.CURLY, Bracket.ROUND, Bracket.SQUARE); parser = p2; break label; case "foo": @@ -305,6 +306,7 @@ public void complete(LineReader reader, ParsedLine line, List candida break; } ParsedLine pl = reader.getParser().parse(line, 0); + String[] argv = pl.words().subList(1, pl.words().size()).toArray(new String[0]); if ("set".equals(pl.word())) { if (pl.words().size() == 3) { reader.setVariable(pl.words().get(1), pl.words().get(2)); @@ -380,6 +382,9 @@ else if ("cls".equals(pl.word())) { else if ("sleep".equals(pl.word())) { Thread.sleep(3000); } + else if ("history".equals(pl.word())) { + Commands.history(reader, System.out, System.err, argv); + } } } catch (Throwable t) { diff --git a/reader/src/main/java/org/jline/reader/History.java b/reader/src/main/java/org/jline/reader/History.java index ac8aff3e1..274d633ba 100644 --- a/reader/src/main/java/org/jline/reader/History.java +++ b/reader/src/main/java/org/jline/reader/History.java @@ -9,6 +9,7 @@ package org.jline.reader; import java.io.IOException; +import java.nio.file.Path; import java.time.Instant; import java.util.Iterator; import java.util.ListIterator; @@ -41,6 +42,26 @@ public interface History extends Iterable */ void save() throws IOException; + /** + * Write history to the file. If incremental only the events that are new since the last incremental operation to + * the file are added. + * @throws IOException if a problem occurs + */ + void write(Path file, boolean incremental) throws IOException; + + /** + * Append history to the file. If incremental only the events that are new since the last incremental operation to + * the file are added. + * @throws IOException if a problem occurs + */ + void append(Path file, boolean incremental) throws IOException; + + /** + * Read history from the file. If incremental only the events that are not contained within the internal list are added. + * @throws IOException if a problem occurs + */ + void read(Path file, boolean incremental) throws IOException; + /** * Purge history. * @throws IOException if a problem occurs diff --git a/reader/src/main/java/org/jline/reader/impl/history/DefaultHistory.java b/reader/src/main/java/org/jline/reader/impl/history/DefaultHistory.java index bd4ceaa1e..45b211881 100644 --- a/reader/src/main/java/org/jline/reader/impl/history/DefaultHistory.java +++ b/reader/src/main/java/org/jline/reader/impl/history/DefaultHistory.java @@ -37,8 +37,7 @@ public class DefaultHistory implements History { private LineReader reader; - private int lastLoaded = 0; - private int nbEntriesInFile = 0; + private Map historyFiles = new HashMap<>(); private int offset = 0; private int index = 0; @@ -85,8 +84,7 @@ public void load() throws IOException { try (BufferedReader reader = Files.newBufferedReader(path)) { internalClear(); reader.lines().forEach(line -> addHistoryLine(path, line)); - lastLoaded = items.size(); - nbEntriesInFile = lastLoaded; + setHistoryFileData(path, new HistoryFileData(items.size(), items.size())); maybeResize(); } } @@ -98,7 +96,80 @@ public void load() throws IOException { } } + @Override + public void read(Path file, boolean incremental) throws IOException { + Path path = file != null ? file : getPath(); + if (path != null) { + try { + if (Files.exists(path)) { + Log.trace("Reading history from: ", path); + try (BufferedReader reader = Files.newBufferedReader(path)) { + reader.lines().forEach(line -> addHistoryLine(path, line, incremental)); + setHistoryFileData(path, new HistoryFileData(items.size(), items.size())); + maybeResize(); + } + } + } catch (IllegalArgumentException | IOException e) { + Log.debug("Failed to read history; clearing", e); + internalClear(); + throw e; + } + } + } + + private String doHistoryFileDataKey (Path path){ + return path != null ? path.toAbsolutePath().toString() : null; + } + + private HistoryFileData getHistoryFileData(Path path) { + String key = doHistoryFileDataKey(path); + if (!historyFiles.containsKey(key)){ + historyFiles.put(key, new HistoryFileData()); + } + return historyFiles.get(key); + } + + private void setHistoryFileData(Path path, HistoryFileData historyFileData) { + historyFiles.put(doHistoryFileDataKey(path), historyFileData); + } + + private boolean isLineReaderHistory (Path path) throws IOException { + Path lrp = getPath(); + if (lrp == null) { + if (path != null) { + return false; + } else { + return true; + } + } + return Files.isSameFile(lrp, path); + } + + private void setLastLoaded(Path path, int lastloaded){ + getHistoryFileData(path).setLastLoaded(lastloaded); + } + + private void setEntriesInFile(Path path, int entriesInFile){ + getHistoryFileData(path).setEntriesInFile(entriesInFile); + } + + private void incEntriesInFile(Path path, int amount){ + getHistoryFileData(path).incEntriesInFile(amount); + } + + private int getLastLoaded(Path path){ + return getHistoryFileData(path).getLastLoaded(); + } + + private int getEntriesInFile(Path path){ + return getHistoryFileData(path).getEntriesInFile(); + } + protected void addHistoryLine(Path path, String line) { + addHistoryLine(path, line, false); + } + + protected void addHistoryLine(Path path, String line, boolean checkDuplicates) { if (reader.isSet(LineReader.Option.HISTORY_TIMESTAMPED)) { int idx = line.indexOf(':'); final String badHistoryFileSyntax = "Bad history file syntax! " + @@ -115,10 +186,10 @@ protected void addHistoryLine(Path path, String line) { } String unescaped = unescape(line.substring(idx + 1)); - internalAdd(time, unescaped); + internalAdd(time, unescaped, checkDuplicates); } else { - internalAdd(Instant.now(), unescape(line)); + internalAdd(Instant.now(), unescape(line), checkDuplicates); } } @@ -132,29 +203,46 @@ public void purge() throws IOException { } } + @Override + public void write(Path file, boolean incremental) throws IOException { + Path path = file != null ? file : getPath(); + if (path != null && Files.exists(path)) { + path.toFile().delete(); + } + internalWrite(path, incremental ? getLastLoaded(path) : 0); + } + + @Override + public void append(Path file, boolean incremental) throws IOException { + internalWrite(file != null ? file : getPath(), + incremental ? getLastLoaded(file) : 0); + } + @Override public void save() throws IOException { - Path path = getPath(); + internalWrite(getPath(), getLastLoaded(getPath())); + } + + private void internalWrite(Path path, int from) throws IOException { if (path != null) { Log.trace("Saving history to: ", path); Files.createDirectories(path.toAbsolutePath().getParent()); // Append new items to the history file try (BufferedWriter writer = Files.newBufferedWriter(path.toAbsolutePath(), StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE)) { - for (Entry entry : items.subList(lastLoaded, items.size())) { + for (Entry entry : items.subList(from, items.size())) { if (isPersistable(entry)) { writer.append(format(entry)); } } } - nbEntriesInFile += items.size() - lastLoaded; - // If we are over 25% max size, trim history file + incEntriesInFile(path, items.size() - from); int max = getInt(reader, LineReader.HISTORY_FILE_SIZE, DEFAULT_HISTORY_FILE_SIZE); - if (nbEntriesInFile > max + max / 4) { + if (getEntriesInFile(path) > max + max / 4) { trimHistory(path, max); } } - lastLoaded = items.size(); + setLastLoaded(path, items.size()); } protected void trimHistory(Path path, int max) throws IOException { @@ -180,11 +268,14 @@ protected void trimHistory(Path path, int max) throws IOException { } Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING); // Keep items in memory - internalClear(); - offset = allItems.get(0).index(); - items.addAll(allItems); - lastLoaded = items.size(); - nbEntriesInFile = items.size(); + if (isLineReaderHistory(path)) { + internalClear(); + offset = allItems.get(0).index(); + items.addAll(allItems); + setHistoryFileData(path, new HistoryFileData(items.size(), items.size())); + } else { + setEntriesInFile(path, allItems.size()); + } maybeResize(); } @@ -202,8 +293,7 @@ protected EntryImpl createEntry(int index, Instant time, String line) { private void internalClear() { offset = 0; index = 0; - lastLoaded = 0; - nbEntriesInFile = 0; + historyFiles = new HashMap<>(); items.clear(); } @@ -310,7 +400,18 @@ protected boolean matchPatterns(String patterns, String line) { } protected void internalAdd(Instant time, String line) { + internalAdd(time, line, false); + } + + protected void internalAdd(Instant time, String line, boolean checkDuplicates) { Entry entry = new EntryImpl(offset + items.size(), time, line); + if (checkDuplicates) { + for (Entry e: items) { + if (e.line().trim().equals(line.trim())) { + return; + } + } + } items.add(entry); maybeResize(); } @@ -318,7 +419,9 @@ protected void internalAdd(Instant time, String line) { private void maybeResize() { while (size() > getInt(reader, LineReader.HISTORY_SIZE, DEFAULT_HISTORY_SIZE)) { items.removeFirst(); - lastLoaded--; + for (HistoryFileData hfd: historyFiles.values()) { + hfd.decLastLoaded(); + } offset++; } index = size(); @@ -511,5 +614,46 @@ static String unescape(String s) { return sb.toString(); } + private class HistoryFileData { + private int lastLoaded = 0; + private int entriesInFile = 0; + + public HistoryFileData() { + } + + public HistoryFileData(int lastLoaded, int entriesInFile) { + this.lastLoaded = lastLoaded; + this.entriesInFile = entriesInFile; + } + + public int getLastLoaded() { + return lastLoaded; + } + + public void setLastLoaded(int lastLoaded) { + this.lastLoaded = lastLoaded; + } + + public void decLastLoaded() { + lastLoaded = lastLoaded - 1; + if (lastLoaded < 0) { + lastLoaded = 0; + } + } + + public int getEntriesInFile() { + return entriesInFile; + } + + public void setEntriesInFile(int entriesInFile) { + this.entriesInFile = entriesInFile; + } + + public void incEntriesInFile(int amount) { + entriesInFile = entriesInFile + amount; + } + + } + }