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}