Skip to content

Commit e90d0a0

Browse files
committed
fix: correct JS entry point, classify well-known JSONC filenames, switch to filesystem PATH probe
Addresses follow-up review on PR #9564 (oz-for-oss bot, second pass) with three important findings: > Custom installs will not be detected or launched because the hard-coded > JS entrypoint does not match the published `vscode-json-languageserver` > package layout. Fixed: the package's `package.json` declares `"main": "./out/node/jsonServerMain"`, not `dist/...`. Updated `LANGSERVER_JS_PATH` accordingly. > JSON-with-comments files such as `tsconfig.json` and > `.vscode/settings.json` are still classified as strict `json`, so valid > comments will be reported as syntax errors. Fixed: `LanguageId::from_path` now first checks the filename. The following well-known config files use `.json` by convention but contain JSONC and now route to `LanguageId::Jsonc`: - `tsconfig.json`, `jsconfig.json` - `tsconfig.<variant>.json`, `jsconfig.<variant>.json` (build, app, etc.) - `.vscode/{settings,launch,tasks,keybindings,extensions}.json` - `devcontainer.json`, `typedoc.json` Strict-JSON filenames (`package.json`, `package-lock.json`, `manifest.json`, etc.) keep going through the extension-only path and remain `LanguageId::Json`. > The PATH installation probe treats any successful spawn as installed > even when the executable exits non-zero. Fixed: `is_installed_on_path` no longer spawns the binary. Spawn-based probes can't reliably distinguish a healthy install from a broken one for this server (it has no documented `--version` or `--help`). Replaced with a pure filesystem PATH search (`binary_in_path` helper) that walks the `path_env_var` directories looking for an executable file by name. On Windows it also tries `.exe`, `.cmd`, `.bat` since npm ships a `.cmd` shim there. Tests added/updated: - `known_dotjson_jsonc_filenames_route_to_jsonc` — exhaustively asserts the 10 well-known JSONC filenames. - `unrelated_dotjson_stays_strict_json` — protects regressions where `package.json` / `manifest.json` would accidentally be reclassified. - `binary_in_path` test module: found in PATH entry and absent. Adds `tempfile` as a workspace dev-dependency (already present in the root `Cargo.toml`) for the test setup. Also adds `.claude/scheduled_tasks.lock` to `.gitignore` so the Claude Code session lock can't sneak into a future commit. Refs #9556, #9564 second review.
1 parent f795e37 commit e90d0a0

6 files changed

Lines changed: 190 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ app/src/persistence/schema.rs.orig
4848

4949
# Don't include personal Claude Code settings
5050
.claude/settings.local.json
51+
.claude/scheduled_tasks.lock
5152

5253
# Tab drag development notes
5354
pr_cleanup.md

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/lsp/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ nix = { workspace = true }
4747
repo_metadata.workspace = true
4848
sha2 = { workspace = true }
4949
tokio = { workspace = true, features = ["process"] }
50+
51+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
52+
tempfile = { workspace = true }

crates/lsp/src/config.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ pub enum LanguageId {
4242

4343
impl LanguageId {
4444
pub fn from_path(path: &Path) -> Option<Self> {
45+
// Some files use the `.json` extension by convention but actually
46+
// contain JSON-with-comments — `tsconfig.json` is the canonical
47+
// example, alongside the VS Code `.vscode/*.json` family. Match
48+
// these by filename first so we send `jsonc` (not `json`) in
49+
// `didOpen` and the JSON server applies its comments-tolerant
50+
// grammar instead of flagging valid `// …` lines as syntax errors.
51+
if filename_is_jsonc(path) {
52+
return Some(Self::Jsonc);
53+
}
4554
let extn = path.extension()?;
4655
match extn.to_str()? {
4756
"rs" => Some(Self::Rust),
@@ -353,6 +362,38 @@ pub fn default_init_params(workspace_uri: &Path, client_name: String) -> Result<
353362
})
354363
}
355364

365+
/// Returns `true` for paths whose final filename is a well-known JSON-with-comments
366+
/// config file. The VS Code JSON language server distinguishes `json` from `jsonc`
367+
/// and only allows comments under `jsonc`, but several Microsoft tools ship with
368+
/// `.json` extensions and JSONC contents (TypeScript's `tsconfig.json`,
369+
/// VS Code's own `settings.json`, etc.). Routing these by name keeps users
370+
/// from getting spurious "comments not allowed" diagnostics.
371+
fn filename_is_jsonc(path: &Path) -> bool {
372+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
373+
return false;
374+
};
375+
// Exact filename matches that are always JSONC by convention.
376+
const EXACT: &[&str] = &[
377+
"tsconfig.json",
378+
"jsconfig.json",
379+
// VS Code workspace/user/folder settings files.
380+
"settings.json",
381+
"launch.json",
382+
"tasks.json",
383+
"keybindings.json",
384+
"extensions.json",
385+
// Other editor configs that follow the same convention.
386+
"devcontainer.json",
387+
"typedoc.json",
388+
];
389+
if EXACT.contains(&name) {
390+
return true;
391+
}
392+
// Variants like `tsconfig.build.json`, `tsconfig.app.json`, etc.
393+
name.starts_with("tsconfig.") && name.ends_with(".json")
394+
|| name.starts_with("jsconfig.") && name.ends_with(".json")
395+
}
396+
356397
#[cfg(test)]
357398
#[path = "config_tests.rs"]
358399
mod tests;

