Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1482.fixed.md
Original file line number Diff line number Diff line change
@@ -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 形式の名前に加えて、単純なユーザー定義関数呼び出しを認識します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1666.fixed.md
Original file line number Diff line number Diff line change
@@ -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` 参照を出力します。
Original file line number Diff line number Diff line change
Expand Up @@ -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*(?<name>[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)+)\b",
@"(?:^|[|;&{=]\s*)\s*(?<name>[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(
@"(?<![\w$])@(?<name>[A-Za-z_][A-Za-z0-9_]*)\b",
RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex SplatAssignmentStartRegex = new(
@"\$(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*@\{",
RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex HashtableKeyRegex = new(
@"(?<![$@])(?:'(?<quoted>[A-Za-z_][A-Za-z0-9_]*)'|""(?<quoted>[A-Za-z_][A-Za-z0-9_]*)""|(?<bare>[A-Za-z_][A-Za-z0-9_]*))\s*=",
RegexOptions.Compiled | RegexOptions.Multiline);

public static void EmitCallReferences(string preparedLine, Action<string, int> addCallLikeReference)
Expand All @@ -17,7 +29,123 @@ public static void EmitCallReferences(string preparedLine, Action<string, int> 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<string, List<SplatAssignment>> BuildSplatAssignments(string[] preparedLines)
{
var assignments = new Dictionary<string, List<SplatAssignment>>(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<string, List<SplatAssignment>> splatAssignments,
int lineNumber,
Action<string, int> 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<string> ExtractHashtableKeys(string text)
{
var keys = new List<string>();
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<string> Keys);
}
14 changes: 14 additions & 0 deletions src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ internal static List<ReferenceRecord> 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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
{
Expand Down
8 changes: 6 additions & 2 deletions src/CodeIndex/Indexer/References/ReferenceExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,13 @@ private static bool IsFunctionLikeSymbolKind(string kind)
"import", "importFrom", "export", "exportClasses", "exportMethods", "S3method", "useDynLib",
},
// PowerShell keywords / PowerShell キーワード
["powershell"] = new HashSet<string>(StringComparer.Ordinal)
["powershell"] = new HashSet<string>(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<string>(StringComparer.Ordinal)
Expand Down
35 changes: 35 additions & 0 deletions tests/CodeIndex.Tests/ReferenceExtractorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 $_ }
Expand All @@ -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" }
""";
Expand All @@ -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");
Expand All @@ -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()
{
Expand Down
Loading