aboutsummaryrefslogtreecommitdiff
path: root/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala
blob: dac1c9d2394c8b788c96c82e5d95e44b34a70651 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
package dotty.tools
package dotc
package repl
package ammonite
package terminal
package filters

import terminal.FilterTools._
import terminal.LazyList._
import terminal._

/**
  * Provides history navigation up and down, saving the current line, a well
  * as history-search functionality (`Ctrl R` in bash) letting you quickly find
  * & filter previous commands by entering a sub-string.
  */
class HistoryFilter(
  history: () => IndexedSeq[String],
  commentStartColor: String,
  commentEndColor: String
) extends DelegateFilter {


  def identifier = "HistoryFilter"
  /**
    * `-1` means we haven't started looking at history, `n >= 0` means we're
    * currently at history command `n`
    */
  var historyIndex = -1

  /**
    * The term we're searching for, if any.
    *
    * - `None` means we're not searching for anything, e.g. we're just
    *   browsing history
    *
    * - `Some(term)` where `term` is not empty is what it normally looks
    *   like when we're searching for something
    *
    * - `Some(term)` where `term` is empty only really happens when you
    *   start searching and delete things, or if you `Ctrl-R` on an empty
    *   prompt
    */
  var searchTerm: Option[Vector[Char]] = None

  /**
    * Records the last buffer that the filter has observed while it's in
    * search/history mode. If the new buffer differs from this, assume that
    * some other filter modified the buffer and drop out of search/history
    */
  var prevBuffer: Option[Vector[Char]] = None

  /**
    * Kicks the HistoryFilter from passive-mode into search-history mode
    */
  def startHistory(b: Vector[Char], c: Int): (Vector[Char], Int, String) = {
    if (b.nonEmpty) searchTerm = Some(b)
    up(Vector(), c)
  }

  def searchHistory(
    start: Int,
    increment: Int,
    buffer: Vector[Char],
    skipped: Vector[Char]
  ) = {

    def nextHistoryIndexFor(v: Vector[Char]) = {
      HistoryFilter.findNewHistoryIndex(start, v, history(), increment, skipped)
    }

    val (newHistoryIndex, newBuffer, newMsg, newCursor) = searchTerm match {
      // We're not searching for anything, just browsing history.
      // Pass in Vector.empty so we scroll through all items
      case None =>
        val (i, b, c) = nextHistoryIndexFor(Vector.empty)
        (i, b, "", 99999)

      // We're searching for some item with a particular search term
      case Some(b) if b.nonEmpty =>
        val (i, b1, c) = nextHistoryIndexFor(b)

        val msg =
          if (i.nonEmpty) ""
          else commentStartColor + HistoryFilter.cannotFindSearchMessage + commentEndColor

        (i, b1, msg, c)

      // We're searching for nothing in particular; in this case,
      // show a help message instead of an unhelpful, empty buffer
      case Some(b) if b.isEmpty =>
        val msg = commentStartColor + HistoryFilter.emptySearchMessage + commentEndColor
        // The cursor in this case always goes to zero
        (Some(start), Vector(), msg, 0)

    }

    historyIndex = newHistoryIndex.getOrElse(-1)

    (newBuffer, newCursor, newMsg)
  }

  def activeHistory = searchTerm.nonEmpty || historyIndex != -1
  def activeSearch = searchTerm.nonEmpty

  def up(b: Vector[Char], c: Int) =
    searchHistory(historyIndex + 1, 1, b, b)

  def down(b: Vector[Char], c: Int) =
    searchHistory(historyIndex - 1, -1, b, b)

  def wrap(rest: LazyList[Int], out: (Vector[Char], Int, String)) =
    TS(rest, out._1, out._2, out._3)

  def ctrlR(b: Vector[Char], c: Int) =
    if (activeSearch) up(b, c)
    else {
      searchTerm = Some(b)
      up(Vector(), c)
    }

  def printableChar(char: Char)(b: Vector[Char], c: Int) = {
    searchTerm = searchTerm.map(_ :+ char)
    searchHistory(historyIndex.max(0), 1, b :+ char, Vector())
  }

  def backspace(b: Vector[Char], c: Int) = {
    searchTerm = searchTerm.map(_.dropRight(1))
    searchHistory(historyIndex, 1, b, Vector())
  }

  /**
    * Predicate to check if either we're searching for a term or if we're in
    * history-browsing mode and some predicate is true.
    *
    * Very often we want to capture keystrokes in search-mode more aggressively
    * than in history-mode, e.g. search-mode drops you out more aggressively
    * than history-mode does, and its up/down keys cycle through history more
    * aggressively on every keystroke while history-mode only cycles when you
    * reach the top/bottom line of the multi-line input.
    */
  def searchOrHistoryAnd(cond: Boolean) =
    activeSearch || (activeHistory && cond)

  val dropHistoryChars = Set(9, 13, 10) // Tab or Enter

  def endHistory() = {
    historyIndex = -1
    searchTerm = None
  }

  def filter = Filter.wrap("historyFilterWrap1") {
    (ti: TermInfo) => {
      prelude.op(ti) match {
        case None =>
          prevBuffer = Some(ti.ts.buffer)
          filter0.op(ti) match {
            case Some(ts: TermState) =>
              prevBuffer = Some(ts.buffer)
              Some(ts)
            case x => x
          }
        case some => some
      }
    }
  }

  def prelude: Filter = Filter("historyPrelude") {
    case TS(inputs, b, c, _) if activeHistory && prevBuffer.exists(_ != b) =>
      endHistory()
      prevBuffer = None
      TS(inputs, b, c)
  }

  def filter0: Filter = Filter("filter0") {
    // Ways to kick off the history/search if you're not already in it

    // `Ctrl-R`
    case TS(18 ~: rest, b, c, _) => wrap(rest, ctrlR(b, c))

    // `Up` from the first line in the input
    case TermInfo(TS(p"\u001b[A$rest", b, c, _), w) if firstRow(c, b, w) && !activeHistory =>
      wrap(rest, startHistory(b, c))

    // `Ctrl P`
    case TermInfo(TS(p"\u0010$rest", b, c, _), w) if firstRow(c, b, w) && !activeHistory =>
      wrap(rest, startHistory(b, c))

    // `Page-Up` from first character starts history
    case TermInfo(TS(p"\u001b[5~$rest", b, c, _), w) if c == 0 && !activeHistory =>
      wrap(rest, startHistory(b, c))

    // Things you can do when you're already in the history search

    // Navigating up and down the history. Each up or down searches for
    // the next thing that matches your current searchTerm
    // Up
    case TermInfo(TS(p"\u001b[A$rest", b, c, _), w) if searchOrHistoryAnd(firstRow(c, b, w)) =>
      wrap(rest, up(b, c))

    // Ctrl P
    case TermInfo(TS(p"\u0010$rest", b, c, _), w) if searchOrHistoryAnd(firstRow(c, b, w)) =>
      wrap(rest, up(b, c))

    // `Page-Up` from first character cycles history up
    case TermInfo(TS(p"\u001b[5~$rest", b, c, _), w) if searchOrHistoryAnd(c == 0) =>
      wrap(rest, up(b, c))

    // Down
    case TermInfo(TS(p"\u001b[B$rest", b, c, _), w) if searchOrHistoryAnd(lastRow(c, b, w))  =>
      wrap(rest, down(b, c))

    // `Ctrl N`

    case TermInfo(TS(p"\u000e$rest", b, c, _), w) if searchOrHistoryAnd(lastRow(c, b, w))  =>
      wrap(rest, down(b, c))
    // `Page-Down` from last character cycles history down
    case TermInfo(TS(p"\u001b[6~$rest", b, c, _), w) if searchOrHistoryAnd(c == b.length - 1) =>
      wrap(rest, down(b, c))


    // Intercept Backspace and delete a character in search-mode, preserving it, but
    // letting it fall through and dropping you out of history-mode if you try to make
    // edits
    case TS(127 ~: rest, buffer, cursor, _) if activeSearch =>
      wrap(rest, backspace(buffer, cursor))

    // Any other control characters drop you out of search mode, but only the
    // set of `dropHistoryChars` drops you out of history mode
    case TS(char ~: inputs, buffer, cursor, _)
      if char.toChar.isControl && searchOrHistoryAnd(dropHistoryChars(char)) =>
      val newBuffer =
      // If we're back to -1, it means we've wrapped around and are
      // displaying the original search term with a wrap-around message
      // in the terminal. Drop the message and just preserve the search term
        if (historyIndex == -1) searchTerm.get
        // If we're searching for an empty string, special-case this and return
        // an empty buffer rather than the first history item (which would be
        // the default) because that wouldn't make much sense
        else if (searchTerm.exists(_.isEmpty)) Vector()
        // Otherwise, pick whatever history entry we're at and use that
        else history()(historyIndex).toVector
      endHistory()

      TS(char ~: inputs, newBuffer, cursor)

    // Intercept every other printable character when search is on and
    // enter it into the current search
    case TS(char ~: rest, buffer, cursor, _) if activeSearch =>
      wrap(rest, printableChar(char.toChar)(buffer, cursor))

    // If you're not in search but are in history, entering any printable
    // characters kicks you out of it and preserves the current buffer. This
    // makes it harder for you to accidentally lose work due to history-moves
    case TS(char ~: rest, buffer, cursor, _) if activeHistory && !char.toChar.isControl =>
      historyIndex = -1
      TS(char ~: rest, buffer, cursor)
  }
}

