Skip to content

Commit 0c34bde

Browse files
KristofferCKristofferC
andauthored
improve behavior of bracket insertion in some situations (JuliaLang#60060)
this more closely mimics how other editors do it and should hopefully remove a few annoying cases see tests for concrete changes (also make sure that other modes also remove paired delimiters on backspace) Tests and some code written by Claude Code 🤖 Co-authored-by: KristofferC <[email protected]>
1 parent e5c57d2 commit 0c34bde

File tree

3 files changed

+110
-56
lines changed

3 files changed

+110
-56
lines changed

stdlib/REPL/src/LineEdit.jl

Lines changed: 51 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2103,6 +2103,29 @@ const escape_defaults = merge!(
21032103
)
21042104

21052105

2106+
# Helper function to check and remove paired brackets/quotes
2107+
# Returns true if paired delimiters were removed, false otherwise
2108+
function try_remove_paired_delimiter(buf::IOBuffer)
2109+
left_brackets = ('(', '{', '[', '"', '\'', '`')
2110+
right_brackets = (')', '}', ']', '"', '\'', '`')
2111+
2112+
if !eof(buf) && position(buf) > 0
2113+
# Peek at char to the left
2114+
p = position(buf)
2115+
left_char = char_move_left(buf)
2116+
seek(buf, p)
2117+
2118+
i = findfirst(isequal(left_char), left_brackets)
2119+
if i !== nothing && peek(buf, Char) == right_brackets[i]
2120+
# Remove both the left and right bracket/quote
2121+
edit_delete(buf)
2122+
edit_backspace(buf)
2123+
return true
2124+
end
2125+
end
2126+
return false
2127+
end
2128+
21062129
# Keymap for automatic bracket/quote insertion and completion
21072130
const bracket_insert_keymap = AnyDict()
21082131
let
@@ -2125,37 +2148,33 @@ let
21252148
return c
21262149
end
21272150

2128-
# Check if there's an unmatched opening quote before the cursor
2129-
function has_unmatched_quote(buf::IOBuffer, quote_char::Char)
2130-
pos = position(buf)
2131-
content = String(buf.data[1:pos])
2132-
isempty(content) && return false
2133-
2134-
# Count unescaped quotes before cursor position
2135-
count = 0
2136-
i = 1
2137-
while i <= length(content)
2138-
if content[i] == quote_char
2139-
# Check if escaped by counting preceding backslashes
2140-
num_backslashes = 0
2141-
j = i - 1
2142-
while j >= 1 && content[j] == '\\'
2143-
num_backslashes += 1
2144-
j -= 1
2145-
end
2146-
# If even number of backslashes (including zero), the quote is not escaped
2147-
if num_backslashes % 2 == 0
2148-
count += 1
2149-
end
2150-
end
2151-
i = nextind(content, i)
2151+
# Check if we should auto-close a quote (insert paired quotes)
2152+
# auto-close when "transparent" chars on both sides
2153+
# Transparent chars: whitespace, opening brackets ([{, closing brackets )]}, or nothing
2154+
function should_auto_close_quote(buf::IOBuffer, quote_char::Char)
2155+
# Check left side: BOF, whitespace, or opening bracket
2156+
left_ok = if position(buf) == 0
2157+
true
2158+
else
2159+
left_char = peek_char_left(buf)
2160+
isspace(left_char) || left_char in ('(', '[', '{')
2161+
end
2162+
2163+
# Check right side: EOF, whitespace, or closing bracket
2164+
right_ok = if eof(buf)
2165+
true
2166+
else
2167+
right_char = peek(buf, Char)
2168+
isspace(right_char) || right_char in (')', ']', '}')
21522169
end
2153-
return isodd(count)
2170+
2171+
return left_ok && right_ok
21542172
end
21552173

21562174
# Left/right bracket pairs
21572175
bracket_pairs = (('(', ')'), ('{', '}'), ('[', ']'))
2158-
right_brackets_ws = (')', '}', ']', ' ', '\t', '\n')
2176+
# Characters that are "transparent" for bracket auto-closing
2177+
right_brackets_ws = (')', '}', ']', ' ', '\t', '\n', '"', '\'', '`')
21592178

21602179
for (left, right) in bracket_pairs
21612180
# Left bracket: insert both and move cursor between them
@@ -2191,14 +2210,13 @@ let
21912210
elseif position(buf) > 0 && should_skip_closing_bracket(peek_char_left(buf), quote_char)
21922211
# Don't auto-close (e.g., for transpose or triple quotes)
21932212
edit_insert(buf, quote_char)
2194-
elseif quote_char in ('"', '\'', '`') && has_unmatched_quote(buf, quote_char)
2195-
# For quotes, check if we're closing an existing string
2196-
edit_insert(buf, quote_char)
2197-
else
2198-
# Insert both quotes
2213+
elseif should_auto_close_quote(buf, quote_char)
21992214
edit_insert(buf, quote_char)
22002215
edit_insert(buf, quote_char)
22012216
edit_move_left(buf)
2217+
else
2218+
# Just insert single quote
2219+
edit_insert(buf, quote_char)
22022220
end
22032221
refresh_line(s)
22042222
end
@@ -2221,18 +2239,8 @@ let
22212239
end
22222240

22232241
buf = buffer(s)
2224-
left_brackets = ('(', '{', '[', '"', '\'', '`')
2225-
right_brackets = (')', '}', ']', '"', '\'', '`')
2226-
2227-
if !eof(buf) && position(buf) > 0
2228-
left_char = peek_char_left(buf)
2229-
i = findfirst(isequal(left_char), left_brackets)
2230-
if i !== nothing && peek(buf, Char) == right_brackets[i]
2231-
# Remove both the left and right bracket/quote
2232-
edit_delete(buf)
2233-
edit_backspace(buf)
2234-
return refresh_line(s)
2235-
end
2242+
if try_remove_paired_delimiter(buf)
2243+
return refresh_line(s)
22362244
end
22372245
return edit_backspace(s)
22382246
end

stdlib/REPL/src/REPL.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,10 @@ function mode_keymap(julia_prompt::Prompt)
11921192
LineEdit.state(s, julia_prompt).input_buffer = buf
11931193
end
11941194
else
1195+
buf = LineEdit.buffer(s)
1196+
if LineEdit.try_remove_paired_delimiter(buf)
1197+
return LineEdit.refresh_line(s)
1198+
end
11951199
LineEdit.edit_backspace(s)
11961200
end
11971201
end,

stdlib/REPL/test/lineedit.jl

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,24 +1103,66 @@ end
11031103
@test content(s) == "() "
11041104
@test position(buffer(s)) == 1
11051105

1106-
# Test context-aware quote closing: typing " inside include("myfile.jl should close the string
1106+
# Test quote behavior: |foo" + " -> "foo" (not ""foo")
11071107
s = LineEdit.init_state(term, interface)
1108-
write_input(s, "include(\"myfile.jl")
1108+
write_input(s, "foo\"")
1109+
charseek(buffer(s), 0)
11091110
write_input(s, "\"")
1110-
@test content(s) == "include(\"myfile.jl\")"
1111-
@test position(buffer(s)) == 19
1111+
@test content(s) == "\"foo\""
1112+
@test position(buffer(s)) == 1
11121113

1113-
# Test context-aware quote closing for single quotes
1114+
# Test quote behavior: foo| + " -> foo" (not foo"")
11141115
s = LineEdit.init_state(term, interface)
1115-
write_input(s, "include('fsfds ")
1116-
write_input(s, "'")
1117-
@test content(s) == "include('fsfds ')"
1118-
@test position(buffer(s)) == 16
1116+
write_input(s, "foo")
1117+
write_input(s, "\"")
1118+
@test content(s) == "foo\""
1119+
@test position(buffer(s)) == 4
11191120

1120-
# Test that auto-close for quotes still works when there's no unmatched quote
1121+
# Test quote behavior: foo | + " -> foo ""
11211122
s = LineEdit.init_state(term, interface)
1122-
write_input(s, "foo()")
1123+
write_input(s, "foo ")
11231124
write_input(s, "\"")
1124-
@test content(s) == "foo()\"\""
1125-
@test position(buffer(s)) == 6
1125+
@test content(s) == "foo \"\""
1126+
@test position(buffer(s)) == 5
1127+
1128+
# Test quote behavior: | foo + " -> "" foo (space before foo means double quotes)
1129+
s = LineEdit.init_state(term, interface)
1130+
write_input(s, " foo")
1131+
charseek(buffer(s), 0)
1132+
write_input(s, "\"")
1133+
@test content(s) == "\"\" foo"
1134+
@test position(buffer(s)) == 1
1135+
1136+
# Test quote behavior: | + " -> ""
1137+
s = LineEdit.init_state(term, interface)
1138+
write_input(s, " ")
1139+
write_input(s, "\"")
1140+
@test content(s) == " \"\""
1141+
@test position(buffer(s)) == 2
1142+
1143+
# Test quote behavior: (|) + " -> ("")
1144+
s = LineEdit.init_state(term, interface)
1145+
write_input(s, ")")
1146+
charseek(buffer(s), 0)
1147+
write_input(s, "(")
1148+
# Buffer is now () with cursor at 1
1149+
write_input(s, "\"")
1150+
@test content(s) == "(\"\"))"
1151+
@test position(buffer(s)) == 2
1152+
1153+
# Test quote behavior: (|bar) + " -> ("bar)
1154+
s = LineEdit.init_state(term, interface)
1155+
write_input(s, "(bar)")
1156+
charseek(buffer(s), 1)
1157+
write_input(s, "\"")
1158+
@test content(s) == "(\"bar)"
1159+
@test position(buffer(s)) == 2
1160+
1161+
# Test bracket behavior: "|" + ( -> "()"
1162+
s = LineEdit.init_state(term, interface)
1163+
write_input(s, "\"\"")
1164+
charseek(buffer(s), 1)
1165+
write_input(s, "(")
1166+
@test content(s) == "\"()\""
1167+
@test position(buffer(s)) == 2
11261168
end

0 commit comments

Comments
 (0)