crates/lsp/src/config_tests.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,53 @@ mod json_language_detection {
244244
// route .jsonc through its own LanguageId so the right languageId is
245245
// sent in `didOpen`.
246246
assert_eq!(
247-
LanguageId::from_path(&PathBuf::from(".vscode/settings.jsonc")),
247+
LanguageId::from_path(&PathBuf::from("docs/example.jsonc")),
248248
Some(LanguageId::Jsonc)
249249
);
250250
}
251251

252+
#[test]
253+
fn known_dotjson_jsonc_filenames_route_to_jsonc() {
254+
// Several well-known config files use `.json` by convention but
255+
// contain JSON-with-comments. Sending `json` would surface valid
256+
// `// …` lines as syntax errors.
257+
for name in [
258+
"tsconfig.json",
259+
"jsconfig.json",
260+
"tsconfig.build.json",
261+
"jsconfig.app.json",
262+
".vscode/settings.json",
263+
".vscode/launch.json",
264+
".vscode/keybindings.json",
265+
".vscode/tasks.json",
266+
".vscode/extensions.json",
267+
".devcontainer/devcontainer.json",
268+
] {
269+
assert_eq!(
270+
LanguageId::from_path(&PathBuf::from(name)),
271+
Some(LanguageId::Jsonc),
272+
"{name} should be classified as JSONC",
273+
);
274+
}
275+
}
276+
277+
#[test]
278+
fn unrelated_dotjson_stays_strict_json() {
279+
// package.json, manifest.json, etc. are strict JSON.
280+
for name in [
281+
"package.json",
282+
"package-lock.json",
283+
"manifest.json",
284+
"data.json",
285+
] {
286+
assert_eq!(
287+
LanguageId::from_path(&PathBuf::from(name)),
288+
Some(LanguageId::Json),
289+
"{name} should remain strict JSON",
290+
);
291+
}
292+
}
293+
252294
#[test]
253295
fn both_json_and_jsonc_route_to_vscode_json_language_server() {
254296
assert_eq!(

crates/lsp/src/servers/vscode_json_language_server.rs

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ pub struct VsCodeJsonLanguageServerCandidate {
2727
}
2828

2929
impl VsCodeJsonLanguageServerCandidate {
30-
/// Path to the langserver JS entry point relative to the install directory.
31-
/// Mirrors the layout produced by `npm install vscode-json-languageserver`.
30+
/// Path to the langserver JS entry point relative to the install directory,
31+
/// matching the layout published by the `vscode-json-languageserver` npm
32+
/// package (whose `package.json` declares `"main": "./out/node/jsonServerMain"`).
3233
#[cfg(feature = "local_fs")]
3334
const LANGSERVER_JS_PATH: &str =
34-
"node_modules/vscode-json-languageserver/dist/node/jsonServerMain.js";
35+
"node_modules/vscode-json-languageserver/out/node/jsonServerMain.js";
3536

3637
pub fn new(client: Arc<http_client::Client>) -> Self {
3738
Self { client }
@@ -103,19 +104,14 @@ impl LanguageServerCandidate for VsCodeJsonLanguageServerCandidate {
103104

104105
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool {
105106
// `vscode-json-languageserver` only documents `--stdio`, `--node-ipc`,
106-
// and `--socket=` as transport flags; passing `--version` or `--help`
107-
// makes it error during connection-transport setup. We just need to
108-
// know that the binary spawned at all (i.e. is on PATH and
109-
// executable), so we use `--stdio` and accept any clean spawn — the
110-
// server will start reading LSP messages from stdin, but `output()`
111-
// returns Ok as soon as the child process is reaped after EOF.
112-
executor
113-
.command("vscode-json-languageserver")
114-
.arg("--stdio")
115-
.stdin(std::process::Stdio::null())
116-
.output()
117-
.await
118-
.is_ok()
107+
// and `--socket=` as transport flags. `--version`/`--help` enter
108+
// connection-transport setup and exit non-zero; spawning the server
109+
// with `--stdio` and EOF on stdin works but treats every spawnable
110+
// binary as healthy (including a corrupted install). Use a pure
111+
// filesystem PATH search instead — the binary either exists and is
112+
// executable or it doesn't, and we never inadvertently prefer a
113+
// broken global install over our (working) data_dir copy.
114+
binary_in_path("vscode-json-languageserver", executor.path_env_var())
119115
}
120116

121117
async fn install(
@@ -224,3 +220,92 @@ impl LanguageServerCandidate for VsCodeJsonLanguageServerCandidate {
224220
todo!()
225221
}
226222
}
223+
224+
/// Pure-filesystem search for an executable named `name` in any directory
225+
/// listed by `path_env_var` (or the process's `PATH` if `None`).
226+
///
227+
/// We use this instead of spawning the binary with a probe flag for LSP
228+
/// servers that have no documented version/help argument — running them
229+
/// with arbitrary flags either errors during connection-transport setup
230+
/// or hangs reading from stdin. A filesystem check has no such ambiguity:
231+
/// the file either exists and is executable, or it doesn't.
232+
///
233+
/// On Windows we additionally try the standard executable extensions
234+
/// (`.exe`, `.cmd`, `.bat`) since the `vscode-json-languageserver` npm
235+
/// package ships a `.cmd` shim there.
236+
#[cfg(feature = "local_fs")]
237+
fn binary_in_path(name: &str, path_env_var: Option<&str>) -> bool {
238+
let owned;
239+
let path_str = match path_env_var {
240+
Some(p) => p,
241+
None => match std::env::var("PATH") {
242+
Ok(p) => {
243+
owned = p;
244+
owned.as_str()
245+
}
246+
Err(_) => return false,
247+
},
248+
};
249+
let separator = if cfg!(windows) { ';' } else { ':' };
250+
#[cfg(windows)]
251+
let extensions: &[&str] = &["", ".exe", ".cmd", ".bat"];
252+
#[cfg(not(windows))]
253+
let extensions: &[&str] = &[""];
254+
255+
for dir in path_str.split(separator) {
256+
if dir.is_empty() {
257+
continue;
258+
}
259+
let dir_path = std::path::Path::new(dir);
260+
for ext in extensions {
261+
let candidate = dir_path.join(format!("{name}{ext}"));
262+
if candidate.is_file() {
263+
return true;
264+
}
265+
}
266+
}
267+
false
268+
}
269+
270+
#[cfg(test)]
271+
#[cfg(feature = "local_fs")]
272+
mod tests {
273+
use super::binary_in_path;
274+
use std::fs::{self, File};
275+
276+
#[cfg(unix)]
277+
use std::os::unix::fs::PermissionsExt as _;
278+
279+
fn touch_exe(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
280+
let path = dir.join(name);
281+
File::create(&path).expect("create test binary");
282+
#[cfg(unix)]
283+
{
284+
let mut perms = fs::metadata(&path).unwrap().permissions();
285+
perms.set_mode(0o755);
286+
fs::set_permissions(&path, perms).unwrap();
287+
}
288+
path
289+
}
290+
291+
#[test]
292+
fn finds_binary_in_first_path_entry() {
293+
let tmp = tempfile::tempdir().expect("tempdir");
294+
touch_exe(tmp.path(), "vscode-json-languageserver");
295+
let path_var = format!("{}:{}", tmp.path().display(), "/nonexistent/dir");
296+
assert!(binary_in_path(
297+
"vscode-json-languageserver",
298+
Some(&path_var)
299+
));
300+
}
301+
302+
#[test]
303+
fn rejects_when_binary_absent() {
304+
let tmp = tempfile::tempdir().expect("tempdir");
305+
let path_var = tmp.path().display().to_string();
306+
assert!(!binary_in_path(
307+
"vscode-json-languageserver",
308+
Some(&path_var)
309+
));
310+
}
311+
}

0 commit comments

Comments
 (0)