From 916b27d61d3fca32c18c0fcac20039853fa0bf7d Mon Sep 17 00:00:00 2001 From: L4XB Date: Sun, 13 Sep 2026 22:42:36 +0200 Subject: [PATCH 1/2] fix(swift): parse an if let binding whose await operand is not a call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-swift 0.7.3 — the only published release, so there is no version to bump to — accepts `await` in an `if let` binding only when the operand is a direct call. `if let r = await pending`, `if let r = await box.rings` and `if let r = try await mint()` are all valid Swift 6 and all parse as ERROR, which marks the file partially extracted and leaves every rule downstream walking a damaged subtree. `guard let` with the same operand parses, which places the gap in the if/while binding rule rather than in `await`. The repair blanks the `await` with SPACES rather than deleting it. That is the whole trick: the repaired source is byte-for-byte the same length, so every offset, line and column downstream still addresses what it did. `await` names no symbol, so the graph built from the repaired bytes is the graph of the original file. It is gated three ways. Swift only; only when the original parse already errored, so a file that parses is never rewritten; and the repaired tree is kept only when it buys something — no error at all, or a first error that moved further down, since a file can carry this grammar gap and a real mistake at once. A match sitting behind a `//` is skipped, so a commented-out binding keeps its text. Four mutants, each caught: the repair never applied, `await` deleted instead of blanked, the comment guard dropped, and `guard let` swept in. Fixes #3540 --- CHANGELOG.md | 1 + graphify/extractors/engine.py | 50 +++++++++ tests/test_swift_await_optional_binding.py | 122 +++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 tests/test_swift_await_optional_binding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cb65991657..c207218236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (unreleased) +- Fix: Swift extraction no longer aborts on an `if let`/`while let` binding whose `await` operand is not a direct call. tree-sitter-swift parses `if let r = await pending`, `if let r = await box.rings` and `if let r = try await mint()` as syntax errors — all valid Swift 6, and all reported the file as partially extracted. The `await` is blanked with spaces before the retry parse, so the repaired source is the same byte length and every offset, line and column still points where it did (#3540). - Fix: `graphify.serve` now imports cleanly on Python 3.12 and 3.13. The `chinese` extra pins `jieba-py` from 3.12 onward (0.9.60 mistakenly kept the old `jieba` until 3.14, and its invalid regex escapes are a hard error on 3.12+), and the jieba import now suppresses the tokenizer's `SyntaxWarning` regardless of message or line so it never escalates under `-W error`. - Fix: the git hook's rebuild-root guard now rejects a symlink-loop or dangling `.graphify_root` on Python 3.13, whose `Path.resolve()` no longer raises on a loop — the saved root must resolve to a real directory inside the repo before it is adopted. diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index aeec2574aa..25cd03428e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4,6 +4,7 @@ import hashlib import importlib import json +import re from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text from graphify.ids import normalize_id from graphify.extractors.models import LanguageConfig @@ -3141,6 +3142,42 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st del ruby_namespace[-len(const_segments):] return True +# tree-sitter-swift (0.7.3, the only published release) accepts `await` in an +# `if let` binding ONLY when the operand is a direct call. `if let r = await +# pending`, `if let r = await box.rings` and `if let r = try await mint()` are +# all valid Swift 6 and all parse as ERROR, which costs the rest of the file: +# a 412-line source came out with 59 of its ~87 symbols (#3540). `guard let` +# with the same operand parses, so the gap is in the if/while binding rule. +# +# Blanking `await` with SPACES rather than deleting it is the whole trick: the +# repaired source is byte-for-byte the same length, so every offset, line and +# column downstream still points where it did. `await` names no symbol, so a +# graph built from the repaired bytes is the graph of the original file. +_SWIFT_AWAIT_BINDING = re.compile( + rb"(\b(?:if|while)\s+(?:let|var)\s+[A-Za-z_]\w*\s*=\s*)((?:try\s+)?await\s+)" +) + + +def _swift_blank_await_bindings(source: bytes) -> bytes: + """`if let x = await y` -> `if let x = y`, same byte length. + + Skips a match that sits behind a `//` on its own line, so a commented-out + binding keeps its text: comments are not parsed into symbols, but some of + them are read as documentation and rewriting one would be a change nobody + asked for. A block comment or a string literal holding this exact shape is + still rewritten — harmless at equal length, and this only runs on a file + that already failed to parse. + """ + + def replace(match: "re.Match[bytes]") -> bytes: + line_start = source.rfind(b"\n", 0, match.start()) + 1 + if b"//" in source[line_start:match.start()]: + return match.group(0) + return match.group(1) + b" " * len(match.group(2)) + + return _SWIFT_AWAIT_BINDING.sub(replace, source) + + def _extract_generic( path: Path, config: LanguageConfig, *, source_override: bytes | None = None ) -> dict: @@ -3184,6 +3221,19 @@ def _extract_generic( source = source + b"\n" tree = parser.parse(source) root = tree.root_node + # Error-gated, so a file that already parses is never rewritten, and + # the repair is kept only when it actually buys something: no error at + # all, or a first error that moved further down the file (a source can + # carry this grammar gap AND a real mistake). + if root.has_error and config.ts_module == "tree_sitter_swift": + repaired = _swift_blank_await_bindings(source) + if repaired != source: + retry_root = parser.parse(repaired).root_node + if not retry_root.has_error or ( + (_first_parse_error_line(retry_root) or 0) + > (_first_parse_error_line(root) or 0) + ): + source, root = repaired, retry_root except Exception as e: return {"nodes": [], "edges": [], "error": str(e)} diff --git a/tests/test_swift_await_optional_binding.py b/tests/test_swift_await_optional_binding.py new file mode 100644 index 0000000000..36b8bd0fd3 --- /dev/null +++ b/tests/test_swift_await_optional_binding.py @@ -0,0 +1,122 @@ +"""Regression tests for #3540. + +tree-sitter-swift (0.7.3, the only published release) accepts `await` in an +`if let` binding ONLY when the operand is a direct call. Valid Swift 6 such as +`if let r = await pending`, `if let r = await box.rings` and +`if let r = try await mint()` all parse as ERROR, so the file is reported as +"partially extracted" and every rule downstream walks a damaged subtree. +`guard let` with the same operand parses, which places the gap in the +if/while binding rule rather than in `await` itself. + +The repair blanks the `await` with SPACES instead of deleting it, so the +repaired source is byte-for-byte the same length and every offset, line and +column downstream still points where it did. +""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from graphify.extract import extract_swift + +from graphify.extractors.engine import _swift_blank_await_bindings + +PREAMBLE = """import Foundation + +struct Rings { let move: Double = 0 } + +func fetchRings() async -> Rings? { nil } +""" + +# A declaration after the binding, so "the file still parsed past it" is +# something the assertions can see rather than something they assume. +SENTINEL = "\nstruct SentinelType { let marker = 1 }\n" + + +class TestSwiftAwaitOptionalBinding(unittest.TestCase): + def _extract(self, body: str) -> dict: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "Await.swift" + p.write_text(PREAMBLE + body + SENTINEL, encoding="utf-8") + return extract_swift(p) + + def test_every_await_binding_shape_parses(self): + """The four shapes the report measured, plus `while` and `var`.""" + bodies = { + "await a bare identifier": ( + "func load() async {\n" + " async let pending = fetchRings()\n" + " if let r = await pending { print(r.move) }\n" + "}\n" + ), + "await a member": ( + "func load(box: Box) async {\n" + " if let r = await box.rings { print(r.move) }\n" + "}\n" + ), + "try await a call": ( + "func load() async throws {\n" + " if let r = try await fetchRings() { print(r.move) }\n" + "}\n" + ), + "if var": ( + "func load() async {\n" + " async let pending = fetchRings()\n" + " if var r = await pending { print(r.move) }\n" + "}\n" + ), + "while let": ( + "func load() async {\n" + " async let pending = fetchRings()\n" + " while let r = await pending { print(r.move) }\n" + "}\n" + ), + } + for name, body in bodies.items(): + with self.subTest(name): + result = self._extract(body) + # `parse_errors`, plural — the key the extractor actually + # sets. The singular spelling is never present, so asserting + # on it passes for a file that failed to parse. + self.assertEqual(result.get("parse_errors"), None, name) + # Arrival: the file really was walked, so the absence above is + # a clean parse and not an empty result. + self.assertIn("SentinelType", [n["label"] for n in result["nodes"]]) + + def test_a_direct_call_operand_was_never_broken(self): + """Accept control. `await` + a direct call always parsed, so a rule + that "fixed" it would be repairing nothing and could only add risk.""" + result = self._extract( + "func load() async {\n" + " if let r = await fetchRings() { print(r.move) }\n" + "}\n" + ) + self.assertEqual(result.get("parse_errors"), None) + self.assertIn("SentinelType", [n["label"] for n in result["nodes"]]) + + def test_the_repair_preserves_every_byte_offset(self): + """The property the whole approach rests on: same length, so every + node's start/end byte still addresses the text it did.""" + for source in ( + b"if let r = await pending {", + b"if let r = try await mint() {", + b"while var r = await box.rings {", + b"if let r = await fetchRings() {", + b"guard let r = await pending else {", + ): + with self.subTest(source.decode()): + self.assertEqual(len(_swift_blank_await_bindings(source)), len(source)) + + def test_the_repair_leaves_everything_else_alone(self): + """It rewrites the binding operand and nothing else — not a `guard`, + which the grammar already accepts, and not an `await` in a statement + position.""" + for untouched in ( + b"guard let r = await pending else { return }", + b"let r = await pending", + b"await doThing()", + b"// if let r = await pending\n", + ): + with self.subTest(untouched.decode()): + self.assertEqual(_swift_blank_await_bindings(untouched), untouched) From 0ba46e243346aa1547465cb3ea359c6a8d110b75 Mon Sep 17 00:00:00 2001 From: L4XB Date: Tue, 15 Sep 2026 15:02:54 +0200 Subject: [PATCH 2/2] fix(swift): a `//` inside a string is not a comment marker Graphify review on #3542, reproduced before it was fixed. The repair skipped a match when any `//` appeared earlier on the line, which is not the same question as "is this line commented out": let base = "https://api.example.com"; if let x = await f(base) { } The `//` belongs to the URL, the binding is real code, and the skip left the file unparseable, which is the exact defect this function exists to clear. A URL in Swift source is ordinary, so this was not a corner. `_swift_line_comment_start` scans the line tracking string state and returns the offset of the `//` that actually opens a comment. What it does not model is stated in its docstring rather than left implicit: raw strings, multi-line literals and interpolation. Getting one of those wrong costs nothing either way, since reading a comment as code rewrites an `await` inside a comment at equal length on a file that already failed to parse, and reading code as a comment is the behaviour being replaced. Two cells: three shapes with a `//` inside a string are repaired and keep their length, and a real line comment is still left alone in both positions it can open. Five mutations, four killed; the fifth turns `<` into `<=`, which is equivalent because the pattern starts at `if`/`while` and can never begin on the `/` the offset points at. --- graphify/extractors/engine.py | 51 +++++++++++++++++++--- tests/test_swift_await_optional_binding.py | 32 ++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 25cd03428e..5dcaf8ca63 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3158,20 +3158,57 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st ) +def _swift_line_comment_start(line: bytes) -> int: + """Offset of the `//` that opens a line comment, or `len(line)` if none. + + Quote-aware, because "is there a `//` earlier on the line" is not the same + question: `let base = "https://x"; if let y = await f(base)` has one inside + a string literal, and reading it as a comment marker skipped the repair on + a line that needed it (Graphify review on #3542). A URL in Swift source is + ordinary, so this was not a corner. + + What it does NOT model: raw strings (`#"..."#`), multi-line `\"\"\"` + literals, and interpolation. Getting one of those wrong costs nothing in + either direction. Reading a comment as code rewrites an `await` inside a + comment, which is equal-length and only reaches a file that already failed + to parse; reading code as a comment is the behaviour this replaces. + """ + inside_string = False + index = 0 + while index < len(line): + character = line[index : index + 1] + if inside_string: + if character == b"\\": + index += 2 + continue + if character == b'"': + inside_string = False + elif character == b'"': + inside_string = True + elif character == b"/" and line[index + 1 : index + 2] == b"/": + return index + index += 1 + return len(line) + + def _swift_blank_await_bindings(source: bytes) -> bytes: """`if let x = await y` -> `if let x = y`, same byte length. - Skips a match that sits behind a `//` on its own line, so a commented-out - binding keeps its text: comments are not parsed into symbols, but some of - them are read as documentation and rewriting one would be a change nobody - asked for. A block comment or a string literal holding this exact shape is - still rewritten — harmless at equal length, and this only runs on a file - that already failed to parse. + Skips a match that sits behind the `//` that opens a line comment, so a + commented-out binding keeps its text: comments are not parsed into symbols, + but some of them are read as documentation and rewriting one would be a + change nobody asked for. A block comment or a string literal holding this + exact shape is still rewritten — harmless at equal length, and this only + runs on a file that already failed to parse. """ def replace(match: "re.Match[bytes]") -> bytes: line_start = source.rfind(b"\n", 0, match.start()) + 1 - if b"//" in source[line_start:match.start()]: + line_end = source.find(b"\n", match.start()) + if line_end == -1: + line_end = len(source) + comment_start = _swift_line_comment_start(source[line_start:line_end]) + if line_start + comment_start < match.start(): return match.group(0) return match.group(1) + b" " * len(match.group(2)) diff --git a/tests/test_swift_await_optional_binding.py b/tests/test_swift_await_optional_binding.py index 36b8bd0fd3..e6f2393733 100644 --- a/tests/test_swift_await_optional_binding.py +++ b/tests/test_swift_await_optional_binding.py @@ -108,6 +108,38 @@ def test_the_repair_preserves_every_byte_offset(self): with self.subTest(source.decode()): self.assertEqual(len(_swift_blank_await_bindings(source)), len(source)) + def test_a_url_in_a_string_is_not_a_comment_marker(self): + """The Graphify review's finding, reproduced first. + + `"https://..."` puts a `//` on the line before the binding, and reading + "is there a `//` earlier" as "is this commented out" skipped the repair + on a line that needed it — so the file stayed unparseable, which is the + defect this whole function exists to clear. A URL in Swift source is + ordinary, so this was not a corner. + """ + for source in ( + b'let base = "https://api.example.com"; if let x = await f(base) {', + b'let u = "a//b"; while var r = await box.rings {', + b'let s = "said \\"hi//\\""; if let r = await p {', + ): + with self.subTest(source.decode()): + repaired = _swift_blank_await_bindings(source) + self.assertNotEqual(repaired, source) + self.assertEqual(len(repaired), len(source)) + self.assertNotIn(b"await", repaired) + + def test_a_real_line_comment_is_still_left_alone(self): + """The control for it, in both positions a comment can open: the whole + line, and after code on the same line. Losing this would rewrite a + commented-out binding that somebody is reading as documentation.""" + for untouched in ( + b"// if let r = await pending\n", + b" // guard let r = await pending else {\n", + b'let base = "x" // if let r = await pending\n', + ): + with self.subTest(untouched.decode()): + self.assertEqual(_swift_blank_await_bindings(untouched), untouched) + def test_the_repair_leaves_everything_else_alone(self): """It rewrites the binding operand and nothing else — not a `guard`, which the grammar already accepts, and not an `await` in a statement