Skip to content

Commit 632167a

Browse files
committed
Add LSP auto-detect and setup guide
1 parent 7a8a76b commit 632167a

File tree

5 files changed

+354
-35
lines changed

5 files changed

+354
-35
lines changed

.diffscope.yml.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ smart_review_summary: true # Include AI-generated PR summary in smart-review o
1515
smart_review_diagram: false # Generate a Mermaid diagram in smart-review output
1616
symbol_index: true # Build repo symbol index for cross-file context (respects .gitignore)
1717
symbol_index_provider: regex # regex | lsp
18-
symbol_index_lsp_command: rust-analyzer
18+
symbol_index_lsp_command: rust-analyzer # optional; omit to auto-detect
1919
symbol_index_lsp_languages:
2020
rs: rust
2121
# For TypeScript (example):

README.md

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ exclude_patterns:
223223
- "**/__pycache__/**"
224224
```
225225

226-
Set `symbol_index_provider: lsp` to use a language server; it falls back to regex indexing if the LSP binary is missing. Configure `symbol_index_lsp_languages` and `symbol_index_lsp_command` to match your server (for example, `typescript-language-server --stdio` with `ts`/`tsx` language IDs).
226+
Set `symbol_index_provider: lsp` to use a language server; it falls back to regex indexing if the LSP binary is missing. Configure `symbol_index_lsp_languages` and `symbol_index_lsp_command` to match your server (for example, `typescript-language-server --stdio` with `ts`/`tsx` language IDs). If you omit `symbol_index_lsp_command`, diffscope will try to auto-detect a server based on installed binaries and the file types in your repo.
227227

228228
### LSP Symbol Index Examples (All Common Languages)
229229

@@ -300,18 +300,7 @@ symbol_index_lsp_languages:
300300

301301
### LSP Setup Notes (Install + Command)
302302

303-
Use your package manager if you already have one. These are the minimal install commands and the server command to set in `symbol_index_lsp_command`.
304-
305-
- rust-analyzer: `rustup component add rust-analyzer` and (recommended) `rustup component add rust-src`; command: `rust-analyzer`.
306-
- TypeScript/JavaScript (typescript-language-server): `npm install -g typescript-language-server typescript`; command: `typescript-language-server --stdio`.
307-
- Python (python-lsp-server/pylsp): `pip install "python-lsp-server[all]"` (or `python-lsp-server`); command: `pylsp`.
308-
- Go (gopls): `go install golang.org/x/tools/gopls@latest`; command: `gopls`.
309-
- Java (Eclipse JDT LS): use the distribution’s wrapper `bin/jdtls` with `-configuration` and `-data` arguments.
310-
- Kotlin (Kotlin LSP): `brew install JetBrains/utils/kotlin-lsp` or use the standalone zip; command: `kotlin-lsp` (or `kotlin-lsp.sh`, see its `--help` for stdio options).
311-
- C/C++ (clangd): install via your package manager (e.g., `brew install llvm` or `apt-get install clangd-12`); command: `clangd`.
312-
- C# (csharp-ls): `dotnet tool install --global csharp-ls`; command: `csharp-ls`.
313-
- Ruby (solargraph): `gem install solargraph`; command: `solargraph stdio`.
314-
- PHP (Phpactor): install Phpactor (e.g., download the PHAR or install via composer); command: `phpactor language-server`.
303+
For detailed install commands, OS-specific package manager options, and troubleshooting, see `docs/lsp.md`.
315304

316305
## Plugin Development
317306

docs/lsp.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# LSP Setup and Troubleshooting
2+
3+
This guide covers installation options, recommended commands, and common pitfalls when using the LSP-backed symbol indexer.
4+
5+
## Auto-detection
6+
7+
If `symbol_index_provider: lsp` and `symbol_index_lsp_command` is not set, diffscope will try to auto-detect a server by:
8+
- scanning repository file extensions (honoring ignore rules), and
9+
- selecting the first available server that matches your configured `symbol_index_lsp_languages`.
10+
11+
If it picks the wrong server, set `symbol_index_lsp_command` explicitly.
12+
13+
## Package Manager Matrix (brew/apt/choco)
14+
15+
Use these when you prefer OS-level packages. If a cell says "manual", use the language-specific install section below.
16+
17+
| Server | macOS (brew) | Ubuntu/Debian (apt) | Windows (choco) |
18+
| --- | --- | --- | --- |
19+
| rust-analyzer | `brew install rust-analyzer` | `rustup component add rust-analyzer` | `rustup component add rust-analyzer` |
20+
| clangd | `brew install llvm` | `sudo apt-get install clangd-12` | `choco install llvm` |
21+
| kotlin-lsp | `brew install JetBrains/utils/kotlin-lsp` | manual (zip) | manual (zip) |
22+
| Eclipse JDT LS | manual (zip) | manual (zip) | manual (zip) |
23+
24+
## Language-specific Installers (cross-platform)
25+
26+
Set `symbol_index_lsp_command` to the command shown after installation.
27+
28+
- Rust (rust-analyzer)
29+
- Install: `rustup component add rust-analyzer`
30+
- Optional: `rustup component add rust-src`
31+
- Command: `rust-analyzer`
32+
- TypeScript / JavaScript (typescript-language-server)
33+
- Install: `npm install -g typescript-language-server typescript`
34+
- Command: `typescript-language-server --stdio`
35+
- Python (python-lsp-server / pylsp)
36+
- Install: `pip install "python-lsp-server[all]"` (or `python-lsp-server`)
37+
- Command: `pylsp`
38+
- Go (gopls)
39+
- Install: `go install golang.org/x/tools/gopls@latest`
40+
- Command: `gopls`
41+
- C# (csharp-ls)
42+
- Install: `dotnet tool install --global csharp-ls`
43+
- Command: `csharp-ls`
44+
- Ruby (solargraph)
45+
- Install: `gem install solargraph`
46+
- Command: `solargraph stdio`
47+
- PHP (Phpactor)
48+
- Install (PHAR): `curl -Lo phpactor.phar https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar`
49+
- Then: `chmod a+x phpactor.phar` and `mv phpactor.phar ~/.local/bin/phpactor`
50+
- Command: `phpactor language-server`
51+
52+
## Manual Installers
53+
54+
### Kotlin LSP
55+
56+
If you didn't use Homebrew:
57+
1. Download the standalone zip from the Kotlin LSP Releases page.
58+
2. `chmod +x $KOTLIN_LSP_DIR/kotlin-lsp.sh`
59+
3. Symlink it: `ln -s $KOTLIN_LSP_DIR/kotlin-lsp.sh $HOME/.local/bin/kotlin-lsp`
60+
61+
### Eclipse JDT LS
62+
63+
Download and extract a milestone or snapshot build. You can run the wrapper:
64+
65+
```
66+
bin/jdtls -configuration /path/to/config -data /path/to/workspace
67+
```
68+
69+
## Troubleshooting
70+
71+
- Auto-detect chose the wrong server: set `symbol_index_lsp_command` explicitly.
72+
- LSP binary not found: check PATH or use an absolute path in `symbol_index_lsp_command`.
73+
- Server expects stdio: use the `--stdio` form when required (e.g., TypeScript, Solargraph).
74+
- JDT LS needs a unique `-data` directory per workspace; configure a stable path.

src/core/symbol_index.rs

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use ignore::WalkBuilder;
33
use once_cell::sync::Lazy;
44
use regex::Regex;
55
use serde_json::{json, Value};
6-
use std::collections::HashMap;
6+
use std::collections::{HashMap, HashSet};
7+
use std::env;
78
use std::fs;
89
use std::io::{BufRead, BufReader, Read, Write};
910
use std::path::{Path, PathBuf};
@@ -22,7 +23,90 @@ pub struct SymbolIndex {
2223
files_indexed: usize,
2324
}
2425

26+
struct LspServerOption {
27+
command: &'static str,
28+
program: &'static str,
29+
extensions: &'static [&'static str],
30+
}
31+
32+
const LSP_DETECT_MAX_FILES: usize = 2000;
33+
34+
const LSP_SERVER_OPTIONS: &[LspServerOption] = &[
35+
LspServerOption {
36+
command: "rust-analyzer",
37+
program: "rust-analyzer",
38+
extensions: &["rs"],
39+
},
40+
LspServerOption {
41+
command: "typescript-language-server --stdio",
42+
program: "typescript-language-server",
43+
extensions: &["ts", "tsx", "js", "jsx"],
44+
},
45+
LspServerOption {
46+
command: "pylsp",
47+
program: "pylsp",
48+
extensions: &["py", "pyi"],
49+
},
50+
LspServerOption {
51+
command: "gopls",
52+
program: "gopls",
53+
extensions: &["go"],
54+
},
55+
LspServerOption {
56+
command: "jdtls",
57+
program: "jdtls",
58+
extensions: &["java"],
59+
},
60+
LspServerOption {
61+
command: "kotlin-lsp",
62+
program: "kotlin-lsp",
63+
extensions: &["kt"],
64+
},
65+
LspServerOption {
66+
command: "clangd",
67+
program: "clangd",
68+
extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"],
69+
},
70+
LspServerOption {
71+
command: "csharp-ls",
72+
program: "csharp-ls",
73+
extensions: &["cs"],
74+
},
75+
LspServerOption {
76+
command: "solargraph stdio",
77+
program: "solargraph",
78+
extensions: &["rb"],
79+
},
80+
LspServerOption {
81+
command: "phpactor language-server",
82+
program: "phpactor",
83+
extensions: &["php"],
84+
},
85+
];
86+
2587
impl SymbolIndex {
88+
pub fn detect_lsp_command<F>(
89+
repo_root: &Path,
90+
max_files: usize,
91+
lsp_languages: &HashMap<String, String>,
92+
should_exclude: F,
93+
) -> Option<String>
94+
where
95+
F: Fn(&PathBuf) -> bool,
96+
{
97+
if max_files == 0 {
98+
return None;
99+
}
100+
let enabled_extensions = normalized_extension_set(lsp_languages);
101+
let extension_counts = collect_extension_counts(
102+
repo_root,
103+
max_files.min(LSP_DETECT_MAX_FILES),
104+
&enabled_extensions,
105+
should_exclude,
106+
);
107+
choose_lsp_command(&extension_counts, &enabled_extensions)
108+
}
109+
26110
pub fn build<F>(
27111
repo_root: &Path,
28112
max_files: usize,
@@ -255,6 +339,150 @@ impl SymbolIndex {
255339
}
256340
}
257341

342+
fn normalized_extension_set(lsp_languages: &HashMap<String, String>) -> HashSet<String> {
343+
lsp_languages
344+
.keys()
345+
.filter(|ext| !ext.trim().is_empty())
346+
.map(|ext| ext.trim().to_ascii_lowercase())
347+
.collect()
348+
}
349+
350+
fn collect_extension_counts<F>(
351+
repo_root: &Path,
352+
max_files: usize,
353+
enabled_extensions: &HashSet<String>,
354+
should_exclude: F,
355+
) -> HashMap<String, usize>
356+
where
357+
F: Fn(&PathBuf) -> bool,
358+
{
359+
let walker = WalkBuilder::new(repo_root)
360+
.hidden(true)
361+
.ignore(true)
362+
.git_ignore(true)
363+
.git_exclude(true)
364+
.git_global(true)
365+
.build();
366+
367+
let mut counts = HashMap::new();
368+
let mut files_seen = 0usize;
369+
370+
for entry in walker.flatten() {
371+
let path = entry.path();
372+
if !path.is_file() {
373+
continue;
374+
}
375+
376+
let relative = path
377+
.strip_prefix(repo_root)
378+
.map(|p| p.to_path_buf())
379+
.unwrap_or_else(|_| path.to_path_buf());
380+
if should_exclude(&relative) {
381+
continue;
382+
}
383+
384+
files_seen += 1;
385+
if files_seen > max_files {
386+
break;
387+
}
388+
389+
let extension = match path.extension().and_then(|ext| ext.to_str()) {
390+
Some(ext) => ext.trim().to_ascii_lowercase(),
391+
None => continue,
392+
};
393+
if extension.is_empty() {
394+
continue;
395+
}
396+
if !enabled_extensions.is_empty() && !enabled_extensions.contains(&extension) {
397+
continue;
398+
}
399+
400+
*counts.entry(extension).or_insert(0) += 1;
401+
}
402+
403+
counts
404+
}
405+
406+
fn choose_lsp_command(
407+
extension_counts: &HashMap<String, usize>,
408+
enabled_extensions: &HashSet<String>,
409+
) -> Option<String> {
410+
let mut best_command: Option<&'static str> = None;
411+
let mut best_score = 0usize;
412+
413+
for option in LSP_SERVER_OPTIONS {
414+
if !is_program_available(option.program) {
415+
continue;
416+
}
417+
418+
let score: usize = option
419+
.extensions
420+
.iter()
421+
.filter(|ext| {
422+
let ext: &str = *ext;
423+
enabled_extensions.is_empty() || enabled_extensions.contains::<str>(ext)
424+
})
425+
.filter_map(|ext| {
426+
let ext: &str = *ext;
427+
extension_counts.get::<str>(ext)
428+
})
429+
.sum();
430+
431+
if score > best_score {
432+
best_score = score;
433+
best_command = Some(option.command);
434+
}
435+
}
436+
437+
best_command.map(|command| command.to_string())
438+
}
439+
440+
fn is_program_available(program: &str) -> bool {
441+
if program.trim().is_empty() {
442+
return false;
443+
}
444+
445+
let program_path = Path::new(program);
446+
if program_path.components().count() > 1 {
447+
return program_path.is_file();
448+
}
449+
450+
let path_var = match env::var_os("PATH") {
451+
Some(path) => path,
452+
None => return false,
453+
};
454+
455+
for path in env::split_paths(&path_var) {
456+
if program_exists_in_dir(&path, program) {
457+
return true;
458+
}
459+
}
460+
461+
false
462+
}
463+
464+
fn program_exists_in_dir(dir: &Path, program: &str) -> bool {
465+
let candidate = dir.join(program);
466+
if candidate.is_file() {
467+
return true;
468+
}
469+
470+
if cfg!(windows) && Path::new(program).extension().is_none() {
471+
let pathext = env::var_os("PATHEXT").unwrap_or_else(|| ".EXE;.CMD;.BAT;.COM".into());
472+
for ext in pathext.to_string_lossy().split(';') {
473+
if ext.is_empty() {
474+
continue;
475+
}
476+
let candidate = dir.join(format!("{}{}", program, ext));
477+
if candidate.is_file() {
478+
return true;
479+
}
480+
}
481+
}
482+
483+
false
484+
}
485+
258486
static SYMBOL_PATTERNS: Lazy<HashMap<&'static str, Vec<Regex>>> = Lazy::new(|| {
259487
let mut map = HashMap::new();
260488

0 commit comments

Comments
 (0)