diff --git a/docs/src/content/docs/consumer/install-lsp-servers.md b/docs/src/content/docs/consumer/install-lsp-servers.md index 3c663b45f..57d5b3d03 100644 --- a/docs/src/content/docs/consumer/install-lsp-servers.md +++ b/docs/src/content/docs/consumer/install-lsp-servers.md @@ -165,9 +165,10 @@ See the [Lockfile specification](../../reference/lockfile-spec/). When APM installs a plugin that contains `lspServers` in `plugin.json` or a `.lsp.json` file, the LSP servers are automatically extracted and -wired into the install pipeline. The `${CLAUDE_PLUGIN_ROOT}` placeholder -in server configs is replaced with the absolute plugin path for legacy -Claude Code plugin compatibility. +wired into the install pipeline. Plugin `.lsp.json` files may use either +a flat server map or a `{ "lspServers": { ... } }` envelope. The +`${CLAUDE_PLUGIN_ROOT}` placeholder in server configs is replaced with +the absolute plugin path for legacy Claude Code plugin compatibility. ## Runtime support diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index 963085a2f..de4902608 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -379,7 +379,8 @@ Optional fields: `args`, `transport`, `env`, `initializationOptions`, Claude Code uses `.lsp.json` or `~/.claude.json`, and GitHub Copilot CLI uses `.github/lsp.json` or `~/.copilot/lsp-config.json`. Copilot CLI uses `fileExtensions` on disk; manifests continue to use -`extensionToLanguage`. +`extensionToLanguage`. Plugin `.lsp.json` files may use either a flat +server map or a `{ "lspServers": { ... } }` envelope. ## Version pinning diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py index 8e34d8b8f..a2ddc825c 100644 --- a/src/apm_cli/deps/plugin_parser.py +++ b/src/apm_cli/deps/plugin_parser.py @@ -482,8 +482,13 @@ def _read_lsp_file(plugin_path: Path, rel_path: str, logger: logging.Logger) -> def _read_lsp_json(path: Path, logger: logging.Logger) -> dict[str, Any]: """Parse a JSON file and return the LSP servers mapping. - Unlike .mcp.json which has a wrapper key, .lsp.json uses server names - as top-level keys directly. + Accepts two formats: + - Flat: top-level keys are server names (e.g. ``{"pyright": {...}}``). + - Wrapped: a ``"lspServers"`` envelope wraps the servers + (e.g. ``{"lspServers": {"pyright": {...}}}``). + + The wrapped format is standard in Copilot ``.github/lsp.json`` and + Claude ``~/.claude.json``. Plugins may ship either variant. """ try: data = json.loads(path.read_text(encoding="utf-8")) @@ -492,6 +497,19 @@ def _read_lsp_json(path: Path, logger: logging.Logger) -> dict[str, Any]: return {} if not isinstance(data, dict): return {} + # Unwrap the { "lspServers": { ... } } envelope when present. + # Only unwrap when the inner value looks like a server *map* (all values + # are dicts). A flat-format server literally named "lspServers" would + # have scalar values like "command", so the all-dicts check avoids + # mis-detecting it as an envelope. + lsp_inner = data.get("lspServers") + if ( + isinstance(lsp_inner, dict) + and lsp_inner + and all(isinstance(v, dict) for v in lsp_inner.values()) + ): + logger.debug("Unwrapped lspServers envelope in %s", path) + return dict(lsp_inner) return dict(data) diff --git a/tests/unit/deps/test_plugin_parser_lsp.py b/tests/unit/deps/test_plugin_parser_lsp.py index 12fecc18a..629e4ed5c 100644 --- a/tests/unit/deps/test_plugin_parser_lsp.py +++ b/tests/unit/deps/test_plugin_parser_lsp.py @@ -8,11 +8,15 @@ from __future__ import annotations import json +import logging + +import yaml from apm_cli.deps.plugin_parser import ( _extract_lsp_servers, _lsp_servers_to_apm_deps, _read_lsp_json, + synthesize_apm_yml_from_plugin, ) # =========================================================================== @@ -25,8 +29,6 @@ def test_reads_valid_json(self, tmp_path, caplog): lsp_file = tmp_path / ".lsp.json" lsp_file.write_text(json.dumps({"pyright": {"command": "pyright-langserver"}})) - import logging - result = _read_lsp_json(lsp_file, logging.getLogger("test")) assert "pyright" in result @@ -34,8 +36,6 @@ def test_returns_empty_on_invalid_json(self, tmp_path, caplog): lsp_file = tmp_path / ".lsp.json" lsp_file.write_text("not valid json{") - import logging - result = _read_lsp_json(lsp_file, logging.getLogger("test")) assert result == {} @@ -43,11 +43,61 @@ def test_returns_empty_on_non_dict_json(self, tmp_path): lsp_file = tmp_path / ".lsp.json" lsp_file.write_text(json.dumps(["not", "a", "dict"])) - import logging - result = _read_lsp_json(lsp_file, logging.getLogger("test")) assert result == {} + def test_unwraps_lsp_servers_envelope_without_warning(self, tmp_path, caplog): + """A .lsp.json using { "lspServers": { ... } } is unwrapped without warnings.""" + lsp_file = tmp_path / ".lsp.json" + lsp_file.write_text( + json.dumps( + { + "lspServers": { + "my-lsp": { + "command": "my-lsp-bin", + "extensionToLanguage": {".ext": "mylang"}, + } + } + } + ) + ) + + with caplog.at_level(logging.WARNING, logger="test"): + result = _read_lsp_json(lsp_file, logging.getLogger("test")) + + assert "my-lsp" in result + assert result["my-lsp"]["command"] == "my-lsp-bin" + assert "lspServers" not in result + assert not caplog.records + + def test_flat_format_still_works(self, tmp_path): + """Flat format (server names as top-level keys) is unchanged.""" + lsp_file = tmp_path / ".lsp.json" + lsp_file.write_text(json.dumps({"pyright": {"command": "pyright-langserver"}})) + + result = _read_lsp_json(lsp_file, logging.getLogger("test")) + assert "pyright" in result + assert result["pyright"]["command"] == "pyright-langserver" + + def test_flat_server_named_lspservers_not_unwrapped(self, tmp_path): + """A flat-format server literally named 'lspServers' must not be mis-detected as an envelope.""" + lsp_file = tmp_path / ".lsp.json" + lsp_file.write_text( + json.dumps( + { + "lspServers": { + "command": "my-lsp", + "extensionToLanguage": {".py": "python"}, + } + } + ) + ) + + result = _read_lsp_json(lsp_file, logging.getLogger("test")) + # Should keep "lspServers" as a server name, not unwrap it + assert "lspServers" in result + assert result["lspServers"]["command"] == "my-lsp" + # =========================================================================== # _extract_lsp_servers @@ -94,6 +144,29 @@ def test_auto_discovery_of_lsp_json(self, tmp_path): result = _extract_lsp_servers(tmp_path, manifest) assert "ts-lsp" in result + def test_auto_discovery_with_lsp_servers_wrapper(self, tmp_path): + """Auto-discovered .lsp.json using the { "lspServers": ... } envelope.""" + lsp_json = tmp_path / ".lsp.json" + lsp_json.write_text( + json.dumps( + { + "lspServers": { + "my-lsp-server": { + "command": "my-lsp", + "args": ["--stdio"], + "extensionToLanguage": {".ext": "mylang"}, + } + } + } + ) + ) + + manifest = {} # No lspServers key -- triggers auto-discovery + result = _extract_lsp_servers(tmp_path, manifest) + assert "my-lsp-server" in result + assert result["my-lsp-server"]["command"] == "my-lsp" + assert "lspServers" not in result + def test_no_lsp_servers_no_file_returns_empty(self, tmp_path): result = _extract_lsp_servers(tmp_path, {}) assert result == {} @@ -211,3 +284,51 @@ def test_all_fields_copied(self, tmp_path): assert d["startupTimeout"] == 5000 assert d["restartOnCrash"] is True assert d["maxRestarts"] == 3 + + def test_wrapped_lsp_json_produces_valid_deps(self, tmp_path): + """End-to-end: .lsp.json with lspServers wrapper yields valid deps.""" + lsp_json = tmp_path / ".lsp.json" + lsp_json.write_text( + json.dumps( + { + "lspServers": { + "my-lsp-server": { + "command": "my-lsp", + "args": ["--stdio"], + "extensionToLanguage": {".ext": "mylang"}, + } + } + } + ) + ) + servers = _extract_lsp_servers(tmp_path, {}) + deps = _lsp_servers_to_apm_deps(servers, tmp_path) + assert len(deps) == 1 + assert deps[0]["name"] == "my-lsp-server" + assert deps[0]["command"] == "my-lsp" + + def test_wrapped_lsp_json_is_written_to_apm_yml_deps(self, tmp_path): + """Wrapped .lsp.json becomes dependencies.lsp in synthesized apm.yml.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / ".lsp.json").write_text( + json.dumps( + { + "lspServers": { + "my-lsp-server": { + "command": "my-lsp", + "args": ["--stdio"], + "extensionToLanguage": {".ext": "mylang"}, + } + } + } + ) + ) + + apm_yml = synthesize_apm_yml_from_plugin(plugin_dir, {"name": "wrapped-lsp"}) + parsed = yaml.safe_load(apm_yml.read_text()) + + lsp_deps = parsed["dependencies"]["lsp"] + assert len(lsp_deps) == 1 + assert lsp_deps[0]["name"] == "my-lsp-server" + assert lsp_deps[0]["command"] == "my-lsp"