1 /*
2  * Copyright (c) 2002-2018, the original author or authors.
3  *
4  * This software is distributable under the BSD license. See the terms of the
5  * BSD license in the documentation provided with this software.
6  *
7  * https://opensource.org/licenses/BSD-3-Clause
8  */
9 package jdk.internal.org.jline.reader.impl.history;
10 
11 import java.io.*;
12 import java.nio.file.*;
13 import java.time.DateTimeException;
14 import java.time.Instant;
15 import java.util.*;
16 
17 import jdk.internal.org.jline.reader.History;
18 import jdk.internal.org.jline.reader.LineReader;
19 import jdk.internal.org.jline.utils.Log;
20 
21 import static jdk.internal.org.jline.reader.LineReader.HISTORY_IGNORE;
22 import static jdk.internal.org.jline.reader.impl.ReaderUtils.*;
23 
24 /**
25  * {@link History} using a file for persistent backing.
26  * <p>
27  * Implementers should install shutdown hook to call {@link DefaultHistory#save}
28  * to save history to disk.
29  * </p>
30  */
31 public class DefaultHistory implements History {
32 
33     public static final int DEFAULT_HISTORY_SIZE = 500;
34     public static final int DEFAULT_HISTORY_FILE_SIZE = 10000;
35 
36     private final LinkedList<Entry> items = new LinkedList<>();
37 
38     private LineReader reader;
39 
40     private Map<String, HistoryFileData> historyFiles = new HashMap<>();
41     private int offset = 0;
42     private int index = 0;
43 
DefaultHistory()44     public DefaultHistory() {
45     }
46 
DefaultHistory(LineReader reader)47     public DefaultHistory(LineReader reader) {
48         attach(reader);
49     }
50 
getPath()51     private Path getPath() {
52         Object obj = reader != null ? reader.getVariables().get(LineReader.HISTORY_FILE) : null;
53         if (obj instanceof Path) {
54             return (Path) obj;
55         } else if (obj instanceof File) {
56             return ((File) obj).toPath();
57         } else if (obj != null) {
58             return Paths.get(obj.toString());
59         } else {
60             return null;
61         }
62     }
63 
64     @Override
attach(LineReader reader)65     public void attach(LineReader reader) {
66         if (this.reader != reader) {
67             this.reader = reader;
68             try {
69                 load();
70             }
71             catch (IllegalArgumentException | IOException e) {
72                 Log.warn("Failed to load history", e);
73             }
74         }
75     }
76 
77     @Override
load()78     public void load() throws IOException {
79         Path path = getPath();
80         if (path != null) {
81             try {
82                 if (Files.exists(path)) {
83                     Log.trace("Loading history from: ", path);
84                     try (BufferedReader reader = Files.newBufferedReader(path)) {
85                         internalClear();
86                         reader.lines().forEach(line -> addHistoryLine(path, line));
87                         setHistoryFileData(path, new HistoryFileData(items.size(), items.size()));
88                         maybeResize();
89                     }
90                 }
91             } catch (IllegalArgumentException | IOException e) {
92                 Log.debug("Failed to load history; clearing", e);
93                 internalClear();
94                 throw e;
95             }
96         }
97     }
98 
99     @Override
read(Path file, boolean incremental)100     public void read(Path file, boolean incremental) throws IOException {
101         Path path = file != null ? file : getPath();
102         if (path != null) {
103             try {
104                 if (Files.exists(path)) {
105                     Log.trace("Reading history from: ", path);
106                     try (BufferedReader reader = Files.newBufferedReader(path)) {
107                         reader.lines().forEach(line -> addHistoryLine(path, line, incremental));
108                         setHistoryFileData(path, new HistoryFileData(items.size(), items.size()));
109                         maybeResize();
110                     }
111                 }
112             } catch (IllegalArgumentException | IOException e) {
113                 Log.debug("Failed to read history; clearing", e);
114                 internalClear();
115                 throw e;
116             }
117         }
118     }
119 
doHistoryFileDataKey(Path path)120     private String doHistoryFileDataKey (Path path){
121         return path != null ? path.toAbsolutePath().toString() : null;
122     }
123 
getHistoryFileData(Path path)124     private HistoryFileData getHistoryFileData(Path path) {
125         String key = doHistoryFileDataKey(path);
126         if (!historyFiles.containsKey(key)){
127             historyFiles.put(key, new HistoryFileData());
128         }
129         return historyFiles.get(key);
130     }
131 
setHistoryFileData(Path path, HistoryFileData historyFileData)132     private void setHistoryFileData(Path path, HistoryFileData historyFileData) {
133         historyFiles.put(doHistoryFileDataKey(path), historyFileData);
134     }
135 
isLineReaderHistory(Path path)136     private boolean isLineReaderHistory (Path path) throws IOException {
137         Path lrp = getPath();
138         if (lrp == null) {
139             if (path != null) {
140                 return false;
141             } else {
142                 return true;
143             }
144         }
145         return Files.isSameFile(lrp, path);
146     }
147 
setLastLoaded(Path path, int lastloaded)148     private void setLastLoaded(Path path, int lastloaded){
149         getHistoryFileData(path).setLastLoaded(lastloaded);
150     }
151 
setEntriesInFile(Path path, int entriesInFile)152     private void setEntriesInFile(Path path, int entriesInFile){
153         getHistoryFileData(path).setEntriesInFile(entriesInFile);
154     }
155 
incEntriesInFile(Path path, int amount)156     private void incEntriesInFile(Path path, int amount){
157         getHistoryFileData(path).incEntriesInFile(amount);
158     }
159 
getLastLoaded(Path path)160     private int getLastLoaded(Path path){
161         return getHistoryFileData(path).getLastLoaded();
162     }
163 
getEntriesInFile(Path path)164     private int getEntriesInFile(Path path){
165         return getHistoryFileData(path).getEntriesInFile();
166     }
167 
addHistoryLine(Path path, String line)168     protected void addHistoryLine(Path path, String line) {
169         addHistoryLine(path, line, false);
170     }
171 
addHistoryLine(Path path, String line, boolean checkDuplicates)172     protected void addHistoryLine(Path path, String line, boolean checkDuplicates) {
173         if (reader.isSet(LineReader.Option.HISTORY_TIMESTAMPED)) {
174             int idx = line.indexOf(':');
175             final String badHistoryFileSyntax = "Bad history file syntax! " +
176                 "The history file `" + path + "` may be an older history: " +
177                 "please remove it or use a different history file.";
178             if (idx < 0) {
179                 throw new IllegalArgumentException(badHistoryFileSyntax);
180             }
181             Instant time;
182             try {
183                 time = Instant.ofEpochMilli(Long.parseLong(line.substring(0, idx)));
184             } catch (DateTimeException | NumberFormatException e) {
185                 throw new IllegalArgumentException(badHistoryFileSyntax);
186             }
187 
188             String unescaped = unescape(line.substring(idx + 1));
189             internalAdd(time, unescaped, checkDuplicates);
190         }
191         else {
192             internalAdd(Instant.now(), unescape(line), checkDuplicates);
193         }
194     }
195 
196     @Override
purge()197     public void purge() throws IOException {
198         internalClear();
199         Path path = getPath();
200         if (path != null) {
201             Log.trace("Purging history from: ", path);
202             Files.deleteIfExists(path);
203         }
204     }
205 
206     @Override
write(Path file, boolean incremental)207     public void write(Path file, boolean incremental) throws IOException {
208         Path path = file != null ? file : getPath();
209         if (path != null && Files.exists(path)) {
210             path.toFile().delete();
211         }
212         internalWrite(path, incremental ? getLastLoaded(path) : 0);
213     }
214 
215     @Override
append(Path file, boolean incremental)216     public void append(Path file, boolean incremental) throws IOException {
217         internalWrite(file != null ? file : getPath(),
218                       incremental ? getLastLoaded(file) : 0);
219     }
220 
221     @Override
save()222     public void save() throws IOException {
223         internalWrite(getPath(), getLastLoaded(getPath()));
224     }
225 
internalWrite(Path path, int from)226     private void internalWrite(Path path, int from) throws IOException {
227         if (path != null) {
228             Log.trace("Saving history to: ", path);
229             Files.createDirectories(path.toAbsolutePath().getParent());
230             // Append new items to the history file
231             try (BufferedWriter writer = Files.newBufferedWriter(path.toAbsolutePath(),
232               StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE)) {
233                 for (Entry entry : items.subList(from, items.size())) {
234                     if (isPersistable(entry)) {
235                         writer.append(format(entry));
236                     }
237                 }
238             }
239             incEntriesInFile(path, items.size() - from);
240             int max = getInt(reader, LineReader.HISTORY_FILE_SIZE, DEFAULT_HISTORY_FILE_SIZE);
241             if (getEntriesInFile(path) > max + max / 4) {
242                 trimHistory(path, max);
243             }
244         }
245         setLastLoaded(path, items.size());
246     }
247 
trimHistory(Path path, int max)248     protected void trimHistory(Path path, int max) throws IOException {
249         Log.trace("Trimming history path: ", path);
250         // Load all history entries
251         LinkedList<Entry> allItems = new LinkedList<>();
252         try (BufferedReader reader = Files.newBufferedReader(path)) {
253             reader.lines().forEach(l -> {
254                 int idx = l.indexOf(':');
255                 Instant time = Instant.ofEpochMilli(Long.parseLong(l.substring(0, idx)));
256                 String line = unescape(l.substring(idx + 1));
257                 allItems.add(createEntry(allItems.size(), time, line));
258             });
259         }
260         // Remove duplicates
261         doTrimHistory(allItems, max);
262         // Write history
263         Path temp = Files.createTempFile(path.toAbsolutePath().getParent(), path.getFileName().toString(), ".tmp");
264         try (BufferedWriter writer = Files.newBufferedWriter(temp, StandardOpenOption.WRITE)) {
265             for (Entry entry : allItems) {
266                 writer.append(format(entry));
267             }
268         }
269         Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING);
270         // Keep items in memory
271         if (isLineReaderHistory(path)) {
272             internalClear();
273             offset = allItems.get(0).index();
274             items.addAll(allItems);
275             setHistoryFileData(path, new HistoryFileData(items.size(), items.size()));
276         } else {
277             setEntriesInFile(path, allItems.size());
278         }
279         maybeResize();
280     }
281 
282     /**
283      * Create a history entry. Subclasses may override to use their own entry implementations.
284      * @param index index of history entry
285      * @param time entry creation time
286      * @param line the entry text
287      * @return entry object
288      */
createEntry(int index, Instant time, String line)289     protected EntryImpl createEntry(int index, Instant time, String line) {
290         return new EntryImpl(index, time, line);
291     }
292 
internalClear()293     private void internalClear() {
294         offset = 0;
295         index = 0;
296         historyFiles = new HashMap<>();
297         items.clear();
298     }
299 
doTrimHistory(List<Entry> allItems, int max)300     static void doTrimHistory(List<Entry> allItems, int max) {
301         int idx = 0;
302         while (idx < allItems.size()) {
303             int ridx = allItems.size() - idx - 1;
304             String line = allItems.get(ridx).line().trim();
305             ListIterator<Entry> iterator = allItems.listIterator(ridx);
306             while (iterator.hasPrevious()) {
307                 String l = iterator.previous().line();
308                 if (line.equals(l.trim())) {
309                     iterator.remove();
310                 }
311             }
312             idx++;
313         }
314         while (allItems.size() > max) {
315             allItems.remove(0);
316         }
317     }
318 
size()319     public int size() {
320         return items.size();
321     }
322 
isEmpty()323     public boolean isEmpty() {
324         return items.isEmpty();
325     }
326 
index()327     public int index() {
328         return offset + index;
329     }
330 
first()331     public int first() {
332         return offset;
333     }
334 
last()335     public int last() {
336         return offset + items.size() - 1;
337     }
338 
format(Entry entry)339     private String format(Entry entry) {
340         if (reader.isSet(LineReader.Option.HISTORY_TIMESTAMPED)) {
341             return Long.toString(entry.time().toEpochMilli()) + ":" + escape(entry.line()) + "\n";
342         }
343         return escape(entry.line()) + "\n";
344     }
345 
get(final int index)346     public String get(final int index) {
347         int idx = index - offset;
348         if (idx >= items.size() || idx < 0) {
349             throw new IllegalArgumentException("IndexOutOfBounds: Index:" + idx +", Size:" + items.size());
350         }
351         return items.get(idx).line();
352     }
353 
354     @Override
add(Instant time, String line)355     public void add(Instant time, String line) {
356         Objects.requireNonNull(time);
357         Objects.requireNonNull(line);
358 
359         if (getBoolean(reader, LineReader.DISABLE_HISTORY, false)) {
360             return;
361         }
362         if (isSet(reader, LineReader.Option.HISTORY_IGNORE_SPACE) && line.startsWith(" ")) {
363             return;
364         }
365         if (isSet(reader, LineReader.Option.HISTORY_REDUCE_BLANKS)) {
366             line = line.trim();
367         }
368         if (isSet(reader, LineReader.Option.HISTORY_IGNORE_DUPS)) {
369             if (!items.isEmpty() && line.equals(items.getLast().line())) {
370                 return;
371             }
372         }
373         if (matchPatterns(getString(reader, HISTORY_IGNORE, ""), line)) {
374             return;
375         }
376         internalAdd(time, line);
377         if (isSet(reader, LineReader.Option.HISTORY_INCREMENTAL)) {
378             try {
379                 save();
380             }
381             catch (IOException e) {
382                 Log.warn("Failed to save history", e);
383             }
384         }
385     }
386 
matchPatterns(String patterns, String line)387     protected boolean matchPatterns(String patterns, String line) {
388         if (patterns == null || patterns.isEmpty()) {
389             return false;
390         }
391         StringBuilder sb = new StringBuilder();
392         for (int i = 0; i < patterns.length(); i++) {
393             char ch = patterns.charAt(i);
394             if (ch == '\\') {
395                 ch = patterns.charAt(++i);
396                 sb.append(ch);
397             } else if (ch == ':') {
398                 sb.append('|');
399             } else if (ch == '*') {
400                 sb.append('.').append('*');
401             }
402         }
403         return line.matches(sb.toString());
404     }
405 
internalAdd(Instant time, String line)406     protected void internalAdd(Instant time, String line) {
407         internalAdd(time, line, false);
408     }
409 
internalAdd(Instant time, String line, boolean checkDuplicates)410     protected void internalAdd(Instant time, String line, boolean checkDuplicates) {
411         Entry entry = new EntryImpl(offset + items.size(), time, line);
412         if (checkDuplicates) {
413             for (Entry e: items) {
414                 if (e.line().trim().equals(line.trim())) {
415                     return;
416                 }
417             }
418         }
419         items.add(entry);
420         maybeResize();
421     }
422 
maybeResize()423     private void maybeResize() {
424         while (size() > getInt(reader, LineReader.HISTORY_SIZE, DEFAULT_HISTORY_SIZE)) {
425             items.removeFirst();
426             for (HistoryFileData hfd: historyFiles.values()) {
427                 hfd.decLastLoaded();
428             }
429             offset++;
430         }
431         index = size();
432     }
433 
iterator(int index)434     public ListIterator<Entry> iterator(int index) {
435         return items.listIterator(index - offset);
436     }
437 
438     @Override
spliterator()439     public Spliterator<Entry> spliterator() {
440         return items.spliterator();
441     }
442 
resetIndex()443     public void resetIndex() {
444         index = index > items.size() ? items.size() : index;
445     }
446 
447     protected static class EntryImpl implements Entry {
448 
449         private final int index;
450         private final Instant time;
451         private final String line;
452 
EntryImpl(int index, Instant time, String line)453         public EntryImpl(int index, Instant time, String line) {
454             this.index = index;
455             this.time = time;
456             this.line = line;
457         }
458 
index()459         public int index() {
460             return index;
461         }
462 
time()463         public Instant time() {
464             return time;
465         }
466 
line()467         public String line() {
468             return line;
469         }
470 
471         @Override
toString()472         public String toString() {
473             return String.format("%d: %s", index, line);
474         }
475     }
476 
477     //
478     // Navigation
479     //
480 
481     /**
482      * This moves the history to the last entry. This entry is one position
483      * before the moveToEnd() position.
484      *
485      * @return Returns false if there were no history iterator or the history
486      * index was already at the last entry.
487      */
moveToLast()488     public boolean moveToLast() {
489         int lastEntry = size() - 1;
490         if (lastEntry >= 0 && lastEntry != index) {
491             index = size() - 1;
492             return true;
493         }
494 
495         return false;
496     }
497 
498     /**
499      * Move to the specified index in the history
500      */
moveTo(int index)501     public boolean moveTo(int index) {
502         index -= offset;
503         if (index >= 0 && index < size()) {
504             this.index = index;
505             return true;
506         }
507         return false;
508     }
509 
510     /**
511      * Moves the history index to the first entry.
512      *
513      * @return Return false if there are no iterator in the history or if the
514      * history is already at the beginning.
515      */
moveToFirst()516     public boolean moveToFirst() {
517         if (size() > 0 && index != 0) {
518             index = 0;
519             return true;
520         }
521         return false;
522     }
523 
524     /**
525      * Move to the end of the history buffer. This will be a blank entry, after
526      * all of the other iterator.
527      */
moveToEnd()528     public void moveToEnd() {
529         index = size();
530     }
531 
532     /**
533      * Return the content of the current buffer.
534      */
current()535     public String current() {
536         if (index >= size()) {
537             return "";
538         }
539         return items.get(index).line();
540     }
541 
542     /**
543      * Move the pointer to the previous element in the buffer.
544      *
545      * @return true if we successfully went to the previous element
546      */
previous()547     public boolean previous() {
548         if (index <= 0) {
549             return false;
550         }
551         index--;
552         return true;
553     }
554 
555     /**
556      * Move the pointer to the next element in the buffer.
557      *
558      * @return true if we successfully went to the next element
559      */
next()560     public boolean next() {
561         if (index >= size()) {
562             return false;
563         }
564         index++;
565         return true;
566     }
567 
568     @Override
toString()569     public String toString() {
570         StringBuilder sb = new StringBuilder();
571         for (Entry e : this) {
572             sb.append(e.toString()).append("\n");
573         }
574         return sb.toString();
575     }
576 
escape(String s)577     private static String escape(String s) {
578         StringBuilder sb = new StringBuilder();
579         for (int i = 0; i < s.length(); i++) {
580             char ch = s.charAt(i);
581             switch (ch) {
582                 case '\n':
583                     sb.append('\\');
584                     sb.append('n');
585                     break;
586                 case '\r':
587                     sb.append('\\');
588                     sb.append('r');
589                     break;
590                 case '\\':
591                     sb.append('\\');
592                     sb.append('\\');
593                     break;
594                 default:
595                     sb.append(ch);
596                     break;
597             }
598         }
599         return sb.toString();
600     }
601 
unescape(String s)602     static String unescape(String s) {
603         StringBuilder sb = new StringBuilder();
604         for (int i = 0; i < s.length(); i++) {
605             char ch = s.charAt(i);
606             switch (ch) {
607                 case '\\':
608                     ch = s.charAt(++i);
609                     if (ch == 'n') {
610                         sb.append('\n');
611                     } else if (ch == 'r') {
612                         sb.append('\r');
613                     } else {
614                         sb.append(ch);
615                     }
616                     break;
617                 default:
618                     sb.append(ch);
619                     break;
620             }
621         }
622         return sb.toString();
623     }
624 
625     private class HistoryFileData {
626         private int lastLoaded = 0;
627         private int entriesInFile = 0;
628 
HistoryFileData()629         public HistoryFileData() {
630         }
631 
HistoryFileData(int lastLoaded, int entriesInFile)632         public HistoryFileData(int lastLoaded, int entriesInFile) {
633             this.lastLoaded = lastLoaded;
634             this.entriesInFile = entriesInFile;
635         }
636 
getLastLoaded()637         public int getLastLoaded() {
638             return lastLoaded;
639         }
640 
setLastLoaded(int lastLoaded)641         public void setLastLoaded(int lastLoaded) {
642             this.lastLoaded = lastLoaded;
643         }
644 
decLastLoaded()645         public void decLastLoaded() {
646             lastLoaded = lastLoaded - 1;
647             if (lastLoaded < 0) {
648                 lastLoaded = 0;
649             }
650         }
651 
getEntriesInFile()652         public int getEntriesInFile() {
653             return entriesInFile;
654         }
655 
setEntriesInFile(int entriesInFile)656         public void setEntriesInFile(int entriesInFile) {
657             this.entriesInFile = entriesInFile;
658         }
659 
incEntriesInFile(int amount)660         public void incEntriesInFile(int amount) {
661             entriesInFile = entriesInFile + amount;
662         }
663 
664     }
665 
666 }
667 
668