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:
Teknium 2026-06-30 16:22:18 -07:00 committed by GitHub
parent 812236bff8
commit 97e0bbef53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 289 additions and 0 deletions

View file

@ -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"},
}

View file

@ -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)",
),
]