From 1fa7235b402b9fb3fd708dff25930bda21f44c2d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 14 Jun 2026 01:29:30 +0900 Subject: [PATCH 01/20] Add Sass and Stylus extraction support --- changelog.d/unreleased/3534.added.md | 19 +++++ .../Languages/CssReferenceExtractor.cs | 83 +++++++++++++++++++ .../References/ReferenceExtractor.Core.cs | 4 + .../References/ReferenceExtractor.State.cs | 2 +- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 6 +- .../Indexer/Symbols/SymbolExtractor.cs | 23 +++++ tests/CodeIndex.Tests/FileIndexerTests.cs | 4 +- .../QueryCommandRunnerTests.cs | 24 ++++-- .../ReferenceExtractorCssTests.cs | 56 +++++++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 56 +++++++++++++ 10 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 changelog.d/unreleased/3534.added.md diff --git a/changelog.d/unreleased/3534.added.md b/changelog.d/unreleased/3534.added.md new file mode 100644 index 0000000000..cac103a07a --- /dev/null +++ b/changelog.d/unreleased/3534.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 3534 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs + - src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs +--- + +## English + +- **Sass and Stylus now expose preprocessor navigation anchors (#3534)** — `.sass` and `.styl` files now surface conservative symbol and reference records for imports, variables, mixins, functions, selectors, and related call sites. + +## 日本語 + +- **Sass と Stylus がプリプロセッサのナビゲーションアンカーを公開するようになりました (#3534)** — `.sass` と `.styl` ファイルで、import、変数、mixin、関数、セレクタ、および関連する呼び出し箇所の保守的な symbol / reference record を出力するようになりました。 diff --git a/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs index cdd6162a2c..0b6d0d81ad 100644 --- a/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs @@ -20,6 +20,18 @@ internal static class CssReferenceExtractor @"@include\s+(?[A-Za-z_][\w-]*)", RegexOptions.Compiled); + private static readonly Regex SassIndentedMixinReferenceRegex = new( + @"(?[A-Za-z_][\w-]*)", + RegexOptions.Compiled); + + private static readonly Regex StylusVariableReferenceRegex = new( + @"(?[A-Za-z_][\w-]*)", + RegexOptions.Compiled); + + private static readonly Regex StylusBareFunctionReferenceRegex = new( + @"(?[A-Za-z_][\w-]*)\s*\(", + RegexOptions.Compiled); + private static readonly Regex CssCustomPropertyReferenceRegex = new(@"\bvar\(\s*--(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex CssAnimationNameValueRegex = new(@"\banimation-name\s*:\s*(?[^;{}]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex CssAnimationShorthandValueRegex = new(@"\banimation\s*:\s*(?[^;{}]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -148,6 +160,77 @@ public static void EmitScss( EmitMatches(pattern, preparedLine, context, lineNumber, references, seen, fileId, definitionNames: null, container); } + public static void EmitSass( + string preparedLine, + List references, + HashSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + EmitScss(preparedLine, references, seen, fileId, context, lineNumber, container); + + foreach (Match match in BoundedRegex.EnumerateMatches(SassIndentedMixinReferenceRegex, preparedLine)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + match, + "call", + context, + lineNumber, + container); + } + } + + public static void EmitStylus( + string preparedLine, + List references, + HashSet seen, + long fileId, + string context, + int lineNumber, + HashSet? definitionNames, + SymbolRecord? container) + { + foreach (Match match in BoundedRegex.EnumerateMatches(StylusVariableReferenceRegex, preparedLine)) + { + if (ShouldSkipScssVariableReference(preparedLine, match.Groups["name"].Index)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + match, + "call", + context, + lineNumber, + container); + } + + foreach (Match match in BoundedRegex.EnumerateMatches(StylusBareFunctionReferenceRegex, preparedLine)) + { + var name = match.Groups["name"].Value; + if (name is "url" or "var" or "calc" or "rgb" or "rgba" or "hsl" or "hsla") + continue; + if (definitionNames != null && definitionNames.Contains(name) && match.Groups["name"].Index == 0) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + match, + "call", + context, + lineNumber, + container); + } + } + private static void EmitMatches( ReferencePattern pattern, string preparedLine, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs index d02fddc743..8f1e45cca9 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs @@ -943,6 +943,10 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) definitionNames, container); } + else if (language == "sass") + CssReferenceExtractor.EmitSass(preparedLine, references, seen, fileId, context, lineNumber, container); + else if (language == "stylus") + CssReferenceExtractor.EmitStylus(preparedLine, references, seen, fileId, context, lineNumber, definitionNames, container); if (language == "terraform") { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs index 6e59f6cad3..dbc7485e0f 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs @@ -31,7 +31,7 @@ internal sealed class CSharpWhereConstraintState "assembly", "r", "powershell", "shell", "haskell", "gradle", "terraform", "protobuf", "dockerfile", "makefile", "cmake", "justfile", "msbuild", - "zig", "css", "graphql", "html", "markdown", "fortran", "pascal", "objc", "smalltalk" + "zig", "css", "sass", "stylus", "graphql", "html", "markdown", "fortran", "pascal", "objc", "smalltalk" ]; private static readonly IReadOnlyDictionary Extractors = diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 67458fe493..e604133f66 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -210,10 +210,10 @@ private sealed class ProjectMarkerFingerprintTraversalState [".less"] = "css", // Less preprocessor / Less プリプロセッサ [".pcss"] = "css", // PostCSS / PostCSS // Sass indented syntax / Stylus use indentation instead of braces, so they live in - // separate search-only buckets — the CSS symbol extractor's brace-based patterns do - // not apply, but exact-name search still works. + // separate buckets with conservative line-level extractors rather than the CSS + // brace-scoped extractor. // Sass インデント構文と Stylus は波括弧ではなくインデントで構造化するため、 - // CSS のシンボル抽出(波括弧ベース)は使わず、検索用の別バケットに分ける。 + // CSS の波括弧スコープ抽出ではなく、保守的な行単位抽出を持つ別バケットに分ける。 [".sass"] = "sass", [".styl"] = "stylus", [".vue"] = "vue", diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 2d79542bb4..a580e83b72 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -2029,6 +2029,29 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // SCSS placeholder selector / SCSS プレースホルダーセレクタ new("class", new Regex(@"^\s*(?%[\w-]+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), ], + ["sass"] = + [ + // Sass indented syntax has no braces, so keep these as line-level anchors. + // Sass インデント構文は波括弧を持たないため、行単位のアンカーとして扱う。 + new("import", new Regex(@"^\s*@(?:import|use|forward)\s+(?.+?)(?:\s*!default)?\s*$", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:@mixin\s+|=)(?[\w-]+)", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*@function\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*\$(?[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?[.#%][\w-]+)(?=[\s\.,:>+~\[]|$)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))", RegexOptions.Compiled), BodyStyle.None), + ], + ["stylus"] = + [ + // Stylus supports optional punctuation, so only capture conservative declaration shapes. + // Stylus は句読点を省略できるため、保守的な宣言形だけを捕捉する。 + new("import", new Regex(@"^\s*@(?:import|require|use)\s+(?.+?)\s*$", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?[A-Za-z_][\w-]*)\s*\([^)\r\n]*\)\s*$", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*\$?(?[A-Za-z_][\w-]*)\s*(?:=|:=)\s*", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?[.#%][\w-]+)(?=[\s\.,:>+~\[]|$)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))", RegexOptions.Compiled), BodyStyle.None), + ], // HTML does not use the regex pattern loop — it needs true tag-structure // awareness (attribute enumeration, quoted-value handling, custom-element // detection) that regex alone can't express without losing outer-tag diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 1e1846a134..4d12e7f8c4 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -1180,8 +1180,8 @@ public void GetLanguageExtensions_ExposesPrefixAndFileNameVariants() Assert.Equal("makefile", map["Makefile."]); Assert.Equal("makefile", map["GNUmakefile."]); - // Sass / Stylus are distinct buckets now (indented syntax is incompatible with the CSS extractor). - // Sass / Stylus は別バケット(インデント構文が CSS のシンボル抽出と非互換のため)。 + // Sass / Stylus are distinct buckets with indentation-aware conservative extraction. + // Sass / Stylus はインデント構文向けの保守的な抽出を持つ別バケット。 Assert.Equal("sass", map[".sass"]); Assert.Equal("stylus", map[".styl"]); Assert.Equal("css", map[".scss"]); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index f78e7b3a4f..02b3cc75ff 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2267,14 +2267,12 @@ public void RunLanguages_JsonListsCSharpRazorAliases() [Fact] public void RunLanguages_Json_SearchOnlyBucketsAdvertiseZeroSymbolAndGraphSupport() { - // Search-only languages that intentionally live outside the Python / CSS extractors - // (Cython .pyx/.pxd, Sass .sass, Stylus .styl, and the newly added extension-only - // languages) must advertise + // Search-only languages that intentionally live outside richer extractors + // (Cython .pyx/.pxd and the newly added extension-only languages) must advertise // symbol_extraction=false / graph_queries=false so AI clients can tell the difference // between "indexed with symbols" and "indexed for search only". - // 意図的に Python / CSS 抽出器の対象外になっている search-only 言語 - // (Cython の .pyx/.pxd、Sass の .sass、Stylus の .styl、そして新規追加の - // 拡張子ベース言語)は、 + // 意図的に richer な抽出器の対象外になっている search-only 言語 + // (Cython の .pyx/.pxd と新規追加の拡張子ベース言語)は、 // symbol_extraction=false / graph_queries=false で広告しなければならない。 // こうしないと、AI クライアントが「シンボル付きインデックス」と「検索のみインデックス」を区別できない。 var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages(["--json"], _jsonOptions)); @@ -2285,7 +2283,7 @@ public void RunLanguages_Json_SearchOnlyBucketsAdvertiseZeroSymbolAndGraphSuppor var languages = document.RootElement.GetProperty("languages").EnumerateArray() .ToDictionary(entry => entry.GetProperty("lang").GetString()!, entry => entry); - foreach (var searchOnly in new[] { "cython", "sass", "stylus" }) + foreach (var searchOnly in new[] { "cython" }) { Assert.True(languages.ContainsKey(searchOnly), $"expected '{searchOnly}' to be listed"); var entry = languages[searchOnly]; @@ -2319,6 +2317,18 @@ public void RunLanguages_Json_SearchOnlyBucketsAdvertiseZeroSymbolAndGraphSuppor Assert.True(languages["json"].GetProperty("symbol_extraction").GetBoolean(), "json must advertise symbol_extraction=true"); + foreach (var stylesheetPreprocessor in new[] { "sass", "stylus" }) + { + Assert.True(languages.ContainsKey(stylesheetPreprocessor), $"expected '{stylesheetPreprocessor}' to be listed"); + Assert.True(languages[stylesheetPreprocessor].GetProperty("symbol_extraction").GetBoolean(), + $"{stylesheetPreprocessor} must advertise symbol_extraction=true"); + Assert.True(languages[stylesheetPreprocessor].GetProperty("reference_extraction").GetBoolean(), + $"{stylesheetPreprocessor} must advertise reference_extraction=true"); + Assert.True(languages[stylesheetPreprocessor].GetProperty("graph_queries").GetBoolean(), + $"{stylesheetPreprocessor} must advertise graph_queries=true"); + Assert.Empty(languages[stylesheetPreprocessor].GetProperty("capability_gaps").EnumerateArray()); + } + Assert.True(languages.ContainsKey("perl"), "expected 'perl' to be listed"); Assert.True(languages["perl"].GetProperty("symbol_extraction").GetBoolean(), "perl must advertise symbol_extraction=true"); diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCssTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCssTests.cs index 2df2050e97..5a240c81a2 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCssTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCssTests.cs @@ -412,4 +412,60 @@ @mixin elevated($depth) { reference.SymbolName == "elevated" && reference.ReferenceKind == "call")); } + + [Fact] + public void Extract_Sass_IndentedVariablesMixinsAndImports_AreReferenced() + { + const string content = """ + @use "theme" + $primary: #3366cc + $spacing-base: 8px + + =rounded($radius) + border-radius: $radius + + .button + color: $primary + padding: $spacing-base * 2 + +rounded(4px) + """; + + var symbols = SymbolExtractor.Extract(1, "sass", content); + var references = ReferenceExtractor.Extract(1, "sass", content, symbols); + + Assert.Single(references.Where(reference => + reference.SymbolName == "primary" + && reference.ReferenceKind == "call")); + Assert.Single(references.Where(reference => + reference.SymbolName == "spacing-base" + && reference.ReferenceKind == "call")); + Assert.Single(references.Where(reference => + reference.SymbolName == "rounded" + && reference.ReferenceKind == "call")); + } + + [Fact] + public void Extract_Stylus_VariablesAndFunctionCalls_AreReferenced() + { + const string content = """ + primary = #3366cc + + rounded(radius) + border-radius radius + + .button + color $primary + rounded(4px) + """; + + var symbols = SymbolExtractor.Extract(1, "stylus", content); + var references = ReferenceExtractor.Extract(1, "stylus", content, symbols); + + Assert.Single(references.Where(reference => + reference.SymbolName == "primary" + && reference.ReferenceKind == "call")); + Assert.Single(references.Where(reference => + reference.SymbolName == "rounded" + && reference.ReferenceKind == "call")); + } } diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 72467a3900..9f5fc9b955 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -329,10 +329,12 @@ public void Extract_AllPatternLanguages_IsDeterministicUnderParallelCalls() ["racket"] = "(define (run x) x)\n", ["ruby"] = "class Service\n def run\n 1\n end\nend\n", ["rust"] = "pub struct Service;\npub fn run() -> i32 { 1 }\n", + ["sass"] = "$primary: #3366cc\n@mixin rounded($radius)\n.button\n +rounded(4px)\n", ["scala"] = "class Service {\n def run(): Int = 1\n}\n", ["shell"] = "run() {\n echo ok\n}\n", ["smalltalk"] = "Object subclass: #Service\nService >> run\n ^1\n", ["sql"] = "CREATE TABLE users (id int);\nCREATE PROCEDURE run AS SELECT 1;\n", + ["stylus"] = "primary = #3366cc\nrounded(radius)\n border-radius radius\n.button\n rounded(4px)\n", ["svelte"] = "\n", ["swift"] = "class Service { func run() -> Int { 1 } }\n", ["terraform"] = "resource \"local_file\" \"demo\" {\n filename = \"demo.txt\"\n}\n", @@ -14959,6 +14961,60 @@ public void Extract_CSS_PreservesLiteralSelectorNames() Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "#root"); } + [Fact] + public void Extract_Sass_CapturesIndentedPreprocessorSymbols() + { + var content = """ + @use "theme" + $primary: #3366cc + + =rounded($radius) + border-radius: $radius + + @function spacing($step) + @return $step * 4px + + %button-base + padding: spacing(2) + + .button + color: $primary + +rounded(4px) + """; + + var symbols = SymbolExtractor.Extract(1, "sass", content); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "\"theme\""); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "primary"); + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "rounded"); + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "spacing"); + Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "%button-base"); + Assert.Contains(symbols, s => s.Kind == "class" && s.Name == ".button"); + } + + [Fact] + public void Extract_Stylus_CapturesIndentedPreprocessorSymbols() + { + var content = """ + @require "theme" + primary = #3366cc + + rounded(radius) + border-radius radius + + .button + color primary + rounded(4px) + """; + + var symbols = SymbolExtractor.Extract(1, "stylus", content); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "\"theme\""); + Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "primary"); + Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "rounded"); + Assert.Contains(symbols, s => s.Kind == "class" && s.Name == ".button"); + } + [Fact] public void Extract_PowerShell_DetectsSymbols() { From 41da7f609cf5ae9392a824ced17915f9ebe2f1c6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 14 Jun 2026 02:11:38 +0900 Subject: [PATCH 02/20] Add XAML UI reference extraction --- changelog.d/unreleased/3535.added.md | 18 ++ .../Languages/XamlReferenceExtractor.cs | 304 ++++++++++++++++++ .../References/ReferenceExtractor.Core.cs | 7 + .../References/ReferenceExtractor.State.cs | 2 +- .../Indexer/Symbols/SymbolExtractor.cs | 2 +- .../QueryCommandRunnerTests.cs | 7 + .../ReferenceExtractorTests.cs | 78 +++++ 7 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3535.added.md create mode 100644 src/CodeIndex/Indexer/References/Languages/XamlReferenceExtractor.cs diff --git a/changelog.d/unreleased/3535.added.md b/changelog.d/unreleased/3535.added.md new file mode 100644 index 0000000000..63f09ea19e --- /dev/null +++ b/changelog.d/unreleased/3535.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3535 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs + - src/CodeIndex/Indexer/References/Languages/XamlReferenceExtractor.cs +--- + +## English + +- **XAML and AXAML now expose UI navigation references (#3535)** — XML-backed XAML files now surface conservative type, resource, binding, element, and event-handler references for WPF and Avalonia navigation. + +## 日本語 + +- **XAML と AXAML が UI ナビゲーション参照を公開するようになりました (#3535)** — XML ベースの XAML ファイルで、WPF と Avalonia の型、リソース、バインディング、要素、イベントハンドラへの保守的な reference record を出力するようになりました。 diff --git a/src/CodeIndex/Indexer/References/Languages/XamlReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/XamlReferenceExtractor.cs new file mode 100644 index 0000000000..d83d388c86 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/XamlReferenceExtractor.cs @@ -0,0 +1,304 @@ +using System.Text; +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static class XamlReferenceExtractor +{ + private static readonly Regex XamlTypeAttributeRegex = new( + @"\b(?x:Class|x:DataType|TargetType|DataType)\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex XamlTypeArgumentsRegex = new( + @"\bx:TypeArguments\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex XamlResourceReferenceRegex = new( + @"\{(?:StaticResource|StaticResourceExtension|DynamicResource|DynamicResourceExtension)\b(?(?:[^{}]|{[^{}]*})*)\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex XamlReferenceRegex = new( + @"\{x:Reference(?:Extension)?\b(?(?:[^{}]|{[^{}]*})*)\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex XamlBindingRegex = new( + @"\{(?Binding|x:Bind|TemplateBinding|CompiledBinding|ReflectionBinding)\b(?(?:[^{}]|{[^{}]*})*)\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex XamlEventHandlerRegex = new( + @"\b(?:Clicked|Tapped|Loaded|Unloaded|SelectionChanged|TextChanged|CheckedChanged|Unchecked|SelectedIndexChanged|PointerPressed|PointerReleased|PointerEntered|PointerExited|Drop|DragOver|Completed|Appearing|Disappearing|NavigatedTo|NavigatedFrom|SizeChanged)\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static bool IsXaml(string[] lines) + { + foreach (var line in lines) + { + if (line.IndexOf("xmlns:x=", StringComparison.OrdinalIgnoreCase) < 0) + continue; + if (line.IndexOf("schemas.microsoft.com/winfx/2006/xaml", StringComparison.OrdinalIgnoreCase) >= 0 + || line.IndexOf("github.com/avaloniaui", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + + return false; + } + + public static string StripXmlComments(string line, ref bool inComment) + { + var builder = new StringBuilder(line.Length); + var index = 0; + + while (index < line.Length) + { + if (inComment) + { + var commentEnd = line.IndexOf("-->", index, StringComparison.Ordinal); + if (commentEnd < 0) + { + builder.Append(' ', line.Length - index); + return builder.ToString(); + } + + builder.Append(' ', commentEnd + 3 - index); + index = commentEnd + 3; + inComment = false; + continue; + } + + var commentStart = line.IndexOf("", commentStart + 4, StringComparison.Ordinal); + if (sameLineCommentEnd < 0) + { + builder.Append(' ', line.Length - commentStart); + inComment = true; + return builder.ToString(); + } + + builder.Append(' ', sameLineCommentEnd + 3 - commentStart); + index = sameLineCommentEnd + 3; + } + + return builder.ToString(); + } + + public static void Emit( + string originalLine, + string context, + int lineNumber, + List references, + HashSet seen, + long fileId, + SymbolRecord? container) + { + if (originalLine.Length == 0) + return; + + foreach (Match match in XamlTypeAttributeRegex.Matches(originalLine)) + { + foreach (var name in NormalizeXamlTypeValues(match.Groups["value"].Value)) + { + AddReference(references, seen, fileId, name, match.Groups["value"].Index, "type_reference", context, lineNumber, container); + } + } + + foreach (Match match in XamlTypeArgumentsRegex.Matches(originalLine)) + { + foreach (var name in NormalizeXamlTypeArguments(match.Groups["value"].Value)) + { + AddReference(references, seen, fileId, name, match.Groups["value"].Index, "type_reference", context, lineNumber, container); + } + } + + foreach (Match match in XamlResourceReferenceRegex.Matches(originalLine)) + { + var value = NormalizeNamedMarkupReference(match.Groups["content"].Value); + if (value.Length > 0) + AddReference(references, seen, fileId, value, match.Groups["content"].Index, "reference", context, lineNumber, container); + } + + foreach (Match match in XamlReferenceRegex.Matches(originalLine)) + { + var value = NormalizeNamedMarkupReference(match.Groups["content"].Value); + if (value.Length > 0) + AddReference(references, seen, fileId, value, match.Groups["content"].Index, "reference", context, lineNumber, container); + } + + foreach (Match match in XamlBindingRegex.Matches(originalLine)) + { + foreach (var name in NormalizeBindingReferences(match.Groups["kind"].Value, match.Groups["content"].Value)) + { + AddReference(references, seen, fileId, name, match.Groups["content"].Index, "reference", context, lineNumber, container); + } + } + + foreach (Match match in XamlEventHandlerRegex.Matches(originalLine)) + { + var value = match.Groups["value"].Value.Trim(); + if (value.Length > 0) + AddReference(references, seen, fileId, value, match.Groups["value"].Index, "call", context, lineNumber, container); + } + } + + private static void AddReference( + List references, + HashSet seen, + long fileId, + string name, + int nameIndex, + string kind, + string context, + int lineNumber, + SymbolRecord? container) + => ReferenceExtractor.AddReference(references, seen, fileId, name, nameIndex, kind, context, lineNumber, container, "xml"); + + private static IEnumerable NormalizeXamlTypeValues(string value) + { + var normalized = NormalizeXamlMarkupArgument(value); + if (normalized.Length > 0) + yield return normalized; + } + + private static IEnumerable NormalizeXamlTypeArguments(string value) + { + foreach (var segment in value.Split(new[] { ',', '(', ')' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var normalized = NormalizeXamlMarkupArgument(segment); + if (normalized.Length > 0) + yield return normalized; + } + } + + private static string NormalizeNamedMarkupReference(string content) + { + foreach (var argument in SplitMarkupArguments(content)) + { + var normalized = NormalizeXamlMarkupArgument(argument); + if (normalized.Length > 0) + return normalized; + } + + return ""; + } + + private static IEnumerable NormalizeBindingReferences(string kind, string content) + { + foreach (var argument in SplitMarkupArguments(content)) + { + var trimmed = argument.Trim(); + if (trimmed.Length == 0) + continue; + + var equalsIndex = trimmed.IndexOf('='); + var key = equalsIndex >= 0 ? trimmed[..equalsIndex].Trim() : ""; + var value = equalsIndex >= 0 ? trimmed[(equalsIndex + 1)..].Trim() : trimmed; + if (key.Equals("Source", StringComparison.OrdinalIgnoreCase) + || key.Equals("Converter", StringComparison.OrdinalIgnoreCase) + || key.Equals("ConverterParameter", StringComparison.OrdinalIgnoreCase)) + continue; + + if (key.Length == 0 + || key.Equals("Path", StringComparison.OrdinalIgnoreCase) + || key.Equals("ElementName", StringComparison.OrdinalIgnoreCase) + || kind.Equals("x:Bind", StringComparison.OrdinalIgnoreCase) + || kind.Equals("TemplateBinding", StringComparison.OrdinalIgnoreCase) + || kind.Equals("CompiledBinding", StringComparison.OrdinalIgnoreCase) + || kind.Equals("ReflectionBinding", StringComparison.OrdinalIgnoreCase)) + { + var normalized = NormalizeBindingPath(value); + if (normalized.Length > 0) + yield return normalized; + } + } + } + + private static string NormalizeBindingPath(string value) + { + var normalized = NormalizeXamlMarkupArgument(value); + if (normalized.Length == 0) + return ""; + + var lastDot = normalized.LastIndexOf('.'); + if (lastDot >= 0 && lastDot + 1 < normalized.Length) + normalized = normalized[(lastDot + 1)..]; + + return normalized.Trim(); + } + + private static string NormalizeXamlMarkupArgument(string value) + { + value = value.Trim().Trim('"', '\''); + if (value.Length == 0) + return ""; + + var equalsIndex = value.IndexOf('='); + if (equalsIndex >= 0) + value = value[(equalsIndex + 1)..].Trim(); + + if (value.StartsWith("{x:Type ", StringComparison.Ordinal)) + value = value["{x:Type ".Length..].TrimEnd('}', ' '); + else if (value.StartsWith("{x:TypeExtension ", StringComparison.Ordinal)) + value = value["{x:TypeExtension ".Length..].TrimEnd('}', ' '); + else if (value.StartsWith("{x:Static ", StringComparison.Ordinal)) + value = value["{x:Static ".Length..].TrimEnd('}', ' '); + + if (value.StartsWith("TypeName=", StringComparison.Ordinal)) + value = value["TypeName=".Length..].Trim(); + if (value.StartsWith("Member=", StringComparison.Ordinal)) + value = value["Member=".Length..].Trim(); + if (value.StartsWith("Name=", StringComparison.Ordinal)) + value = value["Name=".Length..].Trim(); + if (value.StartsWith("ResourceKey=", StringComparison.Ordinal)) + value = value["ResourceKey=".Length..].Trim(); + + if (value.StartsWith("{x:Type ", StringComparison.Ordinal)) + value = value["{x:Type ".Length..].TrimEnd('}', ' '); + if (value.StartsWith("{x:TypeExtension ", StringComparison.Ordinal)) + value = value["{x:TypeExtension ".Length..].TrimEnd('}', ' '); + + var memberTypeEnd = value.IndexOf("}.", StringComparison.Ordinal); + if (memberTypeEnd >= 0 && memberTypeEnd + 2 < value.Length) + { + var typeStart = value.LastIndexOf(' ', memberTypeEnd); + var typeName = typeStart >= 0 ? value[(typeStart + 1)..memberTypeEnd] : ""; + var memberName = value[(memberTypeEnd + 2)..].Trim(); + if (typeName.Length > 0 && memberName.Length > 0) + return $"{typeName}.{memberName}"; + } + + return value.Trim().TrimEnd('}', '/', '>'); + } + + private static IEnumerable SplitMarkupArguments(string content) + { + var start = 0; + var depth = 0; + for (var i = 0; i <= content.Length; i++) + { + if (i < content.Length) + { + if (content[i] == '{') + depth++; + else if (content[i] == '}' && depth > 0) + depth--; + else if (content[i] != ',' || depth != 0) + continue; + } + + var segment = content[start..i].Trim(); + if (segment.Length > 0) + yield return segment; + start = i + 1; + } + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs index 8f1e45cca9..ef178e79b7 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs @@ -26,6 +26,7 @@ internal static List ExtractCore(ReferenceExtractionContext req content = preparedInput.Content; var lines = preparedInput.Lines; + var xamlReferenceEnabled = language == "xml" && XamlReferenceExtractor.IsXaml(lines); var structuralLines = preparedInput.StructuralLines; var csharpLinesInsideMultilineStringContent = preparedInput.CSharpLinesInsideMultilineStringContent; var csharpLinesInsideBlockComment = preparedInput.CSharpLinesInsideBlockComment; @@ -199,6 +200,7 @@ bool HasActiveSameFileCSharpTypeCandidate(string typeExpression, int lineNumber) var markupSchemaState = language is "graphql" or "html" or "markdown" ? new MarkupSchemaReferenceExtractor.MarkupState() : null; + var xamlInXmlComment = false; SymbolRecord? phpDocblockContainer = null; HashSet? phpDocblockPropertyNames = null; @@ -947,6 +949,11 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) CssReferenceExtractor.EmitSass(preparedLine, references, seen, fileId, context, lineNumber, container); else if (language == "stylus") CssReferenceExtractor.EmitStylus(preparedLine, references, seen, fileId, context, lineNumber, definitionNames, container); + else if (language == "xml" && xamlReferenceEnabled) + { + var xamlLine = XamlReferenceExtractor.StripXmlComments(originalLine, ref xamlInXmlComment); + XamlReferenceExtractor.Emit(xamlLine, context, lineNumber, references, seen, fileId, container); + } if (language == "terraform") { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs index dbc7485e0f..7fdf0295a8 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.State.cs @@ -31,7 +31,7 @@ internal sealed class CSharpWhereConstraintState "assembly", "r", "powershell", "shell", "haskell", "gradle", "terraform", "protobuf", "dockerfile", "makefile", "cmake", "justfile", "msbuild", - "zig", "css", "sass", "stylus", "graphql", "html", "markdown", "fortran", "pascal", "objc", "smalltalk" + "zig", "css", "sass", "stylus", "xml", "graphql", "html", "markdown", "fortran", "pascal", "objc", "smalltalk" ]; private static readonly IReadOnlyDictionary Extractors = diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index a580e83b72..a48e3c395d 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -2164,7 +2164,7 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult /// public static IReadOnlyCollection GetSupportedLanguages() => PatternCache.Keys - .Concat(new[] { "commonlisp", "racket", "vue", "svelte", "markdown", "json", "yaml", "razor", "blazor", "cshtml" }) + .Concat(new[] { "commonlisp", "racket", "vue", "svelte", "markdown", "json", "yaml", "xml", "razor", "blazor", "cshtml" }) .Concat(ExtractorPluginRegistry.SymbolLanguages) .Distinct(StringComparer.Ordinal) .ToArray(); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 02b3cc75ff..a2a39121fd 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2329,6 +2329,13 @@ public void RunLanguages_Json_SearchOnlyBucketsAdvertiseZeroSymbolAndGraphSuppor Assert.Empty(languages[stylesheetPreprocessor].GetProperty("capability_gaps").EnumerateArray()); } + Assert.True(languages["xml"].GetProperty("symbol_extraction").GetBoolean(), + "xml must advertise symbol_extraction=true for XAML/AXAML files"); + Assert.True(languages["xml"].GetProperty("reference_extraction").GetBoolean(), + "xml must advertise reference_extraction=true for XAML/AXAML files"); + Assert.True(languages["xml"].GetProperty("graph_queries").GetBoolean(), + "xml must advertise graph_queries=true for XAML/AXAML files"); + Assert.True(languages.ContainsKey("perl"), "expected 'perl' to be listed"); Assert.True(languages["perl"].GetProperty("symbol_extraction").GetBoolean(), "perl must advertise symbol_extraction=true"); diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index d79a6e052b..c33c75adfa 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -531,6 +531,84 @@ public void RegisteredLanguages_MatchSupportedReferenceLanguages() Assert.Equal("csharp", razorExtractor.Language); } + [Fact] + public void Extract_Xml_XamlReferences_CodeBehindResourcesBindingsAndHandlers() + { + const string content = """ + + + +