diff --git a/changelog.d/unreleased/1482.fixed.md b/changelog.d/unreleased/1482.fixed.md new file mode 100644 index 0000000000..b33377ccd2 --- /dev/null +++ b/changelog.d/unreleased/1482.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1482 +affected: + - src/CodeIndex/Indexer/References/Languages/PowerShellReferenceExtractor.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **PowerShell function-call extraction now covers local function names (#1482)** — PowerShell reference extraction recognizes plain user-defined function calls in addition to hyphenated cmdlet-style names. + +## 日本語 + +- **PowerShell の関数呼び出し抽出がローカル関数名に対応しました (#1482)** — PowerShell の参照抽出は、hyphenated な cmdlet 形式の名前に加えて、単純なユーザー定義関数呼び出しを認識します。 diff --git a/changelog.d/unreleased/1666.fixed.md b/changelog.d/unreleased/1666.fixed.md new file mode 100644 index 0000000000..8fa1619ca0 --- /dev/null +++ b/changelog.d/unreleased/1666.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1666 +affected: + - src/CodeIndex/Indexer/References/Languages/PowerShellReferenceExtractor.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **PowerShell splatted hashtables now expose parameter references (#1666)** — simple assignments such as `$params = @{ Path = "." }` now let calls like `Get-ChildItem @params` emit `parameter` references for the hashtable keys. + +## 日本語 + +- **PowerShell の splat された hashtable がパラメーター参照を出力するようになりました (#1666)** — `$params = @{ Path = "." }` のような単純な代入から、`Get-ChildItem @params` が hashtable key の `parameter` 参照を出力します。 diff --git a/src/CodeIndex/Indexer/References/Languages/PowerShellReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/PowerShellReferenceExtractor.cs index 05be467ede..2bca0c4f05 100644 --- a/src/CodeIndex/Indexer/References/Languages/PowerShellReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/PowerShellReferenceExtractor.cs @@ -8,7 +8,19 @@ internal static class PowerShellReferenceExtractor // `Get-ChildItem -Path .`, `Write-Host "x"`, and `$items | ForEach-Object { ... }`. // PowerShell の cmdlet / function 呼び出しは statement-start / pipeline 形で現れる。 private static readonly Regex CallRegex = new( - @"(?:^|[|;&{=]\s*)\s*(?[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+)\b", + @"(?:^|[|;&{=]\s*)\s*(?[A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z][A-Za-z0-9_]*)*)\b", + RegexOptions.Compiled | RegexOptions.Multiline); + + private static readonly Regex SplatTokenRegex = new( + @"(?[A-Za-z_][A-Za-z0-9_]*)\b", + RegexOptions.Compiled | RegexOptions.Multiline); + + private static readonly Regex SplatAssignmentStartRegex = new( + @"\$(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*@\{", + RegexOptions.Compiled | RegexOptions.Multiline); + + private static readonly Regex HashtableKeyRegex = new( + @"(?[A-Za-z_][A-Za-z0-9_]*)'|""(?[A-Za-z_][A-Za-z0-9_]*)""|(?[A-Za-z_][A-Za-z0-9_]*))\s*=", RegexOptions.Compiled | RegexOptions.Multiline); public static void EmitCallReferences(string preparedLine, Action addCallLikeReference) @@ -17,7 +29,123 @@ public static void EmitCallReferences(string preparedLine, Action a { var name = match.Groups["name"].Value; var callIndex = match.Groups["name"].Index; + if (IsAssignmentKey(preparedLine, callIndex + name.Length)) + continue; addCallLikeReference(name, callIndex); } } + + public static Dictionary> BuildSplatAssignments(string[] preparedLines) + { + var assignments = new Dictionary>(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < preparedLines.Length; index++) + { + var line = preparedLines[index]; + foreach (Match match in SplatAssignmentStartRegex.Matches(line)) + { + var start = match.Index + match.Length; + var builder = new System.Text.StringBuilder(); + var endLine = index; + var depth = 1; + var firstFragment = true; + + for (var scanLine = index; scanLine < preparedLines.Length && depth > 0; scanLine++) + { + var text = preparedLines[scanLine]; + var scanStart = scanLine == index ? start : 0; + if (!firstFragment) + builder.Append(' '); + firstFragment = false; + + for (var scan = scanStart; scan < text.Length; scan++) + { + var ch = text[scan]; + if (ch == '{') + depth++; + else if (ch == '}') + { + depth--; + if (depth == 0) + { + endLine = scanLine; + break; + } + } + + if (depth > 0) + builder.Append(ch); + } + } + + var keys = ExtractHashtableKeys(builder.ToString()); + if (keys.Count == 0) + continue; + + var name = match.Groups["name"].Value; + if (!assignments.TryGetValue(name, out var namedAssignments)) + { + namedAssignments = []; + assignments[name] = namedAssignments; + } + + namedAssignments.Add(new SplatAssignment(index + 1, endLine + 1, keys)); + } + } + + return assignments; + } + + public static void EmitSplatParameterReferences( + string preparedLine, + Dictionary> splatAssignments, + int lineNumber, + Action addParameterReference) + { + if (splatAssignments.Count == 0 || !CallRegex.IsMatch(preparedLine)) + return; + + foreach (Match splat in SplatTokenRegex.Matches(preparedLine)) + { + var name = splat.Groups["name"].Value; + if (!splatAssignments.TryGetValue(name, out var candidates)) + continue; + + SplatAssignment? latest = null; + foreach (var candidate in candidates) + { + if (candidate.StartLine <= lineNumber) + latest = candidate; + } + + if (latest == null) + continue; + + foreach (var key in latest.Value.Keys) + addParameterReference(key, splat.Index); + } + } + + private static List ExtractHashtableKeys(string text) + { + var keys = new List(); + foreach (Match match in HashtableKeyRegex.Matches(text)) + { + var key = match.Groups["quoted"].Success + ? match.Groups["quoted"].Value + : match.Groups["bare"].Value; + if (!keys.Contains(key, StringComparer.OrdinalIgnoreCase)) + keys.Add(key); + } + + return keys; + } + + private static bool IsAssignmentKey(string line, int cursor) + { + while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) + cursor++; + return cursor < line.Length && line[cursor] == '='; + } + + public readonly record struct SplatAssignment(int StartLine, int EndLine, IReadOnlyList Keys); } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs index fbd580f984..2880954945 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs @@ -128,6 +128,9 @@ internal static List ExtractCore(ReferenceExtractionContext req structuralLines, csharpKnownTypeNames, csharpUsingAliases); + var powershellSplatAssignments = language == "powershell" + ? PowerShellReferenceExtractor.BuildSplatAssignments(preparedLines) + : null; // Workspace-wide same-name type rescue needs cross-file visibility, so the // extractor leaves ambiguous unqualified using-static pattern heads for the // read path to disambiguate. @@ -1221,6 +1224,12 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) void AddCallLikeReference(string name, int callIndex) => _ = TryAddCallLikeReference(name, callIndex); + void AddPowerShellParameterReference(string name, int callIndex) + { + var callContainer = ResolveContainerForCall(callIndex); + AddReference(references, seen, fileId, name, callIndex, "parameter", context, lineNumber, callContainer, language); + } + bool TryAddCallLikeReference(string name, int callIndex) { var normalizedName = language == "fsharp" && FSharpReferenceExtractor.IsOperatorCallName(name) @@ -1366,6 +1375,11 @@ bool TryAddCallLikeReference(string name, int callIndex) else if (language is "powershell") { PowerShellReferenceExtractor.EmitCallReferences(preparedLine, AddCallLikeReference); + PowerShellReferenceExtractor.EmitSplatParameterReferences( + preparedLine, + powershellSplatAssignments!, + lineNumber, + AddPowerShellParameterReference); } else if (language is "shell") { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index 26b75a8525..2d6f6f6865 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -222,9 +222,13 @@ private static bool IsFunctionLikeSymbolKind(string kind) "import", "importFrom", "export", "exportClasses", "exportMethods", "S3method", "useDynLib", }, // PowerShell keywords / PowerShell キーワード - ["powershell"] = new HashSet(StringComparer.Ordinal) + ["powershell"] = new HashSet(StringComparer.OrdinalIgnoreCase) { - "param", "begin", "process", "Write", "trap", "finally", "elseif", + "function", "filter", "configuration", "workflow", "class", "enum", + "param", "begin", "process", "end", "dynamicparam", + "if", "else", "elseif", "for", "foreach", "while", "do", "until", "switch", + "try", "catch", "finally", "trap", "return", "throw", "break", "continue", + "using", "data", "in", "Write", }, // Shell keywords / Shell キーワード ["shell"] = new HashSet(StringComparer.Ordinal) diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index a34bdd6a0c..54b10be381 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -18300,6 +18300,16 @@ function Fetch-Remote { Invoke-RestMethod -Uri "https://api.example.com$Endpoint" } + function MyFunction { + param($Value) + Write-Host $Value + } + + filter Select-Valid { + process { if ($_.IsValid) { return $_ } } + } + IF ($true) { RETURN "ok" } + function Process-Items { param([array]$Items) $Items | ForEach-Object { Process-One $_ } @@ -18313,6 +18323,7 @@ function Process-Items { function Compute-Stats { param($d) return @{count = $d.Count} } Get-UserData -Name "Alice" -Id 42 + MyFunction "hello" Process-Items -Items @(1,2,3) if ($Items -lt 10) { Write-Host "too few" } """; @@ -18322,6 +18333,7 @@ function Process-Items { Assert.Contains(references, r => r.SymbolName == "Get-UserData" && r.ReferenceKind == "call"); Assert.Contains(references, r => r.SymbolName == "Fetch-Remote" && r.ReferenceKind == "call"); + Assert.Contains(references, r => r.SymbolName == "MyFunction" && r.ReferenceKind == "call"); Assert.Contains(references, r => r.SymbolName == "Process-Items" && r.ReferenceKind == "call"); Assert.Contains(references, r => r.SymbolName == "Process-One" && r.ReferenceKind == "call"); Assert.Contains(references, r => r.SymbolName == "Transform-Item" && r.ReferenceKind == "call"); @@ -18330,9 +18342,32 @@ function Process-Items { Assert.Contains(references, r => r.SymbolName == "Invoke-RestMethod" && r.ReferenceKind == "call"); Assert.Contains(references, r => r.SymbolName == "ForEach-Object" && r.ReferenceKind == "call"); Assert.Contains(references, r => r.SymbolName == "Where-Object" && r.ReferenceKind == "call"); + Assert.DoesNotContain(references, r => r.SymbolName is "function" or "filter" or "if" or "IF" or "return" or "RETURN"); Assert.DoesNotContain(references, r => r.SymbolName == "lt"); } + [Fact] + public void Extract_PowerShell_SplattingEmitsParameterReferences() + { + const string content = """ + $params = @{ + Path = "." + Recurse = $true + } + + Get-ChildItem @params + """; + + var symbols = SymbolExtractor.Extract(1, "powershell", content); + var references = ReferenceExtractor.Extract(1, "powershell", content, symbols); + + Assert.Contains(references, r => r.SymbolName == "Get-ChildItem" && r.ReferenceKind == "call"); + Assert.Contains(references, r => r.SymbolName == "Path" && r.ReferenceKind == "parameter"); + Assert.Contains(references, r => r.SymbolName == "Recurse" && r.ReferenceKind == "parameter"); + Assert.DoesNotContain(references, r => r.SymbolName == "Path" && r.ReferenceKind == "call"); + Assert.DoesNotContain(references, r => r.SymbolName == "Recurse" && r.ReferenceKind == "call"); + } + [Fact] public void Extract_Haskell_DetectsParenthesizedCalls() {