object HistoryFilter {

  def mangleBuffer(
    historyFilter: HistoryFilter,
    buffer: Ansi.Str,
    cursor: Int,
    startColor: Ansi.Attr
  ) = {
    if (!historyFilter.activeSearch) buffer
    else {
      val (searchStart, searchEnd) =
        if (historyFilter.searchTerm.get.isEmpty) (cursor, cursor+1)
        else {
          val start = buffer.plainText.indexOfSlice(historyFilter.searchTerm.get)

          val end = start + (historyFilter.searchTerm.get.length max 1)
          (start, end)
        }

      val newStr = buffer.overlay(startColor, searchStart, searchEnd)
      newStr
    }
  }

  /**
    * @param startIndex The first index to start looking from
    * @param searchTerm The term we're searching from; can be empty
    * @param history The history we're searching through
    * @param indexIncrement Which direction to search, +1 or -1
    * @param skipped Any buffers which we should skip in our search results,
    *                e.g. because the user has seen them before.
    */
  def findNewHistoryIndex(
    startIndex: Int,
    searchTerm: Vector[Char],
    history: IndexedSeq[String],
    indexIncrement: Int,
    skipped: Vector[Char]
  ) = {
    /**
      * `Some(i)` means we found a reasonable result at history element `i`
      * `None` means we couldn't find anything, and should show a not-found
      * error to the user
      */
    def rec(i: Int): Option[Int] = history.lift(i) match {
      // If i < 0, it means the user is pressing `down` too many times, which
      // means it doesn't show anything but we shouldn't show an error
      case None if i < 0 => Some(-1)
      case None => None
      case Some(s) if s.contains(searchTerm) && !s.contentEquals(skipped) =>
        Some(i)
      case _ => rec(i + indexIncrement)
    }

    val newHistoryIndex = rec(startIndex)
    val foundIndex = newHistoryIndex.find(_ != -1)
    val newBuffer = foundIndex match {
      case None => searchTerm
      case Some(i) => history(i).toVector
    }

    val newCursor = foundIndex match {
      case None => newBuffer.length
      case Some(i) => history(i).indexOfSlice(searchTerm) + searchTerm.length
    }

    (newHistoryIndex, newBuffer, newCursor)
  }

  val emptySearchMessage =
    s" ...enter the string to search for, then `up` for more"
  val cannotFindSearchMessage =
    s" ...can't be found in history; re-starting search"
}