diff --git a/NEWS.md b/NEWS.md index e2ff10a08a8c8..9092a45278548 100644 --- a/NEWS.md +++ b/NEWS.md @@ -98,6 +98,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. * 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/stdlib/REPL/docs/src/index.md b/stdlib/REPL/docs/src/index.md index 47a386d867f0f..7115c6fd01242 100644 --- a/stdlib/REPL/docs/src/index.md +++ b/stdlib/REPL/docs/src/index.md @@ -317,6 +317,28 @@ atreplinit(customize_keys) Users should refer to `LineEdit.jl` to discover the available actions on key input. +### Automatic bracket insertion + +The Julia REPL supports automatically inserting closing brackets, parentheses, braces, and quotes +when you type the opening character. + +When enabled, typing an opening bracket `(`, `{`, or `[` will automatically insert the matching +closing bracket `)`, `}`, or `]` and position the cursor between them. The same behavior applies +to quotes (`"`, `'`, and `` ` ``). If you then type the closing character, the REPL will skip over +the auto-inserted character instead of inserting a duplicate. Additionally, pressing backspace +immediately after auto-insertion will remove both the opening and closing characters. + +To disable this feature, add the following to your `~/.julia/config/startup.jl` file: + +```julia +atreplinit() do repl + # Robust against older julia versions + if hasfield(typeof(repl.options), :auto_insert_closing_bracket) + repl.options.auto_insert_closing_bracket = false + end +end +``` + ## Tab completion In the Julian, pkg and help modes of the REPL, one can enter the first few characters of a function diff --git a/stdlib/REPL/src/LineEdit.jl b/stdlib/REPL/src/LineEdit.jl index 53cb5efe44d44..e701cddad6feb 100644 --- a/stdlib/REPL/src/LineEdit.jl +++ b/stdlib/REPL/src/LineEdit.jl @@ -2101,6 +2101,142 @@ const escape_defaults = merge!( AnyDict("\e[$(c)l" => nothing for c in 1:20) ) + +# Keymap for automatic bracket/quote insertion and completion +const bracket_insert_keymap = AnyDict() +let + # Determine when we should not close a bracket/quote + function should_skip_closing_bracket(left_peek, v) + # Don't close if we already have an open quote immediately before (triple quote case) + # For quotes, also check for transpose expressions: issue JuliaLang/OhMyREPL.jl#200 + left_peek == v && return true + if v == '\'' + tr_expr = isletter(left_peek) || isnumeric(left_peek) || left_peek == '_' || left_peek == ']' + return tr_expr + end + return false + end + + function peek_char_left(b::IOBuffer) + p = position(b) + c = char_move_left(b) + seek(b, p) + return c + end + + # Check if there's an unmatched opening quote before the cursor + function has_unmatched_quote(buf::IOBuffer, quote_char::Char) + pos = position(buf) + content = String(buf.data[1:pos]) + isempty(content) && return false + + # Count unescaped quotes before cursor position + count = 0 + i = 1 + while i <= length(content) + if content[i] == quote_char + # Check if escaped by counting preceding backslashes + num_backslashes = 0 + j = i - 1 + while j >= 1 && content[j] == '\\' + num_backslashes += 1 + j -= 1 + end + # If even number of backslashes (including zero), the quote is not escaped + if num_backslashes % 2 == 0 + count += 1 + end + end + i = nextind(content, i) + end + return isodd(count) + end + + # Left/right bracket pairs + bracket_pairs = (('(', ')'), ('{', '}'), ('[', ']')) + right_brackets_ws = (')', '}', ']', ' ', '\t', '\n') + + for (left, right) in bracket_pairs + # Left bracket: insert both and move cursor between them + bracket_insert_keymap[left] = (s::MIState, o...) -> begin + buf = buffer(s) + edit_insert(buf, left) + if eof(buf) || peek(buf, Char) in right_brackets_ws + edit_insert(buf, right) + edit_move_left(buf) + end + refresh_line(s) + end + + # Right bracket: skip over if next char matches, otherwise insert + bracket_insert_keymap[right] = (s::MIState, o...) -> begin + buf = buffer(s) + if !eof(buf) && peek(buf, Char) == right + edit_move_right(buf) + else + edit_insert(buf, right) + end + refresh_line(s) + end + end + + # Quote characters (need special handling for transpose detection) + for quote_char in ('"', '\'', '`') + bracket_insert_keymap[quote_char] = (s::MIState, o...) -> begin + buf = buffer(s) + if !eof(buf) && peek(buf, Char) == quote_char + # Skip over closing quote + edit_move_right(buf) + elseif position(buf) > 0 && should_skip_closing_bracket(peek_char_left(buf), quote_char) + # Don't auto-close (e.g., for transpose or triple quotes) + edit_insert(buf, quote_char) + elseif quote_char in ('"', '\'', '`') && has_unmatched_quote(buf, quote_char) + # For quotes, check if we're closing an existing string + edit_insert(buf, quote_char) + else + # Insert both quotes + edit_insert(buf, quote_char) + edit_insert(buf, quote_char) + edit_move_left(buf) + end + refresh_line(s) + end + end + + # Backspace - also remove matching closing bracket/quote + bracket_insert_keymap['\b'] = (s::MIState, o...) -> begin + if is_region_active(s) + return edit_kill_region(s) + elseif isempty(s) || position(buffer(s)) == 0 + # Handle transitioning to main mode + repl = Base.active_repl + mirepl = isdefined(repl, :mi) ? repl.mi : repl + main_mode = mirepl.interface.modes[1] + buf = copy(buffer(s)) + transition(s, main_mode) do + state(s, main_mode).input_buffer = buf + end + return + end + + buf = buffer(s) + left_brackets = ('(', '{', '[', '"', '\'', '`') + right_brackets = (')', '}', ']', '"', '\'', '`') + + if !eof(buf) && position(buf) > 0 + left_char = peek_char_left(buf) + i = findfirst(isequal(left_char), left_brackets) + if i !== nothing && peek(buf, Char) == right_brackets[i] + # Remove both the left and right bracket/quote + edit_delete(buf) + edit_backspace(buf) + return refresh_line(s) + end + end + return edit_backspace(s) + end +end + mutable struct HistoryPrompt <: TextInterface hp::HistoryProvider complete::CompletionProvider diff --git a/stdlib/REPL/src/REPL.jl b/stdlib/REPL/src/REPL.jl index f1f23ab1c382d..8752ec654ecec 100644 --- a/stdlib/REPL/src/REPL.jl +++ b/stdlib/REPL/src/REPL.jl @@ -1499,7 +1499,18 @@ function setup_interface( end Base.errormonitor(t_replswitch) else - edit_insert(s, ']') + # Use bracket insertion if enabled, otherwise just insert + if repl.options.auto_insert_closing_bracket + buf = LineEdit.buffer(s) + if !eof(buf) && LineEdit.peek(buf, Char) == ']' + LineEdit.edit_move_right(buf) + else + edit_insert(buf, ']') + end + LineEdit.refresh_line(s) + else + edit_insert(s, ']') + end LineEdit.check_show_hint(s) end end, @@ -1671,14 +1682,28 @@ function setup_interface( prefix_prompt, prefix_keymap = LineEdit.setup_prefix_keymap(hp, julia_prompt) - a = Dict{Any,Any}[skeymap, repl_keymap, prefix_keymap, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults] + # Build keymap list - add bracket insertion if enabled + base_keymaps = Dict{Any,Any}[skeymap, repl_keymap, prefix_keymap, LineEdit.history_keymap] + if repl.options.auto_insert_closing_bracket + push!(base_keymaps, LineEdit.bracket_insert_keymap) + end + push!(base_keymaps, LineEdit.default_keymap, LineEdit.escape_defaults) + + a = base_keymaps prepend!(a, extra_repl_keymap) julia_prompt.keymap_dict = LineEdit.keymap(a) mk = mode_keymap(julia_prompt) - b = Dict{Any,Any}[skeymap, mk, prefix_keymap, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults] + # Build keymap list for other modes + mode_base_keymaps = Dict{Any,Any}[skeymap, mk, prefix_keymap, LineEdit.history_keymap] + if repl.options.auto_insert_closing_bracket + push!(mode_base_keymaps, LineEdit.bracket_insert_keymap) + end + push!(mode_base_keymaps, LineEdit.default_keymap, LineEdit.escape_defaults) + + b = mode_base_keymaps prepend!(b, extra_repl_keymap) shell_mode.keymap_dict = help_mode.keymap_dict = dummy_pkg_mode.keymap_dict = LineEdit.keymap(b) diff --git a/stdlib/REPL/src/options.jl b/stdlib/REPL/src/options.jl index fa46c49243bfd..bd881edd23382 100644 --- a/stdlib/REPL/src/options.jl +++ b/stdlib/REPL/src/options.jl @@ -28,6 +28,7 @@ mutable struct Options # refresh after time delay auto_refresh_time_delay::Float64 hint_tab_completes::Bool + auto_insert_closing_bracket::Bool # automatically insert closing brackets, quotes, etc. style_input::Bool # enable syntax highlighting for input # default IOContext settings at the REPL iocontext::Dict{Symbol,Any} @@ -50,6 +51,7 @@ Options(; auto_indent_time_threshold = 0.005, auto_refresh_time_delay = 0.0, # this no longer seems beneficial hint_tab_completes = true, + auto_insert_closing_bracket = true, style_input = true, iocontext = Dict{Symbol,Any}()) = Options(hascolor, extra_keymap, tabwidth, @@ -59,7 +61,7 @@ Options(; backspace_align, backspace_adjust, confirm_exit, auto_indent, auto_indent_tmp_off, auto_indent_bracketed_paste, auto_indent_time_threshold, auto_refresh_time_delay, - hint_tab_completes, style_input, + hint_tab_completes, auto_insert_closing_bracket, style_input, iocontext) # for use by REPLs not having an options field diff --git a/stdlib/REPL/test/lineedit.jl b/stdlib/REPL/test/lineedit.jl index cf87e811508a0..7befa31a518e5 100644 --- a/stdlib/REPL/test/lineedit.jl +++ b/stdlib/REPL/test/lineedit.jl @@ -940,3 +940,187 @@ end strings3 = ["abcdef", "123456\nijklmn"] @test getcompletion(strings3) == "\033[0B\nabcdef\n123456\nijklmn\n" end + +# Test bracket insertion functionality +@testset "Bracket insertion" begin + # Test bracket insertion with a fake REPL that has bracket completion enabled + term = FakeTerminal(IOBuffer(), IOBuffer(), IOBuffer()) + prompt = LineEdit.Prompt("test> ") + + # Build keymap with bracket insertion enabled (as it would be in practice) + base_keymaps = Dict{Any,Any}[LineEdit.bracket_insert_keymap, LineEdit.default_keymap, LineEdit.escape_defaults] + prompt.keymap_dict = LineEdit.keymap(base_keymaps) + + interface = LineEdit.ModalInterface([prompt]) + s = LineEdit.init_state(term, interface) + + # Helper to write characters as stdin input + write_input(s, str) = for c in str + buf = IOBuffer(string(c)) + LineEdit.match_input(prompt.keymap_dict, s, buf)(s, buf) + end + + # Test left bracket at EOF triggers auto-complete + write_input(s, "(") + @test content(s) == "()" + @test position(buffer(s)) == 1 + + # Test right bracket skips over matching bracket + write_input(s, ")") + @test content(s) == "()" + @test position(buffer(s)) == 2 + + # Test backspace removes both brackets + s = LineEdit.init_state(term, interface) + write_input(s, "(") + write_input(s, "\b") + @test content(s) == "" + @test position(buffer(s)) == 0 + + # Test quote insertion at EOF + s = LineEdit.init_state(term, interface) + write_input(s, "\"") + @test content(s) == "\"\"" + @test position(buffer(s)) == 1 + + # Test quote skip over + write_input(s, "\"") + @test content(s) == "\"\"" + @test position(buffer(s)) == 2 + + # Test transpose detection - single quote after letter shouldn't auto-complete + s = LineEdit.init_state(term, interface) + write_input(s, "A") + write_input(s, "'") + @test content(s) == "A'" + @test position(buffer(s)) == 2 + + # Test single quote after space should auto-complete + s = LineEdit.init_state(term, interface) + write_input(s, " ") + write_input(s, "'") + @test content(s) == " ''" + @test position(buffer(s)) == 2 + + # Test bracket not inserted when next char is not whitespace + s = LineEdit.init_state(term, interface) + write_input(s, "x") + charseek(buffer(s), 0) + write_input(s, "(") + @test content(s) == "(x" + @test position(buffer(s)) == 1 + + # Test all bracket types + for (left, right) in (('[', ']'), ('{', '}')) + s = LineEdit.init_state(term, interface) + write_input(s, string(left)) + @test content(s) == string(left, right) + @test position(buffer(s)) == 1 + write_input(s, string(right)) + @test position(buffer(s)) == 2 + write_input(s, "\b") + @test content(s) == string(left) + @test position(buffer(s)) == 1 + write_input(s, "\b") + @test content(s) == "" + @test position(buffer(s)) == 0 + end + + # Test all quote types + for quote_char in ('`', '"', '\'') + s = LineEdit.init_state(term, interface) + write_input(s, string(quote_char)) + @test content(s) == string(quote_char, quote_char) + @test position(buffer(s)) == 1 + end + + # Test nested brackets + s = LineEdit.init_state(term, interface) + write_input(s, "(") + write_input(s, "[") + @test content(s) == "([])" + @test position(buffer(s)) == 2 + write_input(s, "]") + @test position(buffer(s)) == 3 + write_input(s, ")") + @test position(buffer(s)) == 4 + + # Test backspace in middle of nested brackets + s = LineEdit.init_state(term, interface) + write_input(s, "(") + write_input(s, "{") + @test content(s) == "({})" + @test position(buffer(s)) == 2 + write_input(s, "\b") + @test content(s) == "()" + @test position(buffer(s)) == 1 + + # Test triple quotes don't auto-complete + s = LineEdit.init_state(term, interface) + write_input(s, "\"") + @test content(s) == "\"\"" + @test position(buffer(s)) == 1 + write_input(s, "\"") + @test content(s) == "\"\"" + @test position(buffer(s)) == 2 + write_input(s, "\"") + @test content(s) == "\"\"\"" + @test position(buffer(s)) == 3 + + # Test transpose detection for various cases + s = LineEdit.init_state(term, interface) + write_input(s, "x123") + write_input(s, "'") + @test content(s) == "x123'" + @test position(buffer(s)) == 5 + + s = LineEdit.init_state(term, interface) + write_input(s, "arr]") + write_input(s, "'") + @test content(s) == "arr]'" + @test position(buffer(s)) == 5 + + # Test right bracket insert when not matching + s = LineEdit.init_state(term, interface) + write_input(s, ")") + @test content(s) == ")" + @test position(buffer(s)) == 1 + + # Test backspace doesn't remove mismatched brackets + s = LineEdit.init_state(term, interface) + write_input(s, "(") + write_input(s, "]") + charseek(buffer(s), 1) + write_input(s, "\b") + @test content(s) == "])" + @test position(buffer(s)) == 0 + + # Test bracket insertion followed by whitespace + s = LineEdit.init_state(term, interface) + write_input(s, " ") + charseek(buffer(s), 0) + write_input(s, "(") + @test content(s) == "() " + @test position(buffer(s)) == 1 + + # Test context-aware quote closing: typing " inside include("myfile.jl should close the string + s = LineEdit.init_state(term, interface) + write_input(s, "include(\"myfile.jl") + write_input(s, "\"") + @test content(s) == "include(\"myfile.jl\")" + @test position(buffer(s)) == 19 + + # Test context-aware quote closing for single quotes + s = LineEdit.init_state(term, interface) + write_input(s, "include('fsfds ") + write_input(s, "'") + @test content(s) == "include('fsfds ')" + @test position(buffer(s)) == 16 + + # Test that auto-close for quotes still works when there's no unmatched quote + s = LineEdit.init_state(term, interface) + write_input(s, "foo()") + write_input(s, "\"") + @test content(s) == "foo()\"\"" + @test position(buffer(s)) == 6 +end diff --git a/stdlib/REPL/test/repl.jl b/stdlib/REPL/test/repl.jl index 3f4d0d7a3f83d..5b8a159fb9b79 100644 --- a/stdlib/REPL/test/repl.jl +++ b/stdlib/REPL/test/repl.jl @@ -67,7 +67,7 @@ end #end # REPL tests -function fake_repl(@nospecialize(f); options::REPL.Options=REPL.Options(confirm_exit=false,style_input=false)) +function fake_repl(@nospecialize(f); options::REPL.Options=REPL.Options(confirm_exit=false,style_input=false,auto_insert_closing_bracket=false)) # Use pipes so we can easily do blocking reads # In the future if we want we can add a test that the right object # gets displayed by intercepting the display @@ -121,7 +121,7 @@ end # in the mix. If verification needs to be done, keep it to the bare minimum. Basically # this should make sure nothing crashes without depending on how exactly the control # characters are being used. -fake_repl(options = REPL.Options(confirm_exit=false,hascolor=true,style_input=false)) do stdin_write, stdout_read, repl +fake_repl(options = REPL.Options(confirm_exit=false,hascolor=true,style_input=false,auto_insert_closing_bracket=false)) do stdin_write, stdout_read, repl repl.specialdisplay = REPL.REPLDisplay(repl) repl.history_file = false @@ -1893,7 +1893,7 @@ fake_repl() do stdin_write, stdout_read, repl Base.wait(repltask) end ## hints disabled -fake_repl(options=REPL.Options(confirm_exit=false,hascolor=true,hint_tab_completes=false,style_input=false)) do stdin_write, stdout_read, repl +fake_repl(options=REPL.Options(confirm_exit=false,hascolor=true,hint_tab_completes=false,style_input=false,auto_insert_closing_bracket=false)) do stdin_write, stdout_read, repl repltask = @async begin REPL.run_repl(repl) end @@ -2040,7 +2040,7 @@ end :julia_number => StyledStrings.Face(foreground=:blue)) do # Test that julia_prompt has syntax highlighting passes - fake_repl(options = REPL.Options(confirm_exit=false, style_input=true)) do stdin_write, stdout_read, repl + fake_repl(options = REPL.Options(confirm_exit=false, style_input=true, auto_insert_closing_bracket=false)) do stdin_write, stdout_read, repl repl.interface = REPL.setup_interface(repl) julia_prompt = repl.interface.modes[1] shell_mode = repl.interface.modes[3] @@ -2128,7 +2128,7 @@ end end # Test that syntax highlighting can be disabled - fake_repl(options = REPL.Options(confirm_exit=false, style_input=false)) do stdin_write, stdout_read, repl + fake_repl(options = REPL.Options(confirm_exit=false, style_input=false, auto_insert_closing_bracket=false)) do stdin_write, stdout_read, repl repl.interface = REPL.setup_interface(repl) repltask = @async begin