From c1ae96ff74c4352c0bb263a6cb129f21015154cf Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 3 May 2026 01:51:21 +0600 Subject: [PATCH] Fix xonsh integration breaking on backslash line-continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Coconut is loaded as a xonsh xontrib and the user enters a multiline subprocess command joined by ``\``, xonsh raises ``SyntaxError: unexpected indent`` for input that worked fine without Coconut. Root cause: ``Compiler.reind_proc`` intentionally splits backslash- continued source into two physical lines and indents the continuation tail by ``tabideal`` spaces "for readability" of the compiled output. That is fine for standalone Coconut output — Python tolerates it via the implicit string/expression continuation rules — but the xonsh xontrib feeds the compiled output back through xonsh's own parser as top-level statements, where a complete statement followed by an unexpected INDENT is a SyntaxError. Concretely, ``echo a \b`` becomes ``echo a #1: echo a \ b #1: echo a \``, which neither xonsh nor CPython will parse. Fix: collapse ``\`` into a single space before handing the source to ``parse_xonsh``, so the compiler emits one physical line on the other side and the host parser is happy. The collapse walks the source manually so backslash sequences inside string literals and comments are left alone (a plain ``re.sub`` would silently rewrite ``"a\b"``, which is a Python implicit-string line continuation and must keep its meaning). Reproducer (with the xontrib autoloaded): @ echo a \\ b SyntaxError: code: b ← before a b ← after Other xonsh-side scenarios (``echo && cmd`` chains, Coconut features like ``|>``/``->``/``<|``, capture forms, xonsh assignments) are unaffected. --- coconut/integrations.py | 95 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/coconut/integrations.py b/coconut/integrations.py index e83dd13c2..632afd53f 100644 --- a/coconut/integrations.py +++ b/coconut/integrations.py @@ -91,6 +91,94 @@ def magic(line, cell=None): # XONSH: # ----------------------------------------------------------------------------------------------------------------------- +def _collapse_xonsh_line_continuations(src): + """Collapse ``\\`` line continuations into a single space. + + The Coconut compiler's ``reind_proc`` step intentionally splits + backslash-continued source into two physical lines and indents the + continuation tail by ``tabideal`` spaces "for readability". That is + fine for standalone Coconut output, but when the xonsh xontrib + feeds the compiled output back into xonsh's own parser the indented + tail surfaces as ``SyntaxError: unexpected indent`` after the + standalone first half of the statement (it is not even valid + CPython, since ``echo a #...\\n b`` is an INDENT after a + complete statement). + + Collapsing the continuation *before* compile keeps the source as a + single physical line, so the compiler emits one physical line on + the other side and the host parser is happy. This walks the source + manually so backslash sequences inside string literals and comments + are left alone — a plain ``re.sub`` would also rewrite ``"a\\\nb"`` + (a Python implicit-string line continuation) which would silently + change semantics. + """ + out = [] + i = 0 + while i < len(src): + ch = src[i] + + # String literals — preserve as-is, including any embedded + # backslash-newline (those are literal-string line continuations + # whose semantics we must not touch). Triple-quoted strings + # are matched first because their delimiter is longer than a + # single quote. + if ch in ("'", '"'): + triple = src[i:i + 3] + if triple in ('"""', "'''"): + end = src.find(triple, i + 3) + if end == -1: + # Unterminated triple-quote — bail and copy the rest + # as-is rather than reach into it. + out.append(src[i:]) + i = len(src) + continue + out.append(src[i:end + 3]) + i = end + 3 + continue + # Single-quoted string — find matching quote, respecting + # backslash escapes. Stop at an unescaped newline (treat + # the string as unterminated and leave the rest alone). + j = i + 1 + while j < len(src): + if src[j] == "\\" and j + 1 < len(src): + j += 2 + continue + if src[j] == ch: + j += 1 + break + if src[j] == "\n": + break + j += 1 + out.append(src[i:j]) + i = j + continue + + # Comment — copy until end of physical line. Backslash inside + # a comment is just text, never a continuation, so we must not + # touch it. + if ch == "#": + j = src.find("\n", i) + if j == -1: + out.append(src[i:]) + i = len(src) + continue + out.append(src[i:j]) + i = j + continue + + # Backslash + newline + optional indent at top level — collapse. + if ch == "\\" and i + 1 < len(src) and src[i + 1] == "\n": + i += 2 + while i < len(src) and src[i] in " \t": + i += 1 + out.append(" ") + continue + + out.append(ch) + i += 1 + return "".join(out) + + class CoconutXontribLoader(object): """Implements Coconut's _load_xontrib_.""" loaded = False @@ -113,6 +201,13 @@ def compile_code(self, code, log_name="parse"): parse_start_time = get_clock_time() quiet, logger.quiet = logger.quiet, True success = False + # Collapse `\` line continuations before + # parsing — see _collapse_xonsh_line_continuations. Without + # this step, source like ``echo a \b`` produces a + # compiled output that xonsh's host parser rejects as + # ``unexpected indent`` (the reindent pass intentionally + # indents the continuation tail). + code = _collapse_xonsh_line_continuations(code) try: # .strip() outside the memoization compiled = self.memoized_parse_xonsh(code.strip())