Skip to main content

mur_common/
serena_ls_cmd.rs

1//! The one launch command MUR lets serena use for a language server whose
2//! default entry point is a shebang script (#1945; TypeScript #1981).
3//!
4//! serena launches intelephense as `node_modules/.bin/intelephense --stdio`,
5//! a `#!/usr/bin/env node` script under the agent-writable `SERENA_HOME`.
6//! The macOS seal checks exec on the script's own path, so that never runs,
7//! and granting exec there would let anything npm drops in run too. Instead
8//! `mur code-nav setup` pre-installs the pinned package under MUR's tools
9//! root and writes `ls_base_cmd: [node, <entry .js>]` into
10//! `serena_config.yml`; the runtime preflight (C7) accepts an `ls_base_cmd`
11//! only when it is exactly what [`canonical_base_cmd`] returns.
12//!
13//! Both sides call this function, so what setup writes and what C7 accepts
14//! cannot drift. It lives here because `mur-agent-runtime` (C7) must not
15//! depend on `mur-core` (setup).
16//!
17//! `--stdio` is deliberately absent: serena appends it itself
18//! (`LanguageServerDependencyProviderSinglePath`, serena-agent 2.0.0.dev0),
19//! so including it would pass it twice.
20
21use std::path::{Path, PathBuf};
22
23/// A node-run language server MUR pre-installs under `<tools>/<tool_dir>/
24/// <pin>/` and launches as `[node, <entry .js>]` (#1945, #1981). One row
25/// per server; setup's npm installer, the config writer and C7 all read
26/// this table, so adding a server is adding a row.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ManagedNodeLs {
29    /// serena's `ls_specific_settings` key.
30    pub ls_id: &'static str,
31    /// Directory under `<mur_home>/tools/`.
32    pub tool_dir: &'static str,
33    /// `(npm package, exact version)`, installed together. The first is
34    /// the server itself; its version names the install dir.
35    pub packages: &'static [(&'static str, &'static str)],
36    /// The server's `bin` target, relative to the install dir.
37    pub entry_rel: &'static [&'static str],
38    /// Further files, relative to the install dir, an install is not
39    /// complete without. Vue's `.bin` shims: serena execs them, and
40    /// without this a verified-but-shimless install could never be
41    /// repaired by re-running setup (#1998 review).
42    pub required_rel: &'static [&'static [&'static str]],
43}
44
45impl ManagedNodeLs {
46    /// The server package's pinned version.
47    pub fn pin(&self) -> &'static str {
48        self.packages[0].1
49    }
50
51    /// `<tools_dir>/<tool_dir>/<pin>/`.
52    pub fn dir(&self, tools_dir: &Path) -> PathBuf {
53        tools_dir.join(self.tool_dir).join(self.pin())
54    }
55
56    /// Every file in [`Self::required_rel`], under `tools_dir`.
57    pub fn required(&self, tools_dir: &Path) -> impl Iterator<Item = PathBuf> + '_ {
58        let dir = self.dir(tools_dir);
59        self.required_rel.iter().map(move |rel| {
60            let mut p = dir.clone();
61            p.extend(*rel);
62            p
63        })
64    }
65
66    /// The `.js` / `.mjs` node runs.
67    pub fn entry(&self, tools_dir: &Path) -> PathBuf {
68        let mut p = self.dir(tools_dir);
69        p.extend(self.entry_rel);
70        p
71    }
72}
73
74/// serena's `ls_specific_settings` key for intelephense.
75pub const PHP_LS_ID: &str = "php";
76/// Directory under `<mur_home>/tools/` the package is installed into.
77pub const INTELEPHENSE_TOOL_DIR: &str = "intelephense";
78/// npm package name.
79pub const INTELEPHENSE_PACKAGE: &str = "intelephense";
80/// serena's `DEFAULT_INTELEPHENSE_VERSION` at the serena pin. Bump together.
81pub const INTELEPHENSE_PIN: &str = "1.14.4";
82
83pub const INTELEPHENSE: ManagedNodeLs = ManagedNodeLs {
84    ls_id: PHP_LS_ID,
85    tool_dir: INTELEPHENSE_TOOL_DIR,
86    packages: &[(INTELEPHENSE_PACKAGE, INTELEPHENSE_PIN)],
87    entry_rel: &[
88        "node_modules",
89        INTELEPHENSE_PACKAGE,
90        "lib",
91        "intelephense.js",
92    ],
93    required_rel: &[],
94};
95
96/// serena's `ls_specific_settings` key for typescript-language-server.
97pub const TS_LS_ID: &str = "typescript";
98pub const TS_LS_PACKAGE: &str = "typescript-language-server";
99/// serena's `DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION` at the serena pin.
100pub const TS_LS_PIN: &str = "5.1.3";
101/// serena's `DEFAULT_TYPESCRIPT_VERSION` at the serena pin. The server
102/// resolves `typescript` from its own `node_modules` when the repo has none.
103pub const TYPESCRIPT_PIN: &str = "5.9.3";
104
105pub const TYPESCRIPT: ManagedNodeLs = ManagedNodeLs {
106    ls_id: TS_LS_ID,
107    tool_dir: TS_LS_PACKAGE,
108    packages: &[(TS_LS_PACKAGE, TS_LS_PIN), ("typescript", TYPESCRIPT_PIN)],
109    entry_rel: &["node_modules", TS_LS_PACKAGE, "lib", "cli.mjs"],
110    required_rel: &[],
111};
112
113/// serena's `ls_specific_settings` key for the Vue server (#1982).
114pub const VUE_LS_ID: &str = "vue";
115/// Directory under `<mur_home>/tools/` the Vue server is installed into.
116pub const VUE_TOOL_DIR: &str = "vue-language-server";
117pub const VUE_LS_PACKAGE: &str = "@vue/language-server";
118/// serena's `vue_language_server_version` default at the serena pin.
119pub const VUE_LS_PIN: &str = "3.1.5";
120
121/// The Vue server and its companion TypeScript server, installed together
122/// (#1982). Deliberately NOT in [`MANAGED`]: serena's Vue server has no
123/// `ls_base_cmd` hook — it always execs `vue-lsp/node_modules/.bin/*` under
124/// its own resources dir — so setup links that dir to this install instead,
125/// and no `ls_base_cmd` is ever written (or accepted by C7) for `vue`. The
126/// TypeScript pins are [`TYPESCRIPT`]'s, because serena reads both from
127/// `ls_specific_settings.typescript`, so the npm cache serves both installs.
128pub const VUE: ManagedNodeLs = ManagedNodeLs {
129    ls_id: VUE_LS_ID,
130    tool_dir: VUE_TOOL_DIR,
131    packages: &[
132        (VUE_LS_PACKAGE, VUE_LS_PIN),
133        ("typescript", TYPESCRIPT_PIN),
134        (TS_LS_PACKAGE, TS_LS_PIN),
135    ],
136    entry_rel: &[
137        "node_modules",
138        "@vue",
139        "language-server",
140        "bin",
141        "vue-language-server.js",
142    ],
143    required_rel: &[
144        &crate::serena_vue::VUE_SHIM_REL,
145        &crate::serena_vue::TS_SHIM_REL,
146    ],
147};
148
149/// Every server MUR launches through a canonical `ls_base_cmd`.
150pub const MANAGED: [ManagedNodeLs; 2] = [INTELEPHENSE, TYPESCRIPT];
151
152/// The managed server for serena key `ls_id`, if MUR manages it.
153pub fn managed(ls_id: &str) -> Option<&'static ManagedNodeLs> {
154    MANAGED.iter().find(|m| m.ls_id == ls_id)
155}
156
157/// The interpreter, left bare so the sandbox seal resolves it against the
158/// agent's spawn allowlist like any other `node` launch.
159pub const NODE: &str = "node";
160
161/// `<tools_dir>/intelephense/<pin>/`.
162pub fn intelephense_dir(tools_dir: &Path) -> PathBuf {
163    INTELEPHENSE.dir(tools_dir)
164}
165
166/// The `.js` entry node runs.
167pub fn intelephense_entry(tools_dir: &Path) -> PathBuf {
168    INTELEPHENSE.entry(tools_dir)
169}
170
171/// The exact `ls_base_cmd` MUR writes for `ls_id`, or `None` when MUR never
172/// overrides that server's base command (then C7 refuses any value). Also
173/// `None` for a non-UTF-8 tools root, which YAML could not carry anyway.
174pub fn canonical_base_cmd(ls_id: &str, tools_dir: &Path) -> Option<Vec<String>> {
175    let entry = managed(ls_id)?.entry(tools_dir);
176    Some(vec![NODE.to_owned(), entry.to_str()?.to_owned()])
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn php_gets_node_plus_entry_without_stdio() {
185        let tools = Path::new("/h/.mur/tools");
186        let cmd = canonical_base_cmd(PHP_LS_ID, tools).unwrap();
187        assert_eq!(cmd.len(), 2, "serena appends --stdio itself: {cmd:?}");
188        assert_eq!(cmd[0], NODE);
189        assert_eq!(Path::new(&cmd[1]), intelephense_entry(tools));
190        assert!(cmd[1].ends_with("intelephense.js"));
191        assert!(Path::new(&cmd[1]).starts_with(intelephense_dir(tools)));
192    }
193
194    #[test]
195    fn typescript_gets_node_plus_cli_mjs_without_stdio() {
196        let tools = Path::new("/h/.mur/tools");
197        let cmd = canonical_base_cmd(TS_LS_ID, tools).unwrap();
198        assert_eq!(cmd.len(), 2, "serena appends --stdio itself: {cmd:?}");
199        assert_eq!(cmd[0], NODE);
200        assert_eq!(
201            Path::new(&cmd[1]),
202            Path::new(
203                "/h/.mur/tools/typescript-language-server/5.1.3/node_modules/typescript-language-server/lib/cli.mjs"
204            )
205        );
206    }
207
208    /// serena's TypeScript server needs `typescript` next to it; both are
209    /// pinned to serena's DEFAULT_* at the serena pin.
210    #[test]
211    fn typescript_installs_both_pinned_packages() {
212        assert_eq!(
213            TYPESCRIPT.packages,
214            [
215                ("typescript-language-server", "5.1.3"),
216                ("typescript", "5.9.3")
217            ]
218        );
219    }
220
221    #[test]
222    fn every_managed_server_is_found_by_its_ls_id() {
223        for ls in MANAGED {
224            assert_eq!(managed(ls.ls_id).map(|m| m.tool_dir), Some(ls.tool_dir));
225        }
226    }
227
228    #[test]
229    fn other_servers_have_no_canonical_base_cmd() {
230        for id in [
231            "python",
232            "cpp",
233            "rust",
234            "typescript_vts",
235            "php_phpactor",
236            VUE_LS_ID,
237            "",
238        ] {
239            assert!(canonical_base_cmd(id, Path::new("/t")).is_none(), "{id}");
240        }
241    }
242}