Skip to content

Commit 4792044

Browse files
Yogthosyogthos
authored andcommitted
LSP Phase 1: server registry + workspace-root finder + language map
First phase of LSP support (see opencode parity plan). Read-only, no async, no external deps. Builds infrastructure that Phase 2's JSON-RPC client will consume. src/lsp/language.rs — extension → LSP languageId map (42 extensions, 2 extension-less filenames). Case-insensitive, returns 'plaintext' for unknown extensions/filenames. src/lsp/server.rs: - ServerInfo struct: id, extensions, root function pointer. - nearest_root(file, stop_at, includes, excludes): walks parent chain looking for marker files; aborts on exclude marker; falls back to stop_at for single-file projects. Models opencode's NearestRoot. - rust_workspace_root: rust-analyzer-specific. Walks past nested member crates looking for a Cargo.toml that declares [workspace]. - typescript_root / pyright_root / clojure_root: variants of nearest_root with the right marker sets. Typescript excludes deno.json. - builtin_servers(): static list of v1 servers (rust, typescript, pyright, clojure-lsp). - servers_for_extension(ext): registry lookup, case-insensitive, accepts leading dot. src/lsp/mod.rs: re-exports both. Module-wide #[allow(dead_code)] since the consumers land in later phases. 29 tests covering: extension lookup (rs/ts/tsx/jsx/clojure-family/py /case-insensitive/unknown/extensionless), nearest_root happy paths + regression for exclude-above-blocks + closer-include-beats-exclude, rust_workspace_root walks past member crate to [workspace] declaration, registry shape and per-extension lookup. Total: 334 tests passing.
1 parent ee3ca6c commit 4792044

4 files changed

Lines changed: 613 additions & 0 deletions

File tree

