Skip to content

Commit 6266cac

Browse files
committed
add support (disabled by default) for automatic bracket completion in REPL input
1 parent fb52a1c commit 6266cac

File tree

6 files changed

+318
-4
lines changed

6 files changed

+318
-4
lines changed

NEWS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ Standard library changes
9898

9999
* The Julia REPL now support bracketed paste on Windows which should significantly speed up pasting large code blocks into the REPL ([#59825])
100100
* The REPL now provides syntax highlighting for input as you type. See the REPL docs for more info about customization.
101+
* The REPL now supports automatic insertion of closing brackets, parentheses, and quotes. This feature is disabled by default and can be enabled by setting
102+
```julia
103+
atreplinit() do repl
104+
repl.options.auto_insert_closing_bracket = true
105+
end
106+
```
107+
in your `startup.jl` file.
101108
* The display of `AbstractChar`s in the main REPL mode now includes LaTeX input information like what is shown in help mode ([#58181]).
102109
* Display of repeated frames and cycles in stack traces has been improved by bracketing them in the trace and treating them consistently ([#55841]).
103110

stdlib/REPL/docs/src/index.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,26 @@ atreplinit(customize_keys)
317317

318318
Users should refer to `LineEdit.jl` to discover the available actions on key input.
319319

320+
### Automatic bracket insertion
321+
322+
The Julia REPL supports automatically inserting closing brackets, parentheses, braces, and quotes
323+
when you type the opening character. This feature is disabled by default but can be enabled
324+
by setting the `auto_insert_closing_bracket` option to `true`.
325+
326+
When enabled, typing an opening bracket `(`, `{`, or `[` will automatically insert the matching
327+
closing bracket `)`, `}`, or `]` and position the cursor between them. The same behavior applies
328+
to quotes (`"`, `'`, and `` ` ``). If you then type the closing character, the REPL will skip over
329+
the auto-inserted character instead of inserting a duplicate. Additionally, pressing backspace
330+
immediately after auto-insertion will remove both the opening and closing characters.
331+
332+
To enable this feature, add the following to your `~/.julia/config/startup.jl` file:
333+
334+
```julia
335+
atreplinit() do repl
336+
repl.options.auto_insert_closing_bracket = true
337+
end
338+
```
339+
320340
## Tab completion
321341

322342
In the Julian, pkg and help modes of the REPL, one can enter the first few characters of a function

stdlib/REPL/src/LineEdit.jl

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2101,6 +2101,111 @@ const escape_defaults = merge!(
21012101
AnyDict("\e[$(c)l" => nothing for c in 1:20)
21022102
)
21032103

2104+
2105+
# Keymap for automatic bracket/quote insertion and completion
2106+
const bracket_insert_keymap = AnyDict()
2107+
let
2108+
# Determine when we should not close a bracket/quote
2109+
function should_skip_closing_bracket(left_peek, v)
2110+
# Don't close if we already have an open quote immediately before (triple quote case)
2111+
# For quotes, also check for transpose expressions: issue JuliaLang/OhMyREPL.jl#200
2112+
left_peek == v && return true
2113+
if v == '\''
2114+
tr_expr = isletter(left_peek) || isnumeric(left_peek) || left_peek == '_' || left_peek == ']'
2115+
return tr_expr
2116+
end
2117+
return false
2118+
end
2119+
2120+
function peek_char_left(b::IOBuffer)
2121+
p = position(b)
2122+
c = char_move_left(b)
2123+
seek(b, p)
2124+
return c
2125+
end
2126+
2127+
# Left/right bracket pairs
2128+
bracket_pairs = (('(', ')'), ('{', '}'), ('[', ']'))
2129+
right_brackets_ws = (')', '}', ']', ' ', '\t', '\n')
2130+
2131+
for (left, right) in bracket_pairs
2132+
# Left bracket: insert both and move cursor between them
2133+
bracket_insert_keymap[left] = (s::MIState, o...) -> begin
2134+
buf = buffer(s)
2135+
edit_insert(buf, left)
2136+
if eof(buf) || peek(buf, Char) in right_brackets_ws
2137+
edit_insert(buf, right)
2138+
edit_move_left(buf)
2139+
end
2140+
refresh_line(s)
2141+
end
2142+
2143+
# Right bracket: skip over if next char matches, otherwise insert
2144+
bracket_insert_keymap[right] = (s::MIState, o...) -> begin
2145+
buf = buffer(s)
2146+
if !eof(buf) && peek(buf, Char) == right
2147+
edit_move_right(buf)
2148+
else
2149+
edit_insert(buf, right)
2150+
end
2151+
refresh_line(s)
2152+
end
2153+
end
2154+
2155+
# Quote characters (need special handling for transpose detection)
2156+
for quote_char in ('"', '\'', '`')
2157+
bracket_insert_keymap[quote_char] = (s::MIState, o...) -> begin
2158+
buf = buffer(s)
2159+
if !eof(buf) && peek(buf, Char) == quote_char
2160+
# Skip over closing quote
2161+
edit_move_right(buf)
2162+
elseif position(buf) > 0 && should_skip_closing_bracket(peek_char_left(buf), quote_char)
2163+
# Don't auto-close (e.g., for transpose or triple quotes)
2164+
edit_insert(buf, quote_char)
2165+
else
2166+
# Insert both quotes
2167+
edit_insert(buf, quote_char)
2168+
edit_insert(buf, quote_char)
2169+
edit_move_left(buf)
2170+
end
2171+
refresh_line(s)
2172+
end
2173+
end
2174+
2175+
# Backspace - also remove matching closing bracket/quote
2176+
bracket_insert_keymap['\b'] = (s::MIState, o...) -> begin
2177+
if is_region_active(s)
2178+
return edit_kill_region(s)
2179+
elseif isempty(s) || position(buffer(s)) == 0
2180+
# Handle transitioning to main mode
2181+
repl = Base.active_repl
2182+
mirepl = isdefined(repl, :mi) ? repl.mi : repl
2183+
main_mode = mirepl.interface.modes[1]
2184+
buf = copy(buffer(s))
2185+
transition(s, main_mode) do
2186+
state(s, main_mode).input_buffer = buf
2187+
end
2188+
return
2189+
end
2190+
2191+
buf = buffer(s)
2192+
left_brackets = ('(', '{', '[', '"', '\'', '`')
2193+
right_brackets = (')', '}', ']', '"', '\'', '`')
2194+
2195+
if !eof(buf) && position(buf) > 0
2196+
left_char = peek_char_left(buf)
2197+
i = findfirst(isequal(left_char), left_brackets)
2198+
if i !== nothing && peek(buf, Char) == right_brackets[i]
2199+
# Remove both the left and right bracket/quote
2200+
edit_delete(buf)
2201+
edit_backspace(buf)
2202+
return refresh_line(s)
2203+
end
2204+
end
2205+
return edit_backspace(s)
2206+
end
2207+
end
2208+
21042209
mutable struct HistoryPrompt <: TextInterface
21052210
hp::HistoryProvider
21062211
complete::CompletionProvider

stdlib/REPL/src/REPL.jl

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,7 +1499,18 @@ function setup_interface(
14991499
end
15001500
Base.errormonitor(t_replswitch)
15011501
else
1502-
edit_insert(s, ']')
1502+
# Use bracket insertion if enabled, otherwise just insert
1503+
if repl.options.auto_insert_closing_bracket
1504+
buf = LineEdit.buffer(s)
1505+
if !eof(buf) && LineEdit.peek(buf, Char) == ']'
1506+
LineEdit.edit_move_right(buf)
1507+
else
1508+
edit_insert(buf, ']')
1509+
end
1510+
LineEdit.refresh_line(s)
1511+
else
1512+
edit_insert(s, ']')
1513+
end
15031514
LineEdit.check_show_hint(s)
15041515
end
15051516
end,
@@ -1671,14 +1682,28 @@ function setup_interface(
16711682

16721683
prefix_prompt, prefix_keymap = LineEdit.setup_prefix_keymap(hp, julia_prompt)
16731684

1674-
a = Dict{Any,Any}[skeymap, repl_keymap, prefix_keymap, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults]
1685+
# Build keymap list - add bracket insertion if enabled
1686+
base_keymaps = Dict{Any,Any}[skeymap, repl_keymap, prefix_keymap, LineEdit.history_keymap]
1687+
if repl.options.auto_insert_closing_bracket
1688+
push!(base_keymaps, LineEdit.bracket_insert_keymap)
1689+
end
1690+
push!(base_keymaps, LineEdit.default_keymap, LineEdit.escape_defaults)
1691+
1692+
a = base_keymaps
16751693
prepend!(a, extra_repl_keymap)
16761694

16771695
julia_prompt.keymap_dict = LineEdit.keymap(a)
16781696

16791697
mk = mode_keymap(julia_prompt)
16801698

1681-
b = Dict{Any,Any}[skeymap, mk, prefix_keymap, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults]
1699+
# Build keymap list for other modes
1700+
mode_base_keymaps = Dict{Any,Any}[skeymap, mk, prefix_keymap, LineEdit.history_keymap]
1701+
if repl.options.auto_insert_closing_bracket
1702+
push!(mode_base_keymaps, LineEdit.bracket_insert_keymap)
1703+
end
1704+
push!(mode_base_keymaps, LineEdit.default_keymap, LineEdit.escape_defaults)
1705+
1706+
b = mode_base_keymaps
16821707
prepend!(b, extra_repl_keymap)
16831708

16841709
shell_mode.keymap_dict = help_mode.keymap_dict = dummy_pkg_mode.keymap_dict = LineEdit.keymap(b)

stdlib/REPL/src/options.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mutable struct Options
2828
# refresh after time delay
2929
auto_refresh_time_delay::Float64
3030
hint_tab_completes::Bool
31+
auto_insert_closing_bracket::Bool # automatically insert closing brackets, quotes, etc.
3132
style_input::Bool # enable syntax highlighting for input
3233
# default IOContext settings at the REPL
3334
iocontext::Dict{Symbol,Any}
@@ -50,6 +51,7 @@ Options(;
5051
auto_indent_time_threshold = 0.005,
5152
auto_refresh_time_delay = 0.0, # this no longer seems beneficial
5253
hint_tab_completes = true,
54+
auto_insert_closing_bracket = false,
5355
style_input = true,
5456
iocontext = Dict{Symbol,Any}()) =
5557
Options(hascolor, extra_keymap, tabwidth,
@@ -59,7 +61,7 @@ Options(;
5961
backspace_align, backspace_adjust, confirm_exit,
6062
auto_indent, auto_indent_tmp_off, auto_indent_bracketed_paste,
6163
auto_indent_time_threshold, auto_refresh_time_delay,
62-
hint_tab_completes, style_input,
64+
hint_tab_completes, auto_insert_closing_bracket, style_input,
6365
iocontext)
6466

6567
# for use by REPLs not having an options field

stdlib/REPL/test/lineedit.jl

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,3 +940,158 @@ end
940940
strings3 = ["abcdef", "123456\nijklmn"]
941941
@test getcompletion(strings3) == "\033[0B\nabcdef\n123456\nijklmn\n"
942942
end
943+
944+
# Test bracket insertion functionality
945+
@testset "Bracket insertion" begin
946+
# Test bracket insertion with fake REPL
947+
# Note: In practice, bracket_insert_keymap is added via auto_insert_closing_bracket option,
948+
# but here we test the keymap functions directly
949+
term = FakeTerminal(IOBuffer(), IOBuffer(), IOBuffer())
950+
prompt = LineEdit.Prompt("test> ")
951+
prompt.keymap_dict = LineEdit.bracket_insert_keymap
952+
interface = LineEdit.ModalInterface([prompt])
953+
s = LineEdit.init_state(term, interface)
954+
955+
# Test left bracket at EOF triggers auto-complete
956+
LineEdit.bracket_insert_keymap['('](s)
957+
@test content(s) == "()"
958+
@test position(buffer(s)) == 1
959+
960+
# Test right bracket skips over matching bracket
961+
LineEdit.bracket_insert_keymap[')'](s)
962+
@test content(s) == "()"
963+
@test position(buffer(s)) == 2
964+
965+
# Test backspace removes both brackets
966+
s = LineEdit.init_state(term, interface)
967+
LineEdit.bracket_insert_keymap['('](s)
968+
LineEdit.bracket_insert_keymap['\b'](s)
969+
@test content(s) == ""
970+
@test position(buffer(s)) == 0
971+
972+
# Test quote insertion at EOF
973+
s = LineEdit.init_state(term, interface)
974+
LineEdit.bracket_insert_keymap['"'](s)
975+
@test content(s) == "\"\""
976+
@test position(buffer(s)) == 1
977+
978+
# Test quote skip over
979+
LineEdit.bracket_insert_keymap['"'](s)
980+
@test content(s) == "\"\""
981+
@test position(buffer(s)) == 2
982+
983+
# Test transpose detection - single quote after letter shouldn't auto-complete
984+
s = LineEdit.init_state(term, interface)
985+
edit_insert(buffer(s), 'A')
986+
LineEdit.bracket_insert_keymap['\'']( s)
987+
@test content(s) == "A'"
988+
@test position(buffer(s)) == 2
989+
990+
# Test single quote after space should auto-complete
991+
s = LineEdit.init_state(term, interface)
992+
edit_insert(buffer(s), ' ')
993+
LineEdit.bracket_insert_keymap['\''](s)
994+
@test content(s) == " ''"
995+
@test position(buffer(s)) == 2
996+
997+
# Test bracket not inserted when next char is not whitespace
998+
s = LineEdit.init_state(term, interface)
999+
edit_insert(buffer(s), "x")
1000+
charseek(buffer(s), 0)
1001+
LineEdit.bracket_insert_keymap['('](s)
1002+
@test content(s) == "(x"
1003+
@test position(buffer(s)) == 1
1004+
1005+
# Test all bracket types
1006+
for (left, right) in (('[', ']'), ('{', '}'))
1007+
s = LineEdit.init_state(term, interface)
1008+
LineEdit.bracket_insert_keymap[left](s)
1009+
@test content(s) == string(left, right)
1010+
@test position(buffer(s)) == 1
1011+
LineEdit.bracket_insert_keymap[right](s)
1012+
@test position(buffer(s)) == 2
1013+
LineEdit.bracket_insert_keymap['\b'](s)
1014+
@test content(s) == string(left)
1015+
@test position(buffer(s)) == 1
1016+
LineEdit.bracket_insert_keymap['\b'](s)
1017+
@test content(s) == ""
1018+
@test position(buffer(s)) == 0
1019+
end
1020+
1021+
# Test all quote types
1022+
for quote_char in ('`', '"', '\'')
1023+
s = LineEdit.init_state(term, interface)
1024+
LineEdit.bracket_insert_keymap[quote_char](s)
1025+
@test content(s) == string(quote_char, quote_char)
1026+
@test position(buffer(s)) == 1
1027+
end
1028+
1029+
# Test nested brackets
1030+
s = LineEdit.init_state(term, interface)
1031+
LineEdit.bracket_insert_keymap['('](s)
1032+
LineEdit.bracket_insert_keymap['['](s)
1033+
@test content(s) == "([])"
1034+
@test position(buffer(s)) == 2
1035+
LineEdit.bracket_insert_keymap[']'](s)
1036+
@test position(buffer(s)) == 3
1037+
LineEdit.bracket_insert_keymap[')'](s)
1038+
@test position(buffer(s)) == 4
1039+
1040+
# Test backspace in middle of nested brackets
1041+
s = LineEdit.init_state(term, interface)
1042+
LineEdit.bracket_insert_keymap['('](s)
1043+
LineEdit.bracket_insert_keymap['{'](s)
1044+
@test content(s) == "({})"
1045+
@test position(buffer(s)) == 2
1046+
LineEdit.bracket_insert_keymap['\b'](s)
1047+
@test content(s) == "()"
1048+
@test position(buffer(s)) == 1
1049+
1050+
# Test triple quotes don't auto-complete
1051+
s = LineEdit.init_state(term, interface)
1052+
LineEdit.bracket_insert_keymap['"'](s)
1053+
@test content(s) == "\"\""
1054+
@test position(buffer(s)) == 1
1055+
LineEdit.bracket_insert_keymap['"'](s)
1056+
@test content(s) == "\"\""
1057+
@test position(buffer(s)) == 2
1058+
LineEdit.bracket_insert_keymap['"'](s)
1059+
@test content(s) == "\"\"\""
1060+
@test position(buffer(s)) == 3
1061+
1062+
# Test transpose detection for various cases
1063+
s = LineEdit.init_state(term, interface)
1064+
edit_insert(buffer(s), "x123")
1065+
LineEdit.bracket_insert_keymap['\''](s)
1066+
@test content(s) == "x123'"
1067+
@test position(buffer(s)) == 5
1068+
1069+
s = LineEdit.init_state(term, interface)
1070+
edit_insert(buffer(s), "arr]")
1071+
LineEdit.bracket_insert_keymap['\''](s)
1072+
@test content(s) == "arr]'"
1073+
@test position(buffer(s)) == 5
1074+
1075+
# Test right bracket insert when not matching
1076+
s = LineEdit.init_state(term, interface)
1077+
LineEdit.bracket_insert_keymap[')'](s)
1078+
@test content(s) == ")"
1079+
@test position(buffer(s)) == 1
1080+
1081+
# Test backspace doesn't remove mismatched brackets
1082+
s = LineEdit.init_state(term, interface)
1083+
LineEdit.bracket_insert_keymap['('](s)
1084+
edit_insert(buffer(s), ']')
1085+
charseek(buffer(s), 1)
1086+
LineEdit.bracket_insert_keymap['\b'](s)
1087+
@test content(s) == "])"
1088+
@test position(buffer(s)) == 0
1089+
1090+
# Test bracket insertion followed by whitespace
1091+
s = LineEdit.init_state(term, interface)
1092+
edit_insert(buffer(s), " ")
1093+
charseek(buffer(s), 0)
1094+
LineEdit.bracket_insert_keymap['('](s)
1095+
@test content(s) == "() "
1096+
@test position(buffer(s)) == 1
1097+
end

0 commit comments

Comments
 (0)