Merge pull request #831 from michaelleone/feature/power-shell-LSP-support

This commit is contained in:
Michael Panchenko authored and Michael Panchenko committed 2025-12-11 18:48:27 +07:00
commit 7afcf67ae2
12 files changed
+809 -2

No files matched your search

+1
View File
@@ -52,6 +52,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage.
* **Add support for Erlang** experimental, may hang or be slow, uses the recently archived [erlang_ls](https://github.com/erlang-ls/erlang_ls)
* **Ruby dual language server support**: Added ruby-lsp as the modern primary Ruby language server. Solargraph remains available as an experimental legacy option. ruby-lsp supports both .rb and .erb files, while Solargraph supports .rb files only.
* **Add support for PowerShell** via PowerShell Editor Services (PSES). Requires `pwsh` (PowerShell Core) to be installed and available in PATH. Supports symbol navigation, go-to-definition, and within-file references for .ps1 files.
# 0.1.4
+1 -1
View File
@@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Test Markers:**
Available pytest markers for selective testing:
- `python`, `go`, `java`, `rust`, `typescript`, `vue`, `php`, `perl`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph`
- `python`, `go`, `java`, `rust`, `typescript`, `vue`, `php`, `perl`, `powershell`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph`
- `snapshot` - for symbolic editing operation tests
**Project Management:**
+1 -1
View File
@@ -68,7 +68,7 @@ than existing solutions that charge a premium.
Language servers provide support for a wide range of programming languages.
With Serena's LSP library, we provide **support for over 30 programming languages**, including
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, Python, R, Ruby, Rust, Scala, Swift, TypeScript, YAML and Zig.
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TypeScript, YAML and Zig.
> [!IMPORTANT]
> Some languages require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
+1
View File
@@ -301,6 +301,7 @@ markers = [
"fortran: language server running for Fortran",
"haskell: Haskell language server tests",
"yaml: language server running for YAML",
"powershell: language server running for PowerShell",
"slow: tests that require additional Expert instances and have long startup times (~60-90s each)",
]
@@ -0,0 +1,325 @@
"""
Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services.
Contains various configurations and settings specific to PowerShell scripting.
"""
import logging
import os
import pathlib
import platform
import shutil
import tempfile
import threading
import zipfile
from pathlib import Path
import requests
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# PowerShell Editor Services version to download
PSES_VERSION = "4.4.0"
class PowerShellLanguageServer(SolidLanguageServer):
"""
Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services.
Contains various configurations and settings specific to PowerShell scripting.
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For PowerShell projects, ignore common build/output directories
return super().is_ignored_dirname(dirname) or dirname in [
"bin",
"obj",
".vscode",
"TestResults",
"Output",
]
@staticmethod
def _get_pwsh_path() -> str | None:
"""Get the path to PowerShell Core (pwsh) executable."""
# Check if pwsh is in PATH
pwsh = shutil.which("pwsh")
if pwsh:
return pwsh
# Check common installation locations
home = Path.home()
system = platform.system()
possible_paths: list[Path] = []
if system == "Windows":
possible_paths = [
Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7" / "pwsh.exe",
Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7-preview" / "pwsh.exe",
home / "AppData" / "Local" / "Microsoft" / "PowerShell" / "pwsh.exe",
]
elif system == "Darwin":
possible_paths = [
Path("/usr/local/bin/pwsh"),
Path("/opt/homebrew/bin/pwsh"),
home / ".dotnet" / "tools" / "pwsh",
]
else: # Linux
possible_paths = [
Path("/usr/bin/pwsh"),
Path("/usr/local/bin/pwsh"),
Path("/opt/microsoft/powershell/7/pwsh"),
home / ".dotnet" / "tools" / "pwsh",
]
for path in possible_paths:
if path.exists():
return str(path)
return None
@classmethod
def _get_pses_path(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""Get the path to PowerShell Editor Services installation."""
install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell"
start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1"
if start_script.exists():
return str(start_script)
return None
@classmethod
def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str:
"""Download and install PowerShell Editor Services."""
download_url = (
f"https://github.com/PowerShell/PowerShellEditorServices/releases/download/v{PSES_VERSION}/PowerShellEditorServices.zip"
)
# Create installation directory
install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell"
install_dir.mkdir(parents=True, exist_ok=True)
# Download the file
log.info(f"Downloading PowerShell Editor Services from {download_url}...")
response = requests.get(download_url, stream=True, timeout=120)
response.raise_for_status()
# Save the zip file
zip_path = install_dir / "PowerShellEditorServices.zip"
with open(zip_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
log.info(f"Extracting PowerShell Editor Services to {install_dir}...")
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(install_dir)
# Clean up zip file
zip_path.unlink()
start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1"
if not start_script.exists():
raise RuntimeError(f"Failed to find Start-EditorServices.ps1 after extraction at {start_script}")
log.info(f"PowerShell Editor Services installed at: {install_dir}")
return str(start_script)
@classmethod
def _setup_runtime_dependency(cls, solidlsp_settings: SolidLSPSettings) -> tuple[str, str, str]:
"""
Check if required PowerShell runtime dependencies are available.
Downloads PowerShell Editor Services if not present.
Returns:
tuple: (pwsh_path, start_script_path, bundled_modules_path)
"""
# Check for PowerShell Core
pwsh_path = cls._get_pwsh_path()
if not pwsh_path:
raise RuntimeError(
"PowerShell Core (pwsh) is not installed or not in PATH. "
"Please install PowerShell 7+ from https://github.com/PowerShell/PowerShell"
)
# Check for PowerShell Editor Services
pses_path = cls._get_pses_path(solidlsp_settings)
if not pses_path:
log.info("PowerShell Editor Services not found. Downloading...")
pses_path = cls._download_pses(solidlsp_settings)
# The bundled modules path is the directory containing PowerShellEditorServices
bundled_modules_path = str(Path(pses_path).parent)
return pwsh_path, pses_path, bundled_modules_path
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
pwsh_path, pses_path, bundled_modules_path = self._setup_runtime_dependency(solidlsp_settings)
# Create a temp directory for PSES logs and session details
pses_temp_dir = Path(tempfile.gettempdir()) / "solidlsp_pses"
pses_temp_dir.mkdir(parents=True, exist_ok=True)
log_path = pses_temp_dir / "pses.log"
session_details_path = pses_temp_dir / "session.json"
# Build the command to start PowerShell Editor Services in stdio mode
# PSES requires several parameters beyond just -Stdio
# Using list format for robust argument handling - the PowerShell command
# after -Command must be a single string element
pses_command = (
f"& '{pses_path}' "
f"-HostName 'SolidLSP' "
f"-HostProfileId 'solidlsp' "
f"-HostVersion '1.0.0' "
f"-BundledModulesPath '{bundled_modules_path}' "
f"-LogPath '{log_path}' "
f"-LogLevel 'Information' "
f"-SessionDetailsPath '{session_details_path}' "
f"-Stdio"
)
cmd: list[str] = [
pwsh_path,
"-NoLogo",
"-NoProfile",
"-Command",
pses_command,
]
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path),
"powershell",
solidlsp_settings,
)
self.server_ready = threading.Event()
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the PowerShell Editor Services.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {
"dynamicRegistration": True,
"completionItem": {
"snippetSupport": True,
"commitCharactersSupport": True,
"documentationFormat": ["markdown", "plaintext"],
"deprecatedSupport": True,
},
},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {
"documentationFormat": ["markdown", "plaintext"],
"parameterInformation": {"labelOffsetSupport": True},
},
},
"codeAction": {"dynamicRegistration": True},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"configuration": True,
"symbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore[return-value]
def _start_server(self) -> None:
"""
Starts the PowerShell Editor Services, waits for the server to be ready.
"""
self._dynamic_capabilities: set[str] = set()
def register_capability_handler(params: dict) -> None:
"""Handle dynamic capability registration from PSES."""
registrations = params.get("registrations", [])
for reg in registrations:
method = reg.get("method", "")
log.info(f"PSES registered dynamic capability: {method}")
self._dynamic_capabilities.add(method)
# Mark server ready when we get document symbol registration
if method == "textDocument/documentSymbol":
self.server_ready.set()
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
# Check for PSES ready signals
message_text = msg.get("message", "")
if "started" in message_text.lower() or "ready" in message_text.lower():
log.info("PowerShell Editor Services ready signal detected")
self.server_ready.set()
self.completions_available.set()
def do_nothing(params: dict) -> None:
return
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("powerShell/executionStatusChanged", do_nothing)
log.info("Starting PowerShell Editor Services process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.info(f"Received initialize response from PowerShell server: {init_response}")
# Verify server capabilities - PSES uses dynamic capability registration
# so we check for either static or dynamic capabilities
capabilities = init_response.get("capabilities", {})
log.info(f"Server capabilities: {capabilities}")
# Send initialized notification to trigger dynamic capability registration
self.server.notify.initialized({})
# Wait for server readiness with timeout
log.info("Waiting for PowerShell Editor Services to be ready...")
if not self.server_ready.wait(timeout=10.0):
# Fallback: assume server is ready after timeout
log.info("Timeout waiting for PSES ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("PowerShell Editor Services initialization complete")
+7
View File
@@ -62,6 +62,7 @@ class Language(str, Enum):
FORTRAN = "fortran"
HASKELL = "haskell"
VUE = "vue"
POWERSHELL = "powershell"
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -200,6 +201,8 @@ class Language(str, Enum):
for base_pattern in ["ts", "js"]:
path_patterns.append(f"*.{prefix}{base_pattern}{postfix}")
return FilenameMatcher(*path_patterns)
case self.POWERSHELL:
return FilenameMatcher("*.ps1", "*.psm1", "*.psd1")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -353,6 +356,10 @@ class Language(str, Enum):
from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer
return FSharpLanguageServer
case self.POWERSHELL:
from solidlsp.language_servers.powershell_language_server import PowerShellLanguageServer
return PowerShellLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1 @@
{"status":"started","languageServiceTransport":"Stdio","powerShellVersion":"7.5.3"}
@@ -0,0 +1,109 @@
# Main script demonstrating various PowerShell features
# Import utility functions
. "$PSScriptRoot\utils.ps1"
# Global variables
$Script:ScriptName = "Main Script"
$Script:Counter = 0
<#
.SYNOPSIS
Greets a user with various greeting styles.
.PARAMETER Username
The name of the user to greet.
.PARAMETER GreetingType
The type of greeting (formal, casual, or default).
#>
function Greet-User {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $false)]
[ValidateSet("formal", "casual", "default")]
[string]$GreetingType = "default"
)
switch ($GreetingType) {
"formal" {
Write-Output "Good day, $Username!"
}
"casual" {
Write-Output "Hey $Username!"
}
default {
Write-Output "Hello, $Username!"
}
}
}
<#
.SYNOPSIS
Processes an array of items with the specified operation.
.PARAMETER Items
The array of items to process.
.PARAMETER Operation
The operation to perform (count, uppercase).
#>
function Process-Items {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Items,
[Parameter(Mandatory = $true)]
[ValidateSet("count", "uppercase")]
[string]$Operation
)
foreach ($item in $Items) {
switch ($Operation) {
"count" {
$Script:Counter++
Write-Output "Processing item $($Script:Counter): $item"
}
"uppercase" {
Write-Output $item.ToUpper()
}
}
}
}
<#
.SYNOPSIS
Main entry point for the script.
#>
function Main {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$User = "World",
[Parameter(Mandatory = $false)]
[string]$Greeting = "default"
)
Write-Output "Starting $Script:ScriptName"
# Use the Greet-User function
Greet-User -Username $User -GreetingType $Greeting
# Process some items
$items = @("item1", "item2", "item3")
Write-Output "Processing items..."
Process-Items -Items $items -Operation "count"
# Use utility functions from utils.ps1
$upperName = Convert-ToUpperCase -InputString $User
Write-Output "Uppercase name: $upperName"
$trimmed = Remove-Whitespace -InputString " Hello World "
Write-Output "Trimmed: '$trimmed'"
Write-Output "Script completed successfully"
}
# Run main function
Main @args
@@ -0,0 +1,185 @@
# Utility functions for PowerShell operations
<#
.SYNOPSIS
Converts a string to uppercase.
.PARAMETER InputString
The string to convert.
.OUTPUTS
System.String - The uppercase string.
#>
function Convert-ToUpperCase {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]$InputString
)
return $InputString.ToUpper()
}
<#
.SYNOPSIS
Converts a string to lowercase.
.PARAMETER InputString
The string to convert.
.OUTPUTS
System.String - The lowercase string.
#>
function Convert-ToLowerCase {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]$InputString
)
return $InputString.ToLower()
}
<#
.SYNOPSIS
Removes leading and trailing whitespace from a string.
.PARAMETER InputString
The string to trim.
.OUTPUTS
System.String - The trimmed string.
#>
function Remove-Whitespace {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]$InputString
)
return $InputString.Trim()
}
<#
.SYNOPSIS
Creates a backup of a file.
.PARAMETER FilePath
The path to the file to backup.
.PARAMETER BackupDirectory
The directory where the backup will be created.
.OUTPUTS
System.String - The path to the backup file.
#>
function Backup-File {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $false)]
[string]$BackupDirectory = "."
)
if (-not (Test-Path $FilePath)) {
throw "File not found: $FilePath"
}
$fileName = Split-Path $FilePath -Leaf
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$backupName = "$fileName.$timestamp.bak"
$backupPath = Join-Path $BackupDirectory $backupName
Copy-Item -Path $FilePath -Destination $backupPath
return $backupPath
}
<#
.SYNOPSIS
Checks if an array contains a specific element.
.PARAMETER Array
The array to search.
.PARAMETER Element
The element to find.
.OUTPUTS
System.Boolean - True if the element is found, false otherwise.
#>
function Test-ArrayContains {
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]
[array]$Array,
[Parameter(Mandatory = $true)]
$Element
)
return $Array -contains $Element
}
<#
.SYNOPSIS
Writes a log message with timestamp.
.PARAMETER Message
The message to log.
.PARAMETER Level
The log level (Info, Warning, Error).
#>
function Write-LogMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter(Mandatory = $false)]
[ValidateSet("Info", "Warning", "Error")]
[string]$Level = "Info"
)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] [$Level] $Message"
switch ($Level) {
"Info" { Write-Host $logEntry -ForegroundColor White }
"Warning" { Write-Host $logEntry -ForegroundColor Yellow }
"Error" { Write-Host $logEntry -ForegroundColor Red }
}
}
<#
.SYNOPSIS
Validates if a string is a valid email address.
.PARAMETER Email
The email address to validate.
.OUTPUTS
System.Boolean - True if the email is valid, false otherwise.
#>
function Test-ValidEmail {
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]
[string]$Email
)
$emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return $Email -match $emailRegex
}
<#
.SYNOPSIS
Checks if a string is a valid number.
.PARAMETER Value
The string to check.
.OUTPUTS
System.Boolean - True if the string is a valid number, false otherwise.
#>
function Test-IsNumber {
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
$number = 0
return [double]::TryParse($Value, [ref]$number)
}
+3
View File
@@ -31,6 +31,7 @@ def serena_config():
Language.CSHARP,
Language.CLOJURE,
Language.FSHARP,
Language.POWERSHELL,
]:
repo_path = get_repo_path(language)
if repo_path.exists():
@@ -87,6 +88,7 @@ class TestSerenaAgent:
pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure),
pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell),
],
indirect=["serena_agent"],
)
@@ -138,6 +140,7 @@ class TestSerenaAgent:
),
pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
],
indirect=["serena_agent"],
)
+1
View File
@@ -0,0 +1 @@
# PowerShell language server tests
@@ -0,0 +1,174 @@
"""
Basic integration tests for the PowerShell language server functionality.
These tests validate the functionality of the language server APIs
like request_document_symbols using the PowerShell test repository.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
@pytest.mark.powershell
class TestPowerShellLanguageServerBasics:
"""Test basic functionality of the PowerShell language server."""
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_language_server_initialization(self, language_server: SolidLanguageServer) -> None:
"""Test that PowerShell language server can be initialized successfully."""
assert language_server is not None
assert language_server.language == Language.POWERSHELL
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_request_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test request_document_symbols for PowerShell files."""
# Test getting symbols from main.ps1
all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots()
# Extract function symbols (LSP Symbol Kind 12)
function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12]
function_names = [symbol["name"] for symbol in function_symbols]
# PSES returns function names in format "function FuncName ()" - check for function name substring
def has_function(name: str) -> bool:
return any(name in fn for fn in function_names)
# Should detect the main functions from main.ps1
assert has_function("Greet-User"), f"Should find Greet-User function in {function_names}"
assert has_function("Process-Items"), f"Should find Process-Items function in {function_names}"
assert has_function("Main"), f"Should find Main function in {function_names}"
assert len(function_symbols) >= 3, f"Should find at least 3 functions, found {len(function_symbols)}"
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_utils_functions(self, language_server: SolidLanguageServer) -> None:
"""Test function detection in utils.ps1 file."""
# Test with utils.ps1
utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots()
utils_function_symbols = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12]
utils_function_names = [symbol["name"] for symbol in utils_function_symbols]
# PSES returns function names in format "function FuncName ()" - check for function name substring
def has_function(name: str) -> bool:
return any(name in fn for fn in utils_function_names)
# Should detect functions from utils.ps1
expected_utils_functions = [
"Convert-ToUpperCase",
"Convert-ToLowerCase",
"Remove-Whitespace",
"Backup-File",
"Test-ArrayContains",
"Write-LogMessage",
"Test-ValidEmail",
"Test-IsNumber",
]
for func_name in expected_utils_functions:
assert has_function(func_name), f"Should find {func_name} function in utils.ps1, got {utils_function_names}"
assert len(utils_function_symbols) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_function_symbols)}"
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_function_with_parameters(self, language_server: SolidLanguageServer) -> None:
"""Test that functions with CmdletBinding and parameters are detected correctly."""
all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots()
function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12]
# Find Greet-User function which has parameters
# PSES returns function names in format "function FuncName ()"
greet_user_symbol = next((sym for sym in function_symbols if "Greet-User" in sym["name"]), None)
assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}"
# Find Process-Items function which has array parameter
process_items_symbol = next((sym for sym in function_symbols if "Process-Items" in sym["name"]), None)
assert process_items_symbol is not None, f"Should find Process-Items function in {[s['name'] for s in function_symbols]}"
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_all_function_detection(self, language_server: SolidLanguageServer) -> None:
"""Test that all expected functions are detected across both files."""
# Get symbols from main.ps1
main_all_symbols, _main_root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots()
main_functions = [symbol for symbol in main_all_symbols if symbol.get("kind") == 12]
main_function_names = [func["name"] for func in main_functions]
# Get symbols from utils.ps1
utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots()
utils_functions = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12]
utils_function_names = [func["name"] for func in utils_functions]
# PSES returns function names in format "function FuncName ()" - check for function name substring
def has_main_function(name: str) -> bool:
return any(name in fn for fn in main_function_names)
def has_utils_function(name: str) -> bool:
return any(name in fn for fn in utils_function_names)
# Verify main.ps1 functions
expected_main = ["Greet-User", "Process-Items", "Main"]
for expected_func in expected_main:
assert has_main_function(expected_func), f"Should detect {expected_func} function in main.ps1, got {main_function_names}"
# Verify utils.ps1 functions
expected_utils = [
"Convert-ToUpperCase",
"Convert-ToLowerCase",
"Remove-Whitespace",
"Backup-File",
"Test-ArrayContains",
"Write-LogMessage",
"Test-ValidEmail",
"Test-IsNumber",
]
for expected_func in expected_utils:
assert has_utils_function(expected_func), f"Should detect {expected_func} function in utils.ps1, got {utils_function_names}"
# Verify total counts
assert len(main_functions) >= 3, f"Should find at least 3 functions in main.ps1, found {len(main_functions)}"
assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_functions)}"
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_find_references_within_file(self, language_server: SolidLanguageServer) -> None:
"""Test finding references to a function within the same file."""
main_path = "main.ps1"
# Get symbols to find the Greet-User function which is called from Main
all_symbols, _root_symbols = language_server.request_document_symbols(main_path).get_all_symbols_and_roots()
# Find Greet-User function definition
function_symbols = [s for s in all_symbols if s.get("kind") == 12]
greet_user_symbol = next((s for s in function_symbols if "Greet-User" in s["name"]), None)
assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}"
# Find references to Greet-User (should be called from Main function at line 91)
sel_start = greet_user_symbol["selectionRange"]["start"]
refs = language_server.request_references(main_path, sel_start["line"], sel_start["character"])
# Should find at least the call site in Main function
assert refs is not None and len(refs) >= 1, f"Should find references to Greet-User, got {refs}"
assert any(
"main.ps1" in ref.get("uri", ref.get("relativePath", "")) for ref in refs
), f"Should find reference in main.ps1, got {refs}"
@pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True)
def test_powershell_find_definition_across_files(self, language_server: SolidLanguageServer) -> None:
"""Test finding definition of functions across files (main.ps1 -> utils.ps1)."""
# main.ps1 calls Convert-ToUpperCase from utils.ps1 at line 99 (0-indexed: 98)
# The call is: $upperName = Convert-ToUpperCase -InputString $User
# We'll request definition from the call site in main.ps1
main_path = "main.ps1"
# Find definition of Convert-ToUpperCase from its usage in main.ps1
# Line 99 (1-indexed) = line 98 (0-indexed), character position ~16 for "Convert-ToUpperCase"
definition_locations = language_server.request_definition(main_path, 98, 18)
# Should find the definition in utils.ps1
assert (
definition_locations is not None and len(definition_locations) >= 1
), f"Should find definition of Convert-ToUpperCase, got {definition_locations}"
assert any(
"utils.ps1" in loc.get("uri", "") for loc in definition_locations
), f"Should find definition in utils.ps1, got {definition_locations}"