From c747bc778bf38e7dc6ed72d149726ae257d9e09c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 20:38:22 +0900 Subject: [PATCH] Fix Rust attribute raw-string phantom imports (#1441) --- changelog.d/unreleased/1441.fixed.md | 17 +++ .../Languages/RustReferenceExtractor.cs | 124 ++++++++++++++++++ .../Indexer/References/ReferenceExtractor.cs | 3 +- .../ReferenceExtractorTests.cs | 22 ++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1441.fixed.md diff --git a/changelog.d/unreleased/1441.fixed.md b/changelog.d/unreleased/1441.fixed.md new file mode 100644 index 0000000000..82181811e2 --- /dev/null +++ b/changelog.d/unreleased/1441.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1441 +affected: + - src/CodeIndex/Indexer/References/ReferenceExtractor.cs + - src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **Rust raw-string attributes no longer emit phantom imports (#1441)** — Rust reference extraction now masks attribute bodies before scanning `use` statements, so `#[doc = r"use foo::bar;"]` no longer creates a false import/reference. + +## 日本語 + +- **Rust の raw string 属性から phantom import が生成されないようになりました (#1441)** — Rust の参照抽出は `use` 文スキャン前に属性本文をマスクするため、`#[doc = r"use foo::bar;"]` が誤った import/reference を生成しなくなりました。 diff --git a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs index 6d8b18c7a4..3de06de5f3 100644 --- a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs @@ -56,6 +56,130 @@ internal static class RustReferenceExtractor @"(?(?:(?:r#)?\w+::)*r#\w+(?:::(?:r#)?\w+)*)(?:<[^>\n]+>)?\s*\(", RegexOptions.Compiled); + public static string MaskAttributeBodies(string line) + { + var masked = default(char[]); + for (var index = 0; index < line.Length; index++) + { + if (line[index] != '#') + continue; + + var cursor = index + 1; + while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) + cursor++; + if (cursor < line.Length && line[cursor] == '!') + { + cursor++; + while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) + cursor++; + } + + if (cursor >= line.Length || line[cursor] != '[') + continue; + + masked ??= line.ToCharArray(); + var end = FindAttributeEnd(line, cursor); + ReplaceWithSpaces(masked, index, end > cursor ? end - index + 1 : line.Length - index); + index = end > cursor ? end : line.Length; + } + + return masked == null ? line : new string(masked); + } + + private static int FindAttributeEnd(string line, int openBracket) + { + var depth = 0; + for (var index = openBracket; index < line.Length; index++) + { + var c = line[index]; + if (c == 'r') + { + var rawEnd = TrySkipRawString(line, index); + if (rawEnd > index) + { + index = rawEnd; + continue; + } + } + + if (c == '"' || c == '\'') + { + index = SkipQuotedString(line, index, c); + continue; + } + + if (c == '[') + { + depth++; + continue; + } + + if (c != ']') + continue; + + depth--; + if (depth == 0) + return index; + } + + return -1; + } + + private static int TrySkipRawString(string line, int start) + { + var cursor = start + 1; + while (cursor < line.Length && line[cursor] == '#') + cursor++; + if (cursor >= line.Length || line[cursor] != '"') + return -1; + + var hashCount = cursor - start - 1; + for (var index = cursor + 1; index < line.Length; index++) + { + if (line[index] == '"' && HasHashRun(line, index + 1, hashCount)) + return index + hashCount; + } + + return line.Length - 1; + } + + private static bool HasHashRun(string line, int start, int hashCount) + { + if (start + hashCount > line.Length) + return false; + for (var offset = 0; offset < hashCount; offset++) + { + if (line[start + offset] != '#') + return false; + } + + return true; + } + + private static int SkipQuotedString(string line, int start, char quote) + { + for (var index = start + 1; index < line.Length; index++) + { + if (line[index] == '\\') + { + index++; + continue; + } + + if (line[index] == quote) + return index; + } + + return line.Length - 1; + } + + private static void ReplaceWithSpaces(char[] chars, int start, int length) + { + var end = Math.Min(chars.Length, start + length); + for (var index = start; index < end; index++) + chars[index] = ' '; + } + public static void EmitAdditionalCallReferences(string preparedLine, Action addCallLikeReference) { foreach (Match match in RawIdentifierCallRegex.Matches(preparedLine)) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index 5ffd082207..8b50be2d1b 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -1976,8 +1976,9 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) var rustEnumContainer = rustEnumCandidates != null ? FindInnermostContainer(rustEnumCandidates, lineNumber) : null; + var rustTypePositionLine = RustReferenceExtractor.MaskAttributeBodies(preparedLine); RustReferenceExtractor.EmitTypePositionReferences( - preparedLine, + rustTypePositionLine, references, seen, fileId, diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 0ffc9c4f68..fc40f52914 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -17995,6 +17995,28 @@ public void Extract_RustRawString_DoesNotLeakPhantomCallReferences() Assert.Contains(references, r => r.SymbolName == "real_call" && r.ContainerName == "caller"); } + [Fact] + public void Extract_RustAttributeRawString_DoesNotLeakPhantomUseReferences() + { + const string content = """ + #[doc = r"use baz::qux;"] + pub fn f() { + real_call(); + } + + use crate::actual::Thing; + + fn real_call() {} + """; + + var symbols = SymbolExtractor.Extract(1, "rust", content); + var references = ReferenceExtractor.Extract(1, "rust", content, symbols); + + Assert.DoesNotContain(references, r => r.SymbolName == "qux"); + Assert.Contains(references, r => r.SymbolName == "Thing" && r.ReferenceKind == "reference"); + Assert.Contains(references, r => r.SymbolName == "real_call" && r.ContainerName == "f"); + } + [Fact] public void Extract_JsTemplateLiteral_DoesNotLeakPhantomCallsButKeepsInterpolationCalls() {