feat(lsp): add PowerShellEditorServices language server (#55930)
Registers PowerShell (.ps1/.psm1/.psd1) in the LSP server registry, spawning PowerShellEditorServices over stdio via a pwsh/powershell host. PSES ships as a GitHub release zip (no npm/go/pip recipe), so it sits in the manual install tier alongside rust-analyzer and clangd. The spawn builder resolves the module bundle from (in order) the lsp.servers.powershell.command override, init bundlePath, the PSES_BUNDLE_PATH env var, or <HERMES_HOME>/lsp/PowerShellEditorServices, then launches Start-EditorServices.ps1 -Stdio with a non-interactive, no-profile host. hermes lsp status/list report it as manual-only until pwsh is present. Docs and tests included.
This commit is contained in:
parent
812236bff8
commit
97e0bbef53
4 changed files with 289 additions and 0 deletions
|
|
@ -102,6 +102,11 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
|||
# Lua — manual (LuaLS is platform-specific binaries from GitHub
|
||||
# releases; complex enough that we punt to the user)
|
||||
"lua-language-server": {"strategy": "manual", "pkg": "", "bin": "lua-language-server"},
|
||||
# PowerShell — PowerShellEditorServices ships as a GitHub release
|
||||
# zip driven by a pwsh bootstrap script, not a single binary. We
|
||||
# require a manual bundle install and probe for the pwsh host so
|
||||
# `hermes lsp status` reports the host's presence.
|
||||
"powershell": {"strategy": "manual", "pkg": "", "bin": "pwsh"},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ LANGUAGE_BY_EXT: Dict[str, str] = {
|
|||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
".dockerfile": "dockerfile",
|
||||
".ps1": "powershell",
|
||||
".psm1": "powershell",
|
||||
".psd1": "powershell",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -676,6 +679,131 @@ def _spawn_astro(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
|
|||
)
|
||||
|
||||
|
||||
_PSES_BUNDLE_WARNED = False
|
||||
|
||||
|
||||
def _find_pses_bundle(ctx: ServerContext) -> Optional[str]:
|
||||
"""Locate the PowerShellEditorServices module bundle directory.
|
||||
|
||||
PSES ships as a GitHub release zip (not an npm/go/pip package), so
|
||||
there's no auto-install recipe — the user downloads it and points us
|
||||
at the extracted bundle. Resolution order:
|
||||
|
||||
1. ``command`` override in config (``lsp.servers.powershell.command``) —
|
||||
the FIRST element is treated as the bundle path when it's a
|
||||
directory. This is the documented config knob.
|
||||
2. ``init_overrides["powershell"]["bundlePath"]``.
|
||||
3. ``PSES_BUNDLE_PATH`` env var.
|
||||
4. ``<HERMES_HOME>/lsp/PowerShellEditorServices`` staging dir (where a
|
||||
user-run unzip would naturally land).
|
||||
|
||||
Returns the bundle directory containing ``PowerShellEditorServices/``,
|
||||
or ``None`` when it can't be found.
|
||||
"""
|
||||
candidates: List[str] = []
|
||||
override = ctx.binary_overrides.get("powershell")
|
||||
if override and override[0]:
|
||||
candidates.append(override[0])
|
||||
init = ctx.init_overrides.get("powershell", {})
|
||||
if isinstance(init, dict) and init.get("bundlePath"):
|
||||
candidates.append(str(init["bundlePath"]))
|
||||
env_path = os.environ.get("PSES_BUNDLE_PATH")
|
||||
if env_path:
|
||||
candidates.append(env_path)
|
||||
home = os.environ.get("HERMES_HOME") or os.path.join(
|
||||
os.path.expanduser("~"), ".hermes"
|
||||
)
|
||||
candidates.append(os.path.join(home, "lsp", "PowerShellEditorServices"))
|
||||
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
# Accept either the bundle root or the inner module dir.
|
||||
start_script = os.path.join(
|
||||
cand, "PowerShellEditorServices", "Start-EditorServices.ps1"
|
||||
)
|
||||
if os.path.isfile(start_script):
|
||||
return cand
|
||||
inner = os.path.join(cand, "Start-EditorServices.ps1")
|
||||
if os.path.isfile(inner):
|
||||
return os.path.dirname(cand)
|
||||
return None
|
||||
|
||||
|
||||
def _spawn_powershell_es(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
|
||||
"""Spawn PowerShellEditorServices over stdio.
|
||||
|
||||
Unlike the single-binary servers, PSES is a PowerShell module driven
|
||||
by a bootstrap script. We need both a PowerShell host (``pwsh`` for
|
||||
PowerShell 7+, or Windows ``powershell``) and the PSES module bundle.
|
||||
The bundle is manual-install (release zip) — see ``_find_pses_bundle``.
|
||||
"""
|
||||
pwsh = _which("pwsh", "powershell")
|
||||
if pwsh is None:
|
||||
return None
|
||||
bundle = _find_pses_bundle(ctx)
|
||||
if bundle is None:
|
||||
global _PSES_BUNDLE_WARNED
|
||||
if not _PSES_BUNDLE_WARNED:
|
||||
_PSES_BUNDLE_WARNED = True
|
||||
logger.warning(
|
||||
"powershell: pwsh found but the PowerShellEditorServices "
|
||||
"bundle is missing. Download the release zip from "
|
||||
"https://github.com/PowerShell/PowerShellEditorServices/releases, "
|
||||
"extract it, and either set lsp.servers.powershell.command "
|
||||
"to the bundle path or unzip it to "
|
||||
"<HERMES_HOME>/lsp/PowerShellEditorServices."
|
||||
)
|
||||
return None
|
||||
start_script = os.path.join(
|
||||
bundle, "PowerShellEditorServices", "Start-EditorServices.ps1"
|
||||
)
|
||||
# Session details file: PSES writes connection info here on startup.
|
||||
session_path = os.path.join(
|
||||
hermes_lsp_session_dir(), f"pses-session-{os.getpid()}.json"
|
||||
)
|
||||
log_path = os.path.join(hermes_lsp_session_dir(), "pses.log")
|
||||
inner = (
|
||||
f"& '{start_script}' "
|
||||
f"-BundledModulesPath '{bundle}' "
|
||||
f"-LogPath '{log_path}' "
|
||||
f"-SessionDetailsPath '{session_path}' "
|
||||
f"-FeatureFlags @() -AdditionalModules @() "
|
||||
f"-HostName Hermes -HostProfileId hermes -HostVersion 1.0.0 "
|
||||
f"-Stdio -LogLevel Normal"
|
||||
)
|
||||
return SpawnSpec(
|
||||
command=[
|
||||
pwsh,
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
inner,
|
||||
],
|
||||
workspace_root=root,
|
||||
cwd=root,
|
||||
env=ctx.env_overrides.get("powershell", {}),
|
||||
initialization_options={
|
||||
k: v
|
||||
for k, v in ctx.init_overrides.get("powershell", {}).items()
|
||||
if k != "bundlePath"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def hermes_lsp_session_dir() -> str:
|
||||
"""Return (and create) the dir for PSES session/log scratch files."""
|
||||
home = os.environ.get("HERMES_HOME") or os.path.join(
|
||||
os.path.expanduser("~"), ".hermes"
|
||||
)
|
||||
d = os.path.join(home, "lsp", "pses")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _resolve_override(ctx: ServerContext, server_id: str) -> Optional[str]:
|
||||
"""User can pin a binary path in config."""
|
||||
override = ctx.binary_overrides.get(server_id)
|
||||
|
|
@ -823,6 +951,18 @@ def _root_java(file_path: str, workspace: str) -> Optional[str]:
|
|||
)
|
||||
|
||||
|
||||
def _root_powershell(file_path: str, workspace: str) -> Optional[str]:
|
||||
# PowerShell projects rarely have a universal root marker. Use the
|
||||
# PSScriptAnalyzer settings file when present, otherwise fall back to
|
||||
# the git workspace root (nearest_root does exact-name matching only,
|
||||
# so no globs here).
|
||||
return _root_or_workspace(
|
||||
file_path,
|
||||
workspace,
|
||||
["PSScriptAnalyzerSettings.psd1"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1012,6 +1152,13 @@ SERVERS: List[ServerDef] = [
|
|||
build_spawn=_spawn_jdtls,
|
||||
description="Java — Eclipse JDT Language Server",
|
||||
),
|
||||
ServerDef(
|
||||
server_id="powershell",
|
||||
extensions=(".ps1", ".psm1", ".psd1"),
|
||||
resolve_root=_root_powershell,
|
||||
build_spawn=_spawn_powershell_es,
|
||||
description="PowerShell — PowerShellEditorServices (manual bundle)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
114
tests/agent/lsp/test_powershell_server.py
Normal file
114
tests/agent/lsp/test_powershell_server.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for the PowerShellEditorServices (PSES) server registration.
|
||||
|
||||
PSES is unusual among the registry entries: it's a PowerShell module
|
||||
bundle (GitHub release zip) driven by a ``pwsh`` bootstrap script, not a
|
||||
single binary on PATH. These tests cover the registry wiring plus the
|
||||
two-prerequisite spawn logic (pwsh host + module bundle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import agent.lsp.servers as srv
|
||||
from agent.lsp.install import detect_status
|
||||
from agent.lsp.servers import (
|
||||
ServerContext,
|
||||
find_server_for_file,
|
||||
language_id_for,
|
||||
)
|
||||
|
||||
|
||||
def test_powershell_extensions_route_to_pses():
|
||||
for ext in ("script.ps1", "module.psm1", "manifest.psd1"):
|
||||
s = find_server_for_file(ext)
|
||||
assert s is not None, ext
|
||||
assert s.server_id == "powershell"
|
||||
|
||||
|
||||
def test_powershell_language_ids():
|
||||
assert language_id_for("a.ps1") == "powershell"
|
||||
assert language_id_for("a.psm1") == "powershell"
|
||||
assert language_id_for("a.psd1") == "powershell"
|
||||
|
||||
|
||||
def test_powershell_install_status_is_manual_tier():
|
||||
# PSES has no npm/go/pip recipe; it's manual-only (like rust-analyzer).
|
||||
# When pwsh isn't on PATH the status is manual-only, not "missing".
|
||||
status = detect_status("powershell")
|
||||
assert status in {"manual-only", "installed"}
|
||||
|
||||
|
||||
def test_spawn_skips_when_pwsh_missing(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(srv, "_which", lambda *names: None)
|
||||
ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual")
|
||||
assert srv._spawn_powershell_es(str(tmp_path), ctx) is None
|
||||
|
||||
|
||||
def test_spawn_skips_when_bundle_missing(monkeypatch, tmp_path):
|
||||
# pwsh present, but no bundle anywhere.
|
||||
monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh")
|
||||
monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
|
||||
ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual")
|
||||
assert srv._spawn_powershell_es(str(tmp_path), ctx) is None
|
||||
|
||||
|
||||
def _make_fake_bundle(root) -> str:
|
||||
bundle = root / "PowerShellEditorServices"
|
||||
inner = bundle / "PowerShellEditorServices"
|
||||
inner.mkdir(parents=True)
|
||||
(inner / "Start-EditorServices.ps1").write_text("# fake")
|
||||
return str(bundle)
|
||||
|
||||
|
||||
def test_spawn_builds_command_with_bundle_via_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
|
||||
bundle = _make_fake_bundle(tmp_path)
|
||||
monkeypatch.setenv("PSES_BUNDLE_PATH", bundle)
|
||||
|
||||
ctx = ServerContext(workspace_root=str(tmp_path), install_strategy="manual")
|
||||
spec = srv._spawn_powershell_es(str(tmp_path), ctx)
|
||||
assert spec is not None
|
||||
assert spec.command[0] == "/usr/bin/pwsh"
|
||||
assert "-Stdio" in spec.command[-1]
|
||||
assert "Start-EditorServices.ps1" in spec.command[-1]
|
||||
assert bundle in spec.command[-1]
|
||||
# -NonInteractive / -NoProfile keep the host from hanging on a prompt.
|
||||
assert "-NonInteractive" in spec.command
|
||||
assert "-NoProfile" in spec.command
|
||||
|
||||
|
||||
def test_spawn_prefers_command_override_bundle(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
|
||||
monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False)
|
||||
bundle = _make_fake_bundle(tmp_path)
|
||||
|
||||
ctx = ServerContext(
|
||||
workspace_root=str(tmp_path),
|
||||
install_strategy="manual",
|
||||
binary_overrides={"powershell": [bundle]},
|
||||
)
|
||||
spec = srv._spawn_powershell_es(str(tmp_path), ctx)
|
||||
assert spec is not None
|
||||
assert bundle in spec.command[-1]
|
||||
|
||||
|
||||
def test_bundle_path_init_override_not_leaked_into_init_options(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(srv, "_which", lambda *names: "/usr/bin/pwsh")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
|
||||
monkeypatch.delenv("PSES_BUNDLE_PATH", raising=False)
|
||||
bundle = _make_fake_bundle(tmp_path)
|
||||
|
||||
ctx = ServerContext(
|
||||
workspace_root=str(tmp_path),
|
||||
install_strategy="manual",
|
||||
init_overrides={"powershell": {"bundlePath": bundle, "foo": "bar"}},
|
||||
)
|
||||
spec = srv._spawn_powershell_es(str(tmp_path), ctx)
|
||||
assert spec is not None
|
||||
# bundlePath is a Hermes-internal resolution key — it must not be sent
|
||||
# to the server as an LSP initializationOption.
|
||||
assert "bundlePath" not in spec.initialization_options
|
||||
assert spec.initialization_options.get("foo") == "bar"
|
||||
|
|
@ -86,12 +86,35 @@ agent sees a syntax-clean file with semantic problems as
|
|||
| Prisma | `prisma language-server` | manual |
|
||||
| Kotlin | `kotlin-language-server` | manual |
|
||||
| Java | `jdtls` | manual |
|
||||
| PowerShell | `PowerShellEditorServices` (`pwsh` host) | manual (release zip) |
|
||||
|
||||
For "manual" entries, install the server through whatever toolchain
|
||||
manager makes sense for that language (rustup, ghcup, opam, brew,
|
||||
…). Hermes auto-detects the binary on PATH or in
|
||||
`<HERMES_HOME>/lsp/bin/`.
|
||||
|
||||
### PowerShell
|
||||
|
||||
PowerShellEditorServices isn't a single binary — it's a PowerShell
|
||||
module bundle launched by a `pwsh` (PowerShell 7+) or `powershell`
|
||||
host. Setup:
|
||||
|
||||
1. Install [PowerShell](https://github.com/PowerShell/PowerShell) so
|
||||
`pwsh` (or Windows `powershell`) is on PATH.
|
||||
2. Download the latest release zip from
|
||||
[PowerShellEditorServices releases](https://github.com/PowerShell/PowerShellEditorServices/releases)
|
||||
and extract it.
|
||||
3. Point Hermes at the extracted bundle — the directory that contains
|
||||
`PowerShellEditorServices/Start-EditorServices.ps1`. Either:
|
||||
- set `lsp.servers.powershell.command: ["/path/to/bundle"]` in
|
||||
`config.yaml`, or
|
||||
- extract it to `<HERMES_HOME>/lsp/PowerShellEditorServices`, or
|
||||
- export `PSES_BUNDLE_PATH=/path/to/bundle`.
|
||||
|
||||
`hermes lsp status` reports `installed` once `pwsh` is found; if the
|
||||
bundle is missing you'll see a one-time warning in the logs with the
|
||||
download link.
|
||||
|
||||
A few servers are installed alongside a peer dependency that npm
|
||||
won't auto-pull. The current case is `typescript-language-server`,
|
||||
which requires the `typescript` SDK importable from the same
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue