From 032493972c10325d8e9dc481d80d77e0864cb04a Mon Sep 17 00:00:00 2001 From: Michael Leone Date: Wed, 10 Dec 2025 16:06:22 +0000 Subject: [PATCH] Add PowerShell language support Implement PowerShell language server integration using PowerShell Editor Services (PSES). The implementation includes: - PowerShellLanguageServer class with automatic PSES download and setup - Support for PowerShell file extensions: .ps1, .psm1, .psd1 - Symbol detection using PSES format: "function FuncName ()" - 7 basic tests + 2 integration tests covering: - Document symbols and function detection - Within-file references - Cross-file go-to-definition - SerenaAgent find_symbol and find_references Requires: pwsh (PowerShell 7+) in PATH. PSES auto-downloads on first use. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- README.md | 2 +- pyproject.toml | 1 + .../powershell_language_server.py | 325 ++++++++++++++++++ src/solidlsp/ls_config.py | 7 + .../test_repo/PowerShellEditorServices.json | 1 + .../repos/powershell/test_repo/main.ps1 | 109 ++++++ .../repos/powershell/test_repo/utils.ps1 | 185 ++++++++++ test/serena/test_serena_agent.py | 3 + test/solidlsp/powershell/__init__.py | 1 + .../powershell/test_powershell_basic.py | 174 ++++++++++ 12 files changed, 809 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/powershell_language_server.py create mode 100644 test/resources/repos/powershell/test_repo/PowerShellEditorServices.json create mode 100644 test/resources/repos/powershell/test_repo/main.ps1 create mode 100644 test/resources/repos/powershell/test_repo/utils.ps1 create mode 100644 test/solidlsp/powershell/__init__.py create mode 100644 test/solidlsp/powershell/test_powershell_basic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d8327d8a..736b25d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7e1f629f..8f1ef25c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:** diff --git a/README.md b/README.md index 92970158..671aa0c5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 9fce285f..b7f911fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py new file mode 100644 index 00000000..129ab383 --- /dev/null +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -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") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index faf00055..5015f462 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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}") diff --git a/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json b/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json new file mode 100644 index 00000000..20147867 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json @@ -0,0 +1 @@ +{"status":"started","languageServiceTransport":"Stdio","powerShellVersion":"7.5.3"} \ No newline at end of file diff --git a/test/resources/repos/powershell/test_repo/main.ps1 b/test/resources/repos/powershell/test_repo/main.ps1 new file mode 100644 index 00000000..6829fda4 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/main.ps1 @@ -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 diff --git a/test/resources/repos/powershell/test_repo/utils.ps1 b/test/resources/repos/powershell/test_repo/utils.ps1 new file mode 100644 index 00000000..81db68e7 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/utils.ps1 @@ -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) +} diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 2b9060b7..441cdf53 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -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"], ) diff --git a/test/solidlsp/powershell/__init__.py b/test/solidlsp/powershell/__init__.py new file mode 100644 index 00000000..3fb55130 --- /dev/null +++ b/test/solidlsp/powershell/__init__.py @@ -0,0 +1 @@ +# PowerShell language server tests diff --git a/test/solidlsp/powershell/test_powershell_basic.py b/test/solidlsp/powershell/test_powershell_basic.py new file mode 100644 index 00000000..0d8a7349 --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_basic.py @@ -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}"