diff --git a/src/mcp.zig b/src/mcp.zig index fe0d13b1..4d325cfa 100644 --- a/src/mcp.zig +++ b/src/mcp.zig @@ -2578,12 +2578,10 @@ fn handleCallers(alloc: std.mem.Allocator, args: *const std.json.ObjectMap, out: } } if (is_def) continue; - // A whole-word match that only ever lands inside a string literal or a - // trailing comment is a mention, not an invocation — a `("name", - // "file")` table row, a `test "…name…" {` declaration, or - // `init(); // then name() …`. Real calls sit on the code prefix of the - // line, outside the quotes, and survive both cuts. (#682) - if (!hasWholeWordMatchOutsideStrings(lineCodePrefix(r.line_text, lang), name)) continue; + // A whole-word match that only lands inside a literal or comment is a + // mention, not an invocation — a `("name", "file")` table row, a + // `test "…name…" {` declaration, or `init(); // then name() …`. (#682) + if (!hasWholeWordMatchInCode(r.line_text, name, lang)) continue; kept.append(alloc, r_idx) catch {}; } @@ -2645,33 +2643,82 @@ fn isIdentChar(c: u8) bool { c == '_'; } -/// Returns true iff `needle` occurs in `line` as a whole-word identifier -/// (non-identifier characters or line boundaries on both sides) at a position -/// that is NOT inside a string literal. -/// -/// An occurrence that only ever appears between quotes is a mention, not a -/// call: the Python table row `("renderPlainSearch", "src/explore.zig"),` and -/// the Zig declaration `test "def-first: renderPlainSearch surfaces …" {` both -/// name the symbol without invoking it, yet passed every other filter here. -/// -/// Deliberately line-local and pragmatic, in the spirit of `isCommentOrBlank`: -/// - tracks '…' and "…" spans, honouring backslash escapes -/// - a quote with no closing partner on the line (an apostrophe in a -/// trailing comment, a Rust lifetime `&'a`, an open multi-line literal) is -/// treated as ordinary code, so it can never swallow the rest of the line -/// - backticks are not treated as quotes: Go raw strings and JS template -/// literals routinely wrap real calls (`${render()}`) -fn hasWholeWordMatchOutsideStrings(line: []const u8, needle: []const u8) bool { +/// Returns true when `needle` occurs as a whole-word identifier in code rather +/// than in a string, raw/template literal, or comment. This is deliberately a +/// line-local lexical scan: it handles complete spans on the current line and +/// conservatively treats an unclosed block/raw span as consuming the remainder. +fn hasWholeWordMatchInCode(line: []const u8, needle: []const u8, language: explore_mod.Language) bool { + return hasWholeWordMatchInCodeDepth(line, needle, language, 0); +} + +const max_template_nesting = 16; + +fn hasWholeWordMatchInCodeDepth(line: []const u8, needle: []const u8, language: explore_mod.Language, template_depth: usize) bool { if (needle.len == 0 or line.len < needle.len) return false; + const markers = lineCommentMarkers(language); var i: usize = 0; while (i < line.len) { + if (language == .rust) { + if (rustRawLiteralAt(line, i)) |raw| { + if (raw.end) |end| { + i = end; + continue; + } + return false; + } + } if (line[i] == '"' or line[i] == '\'') { if (stringLiteralEnd(line, i)) |end| { i = end + 1; continue; } } - // `i` is a code byte — try to anchor a whole-word match on it. + if (line[i] == '`' and hasBacktickLiterals(language)) { + if (language == .r) { + if (stringLiteralEnd(line, i)) |end| { + if (std.mem.eql(u8, line[i + 1 .. end], needle)) return true; + i = end + 1; + continue; + } + return false; + } + if (hasTemplateLiterals(language)) { + if (template_depth >= max_template_nesting) { + if (stringLiteralEnd(line, i)) |end| { + i = end + 1; + continue; + } + return false; + } + const template = scanTemplateLiteral(line, i, needle, language, template_depth); + if (template.matched) return true; + if (template.end) |end| { + i = end; + continue; + } + return false; + } + if (stringLiteralEnd(line, i)) |end| { + i = end + 1; + continue; + } + return false; + } + if (hasTemplateLiterals(language) and line[i] == '/') { + if (javascriptRegexEndAt(line, i)) |end| { + i = end; + continue; + } + } + if (blockCommentAt(line, i, language)) |block| { + if (block.end) |end| { + i = end; + continue; + } + return false; + } + if (isLineCommentAt(line, i, language, markers)) return false; + if (line.len - i >= needle.len and std.mem.eql(u8, line[i..][0..needle.len], needle)) { const before_ok = i == 0 or !isIdentChar(line[i - 1]); const after = i + needle.len; @@ -2683,45 +2730,239 @@ fn hasWholeWordMatchOutsideStrings(line: []const u8, needle: []const u8) bool { return false; } -/// The slice of `line` before its first line-comment marker outside string -/// literals, per `language` — the part of the line that can actually contain a -/// call. Lines with no marker, and languages without a line-comment syntax -/// (OCaml's `(* *)` is block-only), come back whole. (#682) -/// -/// Same line-local scanning discipline as `hasWholeWordMatchOutsideStrings`: -/// quoted spans are skipped (so `"https://…"` never truncates), an unclosed -/// quote is ordinary code. Mid-line block-comment openers are left alone — a -/// false negative re-admits a mention; truncating at `/*` could drop the real -/// call in `foo(); /* … */ bar()`. -fn lineCodePrefix(line: []const u8, language: explore_mod.Language) []const u8 { - const markers: []const []const u8 = switch (language) { - .zig, .rust, .go_lang, .javascript, .typescript, .c, .cpp, .dart, .java, .kotlin, .mlir, .tablegen, .rescript, .svelte, .vue, .astro => &.{"//"}, +fn lineCommentMarkers(language: explore_mod.Language) []const []const u8 { + return switch (language) { + .zig, .rust, .go_lang, .javascript, .typescript, .c, .cpp, .dart, .java, .kotlin, .swift, .mlir, .tablegen, .rescript, .svelte, .vue, .astro => &.{"//"}, + .php => &.{ "//", "#" }, .python, .ruby, .r, .shell => &.{"#"}, .hcl => &.{ "#", "//" }, .sql => &.{"--"}, .fortran => &.{"!"}, .llvm_ir => &.{";"}, - else => return line, + else => &.{}, }; - var i: usize = 0; +} + +fn isLineCommentAt(line: []const u8, start: usize, language: explore_mod.Language, markers: []const []const u8) bool { + for (markers) |marker| { + if (!std.mem.startsWith(u8, line[start..], marker)) continue; + // `#[` starts a PHP 8 attribute, not a hash comment. + if (language == .php and marker[0] == '#' and start + 1 < line.len and line[start + 1] == '[') return false; + // Shell: `#` opens a comment only when it begins a word. Operators are + // word boundaries too; `${#var}` and `$#` remain live syntax. + if (language == .shell and !isShellCommentStart(line, start)) return false; + return true; + } + return false; +} + +const LineSpan = struct { end: ?usize }; + +fn rustRawLiteralAt(line: []const u8, start: usize) ?LineSpan { + if (start != 0 and isIdentChar(line[start - 1])) return null; + var i = start; + if (line[i] == 'b') { + if (i + 1 >= line.len or line[i + 1] != 'r') return null; + i += 2; + } else if (line[i] == 'r') { + i += 1; + } else return null; + + var hashes: usize = 0; + while (i < line.len and line[i] == '#') : (i += 1) hashes += 1; + if (i >= line.len or line[i] != '"') return null; + + i += 1; + while (i < line.len) : (i += 1) { + if (line[i] != '"' or i + 1 + hashes > line.len) continue; + var matched: usize = 0; + while (matched < hashes and line[i + 1 + matched] == '#') : (matched += 1) {} + if (matched == hashes) return .{ .end = i + 1 + hashes }; + } + return .{ .end = null }; +} + +fn hasBacktickLiterals(language: explore_mod.Language) bool { + return switch (language) { + .go_lang, .javascript, .typescript, .php, .ruby, .r, .svelte, .vue, .astro => true, + else => false, + }; +} + +fn hasTemplateLiterals(language: explore_mod.Language) bool { + return switch (language) { + .javascript, .typescript, .svelte, .vue, .astro => true, + else => false, + }; +} + +const TemplateScan = struct { + end: ?usize, + matched: bool, +}; + +fn scanTemplateLiteral(line: []const u8, open: usize, needle: []const u8, language: explore_mod.Language, template_depth: usize) TemplateScan { + var i = open + 1; while (i < line.len) { - if (line[i] == '"' or line[i] == '\'') { + if (line[i] == '\\') { + i += if (line.len - i > 1) 2 else 1; + continue; + } + if (line[i] == '`') return .{ .end = i + 1, .matched = false }; + if (line[i] == '$' and i + 1 < line.len and line[i + 1] == '{') { + const expression_start = i + 2; + const expression_end = templateExpressionEnd(line, expression_start, language); + const end = expression_end orelse line.len; + if (hasWholeWordMatchInCodeDepth(line[expression_start..end], needle, language, template_depth + 1)) { + return .{ .end = expression_end, .matched = true }; + } + if (expression_end) |close| { + i = close + 1; + continue; + } + return .{ .end = null, .matched = false }; + } + i += 1; + } + return .{ .end = null, .matched = false }; +} + +fn javascriptRegexEndAt(line: []const u8, start: usize) ?usize { + if (start + 1 >= line.len or line[start] != '/' or line[start + 1] == '/' or line[start + 1] == '*') return null; + + var previous = start; + while (previous > 0 and (line[previous - 1] == ' ' or line[previous - 1] == '\t')) previous -= 1; + if (!javascriptRegexCanFollow(line, previous)) return null; + + var in_class = false; + var i = start + 1; + while (i < line.len) { + if (line[i] == '\\') { + i += if (line.len - i > 1) 2 else 1; + continue; + } + if (line[i] == '[') { + in_class = true; + } else if (line[i] == ']') { + in_class = false; + } else if (line[i] == '/' and !in_class) { + i += 1; + while (i < line.len and std.ascii.isAlphabetic(line[i])) i += 1; + return i; + } + i += 1; + } + return null; +} + +fn javascriptRegexCanFollow(line: []const u8, end: usize) bool { + if (end == 0) return true; + if (std.mem.indexOfScalar(u8, "([{:;,=!?&|+-*%^~<>", line[end - 1]) != null) return true; + + var start = end; + while (start > 0 and (std.ascii.isAlphanumeric(line[start - 1]) or line[start - 1] == '_')) start -= 1; + const word = line[start..end]; + const keywords = [_][]const u8{ + "await", "case", "delete", "do", "else", "in", "instanceof", + "new", "of", "return", "throw", "typeof", "void", "yield", + }; + for (keywords) |keyword| { + if (std.mem.eql(u8, word, keyword)) return true; + } + return false; +} + +fn templateExpressionEnd(line: []const u8, start: usize, language: explore_mod.Language) ?usize { + const markers = lineCommentMarkers(language); + var depth: usize = 1; + var i = start; + while (i < line.len) { + if (line[i] == '"' or line[i] == '\'' or line[i] == '`') { if (stringLiteralEnd(line, i)) |end| { i = end + 1; continue; } + return null; } - for (markers) |m| { - if (std.mem.startsWith(u8, line[i..], m)) { - // Shell: `#` only opens a comment at the start of a word — - // `${#var}` and `$#` are live syntax, not comments. - if (language == .shell and i != 0 and line[i - 1] != ' ' and line[i - 1] != '\t') break; - return line[0..i]; + if (line[i] == '/') { + if (javascriptRegexEndAt(line, i)) |end| { + i = end; + continue; + } + } + if (blockCommentAt(line, i, language)) |block| { + if (block.end) |end| { + i = end; + continue; } + return null; + } + if (isLineCommentAt(line, i, language, markers)) return null; + if (line[i] == '{') { + depth += 1; + } else if (line[i] == '}') { + depth -= 1; + if (depth == 0) return i; + } + i += 1; + } + return null; +} + +fn blockCommentAt(line: []const u8, start: usize, language: explore_mod.Language) ?LineSpan { + if (language == .ocaml and std.mem.startsWith(u8, line[start..], "(*")) { + return .{ .end = delimitedCommentEnd(line, start, "(*", "*)", true) }; + } + const slash_block = switch (language) { + .rust, .go_lang, .javascript, .typescript, .c, .cpp, .php, .hcl, .dart, .java, .kotlin, .swift, .svelte, .vue, .astro, .sql, .mlir, .tablegen, .rescript => true, + else => false, + }; + if (slash_block and std.mem.startsWith(u8, line[start..], "/*")) { + return .{ .end = delimitedCommentEnd(line, start, "/*", "*/", language == .rust or language == .swift) }; + } + const html_block = switch (language) { + .svelte, .vue, .astro => true, + else => false, + }; + if (html_block and std.mem.startsWith(u8, line[start..], "", false) }; + } + return null; +} + +fn delimitedCommentEnd(line: []const u8, start: usize, open: []const u8, close: []const u8, nested: bool) ?usize { + var depth: usize = 1; + var i = start + open.len; + while (i < line.len) { + if (nested and std.mem.startsWith(u8, line[i..], open)) { + depth += 1; + i += open.len; + continue; + } + if (std.mem.startsWith(u8, line[i..], close)) { + depth -= 1; + i += close.len; + if (depth == 0) return i; + continue; } i += 1; } - return line; + return null; +} + +fn isShellCommentStart(line: []const u8, hash: usize) bool { + if (hash == 0) return true; + const boundary = hash - 1; + const c = line[boundary]; + if (c != ' ' and c != '\t' and std.mem.indexOfScalar(u8, "|&;()<>", c) == null) return false; + + var slash_count: usize = 0; + var i = boundary; + while (i > 0 and line[i - 1] == '\\') { + slash_count += 1; + i -= 1; + } + return slash_count % 2 == 0; } /// Index of the quote closing the literal opened at `open`, or null when the diff --git a/src/test_mcp.zig b/src/test_mcp.zig index 536684e9..0356f3f5 100644 --- a/src/test_mcp.zig +++ b/src/test_mcp.zig @@ -3821,6 +3821,104 @@ test "issue-682: codedb_callers keeps a call after a string containing the comme try testing.expect(std.mem.indexOf(u8, out.items, "url.zig:2") != null); } +test "issue-682: codedb_callers recognizes PHP and Swift trailing comments" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(testing.allocator); + + try renderCallersFixture(arena.allocator(), &.{ + .{ "trail.php", "