src/lsp/language.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//! Extension → LSP language identifier mapping.
2+
//!
3+
//! Returned values are the LSP `languageId` strings (see the LSP spec §3.18.1)
4+
//! used in `textDocument/didOpen`. Unknown extensions return `"plaintext"` so
5+
//! `notify.open` always has a well-formed payload.
6+
7+
use std::path::Path;
8+
9+
/// Returns the LSP `languageId` for the given file path.
10+
///
11+
/// Looks at the lowercased file extension. Files with no extension match the
12+
/// filename (e.g. `Makefile` → `makefile`). Returns `"plaintext"` for any
13+
/// unrecognised extension/filename.
14+
pub fn language_for_path(path: &Path) -> &'static str {
15+
let name = path
16+
.file_name()
17+
.and_then(|s| s.to_str())
18+
.unwrap_or("")
19+
.to_lowercase();
20+
21+
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
22+
if let Some(lang) = LANGUAGES.iter().find(|(e, _)| *e == ext.to_lowercase()) {
23+
return lang.1;
24+
}
25+
}
26+
27+
// Some filenames are themselves the marker (Makefile, Dockerfile, etc).
28+
if let Some(lang) = FILENAMES.iter().find(|(n, _)| *n == name) {
29+
return lang.1;
30+
}
31+
"plaintext"
32+
}
33+
34+
/// Extension → languageId. Lowercase keys; lookups lowercase the input.
35+
const LANGUAGES: &[(&str, &str)] = &[
36+
("rs", "rust"),
37+
("ts", "typescript"),
38+
("tsx", "typescriptreact"),
39+
("mts", "typescript"),
40+
("cts", "typescript"),
41+
("js", "javascript"),
42+
("jsx", "javascriptreact"),
43+
("mjs", "javascript"),
44+
("cjs", "javascript"),
45+
("py", "python"),
46+
("pyi", "python"),
47+
("clj", "clojure"),
48+
("cljs", "clojure"),
49+
("cljc", "clojure"),
50+
("edn", "clojure"),
51+
("bb", "clojure"),
52+
("go", "go"),
53+
("c", "c"),
54+
("h", "c"),
55+
("cpp", "cpp"),
56+
("cxx", "cpp"),
57+
("cc", "cpp"),
58+
("hpp", "cpp"),
59+
("hxx", "cpp"),
60+
("hh", "cpp"),
61+
("java", "java"),
62+
("rb", "ruby"),
63+
("sh", "shellscript"),
64+
("bash", "shellscript"),
65+
("zsh", "shellscript"),
66+
("json", "json"),
67+
("yaml", "yaml"),
68+
("yml", "yaml"),
69+
("toml", "toml"),
70+
("md", "markdown"),
71+
("html", "html"),
72+
("css", "css"),
73+
("scss", "scss"),
74+
("xml", "xml"),
75+
("nix", "nix"),
76+
("zig", "zig"),
77+
];
78+
79+
const FILENAMES: &[(&str, &str)] = &[("makefile", "makefile"), ("dockerfile", "dockerfile")];
80+
81+
#[cfg(test)]
82+
mod tests {
83+
use super::*;
84+
use std::path::PathBuf;
85+
86+
fn lang(p: &str) -> &'static str {
87+
language_for_path(&PathBuf::from(p))
88+
}
89+
90+
#[test]
91+
fn rs_is_rust() {
92+
assert_eq!(lang("src/main.rs"), "rust");
93+
assert_eq!(lang("main.rs"), "rust");
94+
}
95+
96+
#[test]
97+
fn ts_and_tsx_are_distinct() {
98+
assert_eq!(lang("a.ts"), "typescript");
99+
assert_eq!(lang("a.tsx"), "typescriptreact");
100+
assert_eq!(lang("a.mts"), "typescript");
101+
}
102+
103+
#[test]
104+
fn jsx_is_javascriptreact_not_javascript() {
105+
assert_eq!(lang("a.jsx"), "javascriptreact");
106+
assert_eq!(lang("a.js"), "javascript");
107+
}
108+
109+
#[test]
110+
fn clojure_dialects_all_clojure() {
111+
for ext in &["clj", "cljs", "cljc", "edn", "bb"] {
112+
assert_eq!(lang(&format!("foo.{ext}")), "clojure", "ext={ext}");
113+
}
114+
}
115+
116+
#[test]
117+
fn python_extensions() {
118+
assert_eq!(lang("a.py"), "python");
119+
assert_eq!(lang("a.pyi"), "python");
120+
}
121+
122+
#[test]
123+
fn extension_lookup_is_case_insensitive() {
124+
// LSP language IDs are stable identifiers; pathological capitalisation
125+
// in filenames must not break the mapping.
126+
assert_eq!(lang("README.MD"), "markdown");
127+
assert_eq!(lang("Main.RS"), "rust");
128+
}
129+
130+
#[test]
131+
fn unknown_extension_returns_plaintext() {
132+
assert_eq!(lang("a.unknown_ext_42"), "plaintext");
133+
}
134+
135+
#[test]
136+
fn missing_extension_returns_plaintext() {
137+
assert_eq!(lang("just_a_filename"), "plaintext");
138+
}
139+
140+
#[test]
141+
fn filenames_without_extension_match_by_name() {
142+
assert_eq!(lang("Makefile"), "makefile");
143+
assert_eq!(lang("Dockerfile"), "dockerfile");
144+
// Case insensitive.
145+
assert_eq!(lang("makefile"), "makefile");
146+
assert_eq!(lang("path/to/Makefile"), "makefile");
147+
}
148+
149+
#[test]
150+
fn empty_path_returns_plaintext() {
151+
assert_eq!(lang(""), "plaintext");
152+
}
153+
}

src/lsp/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! Language Server Protocol support.
2+
//!
3+
//! Phase 1 lands the read-only pieces: a registry of supported servers and a
4+
//! workspace-root finder. Subsequent phases add JSON-RPC plumbing
5+
//! ([`client`], P2), file lifecycle + diagnostics (P3), an orchestrator
6+
//! ([`manager`], P4), the agent-facing `lsp` tool (P5), and write/edit
7+
//! integration (P6).
8+
9+
// Symbols in this module are consumed starting in Phase 4. Until then the
10+
// dead-code warnings would clutter every build — silenced module-wide.
11+
#![allow(dead_code)]
12+
13+
pub mod language;
14+
pub mod server;

0 commit comments

Comments
 (0)