fix(lsp): offer auto-imports from unopened files in projects without a tsconfig (#496)

Without a tsconfig.json or jsconfig.json, tsserver's inferred project holds only
open files and what they import, so completion never offered exports from other
files. Register the project's sources with tsserver as an external project,
using the options an inferred project assumes, so no file is written to disk.

Registration needs `typescript.tsserverRequest`. Projects with a configuration,
including one in a subfolder, stay as they were, and so do trees over 10,000
sources or 16 MiB of JavaScript, since tsserver disables its language service
past 20 MiB. Created and deleted files update the project, and unopened files
stay out of Problems.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 01:49:00 +08:00
1 джерело a3cd99db1f
коміт 8fabf054ef
16 files changed
+393 -1

No files matched your search

Створено
+1
Переглянути файл
@@ -4372,6 +4372,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ignore",
"lsp-types",
"parking_lot",
"percent-encoding",
+8
Переглянути файл
@@ -223,6 +223,7 @@ impl Host {
let history = history.clone();
let remote = self.remote.clone();
let runtime = self.runtime.clone();
let lsp = lsp.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -230,6 +231,7 @@ impl Host {
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
@@ -358,6 +360,12 @@ impl Host {
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.lsp.set_project_excludes_hook(Box::new(move || {
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
}));
}
self.insert_project(window, project.clone());
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
+3
Переглянути файл
@@ -120,6 +120,9 @@ pub struct LanguageServerConfig {
/// a JDK 21+); reported as the failure instead of launching it.
#[serde(skip)]
pub launch_error: Option<String>,
/// Register source files with tsserver when no project configuration exists.
#[serde(default)]
pub tsserver_project: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
+1
Переглянути файл
@@ -19,6 +19,7 @@ parking_lot.workspace = true
tracing.workspace = true
futures.workspace = true
globset.workspace = true
ignore.workspace = true
toml.workspace = true
percent-encoding = "2"
+286
Переглянути файл
@@ -0,0 +1,286 @@
//! Give tsserver a project for unopened sources, without creating a config file.
use crate::LspManager;
use crate::server::{Running, Server};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
pub type ExcludesFn = dyn Fn() -> Vec<String> + Send + Sync;
const COMMAND: &str = "typescript.tsserverRequest";
const LIMIT: usize = 10_000;
const SCAN_LIMIT: usize = 100_000;
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
const REFRESH_DELAY: Duration = Duration::from_millis(300);
const TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Default)]
pub(crate) struct State {
generation: u64,
files: Vec<PathBuf>,
}
fn supported(server: &Server, run: &Running) -> bool {
server.config.tsserver_project
&& run
.caps
.read()
.raw
.pointer("/executeCommandProvider/commands")
.and_then(Value::as_array)
.is_some_and(|commands| commands.iter().any(|c| c.as_str() == Some(COMMAND)))
}
fn configured(dir: &Path) -> bool {
["tsconfig.json", "jsconfig.json"].iter().any(|name| dir.join(name).is_file())
}
fn is_script(ext: &str) -> bool {
matches!(ext, "js" | "jsx" | "mjs" | "cjs")
}
fn is_source(ext: &str) -> bool {
is_script(ext) || matches!(ext, "ts" | "tsx" | "mts" | "cts")
}
/// All-or-nothing at the caps: a partial project could displace an inferred
/// project while omitting some of its files. Configured trees stay with tsserver.
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
if root.ancestors().any(configured) {
return Vec::new();
}
let mut ignores = ignore::gitignore::GitignoreBuilder::new(exclude_root);
for pattern in excludes {
if ignores.add_line(None, &pattern).is_err() {
return Vec::new();
}
}
let Ok(ignores) = ignores.build() else { return Vec::new() };
let mut walk = ignore::WalkBuilder::new(root);
walk.require_git(false).follow_links(false).filter_entry(move |entry| {
if entry.depth() == 0 {
return true;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy();
!matches!(name.as_ref(), "node_modules" | "dist" | "build" | "out" | "coverage" | "target" | ".git")
&& !ignores.matched_path_or_any_parents(path, entry.file_type().is_some_and(|t| t.is_dir())).is_ignore()
});
let mut files = Vec::new();
let mut script_bytes = 0;
for (visited, entry) in walk.build().enumerate() {
// Do not claim a tree is config-less if we could not inspect it fully.
let Ok(entry) = entry else { return Vec::new() };
if visited >= SCAN_LIMIT {
return Vec::new();
}
if entry.file_type().is_some_and(|t| t.is_dir()) && configured(entry.path()) {
return Vec::new();
}
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
if is_script(ext) {
script_bytes += entry.metadata().map_or(0, |m| m.len());
if script_bytes > SCRIPT_BYTES_LIMIT {
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
return Vec::new();
}
}
files.push(entry.into_path());
if files.len() > limit {
tracing::debug!("external TypeScript project exceeds {limit} files; retaining inferred projects");
return Vec::new();
}
}
}
files.sort();
files
}
fn project_name(server: &Server) -> PathBuf {
server.root.join(".openidea-external-project")
}
async fn request(run: &Running, command: &str, args: Value) -> bool {
let request = run.peer.request::<_, Value>(
"workspace/executeCommand",
json!({
"command": COMMAND,
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
}),
None,
);
match tokio::time::timeout(TIMEOUT, request).await {
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
result => {
tracing::debug!("tsserver {command}: {result:?}");
false
}
}
}
impl LspManager {
pub fn set_project_excludes_hook(&self, hook: Box<ExcludesFn>) {
*self.excludes_hook.write() = Some(Arc::from(hook));
}
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
if !supported(server, run) {
return;
}
let mut state = server.external_project.lock().await;
if state.generation != run.generation {
*state = State { generation: run.generation, files: Vec::new() };
server.external_diagnostics.store(false, Ordering::Release);
}
let root = server.root.clone();
let exclude_root = self.project_root_for(&root);
let hook = self.excludes_hook.read().clone();
let files = tokio::task::spawn_blocking(move || {
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
})
.await
.unwrap_or_default();
if state.files == files {
return;
}
if files.is_empty() {
if request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await {
state.files.clear();
// Keep suppressing delayed diagnostics for unopened files from this instance.
}
return;
}
server.external_diagnostics.store(true, Ordering::Release);
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
if request(
run,
"openExternalProject",
json!({
"projectFileName": project_name(server), "rootFiles": roots,
// What an inferred project assumes, so sharing a project changes no diagnostics.
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
"strictFunctionTypes": true, "moduleDetection": "force" }
}),
)
.await
{
tracing::debug!(
"{}: registered {} external project files at {}",
server.name,
files.len(),
server.root.display()
);
state.files = files;
}
}
/// Re-registers config-less projects after sources or configurations changed on disk.
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
let relevant = paths.into_iter().any(|p| {
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
});
if !relevant {
return;
}
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
continue;
}
let (me, server) = (self.me.clone(), server.clone());
self.runtime.spawn(async move {
tokio::time::sleep(REFRESH_DELAY).await;
server.external_refresh_queued.store(false, Ordering::Release);
if let (Some(me), Some(run)) = (me.upgrade(), server.running()) {
me.refresh_external_project(&server, &run).await;
}
});
}
}
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
let mut state = server.external_project.lock().await;
if state.generation == run.generation && !state.files.is_empty() {
request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await;
state.files.clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, path: &str) {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
#[test]
fn selects_sources_honouring_ignores_exclusions_and_roots() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for path in [
"a.ts",
"src/b.js",
"src/c.tsx",
"readme.md",
"node_modules/n.ts",
"dist/d.js",
"excluded/e.ts",
"ignored/i.ts",
"other/f.py",
] {
write(root, path);
}
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
assert_eq!(
source_files(root, root, vec!["excluded".into()], LIMIT),
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
);
assert!(source_files(root, root, vec![], 1).is_empty());
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert!(source_files(root, root, vec![], LIMIT).is_empty());
std::fs::remove_file(root.join("large.js")).unwrap();
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
std::fs::remove_file(root.join("large.ts")).unwrap();
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
}
#[test]
fn root_ancestor_and_nested_configs_keep_existing_projects() {
for name in ["tsconfig.json", "jsconfig.json"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(root, "a.ts");
write(root, "pkg/b.js");
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
write(root, &format!("pkg/{name}"));
assert!(source_files(root, root, vec![], LIMIT).is_empty());
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
write(root, name);
assert!(source_files(root, root, vec![], LIMIT).is_empty());
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
}
}
#[test]
fn additions_deletions_and_renames_change_membership() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "a.js");
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
write(dir.path(), "b.ts");
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
std::fs::remove_file(dir.path().join("a.js")).unwrap();
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
}
}
+4 -1
Переглянути файл
@@ -120,7 +120,10 @@ impl ClientHandler {
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
// Files opened only for a request (references search) are not the user's problems.
if self.server.suppresses_diagnostics(&path) {
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
&& !mgr.docs.lock().values().any(|d| d.path == path))
|| self.server.suppresses_diagnostics(&path)
{
return;
}
let conv = mgr.conv(self.encoding());
+6
Переглянути файл
@@ -9,6 +9,7 @@
mod angular;
mod caps;
mod convert;
mod external_project;
mod file_ops;
mod handler;
mod inspect;
@@ -169,6 +170,7 @@ pub struct LspManager {
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
shutting_down: AtomicBool,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
@@ -299,6 +301,7 @@ impl LspManager {
prepare_hook: RwLock::new(None),
servers_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
excludes_hook: RwLock::new(None),
shutting_down: AtomicBool::new(false),
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
@@ -517,6 +520,8 @@ impl LspManager {
}
let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) };
self.refresh_external_project(&server, &run).await;
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
{
let mut docs = self.docs.lock();
@@ -2212,6 +2217,7 @@ impl LspManager {
return;
}
let graceful = async {
self.close_external_project(server, &run).await;
peer.request::<_, Value>("shutdown", (), None).await.ok();
peer.notify("exit", ()).ok();
peer.closed().await;
+6
Переглянути файл
@@ -99,6 +99,9 @@ pub struct Server {
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
pub external_refresh_queued: std::sync::atomic::AtomicBool,
}
impl Server {
@@ -123,6 +126,9 @@ impl Server {
published_notify: Notify::new(),
idle_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
external_project: tokio::sync::Mutex::new(Default::default()),
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
}
}
+65
Переглянути файл
@@ -210,6 +210,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
@@ -2258,3 +2259,67 @@ async fn rename_edits_files_outside_the_analysis_scope() {
assert_eq!(paths, vec![a, b.clone()]);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
}
#[tokio::test]
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
for capable in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": if capable {
vec!["typescript.tsserverRequest"]
} else { vec!["_typescript.organizeImports"] } });
let h = harness_configured("typescript", "ts", &[("arbitrary-name", caps)], |cfg| {
cfg.tsserver_project = true;
});
let s = h.launcher.state("arbitrary-name");
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
std::fs::write(h.root.join("unopened.ts"), "export const helper = 1;").unwrap();
h.open("main.ts", "helper");
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
if !capable {
assert!(s.requested("workspace/executeCommand").is_empty());
h.mgr.shutdown_all().await;
assert!(s.requested("workspace/executeCommand").is_empty());
continue;
}
let requests = s.requested("workspace/executeCommand");
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
h.mgr.project_files_changed([&h.root.join("new.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
assert_eq!(
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
3
);
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
eventually(|| {
s.requested("workspace/executeCommand")
.iter()
.filter(|r| r["arguments"][0] == "openExternalProject")
.count()
>= 5
})
.await;
std::fs::write(h.root.join("tsconfig.json"), "{}").unwrap();
h.mgr.project_files_changed([&h.root.join("tsconfig.json")]);
eventually(|| {
s.requested("workspace/executeCommand")
.iter()
.filter(|r| r["arguments"][0] == "closeExternalProject")
.count()
>= 2
})
.await;
h.mgr.shutdown_all().await;
}
}
+3
Переглянути файл
@@ -78,6 +78,7 @@ async fn missing_binary_is_reported_with_install_hint() {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
let path = dir.path().join("a.nope");
@@ -247,6 +248,7 @@ async fn typescript_language_server_end_to_end() {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
let doc = DocId::next();
@@ -292,6 +294,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
+1
Переглянути файл
@@ -462,6 +462,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
+1
Переглянути файл
@@ -35,6 +35,7 @@ fn server(name: &str) -> LanguageServerConfig {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
+2
Переглянути файл
@@ -10,6 +10,7 @@ tree-sitter = "javascript"
[[language-servers]]
name = "vtsls"
tsserver-project = true
command = "vtsls"
args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
@@ -17,6 +18,7 @@ install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
# Alternative: used only when the server above is not installed.
fallback = true
command = "typescript-language-server"
+2
Переглянути файл
@@ -9,6 +9,7 @@ tree-sitter = "javascript"
[[language-servers]]
name = "vtsls"
tsserver-project = true
command = "vtsls"
args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
@@ -16,6 +17,7 @@ install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
# Alternative: used only when the server above is not installed.
fallback = true
command = "typescript-language-server"
+2
Переглянути файл
@@ -9,6 +9,7 @@ tree-sitter = "typescript"
[[language-servers]]
name = "vtsls"
tsserver-project = true
command = "vtsls"
args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
@@ -16,6 +17,7 @@ install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
# Alternative: used only when the server above is not installed.
fallback = true
command = "typescript-language-server"
+2
Переглянути файл
@@ -9,6 +9,7 @@ tree-sitter = "tsx"
[[language-servers]]
name = "vtsls"
tsserver-project = true
command = "vtsls"
args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
@@ -16,6 +17,7 @@ install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "typescript-language-server"
tsserver-project = true
# Alternative: used only when the server above is not installed.
fallback = true
command = "typescript-language-server"