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