On second thought this issue should be an enhancement instead of a bug report. Sorry for the wrong template.
Bug description:
There are some cases where _pyrepl auto-indentation works not well.
Cases
A line ending with : in a multi-line string is wrongly indented.
Observed
>>> s = '''... Note:... ␣␣␣␣|
Expected
>>> s = '''... Note:... |
# inside strings is seen as a comment, the following : is ignored.
Observed
>>> if'' == '#':
... |
Expected
>>> if'' == '#':
... ␣␣␣␣|
When the entire cursor line is a comment and is already indented, pressing Enter gives a further indent.
Observed
>>> def f():
... # foo⤶
... ␣␣␣␣␣␣␣␣|
Expected
>>> def f():
... # foo⤶
... ␣␣␣␣|
Possible solution
Currently _should_auto_indent() parses the buffer from right to left and stops at the first newline it encounters. Only the last line that is not a comment line of the buffer is parsed.
But by parsing from right to left we can't tell if a # starts a comment or is part of a string. For example if we, from right to left, encounter a " first and then a #, we don't know if the # is a comment. To know that, we need to know if the " is a string boundary, but the # might comment out the ", so we can't be sure. There is a information dependency cycle.
To fix this I made a change to parse the buffer from left to right, keeping track of whether current char is inside a string or a comment. This approach solves the above three cases. However the whole buffer is parsed on every call of _should_auto_indent(), with very long buffer, there might be noticeable delay when pressing Enter.
I think this is a big change. It affects how _should_auto_indent() works as a whole. I am hesitated to create a PR and just put it here first to hopefully get feedback.
changed the title [-]PyREPL auto-indent not work in two cases[/-][+]PyREPL auto-indent not work in multi-line string and when '#' is literal string instead of comment[/+]on May 8, 2025
changed the title [-]PyREPL auto-indent not work in multi-line string and when '#' is literal string instead of comment[/-][+]PyREPL auto-indent not work in multi-line string, when '#' is literal string instead of comment, and comment line below function definition[/+]on May 9, 2025
changed the title [-]PyREPL auto-indent not work in multi-line string, when '#' is literal string instead of comment, and comment line below function definition[/-][+]Improve PyREPL auto-indentation[/+]on May 9, 2025
Thanks for the response! I will see if I can profile it over the weekend. If it ends up causing significant lag, what do you think about left-to-right full parsing only when the buffer is under certain threshold length, and falling back to the original right-to-left partial parsing for longer buffer?
I would prefer to keep the implementation simple and not split behavior based on input size. What problem are we solving here exactly? Just that auto-indent works inside multiline strings?
@ambv Apologies for the split behavior suggestion, I agree it would complicate the implementation unnecessarily.
@pablogsal I've profiled the performance impact of the change. Here's a comparison between the original and enhanced versions when processing a multiline string with up to 10k characters. While the relative slowdown is significant, the absolute delay remains low (~0.02s for 10k characters on an i5-7200U 2.5GHz CPU with an HDD). The delay is not noticeable, I think, and in interactive REPL scenarios the inputs would be shorter.
The code:
importtimeitimportrandomimportstringdef_should_auto_indent__main_branch(buffer: list[str], pos: int) ->bool:
# check if last character before "pos" is a colon, ignoring# whitespaces and comments.last_char=Nonewhilepos>0:
pos-=1iflast_charisNone:
ifbuffer[pos] notin" \t\n#": # ignore whitespaces and commentslast_char=buffer[pos]
else:
# even if we found a non-whitespace character before# original pos, we keep going back until newline is reached# to make sure we ignore commentsifbuffer[pos] =="\n":
breakifbuffer[pos] =="#":
last_char=Nonereturnlast_char==":"def_should_auto_indent__enhance_branch(buffer: list[str], pos: int) ->bool:
# check if last character before "pos" is a colon, ignoring# whitespaces, comments, and contents within strings.last_char=None# A stack to keep track of string delimiters. Push a quote when entering a# string, pop it when the string ends. If the stack is empty, we're not# inside a string. When see a '#', it's a comment start if we're not inside# a string; otherwise, it's just a '#' character within a string.str_delims: list[str] = []
in_comment=Falsechar_line_indent_start: int|None=Nonechar_line_indent=0lastchar_line_indent=0cursor_line_indent=0i=-1whilei<pos-1:
i+=1char=buffer[i]
# update last_charifchar=="#":
ifstr_delims:
last_char=char# '#' inside a string is just a characterelse:
in_comment=Trueelifchar=="\n":
# newline ends a commentin_comment=Falseifi<pos-1andbuffer[i+1] in" \t":
char_line_indent_start=i+1else:
char_line_indent_start=None# clear last line's line_indent_startchar_line_indent=0elifcharnotin" \t":
ifchar_line_indent_startisnotNone:
char_line_indent=i-char_line_indent_startifnotin_commentandnotstr_delims:
# update last_char with non-whitespace chars outside comments and stringslast_char=charlastchar_line_indent=char_line_indent# update stackifcharin"\"'"and (i==0orbuffer[i-1] !="\\"):
ifstr_delimsandstr_delims[-1] ==char:
str_delims.pop()
else:
str_delims.append(char)
cursor_line_indent=char_line_indentreturnlast_char==":"andcursor_line_indent<=lastchar_line_indent# 1000x100 multilines stringmultiline_str="\n".join("".join(random.choices(string.ascii_letters, k=100)) for_inrange(1000))
if__name__=="__main__":
foriinrange(2, 12, 2):
buffer=list(f'"""\n{multiline_str[:i*1000]}\n"""')
pos=len(buffer)
t1=timeit.timeit("_should_auto_indent__main_branch(buffer, pos)", number=100, globals=globals()) /100t2=timeit.timeit("_should_auto_indent__enhance_branch(buffer, pos)", number=100, globals=globals()) /100print("Multiline string length:", i*1000)
print("before:", t1)
print("after:", t2)
print("diff:", f"{t2-t1=}, {t2/t1=}", "\n")
Activity
pablogsal commented on Oct 30, 2025
I think that this may unfortunately be a deal breaker. Thoughts @ambv ?
tanloong commented on Oct 30, 2025
Thanks for the response! I will see if I can profile it over the weekend. If it ends up causing significant lag, what do you think about left-to-right full parsing only when the buffer is under certain threshold length, and falling back to the original right-to-left partial parsing for longer buffer?
ambv commented on Oct 31, 2025
I would prefer to keep the implementation simple and not split behavior based on input size. What problem are we solving here exactly? Just that auto-indent works inside multiline strings?
tanloong commented on Nov 9, 2025
@ambv Apologies for the split behavior suggestion, I agree it would complicate the implementation unnecessarily.
@pablogsal I've profiled the performance impact of the change. Here's a comparison between the original and enhanced versions when processing a multiline string with up to 10k characters. While the relative slowdown is significant, the absolute delay remains low (~0.02s for 10k characters on an i5-7200U 2.5GHz CPU with an HDD). The delay is not noticeable, I think, and in interactive REPL scenarios the inputs would be shorter.
The code:The result: