Skip to content

Commit 739961b

Browse files
committed
fix(parser): multi-line /// doc comments were truncated to their last line
Found live via mcp__ci__understand() on this repo's own apply_personalization_boost: its real 12-line /// doc comment came back as just the final line ("configured to 0.0 — the common case for a session's first calls."), silently dropping the actual explanation of what the function does. Root cause: walk_symbols took only node.prev_named_sibling() as the docstring — a single tree-sitter node — but line-comment doc conventions (Rust ///, Go //, Shell/C #) parse each line as its own separate node, so anything past the immediate last line was never collected. Python (separate branch, expression_statement-based) and block-comment languages (/** */ is already one node) were unaffected. Fix: collect_doc_comment_lines walks backward through contiguous same-kind comment siblings (stopping at a blank-line gap or a different node kind) and joins them. Adjacency check needed n.end_position().row == expected_row, not + 1 — confirmed by dumping the real parse tree: a line_comment node's end_position() already lands on the following line (the grammar folds the terminating newline into the token), an off-by-one that showed up immediately as a regression in the existing single-line test before landing on the right formula. 2 new regression tests: multi-line capture, and blank-line-gap isolation (an unrelated comment above must not merge into the docstring). Verified: cargo build/clippy -D warnings/fmt --check clean, full workspace test suite green (354 ci-core + 88 ci-server). mcp__ci__diff_impact(staged=true) flagged parser.rs::find as "high risk" — confirmed via git diff it was untouched, just line-shifted by the new tests above it in the same file (known line-overlap heuristic limitation, not a real signature change).
1 parent 7cbfac0 commit 739961b

1 file changed

Lines changed: 87 additions & 5 deletions

File tree

crates/ci-core/src/indexer/parser.rs

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,39 @@ fn resolve_name_node<'a>(
190190
}
191191
}
192192

193+
/// Walks backward through contiguous same-kind comment siblings immediately
194+
/// preceding `node` — no blank-line gap between any two, nor between the
195+
/// last one and `node` itself — and joins them in source order. Line-
196+
/// comment doc conventions (Rust `///`, Go `//`, Shell/C `#`) parse each
197+
/// line as its *own* tree-sitter node, so taking only the single immediate
198+
/// `prev_named_sibling()` (the old behavior) silently captured just the
199+
/// *last* line of a multi-line doc comment and dropped the rest. Block-
200+
/// comment conventions (`/** */`) are already one node spanning every line,
201+
/// so they pass through unaffected — the loop just finds nothing above them
202+
/// to merge, same net result as before.
203+
///
204+
/// Adjacency check: a `line_comment` node's `end_position().row` already
205+
/// lands on the *following* line (tree-sitter's rust grammar folds the
206+
/// terminating newline into the token), so two nodes are immediately
207+
/// adjacent when `earlier.end_position().row == later.start_position().row`
208+
/// — no `+ 1` needed (confirmed by walking the real parse tree; an earlier
209+
/// version of this got that off by one and matched nothing).
210+
fn collect_doc_comment_lines(node: tree_sitter::Node, source: &str, doc_type: &str) -> String {
211+
let mut lines: Vec<String> = Vec::new();
212+
let mut current = node.prev_named_sibling();
213+
let mut expected_row = node.start_position().row;
214+
while let Some(n) = current {
215+
if n.kind() != doc_type || n.end_position().row != expected_row {
216+
break;
217+
}
218+
lines.push(source[n.byte_range()].trim().to_string());
219+
expected_row = n.start_position().row;
220+
current = n.prev_named_sibling();
221+
}
222+
lines.reverse();
223+
lines.join("\n")
224+
}
225+
193226
/// Recursive symbol walk tracking the enclosing class/impl so methods record
194227
/// their `class_context`.
195228
fn walk_symbols(
@@ -217,11 +250,8 @@ fn walk_symbols(
217250
let raw_doc = source[expr.byte_range()].trim();
218251
docstring = raw_doc.trim_matches(|c| c == '"' || c == '\'').to_string();
219252
}
220-
} else if let Some(prev) = node.prev_named_sibling()
221-
&& let Some(doc_type) = lc.docstring_type
222-
&& prev.kind() == doc_type
223-
{
224-
docstring = source[prev.byte_range()].trim().to_string();
253+
} else if let Some(doc_type) = lc.docstring_type {
254+
docstring = collect_doc_comment_lines(node, source, doc_type);
225255
}
226256

227257
let sig_end = source[node.start_byte()..]
@@ -1318,6 +1348,58 @@ pub fn hello(a: i32, b: i32) -> i32 {
13181348
assert_eq!(symbols[0].docstring.trim(), "/// This is a docstring");
13191349
}
13201350

1351+
/// Regression: each `///` line parses as its own tree-sitter node, so
1352+
/// taking only the single immediate `prev_named_sibling()` silently
1353+
/// captured just the *last* line of a multi-line doc comment. Reproduces
1354+
/// the exact shape discovered live: `understand()`'s output for a real
1355+
/// symbol here (`apply_personalization_boost` in common.rs) returned
1356+
/// only its doc comment's last line, dropping the actual explanation.
1357+
#[test]
1358+
fn test_rust_multiline_docstring_captures_all_lines() {
1359+
let code = r#"
1360+
/// First line of explanation.
1361+
/// Second line with more detail.
1362+
///
1363+
/// A blank `///` line inside the block must still be included.
1364+
pub fn hello(a: i32, b: i32) -> i32 {
1365+
a + b
1366+
}
1367+
"#;
1368+
let symbols = extract_symbols(code, "rust", "test.rs").unwrap();
1369+
assert_eq!(symbols.len(), 1);
1370+
let doc = &symbols[0].docstring;
1371+
assert!(doc.contains("First line of explanation."), "got: {doc:?}");
1372+
assert!(
1373+
doc.contains("Second line with more detail."),
1374+
"got: {doc:?}"
1375+
);
1376+
assert!(
1377+
doc.contains("A blank `///` line inside the block must still be included."),
1378+
"got: {doc:?}"
1379+
);
1380+
}
1381+
1382+
/// A doc comment block separated from an *unrelated* comment above it by
1383+
/// a blank line must not merge the two — only the contiguous block
1384+
/// immediately touching the function belongs to its docstring.
1385+
#[test]
1386+
fn test_rust_docstring_stops_at_blank_line_gap() {
1387+
let code = r#"
1388+
// Unrelated comment far above, not part of the docstring.
1389+
1390+
/// Actual doc comment.
1391+
pub fn hello() {}
1392+
"#;
1393+
let symbols = extract_symbols(code, "rust", "test.rs").unwrap();
1394+
assert_eq!(symbols.len(), 1);
1395+
let doc = &symbols[0].docstring;
1396+
assert!(doc.contains("Actual doc comment."), "got: {doc:?}");
1397+
assert!(
1398+
!doc.contains("Unrelated comment far above"),
1399+
"must not merge across a blank-line gap, got: {doc:?}"
1400+
);
1401+
}
1402+
13211403
fn find<'a>(symbols: &'a [ParsedSymbol], name: &str) -> &'a ParsedSymbol {
13221404
symbols
13231405
.iter()

0 commit comments

Comments
 (0)