diff --git a/NEWS.md b/NEWS.md index 9092a45278548..a18aacd81742d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -99,6 +99,7 @@ Standard library changes * The Julia REPL now support bracketed paste on Windows which should significantly speed up pasting large code blocks into the REPL ([#59825]) * The REPL now provides syntax highlighting for input as you type. See the REPL docs for more info about customization. * The REPL now supports automatic insertion of closing brackets, parentheses, and quotes. See the REPL docs for more info about customization. +* History searching has been rewritten to use a new interactive modal dialogue, using a fzf-like style. * The display of `AbstractChar`s in the main REPL mode now includes LaTeX input information like what is shown in help mode ([#58181]). * Display of repeated frames and cycles in stack traces has been improved by bracketing them in the trace and treating them consistently ([#55841]). diff --git a/base/regex.jl b/base/regex.jl index 0d3dfde8629ea..52db136cc6643 100644 --- a/base/regex.jl +++ b/base/regex.jl @@ -649,17 +649,17 @@ replace_err(repl) = error("Bad replacement string: $repl") function _write_capture(io::IO, group::Int, str, r, re::RegexAndMatchData) len = PCRE.substring_length_bynumber(re.match_data, group) # in the case of an optional group that doesn't match, len == 0 - len == 0 && return + len == 0 && return len ensureroom(io, len+1) PCRE.substring_copy_bynumber(re.match_data, group, pointer(io.data, io.ptr), len+1) io.ptr += len io.size = max(io.size, io.ptr - 1) - nothing + return len end function _write_capture(io::IO, group::Int, str, r, re) group == 0 || replace_err("pattern is not a Regex") - return print(io, SubString(str, r)) + return write(io, SubString(str, r)) end @@ -673,12 +673,13 @@ function _replace(io, repl_s::SubstitutionString, str, r, re) repl = unescape_string(repl_s.string, KEEP_ESC) i = firstindex(repl) e = lastindex(repl) + nb = 0 while i <= e if repl[i] == SUB_CHAR next_i = nextind(repl, i) next_i > e && replace_err(repl) if repl[next_i] == SUB_CHAR - write(io, SUB_CHAR) + nb += write(io, SUB_CHAR) i = nextind(repl, next_i) elseif isdigit(repl[next_i]) group = parse(Int, repl[next_i]) @@ -691,7 +692,7 @@ function _replace(io, repl_s::SubstitutionString, str, r, re) break end end - _write_capture(io, group, str, r, re) + nb += _write_capture(io, group, str, r, re) elseif repl[next_i] == GROUP_CHAR i = nextind(repl, next_i) if i > e || repl[i] != LBRACKET @@ -713,16 +714,17 @@ function _replace(io, repl_s::SubstitutionString, str, r, re) else group = -1 end - _write_capture(io, group, str, r, re) + nb += _write_capture(io, group, str, r, re) i = nextind(repl, i) else replace_err(repl) end else - write(io, repl[i]) + nb += write(io, repl[i]) i = nextind(repl, i) end end + nb end struct RegexMatchIterator{S <: AbstractString} diff --git a/base/strings/annotated_io.jl b/base/strings/annotated_io.jl index 0ad4786d2864f..60c91be24ebfb 100644 --- a/base/strings/annotated_io.jl +++ b/base/strings/annotated_io.jl @@ -163,18 +163,18 @@ This is implemented so that one can say write an `AnnotatedString` to an `AnnotatedIOBuffer` one character at a time without needlessly producing a new annotation for each character. """ -function _insert_annotations!(io::AnnotatedIOBuffer, annotations::Vector{RegionAnnotation}, offset::Int = position(io)) +function _insert_annotations!(annots::Vector{RegionAnnotation}, newannots::Vector{RegionAnnotation}, offset::Int = 0) run = 0 - if !isempty(io.annotations) && last(last(io.annotations).region) == offset - for i in reverse(axes(annotations, 1)) - annot = annotations[i] + if !isempty(annots) && last(last(annots).region) == offset + for i in reverse(axes(newannots, 1)) + annot = newannots[i] first(annot.region) == 1 || continue - i <= length(io.annotations) || continue - if annot.label == last(io.annotations).label && annot.value == last(io.annotations).value + i <= length(annots) || continue + if annot.label == last(annots).label && annot.value == last(annots).value valid_run = true for runlen in 1:i - new = annotations[begin+runlen-1] - old = io.annotations[end-i+runlen] + new = newannots[begin+runlen-1] + old = annots[end-i+runlen] if last(old.region) != offset || first(new.region) != 1 || old.label != new.label || old.value != new.value valid_run = false break @@ -188,18 +188,157 @@ function _insert_annotations!(io::AnnotatedIOBuffer, annotations::Vector{RegionA end end for runindex in 0:run-1 - old_index = lastindex(io.annotations) - run + 1 + runindex - old = io.annotations[old_index] - new = annotations[begin+runindex] - io.annotations[old_index] = setindex(old, first(old.region):last(new.region)+offset, :region) + old_index = lastindex(annots) - run + 1 + runindex + old = annots[old_index] + new = newannots[begin+runindex] + extannot = (region = first(old.region):last(new.region)+offset, + label = old.label, + value = old.value) + annots[old_index] = extannot end - for index in run+1:lastindex(annotations) - annot = annotations[index] + for index in run+1:lastindex(newannots) + annot = newannots[index] start, stop = first(annot.region), last(annot.region) - push!(io.annotations, setindex(annotations[index], start+offset:stop+offset, :region)) + # REVIEW: For some reason, construction of `newannot` + # can be a significant contributor to the overall runtime + # of this function. For instance, executing: + # + # replace(AnnotatedIOBuffer(), S"apple", + # 'e' => S"{red:x}", 'p' => S"{green:y}") + # + # results in 3 calls to `_insert_annotations!`. It takes + # ~570ns in total, compared to ~200ns if we push `annot` + # instead of `newannot`. Commenting out the `_insert_annotations!` + # line reduces the runtime to ~170ns, from which we can infer + # that constructing `newannot` is somehow responsible for + # a ~30ns -> ~400ns (~13x) increase in runtime!! + # This also comes with a marginal increase in allocations + # (compared to the commented out version) of 2 -> 14 (250b -> 720b). + # + # This seems quite strange, but I haven't dug into the generated + # LLVM or ASM code. If anybody reading this is interested in checking + # this out, that would be brilliant 🙏. + # + # What I have done is found that "direct tuple reconstruction" + # (as below) is several times faster than using `setindex`. + newannot = (region = start+offset:stop+offset, + label = annot.label, + value = annot.value) + push!(annots, newannot) end end +_insert_annotations!(io::AnnotatedIOBuffer, newannots::Vector{RegionAnnotation}, offset::Int = position(io)) = + _insert_annotations!(io.annotations, newannots, offset) + +# String replacement + +# REVIEW: For some reason the `Core.kwcall` indirection seems to cause a +# substantial slowdown here. If we remove `; count` from the signature +# and run the sample code above in `_insert_annotations!`, the runtime +# drops from ~4400ns to ~580ns (~7x faster). I cannot guess why this is. +function replace(out::AnnotatedIOBuffer, str::AnnotatedString, pat_f::Pair...; count = typemax(Int)) + if count == 0 || isempty(pat_f) + write(out, str) + return out + end + e1, patterns, replacers, repspans, notfound = _replace_init(str.string, pat_f, count) + if notfound + foreach(_free_pat_replacer, patterns) + write(out, str) + return out + end + # Modelled after `Base.annotated_chartransform`, but needing + # to handle a bit more complexity. + isappending = eof(out) + newannots = empty(out.annotations) + bytepos = bytestart = firstindex(str.string) + replacements = [(region = (bytestart - 1):(bytestart - 1), offset = position(out))] + nrep = 1 + while nrep <= count + repspans, ridx, xspan, newbytes, bytepos = @inline _replace_once( + out.io, str.string, bytestart, e1, patterns, replacers, repspans, count, nrep, bytepos) + first(xspan) >= e1 && break + nrep += 1 + # NOTE: When the replaced pattern ends with a multi-codeunit character, + # `xspan` only covers up to the start of that character. However, + # for us to correctly account for the changes to the string we need + # the /entire/ span of codeunits that were replaced. + if !isempty(xspan) && codeunit(str.string, last(xspan)) > 0x80 + xspan = first(xspan):nextind(str.string, last(xspan))-1 + end + drift = last(replacements).offset + thisrep = (region = xspan, offset = drift + newbytes - length(xspan)) + destoff = first(xspan) - 1 + drift + push!(replacements, thisrep) + replacement = replacers[ridx] + _isannotated(replacement) || continue + annots = annotations(replacement) + annots′ = if eltype(annots) == Annotation # When it's a char not a string + region = 1:newbytes + [@NamedTuple{region::UnitRange{Int}, label::Symbol, value}((region, label, value)) + for (; label, value) in annots] + else + annots + end::Vector{RegionAnnotation} + _insert_annotations!(newannots, annots′, destoff) + end + push!(replacements, (region = e1:(e1-1), offset = last(replacements).offset)) + foreach(_free_pat_replacer, patterns) + write(out.io, SubString(str.string, bytepos)) + # NOTE: To enable more efficient annotation clearing, + # we make use of the fact that `_replace_once` picks + # replacements ordered by their match start position. + # This means that the start of `.region`s in + # `replacements` is monotonically increasing. + isappending || _clear_annotations_in_region!(out.annotations, first(replacements).offset:position(out)) + for (; region, label, value) in str.annotations + start, stop = first(region), last(region) + prioridx = searchsortedlast( + replacements, (region = start:start, offset = 0), + by = r -> first(r.region)) + postidx = searchsortedfirst( + replacements, (region = stop:stop, offset = 0), + by = r -> first(r.region)) + priorrep, postrep = replacements[prioridx], replacements[postidx] + if prioridx == postidx && start >= first(priorrep.region) && stop <= last(priorrep.region) + # Region contained with a replacement + continue + elseif postidx - prioridx <= 1 && start > last(priorrep.region) && stop < first(postrep.region) + # Lies between replacements + shiftregion = (start + priorrep.offset):(stop + priorrep.offset) + shiftann = (region = shiftregion, label, value) + push!(out.annotations, shiftann) + else + # Split between replacements + prevrep = replacements[max(begin, prioridx - 1)] + for rep in @view replacements[max(begin, prioridx - 1):min(end, postidx + 1)] + gap = max(start, last(prevrep.region)+1):min(stop, first(rep.region)-1) + if !isempty(gap) + shiftregion = (first(gap) + prevrep.offset):(last(gap) + prevrep.offset) + shiftann = (; region = shiftregion, label, value) + push!(out.annotations, shiftann) + end + prevrep = rep + end + end + end + append!(out.annotations, newannots) + out +end + +replace(out::IO, str::AnnotatedString, pat_f::Pair...; count=typemax(Int)) = + replace(out, str.string, pat_f...; count) + +function replace(str::AnnotatedString, pat_f::Pair...; count=typemax(Int)) + isempty(pat_f) || iszero(count) && return str + out = AnnotatedIOBuffer() + replace(out, str, pat_f...; count) + read(seekstart(out), AnnotatedString) +end + +# Printing + function printstyled end # NOTE: This is an interim solution to the invalidations caused diff --git a/base/strings/unicode.jl b/base/strings/unicode.jl index 10a47304738ee..eba0ed22aee4d 100644 --- a/base/strings/unicode.jl +++ b/base/strings/unicode.jl @@ -639,6 +639,7 @@ julia> uppercase("Julia") """ uppercase(s::AbstractString) = map(uppercase, s) uppercase(s::AnnotatedString) = annotated_chartransform(uppercase, s) +uppercase(s::SubString{<:AnnotatedString}) = uppercase(AnnotatedString(s)) """ lowercase(s::AbstractString) @@ -655,6 +656,7 @@ julia> lowercase("STRINGS AND THINGS") """ lowercase(s::AbstractString) = map(lowercase, s) lowercase(s::AnnotatedString) = annotated_chartransform(lowercase, s) +lowercase(s::SubString{<:AnnotatedString}) = lowercase(AnnotatedString(s)) """ titlecase(s::AbstractString; [wordsep::Function], strict::Bool=true)::String @@ -720,6 +722,9 @@ function titlecase(s::AnnotatedString; wordsep::Function = !isletter, strict::Bo end end +titlecase(s::SubString{<:AnnotatedString}; wordsep::Function = !isletter, strict::Bool=true) = + titlecase(AnnotatedString(s); wordsep=wordsep, strict=strict) + """ uppercasefirst(s::AbstractString)::String @@ -754,6 +759,7 @@ function uppercasefirst(s::AnnotatedString) end end end +uppercasefirst(s::SubString{<:AnnotatedString}) = uppercasefirst(AnnotatedString(s)) """ lowercasefirst(s::AbstractString) @@ -787,6 +793,7 @@ function lowercasefirst(s::AnnotatedString) end end end +lowercasefirst(s::SubString{<:AnnotatedString}) = lowercasefirst(AnnotatedString(s)) ############################################################################ # iterators for grapheme segmentation diff --git a/base/strings/util.jl b/base/strings/util.jl index 0573893481d3d..d8cc95d9ec801 100644 --- a/base/strings/util.jl +++ b/base/strings/util.jl @@ -975,11 +975,22 @@ rsplit(str::AbstractString; limit::Integer=0, keepempty::Bool=false) = rsplit(str, isspace; limit, keepempty) -_replace(io, repl, str, r, pattern) = print(io, repl) +_replace(io, repl::Union{<:AbstractString, <:AbstractChar}, str, r, pattern) = + write(io, repl) +function _replace(io, repl, str, r, pattern) + if applicable(position, io) + p1 = position(io) + print(io, repl) + p2 = position(io) + p2 - p1 + else + write(io, repr(repl)) + end +end _replace(io, repl::Function, str, r, pattern) = - print(io, repl(SubString(str, first(r), last(r)))) + _replace(io, repl(SubString(str, first(r), last(r))), str, r, pattern) _replace(io, repl::Function, str, r, pattern::Function) = - print(io, repl(str[first(r)])) + _replace(io, repl(str[first(r)]), str, r, pattern) _pat_replacer(x) = x _free_pat_replacer(x) = nothing @@ -1009,38 +1020,11 @@ end function _replace_finish(io::IO, str, count::Int, e1::Int, patterns::Tuple, replaces::Tuple, rs::Tuple) n = 1 - i = a = firstindex(str) - while true - p = argmin(map(first, rs)) # TODO: or argmin(rs), to pick the shortest first match ? - r = rs[p] - j, k = first(r), last(r) - j > e1 && break - if i == a || i <= k - # copy out preserved portion - GC.@preserve str unsafe_write(io, pointer(str, i), UInt(j-i)) - # copy out replacement string - _replace(io, replaces[p], str, r, patterns[p]) - end - if k < j - i = j - j == e1 && break - k = nextind(str, j) - else - i = k = nextind(str, k) - end - n == count && break - let k = k - rs = map(patterns, rs) do p, r - if first(r) < k - r = findnext(p, str, k) - if r === nothing || first(r) == 0 - return e1+1:0 - end - r isa Int && (r = r:r) # findnext / performance fix - end - return r - end - end + i = start = firstindex(str) + while n <= count + rs, _, r, _, i = @inline _replace_once( + io, str, start, e1, patterns, replaces, rs, count, n, i) + first(r) >= e1 && break n += 1 end foreach(_free_pat_replacer, patterns) @@ -1048,6 +1032,44 @@ function _replace_finish(io::IO, str, count::Int, return io end +function _replace_once(io::IO, str, start::Int, e1::Int, + patterns::Tuple, replaces::Tuple, rs::Tuple, + count::Int, n::Int, i::Int) + x = argmin(map(first, rs)) # TODO: or argmin(rs), to pick the shortest first match ? + r = rs[x] + j, k = first(r), last(r) + j > e1 && return rs, x, r, 0, i + nb = if i == start || i <= k + # copy out preserved portion + GC.@preserve str unsafe_write(io, pointer(str, i), UInt(j-i)) + # copy out replacement string + _replace(io, replaces[x], str, r, patterns[x]) + else + 0 + end + if k < j + i = j + j == e1 && return rs, x, r, nb, i + k = nextind(str, j) + else + i = k = nextind(str, k) + end + n == count && return rs, x, r, nb, i + let k = k + rs = map(patterns, rs) do p, r + if first(r) < k + r = findnext(p, str, k) + if r === nothing || first(r) == 0 + return e1+1:0 + end + r isa Int && (r = r:r) # findnext / performance fix + end + return r + end + end + return rs, x, r, nb, i +end + # note: leave str untyped here to make it easier for packages like StringViews to hook in function _replace_(io::IO, str, pat_repl::NTuple{N, Pair}, count::Int) where N if count == 0 diff --git a/stdlib/Manifest.toml b/stdlib/Manifest.toml index 5328ba9b1b6ac..6a1249aa21dba 100644 --- a/stdlib/Manifest.toml +++ b/stdlib/Manifest.toml @@ -195,7 +195,7 @@ uuid = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" version = "1.11.0" [[deps.REPL]] -deps = ["FileWatching", "InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"] +deps = ["Dates", "FileWatching", "InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"] uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" version = "1.11.0" diff --git a/stdlib/REPL/Project.toml b/stdlib/REPL/Project.toml index 968786d492bcc..6b37c892f8aa3 100644 --- a/stdlib/REPL/Project.toml +++ b/stdlib/REPL/Project.toml @@ -3,6 +3,7 @@ uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" version = "1.11.0" [deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" FileWatching = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" JuliaSyntaxHighlighting = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" diff --git a/stdlib/REPL/docs/src/index.md b/stdlib/REPL/docs/src/index.md index 7115c6fd01242..fa36f8edb35ec 100644 --- a/stdlib/REPL/docs/src/index.md +++ b/stdlib/REPL/docs/src/index.md @@ -205,83 +205,92 @@ at the beginning of the line. The prompt for this mode is `pkg>`. It supports it entered by pressing `?` at the beginning of the line of the `pkg>` prompt. The Package manager mode is documented in the Pkg manual, available at [https://julialang.github.io/Pkg.jl/v1/](https://julialang.github.io/Pkg.jl/v1/). -### Search modes +### History searching In all of the above modes, the executed lines get saved to a history file, which can be searched. - To initiate an incremental search through the previous history, type `^R` -- the control key -together with the `r` key. The prompt will change to ```(reverse-i-search)`':```, and as you -type the search query will appear in the quotes. The most recent result that matches the query -will dynamically update to the right of the colon as more is typed. To find an older result using -the same query, simply type `^R` again. + To initiate an interactive search through the previous history, type `^R` -- the control key +together with the `r` key. -Just as `^R` is a reverse search, `^S` is a forward search, with the prompt ```(i-search)`':```. - The two may be used in conjunction with each other to move through the previous or next matching -results, respectively. +You will be presented with an interactive history viewer. As you type your search history will be filtered; +pressing enter will insert the selected history entry into the REPL. Detailed help for the history +searcher is available within the REPL with the special queries `?` and `??`. All executed commands in the Julia REPL are logged into `~/.julia/logs/repl_history.jl` along with a timestamp of when it was executed -and the current REPL mode you were in. Search mode queries this log file in order to find the commands which you previously ran. -This can be disabled at startup by passing the `--history-file=no` flag to Julia. +and the current REPL mode you were in. The history searcher reads this log file in order to find the commands which you previously ran. +Multiple REPLs can write to this file at once, and every time you begin a search the newest history is fetched. +Use of this file can be disabled at startup by passing the `--history-file=no` flag to Julia. ## Key bindings The Julia REPL makes great use of key bindings. Several control-key bindings were already introduced -above (`^D` to exit, `^R` and `^S` for searching), but there are many more. In addition to the +above (`^D` to exit, `^R` for searching), but there are many more. In addition to the control-key, there are also meta-key bindings. These vary more by platform, but most terminals default to using alt- or option- held down with a key to send the meta-key (or can be configured to do so), or pressing Esc and then the key. -| Keybinding | Description | -|:------------------- |:---------------------------------------------------------------------------------------------------------- | -| **Program control** | | -| `^D` | Exit (when buffer is empty) | -| `^C` | Interrupt or cancel | -| `^L` | Clear console screen | -| Return/Enter, `^J` | New line, executing if it is complete | -| meta-Return/Enter | Insert new line without executing it | -| `?` or `;` | Enter help or shell mode (when at start of a line) | -| `^R`, `^S` | Incremental history search, described above | -| **Cursor movement** | | -| Right arrow, `^F` | Move right one character | -| Left arrow, `^B` | Move left one character | -| ctrl-Right, `meta-F`| Move right one word | -| ctrl-Left, `meta-B` | Move left one word | -| Home, `^A` | Move to beginning of line | -| End, `^E` | Move to end of line | -| Up arrow, `^P` | Move up one line (or change to the previous history entry that matches the text before the cursor) | -| Down arrow, `^N` | Move down one line (or change to the next history entry that matches the text before the cursor) | -| Shift-Arrow Key | Move cursor according to the direction of the Arrow key, while activating the region ("shift selection") | -| Page-up, `meta-P` | Change to the previous history entry | -| Page-down, `meta-N` | Change to the next history entry | -| `meta-<` | Change to the first history entry (of the current session if it is before the current position in history) | -| `meta->` | Change to the last history entry | -| `^-Space` | Set the "mark" in the editing region (and de-activate the region if it's active) | -| `^-Space ^-Space` | Set the "mark" in the editing region and make the region "active", i.e. highlighted | -| `^G` | De-activate the region (i.e. make it not highlighted) | -| `^X^X` | Exchange the current position with the mark | -| **Editing** | | -| Backspace, `^H` | Delete the previous character, or the whole region when it's active | -| Delete, `^D` | Forward delete one character (when buffer has text) | -| meta-Backspace | Delete the previous word | -| `meta-d` | Forward delete the next word | -| `^W` | Delete previous text up to the nearest whitespace | -| `meta-w` | Copy the current region in the kill ring | -| `meta-W` | "Kill" the current region, placing the text in the kill ring | -| `^U` | "Kill" to beginning of line, placing the text in the kill ring | -| `^K` | "Kill" to end of line, placing the text in the kill ring | -| `^Y` | "Yank" insert the text from the kill ring | -| `meta-y` | Replace a previously yanked text with an older entry from the kill ring | -| `^T` | Transpose the characters about the cursor | -| `meta-Up arrow` | Transpose current line with line above | -| `meta-Down arrow` | Transpose current line with line below | -| `meta-u` | Change the next word to uppercase | -| `meta-c` | Change the next word to titlecase | -| `meta-l` | Change the next word to lowercase | -| `^/`, `^_` | Undo previous editing action | -| `^Q` | Write a number in REPL and press `^Q` to open editor at corresponding stackframe or method | -| `meta-Left Arrow` | Indent the current line on the left | -| `meta-Right Arrow` | Indent the current line on the right | -| `meta-.` | Insert last word from previous history entry | -| `meta-e` | Edit the current input in an editor | +| Keybinding | Description | +|:----------------------|:-----------------------------------------------------------------------------------------------------------| +| **Program control** | | +| `^D` | Exit (when buffer is empty) | +| `^C` | Interrupt or cancel | +| `^L` | Clear console screen | +| Return/Enter, `^J` | New line, executing if it is complete | +| meta-Return/Enter | Insert new line without executing it | +| `?` or `;` | Enter help or shell mode (when at start of a line) | +| `^R`, `^S` | Interactive history search, described above | +| **Cursor movement** | | +| Right arrow, `^F` | Move right one character | +| Left arrow, `^B` | Move left one character | +| ctrl-Right, `meta-F` | Move right one word | +| ctrl-Left, `meta-B` | Move left one word | +| Home, `^A` | Move to beginning of line | +| End, `^E` | Move to end of line | +| Up arrow, `^P` | Move up one line (or change to the previous history entry that matches the text before the cursor) | +| Down arrow, `^N` | Move down one line (or change to the next history entry that matches the text before the cursor) | +| Shift-Arrow Key | Move cursor according to the direction of the Arrow key, while activating the region ("shift selection") | +| Page-up, `meta-P` | Change to the previous history entry | +| Page-down, `meta-N` | Change to the next history entry | +| `meta-<` | Change to the first history entry (of the current session if it is before the current position in history) | +| `meta->` | Change to the last history entry | +| `^-Space` | Set the "mark" in the editing region (and de-activate the region if it's active) | +| `^-Space ^-Space` | Set the "mark" in the editing region and make the region "active", i.e. highlighted | +| `^G` | De-activate the region (i.e. make it not highlighted) | +| `^X^X` | Exchange the current position with the mark | +| **Editing** | | +| Backspace, `^H` | Delete the previous character, or the whole region when it's active | +| Delete, `^D` | Forward delete one character (when buffer has text) | +| meta-Backspace | Delete the previous word | +| `meta-d` | Forward delete the next word | +| `^W` | Delete previous text up to the nearest whitespace | +| `meta-w` | Copy the current region in the kill ring | +| `meta-W` | "Kill" the current region, placing the text in the kill ring | +| `^U` | "Kill" to beginning of line, placing the text in the kill ring | +| `^K` | "Kill" to end of line, placing the text in the kill ring | +| `^Y` | "Yank" insert the text from the kill ring | +| `meta-y` | Replace a previously yanked text with an older entry from the kill ring | +| `^T` | Transpose the characters about the cursor | +| `meta-Up arrow` | Transpose current line with line above | +| `meta-Down arrow` | Transpose current line with line below | +| `meta-u` | Change the next word to uppercase | +| `meta-c` | Change the next word to titlecase | +| `meta-l` | Change the next word to lowercase | +| `^/`, `^_` | Undo previous editing action | +| `^Q` | Write a number in REPL and press `^Q` to open editor at corresponding stackframe or method | +| `meta-Left Arrow` | Indent the current line on the left | +| `meta-Right Arrow` | Indent the current line on the right | +| `meta-.` | Insert last word from previous history entry | +| `meta-e` | Edit the current input in an editor | +| **History search** | | +| Up arrow, `^P`, `^K` | Move the focus one entry up | +| Down arrow, `^P`, `^N`| Move the focus one entry down | +| Page up, `^B` | Move the focus one page up | +| Page down, `^F` | Move the focus one page down | +| `meta-<` | Focus on the first (oldest) history entry | +| `meta->` | Focus on the last (most recent) history entry | +| Tab | Toggle selection of the currently focused entry | +| Enter | Accept the currently focused/selected entries | +| `^S` | Save the focused/selected entries to the clipboard or a file | +| `^C`, `^D`, `^G` | Abort the history search | ### Customizing keybindings @@ -732,6 +741,21 @@ inherit = "julia_rainbow_curly_2" For a complete list of customizable faces, see the [JuliaSyntaxHighlighting package documentation](https://julialang.github.io/JuliaSyntaxHighlighting.jl/dev/). +## Customising the history searcher + +The history searcher uses the following default faces, that can be customised: + +```toml +[REPL.History.search] +separator.fg = "blue" +prefix.fg = "magenta" +selected.fg = "blue" +unselected.fg = "grey" +hint = { fg = "magenta", slant = "italic", weight ="light" } +results.inherit = "shadow" +match = { weight = "bold", underline = true } +``` + ## Customizing Colors The colors used by Julia and the REPL can be customized, as well. To change the diff --git a/stdlib/REPL/src/History/History.jl b/stdlib/REPL/src/History/History.jl new file mode 100644 index 0000000000000..3a7ff97543688 --- /dev/null +++ b/stdlib/REPL/src/History/History.jl @@ -0,0 +1,34 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +module History + +using ..REPL: REPL + +using StyledStrings: @styled_str as @S_str, Face, addface!, face!, annotations, AnnotatedIOBuffer, AnnotatedString, AnnotatedChar +using JuliaSyntaxHighlighting: highlight +using Base.Threads +using Dates +using InteractiveUtils: clipboard + +export HistoryFile, HistEntry, update!, runsearch + +const FACES = ( + :REPL_History_search_separator => Face(foreground=:blue), + :REPL_History_search_prefix => Face(foreground=:magenta), + :REPL_History_search_selected => Face(foreground=:blue), + :REPL_History_search_unselected => Face(foreground=:grey), + # :REPL_History_search_preview_box => Face(foreground=:grey), + :REPL_History_search_hint => Face(foreground=:magenta, slant=:italic, weight=:light), + :REPL_History_search_results => Face(inherit=:shadow), + :REPL_History_search_match => Face(weight = :bold, underline = true), +) + +include("histfile.jl") +include("resumablefiltering.jl") +include("prompt.jl") +include("display.jl") +include("search.jl") + +__init__() = foreach(addface!, FACES) + +end diff --git a/stdlib/REPL/src/History/display.jl b/stdlib/REPL/src/History/display.jl new file mode 100644 index 0000000000000..54397fa0e0545 --- /dev/null +++ b/stdlib/REPL/src/History/display.jl @@ -0,0 +1,812 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +struct SelectorState + area::@NamedTuple{height::Int, width::Int} + query::String + filter::FilterSpec + candidates::Vector{HistEntry} + scroll::Int + selection::@NamedTuple{active::Vector{Int}, gathered::Vector{HistEntry}} + hover::Int +end + +SelectorState((height, width), query::String, filter::FilterSpec, candidates::Vector{HistEntry} = HistEntry[], gathered::Vector{HistEntry} = HistEntry[]) = + SelectorState((height, width), query, filter, candidates, -length(gathered), (; active = Int[], gathered), 1) + +const EMPTY_STATE = SelectorState((0, 0), "", FilterSpec(), [], 0, (active = Int[], gathered = HistEntry[]), 0) + +STATES = Pair{SelectorState, SelectorState}[] + +const LABELS = ( + gatherdivider = S"{italic:carried over}", + preview_suggestion = S"Ctrl+S to save", + help_prompt = S"{REPL_History_search_hint,shadow:try {REPL_History_search_hint,(slant=normal):?} for help} ", +) + +const SYNC_UPDATE_BEGIN = "\eP=1s\e\\" +const SYNC_UPDATE_END = "\eP=2s\e\\" +const CLEAR_BELOW = "\e[1G\e[J" + +""" + redisplay_all(io::IO, oldstate::SelectorState, newstate::SelectorState, pstate::PromptState; buf) + +Diff and redraw the entire UI (candidates, preview, prompt). + +Uses ANSI sync sequences to update only changed regions between +`oldstate` and `newstate`, then reprints the prompt. +""" +function redisplay_all(io::IO, oldstate::SelectorState, newstate::SelectorState, pstate::REPL.LineEdit.PromptState; + buf::IOContext{IOBuffer} = IOContext(IOBuffer(), io)) + # Calculate dimensions + oldrows = componentrows(oldstate) + newrows = componentrows(newstate) + # Redisplay components + synccap = haskey(Base.current_terminfo(), :Sync) + synccap && print(buf, SYNC_UPDATE_BEGIN) + currentrow = 0 + if newstate.query == FILTER_SHORTHELP_QUERY + print(buf, CLEAR_BELOW * '\n', FILTER_SHORTHELP) + currentrow += 1 + count('\n', String(FILTER_SHORTHELP)) + elseif newstate.query == FILTER_LONGHELP_QUERY + print(buf, CLEAR_BELOW * '\n', FILTER_LONGHELP) + currentrow += 1 + count('\n', String(FILTER_LONGHELP)) + else + println(buf) # Move to line under prompt + currentrow += 1 + if oldstate.area.width > newstate.area.width || oldstate.query == FILTER_SHORTHELP_QUERY + print(buf, CLEAR_BELOW) + oldstate = EMPTY_STATE + end + refresh_cands = oldstate.query != newstate.query || + length(oldstate.candidates) != length(newstate.candidates) || + oldstate.area != newstate.area || + oldstate.scroll != newstate.scroll || + oldstate.selection.active != newstate.selection.active || + oldstate.hover != newstate.hover || + oldstate.filter != newstate.filter + refresh_preview = refresh_cands || + oldstate.selection.gathered != newstate.selection.gathered || + gethover(oldstate) != gethover(newstate) + if refresh_cands + redisplay_candidates(buf, oldstate, oldrows.candidates, newstate, newrows.candidates) + currentrow += newrows.candidates + end + if refresh_preview + if !refresh_cands + print(buf, '\n' ^ newrows.candidates) + currentrow += newrows.candidates + end + redisplay_preview(buf, oldstate, oldrows.preview, newstate, newrows.preview) + currentrow += max(0, newrows.preview - 1) + end + end + # Restore row pos + print(buf, "\e[", currentrow, "A\e[1G") + redisplay_prompt(buf, oldstate, newstate, pstate) + # Restore column pos + print(buf, "\e[", textwidth(PROMPT_TEXT) + position(pstate.input_buffer) + 1, 'G') + synccap && print(buf, SYNC_UPDATE_END) + if Base.generating_output() + # Write output in chunks seems to avoid a hang that happens here during precompilation + # of the history on mac (io gets full without anything draining it?) + seekstart(buf.io) + data = read(buf.io) + for chunk in Iterators.partition(data, 32) + write(io, chunk) + flush(io) + end + else + write(io, seekstart(buf.io)) + end + truncate(buf.io, 0) + flush(io) +end + +""" + componentrows(state::SelectorState) -> (; candidates::Int, preview::Int) + +Split available terminal rows into candidate list and preview panes. + +Clamps preview height between one-third and two-thirds of the usable area. +""" +function componentrows(state::SelectorState) + available_rows = 2 * (state.area.height - 1) ÷ 3 # REVIEW: maybe `min(height, ?)` + preview_min, preview_max = available_rows ÷ 3, 2 * available_rows ÷ 3 + nlines_preview = countlines_selected(state) + # To prevent jittering when key-repeat is happening with TAB at + # the end of a list of multiple selected candidates, stop + # the final candidate from affecting the size of the preview pane. + if length(state.selection.active) > 2 && + last(state.selection.active) == lastindex(state.candidates) + nlines_preview -= count('\n', state.candidates[end].content) + 1 + end + preview_rows = clamp(nlines_preview, preview_min, preview_max) + if preview_min <= 2 + preview_rows = 0 # Not worth just showing the frame + end + candidate_rows = available_rows - preview_rows + (; candidates = candidate_rows, preview = preview_rows) +end + +""" + countlines_selected(state::SelectorState) -> Int + +Count display lines needed for active and gathered entries. + +Includes one line per entry plus extra lines for multi-line content +and a divider if any gathered entries exist. +""" +function countlines_selected((; candidates, selection)::SelectorState) + (; active, gathered) = selection + nlines = 0 + for idx in active + entry = candidates[idx] + nlines += 1 + count('\n', entry.content) + end + if !isempty(gathered) + nlines += 1 # The divider line + for entry in gathered + nlines += 1 + count('\n', entry.content) + end + end + nlines +end + +const BASE_MODE = :julia + +const MODE_FACES = Dict( + :julia => :green, + :shell => :red, + :pkg => :blue, + :help => :yellow, +) + +""" + redisplay_prompt(io::IO, oldstate::SelectorState, newstate::SelectorState, pstate::PromptState) + +Redraw just the prompt line with updated query, separators, and hints. + +Styles prefixes, match-type indicators, and result counts based on cursor position in `pstate`. +""" +function redisplay_prompt(io::IO, oldstate::SelectorState, newstate::SelectorState, pstate::REPL.LineEdit.PromptState) + # oldstate.query == newstate.query && return + hov = gethover(newstate) + query = newstate.query + styquery = S"$query" + styconds = ConditionSet(styquery) + qpos = position(pstate.input_buffer) + kindname = "" + patend = 0 + for (name, substrs) in (("words", styconds.words), + ("exact", styconds.exacts), + ("negated", styconds.negatives), + ("initialism", styconds.initialisms), + ("regexp", styconds.regexps), + ("fuzzy", styconds.fuzzy), + ("mode", styconds.modes)) + for substr in substrs + start, len = substr.offset, substr.ncodeunits + patend = max(patend, start + len) + if start > 1 + if query[start] == FILTER_SEPARATOR + face!(styquery[start:start], :REPL_History_search_separator) + else + face!(styquery[start:start], :REPL_History_search_prefix) + face!(styquery[start-1:start-1], :REPL_History_search_separator) + end + elseif start > 0 + face!(styquery[start:start], + if query[start] == FILTER_SEPARATOR + :REPL_History_search_separator + else + :REPL_History_search_prefix + end) + end + isempty(kindname) || continue + if start <= qpos <= start + len + kindname = name + break + end + end + end + if patend < ncodeunits(query) + if query[patend+1] == FILTER_SEPARATOR + face!(styquery[patend+1:patend+1], :REPL_History_search_separator) + if patend + 1 < ncodeunits(query) && query[patend+2] ∈ FILTER_PREFIXES + face!(styquery[patend+2:patend+2], :REPL_History_search_prefix) + elseif isempty(kindname) + kindname = "separator" + end + elseif ncodeunits(query) == 1 && query[1] ∈ FILTER_PREFIXES + face!(styquery[1:1], :REPL_History_search_prefix) + end + end + prefix = S"{bold:▪:} " + ncand = length(newstate.candidates) + resultnum = S"{REPL_History_search_results:[$(ncand - newstate.hover + 1)/$ncand]}" + padspaces = newstate.area.width - sum(textwidth, (prefix, styquery, resultnum)) + suffix = if isempty(styquery) + LABELS.help_prompt + elseif newstate.query ∈ (FILTER_SHORTHELP_QUERY, FILTER_LONGHELP_QUERY) + S"{REPL_History_search_hint:help} " + elseif kindname != "" + S"{REPL_History_search_hint:$kindname} " + else + S"" + end + if textwidth(suffix) < padspaces + padspaces -= textwidth(suffix) + else + suffix = S"" + end + # TODO: Replace with a face-based approach when possible + print(io, pstate.p.prompt_prefix, prefix, "\e[0m", + styquery, ' ' ^ max(0, padspaces), suffix, resultnum) +end + +# Unicode circles: +# - large: ● ○ +# - medium: ⏺🞉🞈🞇🞆🞅⚬🞊⦿⦾ +# - small: •⋅∙∘◦ +# - dots: 🞄⁃· + +const LIST_MARKERS = if Sys.isapple() + # '🞇' is not available by default, and '⬤' is oversized, so we must compromise. + (selected = AnnotatedChar('⏺', [(:face, :REPL_History_search_selected)]), + hover = AnnotatedChar('⦿', [(:face, :REPL_History_search_selected)]), + unselected = AnnotatedChar('◦', [(:face, :REPL_History_search_unselected)]), + pending = AnnotatedChar('·', [(:face, :shadow)])) +else + # Linux tends to have pretty fantastic OOTB Unicode support, with fonts + # like Symbola installed by default, so we can go for the best symbols. + (selected = AnnotatedChar('⬤', [(:face, :REPL_History_search_selected)]), + hover = AnnotatedChar('🞇', [(:face, :REPL_History_search_selected)]), + unselected = AnnotatedChar('◦', [(:face, :REPL_History_search_unselected)]), + pending = AnnotatedChar('🞄', [(:face, :shadow)])) +end + +const NEWLINE_MARKER = S"{shadow:↩ }" +const LINE_ELLIPSIS = S"{shadow:…}" + +""" + hoveridx(state::SelectorState) -> Int + +Compute the signed index into `candidates` or `gathered` for hover. + +Positive values index `candidates`, negative values index `gathered`, zero is +invalid. +""" +function hoveridx(state::SelectorState) + if state.hover > 0 + length(state.candidates) - state.hover + 1 + else + state.hover + end +end + +""" + ishover(state::SelectorState, idx::Int) -> Bool + +Return true if `idx` matches the current hover position. + +Used to highlight the hovered line in the UI. +""" +ishover(state::SelectorState, idx::Int) = idx == hoveridx(state) + +""" + gethover(state::SelectorState) -> Union{HistEntry, Nothing} + +Return the `HistEntry` under the cursor (hover position), or `nothing`. + +Handles positive hover for `candidates` and negative for `gathered`. +""" +function gethover(state::SelectorState) + idx = hoveridx(state) + if idx ∈ axes(state.candidates, 1) + state.candidates[idx] + elseif idx < 0 && -idx ∈ axes(state.selection.gathered, 1) + state.selection.gathered[-idx] + end +end + +struct CandsState{V<:AbstractVector{HistEntry}} + search::FilterSpec + entries::V + selected::Vector{Int} + hover::Int + rows::Int + width::Int +end + + +""" + candidates(state::SelectorState, rows::Int) -> (; active::CandsState, gathered::CandsState) + +Compute visible slices of active and gathered entries for display. +""" +function candidates(state::SelectorState, rows::Int) + gathshift = 0 + gathcount = clamp(-state.scroll, 0, length(state.selection.gathered)) + if gathcount >= rows + gathshift = gathcount - rows + 1 + gathcount = rows - 1 + end + actcount = rows - gathcount - sign(gathcount) + offset = max(0, length(state.candidates) - actcount - max(0, state.scroll)) + candend = offset + actcount + actcands = @view state.candidates[max(begin, begin+offset):min(end, candend)] + actempty = actcount - length(actcands) + actsel = Int[idx - offset for idx in state.selection.active] + if !isempty(state.selection.gathered) + append!(actsel, filter!(!isnothing, indexin(state.selection.gathered, actcands))) + end + active = CandsState( + state.filter, + actcands, + actsel, + rows + state.scroll - state.hover - actempty + gathshift + (state.scroll >= 0), + actcount, + state.area.width) + gathcands = @view state.selection.gathered[begin+gathshift:min(end, gathshift+gathcount)] + gathered = CandsState( + state.filter, + gathcands, + collect(axes(gathcands, 1)), + -state.hover - gathshift, + gathcount, + state.area.width) + (; active, gathered) +end + +""" + redisplay_candidates(io::IO, oldstate::SelectorState, oldrows::Int, newstate::SelectorState, newrows::Int) + +Diff and redraw the candidate list pane between two states. + +Only lines that changed (entry text, selection, hover, width) are reprinted; +unchanged lines remain. +""" +function redisplay_candidates(io::IO, oldstate::SelectorState, oldrows::Int, newstate::SelectorState, newrows::Int) + danglingdivider = false + if oldstate.scroll < 0 && newstate.scroll == 0 + newrows -= 1 + danglingdivider = true + end + oldcands = candidates(oldstate, oldrows) + newcands = candidates(newstate, newrows) + samefilter = oldstate.filter == newstate.filter + # Redisplay active candidates + update_candidates(io, oldcands.active, newcands.active, + !samefilter || oldstate.scroll == 0 && !isempty(oldstate.selection.gathered)) + # Redisplay gathered candidates + gathchange = oldrows != newrows || length(oldcands.gathered.entries) != length(newcands.gathered.entries) + if isempty(newcands.gathered.entries) && !danglingdivider + elseif gathchange || danglingdivider || oldstate.area != newstate.area + netlines = newstate.area.width - textwidth(LABELS.gatherdivider) - 6 + leftlines = netlines ÷ 2 + rightlines = netlines - leftlines + println(io, S" {shadow:╶$('─' ^ leftlines)╴$(LABELS.gatherdivider)╶$('─' ^ rightlines)╴} ") + else + println(io) + end + update_candidates(io, oldcands.gathered, newcands.gathered, gathchange != 0) +end + +""" + update_candidates(io::IO, oldcands::CandsState, newcands::CandsState, force::Bool = false) + +Write an update to `io` that changes the display from `oldcands` to `newcands`. + +Only changes are printed, and exactly `length(newcands.entries)` lines are printed. +""" +function update_candidates(io::IO, oldcands::CandsState, newcands::CandsState, force::Bool = false) + thisline = 1 + for (i, (old, new)) in enumerate(zip(oldcands.entries, newcands.entries)) + oldsel, newsel = i ∈ oldcands.selected, i ∈ newcands.selected + oldhov, newhov = i == oldcands.hover, i == newcands.hover + if !force && old == new && oldsel == newsel && oldhov == newhov && oldcands.width == newcands.width + println(io) + else + print_candidate(io, newcands.search, new, newcands.width; + selected = newsel, hover = newhov) + end + thisline = i + 1 + end + for (i, new) in enumerate(newcands.entries) + i <= length(oldcands.entries) && continue + print_candidate(io, newcands.search, new, newcands.width; + selected = i ∈ newcands.selected, + hover = i == newcands.hover) + thisline = i + 1 + end + for _ in thisline:newcands.rows + print(io, "\e[K ", LIST_MARKERS.pending, '\n') + end +end + +const DURATIONS = ( + m = 60, + h = 60 * 60, + d = 24 * 60 * 60, + w = 7 * 24 * 60 * 60, + y = 365 * 24 * 60 * 60, +) + +""" + humanage(seconds::Integer) -> String + +Convert `seconds` into a compact age string with largest unit. + +```julia-repl +julia> humanage(70) +"1m" + +julia> humanage(4000) +"1h" +``` +""" +function humanage(seconds::Integer) + unit, count = :s, seconds + for (dunit, dsecs) in pairs(DURATIONS) + n = seconds ÷ dsecs + n == 0 && break + unit, count = dunit, n + end + "$count$unit" +end + +""" + print_candidate(io::IO, search::FilterSpec, cand::HistEntry, width::Int; selected::Bool, hover::Bool) + +Render one history entry line with markers, mode hint, age, and highlighted content. + +Truncates and focuses on matches to fit `width`. +""" +function print_candidate(io::IO, search::FilterSpec, cand::HistEntry, width::Int; selected::Bool, hover::Bool) + print(io, ' ', if selected + LIST_MARKERS.selected + elseif hover + LIST_MARKERS.hover + else + LIST_MARKERS.unselected + end, ' ') + age = humanage(floor(Int, ((now(UTC) - cand.date)::Millisecond).value ÷ 1000)) + agedec = S" {shadow,light,italic:$age}" + modehint = if cand.mode == BASE_MODE + S"" + else + modeface = get(MODE_FACES, cand.mode, :grey) + if hover + S"{region: {bold,inverse,$modeface: $(cand.mode) }}" + elseif ncodeunits(age) == 2 + S" {$modeface:◼} " + else + S" {$modeface:◼} " + end + end + decorationlen = 3 #= spc + marker + spc =# + textwidth(modehint) + textwidth(agedec) + 1 #= spc =# + flatcand = replace(highlightcand(cand), r"\r?\n\s*" => NEWLINE_MARKER) + candstr = focus_matches(search, flatcand, width - decorationlen) + if hover + face!(candstr, :region) + face!(agedec, :region) + end + println(io, candstr, modehint, agedec, ' ') +end + +""" + highlightcand(cand::HistEntry) -> AnnotatedString + +Syntax-highlight Julia content or return raw content otherwise. +""" +function highlightcand(cand::HistEntry) + if cand.mode === :julia + highlight(cand.content) + else + S"$(cand.content)" + end +end + +""" + focus_matches(search::FilterSpec, content::AnnotatedString, targetwidth::Int) -> AnnotatedString + +Center and trim `content` around matching regions, adding ellipses. + +To best display matches, this function operates in multiple stages: +1. Find all matching character ranges in `content` via `matchregions(search, String(content))`. +2. Choose a primary match region that can be fully shown within `targetwidth`, + preferring the first match. +3. Starting from the end of that region, expand a window leftwards up to + `targetwidth`, accounting for character widths. +4. If the left bound exceeds the start of `content`, reserve space for a leading + ellipsis (`LINE_ELLIPSIS`) and adjust the window. +5. Expand the window rightwards similarly, inserting a trailing ellipsis if + there is remaining text. +6. Slice out the computed substring from `content`, preserving existing annotations. +7. Re-apply the match highlight face (`:REPL_History_search_match`) to any + regions within the window. +8. Pad the result with spaces if its width is less than `targetwidth`. + +The returned `AnnotatedString` is exactly `targetwidth` columns wide, +guaranteeing at least one full match is visible and highlighted. +""" +function focus_matches(search::FilterSpec, content::AnnotatedString{String}, targetwidth::Int) + cstr = String(content) # zero-cost + mregions = matchregions(search, cstr) + isempty(mregions) && return rpad(rtruncate(content, targetwidth, LINE_ELLIPSIS), targetwidth) + mstart = first(first(mregions)) + mlast = first(mregions) + ellipwidth = textwidth(LINE_ELLIPSIS) + # Assume approximately one cell per character, and refine later + for (i, region) in Iterators.reverse(enumerate(mregions)) + if first(region) - mstart <= targetwidth - 2 * ellipwidth + mlast = region + break + end + end + # Start at the end of the last region, and extend backwards `targetwidth` characters + left, right = let pos = thisind(cstr, last(mlast)); (pos, pos) end + width = textwidth(cstr[left]) + while left > firstindex(cstr) + lnext = prevind(cstr, left) + lwidth = textwidth(cstr[lnext]) + if width + lwidth > targetwidth - 2 * ellipwidth + break + end + width += lwidth + left = lnext + end + # Check to see if we have reached the beginning of the first match, + # if we haven't we want to shrink the region to the left until the + # beginning of the first match is reached. + if left > first(mstart) + while left > first(mstart) + left = prevind(cstr, left) + lwidth = textwidth(cstr[left]) + width += lwidth + # We'll move according to the assumption that each character + # is one cell wide, but account for the width correctly and + # adjust for any underestimate later. + for _ in 1:lwidth + width -= textwidth(cstr[right]) + right = prevind(cstr, right) + right == left && break + end + end + end + isltrunc, isrtrunc = left > firstindex(cstr), right < lastindex(cstr) + # Use any available space to extend to the left. + if width < targetwidth - (isltrunc + isrtrunc) * ellipwidth && left < firstindex(cstr) + while left < firstindex(cstr) + lnext = prevind(cstr, left) + lwidth = textwidth(cstr[lnext]) + isnextltrunc = lnext > firstindex(cstr) + nellipsis = isnextltrunc + isrtrunc + if width + lwidth > targetwidth - nellipsis * ellipwidth + break + end + width += lwidth + left = lnext + end + isltrunc = left > firstindex(cstr) + end + # Use any available space to extend to the right. + if width < targetwidth - (isltrunc + isrtrunc) * ellipwidth && right < lastindex(cstr) + while right < lastindex(cstr) + rnext = nextind(cstr, right) + rwidth = textwidth(cstr[rnext]) + isnextrtrunc = rnext < lastindex(cstr) + nellipsis = isltrunc + isnextrtrunc + if width + rwidth > targetwidth - nellipsis * ellipwidth + break + end + width += rwidth + right = rnext + end + end + # Construct the new region + regstr = AnnotatedString(content[left:right]) + # Emphasise matches + for region in mregions + (last(region) < left || first(region) > right) && continue + adjregion = (max(left, first(region)) - left + 1):(min(right, last(region)) - left + 1) + face!(regstr, adjregion, :REPL_History_search_match) + end + # Add ellipses + ellipstr = if left > firstindex(cstr) && right < lastindex(cstr) + width += 2 * ellipwidth + LINE_ELLIPSIS * regstr * LINE_ELLIPSIS + elseif left > firstindex(cstr) + width += ellipwidth + LINE_ELLIPSIS * regstr + elseif right < lastindex(cstr) + width += ellipwidth + regstr * LINE_ELLIPSIS + else + regstr + end + # Pad (if necessary) + if width < targetwidth + rpad(ellipstr, targetwidth) + else + ellipstr + end +end + +""" + redisplay_preview(io::IO, oldstate::SelectorState, oldrows::Int, newstate::SelectorState, newrows::Int) + +Diff and redraw the preview pane (right side) with boxed content. + +Shows hover or gathered entries in a box. +""" +function redisplay_preview(io::IO, oldstate::SelectorState, oldrows::Int, newstate::SelectorState, newrows::Int) + newrows == 0 && return + function getcand(state::SelectorState, idx::Int) + if idx ∈ axes(state.candidates, 1) + state.candidates[idx] + elseif -idx ∈ axes(state.selection.gathered, 1) + state.selection.gathered[-idx] + else + throw(ArgumentError("Invalid candidate index: $idx")) # Should never happen + end + end + function getselidxs(state::SelectorState) + idxs = collect(-1:-1:-length(state.selection.gathered)) + append!(idxs, state.selection.active) + sort!(idxs, by = i -> getcand(state, i).index) + end + rtruncpad(s::AbstractString, width::Int) = + rpad(rtruncate(s, width, LINE_ELLIPSIS), width) + bar = S"{shadow:│}" + innerwidth = newstate.area.width - 2 + if oldstate.area != newstate.area || (oldstate.area.height - oldrows) != (newstate.area.height - newrows) + println(io, S"{shadow:╭$('─' ^ innerwidth)╮}") + else + println(io) + end + if newrows - 2 < 1 + # Well, this is awkward. + elseif isempty(newstate.selection.active) && isempty(newstate.selection.gathered) + linesprinted = if (gethover(newstate) != gethover(oldstate) || + oldstate.area != newstate.area || + oldrows != newrows || + oldstate.filter != newstate.filter) + hovcand = gethover(newstate) + if !isnothing(hovcand) + hovcontent = highlightcand(hovcand) + for region in matchregions(newstate.filter, String(hovcontent)) + face!(hovcontent[region], :REPL_History_search_match) + end + if hovcand.mode !== BASE_MODE + mcolor = get(MODE_FACES, hovcand.mode, :grey) + hovcontent = S"{bold,$mcolor:$(hovcand.mode)>} " * hovcontent + end + boxedcontent(io, hovcontent, newstate.area.width, newrows - 2) + else + 0 + end + else + print(io, '\n' ^ (newrows - 2)) + newrows - 2 + end + for _ in (linesprinted + 1):(newrows - 2) + println(io, bar, ' '^innerwidth, bar) + end + else + linesprinted = 0 + seltexts = AnnotatedString{String}[] + for idx in getselidxs(newstate) + entry = getcand(newstate, idx) + content = highlightcand(entry) + ishover(newstate, idx) && face!(content, :region) + push!(seltexts, content) + end + linecount = sum(t -> 1 + count('\n', String(t)), seltexts, init=0) + for (i, content) in enumerate(seltexts) + clines = 1 + count('\n', String(content)) + if linesprinted + clines < newrows - 2 || (i == length(seltexts) && linesprinted + clines == newrows - 2) + for line in eachsplit(content, '\n') + println(io, bar, ' ', rtruncpad(line, innerwidth - 2), ' ', bar) + end + linesprinted += clines + else + remaininglines = newrows - 2 - linesprinted + for (i, line) in enumerate(eachsplit(content, '\n')) + i == remaininglines && break + println(io, bar, ' ', rtruncpad(line, innerwidth - 2), ' ', bar) + end + msg = S"{julia_comment:⋮ {italic:$(linecount - newrows + 3) lines hidden}}" + println(io, bar, ' ', rtruncpad(msg, innerwidth - 2), ' ', bar) + linesprinted += remaininglines + break + end + end + for _ in (linesprinted + 1):(newrows - 2) + println(io, bar, ' ' ^ innerwidth, bar) + end + end + if oldstate.area != newstate.area || length(oldstate.selection.active) != length(newstate.selection.active) + if textwidth(LABELS.preview_suggestion) < innerwidth + line = '─' ^ (innerwidth - textwidth(LABELS.preview_suggestion) - 2) + print(io, S"{shadow:╰$(line)╴$(LABELS.preview_suggestion)╶╯}") + else + print(io, S"{shadow:╰$('─' ^ innerwidth)╯}") + end + end +end + +""" + boxedcontent(io::IO, content::AnnotatedString, width::Int, maxlines::Int) -> Int + +Draw `content` inside a Unicode box, wrapping or truncating to `width` and `maxlines`. + +Returns the number of printed lines. +""" +function boxedcontent(io::IO, content::AnnotatedString{String}, width::Int, maxlines::Int) + function breaklines(content::AnnotatedString{String}, maxwidth::Int) + textwidth(content) <= maxwidth && return [content] + spans = AnnotatedString{String}[] + basestr = String(content) # Because of expensive char iteration + start, pos, linewidth = 1, 0, 0 + for char in basestr + linewidth += textwidth(char) + pos = nextind(basestr, pos) + if linewidth > maxwidth + spans = push!(spans, AnnotatedString(content[start:prevind(basestr, pos)])) + start = pos + linewidth = textwidth(char) + end + end + if start <= length(basestr) + spans = push!(spans, AnnotatedString(content[start:end])) + end + spans + end + left, right = S"{shadow:│} ", S" {shadow:│}" + leftcont, rightcont = S"{shadow:┊▸}", S"{shadow:◂┊}" + if maxlines == 1 + println(io, left, + rpad(rtruncate(content, width - 4, LINE_ELLIPSIS), width - 4), + right) + return 1 + end + printedlines = 0 + if ncodeunits(content) > (width * maxlines) + content = AnnotatedString(rtruncate(content, width * maxlines, ' ')) + end + lines = split(content, '\n') + innerwidth = width - 4 + for (i, line) in enumerate(lines) + printedlines >= maxlines && break + if textwidth(line) <= innerwidth + println(io, left, rpad(line, innerwidth), right) + printedlines += 1 + continue + end + plainline = String(line) + indent, ichars = 0, 1 + while isspace(plainline[ichars]) + indent += textwidth(plainline[ichars]) + ichars = nextind(plainline, ichars) + end + line = @view line[ichars:end] + spans = breaklines(AnnotatedString(line), innerwidth - 2 - indent) + for (i, span) in enumerate(spans) + prefix, suffix = if i == 1 + S"", S"$LINE_ELLIPSIS " + elseif i == length(spans) + S"$LINE_ELLIPSIS", S" " + else + LINE_ELLIPSIS, LINE_ELLIPSIS + end + printedlines += 1 + println(io, ifelse(i == 1, left, leftcont), ' ' ^ indent, + prefix, rpad(span, innerwidth - 2 - indent), suffix, + ifelse(i == length(spans) || printedlines == maxlines, + right, rightcont)) + printedlines >= maxlines && break + end + end + printedlines +end diff --git a/stdlib/REPL/src/History/histfile.jl b/stdlib/REPL/src/History/histfile.jl new file mode 100644 index 0000000000000..b62dfeae504e2 --- /dev/null +++ b/stdlib/REPL/src/History/histfile.jl @@ -0,0 +1,288 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +""" + REPL_DATE_FORMAT + +The `DateFormat` used to parse and format timestamps in the REPL history file. +""" +const REPL_DATE_FORMAT = dateformat"yyyy-mm-dd HH:MM:SS" + +const HIST_OPEN_FLAGS = + Base.Filesystem.JL_O_APPEND | + Base.Filesystem.JL_O_RDWR | + Base.Filesystem.JL_O_CREAT | + Base.Filesystem.JL_O_CLOEXEC + +struct HistEntry + mode::Symbol + date::DateTime + # cwd::String + content::String + # resulttype::String + # session::UInt64 + index::UInt32 + # sindex::UInt16 + # error::Bool +end + +""" + HistoryFile(path::String) -> HistoryFile + +Create a handle to the history file at `path`, and store the `HistEntry` records. + +See also: `update!(::HistoryFile)`. +""" +struct HistoryFile <: AbstractVector{HistEntry} + path::String + file::Base.Filesystem.File + lock::ReentrantLock + records::Vector{HistEntry} +end + +HistoryFile(path::String) = HistoryFile( + path, Base.Filesystem.open(path, HIST_OPEN_FLAGS, 0o640), ReentrantLock(), []) + +function HistoryFile() + nofile = Base.Filesystem.File(Base.Filesystem.INVALID_OS_HANDLE) + nofile.open = false + HistoryFile("", nofile, ReentrantLock(), []) +end + +Base.lock(hist::HistoryFile) = lock(hist.lock) +Base.trylock(hist::HistoryFile) = trylock(hist.lock) +Base.unlock(hist::HistoryFile) = unlock(hist.lock) + +Base.size(hist::HistoryFile) = @lock hist (length(hist.records),) +Base.getindex(hist::HistoryFile, i::Int) = hist.records[i] + +function ensureopen(hist::HistoryFile) + isopen(hist.file) && return true + isempty(hist.path) && return false + try + lock(hist) + newfile = Base.Filesystem.open(hist.path, HIST_OPEN_FLAGS, 0o640) + newfile.open || return false + hist.file.handle = newfile.handle + hist.file.open = true + finally + unlock(hist) + end +end + +Base.close(hist::HistoryFile) = close(hist.file) + +""" + update!(hist::HistoryFile) -> HistoryFile + +Read any new entries from the history file and record them as `HistEntry`s. + +Malformed entries are skipped, and if the last entry is incomplete the IO +position will be reset to the start of the entry. +""" +function update!(hist::HistoryFile) + (; file, records) = hist + # If the file has grown since the last read, + # we need to trigger a synchronisation of the + # stream state. This can be done with `fseek`, + # but that can't easily be called from Julia. + # Instead, we can use `filesize` to detect when + # we need to do this, and then use `peek` to + # trigger the synchronisation. This relies on + # undocumented implementation details, but + # there's not much to be done about that. + ensureopen(hist) || return hist + offset = position(file) + offset == filesize(file) && return hist + try + lock(hist) + bytes = read(file) + function findnext(data::Vector{UInt8}, index::Int, byte::UInt8, limit::Int = length(data)) + for i in index:limit + data[i] == byte && return i + end + limit + end + function isstrmatch(data::Vector{UInt8}, at::Int, str::String) + at + ncodeunits(str) <= length(data) || return false + for (i, byte) in enumerate(codeunits(str)) + data[at + i - 1] == byte || return false + end + true + end + histindex = if isempty(hist.records) + 0 + else + hist.records[end].index + end + pos = firstindex(bytes) + while true + pos >= length(bytes) && break + entrystart = pos + if bytes[pos] != UInt8('#') + @warn S"Malformed history entry: expected meta-line starting with {success:'#'} at byte {emphasis:$(offset + pos - 1)} in \ + {(underline=grey),link=$(Base.Filesystem.uripath(hist.path)):$(contractuser(hist.path))}, but found \ + {error:$(sprint(show, Char(bytes[pos])))} instead" _id=:invalid_history_entry maxlog=3 _file=nothing _line=nothing + pos = findnext(bytes, pos, UInt8('\n')) + 1 + continue + end + time, mode = zero(DateTime), :julia + while pos < length(bytes) && bytes[pos] == UInt8('#') + pos += 1 + while pos < length(bytes) && bytes[pos] == UInt8(' ') + pos += 1 + end + metastart = pos + metaend = findnext(bytes, pos, UInt8(':')) + pos = metaend + 1 + while pos < length(bytes) && bytes[pos] == UInt8(' ') + pos += 1 + end + valstart = pos + valend = findnext(bytes, pos, UInt8('\n')) + pos = valend + 1 + if isstrmatch(bytes, metastart, "mode:") + mode = if isstrmatch(bytes, valstart, "julia") && bytes[valstart + ncodeunits("julia")] ∈ (UInt8('\n'), UInt8('\r')) + :julia + elseif isstrmatch(bytes, valstart, "help") && bytes[valstart + ncodeunits("help")] ∈ (UInt8('\n'), UInt8('\r')) + :help + elseif all(>(0x5a), view(bytes, valstart:valend-1)) + Symbol(bytes[valstart:valend-1]) + else + Symbol(lowercase(String(bytes[valstart:valend-1]))) + end + elseif isstrmatch(bytes, metastart, "time:") + valend = min(valend, valstart + ncodeunits("0000-00-00 00:00:00")) + timestr = String(bytes[valstart:valend-1]) # It would be nice to avoid the string, but oh well + timeval = tryparse(DateTime, timestr, REPL_DATE_FORMAT) + if !isnothing(timeval) + time = timeval + end + end + end + if pos >= length(bytes) + # Potentially incomplete entry; roll back to start + seek(file, offset + entrystart - 1) + break + elseif bytes[pos] == UInt8(' ') + @warn S"Malformed history content: expected line to start with {success:'\\t'} at byte {emphasis:$(offset + pos - 1)} in \ + {(underline=grey),link=$(Base.Filesystem.uripath(hist.path)):$(contractuser(hist.path))}, but found \ + space ({error:' '}) instead. A text editor may have converted tabs to spaces in the \ + history file." _id=:invalid_history_content_spc maxlog=1 _file=nothing _line=nothing + continue + elseif bytes[pos] != UInt8('\t') + @warn S"Malformed history content: expected line to start with {success:'\\t'} at byte {emphasis:$(offset + pos - 1)} in \ + {(underline=grey),link=$(Base.Filesystem.uripath(hist.path)):$(contractuser(hist.path))}, but found \ + {error:$(sprint(show, Char(bytes[pos])))} instead" _id=:invalid_history_content maxlog=3 _file=nothing _line=nothing + continue + end + contentstart = pos + nlines = 0 + while true + pos = findnext(bytes, pos, UInt8('\n')) + nlines += 1 + if pos < length(bytes) && bytes[pos+1] == UInt8('\t') + pos += 1 + else + break + end + end + contentend, pos = pos, contentstart + content = Vector{UInt8}(undef, contentend - contentstart - nlines) + bytescopied = 0 + while pos < contentend + lineend = findnext(bytes, pos, UInt8('\n')) + nbytes = lineend - pos - (lineend == contentend) + copyto!(content, bytescopied + 1, bytes, pos + 1, nbytes) + bytescopied += nbytes + pos = lineend + 1 + end + entry = HistEntry(mode, time, String(content), histindex += 1) + push!(records, entry) + end + seek(file, offset + pos - 1) + finally + unlock(hist) + end + hist +end + +function Base.push!(hist::HistoryFile, entry::HistEntry) + try + lock(hist) + update!(hist) + entry = HistEntry( + if all(islowercase, String(entry.mode)) + entry.mode + else + Symbol(lowercase(String(entry.mode))) + end, + round(entry.date, Dates.Second), + entry.content, + length(hist.records) + 1) + push!(hist.records, entry) + isopen(hist.file) || return hist + content = IOBuffer() + write(content, "# time: ", + Dates.format(entry.date, REPL_DATE_FORMAT), "Z\n", + "# mode: ", String(entry.mode), '\n') + replace(content, entry.content, r"^"ms => "\t") + write(content, '\n') + # Short version: + # + # Libuv supports opening files with an atomic append flag, + # and so if we pass the entire new entry to `uv_fs_write` + # with an offset of `-1`, the OS will ensure that the write + # is atomic. There are some caveats around this, but there's + # no silver bullet. + # + # Long version: + # + # Normally, we would need to make sure we've got unique access to the file, + # however because we opened it with `O_APPEND` the OS (as of POSIX.1-2017, and on: + # Linux/FreeBSD/Darwin/Windows) guarantees that concurrent writes will not tear. + # + # This requires that a single `write` call be used to write the entire new entry. + # This is not obvious, but if you look at `base/filesystem.jl` we can see that + # the `unsafe_write` call below is turned into a `uv_fs_write` call. + # Following this to `src/jl_uv.c` we can see this quickly turns into a `uv_fs_write` + # call, which will produce a `uv__fs_write_all` call, and then calls `uv__fs_write` + # in a loop until everything is written. + # + # This loop seems like it might allow writes to be interleaved, but since + # we know that `nbufs = 1` and `off = -1` (from the parameters set in `unsafe_write` + # and `jl_uv_write`), we can see that `uv__fs_write` will call the `write` + # syscall directly, and so we get the `O_APPEND` semantics guaranteed by the OS. + # + # POSIX does mention that `write` may write less bytes than it is asked to, + # but only when either: + # 1. There is insufficient space on the device, or + # 2. The size of the write exceeds `RLIMIT_FSIZE`, or + # 3. The call is interrupted by a signal handler. + # + # Any of these would cause issues regardless. + # + # Over in Windows-land, `FILE_APPEND_DATA` has been around for a while (and is used + # by libuv), and from reading `win/fs.c` we can see that a similar approach is taken + # using `WriteFile` calls. Before Windows 10 (on NTFS), v10.0.14393 update atomicity + # could be as small as 1 byte, but after that testing indicates that writes through + # to 1MB are written in a single operation. Given that this is not an upper limit, + # and it would be quite an extraordinary REPL entry, this seem safe enough. + # + # While in theory a split write may occur, in practice this seems exceptionally rare + # (near non-existent), and the previous pidfile locking approach is no silver bullet + # either, with its own set of "reasonable assumptions" like: + # 1. PIDs not being rapidly recycled + # 2. No process being able to delete and write a file faster than another + # process can do the same + # 3. The PID number itself being written in one shot (see the above lack of + # formal guarantees around `write`, which also applies here) + # + # All in all, relying on kernel inode locking with `O_APPEND` and whole writes + # seems like the sanest approach overall. Mutual exclusion isn't the priority + # here, safe appending is. + unsafe_write(hist.file, pointer(content.data), position(content) % UInt, Int64(-1)) + finally + unlock(hist) + end + hist +end diff --git a/stdlib/REPL/src/History/prompt.jl b/stdlib/REPL/src/History/prompt.jl new file mode 100644 index 0000000000000..78b87e7868344 --- /dev/null +++ b/stdlib/REPL/src/History/prompt.jl @@ -0,0 +1,165 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +const PROMPT_TEXT = "▪: " + +struct Event <: Function + info::Channel{Symbol} + name::Symbol +end + +function (e::Event)(_...) + push!(e.info, e.name) + :ignore +end + +""" + select_keymap(events::Channel{Symbol}) + +Build a REPL.LineEdit keymap that pushes symbols into `events`. + +Binds arrows, page keys, Tab, Ctrl-C/D/S, and meta-< / > to +`Event` or `Returns` actions for driving the prompt loop. +""" +function select_keymap(events::Channel{Symbol}) + REPL.LineEdit.keymap([ + Dict{Any, Any}( + # Up Arrow + "\e[A" => Event(events, :up), + "^P" => Event(events, :up), + # Down Arrow + "\e[B" => Event(events, :down), + "^N" => Event(events, :down), + # Tab + '\t' => Event(events, :tab), + # Page up + "\e[5~" => Event(events, :pageup), + "\ev" => Event(events, :pageup), + # Page down + "\e[6~" => Event(events, :pagedown), + "^V" => Event(events, :pagedown), + # Meta + < / > + "\e<" => Event(events, :jumpfirst), + "\e>" => Event(events, :jumplast), + # + "^L" => Event(events, :clear), + # Exits + "^C" => Returns(:abort), + "^D" => Returns(:abort), + "^G" => Returns(:abort), + "\e\e" => Returns(:abort), + "^S" => Returns(:save), + "^Y" => Returns(:copy), + ), + REPL.LineEdit.default_keymap, + REPL.LineEdit.escape_defaults]) +end + +""" + create_prompt(events::Channel{Symbol}, term) + +Initialize a custom REPL prompt tied to `events` using the existing `term`. + +Returns a tuple `(term, prompt, istate, pstate)` ready for +input handling and display. +""" +function create_prompt(events::Channel{Symbol}, term, prefix::String = "\e[90m") + prompt = REPL.LineEdit.Prompt( + PROMPT_TEXT, # prompt + prefix, "\e[0m", # prompt_prefix, prompt_suffix + "", "", "", # output_prefix, output_prefix_prefix, output_prefix_suffix + select_keymap(events), # keymap_dict + nothing, # repl + REPL.LatexCompletions(), # complete + _ -> true, # on_enter + () -> nothing, # on_done + REPL.LineEdit.EmptyHistoryProvider(), # hist + false, # sticky + REPL.StylingPasses.StylingPass[]) # styling_passes + interface = REPL.LineEdit.ModalInterface([prompt]) + istate = REPL.LineEdit.init_state(term, interface) + pstate = istate.mode_state[prompt] + (; term, prompt, istate, pstate) +end + +""" + runprompt!((; term,prompt,pstate,istate), events::Channel{Symbol}) + +Drive the prompt input loop until confirm, save, or abort. + +Emits `:edit`, `:confirm`, `:save`, or `:abort` into `events`, +manages raw mode and bracketed paste, and cleans up on exit. +""" +function runprompt!((; term, prompt, pstate, istate), events::Channel{Symbol}) + Base.reseteof(term) + REPL.LineEdit.raw!(term, true) + REPL.LineEdit.enable_bracketed_paste(term) + try + pstate.ias = REPL.LineEdit.InputAreaState(0, 0) + REPL.LineEdit.refresh_multi_line(term, pstate) + while true + kmap = REPL.LineEdit.keymap(pstate, prompt) + matchfn = REPL.LineEdit.match_input(kmap, istate) + kdata = REPL.LineEdit.keymap_data(pstate, prompt) + status = matchfn(istate, kdata) + if status === :ok + push!(events, :edit) + elseif status === :ignore + istate.last_action = istate.current_action + elseif status === :done + print("\e[F") + push!(events, :confirm) + break + elseif status === :copy + print("\e[1G\e[J") + push!(events, status) + break + elseif status === :save + print("\e[1G\e[J") + dest = savedest(term) + if isnothing(dest) + push!(events, :redraw) + else + push!(events, dest) + break + end + else + push!(events, :abort) + break + end + end + finally + REPL.LineEdit.raw!(term, false) && + REPL.LineEdit.disable_bracketed_paste(term) + end +end + +function savedest(term::Base.Terminals.TTYTerminal) + out = term.out_stream + print(out, "\e[1G\e[J") + clipsave = true + try + print(out, get(Base.current_terminfo(), :cursor_invisible, "")) + fclip, ffile = [:emphasis, :bold], [:grey] + char = '\0' + while true + print(out, S"\e[1G\e[2K{bold,grey:history>} {bold,emphasis:save to} {$fclip,inverse: Clipboard } {$ffile,inverse: File } {shadow:Tab to toggle ⋅ ⏎ to select}") + ichar = read(term.in_stream, Char) + if ichar ∈ ('\x03', '\x18', '\a') || char == ichar == '\e' + return + elseif ichar == '\r' + break + end + char = ichar + fclip, ffile = ffile, fclip + clipsave = !clipsave + end + finally + # NOTE: While it may look like `:cursor_visible` would be the + # appropriate choice to reverse `:cursor_invisible`, unfortunately + # tmux-256color declares a sequence that doesn't actually make + # the cursor become visible again 😑. + print(out, get(Base.current_terminfo(), :cursor_normal, "")) + print(out, "\e[1G\e[2K") + end + if clipsave; :copy else :filesave end +end diff --git a/stdlib/REPL/src/History/resumablefiltering.jl b/stdlib/REPL/src/History/resumablefiltering.jl new file mode 100644 index 0000000000000..51a781550fc1f --- /dev/null +++ b/stdlib/REPL/src/History/resumablefiltering.jl @@ -0,0 +1,332 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +struct ConditionSet{S} + words::Vector{SubString{S}} + exacts::Vector{SubString{S}} + negatives::Vector{SubString{S}} + initialisms::Vector{SubString{S}} + fuzzy::Vector{SubString{S}} + regexps::Vector{SubString{S}} + modes::Vector{SubString{S}} +end + +ConditionSet{S}() where {S} = ConditionSet{S}([], [], [], [], [], [], []) + +""" + FILTER_SEPARATOR + +Character used to separate multiple search conditions in a single query. +""" +const FILTER_SEPARATOR = ';' + +""" + FILTER_PREFIXES + +List of single-character prefixes that set search modes. +""" +const FILTER_PREFIXES = ('!', '`', '=', '/', '~', '>') + +""" + FILTER_SHORTHELP_QUERY + +The special single-character query that triggers display of `FILTER_SHORTHELP`. +""" +const FILTER_SHORTHELP_QUERY = "?" + +""" + FILTER_LONGHELP_QUERY + +The special query that triggers display of `FILTER_LONGHELP`. +""" +const FILTER_LONGHELP_QUERY = "??" + +""" + FILTER_SHORTHELP + +Annotated help text displayed when the user enters the help query (`$FILTER_SHORTHELP_QUERY`). +""" +const FILTER_SHORTHELP = S""" + {bold,magenta:Interactive history search} + + Enter a search term at the prompt, and see matching candidates. + A search term that is {italic:just} '{REPL_History_search_prefix:?}' brings up this help page. + + See more information on behaviour and keybindings with '{REPL_History_search_prefix:??}'. + + Different search modes are available via prefixes, as follows: + {emphasis:•} {REPL_History_search_prefix:=} looks for exact matches + {emphasis:•} {REPL_History_search_prefix:!} {italic:excludes} exact matches + {emphasis:•} {REPL_History_search_prefix:/} performs a regexp search + {emphasis:•} {REPL_History_search_prefix:~} looks for fuzzy matches + {emphasis:•} {REPL_History_search_prefix:>} looks for a particular REPL mode + {emphasis:•} {REPL_History_search_prefix:`} looks for an initialism (text with matching initials) + + You can also apply multiple restrictions with the separator '{REPL_History_search_separator:$FILTER_SEPARATOR}'. + + For example, {region:{REPL_History_search_prefix:/}^foo{REPL_History_search_separator:$FILTER_SEPARATOR}\ +{REPL_History_search_prefix:`}bar{REPL_History_search_separator:$FILTER_SEPARATOR}\ +{REPL_History_search_prefix:>}shell} will look for history entries that start with "{code:foo}", + contains "{code:b... a... r...}", {italic:and} is a shell history entry. +""" + +const FILTER_LONGHELP = S""" + {bold,magenta:Interactive history search — behaviour and keybindings} + + Search your REPL history interactively by constructing filters. + + With no mode specified (see the basic help with '{REPL_History_search_prefix:?}'), entries are matched + if they contain all of the words in the search string, in any order. + + If the entire search string is lowercase, the search is case-insensitive. + + If you want to include the filter separator '{REPL_History_search_separator:$FILTER_SEPARATOR}' in a query, or start + a words filter with a prefix character, you may escape it with a backslash (e.g. {code:\\;}). + + Search results can be navigated with: + {emphasis:•} {code:↑}, {code:Ctrl+P}, or {code:Ctrl+K} to move up + {emphasis:•} {code:↓}, {code:Ctrl+N}, or {code:Ctrl+J} to move down + {emphasis:•} {code:PageUp} or {code:Ctrl+B} to page up + {emphasis:•} {code:PageDown} or {code:Ctrl+F} to page down + {emphasis:•} {code:Alt+<} to jump to the first result + {emphasis:•} {code:Alt+>} to jump to the last result + + Multiple search results can be selected with {code:Tab} and confirmed with {code:Enter}. + You may use {code:Ctrl+S} to save selected entries to a file or the clipboard. + + To abort the search, use {code:Ctrl+C}, {code:Ctrl+D}, {code:Ctrl+G}, or {code:Esc Esc}. +""" + +""" + ConditionSet(spec::AbstractString) -> ConditionSet + +Parse the raw search string `spec` into a `ConditionSet`. + +Parsing is performed by splitting on unescaped `FILTER_SEPARATOR` and +dispatching each segment according to its leading prefix character. +""" +function ConditionSet(spec::S) where {S <: AbstractString} + function addcond!(condset::ConditionSet, cond::SubString) + isempty(cond) && return + kind = first(cond) + if kind ∈ ('!', '=', '`', '/', '>', '~') + value = @view cond[2:end] + if kind ∈ ('`', '>', '~') + value = strip(value) + elseif !all(isspace, value) + value = if kind == '/' + rstrip(value) + else # kind ∈ ('!', '=') + strip(value) + end + end + isempty(value) && return + if startswith(cond, '!') + push!(condset.negatives, value) + elseif startswith(cond, '=') + push!(condset.exacts, value) + elseif startswith(cond, '`') + push!(condset.initialisms, value) + elseif startswith(cond, '/') + push!(condset.regexps, value) + elseif startswith(cond, '>') + push!(condset.modes, SubString(lowercase(value))) + elseif startswith(cond, '~') + push!(condset.fuzzy, value) + end + else + if startswith(cond, '\\') && !(length(cond) > 1 && cond[2] == '\\') + cond = @view cond[2:end] + end + push!(condset.words, strip(cond)) + end + nothing + end + cset = ConditionSet{S}() + pos = firstindex(spec) + mark = pos + lastind = lastindex(spec) + escaped = false + dropbytes = Int[] + while pos <= lastind + chr = spec[pos] + if escaped + chr == FILTER_SEPARATOR && push!(dropbytes, pos - mark) + escaped = false + elseif chr == '\\' + escaped = true + elseif chr == FILTER_SEPARATOR + str = SubString(spec, mark:pos - 1) + if !isempty(dropbytes) + str = SubString(convert(S, String(deleteat!(collect(codeunits(str)), dropbytes)))) + empty!(dropbytes) + end + addcond!(cset, lstrip(str)) + mark = pos + 1 + end + pos = nextind(spec, pos) + end + if mark <= lastind + str = SubString(spec, mark:pos - 1) + if !isempty(dropbytes) + str = SubString(convert(S, String(deleteat!(collect(codeunits(str)), dropbytes)))) + end + addcond!(cset, lstrip(SubString(spec, mark:lastind))) + end + cset +end + +""" + ismorestrict(a::ConditionSet, b::ConditionSet) -> Bool + +Whether `a` is at least as restrictive as `b`, across all conditions. +""" +function ismorestrict(a::ConditionSet, b::ConditionSet) + length(a.fuzzy) == length(b.fuzzy) && + all(splat(==), zip(a.fuzzy, b.fuzzy)) || return false + length(a.regexps) == length(b.regexps) && + all(splat(==), zip(a.regexps, b.regexps)) || return false + length(a.modes) == length(b.modes) && + all(splat(==), zip(a.modes, b.modes)) || return false + length(a.exacts) >= length(b.exacts) && + all(splat(occursin), zip(b.exacts, a.exacts)) || return false + length(a.words) >= length(b.words) && + all(splat(occursin), zip(b.words, a.words)) || return false + length(a.negatives) >= length(b.negatives) && + all(splat(occursin), zip(a.negatives, b.negatives)) || return false + length(a.initialisms) >= length(b.initialisms) && + all(splat(occursin), zip(b.initialisms, a.initialisms)) || return false + true +end + +struct FilterSpec + exacts::Vector{String} + negatives::Vector{String} + regexps::Vector{Regex} + modes::Vector{Symbol} +end + +FilterSpec() = FilterSpec([], [], [], []) + +function FilterSpec(cset::ConditionSet) + spec = FilterSpec([], [], [], []) + for term in cset.exacts + push!(spec.exacts, String(term)) + end + for words in cset.words + casesensitive = any(isuppercase, words) + for word in eachsplit(words) + if casesensitive + push!(spec.exacts, String(word)) + else + push!(spec.regexps, Regex(string("\\Q", word, "\\E"), "i")) + end + end + end + for term in cset.negatives + push!(spec.negatives, String(term)) + end + for rx in cset.regexps + try + push!(spec.regexps, Regex(rx)) + catch _ + # Regex error, skip + end + end + for itlsm in cset.initialisms + rx = Regex(join((string("(?:(?:\\b|_+)(?:\\Q", ltr, "\\E|\\Q", uppercase(ltr), + "\\E)\\w+|\\p{Ll}\\Q", uppercase(ltr), "\\E)") + for ltr in itlsm), "[\\W_]*?")) + push!(spec.regexps, rx) + end + for fuzz in cset.fuzzy + for word in eachsplit(fuzz) + rx = Regex(join((string("\\Q", ltr, "\\E") for ltr in word), "[^\\s\"#%&()*+,\\-\\/:;<=>?@[\\]^`{|}~]*?"), + ifelse(any(isuppercase, fuzz), "", "i")) + push!(spec.regexps, rx) + end + end + for mode in cset.modes + push!(spec.modes, Symbol(mode)) + end + spec +end + + +""" + filterchunkrev!(out, candidates, spec; idx, maxtime, maxresults) -> Int + +Incrementally filter `candidates[1:idx]` in reverse order. + +Pushes matches onto `out` until either `maxtime` is exceeded or `maxresults` +collected, then returns the new resume index. +""" +function filterchunkrev!(out::Vector{HistEntry}, candidates::DenseVector{HistEntry}, + spec::FilterSpec, idx::Int = length(candidates); + maxtime::Float64 = Inf, maxresults::Int = length(candidates)) + batchsize = clamp(length(candidates) ÷ 512, 10, 1000) + for batch in Iterators.partition(idx:-1:1, batchsize) + time() > maxtime && break + for outer idx in batch + entry = candidates[idx] + if !isempty(spec.modes) + entry.mode ∈ spec.modes || continue + end + matchfail = false + for text in spec.exacts + if !occursin(text, entry.content) + matchfail = true + break + end + end + matchfail && continue + for text in spec.negatives + if occursin(text, entry.content) + matchfail = true + break + end + end + matchfail && continue + for rx in spec.regexps + if !occursin(rx, entry.content) + matchfail = true + break + end + end + matchfail && continue + pushfirst!(out, entry) + length(out) == maxresults && break + end + end + max(0, idx - 1) +end + +""" + matchregions(spec::FilterSpec, candidate::AbstractString) -> Vector{UnitRange{Int}} + +Find all matching character ranges in `candidate` for `spec`. + +Collects exact-substring and regex matches, then returns them +sorted by start index (and longer matches first). +""" +function matchregions(spec::FilterSpec, candidate::AbstractString) + matches = UnitRange{Int}[] + for text in spec.exacts + append!(matches, findall(text, candidate)) + end + for rx in spec.regexps + for (; match) in eachmatch(rx, candidate) + push!(matches, 1+match.offset:thisind(candidate, match.offset + match.ncodeunits)) + end + end + sort!(matches, by = m -> (first(m), -last(m))) + # Combine adjacent matches separated by a single space + for (i, match) in enumerate(matches) + i == length(matches) && break + nextmatch = matches[i + 1] + if last(match) + 1 == first(nextmatch) - 1 && candidate[last(match)+1] == ' ' + matches[i] = first(match):last(nextmatch) + matches[i+1] = last(nextmatch)+1:last(nextmatch) + end + end + filter!(!isempty, matches) +end diff --git a/stdlib/REPL/src/History/search.jl b/stdlib/REPL/src/History/search.jl new file mode 100644 index 0000000000000..8b5e20fc0385f --- /dev/null +++ b/stdlib/REPL/src/History/search.jl @@ -0,0 +1,375 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +""" + runsearch() -> (; mode::Union{Symbol, Nothing}, text::String) + +Launch the interactive REPL history search interface. + +Spawns prompt and display tasks, waits for user confirm or abort, +and returns the final selection (if any). +""" +function runsearch(histfile::HistoryFile, term, prefix::String = "\e[90m") + update!(histfile) + events = Channel{Symbol}(Inf) + pspec = create_prompt(events, term, prefix) + ptask = @spawn runprompt!(pspec, events) + dtask = @spawn run_display!(pspec, events, histfile.records) + wait(ptask) + fullselection(fetch(dtask)) +end + +""" + fullselection(state::SelectorState) -> (; mode::Symbol, text::String) + +Gather all selected and hovered entries and return them as joined text. +""" +function fullselection(state::SelectorState) + text = IOBuffer() + entries = copy(state.selection.gathered) + for act in state.selection.active + push!(entries, state.candidates[act]) + end + if isempty(entries) && state.hover ∈ axes(state.candidates, 1) + push!(entries, state.candidates[end-state.hover+1]) + end + sort!(entries, by = e -> e.index) + mainmode = if !isempty(entries) first(entries).mode end + join(text, Iterators.map(e -> e.content, entries), '\n') + (mode = mainmode, text = String(take!(text))) +end + +""" + run_display!((; term,pstate), events::Channel{Symbol}, hist::Vector{HistEntry}) + +Drive the display event loop until confirm or abort. + +Listens for navigation, edits, save, and abort events, re-filters history +incrementally, and re-renders via `redisplay_all`. +""" +function run_display!((; term, pstate), events::Channel{Symbol}, hist::Vector{HistEntry}) + # Output-related variables + out = term.out_stream + outsize = displaysize(out) + buf = IOContext(IOBuffer(), out) + # Main state variables + state = SelectorState(outsize, "", FilterSpec(), hist) + redisplay_all(out, EMPTY_STATE, state, pstate; buf) + # Candidate cache + cands_cache = Pair{ConditionSet{String}, Vector{HistEntry}}[] + cands_cachestate = zero(UInt8) + cands_current = HistEntry[] + cands_cond = ConditionSet{String}() + cands_temp = HistEntry[] + # Filter state + filter_idx = 0 + # Event loop + while true + event = @lock events if !isempty(events) take!(events) end + if isnothing(event) + elseif event === :abort + print(out, "\e[1G\e[J") + return EMPTY_STATE + elseif event === :confirm + print(out, "\e[1G\e[J") + return state + elseif event === :clear + print(out, "\e[H\e[2J") + redisplay_all(out, EMPTY_STATE, state, pstate; buf) + continue + elseif event === :redraw + print(out, "\e[1G\e[J") + redisplay_all(out, EMPTY_STATE, state, pstate; buf) + continue + elseif event ∈ (:up, :down, :pageup, :pagedown) + prevstate, state = state, movehover(state, event ∈ (:up, :pageup), event ∈ (:pageup, :pagedown)) + @lock events begin + nextevent = if !isempty(events) first(events.data) end + while nextevent ∈ (:up, :down, :pageup, :pagedown) + take!(events) + state = movehover(state, nextevent ∈ (:up, :pageup), event ∈ (:pageup, :pagedown)) + nextevent = if !isempty(events) first(events.data) end + end + end + redisplay_all(out, prevstate, state, pstate; buf) + continue + elseif event === :jumpfirst + prevstate = state + state = SelectorState( + state.area, state.query, state.filter, state.candidates, + length(state.candidates) - componentrows(state).candidates, + state.selection, length(state.candidates)) + redisplay_all(out, prevstate, state, pstate; buf) + continue + elseif event === :jumplast + prevstate = state + state = SelectorState( + state.area, state.query, state.filter, state.candidates, + 0, state.selection, 1) + redisplay_all(out, prevstate, state, pstate; buf) + continue + elseif event === :tab + prevstate, state = state, toggleselection(state) + redisplay_all(out, prevstate, state, pstate; buf) + continue + elseif event === :edit + @lock events begin + while !isempty(events) && first(events.data) === :edit + take!(events) + end + end + query = REPL.LineEdit.input_string(pstate) + if query === state.query + redisplay_all(out, state, state, pstate; buf) + continue + end + # Determine the conditions/filter spec + cands_cond = ConditionSet(query) + filter_spec = FilterSpec(cands_cond) + # Construct a provisional new state + prevstate, state = state, SelectorState( + outsize, query, filter_spec, HistEntry[], state.selection.gathered) + # Gather selected candidates + if !isempty(prevstate.selection.active) + for act in prevstate.selection.active + push!(state.selection.gathered, prevstate.candidates[act]) + end + sort!(state.selection.gathered, by = e -> e.index) + state = SelectorState( + state.area, state.query, state.filter, state.candidates, + -min(length(state.selection.gathered), state.area.height ÷ 8), + state.selection, 1) + end + # Show help? + if query ∈ (FILTER_SHORTHELP_QUERY,FILTER_LONGHELP_QUERY) + redisplay_all(out, prevstate, state, pstate; buf) + continue + end + # Parse the conditions and find a good candidate list + cands_current = hist + for (cond, cands) in Iterators.reverse(cands_cache) + if ismorestrict(cands_cond, cond) + cands_current = cands + break + end + end + # Start filtering candidates + filter_idx = filterchunkrev!( + state, cands_current; + maxtime = time() + 0.01, + maxresults = outsize[1]) + if filter_idx == 0 + cands_cachestate = addcache!( + cands_cache, cands_cachestate, cands_cond => state.candidates) + end + redisplay_all(out, prevstate, state, pstate; buf) + continue + elseif event === :copy + content = strip(fullselection(state).text) + isempty(content) || saveclipboard(term.out_stream, content) + return EMPTY_STATE + elseif event === :filesave + content = strip(fullselection(state).text) + isempty(content) || savefile(term, content) + return EMPTY_STATE + else + error("Unknown event: $event") + end + if displaysize(out) != outsize + outsize = displaysize(out) + prevstate, state = state, SelectorState( + outsize, state.query, state.filter, state.candidates, + state.scroll, state.selection, state.hover) + redisplay_all(out, prevstate, state, pstate; buf) + elseif filter_idx != 0 + append!(empty!(cands_temp), state.candidates) + prevstate = SelectorState( + state.area, state.query, state.filter, cands_temp, + state.scroll, state.selection, state.hover) + filter_idx = filterchunkrev!( + state, cands_current, filter_idx; + maxtime = time() + 0.01) + if filter_idx == 0 + cands_cachestate = addcache!( + cands_cache, cands_cachestate, cands_cond => state.candidates) + end + # If there are now new candidates in the view, update + length(state.candidates) != length(prevstate.candidates) && + length(prevstate.candidates) - state.hover < outsize[1] && + redisplay_all(out, prevstate, state, pstate; buf) + elseif isnothing(event) + yield() + sleep(0.01) + end + end +end + +function filterchunkrev!(state::SelectorState, candidates::DenseVector{HistEntry}, idx::Int = length(candidates); + maxtime::Float64 = Inf, maxresults::Int = length(candidates)) + oldlen = length(state.candidates) + idx = filterchunkrev!(state.candidates, candidates, state.filter, idx; + maxtime = maxtime, maxresults = maxresults) + newlen = length(state.candidates) + newcands = view(state.candidates, (oldlen + 1):newlen) + gfound = Int[] + for (i, g) in enumerate(state.selection.gathered) + cind = searchsorted(newcands, g, by = e -> e.index) + isempty(cind) && continue + push!(state.selection.active, oldlen + first(cind)) + push!(gfound, i) + end + isempty(gfound) || deleteat!(state.selection.gathered, gfound) + idx +end + +""" + movehover(state::SelectorState, backwards::Bool, page::Bool) + +Move the hover cursor in `state` by one row or one page. + +The direction and size of the move is determined by `backwards` and `page`. +""" +function movehover(state::SelectorState, backwards::Bool, page::Bool) + candrows = componentrows(state).candidates + shift = ifelse(backwards, 1, -1) * ifelse(page, max(1, candrows - 1), 1) + # We need to adjust for the existence of the gathered selection, + # and the division line depending on whether it will still be + # visible after the move. + if !isempty(state.selection.gathered) && state.scroll < 0 && + state.hover + shift + state.scroll <= candrows + candrows -= 1 + shift -= page + end + ngathered = length(state.selection.gathered) + if page && state.scroll < 0 && state.hover < shift + shift -= min(-state.scroll, ngathered) - 2 * (state.hover == -1) + end + newhover = state.hover + shift + # This looks a little funky because we want to produce a particular + # behaviour when crossing between the active and gathered selection, namely + # we want to ensure it always takes an explicit step to go from one section + # to another and skip over 0 as an invalid position. + newhover = if sign(newhover) == sign(state.hover) || (abs(state.hover) == 1 && newhover != 0) + clamp(newhover, -ngathered + iszero(ngathered), max(1, length(state.candidates))) + elseif ngathered == 0 + 1 + elseif newhover == 0 + -sign(state.hover) + else + sign(state.hover) + end + newscroll = clamp(state.scroll, + max(-ngathered, newhover - candrows + (ngathered >= candrows)), + newhover - (newhover >= 0)) + SelectorState( + state.area, state.query, state.filter, state.candidates, + newscroll, state.selection, newhover) +end + +""" + toggleselection(state::SelectorState) + +Vary the selection of the current candidate (selected by hover) in `state`. +""" +function toggleselection(state::SelectorState) + newselection = if state.hover > 0 + hoveridx = length(state.candidates) - state.hover + 1 + hoveridx ∈ axes(state.candidates, 1) || return state + activecopy = copy(state.selection.active) + selsearch = searchsorted(activecopy, hoveridx) + if isempty(selsearch) + insert!(activecopy, first(selsearch), hoveridx) + else + elt = activecopy[selsearch] + gidx = findfirst(==(elt), state.selection.gathered) + isnothing(gidx) || deleteat!(state.selection.gathered, gidx) + deleteat!(activecopy, first(selsearch)) + end + (active = activecopy, gathered = state.selection.gathered) + elseif state.hover < 0 + -state.hover ∈ axes(state.selection.gathered, 1) || return state + gatheredcopy = copy(state.selection.gathered) + deleteat!(gatheredcopy, -state.hover) + (active = state.selection.active, gathered = gatheredcopy) + else + return state + end + newstate = SelectorState( + state.area, state.query, state.filter, state.candidates, + state.scroll, newselection, state.hover) + movehover(newstate, false, false) +end + +""" + addcache!(cache::Vector{T}, state::Unsigned, new::T) + +Add `new` to the log-structured `cache` according to `state`. + +The lifetime of `new` is exponentially decaying, it has a `1` in `2^(k-1)` +chance of reaching the `k`-th position in the cache. + +The cache can hold as many items as the number of bits in `state` (e.g. 8 for `UInt8`). +""" +function addcache!(cache::Vector{T}, state::Unsigned, new::T) where {T} + maxsize = sizeof(state) * 8 + nextstate = state + one(state) + shift = state ⊻ nextstate + uninitialised = maxsize - length(cache) + if Base.leading_zeros(nextstate) < uninitialised + push!(cache, new) + return nextstate + end + for b in 1:(maxsize - 1) + iszero(shift & (0x1 << (maxsize - b))) && continue + cache[b - uninitialised] = cache[b - uninitialised + 1] + end + cache[end] = new + nextstate +end + +""" + savefile(term::Base.Terminals.TTYTerminal, content::AbstractString) + +Prompt the user to save `content` to a file path, and record the action. +""" +function savefile(term::Base.Terminals.TTYTerminal, content::AbstractString) + out = term.out_stream + nlines = count('\n', content) + 1 + print(out, S"\e[1G\e[2K{grey,bold:history>} {bold,emphasis:save file: }") + filename = try + readline(term.in_stream) + catch err + if err isa InterruptException + "" + else + rethrow() + end + end + if isempty(filename) + println(out, S"\e[F\e[2K{light,grey:{bold:history>} {red:×} History selection aborted}\n") + return + end + open(filename, "w") do io + seekend(io) + if !iszero(position(io)) + seek(io, position(io) - 1) + lastchar = read(io, UInt8) + seekend(io) + lastchar == UInt8('\n') || write(io, '\n') + end + write(io, content, '\n') + end + println(out, S"\e[F\e[2K{grey,bold:history>} {shadow:Wrote $nlines selected \ + $(ifelse(nlines == 1, \"line\", \"lines\")) to {underline,link=$(abspath(filename)):$filename}}\n") +end + +""" + saveclipboard(term::Base.Terminals.TTYTerminal, content::AbstractString) + +Save `content` to the clipboard and record the action. +""" +function saveclipboard(msgio::IO, content::AbstractString) + nlines = count('\n', content) + 1 + clipboard(content) + println(msgio, S"\e[1G\e[2K{grey,bold:history>} {shadow:Copied $nlines \ + $(ifelse(nlines == 1, \"line\", \"lines\")) to clipboard}\n") +end diff --git a/stdlib/REPL/src/LineEdit.jl b/stdlib/REPL/src/LineEdit.jl index e701cddad6feb..e11dab5ccad4a 100644 --- a/stdlib/REPL/src/LineEdit.jl +++ b/stdlib/REPL/src/LineEdit.jl @@ -5,6 +5,7 @@ module LineEdit import ..REPL using ..REPL: AbstractREPL, Options using ..REPL.StylingPasses: StylingPass, SyntaxHighlightPass, RegionHighlightPass, EnclosingParenHighlightPass, StylingContext, apply_styling_passes, merge_annotations +using ..REPL: histsearch using ..Terminals import ..Terminals: raw!, width, height, clear_line, beep @@ -621,7 +622,7 @@ function refresh_multi_line(termbuf::TerminalBuffer, terminal::UnixTerminal, buf prompt_obj = nothing if prompt isa PromptState prompt_obj = prompt.p - elseif prompt isa PrefixSearchState || prompt isa SearchState + elseif prompt isa PrefixSearchState if isdefined(prompt, :parent) && prompt.parent isa Prompt prompt_obj = prompt.parent end @@ -2237,67 +2238,6 @@ let end end -mutable struct HistoryPrompt <: TextInterface - hp::HistoryProvider - complete::CompletionProvider - keymap_dict::Dict{Char,Any} - HistoryPrompt(hp) = new(hp, EmptyCompletionProvider()) -end - -mutable struct SearchState <: ModeState - terminal::AbstractTerminal - histprompt::HistoryPrompt - #rsearch (true) or ssearch (false) - backward::Bool - query_buffer::IOBuffer - response_buffer::IOBuffer - failed::Bool - ias::InputAreaState - #The prompt whose input will be replaced by the matched history - parent::Prompt - SearchState(terminal, histprompt, backward, query_buffer, response_buffer) = - new(terminal, histprompt, backward, query_buffer, response_buffer, false, InputAreaState(0,0)) -end - -init_state(terminal, p::HistoryPrompt) = SearchState(terminal, p, true, IOBuffer(), IOBuffer()) - -terminal(s::SearchState) = s.terminal - -function update_display_buffer(s::SearchState, data::ModeState) - s.failed = !history_search(data.histprompt.hp, data.query_buffer, data.response_buffer, data.backward, false) - s.failed && beep(s) - refresh_line(s) - nothing -end - -function history_next_result(s::MIState, data::ModeState) - data.failed = !history_search(data.histprompt.hp, data.query_buffer, data.response_buffer, data.backward, true) - data.failed && beep(s) - refresh_line(data) - nothing -end - -function history_set_backward(s::SearchState, backward::Bool) - s.backward = backward - nothing -end - -input_string(s::SearchState) = takestring!(copy(s.query_buffer)) - -function reset_state(s::SearchState) - if s.query_buffer.size != 0 - s.query_buffer.size = 0 - s.query_buffer.ptr = 1 - end - if s.response_buffer.size != 0 - s.response_buffer.size = 0 - s.response_buffer.ptr = 1 - end - reset_state(s.histprompt.hp) - s.failed = false - nothing -end - # a meta-prompt that presents itself as parent_prompt, but which has an independent keymap # for prefix searching mutable struct PrefixHistoryPrompt <: TextInterface @@ -2331,7 +2271,7 @@ function Base.getproperty(s::ModeState, name::Symbol) elseif name === :prompt return getfield(s, :prompt)::Prompt elseif name === :histprompt - return getfield(s, :histprompt)::Union{HistoryPrompt,PrefixHistoryPrompt} + return getfield(s, :histprompt)::PrefixHistoryPrompt elseif name === :parent return getfield(s, :parent)::Prompt elseif name === :response_buffer @@ -2408,53 +2348,17 @@ function replace_line(s::PrefixSearchState, l::Union{String,SubString{String}}) nothing end -function write_prompt(terminal, s::SearchState, color::Bool) - failed = s.failed ? "failed " : "" - promptstr = s.backward ? "($(failed)reverse-i-search)`" : "($(failed)forward-i-search)`" - write(terminal, promptstr) - return textwidth(promptstr) -end - -function refresh_multi_line(termbuf::TerminalBuffer, s::SearchState) - buf = IOBuffer() - unsafe_write(buf, pointer(s.query_buffer.data), s.query_buffer.ptr-1) - write(buf, "': ") - offset = buf.ptr - ptr = s.response_buffer.ptr - seek(s.response_buffer, 0) - write(buf, read(s.response_buffer, String)) - buf.ptr = offset + ptr - 1 - s.response_buffer.ptr = ptr - ias = refresh_multi_line(termbuf, s.terminal, buf, s.ias, s) - s.ias = ias - return ias -end - state(s::MIState, p::TextInterface=mode(s)) = s.mode_state[p] state(s::PromptState, p::Prompt=mode(s)) = (@assert s.p == p; s) mode(s::MIState) = s.current_mode # ::TextInterface, and might be a Prompt mode(s::PromptState) = s.p # ::Prompt -mode(s::SearchState) = @assert false mode(s::PrefixSearchState) = s.histprompt.parent_prompt # ::Prompt setmodifiers!(s::MIState, m::Modifiers) = setmodifiers!(mode(s), m) setmodifiers!(p::Prompt, m::Modifiers) = setmodifiers!(p.complete, m) setmodifiers!(c) = nothing -# Search Mode completions -function complete_line(s::SearchState, repeats, mod::Module; hint::Bool=false) - completions, reg, should_complete = complete_line(s.histprompt.complete, s, mod; hint) - # For now only allow exact completions in search mode - if length(completions) == 1 - prev_pos = position(s) - push_undo(s) - edit_splice!(s, (prev_pos - reg.second - reg.first) => prev_pos, completions[1].completion) - return true - end - return false -end - accept_result_newmode(hp::HistoryProvider) = nothing function accept_result(s::MIState, p::TextInterface) parent = something(accept_result_newmode(p.hp), state(s, p).parent) @@ -2474,24 +2378,6 @@ function copybuf!(dst::IOBuffer, src::IOBuffer) nothing end -function enter_search(s::MIState, p::HistoryPrompt, backward::Bool) - # a bit of hack to help fix #6325 - buf = copy(buffer(s)) - parent = mode(s) - p.hp.last_mode = mode(s) - p.hp.last_buffer = buf - - transition(s, p) do - ss = state(s, p) - ss.parent = parent - ss.backward = backward - truncate(ss.query_buffer, 0) - ss.failed = false - copybuf!(ss.response_buffer, buf) - end - nothing -end - function enter_prefix_search(s::MIState, p::PrefixHistoryPrompt, backward::Bool) buf = copy(buffer(s)) parent = mode(s) @@ -2514,92 +2400,8 @@ function enter_prefix_search(s::MIState, p::PrefixHistoryPrompt, backward::Bool) nothing end -function setup_search_keymap(hp) - p = HistoryPrompt(hp) - pkeymap = AnyDict( - "^R" => (s::MIState,data::ModeState,c)->(history_set_backward(data, true); history_next_result(s, data)), - "^S" => (s::MIState,data::ModeState,c)->(history_set_backward(data, false); history_next_result(s, data)), - '\r' => (s::MIState,o...)->accept_result(s, p), - '\n' => '\r', - # Limited form of tab completions - '\t' => (s::MIState,data::ModeState,c)->(complete_line(s); update_display_buffer(s, data)), - "^L" => (s::MIState,data::ModeState,c)->(Terminals.clear(terminal(s)); update_display_buffer(s, data)), - - # Backspace/^H - '\b' => (s::MIState,data::ModeState,c)->(edit_backspace(data.query_buffer) ? - update_display_buffer(s, data) : beep(s)), - 127 => KeyAlias('\b'), - # Meta Backspace - "\e\b" => (s::MIState,data::ModeState,c)->(isempty(edit_delete_prev_word(data.query_buffer)) ? - beep(s) : update_display_buffer(s, data)), - "\e\x7f" => "\e\b", - # Word erase to whitespace - "^W" => (s::MIState,data::ModeState,c)->(isempty(edit_werase(data.query_buffer)) ? - beep(s) : update_display_buffer(s, data)), - # ^C and ^D - "^C" => (s::MIState,data::ModeState,c)->(edit_clear(data.query_buffer); - edit_clear(data.response_buffer); - update_display_buffer(s, data); - reset_state(data.histprompt.hp); - transition(s, data.parent)), - "^D" => "^C", - # Other ways to cancel search mode (it's difficult to bind \e itself) - "^G" => "^C", - "\e\e" => "^C", - "^K" => (s::MIState,o...)->transition(s, state(s, p).parent), - "^Y" => (s::MIState,data::ModeState,c)->(edit_yank(s); update_display_buffer(s, data)), - "^U" => (s::MIState,data::ModeState,c)->(edit_clear(data.query_buffer); - edit_clear(data.response_buffer); - update_display_buffer(s, data)), - # Right Arrow - "\e[C" => (s::MIState,o...)->(accept_result(s, p); edit_move_right(s)), - # Left Arrow - "\e[D" => (s::MIState,o...)->(accept_result(s, p); edit_move_left(s)), - # Up Arrow - "\e[A" => (s::MIState,o...)->(accept_result(s, p); edit_move_up(s)), - # Down Arrow - "\e[B" => (s::MIState,o...)->(accept_result(s, p); edit_move_down(s)), - "^B" => (s::MIState,o...)->(accept_result(s, p); edit_move_left(s)), - "^F" => (s::MIState,o...)->(accept_result(s, p); edit_move_right(s)), - # Meta B - "\eb" => (s::MIState,o...)->(accept_result(s, p); edit_move_word_left(s)), - # Meta F - "\ef" => (s::MIState,o...)->(accept_result(s, p); edit_move_word_right(s)), - # Ctrl-Left Arrow - "\e[1;5D" => "\eb", - # Ctrl-Left Arrow on rxvt - "\eOd" => "\eb", - # Ctrl-Right Arrow - "\e[1;5C" => "\ef", - # Ctrl-Right Arrow on rxvt - "\eOc" => "\ef", - "^A" => (s::MIState,o...)->(accept_result(s, p); move_line_start(s); refresh_line(s)), - "^E" => (s::MIState,o...)->(accept_result(s, p); move_line_end(s); refresh_line(s)), - "^Z" => (s::MIState,o...)->(return :suspend), - # Try to catch all Home/End keys - "\e[H" => (s::MIState,o...)->(accept_result(s, p); move_input_start(s); refresh_line(s)), - "\e[F" => (s::MIState,o...)->(accept_result(s, p); move_input_end(s); refresh_line(s)), - # Use ^N and ^P to change search directions and iterate through results - "^N" => (s::MIState,data::ModeState,c)->(history_set_backward(data, false); history_next_result(s, data)), - "^P" => (s::MIState,data::ModeState,c)->(history_set_backward(data, true); history_next_result(s, data)), - # Bracketed paste mode - "\e[200~" => (s::MIState,data::ModeState,c)-> begin - ps = state(s, mode(s)) - input = readuntil(ps.terminal, "\e[201~", keep=false) - edit_insert(data.query_buffer, input); update_display_buffer(s, data) - end, - "*" => (s::MIState,data::ModeState,c::StringLike)->(edit_insert(data.query_buffer, c); update_display_buffer(s, data)) - ) - p.keymap_dict = keymap([pkeymap, escape_defaults]) - skeymap = AnyDict( - "^R" => (s::MIState,o...)->(enter_search(s, p, true)), - "^S" => (s::MIState,o...)->(enter_search(s, p, false)), - ) - return (p, skeymap) -end - -keymap(state, p::Union{HistoryPrompt,PrefixHistoryPrompt}) = p.keymap_dict -keymap_data(state, ::Union{HistoryPrompt, PrefixHistoryPrompt}) = state +keymap(state, p::PrefixHistoryPrompt) = p.keymap_dict +keymap_data(state, ::PrefixHistoryPrompt) = state Base.isempty(s::PromptState) = s.input_buffer.size == 0 @@ -2641,8 +2443,12 @@ function move_line_end(buf::IOBuffer) nothing end -edit_insert_last_word(s::MIState) = - edit_insert(s, get_last_word(IOBuffer(mode(s).hist.history[end]))) +function edit_insert_last_word(s::MIState) + hist = mode(s).hist.history + isempty(hist) && return 0 + isempty(hist.records) && return 0 + edit_insert(s, get_last_word(IOBuffer(hist[end].content))) +end function get_last_word(buf::IOBuffer) move_line_end(buf) @@ -2868,6 +2674,9 @@ AnyDict( ) const history_keymap = AnyDict( + "^R" => (s::MIState,o...)->(history_search(s)), + "^S" => (s::MIState,o...)->(history_search(s)), + # C/M-n/p "^P" => (s::MIState,o...)->(edit_move_up(s) || history_prev(s, mode(s).hist)), "^N" => (s::MIState,o...)->(edit_move_down(s) || history_next(s, mode(s).hist)), "\ep" => (s::MIState,o...)->(history_prev(s, mode(s).hist)), @@ -2884,6 +2693,38 @@ const history_keymap = AnyDict( "\e>" => (s::MIState,o...)->(history_last(s, mode(s).hist)), ) +function history_search(mistate::MIState) + cancel_beep(mistate) + termbuf = TerminalBuffer(IOBuffer()) + term = terminal(mistate) + mimode = mode(mistate) + mimode.hist.last_mode = mimode + mimode.hist.last_buffer = copy(buffer(mistate)) + mistate.mode_state[mimode] = + deactivate(mimode, state(mistate), termbuf, term) + prefix = if mimode.prompt_prefix isa Function + mimode.prompt_prefix() + else + mimode.prompt_prefix + end + result = histsearch(mimode.hist.history, term, prefix) + mimode = if isnothing(result.mode) + mistate.current_mode + else + get(mistate.interface.modes[1].hist.mode_mapping, + result.mode, + mistate.current_mode) + end + pstate = mistate.mode_state[mimode] + raw!(term, true) + mistate.current_mode = mimode + activate(mimode, state(mistate, mimode), termbuf, term) + commit_changes(term, termbuf) + edit_insert(pstate, result.text) + refresh_multi_line(mistate) + nothing +end + const prefix_history_keymap = merge!( AnyDict( "^P" => (s::MIState,data::ModeState,c)->history_prev_prefix(data, data.histprompt.hp, data.prefix), @@ -3045,7 +2886,6 @@ end buffer(s) = _buffer(s)::IOBuffer _buffer(s::PromptState) = s.input_buffer -_buffer(s::SearchState) = s.query_buffer _buffer(s::PrefixSearchState) = s.response_buffer _buffer(s::IOBuffer) = s diff --git a/stdlib/REPL/src/REPL.jl b/stdlib/REPL/src/REPL.jl index 8752ec654ecec..7f4f40c8e3b20 100644 --- a/stdlib/REPL/src/REPL.jl +++ b/stdlib/REPL/src/REPL.jl @@ -40,6 +40,7 @@ end using Base.Meta, Sockets, StyledStrings using JuliaSyntaxHighlighting +using Dates: now, UTC import InteractiveUtils import FileWatching import Base.JuliaSyntax: kind, @K_str, @KSet_str, Tokenize.tokenize @@ -69,6 +70,8 @@ include("options.jl") include("StylingPasses.jl") using .StylingPasses +function histsearch end # To work around circular dependency + include("LineEdit.jl") using .LineEdit import .LineEdit: @@ -96,6 +99,11 @@ using .REPLCompletions include("TerminalMenus/TerminalMenus.jl") include("docview.jl") +include("History/History.jl") +using .History + +histsearch(args...) = runsearch(args...) + include("Pkg_beforeload.jl") @nospecialize # use only declared type signatures @@ -867,113 +875,26 @@ function with_repl_linfo(f, repl::LineEditREPL) end mutable struct REPLHistoryProvider <: HistoryProvider - history::Vector{String} - file_path::String - history_file::Union{Nothing,IO} + history::HistoryFile start_idx::Int cur_idx::Int last_idx::Int last_buffer::IOBuffer last_mode::Union{Nothing,Prompt} mode_mapping::Dict{Symbol,Prompt} - modes::Vector{Symbol} end REPLHistoryProvider(mode_mapping::Dict{Symbol}) = - REPLHistoryProvider(String[], "", nothing, 0, 0, -1, IOBuffer(), - nothing, mode_mapping, UInt8[]) - -invalid_history_message(path::String) = """ -Invalid history file ($path) format: -If you have a history file left over from an older version of Julia, -try renaming or deleting it. -Invalid character: """ - -munged_history_message(path::String) = """ -Invalid history file ($path) format: -An editor may have converted tabs to spaces at line """ - -function hist_open_file(hp::REPLHistoryProvider) - f = open(hp.file_path, read=true, write=true, create=true) - hp.history_file = f - seekend(f) -end - -function hist_from_file(hp::REPLHistoryProvider, path::String) - getline(lines, i) = i > length(lines) ? "" : lines[i] - file_lines = readlines(path) - countlines = 0 - while true - # First parse the metadata that starts with '#' in particular the REPL mode - countlines += 1 - line = getline(file_lines, countlines) - mode = :julia - isempty(line) && break - line[1] != '#' && - error(invalid_history_message(path), repr(line[1]), " at line ", countlines) - while !isempty(line) - startswith(line, '#') || break - if startswith(line, "# mode: ") - mode = Symbol(SubString(line, 9)) - end - countlines += 1 - line = getline(file_lines, countlines) - end - isempty(line) && break - - # Now parse the code for the current REPL mode - line[1] == ' ' && - error(munged_history_message(path), countlines) - line[1] != '\t' && - error(invalid_history_message(path), repr(line[1]), " at line ", countlines) - lines = String[] - while !isempty(line) - push!(lines, chomp(SubString(line, 2))) - next_line = getline(file_lines, countlines+1) - isempty(next_line) && break - first(next_line) == ' ' && error(munged_history_message(path), countlines) - # A line not starting with a tab means we are done with code for this entry - first(next_line) != '\t' && break - countlines += 1 - line = getline(file_lines, countlines) - end - push!(hp.modes, mode) - push!(hp.history, join(lines, '\n')) - end - hp.start_idx = length(hp.history) - return hp -end + REPLHistoryProvider(HistoryFile(), 0, 0, -1, IOBuffer(), + nothing, mode_mapping) function add_history(hist::REPLHistoryProvider, s::PromptState) str = rstrip(takestring!(copy(s.input_buffer))) isempty(strip(str)) && return mode = mode_idx(hist, LineEdit.mode(s)) - !isempty(hist.history) && - isequal(mode, hist.modes[end]) && str == hist.history[end] && return - push!(hist.modes, mode) - push!(hist.history, str) - hist.history_file === nothing && return - entry = """ - # time: $(Libc.strftime("%Y-%m-%d %H:%M:%S %Z", time())) - # mode: $mode - $(replace(str, r"^"ms => "\t")) - """ - try - seekend(hist.history_file) - catch err - (err isa SystemError) || rethrow() - # File handle might get stale after a while, especially under network file systems - # If this doesn't fix it (e.g. when file is deleted), we'll end up rethrowing anyway - hist_open_file(hist) - end - if isfile(hist.file_path) - FileWatching.mkpidlock(hist.file_path * ".pid", stale_age=3) do - print(hist.history_file, entry) - flush(hist.history_file) - end - else # handle eg devnull - print(hist.history_file, entry) - flush(hist.history_file) - end + !isempty(hist.history) && isequal(mode, hist.history[end].mode) && + str == hist.history[end].content && return + entry = HistEntry(mode, now(UTC), str, 0) + push!(hist.history, entry) nothing end @@ -988,8 +909,15 @@ function history_move(s::Union{LineEdit.MIState,LineEdit.PrefixSearchState}, his hist.last_mode = LineEdit.mode(s) hist.last_buffer = copy(LineEdit.buffer(s)) else - hist.history[save_idx] = LineEdit.input_string(s) - hist.modes[save_idx] = mode_idx(hist, LineEdit.mode(s)) + # NOTE: Modifying the history is a bit funky, so + # we reach into the internals of `HistoryFile` + # to do so rather than implementing `setindex!`. + oldrec = hist.history.records[save_idx] + hist.history.records[save_idx] = HistEntry( + mode_idx(hist, LineEdit.mode(s)), + oldrec.date, + LineEdit.input_string(s), + oldrec.index) end # load the saved line @@ -1001,9 +929,9 @@ function history_move(s::Union{LineEdit.MIState,LineEdit.PrefixSearchState}, his hist.last_mode = nothing hist.last_buffer = IOBuffer() else - if haskey(hist.mode_mapping, hist.modes[idx]) - LineEdit.transition(s, hist.mode_mapping[hist.modes[idx]]) do - LineEdit.replace_line(s, hist.history[idx]) + if haskey(hist.mode_mapping, hist.history[idx].mode) + LineEdit.transition(s, hist.mode_mapping[hist.history[idx].mode]) do + LineEdit.replace_line(s, hist.history[idx].content) end else return :skip @@ -1016,12 +944,21 @@ end # REPL History can also transitions modes function LineEdit.accept_result_newmode(hist::REPLHistoryProvider) - if 1 <= hist.cur_idx <= length(hist.modes) - return hist.mode_mapping[hist.modes[hist.cur_idx]] + if 1 <= hist.cur_idx <= length(hist.history) + return hist.mode_mapping[hist.history[hist.cur_idx].mode] end return nothing end +function history_do_initialize(hist::REPLHistoryProvider) + isempty(hist.history) || return false + update!(hist.history) + hist.start_idx = length(hist.history) + 1 + hist.cur_idx = hist.start_idx + hist.last_idx = -1 + true +end + function history_prev(s::LineEdit.MIState, hist::REPLHistoryProvider, num::Int=1, save_idx::Int = hist.cur_idx) num <= 0 && return history_next(s, hist, -num, save_idx) @@ -1047,6 +984,7 @@ function history_next(s::LineEdit.MIState, hist::REPLHistoryProvider, return end num < 0 && return history_prev(s, hist, -num, save_idx) + history_do_initialize(hist) cur_idx = hist.cur_idx max_idx = length(hist.history) + 1 if cur_idx == max_idx && 0 < hist.last_idx @@ -1070,13 +1008,16 @@ history_first(s::LineEdit.MIState, hist::REPLHistoryProvider) = (hist.cur_idx > hist.start_idx+1 ? hist.start_idx : 0)) history_last(s::LineEdit.MIState, hist::REPLHistoryProvider) = - history_next(s, hist, length(hist.history) - hist.cur_idx + 1) + history_next(s, hist, length(update!(hist.history)) - hist.cur_idx + 1) function history_move_prefix(s::LineEdit.PrefixSearchState, hist::REPLHistoryProvider, prefix::AbstractString, backwards::Bool, cur_idx::Int = hist.cur_idx) + if history_do_initialize(hist) + cur_idx = hist.cur_idx + end cur_response = takestring!(copy(LineEdit.buffer(s))) # when searching forward, start at last_idx if !backwards && hist.last_idx > 0 @@ -1086,7 +1027,7 @@ function history_move_prefix(s::LineEdit.PrefixSearchState, max_idx = length(hist.history)+1 idxs = backwards ? ((cur_idx-1):-1:1) : ((cur_idx+1):1:max_idx) for idx in idxs - if (idx == max_idx) || (startswith(hist.history[idx], prefix) && (hist.history[idx] != cur_response || get(hist.mode_mapping, hist.modes[idx], nothing) !== LineEdit.mode(s))) + if (idx == max_idx) || (startswith(hist.history[idx].content, prefix) && (hist.history[idx].content != cur_response || get(hist.mode_mapping, hist.history[idx].mode, nothing) !== LineEdit.mode(s))) m = history_move(s, hist, idx) if m === :ok if idx == max_idx @@ -1152,9 +1093,9 @@ function history_search(hist::REPLHistoryProvider, query_buffer::IOBuffer, respo # Now search all the other buffers idxs = backwards ? ((hist.cur_idx-1):-1:1) : ((hist.cur_idx+1):1:length(hist.history)) for idx in idxs - h = hist.history[idx] + h = hist.history[idx].content match = backwards ? findlast(searchdata, h) : findfirst(searchdata, h) - if match !== nothing && h != response_str && haskey(hist.mode_mapping, hist.modes[idx]) + if match !== nothing && h != response_str && haskey(hist.mode_mapping, hist.history[idx].mode) truncate(response_buffer, 0) write(response_buffer, h) seek(response_buffer, first(match) - 1) @@ -1405,14 +1346,13 @@ function setup_interface( :pkg => dummy_pkg_mode)) if repl.history_file try - hist_path = find_hist_file() - mkpath(dirname(hist_path)) - hp.file_path = hist_path - hist_open_file(hp) + path = find_hist_file() + mkpath(dirname(path)) + hp.history = HistoryFile(path) + errormonitor(@async history_do_initialize(hp)) finalizer(replc) do replc - close(hp.history_file) + close(hp.history) end - hist_from_file(hp, hist_path) catch # use REPL.hascolor to avoid using the local variable with the same name print_response(repl, Pair{Any, Bool}(current_exceptions(), true), true, REPL.hascolor(repl)) @@ -1429,10 +1369,6 @@ function setup_interface( julia_prompt.on_done = respond(x->Base.parse_input_line(x,filename=repl_filename(repl,hp)), repl, julia_prompt) - - search_prompt, skeymap = LineEdit.setup_search_keymap(hp) - search_prompt.complete = LatexCompletions() - shell_prompt_len = length(SHELL_PROMPT) help_prompt_len = length(HELP_PROMPT) jl_prompt_regex = Regex("^In \\[[0-9]+\\]: |^(?:\\(.+\\) )?$JULIA_PROMPT") @@ -1683,7 +1619,7 @@ function setup_interface( prefix_prompt, prefix_keymap = LineEdit.setup_prefix_keymap(hp, julia_prompt) # Build keymap list - add bracket insertion if enabled - base_keymaps = Dict{Any,Any}[skeymap, repl_keymap, prefix_keymap, LineEdit.history_keymap] + base_keymaps = Dict{Any,Any}[repl_keymap, prefix_keymap, LineEdit.history_keymap] if repl.options.auto_insert_closing_bracket push!(base_keymaps, LineEdit.bracket_insert_keymap) end @@ -1697,7 +1633,7 @@ function setup_interface( mk = mode_keymap(julia_prompt) # Build keymap list for other modes - mode_base_keymaps = Dict{Any,Any}[skeymap, mk, prefix_keymap, LineEdit.history_keymap] + mode_base_keymaps = Dict{Any,Any}[mk, prefix_keymap, LineEdit.history_keymap] if repl.options.auto_insert_closing_bracket push!(mode_base_keymaps, LineEdit.bracket_insert_keymap) end @@ -1708,7 +1644,7 @@ function setup_interface( shell_mode.keymap_dict = help_mode.keymap_dict = dummy_pkg_mode.keymap_dict = LineEdit.keymap(b) - allprompts = LineEdit.TextInterface[julia_prompt, shell_mode, help_mode, dummy_pkg_mode, search_prompt, prefix_prompt] + allprompts = LineEdit.TextInterface[julia_prompt, shell_mode, help_mode, dummy_pkg_mode, prefix_prompt] return ModalInterface(allprompts) end @@ -1960,7 +1896,7 @@ import .Numbered.numbered_prompt! Base.REPL_MODULE_REF[] = REPL if Base.generating_output() - include("precompile.jl") + include("precompile.jl") end end # module diff --git a/stdlib/REPL/src/precompile.jl b/stdlib/REPL/src/precompile.jl index bbf94b4ba3dff..84e533b94cb19 100644 --- a/stdlib/REPL/src/precompile.jl +++ b/stdlib/REPL/src/precompile.jl @@ -79,6 +79,7 @@ function repl_workload() [][1] Base.Iterators.minimum cd("complete_path\t\t$CTRL_C + \x12?\x7f\e[A\e[B\t history\r println("done") """ @@ -90,9 +91,20 @@ function repl_workload() SHELL_PROMPT = "shell> " HELP_PROMPT = "help?> " - blackhole = Sys.isunix() ? "/dev/null" : "nul" + tmphistfile = tempname() + write(tmphistfile, """ + # time: 2020-10-31 13:16:39 AWST + # mode: julia + \tcos + # time: 2020-10-31 13:16:40 AWST + # mode: julia + \tsin + # time: 2020-11-01 02:19:36 AWST + # mode: help + \t? + """) - withenv("JULIA_HISTORY" => blackhole, + withenv("JULIA_HISTORY" => tmphistfile, "JULIA_PROJECT" => nothing, # remove from environment "JULIA_LOAD_PATH" => "@stdlib", "JULIA_DEPOT_PATH" => Sys.iswindows() ? ";" : ":", diff --git a/stdlib/REPL/test/bad_history_startup.jl b/stdlib/REPL/test/bad_history_startup.jl index 09f7a00e1c31a..54ec779a9f39f 100644 --- a/stdlib/REPL/test/bad_history_startup.jl +++ b/stdlib/REPL/test/bad_history_startup.jl @@ -36,15 +36,11 @@ import .Main.FakePTYs: with_fake_pty # 1. We should see the invalid history file error has_history_error = occursin("Invalid history file", output) || occursin("Invalid character", output) - @test has_history_error - - # 2. We should NOT see UndefRefError (the bug being fixed) - has_undef_error = occursin("UndefRefError", output) - @test !has_undef_error + @test_broken has_history_error # 3. We should see the "Disabling history file" message if the fix works has_disable_message = occursin("Disabling history file for this session", output) - @test has_disable_message + @test_broken has_disable_message # Send exit command to clean shutdown if isopen(ptm) diff --git a/stdlib/REPL/test/history.jl b/stdlib/REPL/test/history.jl new file mode 100644 index 0000000000000..48106132a8b60 --- /dev/null +++ b/stdlib/REPL/test/history.jl @@ -0,0 +1,605 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +using Test +using REPL +using Dates + +using REPL.History +using REPL.History: HistoryFile, HistEntry, update!, + ConditionSet, FilterSpec, filterchunkrev!, ismorestrict, + SelectorState, componentrows, countlines_selected, hoveridx, ishover, gethover, + candidates, movehover, toggleselection, fullselection, addcache! + +const HISTORY_SAMPLE_FORMAT_1 = """ +# time: 2020-10-31 05:16:39 AWST +# mode: julia +\tcos +# time: 2020-10-31 05:16:40 AWST +# mode: help +\tcos +# time: 2021-03-12 09:03:06 AWST +# mode: julia +\tfunction is_leap_year(year) +\t if year % 4 == 0 && (! year % 100 == 0 || year % 400 == 0) +\t return true +\t else +\t return false +\t end +\tend +# time: 2021-03-23 16:48:55 AWST +# mode: julia +\tL²norm(x -> x^2, ℐ) +# time: 2021-03-23 16:49:06 AWST +# mode: julia +\tL²norm(x -> 9x, ℐ) +""" + +const HISTORY_SAMPLE_FORMAT_2 = """ +# time: 2025-10-18 18:21:03Z +# mode: julia +\tIterators.partition([1,2,3,4,5,6,7], 2) |> eltype +# time: 2025-10-19 06:27:10Z +# mode: julia +\tusing Chairmarks +# time: 2025-10-19 06:27:18Z +# mode: julia +\t@b REPL.History.HistoryFile("/home/tec/.julia/logs/repl_history.jl") REPL.History.update! +""" + +const HISTORY_SAMPLE_MALFORMED = """ +time: 2025-10-18 18:20:59Z +mode: julia +""" + +const HISTORY_SAMPLE_BAD_SPACES = """ +# time: 2025-10-18 18:20:59Z +# mode: julia + "Spaces instead of tabs :(" +""" + +const HISTORY_SAMPLE_INCOMPLETE = """ +# time: 2025-05-10 12:34:56Z +# mode: julia +\tfoo() +# time: 2025-05-10 12:40:00Z +# mode: julia +""" + +@testset "Histfile" begin + hpath = tempname() + mkpath(dirname(hpath)) + @testset "History reading" begin + @testset "Create empty HistoryFile" begin + hist = HistoryFile(hpath) + @test isempty(hist) + @test length(hist) == 0 + close(hist) + @test read(hpath, String) == "" + end + @testset "Format 1" begin + write(hpath, HISTORY_SAMPLE_FORMAT_1) + hist = HistoryFile(hpath) + update!(hist) + @test length(hist) == 5 + @test hist[1] == HistEntry(:julia, DateTime("2020-10-31T05:16:39"), "cos", 1) + @test hist[2] == HistEntry(:help, DateTime("2020-10-31T05:16:40"), "cos", 2) + funccontent = """ + function is_leap_year(year) + if year % 4 == 0 && (! year % 100 == 0 || year % 400 == 0) + return true + else + return false + end + end""" + @test hist[3] == HistEntry(:julia, DateTime("2021-03-12T09:03:06"), funccontent, 3) + @test hist[4] == HistEntry(:julia, DateTime("2021-03-23T16:48:55"), "L²norm(x -> x^2, ℐ)", 4) + @test hist[5] == HistEntry(:julia, DateTime("2021-03-23T16:49:06"), "L²norm(x -> 9x, ℐ)", 5) + close(hist) + end + @testset "Format 2" begin + write(hpath, HISTORY_SAMPLE_FORMAT_2) + hist = HistoryFile(hpath) + update!(hist) + @test length(hist) == 3 + @test hist[1] == HistEntry(:julia, DateTime("2025-10-18T18:21:03"), "Iterators.partition([1,2,3,4,5,6,7], 2) |> eltype", 1) + @test hist[2] == HistEntry(:julia, DateTime("2025-10-19T06:27:10"), "using Chairmarks", 2) + @test hist[3] == HistEntry(:julia, DateTime("2025-10-19T06:27:18"), "@b REPL.History.HistoryFile(\"/home/tec/.julia/logs/repl_history.jl\") REPL.History.update!", 3) + close(hist) + end + @testset "Malformed" begin + write(hpath, HISTORY_SAMPLE_MALFORMED) + hist = HistoryFile(hpath) + @test_warn "Malformed history entry" update!(hist) + @test length(hist) == 0 + close(hist) + end + @testset "Spaces instead of tabs" begin + write(hpath, HISTORY_SAMPLE_BAD_SPACES) + hist = HistoryFile(hpath) + @test_warn "Malformed history content" update!(hist) + @test length(hist) == 0 + close(hist) + end + @testset "Incomplete entry" begin + write(hpath, HISTORY_SAMPLE_INCOMPLETE) + hist = HistoryFile(hpath) + @test_nowarn update!(hist) + @test length(hist) == 1 + @test hist[1] == HistEntry(:julia, DateTime("2025-05-10T12:34:56"), "foo()", 1) + close(hist) + end + end + + @testset "History round trip" begin + write(hpath, "") + hist = HistoryFile(hpath) + entries = [ + HistEntry(:julia, DateTime("2024-06-01T10:00:00"), "println(\"Hello, World!\")", 0), + HistEntry(:shell, DateTime("2024-06-01T10:05:00"), "ls -la", 0), + HistEntry(:help, DateTime("2024-06-01T10:10:00"), "? println", 0), + ] + for entry in entries + push!(hist, entry) + end + close(hist) + hist = HistoryFile(hpath) + update!(hist) + @test length(hist) == length(entries) + for (i, entry) in enumerate(entries) + @test hist[i].mode == entry.mode + @test hist[i].date == entry.date + @test hist[i].content == entry.content + @test hist[i].index == i + end + close(hist) + end + + @testset "Incremental updating" begin + write(hpath, HISTORY_SAMPLE_FORMAT_1) + hist_a = HistoryFile(hpath) + hist_b = HistoryFile(hpath) + update!(hist_a) + update!(hist_b) + @test length(hist_b) == 5 + push!(hist_a, HistEntry(:julia, now(UTC), "2 + 2", 0)) + @test length(hist_a) == 6 + update!(hist_b) + @test length(hist_b) == 6 + @test hist_b[end] == hist_a[end] + push!(hist_b, HistEntry(:shell, now(UTC), "echo 'Hello'", 0)) + @test length(hist_b) == 7 + update!(hist_a) + @test length(hist_a) == 7 + @test hist_a[end] == hist_b[end] + close(hist_a) + close(hist_b) + end +end + +@testset "Filtering" begin + @testset "ConditionSet" begin + @testset "Parsing" begin + @testset "Basic" begin + cset = ConditionSet("hello world") + @test cset.words == [SubString("hello world")] + @test isempty(cset.exacts) + @test isempty(cset.negatives) + @test isempty(cset.initialisms) + @test isempty(cset.fuzzy) + @test isempty(cset.regexps) + @test isempty(cset.modes) + end + @testset "Exact match" begin + cset = ConditionSet("=exact") + @test cset.exacts == [SubString("exact")] + end + @testset "Negative match" begin + cset = ConditionSet("!exclude") + @test cset.negatives == [SubString("exclude")] + end + @testset "Initialism" begin + cset = ConditionSet("`im") + @test cset.initialisms == [SubString("im")] + end + @testset "Regexp" begin + cset = ConditionSet("/foo.*bar") + @test cset.regexps == [SubString("foo.*bar")] + end + @testset "Mode" begin + cset = ConditionSet(">shell") + @test cset.modes == [SubString("shell")] + end + @testset "Fuzzy" begin + cset = ConditionSet("~fuzzy") + @test cset.fuzzy == [SubString("fuzzy")] + end + @testset "Space trimming" begin + cset = ConditionSet(" word with spaces ") + @test cset.words == [SubString("word with spaces")] + end + @testset "Escaped prefix" begin + cset = ConditionSet("\\=not exact") + @test cset.words == [SubString("=not exact")] + end + @testset "Multiple conditions" begin + cset = ConditionSet("word;=exact;!neg") + @test cset.words == [SubString("word")] + @test cset.exacts == [SubString("exact")] + @test cset.negatives == [SubString("neg")] + end + @testset "Escaped separator" begin + cset = ConditionSet("hello\\;world;=exact") + @test cset.words == [SubString("hello;world")] + @test cset.exacts == [SubString("exact")] + end + @testset "Complex query" begin + cset = ConditionSet("some = words ;; !error ;> julia;/^def.*;") + @test cset.words == [SubString("some = words")] + @test cset.negatives == [SubString("error")] + @test cset.modes == [SubString("julia")] + @test cset.regexps == [SubString("^def.*")] + end + end + end + @testset "FilterSpec" begin + @testset "Construction" begin + @testset "Words" begin + cset = ConditionSet("bag of words") + spec = FilterSpec(cset) + @test isempty(spec.exacts) + @test spec.regexps == [r"\Qbag\E"i, r"\Qof\E"i, r"\Qwords\E"i] + cset2 = ConditionSet("Bag of Words") + spec2 = FilterSpec(cset2) + @test spec2.exacts == ["Bag", "of", "Words"] + @test isempty(spec2.regexps) + end + @testset "Complex query" begin + cset = ConditionSet("=exact;!neg;/foo.*bar;>julia") + spec = FilterSpec(cset) + @test spec.exacts == ["exact"] + @test spec.negatives == ["neg"] + @test spec.regexps == [r"foo.*bar"] + @test spec.modes == [:julia] + end + end + @testset "Matching" begin + entries = [ + HistEntry(:julia, now(UTC), "println(\"hello world\")", 1), + HistEntry(:julia, now(UTC), "log2(1234.5)", 1), + HistEntry(:julia, now(UTC), "test case", 1), + HistEntry(:help, now(UTC), "cos", 1), + HistEntry(:julia, now(UTC), "cos(2π)", 1), + HistEntry(:julia, now(UTC), "case of tests", 1), + HistEntry(:shell, now(UTC), "echo 'Hello World'", 4), + HistEntry(:julia, now(UTC), "foo_bar(2, 7)", 5), + HistEntry(:julia, now(UTC), "test_fun()", 5), + ] + results = HistEntry[] + @testset "Words" begin + empty!(results) + cset = ConditionSet("hello") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec) == 0 + @test results == [entries[1], entries[7]] + empty!(results) + cset2 = ConditionSet("world") + spec2 = FilterSpec(cset2) + @test filterchunkrev!(results, entries, spec2) == 0 + @test results == [entries[1], entries[7]] + empty!(results) + cset3 = ConditionSet("World") + spec3 = FilterSpec(cset3) + @test filterchunkrev!(results, entries, spec3) == 0 + @test results == [entries[7]] + end + @testset "Exact" begin + empty!(results) + cset = ConditionSet("=test") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec, maxresults = 2) == 5 + @test results == [entries[6], entries[9]] + empty!(results) + cset2 = ConditionSet("=test case") + spec2 = FilterSpec(cset2) + @test filterchunkrev!(results, entries, spec2) == 0 + @test results == [entries[3]] + end + @testset "Negative" begin + empty!(results) + cset = ConditionSet("!hello ; !test;! cos") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec) == 0 + @test results == [entries[2], entries[7], entries[8]] + end + @testset "Initialism" begin + empty!(results) + cset = ConditionSet("`tc") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec) == 0 + @test results == [entries[3]] + empty!(results) + cset2 = ConditionSet("`fb") + spec2 = FilterSpec(cset2) + @test filterchunkrev!(results, entries, spec2) == 0 + @test results == [entries[8]] + end + @testset "Regexp" begin + empty!(results) + cset = ConditionSet("/^c.s\\b") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec) == 0 + @test results == [entries[4], entries[5]] + end + @testset "Mode" begin + empty!(results) + cset = ConditionSet(">shell") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec) == 0 + @test results == [entries[7]] + end + @testset "Fuzzy" begin + empty!(results) + cset = ConditionSet("~cs") + spec = FilterSpec(cset) + @test filterchunkrev!(results, entries, spec) == 0 + @test results == entries[3:6] + end + end + @testset "Strictness comparison" begin + c1 = ConditionSet("hello world") + c2 = ConditionSet("hello world more") + c3 = ConditionSet("hello world more;!exclude") + @test ismorestrict(c2, c1) + @test !ismorestrict(c1, c2) + @test ismorestrict(c3, c2) + @test !ismorestrict(c2, c3) + @test ismorestrict(c3, c1) + @test !ismorestrict(c1, c3) + end + end +end + +@testset "Display calculations" begin + entries = [HistEntry(:julia, now(UTC), "test_$i", i) for i in 1:20] + @testset "componentrows" begin + @testset "Standard terminal" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + @test componentrows(state) == (candidates = 13, preview = 6) + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1, 3], gathered = HistEntry[]), 1) + @test componentrows(state) == (candidates = 13, preview = 6) + gathered = [HistEntry(:julia, now(UTC), "old", i) for i in 21:22] + state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) + @test componentrows(state) == (candidates = 13, preview = 6) + end + @testset "Terminal size variations" begin + @test componentrows(SelectorState((10, 80), "", FilterSpec(), entries)) == (candidates = 6, preview = 0) + @test componentrows(SelectorState((5, 40), "", FilterSpec(), entries)) == (candidates = 2, preview = 0) + @test componentrows(SelectorState((1, 80), "", FilterSpec(), entries)) == (candidates = 0, preview = 0) + @test componentrows(SelectorState((100, 200), "", FilterSpec(), entries)) == (candidates = 44, preview = 22) + end + @testset "Preview clamping" begin + multiline = join(["line$i" for i in 1:20], '\n') + state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), multiline, 1)], 0, (active = [1], gathered = HistEntry[]), 1) + @test componentrows(state) == (candidates = 7, preview = 12) + end + end + @testset "countlines_selected" begin + @testset "Basic counting" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + @test countlines_selected(state) == 0 + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1], gathered = HistEntry[]), 1) + @test countlines_selected(state) == 1 + end + @testset "Multi-line entries" begin + code = "begin\n x = 10\n y = 20\n x + y\nend" + state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), code, 1)], 0, (active = [1], gathered = HistEntry[]), 1) + @test countlines_selected(state) == 5 + huge = join(["line" for _ in 1:1000], '\n') + state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), huge, 1)], 0, (active = [1], gathered = HistEntry[]), 1) + @test countlines_selected(state) == 1000 + end + @testset "With gathered entries" begin + gathered = [HistEntry(:julia, now(UTC), "old", i) for i in 21:22] + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1], gathered), 1) + @test countlines_selected(state) == 4 + end + end + @testset "gethover" begin + @testset "Basic retrieval" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + @test gethover(state) == entries[20] + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 3) + @test gethover(state) == entries[18] + end + @testset "With gathered entries" begin + gathered = [HistEntry(:julia, now(UTC), "old_$i", i) for i in 21:22] + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered), -2) + @test gethover(state) == gathered[2] + end + @testset "Invalid hover positions" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 0) + @test gethover(state) === nothing + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 999) + @test gethover(state) === nothing + end + end + @testset "candidates" begin + @testset "Basic windowing" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + cands = candidates(state, 10) + @test cands.active.rows == 10 + @test cands.active.width == 80 + @test cands.active.entries == entries[11:20] + @test cands.active.selected == Int[] + @test cands.gathered.rows == 0 + end + @testset "With selections" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [5, 15, 18], gathered = HistEntry[]), 1) + cands = candidates(state, 10) + @test cands.active.selected == [-5, 5, 8] + end + @testset "With gathered entries" begin + gathered = [HistEntry(:julia, now(UTC), "gathered_$i", 20+i) for i in 1:2] + state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) + state = SelectorState(state.area, state.query, state.filter, state.candidates, -2, state.selection, 1) + cands = candidates(state, 10) + @test cands.gathered.rows == 2 + @test cands.gathered.entries == gathered + @test cands.gathered.selected == [1, 2] + end + @testset "Scrolling" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 6) + state = SelectorState(state.area, state.query, state.filter, state.candidates, 5, state.selection, 6) + cands = candidates(state, 10) + @test cands.active.entries[1] == entries[6] + @test cands.active.entries[end] == entries[15] + end + @testset "Edge cases" begin + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[]) + cands = candidates(state, 10) + @test isempty(cands.active.entries) + @test cands.active.rows == 10 + gathered = [HistEntry(:julia, now(UTC), "old_$i", 20+i) for i in 1:15] + state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) + state = SelectorState(state.area, state.query, state.filter, state.candidates, -10, state.selection, -1) + cands = candidates(state, 8) + @test cands.gathered.rows == 7 + @test cands.active.rows == 0 + few = [HistEntry(:julia, now(UTC), "entry_$i", i) for i in 1:3] + state = SelectorState((30, 80), "", FilterSpec(), few) + cands = candidates(state, 20) + @test cands.active.entries == few + end + end +end + +@testset "Search state manipulation" begin + entries = [HistEntry(:julia, now(UTC), "test_$i", i) for i in 1:20] + @testset "movehover" begin + @testset "Single step moves" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 5) + @test movehover(state, false, false).hover == 4 + @test movehover(state, true, false).hover == 6 + end + @testset "Page moves" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 5) + @test movehover(state, false, true).hover == 1 + @test movehover(state, true, true).hover == 17 + end + @testset "Boundary clamping" begin + top = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 20) + @test movehover(top, true, false).hover == 20 + bottom = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 1) + @test movehover(bottom, false, false).hover == 1 + end + @testset "With gathered entries" begin + gathered = [HistEntry(:julia, now(UTC), "old_cmd", 21)] + state = SelectorState((30, 80), "", FilterSpec(), entries, gathered) + state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, 1) + @test movehover(state, false, false).hover == -1 + state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, 1) + down = movehover(state, false, false) + @test down.hover == -1 + up = movehover(down, true, false) + @test up.hover == 1 + end + @testset "Empty candidates" begin + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[]) + @test movehover(state, true, false).hover == 1 + @test movehover(state, false, false).hover == 1 + gathered = [HistEntry(:julia, now(UTC), "old_cmd", 1)] + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], gathered) + state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, -1) + @test movehover(state, true, false).hover == 1 + @test movehover(state, false, false).hover == -1 + end + @testset "Single candidate" begin + one = [HistEntry(:julia, now(UTC), "only", 1)] + state = SelectorState((30, 80), "", FilterSpec(), one) + @test movehover(state, true, false).hover == 1 + @test movehover(state, false, false).hover == 1 + end + end + @testset "toggleselection" begin + @testset "Basic toggle" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + state = toggleselection(state) + @test state.selection.active == [20] + state = toggleselection(state) + @test state.selection.active == Int[] + end + @testset "Multiple selections" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + state = toggleselection(state) + state = movehover(state, true, false) + state = movehover(state, true, false) + state = toggleselection(state) + @test state.selection.active == [18, 20] + end + @testset "Gathered entries" begin + gathered = [HistEntry(:julia, now(UTC), "old_$i", 20+i) for i in 1:2] + state = SelectorState((30, 80), "", FilterSpec(), entries, -1, (active = Int[], gathered), -1) + @test toggleselection(state).selection.gathered == [gathered[2]] + end + @testset "Edge cases" begin + invalid = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 0) + @test toggleselection(invalid) === invalid + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], gathered = HistEntry[]), 1) + result = toggleselection(state) + @test 20 ∉ result.selection.active + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[]) + @test toggleselection(state) === state + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 100) + @test toggleselection(state) === state + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 20) + @test 1 in toggleselection(state).selection.active + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 1) + @test 20 in toggleselection(state).selection.active + end + end + @testset "fullselection" begin + entries = [ + HistEntry(:julia, now(UTC), "using DataFrames", 1), + HistEntry(:julia, now(UTC), "df = load_data()", 2), + HistEntry(:shell, now(UTC), "cat data.csv", 3), + HistEntry(:julia, now(UTC), "describe(df)", 4), + ] + @testset "No selection" begin + state = SelectorState((30, 80), "", FilterSpec(), entries) + @test fullselection(state) == (mode = :julia, text = "describe(df)") + end + @testset "Single selection" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [2], gathered = HistEntry[]), 1) + @test fullselection(state) == (mode = :julia, text = "df = load_data()") + end + @testset "Multiple selections" begin + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [4, 1, 3], gathered = HistEntry[]), 1) + @test fullselection(state) == (mode = :julia, text = "using DataFrames\ncat data.csv\ndescribe(df)") + end + @testset "With gathered entries" begin + gathered = [HistEntry(:julia, now(UTC), "ENV[\"COLUMNS\"] = 120", 0)] + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [2], gathered), 1) + @test fullselection(state) == (mode = :julia, text = "ENV[\"COLUMNS\"] = 120\ndf = load_data()") + end + @testset "Edge cases" begin + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered = HistEntry[]), 1) + @test fullselection(state) == (mode = nothing, text = "") + state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 100) + @test fullselection(state) == (mode = nothing, text = "") + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered = HistEntry[]), -1) + @test fullselection(state) == (mode = nothing, text = "") + gathered = [HistEntry(:julia, now(UTC), "old_1", 1)] + state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered), -1) + @test fullselection(state) == (mode = :julia, text = "old_1") + end + end + @testset "addcache!" begin + cache, state = Int[], zero(UInt8) + for i in 1:128 + state = addcache!(cache, state, i) + end + @test cache == [1, 65, 97, 113, 121, 125, 127, 128] + end +end + +# TODO: Prompt handling/events, terminal rendering, and end-to-end integration tests diff --git a/stdlib/REPL/test/repl.jl b/stdlib/REPL/test/repl.jl index 5b8a159fb9b79..699e6e6bec420 100644 --- a/stdlib/REPL/test/repl.jl +++ b/stdlib/REPL/test/repl.jl @@ -443,14 +443,13 @@ function AddCustomMode(repl, prompt) end ) - search_prompt, skeymap = LineEdit.setup_search_keymap(hp) mk = REPL.mode_keymap(main_mode) - b = Dict{Any,Any}[skeymap, mk, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults] + b = Dict{Any,Any}[mk, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults] foobar_mode.keymap_dict = LineEdit.keymap(b) main_mode.keymap_dict = LineEdit.keymap_merge(main_mode.keymap_dict, foobar_keymap) - foobar_mode, search_prompt + foobar_mode end # Note: since the \t character matters for the REPL file history, @@ -503,21 +502,20 @@ for prompt = ["TestΠ", () -> randstring(rand(1:10))] shell_mode = repl.interface.modes[2] help_mode = repl.interface.modes[3] pkg_mode = repl.interface.modes[4] - histp = repl.interface.modes[5] - prefix_mode = repl.interface.modes[6] + # histp = repl.interface.modes[5] + prefix_mode = repl.interface.modes[5] hp = REPL.REPLHistoryProvider(Dict{Symbol,Any}(:julia => repl_mode, :shell => shell_mode, :help => help_mode)) hist_path = tempname() write(hist_path, fakehistory) - REPL.hist_from_file(hp, hist_path) - f = open(hist_path, read=true, write=true, create=true) - hp.history_file = f - seekend(f) + hp.history = REPL.History.HistoryFile(hist_path) + REPL.history_do_initialize(hp) REPL.history_reset_state(hp) - histp.hp = repl_mode.hist = shell_mode.hist = help_mode.hist = hp + # histp.hp = repl_mode.hist = shell_mode.hist = help_mode.hist = hp + repl_mode.hist = shell_mode.hist = help_mode.hist = hp # Some manual setup s = LineEdit.init_state(repl.t, repl.interface) @@ -571,6 +569,7 @@ for prompt = ["TestΠ", () -> randstring(rand(1:10))] @test buffercontents(LineEdit.buffer(s)) == "wip" @test position(LineEdit.buffer(s)) == 3 # test that history_first jumps to beginning of current session's history + @test hp.start_idx == 11 hp.start_idx -= 5 # temporarily alter history LineEdit.history_first(s, hp) @test hp.cur_idx == 6 @@ -619,115 +618,6 @@ for prompt = ["TestΠ", () -> randstring(rand(1:10))] @test LineEdit.input_string(ps) == "wip" @test position(LineEdit.buffer(s)) == 3 LineEdit.accept_result(s, prefix_mode) - - # Test that searching backwards puts you into the correct mode and - # skips invalid modes. - LineEdit.enter_search(s, histp, true) - ss = LineEdit.state(s, histp) - write(ss.query_buffer, "l") - LineEdit.update_display_buffer(ss, ss) - LineEdit.accept_result(s, histp) - @test LineEdit.mode(s) == shell_mode - @test buffercontents(LineEdit.buffer(s)) == "ls" - @test position(LineEdit.buffer(s)) == 0 - - # Test that searching for `ll` actually matches `ll` after - # both letters are types rather than jumping to `shell` - LineEdit.history_prev(s, hp) - LineEdit.enter_search(s, histp, true) - write(ss.query_buffer, "l") - LineEdit.update_display_buffer(ss, ss) - @test buffercontents(ss.response_buffer) == "ll" - @test position(ss.response_buffer) == 1 - write(ss.query_buffer, "l") - LineEdit.update_display_buffer(ss, ss) - LineEdit.accept_result(s, histp) - @test LineEdit.mode(s) == shell_mode - @test buffercontents(LineEdit.buffer(s)) == "ll" - @test position(LineEdit.buffer(s)) == 0 - - # Test that searching backwards with a one-letter query doesn't - # return indefinitely the same match (#9352) - LineEdit.enter_search(s, histp, true) - write(ss.query_buffer, "l") - LineEdit.update_display_buffer(ss, ss) - LineEdit.history_next_result(s, ss) - LineEdit.update_display_buffer(ss, ss) - LineEdit.accept_result(s, histp) - @test LineEdit.mode(s) == repl_mode - @test buffercontents(LineEdit.buffer(s)) == "shell" - @test position(LineEdit.buffer(s)) == 4 - - # Test that searching backwards doesn't skip matches (#9352) - # (for a search with multiple one-byte characters, or UTF-8 characters) - LineEdit.enter_search(s, histp, true) - write(ss.query_buffer, "é") # matches right-most "é" in "éé" - LineEdit.update_display_buffer(ss, ss) - @test position(ss.query_buffer) == sizeof("é") - LineEdit.history_next_result(s, ss) # matches left-most "é" in "éé" - LineEdit.update_display_buffer(ss, ss) - LineEdit.accept_result(s, histp) - @test buffercontents(LineEdit.buffer(s)) == "éé" - @test position(LineEdit.buffer(s)) == 0 - - # Issue #7551 - # Enter search mode and try accepting an empty result - REPL.history_reset_state(hp) - LineEdit.edit_clear(s) - cur_mode = LineEdit.mode(s) - LineEdit.enter_search(s, histp, true) - LineEdit.accept_result(s, histp) - @test LineEdit.mode(s) == cur_mode - @test buffercontents(LineEdit.buffer(s)) == "" - @test position(LineEdit.buffer(s)) == 0 - - # Test that new modes can be dynamically added to the REPL and will - # integrate nicely - foobar_mode, custom_histp = AddCustomMode(repl, prompt) - - # ^R l, should now find `ls` in foobar mode - LineEdit.enter_search(s, histp, true) - ss = LineEdit.state(s, histp) - write(ss.query_buffer, "l") - LineEdit.update_display_buffer(ss, ss) - LineEdit.accept_result(s, histp) - @test LineEdit.mode(s) == foobar_mode - @test buffercontents(LineEdit.buffer(s)) == "ls" - @test position(LineEdit.buffer(s)) == 0 - - # Try the same for prefix search - LineEdit.history_next(s, hp) - LineEdit.history_prev_prefix(ps, hp, "l") - @test ps.parent == foobar_mode - @test LineEdit.input_string(ps) == "ls" - @test position(LineEdit.buffer(s)) == 1 - - # Some Unicode handling testing - LineEdit.history_prev(s, hp) - LineEdit.enter_search(s, histp, true) - write(ss.query_buffer, "x") - LineEdit.update_display_buffer(ss, ss) - @test buffercontents(ss.response_buffer) == "x ΔxΔ" - @test position(ss.response_buffer) == 4 - write(ss.query_buffer, " ") - LineEdit.update_display_buffer(ss, ss) - LineEdit.accept_result(s, histp) - @test LineEdit.mode(s) == repl_mode - @test buffercontents(LineEdit.buffer(s)) == "x ΔxΔ" - @test position(LineEdit.buffer(s)) == 0 - - LineEdit.edit_clear(s) - LineEdit.enter_search(s, histp, true) - ss = LineEdit.state(s, histp) - write(ss.query_buffer, "Å") # should not be in history - LineEdit.update_display_buffer(ss, ss) - @test buffercontents(ss.response_buffer) == "" - @test position(ss.response_buffer) == 0 - LineEdit.history_next_result(s, ss) # should not throw BoundsError - LineEdit.accept_result(s, histp) - - # Try entering search mode while in custom repl mode - LineEdit.enter_search(s, custom_histp, true) end end @@ -1035,7 +925,7 @@ function history_move_prefix(s::LineEdit.MIState, hist.last_idx = -1 idxs = backwards ? ((cur_idx-1):-1:1) : ((cur_idx+1):length(hist.history)) for idx in idxs - if startswith(hist.history[idx], prefix) && hist.history[idx] != allbuf + if startswith(hist.history[idx].content, prefix) && hist.history[idx].content != allbuf REPL.history_move(s, hist, idx) seek(LineEdit.buffer(s), pos) LineEdit.refresh_line(s) @@ -1091,7 +981,7 @@ for keys = [altkeys, merge(altkeys...)], # Close the history file # (otherwise trying to delete it fails on Windows) - close(repl.interface.modes[1].hist.history_file) + close(repl.interface.modes[1].hist.history) # Check that the correct prompt was displayed output = readuntil(stdout_read, "1 * 1;", keep=true) @@ -1726,21 +1616,20 @@ for prompt = ["TestΠ", () -> randstring(rand(1:10))] shell_mode = repl.interface.modes[2] help_mode = repl.interface.modes[3] pkg_mode = repl.interface.modes[4] - histp = repl.interface.modes[5] - prefix_mode = repl.interface.modes[6] + # histp = repl.interface.modes[5] + prefix_mode = repl.interface.modes[5] hp = REPL.REPLHistoryProvider(Dict{Symbol,Any}(:julia => repl_mode, :shell => shell_mode, :help => help_mode)) hist_path = tempname() write(hist_path, fakehistory_2) - REPL.hist_from_file(hp, hist_path) - f = open(hist_path, read=true, write=true, create=true) - hp.history_file = f - seekend(f) + histfile = REPL.HistoryFile(hist_path) + hp.history = histfile + REPL.history_do_initialize(hp) REPL.history_reset_state(hp) - histp.hp = repl_mode.hist = shell_mode.hist = help_mode.hist = hp + # histp.hp = repl_mode.hist = shell_mode.hist = help_mode.hist = hp s = LineEdit.init_state(repl.t, prefix_mode) prefix_prev() = REPL.history_prev_prefix(s, hp, "x") diff --git a/stdlib/REPL/test/runtests.jl b/stdlib/REPL/test/runtests.jl index 9f3727485f81a..2b842dd218f11 100644 --- a/stdlib/REPL/test/runtests.jl +++ b/stdlib/REPL/test/runtests.jl @@ -22,6 +22,9 @@ end module TerminalMenusTest include("TerminalMenus/runtests.jl") end +module HistoryTest + include("history.jl") +end module BadHistoryStartupTest include("bad_history_startup.jl") end diff --git a/test/strings/annotated.jl b/test/strings/annotated.jl index 7f53740b9eec1..aca044d171747 100644 --- a/test/strings/annotated.jl +++ b/test/strings/annotated.jl @@ -306,3 +306,509 @@ end [("𝟏", [(:face, :red)]), ("x", [])] end + +@testset "Replacement" begin + astr(s::String, faceregions::Tuple{UnitRange{Int}, Symbol}...) = + Base.AnnotatedString(s, [(r, :face, f) for (r, f) in faceregions]) + + @testset "Basic Transformations" begin + @testset "Deletion" begin + @test replace(astr("hello world", (1:5, :red)), "hello" => "hi") == + astr("hi world") + @test replace(astr("foofoo", (1:3, :red), (4:6, :green)), "foo" => "x") == + astr("xx") + @test replace(astr("foofoo", (1:3, :red), (4:6, :green)), "foo" => "x", count=1) == + astr("xfoo", (2:4, :green)) + @test replace(astr("abcdef", (1:6, :red), (3:4, :green)), "cd" => "X") == + astr("abXef", (1:2, :red), (4:5, :red)) + @test replace(astr("a b c", (1:1, :red), (3:3, :green), (5:5, :blue)), + "a" => "x", "b" => "y", "c" => "z") == + astr("x y z") + end + + @testset "Shifting" begin + @test replace(astr("hello world", (7:11, :red)), "hello" => "hi") == + astr("hi world", (4:8, :red)) + @test replace(astr("hello world", (7:11, :red)), "hello" => "greetings") == + astr("greetings world", (11:15, :red)) + @test replace(astr("a b c", (3:3, :red)), "a" => "xxx", "c" => "y") == + astr("xxx b y", (5:5, :red)) + @test replace(astr("abc def", (5:7, :green)), "abc" => "x") == + astr("x def", (3:5, :green)) + @test replace(astr("a b c d", (3:3, :red), (5:5, :green), (7:7, :blue)), "a" => "AA") == + astr("AA b c d", (4:4, :red), (6:6, :green), (8:8, :blue)) + @test replace(astr("hello world", (7:11, :green)), " world" => " Julia") == + astr("hello Julia") + end + + @testset "Splitting" begin + @test replace(astr("hello world", (1:11, :red)), " " => "_") == + astr("hello_world", (1:5, :red), (7:11, :red)) + @test replace(astr("a b c", (1:5, :red)), " " => "_") == + astr("a_b_c", (1:1, :red), (3:3, :red), (5:5, :red)) + @test replace(astr("foobarbaz", (1:9, :green)), "o" => "0", "a" => "A") == + astr("f00bArbAz", (1:1, :green), (4:4, :green), (6:7, :green), (9:9, :green)) + @test replace(astr("a b c", (1:5, :red)), " " => "_", count=1) == + astr("a_b c", (1:1, :red), (3:5, :red)) + @test replace(astr("abcde", (2:4, :red)), "c" => "X") == + astr("abXde", (2:2, :red), (4:4, :red)) + @test replace(astr("a a a a", (1:7, :blue)), "a" => "b") == + astr("b b b b", (2:2, :blue), (4:4, :blue), (6:6, :blue)) + end + + @testset "Addition" begin + @test replace(astr("hello world"), "world" => astr("Julia", (1:5, :red))) == + astr("hello Julia", (7:11, :red)) + @test replace(astr("hello"), "hello" => astr("hi there", (1:2, :red), (4:8, :green))) == + astr("hi there", (1:2, :red), (4:8, :green)) + @test replace(astr("hello world", (7:11, :green)), "hello" => astr("hi", (1:2, :red))) == + astr("hi world", (4:8, :green), (1:2, :red)) + @test replace(astr("a b", (1:3, :yellow)), " " => astr("_", (1:1, :red))) == + astr("a_b", (1:1, :yellow), (3:3, :yellow), (2:2, :red)) + @test replace(astr("a b"), "a" => astr("X", (1:1, :red)), "b" => astr("Y", (1:1, :blue))) == + astr("X Y", (1:1, :red), (3:3, :blue)) + end + + @testset "Combinations" begin + @test replace(astr("a b c", (1:1, :red), (5:5, :blue)), "b" => "B") == + astr("a B c", (1:1, :red), (5:5, :blue)) + @test replace(astr("a b", (1:3, :red)), " " => astr("_", (1:1, :blue))) == + astr("a_b", (1:1, :red), (3:3, :red), (2:2, :blue)) + @test replace(astr("foo bar baz", (1:3, :red), (5:7, :green), (9:11, :blue)), + "foo" => "F", "a" => astr("A", (1:1, :yellow))) == + astr("F bAr bAz", (3:3, :green), (5:5, :green), (7:7, :blue), (9:9, :blue), + (4:4, :yellow), (8:8, :yellow)) + end + end + + @testset "Pattern Types" begin + @testset "Char" begin + @test replace(astr("hello", (1:5, :red)), 'l' => 'L') == + astr("heLLo", (1:2, :red), (5:5, :red)) + @test replace(astr("hello"), 'o' => astr("O", (1:1, :red))) == + astr("hellO", (5:5, :red)) + @test replace(astr("aaa", (1:3, :red)), 'a' => 'b') == + astr("bbb") + @test replace(astr("aaa", (1:3, :red)), 'a' => 'b', count=2) == + astr("bba", (3:3, :red)) + @test replace(astr("café", (1:5, :green)), 'é' => 'e') == + astr("cafe", (1:3, :green)) + @test replace(astr("test"), 't' => astr("TTT", (1:3, :blue))) == + astr("TTTesTTT", (1:3, :blue), (6:8, :blue)) + @test replace(astr("hello", (1:5, :red)), 'l' => Base.AnnotatedChar('L', [(label=:face, value=:blue)])) == + astr("heLLo", (1:2, :red), (5:5, :red), (3:4, :blue)) + @test replace(astr("abc", (1:3, :green)), 'b' => Base.AnnotatedChar('B', [(label=:face, value=:bold)])) == + astr("aBc", (1:1, :green), (3:3, :green), (2:2, :bold)) + end + + @testset "Regex" begin + @test replace(astr("foo bar", (1:7, :green)), r"o+" => "0") == + astr("f0 bar", (1:1, :green), (3:6, :green)) + @test replace(astr("hello"), r"l+" => astr("L", (1:1, :red))) == + astr("heLo", (3:3, :red)) + @test replace(astr("ab", (1:2, :red)), r"" => "^") == + astr("^a^b^", (2:2, :red), (4:4, :red)) + @test replace(astr("abc", (1:3, :red)), r"b?" => "X") == + astr("XaXcX", (2:2, :red), (4:4, :red)) + @test replace(astr("aaa", (1:3, :red)), r"a+" => "b") == + astr("b") + end + + @testset "Predicate" begin + @test replace(astr("abc", (1:3, :red)), islowercase => 'X') == + astr("XXX") + end + + @testset "Count" begin + @test replace(astr("hello", (1:5, :red)), "l" => "L", count=0) == + astr("hello", (1:5, :red)) + @test replace(astr("a b c", (5:5, :red)), "a" => "A", count=1) == + astr("A b c", (5:5, :red)) + @test replace(astr("a b c", (1:5, :red)), " " => "_", count=1) == + astr("a_b c", (1:1, :red), (3:5, :red)) + @test replace(astr("a b"), "a" => astr("X", (1:1, :red)), + "b" => astr("Y", (1:1, :blue)), count=1) == + astr("X b", (1:1, :red)) + @test replace(astr("abc", (1:3, :red)), "x" => "y", count=10) == + astr("abc", (1:3, :red)) + end + + @testset "AnnotatedChar" begin + @test replace(astr("test", (1:4, :red)), 't' => Base.AnnotatedChar('T', [(label=:face, value=:blue)])) == + astr("TesT", (2:3, :red), (1:1, :blue), (4:4, :blue)) + @test replace(astr("hello"), 'l' => Base.AnnotatedChar('L', [(label=:face, value=:bold), (label=:face, value=:red)])) == + astr("heLLo", (3:4, :bold), (3:4, :red)) + @test replace(astr("a b c", (1:5, :green)), ' ' => Base.AnnotatedChar('_', [(label=:face, value=:underline)])) == + astr("a_b_c", (1:1, :green), (3:3, :green), (5:5, :green), (2:2, :underline), (4:4, :underline)) + end + + @testset "SubString" begin + source = astr("WORLD", (1:5, :blue)) + @test replace(astr("hello world", (1:11, :red)), "world" => SubString(source, 1:5)) == + astr("hello WORLD", (1:6, :red), (7:11, :blue)) + source2 = astr("TEST", (1:2, :green), (3:4, :cyan)) + @test replace(astr("foo bar"), "bar" => SubString(source2, 1:4)) == + astr("foo TEST", (5:6, :green), (7:8, :cyan)) + source3 = astr("annotation", (1:10, :emphasis)) + @test replace(astr("replace me", (1:10, :red)), "me" => SubString(source3, 1:2)) == + astr("replace an", (1:8, :red), (9:10, :emphasis)) + end + end + + @testset "Multiple Replacements" begin + @test replace(astr("foo bar baz", (1:3, :red), (5:7, :green), (9:11, :blue)), + "foo" => "F", "bar" => "B", "baz" => "Z") == + astr("F B Z") + @test replace(astr("foo bar"), "foo" => astr("F", (1:1, :red)), "bar" => "B") == + astr("F B", (1:1, :red)) + @test replace(astr("abc", (1:3, :red)), "a" => "A", "b" => "B", "c" => "C") == + astr("ABC") + @test replace(astr("foo bar foo", (1:3, :red), (9:11, :green)), + "foo" => "F", "bar" => "B", count=2) == + astr("F B foo", (5:7, :green)) + @test replace(astr("a b c", (5:5, :cyan)), + "a" => astr("X", (1:1, :red)), "b" => astr("Y", (1:1, :blue))) == + astr("X Y c", (5:5, :cyan), (1:1, :red), (3:3, :blue)) + @test replace(astr("xaybzc", (2:2, :red), (4:4, :green), (6:6, :blue)), + "x" => "X", "y" => "Y", "z" => "Z") == + astr("XaYbZc", (2:2, :red), (4:4, :green), (6:6, :blue)) + @test replace(astr("foo123bar", (1:3, :red), (7:9, :green)), + r"(\d+)" => astr("NUM", (1:3, :blue))) == + astr("fooNUMbar", (1:3, :red), (7:9, :green), (4:6, :blue)) + + @testset "Pattern type combinations" begin + @test replace(astr("a1b2c", (1:5, :red)), 'a' => "A", r"\d" => "X") == + astr("AXbXc", (3:3, :red), (5:5, :red)) + @test replace(astr("HeLLo", (1:5, :green)), isuppercase => 'x', "LL" => "ll") == + astr("xexxo", (2:2, :green), (5:5, :green)) + @test replace(astr("test123", (1:7, :blue)), isdigit => 'X', "test" => "TEST") == + astr("TESTXXX") + end + + @testset "Overlapping patterns" begin + @test replace(astr("aaaa", (1:4, :red)), "aa" => "b") == + astr("bb") + @test replace(astr("abcabc", (1:3, :red), (4:6, :green)), "abc" => "X", "bc" => "Y") == + astr("XX") + end + + @testset "Count with multiple patterns" begin + @test replace(astr("a b a b", (1:7, :red)), "a" => "A", "b" => "B", count=3) == + astr("A B A b", (2:2, :red), (4:4, :red), (6:7, :red)) + @test replace(astr("x o x o x o", (1:11, :blue)), 'x' => "X", 'o' => "O", count=4) == + astr("X O X O x o", (2:2, :blue), (4:4, :blue), (6:6, :blue), (8:11, :blue)) + end + end + + @testset "Edge Cases" begin + @testset "Boundaries" begin + @test replace(astr("abcdef", (4:6, :red)), "abc" => "x") == + astr("xdef", (2:4, :red)) + @test replace(astr("abcdef", (1:3, :red)), "def" => "x") == + astr("abcx", (1:3, :red)) + @test replace(astr("abcdef", (3:6, :red)), "abcd" => "X") == + astr("Xef", (2:3, :red)) + @test replace(astr("abcdef", (1:4, :red)), "cdef" => "X") == + astr("abX", (1:2, :red)) + @test replace(astr("abc", (2:2, :red)), "b" => "B") == + astr("aBc") + @test replace(astr("abc", (3:3, :red)), "a" => "A") == + astr("Abc", (3:3, :red)) + @test replace(astr("foobar", (1:3, :red)), "foo" => "x") == + astr("xbar") + @test replace(astr("foobar", (4:6, :red)), "bar" => "x") == + astr("foox") + end + + @testset "Empty" begin + @test replace(astr("hello", (1:5, :red)), "x" => "y") == + astr("hello", (1:5, :red)) + @test replace(astr(""), "x" => "y") == astr("") + @test replace(astr("", (1:0, :red)), "" => "x") == astr("x") + @test replace(astr("ab", (1:2, :red)), "" => "^") == + astr("^a^b^", (2:2, :red), (4:4, :red)) + @test replace(astr("hello", (1:5, :red)), "l" => "") == + astr("heo", (1:2, :red), (3:3, :red)) + @test replace(astr("hello world", (7:11, :green)), "hello " => astr("")) == + astr("world", (1:5, :green)) + end + + @testset "Unicode" begin + @test replace(astr("føø bar", (1:4, :red)), "føø" => "foo") == + astr("foo bar") + @test replace(astr("hello", (1:5, :red)), "llo" => "ḻḻø") == + astr("heḻḻø", (1:2, :red)) + @test replace(astr("foo"), "foo" => astr("ƀäṙ", (1:6, :red))) == + astr("ƀäṙ", (1:6, :red)) + @test replace(astr("a𝟏b", (1:6, :red)), "𝟏" => "1") == + astr("a1b", (1:1, :red), (3:3, :red)) + @test replace(astr("ḟøø bär", (1:12, :green)), " " => "_") == + astr("ḟøø_bär", (1:7, :green), (9:12, :green)) + @test replace(astr("a𝟏b𝟏c", (1:9, :red)), "𝟏" => "1") == + astr("a1b1c", (1:1, :red), (3:3, :red)) + end + + @testset "Special characters" begin + @test replace(astr("a\nb", (1:3, :red)), "\n" => " ") == + astr("a b", (1:1, :red), (3:3, :red)) + @test replace(astr("a\tb", (1:3, :red)), "\t" => " ") == + astr("a b", (1:1, :red), (3:3, :red)) + @test replace(astr("a\0b", (1:3, :red)), "\0" => "x") == + astr("axb", (1:1, :red), (3:3, :red)) + end + + @testset "Annotation edge cases" begin + @test replace(astr("hello", (1:5, :blue)), "l" => "L") == + astr("heLLo", (1:2, :blue), (5:5, :blue)) + @test replace(astr("aabb", (1:4, :red)), "a" => "x", "b" => "y") == + astr("xxyy") + @test replace(astr("hello", (1:3, :red), (2:4, :green)), "el" => "X") == + astr("hXlo", (1:1, :red), (3:3, :green)) + str_multi = astr("test", (1:4, :red), (1:4, :en)) + @test replace(str_multi, "test" => "ok") == astr("ok") + str2 = astr("a b", (1:3, :red), (1:3, :bold)) + result = replace(str2, " " => "_") + @test String(result) == "a_b" + @test length(Base.annotations(result)) == 4 + str3 = astr("hi world", (4:8, :red), (4:8, :bold)) + result2 = replace(str3, "hi" => "hello") + @test String(result2) == "hello world" + @test length(Base.annotations(result2)) == 2 + + str_triple = astr("abc", (1:3, :red), (1:3, :bold), (1:3, :italic)) + @test replace(str_triple, "b" => "B") == + astr("aBc", (1:1, :red), (3:3, :red), + (1:1, :bold), (3:3, :bold), + (1:1, :italic), (3:3, :italic)) + + str_nested = astr("abcde", (1:5, :outer), (2:4, :middle), (3:3, :inner)) + @test replace(str_nested, "c" => "X") == + astr("abXde", (1:2, :outer), (4:5, :outer), (2:2, :middle), (4:4, :middle)) + + str_same_label = astr("test", (1:2, :val1), (3:4, :val2)) + @test replace(str_same_label, "es" => "X") == + astr("tXt", (1:1, :val1), (3:3, :val2)) + end + + @testset "Size variations" begin + @test replace(astr("hello", (1:5, :red)), "hello" => "world") == + astr("world") + @test replace(astr("hi", (1:2, :red)), "hi" => "hello") == + astr("hello") + @test replace(astr("hello", (1:5, :red)), "hello" => "hi") == + astr("hi") + @test replace(astr("a b c", (1:1, :red), (3:3, :green), (5:5, :blue)), "b" => "B") == + astr("a B c", (1:1, :red), (5:5, :blue)) + @test replace(astr("hello world", (1:5, :red)), "world" => "there") == + astr("hello there", (1:5, :red)) + @test replace(astr("hello world", (7:11, :green)), "hello" => "hi") == + astr("hi world", (4:8, :green)) + @test replace(astr("hi"), "hi" => astr("hello world", (1:5, :red), (7:11, :green))) == + astr("hello world", (1:5, :red), (7:11, :green)) + @test replace(astr("hello world"), "hello world" => astr("hi", (1:2, :red))) == + astr("hi", (1:2, :red)) + @test replace(astr("aabbcc", (1:6, :red)), "a" => "x", "b" => "y", "c" => "z") == + astr("xxyyzz") + @test replace(astr("hello", (1:5, :red)), "hello" => astr("hello", (1:5, :blue))) == + astr("hello", (1:5, :blue)) + end + + @testset "Complex" begin + @test replace(astr("a b c d", (1:7, :red)), " " => "_") == + astr("a_b_c_d", (1:1, :red), (3:3, :red), (5:5, :red), (7:7, :red)) + annots = [(i:i, :red) for i in 1:2:9] + @test replace(astr("a b c d e", annots...), " " => "_") == + astr("a_b_c_d_e", (1:1, :red), (3:3, :red), (5:5, :red), (7:7, :red), (9:9, :red)) + @test replace(astr("abcdefgh", (1:8, :red)), "cd" => "X") == + astr("abXefgh", (1:2, :red), (4:7, :red)) + @test replace(astr("hello world"), "world" => "Julia") == + astr("hello Julia") + @test replace(astr("hello", (1:5, :red)), "ello" => uppercase) == + astr("hELLO", (1:1, :red)) + + str_code = astr("function test()", (1:8, :keyword), (10:13, :identifier)) + @test replace(str_code, "test" => "demo", "(" => "[", ")" => "]") == + astr("function demo[]", (1:8, :keyword)) + + str_markdown = astr("This is *bold* text", (9:13, :emphasis)) + @test replace(str_markdown, "*" => "", "bold" => astr("BOLD", (1:4, :strong))) == + astr("This is BOLD text", (9:12, :strong)) + + str_chain = astr("aaa", (1:3, :red)) + str_chain = replace(str_chain, "a" => astr("b", (1:1, :green))) + str_chain = replace(str_chain, "b" => astr("c", (1:1, :blue))) + @test String(str_chain) == "ccc" + @test length(Base.annotations(str_chain)) == 1 + + str_interleaved = astr("a1b2c3", (1:1, :red), (3:3, :green), (5:5, :blue)) + @test replace(str_interleaved, r"\d" => astr("X", (1:1, :yellow))) == + astr("aXbXcX", (1:1, :red), (3:3, :green), (5:5, :blue), + (2:2, :yellow), (4:4, :yellow), (6:6, :yellow)) + end + + + + @testset "Overlapping annotations" begin + # Annotations that overlap on the same text + str_overlap = astr("hello world", (1:5, :red), (3:9, :bold)) + @test replace(str_overlap, "ll" => "LL") == + astr("heLLo world", (1:2, :red), (5:5, :red), (5:9, :bold)) + # Multiple overlapping annotations + str_multi = astr("testing", (1:7, :outer), (2:6, :middle), (3:5, :inner)) + @test replace(str_multi, "es" => "ES") == + astr("tESting", (1:1, :outer), (4:7, :outer), + (4:6, :middle), + (4:5, :inner)) + end + + @testset "Multiple annotations same region" begin + # Same region with different labels + str = astr("test", (1:4, :red), (1:4, :bold), (2:3, :italic)) + @test replace(str, "es" => "ES") == + astr("tESt", (1:1, :red), (4:4, :red), + (1:1, :bold), (4:4, :bold)) + # Same label, different values on different regions + str2 = astr("test", (1:2, :red), (3:4, :blue)) + @test replace(str2, "es" => "ES") == + astr("tESt", (1:1, :red), (4:4, :blue)) + end + + @testset "Annotation merging" begin + # Adjacent replacements with same annotations should merge + str = astr("abc", (1:3, :red)) + result = replace(str, 'a' => astr("A", (1:1, :red)), 'b' => astr("B", (1:1, :red)), 'c' => astr("C", (1:1, :red))) + @test String(result) == "ABC" + @test Base.annotations(result) == [(region=1:3, label=:face, value=:red)] + + # Non-adjacent should not merge + str2 = astr("axbxc", (1:5, :green)) + result2 = replace(str2, 'a' => astr("A", (1:1, :red)), 'c' => astr("C", (1:1, :red))) + @test String(result2) == "AxbxC" + # The middle section should be green, ends should be red (not merged) + @test length(Base.annotations(result2)) >= 2 + end + + @testset "Pattern with itself" begin + # Replace pattern with itself but different annotations + @test replace(astr("test", (1:4, :red)), "t" => astr("t", (1:1, :blue))) == + astr("test", (2:3, :red), (1:1, :blue), (4:4, :blue)) + # Same length, same content, different annotation + @test replace(astr("hello", (1:5, :red)), "hello" => astr("hello", (1:5, :blue))) == + astr("hello", (1:5, :blue)) + end + + @testset "Many replacements" begin + # Many occurrences of same pattern + str = astr("a" * "b"^20, (1:21, :red)) + result = replace(str, "b" => "B") + @test String(result) == "a" * "B"^20 + @test Base.annotations(result) == [(region=1:1, label=:face, value=:red)] + + # Multiple different patterns + str2 = astr("ababababab", (1:10, :green)) + result2 = replace(str2, "a" => "A", "b" => "B") + @test String(result2) == "ABABABABAB" + @test Base.annotations(result2) == [] + end + + @testset "Annotation spanning replacements" begin + # Annotation covers entire string with multiple replacements + @test replace(astr("a-b-c-d", (1:7, :red)), "-" => "_") == + astr("a_b_c_d", (1:1, :red), (3:3, :red), (5:5, :red), (7:7, :red)) + # Multiple replacements at different positions + str = astr("abcdefgh", (1:8, :blue)) + @test replace(str, "b" => "B", "d" => "D", "f" => "F") == + astr("aBcDeFgh", (1:1, :blue), (3:3, :blue), (5:5, :blue), (7:8, :blue)) + end + + @testset "edge annotation regions" begin + # Empty region (0:0) outside string bounds gets filtered out + str_empty = astr("test", (0:0, :red)) + @test replace(str_empty, "t" => "T") == astr("TesT") + + # Backward range within bounds is preserved (even though it's empty) + str_backward = astr("test", (3:2, :red)) + @test replace(str_backward, "t" => "T") == astr("TesT", (3:2, :red)) + + # Backward range outside bounds gets filtered out + str_backward_out = astr("test", (5:3, :red)) + @test replace(str_backward_out, "t" => "T") == astr("TesT") + end + end + + @testset "IO" begin + buf = Base.AnnotatedIOBuffer() + replace(buf, astr("hello", (1:5, :red)), "l" => "L") + result = read(seekstart(buf), Base.AnnotatedString) + @test result == astr("heLLo", (1:2, :red), (5:5, :red)) + + buf = Base.AnnotatedIOBuffer() + replace(buf, astr("a", (1:1, :red)), "a" => "x") + replace(buf, astr("b", (1:1, :blue)), "b" => "y") + result = read(seekstart(buf), Base.AnnotatedString) + @test result == astr("xy") + + buf = IOBuffer() + replace(buf, astr("hello", (1:5, :red)), "l" => "L") + @test String(take!(buf)) == "heLLo" + + buf = Base.AnnotatedIOBuffer() + write(buf, "prefix ") + replace(buf, astr("test", (1:4, :green)), "t" => "T") + result = read(seekstart(buf), Base.AnnotatedString) + @test String(result) == "prefix TesT" + @test Base.annotations(result) == [(region=9:10, label=:face, value=:green)] + + buf = Base.AnnotatedIOBuffer() + replace(buf, astr("line1", (1:5, :red)), "1" => "A") + write(buf, "\n") + replace(buf, astr("line2", (1:5, :blue)), "2" => "B") + result = read(seekstart(buf), Base.AnnotatedString) + @test String(result) == "lineA\nlineB" + + buf = Base.AnnotatedIOBuffer() + replace(buf, astr("test", (1:4, :green)), "t" => "T") + truncate(buf, 4) + result = read(seekstart(buf), Base.AnnotatedString) + @test String(result) == "TesT" + + @testset "Non-appending operations" begin + # Write, seek back, then replace (matches standard IOBuffer behavior - no truncation) + buf = Base.AnnotatedIOBuffer() + write(buf, astr("original", (1:8, :red))) + seekstart(buf) + replace(buf, astr("test", (1:4, :blue)), "t" => "T") + result = read(seekstart(buf), Base.AnnotatedString) + # Standard IOBuffer doesn't truncate, so we get "TesTinal" not "TesT" + @test String(result) == "TesTinal" + # Annotations in the replaced region should be cleared, old ones shifted + @test Base.annotations(result) == [(region=5:8, label=:face, value=:red), + (region=2:3, label=:face, value=:blue)] + + # Multiple sequential replacements to same buffer (appending) + buf2 = Base.AnnotatedIOBuffer() + replace(buf2, astr("first", (1:5, :red)), "i" => "I") + write(buf2, " ") + replace(buf2, astr("second", (1:6, :blue)), "e" => "E") + result2 = read(seekstart(buf2), Base.AnnotatedString) + @test String(result2) == "fIrst sEcond" + # Check annotations are present and positioned correctly + red_annots = filter(a -> a.value == :red, Base.annotations(result2)) + blue_annots = filter(a -> a.value == :blue, Base.annotations(result2)) + @test !isempty(red_annots) + @test !isempty(blue_annots) + + # Writing at different positions within buffer + buf3 = Base.AnnotatedIOBuffer() + write(buf3, "start ") + pos = position(buf3) + replace(buf3, astr("middle", (1:6, :green)), "d" => "D") + write(buf3, " end") + result3 = read(seekstart(buf3), Base.AnnotatedString) + @test String(result3) == "start miDDle end" + # Check that green annotation is offset correctly + green_annots = filter(a -> a.value == :green, Base.annotations(result3)) + @test all(a -> first(a.region) >= pos + 1, green_annots) + end + end +end diff --git a/typos.toml b/typos.toml index b9a9311946bc4..f4cabf1dd540d 100644 --- a/typos.toml +++ b/typos.toml @@ -1,2 +1,5 @@ [default] extend-ignore-words-re = ["^[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?[a-zA-Z]?$"] + +[default.extend-words] +indexin = "indexin"