Skip to main content

lean_ctx/lsp/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct LspServerConfig {
6    pub command: String,
7    pub args: Vec<String>,
8}
9
10#[derive(Debug, Clone)]
11pub struct LspServerInfo {
12    pub language: &'static str,
13    pub binary: &'static str,
14    pub install_hint: &'static str,
15}
16
17pub const KNOWN_SERVERS: &[LspServerInfo] = &[
18    LspServerInfo {
19        language: "rust",
20        binary: "rust-analyzer",
21        install_hint: "rustup component add rust-analyzer",
22    },
23    LspServerInfo {
24        language: "typescript",
25        binary: "typescript-language-server",
26        // TypeScript ≥ 7 is its own language server; older versions need
27        // typescript-language-server as well.
28        install_hint: "npm install -g typescript (≤ 6: plus typescript-language-server)",
29    },
30    LspServerInfo {
31        language: "python",
32        binary: "pylsp",
33        install_hint: "pip install python-lsp-server",
34    },
35    LspServerInfo {
36        language: "go",
37        binary: "gopls",
38        install_hint: "go install golang.org/x/tools/gopls@latest",
39    },
40];
41
42pub fn default_servers() -> HashMap<&'static str, LspServerConfig> {
43    let mut m = HashMap::new();
44    m.insert(
45        "rust",
46        LspServerConfig {
47            command: "rust-analyzer".into(),
48            args: vec![],
49        },
50    );
51    m.insert(
52        "typescript",
53        LspServerConfig {
54            command: "typescript-language-server".into(),
55            args: vec!["--stdio".into()],
56        },
57    );
58    m.insert(
59        "javascript",
60        LspServerConfig {
61            command: "typescript-language-server".into(),
62            args: vec!["--stdio".into()],
63        },
64    );
65    m.insert(
66        "python",
67        LspServerConfig {
68            command: "pylsp".into(),
69            args: vec![],
70        },
71    );
72    m.insert(
73        "go",
74        LspServerConfig {
75            command: "gopls".into(),
76            args: vec!["serve".into()],
77        },
78    );
79    m
80}
81
82pub fn language_for_extension(ext: &str) -> Option<&'static str> {
83    match ext {
84        "rs" => Some("rust"),
85        "ts" | "tsx" => Some("typescript"),
86        "js" | "jsx" | "mjs" | "cjs" => Some("javascript"),
87        "py" | "pyi" => Some("python"),
88        "go" => Some("go"),
89        "java" => Some("java"),
90        "kt" | "kts" => Some("kotlin"),
91        "rb" => Some("ruby"),
92        "c" | "h" => Some("c"),
93        "cpp" | "cxx" | "cc" | "hpp" => Some("cpp"),
94        "cs" => Some("csharp"),
95        _ => None,
96    }
97}
98
99pub fn find_binary_in_path(binary: &str) -> Option<PathBuf> {
100    let path_var = std::env::var("PATH").ok()?;
101    std::env::split_paths(&path_var).find_map(|dir| executable_in(&dir, binary))
102}
103
104/// `binary` in `dir`. On Windows, npm installs `.cmd` shims (next to an
105/// extensionless shell script that Windows cannot run), so `.exe` and `.cmd`
106/// come first; `std::process::Command` runs `.cmd` files through `cmd.exe`.
107fn executable_in(dir: &Path, binary: &str) -> Option<PathBuf> {
108    let names: &[String] = if cfg!(windows) {
109        &[
110            format!("{binary}.exe"),
111            format!("{binary}.cmd"),
112            binary.to_string(),
113        ]
114    } else {
115        &[binary.to_string()]
116    };
117    names.iter().map(|n| dir.join(n)).find(|p| p.is_file())
118}
119
120/// Like [`find_binary_in_path`], but only returns servers that can actually
121/// run. A rustup proxy (`~/.cargo/bin/rust-analyzer` → `rustup`) exists even
122/// when the component is not installed and then fails on start; such a proxy
123/// counts only if `--version` succeeds. Plain binaries are not executed.
124pub fn find_runnable_server(binary: &str) -> Option<PathBuf> {
125    find_binary_in_path(binary).and_then(runnable)
126}
127
128fn runnable(path: PathBuf) -> Option<PathBuf> {
129    let is_rustup_proxy = std::fs::read_link(&path).is_ok_and(|target| {
130        target
131            .file_stem()
132            .is_some_and(|stem| stem.eq_ignore_ascii_case("rustup"))
133    });
134    if !is_rustup_proxy {
135        return Some(path);
136    }
137    // Never let the probe install a toolchain (a `rust-toolchain.toml` in the
138    // working directory could otherwise trigger a download).
139    std::process::Command::new(&path)
140        .arg("--version")
141        .env("RUSTUP_AUTO_INSTALL", "0")
142        .current_dir(std::env::temp_dir())
143        .stdin(std::process::Stdio::null())
144        .stdout(std::process::Stdio::null())
145        .stderr(std::process::Stdio::null())
146        .status()
147        .is_ok_and(|s| s.success())
148        .then_some(path)
149}
150
151/// A language server lean-ctx can start, as resolved for one project.
152#[derive(Debug, Clone, PartialEq)]
153pub struct ResolvedServer {
154    /// The installed server binary (or npm shim) — what users know it by.
155    pub binary: PathBuf,
156    /// How to start it (may differ from `binary`: an npm shim on Windows runs through Node).
157    pub config: LspServerConfig,
158    /// Sent as `initializationOptions`.
159    pub init_options: Option<serde_json::Value>,
160}
161
162impl ResolvedServer {
163    /// Display name of the server binary (`gopls`, `tsc`, …), without a
164    /// Windows extension.
165    pub fn binary_name(&self) -> String {
166        self.binary.file_stem().map_or_else(
167            || self.binary.to_string_lossy().into_owned(),
168            |n| n.to_string_lossy().into_owned(),
169        )
170    }
171
172    /// Where the binary lives.
173    pub fn binary_path(&self) -> &Path {
174        &self.binary
175    }
176
177    fn new(binary: PathBuf, config: LspServerConfig) -> Self {
178        Self {
179            binary,
180            config,
181            init_options: None,
182        }
183    }
184}
185
186const TS_LANGUAGE_SERVER: &str = "typescript-language-server";
187
188/// The server lean-ctx would start for `language` in `project_root`, or
189/// `None` when none can run. `project_root = None` asks what this machine
190/// provides (doctor). The command is the resolved path, so starting it does
191/// not depend on `PATH` lookup rules.
192pub fn resolve_server(language: &str, project_root: Option<&Path>) -> Option<ResolvedServer> {
193    if matches!(language, "typescript" | "javascript") {
194        return resolve_typescript(project_root, &find_binary_in_path);
195    }
196    let mut config = default_servers().remove(language)?;
197    let binary = find_runnable_server(&config.command)?;
198    config.command = binary.to_string_lossy().into_owned();
199    Some(ResolvedServer::new(binary, config))
200}
201
202/// A server binary the user configured explicitly (`[lsp] <language> =
203/// "<path>"`), with the arguments and options its kind needs.
204pub fn configured_server(language: &str, command: &str, project_root: &Path) -> ResolvedServer {
205    let binary = PathBuf::from(command);
206    let stem = binary
207        .file_stem()
208        .map(|s| s.to_string_lossy().to_ascii_lowercase());
209    let plain = |args: &[&str]| LspServerConfig {
210        command: command.to_string(),
211        args: args.iter().map(|a| (*a).to_string()).collect(),
212    };
213    let (config, init_options) = match (language, stem.as_deref()) {
214        ("typescript" | "javascript", Some("tsc")) => (
215            npm_launch(
216                &find_binary_in_path,
217                &binary,
218                "typescript",
219                "tsc",
220                &["--lsp", "--stdio"],
221            )
222            .unwrap_or_else(|| plain(&["--lsp", "--stdio"])),
223            None,
224        ),
225        ("typescript" | "javascript", Some(TS_LANGUAGE_SERVER)) => (
226            npm_launch(
227                &find_binary_in_path,
228                &binary,
229                TS_LANGUAGE_SERVER,
230                TS_LANGUAGE_SERVER,
231                &["--stdio"],
232            )
233            .unwrap_or_else(|| plain(&["--stdio"])),
234            project_typescript(project_root)
235                .is_none()
236                .then(|| tsserver_options(&binary))
237                .flatten(),
238        ),
239        ("typescript" | "javascript", _) => (plain(&["--stdio"]), None),
240        ("go", _) => (plain(&["serve"]), None),
241        _ => (plain(&[]), None),
242    };
243    ResolvedServer {
244        binary,
245        config,
246        init_options,
247    }
248}
249
250/// TypeScript ≤ 6 ships `tsserver.js`, which `typescript-language-server`
251/// drives. TypeScript ≥ 7 (the native port) has no `tsserver.js`; its `tsc`
252/// is a language server itself (`tsc --lsp --stdio`). The project's own
253/// TypeScript decides; without one, what is installed machine-wide. Versions
254/// are read from `package.json` — nothing is executed to find out.
255fn resolve_typescript(
256    project_root: Option<&Path>,
257    which: &dyn Fn(&str) -> Option<PathBuf>,
258) -> Option<ResolvedServer> {
259    let tls = |init_options| {
260        let binary = which(TS_LANGUAGE_SERVER)?;
261        Some(ResolvedServer {
262            config: npm_launch(
263                which,
264                &binary,
265                TS_LANGUAGE_SERVER,
266                TS_LANGUAGE_SERVER,
267                &["--stdio"],
268            )?,
269            binary,
270            init_options,
271        })
272    };
273    if let Some(node_modules) = project_root.and_then(project_typescript) {
274        return if typescript_major(&node_modules.join("typescript"))? >= 7 {
275            native_tsc(which, executable_in(&node_modules.join(".bin"), "tsc")?)
276        } else {
277            // It finds the workspace's TypeScript on its own.
278            tls(None)
279        };
280    }
281    // `npm install -g typescript-language-server typescript` (TypeScript ≤ 6):
282    // the server does not look beside itself, so its `tsserver.js` is passed.
283    if let Some(options) = which(TS_LANGUAGE_SERVER).and_then(|p| tsserver_options(&p)) {
284        return tls(Some(options));
285    }
286    // `npm install -g typescript` (TypeScript ≥ 7).
287    let tsc = which("tsc")?;
288    if typescript_major(&npm_package_of(&tsc, "typescript")?)? >= 7 {
289        native_tsc(which, tsc)
290    } else {
291        None
292    }
293}
294
295fn native_tsc(which: &dyn Fn(&str) -> Option<PathBuf>, tsc: PathBuf) -> Option<ResolvedServer> {
296    let config = npm_launch(which, &tsc, "typescript", "tsc", &["--lsp", "--stdio"])?;
297    Some(ResolvedServer::new(tsc, config))
298}
299
300/// How to start the npm-installed binary `bin` (`bin_name` of `package`).
301/// A Unix bin is executable itself. A Windows `.cmd`/`.bat` shim is not an
302/// executable image, and `std` running batch files through `cmd.exe` is
303/// documented as not to be relied upon — so, like the shim itself, Node
304/// runs the package's own `bin` entry point.
305fn npm_launch(
306    which: &dyn Fn(&str) -> Option<PathBuf>,
307    bin: &Path,
308    package: &str,
309    bin_name: &str,
310    args: &[&str],
311) -> Option<LspServerConfig> {
312    let args = args.iter().map(|a| (*a).to_string());
313    let is_shim = bin
314        .extension()
315        .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
316    if !is_shim {
317        return bin.is_file().then(|| LspServerConfig {
318            command: bin.to_string_lossy().into_owned(),
319            args: args.collect(),
320        });
321    }
322    let entry = npm_bin_entry(&npm_package_of(bin, package)?, bin_name)?;
323    let node = which("node")?;
324    Some(LspServerConfig {
325        command: node.to_string_lossy().into_owned(),
326        args: std::iter::once(entry.to_string_lossy().into_owned())
327            .chain(args)
328            .collect(),
329    })
330}
331
332/// The script `package.json`'s `bin` maps `bin_name` to (a string `bin` is
333/// the package's only binary).
334fn npm_bin_entry(package: &Path, bin_name: &str) -> Option<PathBuf> {
335    let manifest = std::fs::read_to_string(package.join("package.json")).ok()?;
336    let manifest: serde_json::Value = serde_json::from_str(&manifest).ok()?;
337    let relative = match manifest.get("bin")? {
338        serde_json::Value::String(only) => only.as_str(),
339        bins => bins.get(bin_name)?.as_str()?,
340    };
341    let entry = package.join(relative);
342    // Stays inside the package (a manifest is data, not a path authority).
343    (entry.is_file()
344        && !Path::new(relative).components().any(|c| {
345            matches!(
346                c,
347                std::path::Component::ParentDir
348                    | std::path::Component::RootDir
349                    | std::path::Component::Prefix(_)
350            )
351        }))
352    .then_some(entry)
353}
354
355/// The `node_modules` holding the project's own `typescript`, nearest first.
356fn project_typescript(project_root: &Path) -> Option<PathBuf> {
357    project_root
358        .ancestors()
359        .map(|dir| dir.join("node_modules"))
360        .find(|nm| typescript_major(&nm.join("typescript")).is_some())
361}
362
363/// Major version of the `typescript` package in `package_dir`.
364fn typescript_major(package_dir: &Path) -> Option<u32> {
365    let manifest = std::fs::read_to_string(package_dir.join("package.json")).ok()?;
366    let manifest: serde_json::Value = serde_json::from_str(&manifest).ok()?;
367    if manifest.get("name")?.as_str()? != "typescript" {
368        return None;
369    }
370    manifest
371        .get("version")?
372        .as_str()?
373        .split('.')
374        .next()?
375        .parse()
376        .ok()
377}
378
379/// The npm package directory `name` that the installed binary `bin` belongs
380/// to. Unix: the bin is a symlink into `…/node_modules/<name>/`. Windows: a
381/// `.cmd` shim (not a link) — globally `<prefix>/<bin>.cmd` beside
382/// `<prefix>/node_modules/<name>`, locally `node_modules/.bin/<bin>.cmd`.
383fn npm_package_of(bin: &Path, name: &str) -> Option<PathBuf> {
384    let is_package = |dir: &Path| {
385        dir.file_name().is_some_and(|n| n == name)
386            && dir
387                .parent()
388                .and_then(Path::file_name)
389                .is_some_and(|n| n == "node_modules")
390    };
391    if let Some(dir) = std::fs::canonicalize(bin).ok().and_then(|real| {
392        real.ancestors()
393            .find(|d| is_package(d))
394            .map(Path::to_path_buf)
395    }) {
396        return Some(dir);
397    }
398    let dir = bin.parent()?;
399    let shim_target = if dir.file_name().is_some_and(|n| n == ".bin") {
400        dir.parent()?.join(name)
401    } else {
402        dir.join("node_modules").join(name)
403    };
404    shim_target
405        .join("package.json")
406        .is_file()
407        .then_some(shim_target)
408}
409
410/// `initializationOptions` pointing `typescript-language-server` (installed
411/// as `server`) at the `tsserver.js` installed with it: a nested dependency
412/// or a sibling package.
413fn tsserver_options(server: &Path) -> Option<serde_json::Value> {
414    let package = npm_package_of(server, TS_LANGUAGE_SERVER)?;
415    let tsserver = [
416        package.join("node_modules"),
417        package.parent()?.to_path_buf(),
418    ]
419    .into_iter()
420    .map(|nm| nm.join("typescript/lib/tsserver.js"))
421    .find(|p| p.is_file())?;
422    Some(serde_json::json!({ "tsserver": { "path": tsserver } }))
423}
424
425pub fn install_hint_for_language(language: &str) -> &'static str {
426    for info in KNOWN_SERVERS {
427        if info.language == language {
428            return info.install_hint;
429        }
430    }
431    "No install instructions available for this language server."
432}
433
434pub fn binary_for_language(language: &str) -> Option<&'static str> {
435    for info in KNOWN_SERVERS {
436        if info.language == language {
437            return Some(info.binary);
438        }
439    }
440    None
441}
442
443/// [`resolve_server`] for `project_root`, or an actionable error.
444pub fn check_server_available(
445    language: &str,
446    project_root: &Path,
447) -> Result<ResolvedServer, String> {
448    let servers = default_servers();
449    let config = servers
450        .get(language)
451        .ok_or_else(|| format!("No LSP server configured for '{language}'"))?;
452
453    resolve_server(language, Some(project_root)).ok_or_else(|| {
454        let hint = install_hint_for_language(language);
455        format!(
456            "Language server '{}' not found in PATH (or not installed behind its rustup proxy).\n\
457             \n\
458             LSP-backed symbol operations require an external language server for '{}' files.\n\
459             Install it with:\n\
460             \n\
461             \x20   {}\n\
462             \n\
463             Then retry. This is optional — ctx_search and ctx_graph work without it.",
464            config.command, language, hint
465        )
466    })
467}
468
469#[cfg(test)]
470mod npm_shim_tests {
471    use std::path::Path;
472
473    fn write(path: &Path, content: &str) {
474        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
475        std::fs::write(path, content).unwrap();
476    }
477
478    /// npm's Windows install layout: `.cmd` shims (plain files, not links)
479    /// beside `<prefix>/node_modules/<package>`. The package behind a shim is
480    /// found, and — since a batch shim is no executable — Node starts the
481    /// package's `bin` entry, for both TypeScript server kinds.
482    #[test]
483    fn windows_style_shims_start_the_package_entry_with_node() {
484        let dir = tempfile::tempdir().unwrap();
485        let prefix = dir.path();
486        let nm = prefix.join("node_modules");
487        let node = prefix.join("node.exe");
488        write(&node, "");
489        write(&prefix.join("tsc.cmd"), "@node tsc %*");
490        write(
491            &nm.join("typescript/package.json"),
492            r#"{"name":"typescript","version":"7.0.2","bin":{"tsc":"./bin/tsc"}}"#,
493        );
494        write(&nm.join("typescript/bin/tsc"), "");
495        // Outside the prefix: its `node_modules` is no project TypeScript.
496        let project_dir = tempfile::tempdir().unwrap();
497        let project = project_dir.path();
498        let which = |name: &str| {
499            if name == "node" {
500                return Some(node.clone());
501            }
502            let shim = prefix.join(format!("{name}.cmd"));
503            shim.is_file().then_some(shim)
504        };
505        let entry = |pkg: &str, rel: &str| nm.join(pkg).join(rel).to_string_lossy().into_owned();
506
507        let native = super::resolve_typescript(Some(project), &which).unwrap();
508        assert_eq!(native.binary, prefix.join("tsc.cmd"));
509        assert_eq!(native.binary_name(), "tsc");
510        assert_eq!(Path::new(&native.config.command), node);
511        assert_eq!(
512            native.config.args,
513            [
514                entry("typescript", "./bin/tsc"),
515                "--lsp".into(),
516                "--stdio".into()
517            ]
518        );
519
520        // TypeScript 5 beside typescript-language-server (string `bin`).
521        write(
522            &nm.join("typescript/package.json"),
523            r#"{"name":"typescript","version":"5.9.3"}"#,
524        );
525        write(&nm.join("typescript/lib/tsserver.js"), "");
526        write(&prefix.join("typescript-language-server.cmd"), "");
527        write(
528            &nm.join("typescript-language-server/package.json"),
529            r#"{"bin":"lib/cli.mjs"}"#,
530        );
531        write(&nm.join("typescript-language-server/lib/cli.mjs"), "");
532        let tls = super::resolve_typescript(Some(project), &which).unwrap();
533        assert_eq!(tls.binary_name(), "typescript-language-server");
534        assert_eq!(
535            tls.config.args,
536            [
537                entry("typescript-language-server", "lib/cli.mjs"),
538                "--stdio".into()
539            ]
540        );
541        assert_eq!(
542            tls.init_options.unwrap()["tsserver"]["path"],
543            nm.join("typescript/lib/tsserver.js").to_str().unwrap()
544        );
545
546        // A manifest cannot point the entry outside its package.
547        write(
548            &nm.join("typescript-language-server/package.json"),
549            r#"{"bin":"../typescript/lib/tsserver.js"}"#,
550        );
551        assert_eq!(super::resolve_typescript(Some(project), &which), None);
552    }
553
554    /// An explicitly configured server gets the arguments its kind needs.
555    #[test]
556    fn configured_servers_get_their_kind_specific_arguments() {
557        let root = Path::new("/nonexistent-project");
558        let args = |lang: &str, cmd: &str| super::configured_server(lang, cmd, root).config.args;
559        assert_eq!(args("typescript", "/opt/ts/bin/tsc"), ["--lsp", "--stdio"]);
560        assert_eq!(
561            args("typescript", "/opt/typescript-language-server"),
562            ["--stdio"]
563        );
564        assert_eq!(args("go", "/opt/gopls"), ["serve"]);
565        {
566            let found = &args("rust", "/opt/rust-analyzer");
567            assert!(found.is_empty(), "{found:?}");
568        }
569    }
570}
571
572#[cfg(all(test, unix))]
573mod tests {
574    use super::runnable;
575    use std::os::unix::fs::{PermissionsExt, symlink};
576
577    /// A rustup proxy without the installed component must not count as an
578    /// available server (doctor showed ✓ and ctx_refactor failed on start).
579    #[test]
580    fn rustup_proxy_counts_only_when_it_runs() {
581        let dir = tempfile::tempdir().unwrap();
582        let rustup = dir.path().join("rustup");
583        let proxy = dir.path().join("fake-analyzer");
584        symlink(&rustup, &proxy).unwrap();
585
586        let mut verdicts = Vec::new();
587        for exit_code in [1, 0] {
588            std::fs::write(&rustup, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap();
589            std::fs::set_permissions(&rustup, std::fs::Permissions::from_mode(0o755)).unwrap();
590            verdicts.push(runnable(proxy.clone()).is_some());
591        }
592        assert_eq!(verdicts, vec![false, true]);
593    }
594
595    /// The project's TypeScript picks the server; without one, the machine's:
596    /// typescript-language-server with the `tsserver.js` beside it (≤ 6), or
597    /// TypeScript 7's own `tsc --lsp`.
598    #[test]
599    fn typescript_server_follows_the_typescript_version() {
600        use std::path::{Path, PathBuf};
601        fn write(path: &Path, content: &str) {
602            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
603            std::fs::write(path, content).unwrap();
604        }
605        fn typescript(pkg: &Path, version: &str) {
606            write(
607                &pkg.join("package.json"),
608                &format!(r#"{{"name":"typescript","version":"{version}"}}"#),
609            );
610        }
611        let dir = tempfile::tempdir().unwrap();
612        let (global, bin) = (dir.path().join("lib/node_modules"), dir.path().join("bin"));
613        // `npm install -g typescript-language-server typescript@5`.
614        write(&global.join("typescript-language-server/lib/cli.mjs"), "");
615        typescript(&global.join("typescript"), "5.9.3");
616        write(&global.join("typescript/lib/tsserver.js"), "");
617        write(&global.join("typescript/bin/tsc"), "");
618        std::fs::create_dir_all(&bin).unwrap();
619        symlink(
620            global.join("typescript-language-server/lib/cli.mjs"),
621            bin.join("typescript-language-server"),
622        )
623        .unwrap();
624        symlink(global.join("typescript/bin/tsc"), bin.join("tsc")).unwrap();
625        let which = |name: &str| Some(bin.join(name)).filter(|p| p.exists());
626        let resolve = |root: &Path| super::resolve_typescript(Some(root), &which);
627        let command = |r: Option<super::ResolvedServer>| r.map(|r| r.binary_name());
628
629        let bare = dir.path().join("bare");
630        std::fs::create_dir_all(&bare).unwrap();
631        let tls = resolve(&bare).unwrap();
632        assert_eq!(tls.binary_name(), "typescript-language-server");
633        assert_eq!(
634            tls.init_options.unwrap()["tsserver"]["path"],
635            std::fs::canonicalize(global.join("typescript/lib/tsserver.js"))
636                .unwrap()
637                .to_str()
638                .unwrap()
639        );
640
641        // A TypeScript 7 project uses its own `tsc --lsp`, a TypeScript 5
642        // project typescript-language-server (which finds that TypeScript).
643        let ts7 = dir.path().join("ts7");
644        typescript(&ts7.join("node_modules/typescript"), "7.0.2");
645        write(&ts7.join("node_modules/.bin/tsc"), "");
646        let native = resolve(&ts7.join("packages/app")).unwrap();
647        assert_eq!(native.config.args, ["--lsp", "--stdio"]);
648        assert!(Path::new(&native.config.command).starts_with(&ts7));
649        let ts5 = dir.path().join("ts5");
650        typescript(&ts5.join("node_modules/typescript"), "5.4.0");
651        assert_eq!(resolve(&ts5).unwrap().init_options, None);
652
653        // `npm install -g typescript@7` alone: the global `tsc` serves.
654        std::fs::remove_file(bin.join("typescript-language-server")).unwrap();
655        assert_eq!(command(resolve(&bare)), None, "tsc 5 is no server");
656        typescript(&global.join("typescript"), "7.0.2");
657        let global_native = resolve(&bare).unwrap();
658        assert_eq!(
659            PathBuf::from(&global_native.config.command),
660            bin.join("tsc")
661        );
662    }
663}