diff --git a/Cargo.lock b/Cargo.lock index 5ff72f2baf95a..0cb0958429bf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8839,6 +8839,7 @@ dependencies = [ "jsonc-parser 0.23.0", "pidlock", "serde_json", + "tempfile", "tokio", "tokio-retry", "tower-lsp", diff --git a/crates/turborepo-lsp/Cargo.toml b/crates/turborepo-lsp/Cargo.toml index a9cc8fb1a1d1c..c08680cc3e5bb 100644 --- a/crates/turborepo-lsp/Cargo.toml +++ b/crates/turborepo-lsp/Cargo.toml @@ -27,3 +27,6 @@ turbopath = { version = "0.1.0", path = "../turborepo-paths" } turborepo-lib = { version = "0.1.0", path = "../turborepo-lib" } turborepo-repository = { version = "0.1.0", path = "../turborepo-repository" } wax.workspace = true + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/turborepo-lsp/src/lib.rs b/crates/turborepo-lsp/src/lib.rs index 0dbfa9d5ee956..33d371c42ec6c 100644 --- a/crates/turborepo-lsp/src/lib.rs +++ b/crates/turborepo-lsp/src/lib.rs @@ -15,11 +15,10 @@ use std::{ borrow::Cow, collections::{HashMap, HashSet}, - iter, sync::{Arc, Mutex, MutexGuard}, }; -use itertools::{Itertools, chain}; +use itertools::Itertools; use jsonc_parser::{ CollectOptions, ast::{ObjectPropName, StringLit}, @@ -37,19 +36,249 @@ use turborepo_lib::{ DaemonPaths, }; use turborepo_repository::{ - discovery::{self, DiscoveryResponse, PackageDiscovery, WorkspaceData}, + discovery::{self, PackageDiscovery}, inference::RepoState, + package_graph::{self, PackageGraph, PackageName}, package_json::PackageJson, + toolchain::ToolchainId, }; const TURBO_EXTENDS: &str = "$TURBO_EXTENDS$"; +/// Script source joined to identity and paths supplied by `PackageGraph`. +/// +/// The parsed manifest remains a payload because LSP features need scripts and +/// source ranges. It is deliberately not consulted for package identity or +/// location after the graph has been built. +#[derive(Debug)] +struct LspPackage { + /// `None` is a parsed compatibility source that repository knowledge did + /// not represent (notably an unnamed workspace). + identity: Option, + source_path: AbsoluteSystemPathBuf, + source: String, + package_json: PackageJson, +} + +#[derive(Debug)] +struct PackageSource { + text: String, + package_json: PackageJson, +} + +#[derive(Debug, Default)] +struct LspPackages { + packages: Vec, +} + +impl LspPackages { + fn from_graph_or_unscoped( + graph: Result, + mut sources: HashMap, + root_path: &AbsoluteSystemPathBuf, + ) -> Self { + match graph { + Ok(graph) => Self::from_graph(&graph, sources), + Err(_) => { + let root = sources.remove(root_path).map(|source| LspPackage { + identity: Some(PackageName::Root), + source_path: root_path.clone(), + source: source.text, + package_json: source.package_json, + }); + let mut packages = Self::unscoped(sources).packages; + packages.extend(root); + Self::new(packages) + } + } + } + + fn from_graph( + graph: &PackageGraph, + mut sources: HashMap, + ) -> Self { + let mut packages = Vec::with_capacity(sources.len()); + for (identity, _) in graph.package_scope_directories() { + let Some(definition_path) = graph.package_definition_path(&identity) else { + continue; + }; + let definition_path = graph.repo_root().resolve(definition_path); + // Remove every graph-owned source before feature filtering. An + // aggregate or native package must not reappear as an unscoped JS + // compatibility package. + let Some(source) = sources.remove(&definition_path) else { + continue; + }; + if graph.package_toolchain(&identity) != Some(&ToolchainId::JAVASCRIPT) + || graph.is_aggregate_scope(&identity) + { + continue; + } + packages.push(LspPackage { + identity: Some(identity), + source_path: definition_path, + source: source.text, + package_json: source.package_json, + }); + } + + packages.extend(sources.into_iter().map(|(source_path, source)| LspPackage { + identity: None, + source_path, + source: source.text, + package_json: source.package_json, + })); + Self::new(packages) + } + + fn unscoped(sources: HashMap) -> Self { + Self::new( + sources + .into_iter() + .map(|(source_path, source)| LspPackage { + identity: None, + source_path, + source: source.text, + package_json: source.package_json, + }) + .collect(), + ) + } + + fn new(mut packages: Vec) -> Self { + packages.sort_by(|left, right| { + package_sort_key(left) + .cmp(&package_sort_key(right)) + .then_with(|| left.source_path.cmp(&right.source_path)) + }); + Self { packages } + } + + fn task_index(&self) -> HashMap>> { + let mut tasks = HashMap::>>::new(); + for package in &self.packages { + let identity = package + .identity + .as_ref() + .map(|identity| identity.as_str().to_string()); + for script in package.package_json.scripts.keys() { + let identities = tasks.entry(script.clone()).or_default(); + if !identities.contains(&identity) { + identities.push(identity.clone()); + } + } + } + tasks + } + + fn completion_labels(&self) -> Vec { + let qualified = self.packages.iter().flat_map(|package| { + package.identity.iter().flat_map(|identity| { + package + .package_json + .scripts + .keys() + .map(move |script| format!("{}#{script}", identity.as_str())) + }) + }); + let tasks = self + .packages + .iter() + .flat_map(|package| package.package_json.scripts.keys().cloned()); + + qualified.chain(tasks).unique().collect() + } + + fn references(&self, referenced_task: &str) -> Vec { + let (requested_package, task) = referenced_task + .rsplit_once('#') + .map(|(package, task)| (Some(package), task)) + .unwrap_or((None, referenced_task)); + + self.packages + .iter() + .filter(|package| { + requested_package.is_none_or(|requested| { + package + .identity + .as_ref() + .is_some_and(|identity| requested == identity.as_str()) + }) && package.package_json.scripts.contains_key(task) + }) + .filter_map(|package| { + // TODO: use jsonc_ast instead of text search. + let start = package.source.find(&format!("\"{task}\""))?; + let end = start + task.len() + 2; + let rope = crop::Rope::from(package.source.as_str()); + let start_line = rope.line_of_byte(start); + let end_line = rope.line_of_byte(end); + let range = Range { + start: Position { + line: start_line as u32, + character: (start - rope.byte_of_line(start_line)) as u32, + }, + end: Position { + line: end_line as u32, + character: (end - rope.byte_of_line(end_line)) as u32, + }, + }; + let uri = Url::from_file_path(&package.source_path).ok()?; + Some(Location::new(uri, range)) + }) + .collect() + } +} + +#[derive(Debug, Default)] +struct LspPackageCache(Mutex>>); + +impl LspPackageCache { + fn get(&self) -> Option> { + lock_or_recover(&self.0).clone() + } + + fn set(&self, packages: Arc) { + *lock_or_recover(&self.0) = Some(packages); + } + + fn invalidate(&self) { + lock_or_recover(&self.0).take(); + } +} + +fn retain_unique_named(package_jsons: &mut HashMap) { + let counts = package_jsons + .values() + .filter_map(|package_json| { + package_json + .name + .as_ref() + .map(|name| name.as_str().to_string()) + }) + .counts(); + package_jsons.retain(|_, package_json| { + package_json + .name + .as_ref() + .is_some_and(|name| counts.get(name.as_str()) == Some(&1)) + }); +} + +fn package_sort_key(package: &LspPackage) -> (u8, &str) { + match package.identity.as_ref() { + Some(PackageName::Root) => (0, ""), + Some(PackageName::Other(identity)) => (1, identity), + None => (2, ""), + } +} + pub struct Backend { client: Client, repo_root: Arc>>, files: Mutex>, initializer: Sender>>, daemon: Receiver>>, + packages: LspPackageCache, // this is only used for turbo optimize pidlock: Mutex>, @@ -107,6 +336,7 @@ impl LanguageServer for Backend { Err(_) => repo_root, }; + self.packages.invalidate(); lock_or_recover(&self.repo_root).replace(repo_root.clone()); let paths = DaemonPaths::from_repo_root(&repo_root).map_err(|err| { @@ -346,18 +576,6 @@ impl LanguageServer for Backend { ) .await; - let repo_root = lock_or_recover(&self.repo_root).clone(); - - let repo_root = match repo_root { - Some(repo_root) => repo_root, - None => { - self.client - .log_message(MessageType::INFO, "received request before initialization") - .await; - return Ok(None); - } - }; - let packages = match self.package_discovery().await { Ok(packages) => packages, Err(e) => { @@ -373,72 +591,7 @@ impl LanguageServer for Backend { } }; - let mut locations = vec![]; - for wd in packages.workspaces { - let data = match std::fs::read_to_string(&wd.package_json) { - Ok(data) => data, - // if we can't read a package.json, then we can't set up references to it - // so we just skip it and do a best effort - Err(_) => continue, - }; - let package_json = match PackageJson::load_from_str(&data, wd.package_json.as_str()) { - Ok(package_json) => package_json, - // if we can't parse a package.json, then we can't set up references to it - // so we just skip it and do a best effort - Err(_) => continue, - }; - let scripts = package_json.scripts.into_keys().collect::>(); - - // if in the root, the name should be '//' - let package_json_name = if repo_root.contains(&wd.package_json) { - Some("//") - } else { - package_json.name.as_ref().map(|name| name.as_str()) - }; - - // todo: use jsonc_ast instead of text search - let rope = crop::Rope::from(data.clone()); - - let (package, task) = referenced_task - .rsplit_once('#') - .map(|(p, t)| (Some(p), t)) - .unwrap_or((None, &referenced_task)); - - if let (Some(package), Some(package_name)) = (package, package_json_name) - && package_name != package - { - continue; - }; - - let Some(start) = data.find(&format!("\"{task}\"")) else { - continue; - }; - let end = start + task.len() + 2; - - let start_line = rope.line_of_byte(start); - let end_line = rope.line_of_byte(end); - - let range = Range { - start: Position { - line: start_line as u32, - character: (start - rope.byte_of_line(start_line)) as u32, - }, - end: Position { - line: end_line as u32, - character: (end - rope.byte_of_line(end_line)) as u32, - }, - }; - - if scripts.contains(task) { - let Ok(uri) = Url::from_file_path(&wd.package_json) else { - continue; - }; - let location = Location::new(uri, range); - locations.push(location); - } - } - - Ok(Some(locations)) + Ok(Some(packages.references(&referenced_task))) } /// Add code lens items for running a particular task in the turbo.json @@ -552,18 +705,21 @@ impl LanguageServer for Backend { } async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) { + self.packages.invalidate(); self.client .log_message(MessageType::INFO, "workspace folders changed!") .await; } async fn did_change_configuration(&self, _: DidChangeConfigurationParams) { + self.packages.invalidate(); self.client .log_message(MessageType::INFO, "configuration changed!") .await; } async fn did_change_watched_files(&self, _: DidChangeWatchedFilesParams) { + self.packages.invalidate(); self.client .log_message(MessageType::INFO, "watched files have changed!") .await; @@ -643,6 +799,9 @@ impl LanguageServer for Backend { } async fn did_save(&self, document: DidSaveTextDocumentParams) { + if document.text_document.uri.path().ends_with("/package.json") { + self.packages.invalidate(); + } self.client .log_message( MessageType::INFO, @@ -673,32 +832,17 @@ impl LanguageServer for Backend { .await .map_err(|_e| Error::internal_error())?; - let package_jsons = packages - .workspaces + let items = packages + .completion_labels() .into_iter() - .flat_map(|wd| PackageJson::load(&wd.package_json).ok()) // if we can't parse a package.json, then we can't infer its tasks - .collect::>(); - - let tasks = package_jsons - .iter() - .flat_map(|p| p.scripts.keys()) - .unique() - .map(|s| CompletionItem { - label: s.to_owned(), + .map(|label| CompletionItem { + label, kind: Some(CompletionItemKind::FIELD), ..Default::default() - }); + }) + .collect(); - let keys = package_jsons - .iter() - .flat_map(|p| p.scripts.keys().map(move |k| (p.name.clone(), k))) - .map(|(package, s)| CompletionItem { - label: format!("{}#{}", package.unwrap_or_default().into_inner(), s), - kind: Some(CompletionItemKind::FIELD), - ..Default::default() - }); - - Ok(Some(CompletionResponse::Array(keys.chain(tasks).collect()))) + Ok(Some(CompletionResponse::Array(items))) } } @@ -712,30 +856,100 @@ impl Backend { files: Mutex::new(HashMap::new()), initializer: rx, daemon: tx, + packages: LspPackageCache::default(), pidlock: Mutex::new(None), } } - pub async fn package_discovery(&self) -> Result { - let repo_root = lock_or_recover(&self.repo_root) - .clone() - .ok_or(discovery::Error::Unavailable)?; + async fn package_discovery(&self) -> Result, package_graph::Error> { + if let Some(packages) = self.packages.get() { + return Ok(packages); + } + let repo_root = + lock_or_recover(&self.repo_root) + .clone() + .ok_or(package_graph::Error::Discovery( + discovery::Error::Unavailable, + ))?; + let root_manifest_path = repo_root.join_component("package.json"); + + // LSP has no Cargo package features today. Still construct the same + // repository view so a pure Cargo repository cannot gain a synthetic + // JavaScript manifest or root script scope. + if !root_manifest_path.exists() { + let graph = PackageGraph::builder_optional(&repo_root, None) + .build() + .await?; + let packages = Arc::new(LspPackages::from_graph(&graph, HashMap::new())); + self.packages.set(packages.clone()); + return Ok(packages); + } + let daemon = { let mut daemon = self.daemon.clone(); let daemon = daemon .wait_for(|d| d.is_some()) .await - .map_err(|_| discovery::Error::Unavailable)?; + .map_err(|_| package_graph::Error::Discovery(discovery::Error::Unavailable))?; let Some(daemon) = daemon.as_ref() else { - return Err(discovery::Error::Unavailable); + return Err(package_graph::Error::Discovery( + discovery::Error::Unavailable, + )); }; daemon.clone() }; - DaemonPackageDiscovery::new(daemon, repo_root) + let response = DaemonPackageDiscovery::new(daemon, repo_root.clone()) .discover_packages_blocking() - .await + .await?; + + // Parse each manifest once. The parsed map seeds graph construction; + // the parallel source map is retained only for scripts and ranges. + let mut package_jsons = HashMap::with_capacity(response.workspaces.len()); + let mut sources = HashMap::with_capacity(response.workspaces.len() + 1); + for workspace in response.workspaces { + let Ok(text) = std::fs::read_to_string(&workspace.package_json) else { + continue; + }; + let Ok(package_json) = + PackageJson::load_from_str(&text, workspace.package_json.as_str()) + else { + continue; + }; + package_jsons.insert(workspace.package_json.clone(), package_json.clone()); + sources.insert(workspace.package_json, PackageSource { text, package_json }); + } + retain_unique_named(&mut package_jsons); + + let root_text = std::fs::read_to_string(&root_manifest_path).ok(); + let parsed_root = root_text + .as_deref() + .and_then(|text| PackageJson::load_from_str(text, root_manifest_path.as_str()).ok()); + let root_package_json = parsed_root.clone().unwrap_or_default(); + if let (Some(text), Some(package_json)) = (root_text, parsed_root) { + sources.insert( + root_manifest_path.clone(), + PackageSource { text, package_json }, + ); + } + + let graph = PackageGraph::builder(&repo_root, root_package_json) + .with_package_manager(response.package_manager) + .with_package_jsons(Some(package_jsons)) + .build() + .await; + + // Discovery succeeded, so repository validation errors should not + // disable every LSP script feature. Sources that cannot be joined to + // authoritative knowledge remain deliberately unscoped. + let packages = Arc::new(LspPackages::from_graph_or_unscoped( + graph, + sources, + &root_manifest_path, + )); + self.packages.set(packages.clone()); + Ok(packages) } /// Handle a file update to a rope, emitting diagnostics if necessary. @@ -751,51 +965,8 @@ impl Backend { let contents = rope.chunks().join(""); - let repo_root = lock_or_recover(&self.repo_root).clone(); - - let repo_root = match repo_root { - Some(repo_root) => repo_root, - None => { - self.client - .log_message(MessageType::INFO, "received request before initialization") - .await; - return; - } - }; - let packages = self.package_discovery().await; - - // package discovery does not yield the root, so we must add it - let root_turbo_json = repo_root.join_component("turbo.json"); - let workspaces = packages.map(|p| { - chain( - p.workspaces, - iter::once(WorkspaceData { - package_json: repo_root.join_component("package.json"), - turbo_json: root_turbo_json.exists().then_some(root_turbo_json), - }), - ) - }); - - let tasks = workspaces.map(|workspaces| { - workspaces - .filter_map(|wd| { - let package_json = PackageJson::load(&wd.package_json).ok()?; // if we can't load a package.json, then we can't infer its tasks - let package_json_name = if wd.package_json.parent() == Some(&repo_root) { - Some("//".to_string()) - } else { - package_json.name.map(|name| name.into_inner()) - }; - Some( - package_json - .scripts - .into_keys() - .map(move |k| (k, package_json_name.clone())), - ) - }) - .flatten() - .into_group_map() - }); + let tasks = packages.map(|packages| packages.task_index()); // we still want to emit diagnostics if we can't infer tasks let tasks_and_packages = tasks.as_ref().map(|tasks| { @@ -1129,15 +1300,86 @@ mod tests { use std::{ borrow::Cow, collections::{HashMap, HashSet}, + sync::Arc, }; use jsonc_parser::{ast::StringLit, common::Range}; + use serde_json::json; use tower_lsp::lsp_types::{Diagnostic, NumberOrString}; + use turbopath::AbsoluteSystemPathBuf; + use turborepo_repository::{ + package_graph::{PackageGraph, PackageName}, + package_json::PackageJson, + package_manager::PackageManager, + }; use super::{ - collect_transit_node_tasks, is_turbo_extends_sentinel, report_invalid_packages_and_tasks, + LspPackageCache, LspPackages, PackageSource, collect_transit_node_tasks, + is_turbo_extends_sentinel, report_invalid_packages_and_tasks, retain_unique_named, }; + struct TestRepository { + _temp_dir: tempfile::TempDir, + root: AbsoluteSystemPathBuf, + } + + impl TestRepository { + fn new() -> Self { + let temp_dir = tempfile::tempdir().expect("temp directory"); + let root = + AbsoluteSystemPathBuf::new(temp_dir.path().to_str().expect("UTF-8 temp directory")) + .expect("absolute temp directory"); + Self { + _temp_dir: temp_dir, + root, + } + } + + async fn lsp_packages( + &self, + root_source: &str, + workspaces: &[(&str, &str)], + ) -> LspPackages { + let root_path = self.root.join_component("package.json"); + let root_package_json = parse_package_json(root_source, &root_path); + let mut package_jsons = HashMap::new(); + let mut sources = HashMap::from([( + root_path, + PackageSource { + text: root_source.to_string(), + package_json: root_package_json.clone(), + }, + )]); + + for (relative_path, source) in workspaces { + let components = relative_path.split('/').collect::>(); + let path = self.root.join_components(&components); + let package_json = parse_package_json(source, &path); + package_jsons.insert(path.clone(), package_json.clone()); + sources.insert( + path, + PackageSource { + text: source.to_string(), + package_json, + }, + ); + } + retain_unique_named(&mut package_jsons); + + let graph = PackageGraph::builder(&self.root, root_package_json) + .with_package_manager(PackageManager::Npm) + .with_package_jsons(Some(package_jsons)) + .build() + .await + .expect("package graph"); + LspPackages::from_graph(&graph, sources) + } + } + + fn parse_package_json(source: &str, path: &AbsoluteSystemPathBuf) -> PackageJson { + PackageJson::load_from_str(source, path.as_str()).expect("valid package.json") + } + fn string_lit(value: &'static str) -> StringLit<'static> { StringLit { value: Cow::Borrowed(value), @@ -1224,6 +1466,272 @@ mod tests { assert_eq!(diagnostic_code(&diagnostics[0]), Some("turbo:no-such-task")); } + #[tokio::test] + async fn root_scope_uses_graph_identity_when_named_or_unnamed() { + for root_source in [ + r#"{"name":"repository","scripts":{"build":"named"}}"#, + r#"{"scripts":{"build":"unnamed"}}"#, + ] { + let repository = TestRepository::new(); + let packages = repository.lsp_packages(root_source, &[]).await; + + assert_eq!(packages.packages.len(), 1); + assert_eq!(packages.packages[0].identity, Some(PackageName::Root)); + assert!( + packages + .completion_labels() + .contains(&"//#build".to_string()) + ); + } + } + + #[tokio::test] + async fn nested_package_definition_path_is_graph_backed() { + let repository = TestRepository::new(); + let packages = repository + .lsp_packages( + r#"{"scripts":{}}"#, + &[( + "apps/nested/web/package.json", + r#"{"name":"web","scripts":{"dev":"next dev"}}"#, + )], + ) + .await; + let web = packages + .packages + .iter() + .find(|package| { + package + .identity + .as_ref() + .is_some_and(|identity| identity.as_str() == "web") + }) + .expect("web package"); + + assert_eq!( + web.source_path, + repository + .root + .join_components(&["apps", "nested", "web", "package.json"]) + ); + } + + #[tokio::test] + async fn stale_source_name_cannot_change_identity_or_reference_path() { + let repository = TestRepository::new(); + let definition_path = repository + .root + .join_components(&["packages", "app", "package.json"]); + let authoritative = PackageJson::from_value(json!({ + "name": "authoritative", + "scripts": { "stale-task": "old command" } + })) + .expect("package json"); + let graph = PackageGraph::builder( + &repository.root, + PackageJson::from_value(json!({})).expect("root package json"), + ) + .with_package_manager(PackageManager::Npm) + .with_package_jsons(Some(HashMap::from([( + definition_path.clone(), + authoritative, + )]))) + .build() + .await + .expect("package graph"); + // Identity and definition path belong to graph knowledge, while the + // current source payload owns scripts until native task APIs exist. + let stale_source = r#"{"name":"stale","scripts":{"source-task":"new command"}}"#; + let packages = LspPackages::from_graph( + &graph, + HashMap::from([( + definition_path.clone(), + PackageSource { + text: stale_source.to_string(), + package_json: parse_package_json(stale_source, &definition_path), + }, + )]), + ); + + assert!( + packages + .completion_labels() + .contains(&"authoritative#source-task".to_string()) + ); + assert!( + !packages + .completion_labels() + .contains(&"stale#source-task".to_string()) + ); + assert!( + !packages + .completion_labels() + .contains(&"authoritative#stale-task".to_string()) + ); + let references = packages.references("authoritative#source-task"); + assert_eq!(references.len(), 1); + assert_eq!( + references[0].uri.to_file_path().ok(), + Some(definition_path.into()) + ); + assert!(packages.references("stale#source-task").is_empty()); + } + + #[tokio::test] + async fn unnamed_workspace_is_unscoped_compatibility_payload() { + let repository = TestRepository::new(); + let packages = repository + .lsp_packages( + r#"{"scripts":{}}"#, + &[( + "packages/unnamed/package.json", + r#"{"scripts":{"build":"build unnamed"}}"#, + )], + ) + .await; + let unnamed = packages + .packages + .iter() + .find(|package| package.identity.is_none()) + .expect("unscoped package source"); + + assert!( + unnamed + .source_path + .ends_with("packages/unnamed/package.json") + ); + assert_eq!(packages.completion_labels(), vec!["build"]); + assert_eq!(packages.references("build").len(), 1); + assert!(packages.references("#build").is_empty()); + assert_eq!(packages.task_index().get("build"), Some(&vec![None])); + } + + #[tokio::test] + async fn duplicate_names_remain_unscoped_without_hiding_valid_packages() { + let repository = TestRepository::new(); + let packages = repository + .lsp_packages( + r#"{"scripts":{"root":"root"}}"#, + &[ + ( + "unique/package.json", + r#"{"name":"unique","scripts":{"unique":"unique"}}"#, + ), + ( + "first/package.json", + r#"{"name":"duplicate","scripts":{"shared":"first"}}"#, + ), + ( + "second/package.json", + r#"{"name":"duplicate","scripts":{"shared":"second"}}"#, + ), + ], + ) + .await; + + let labels = packages.completion_labels(); + assert!(labels.contains(&"//#root".to_string())); + assert!(labels.contains(&"unique#unique".to_string())); + assert!(!labels.iter().any(|label| label == "duplicate#shared")); + assert_eq!(packages.references("shared").len(), 2); + assert_eq!(packages.task_index().get("shared"), Some(&vec![None])); + } + + #[test] + fn package_cache_survives_changes_until_save_invalidation() { + let cache = LspPackageCache::default(); + let packages = Arc::new(LspPackages::default()); + cache.set(packages.clone()); + assert!(Arc::ptr_eq( + &cache.get().expect("cached packages"), + &packages + )); + assert!(Arc::ptr_eq( + &cache.get().expect("cache survives another document change"), + &packages + )); + cache.invalidate(); + assert!(cache.get().is_none()); + } + + #[test] + fn source_insertion_order_does_not_affect_lsp_results() { + let repository = TestRepository::new(); + let first_path = repository.root.join_component("a-package.json"); + let second_path = repository.root.join_component("b-package.json"); + let first_source = r#"{"scripts":{"build":"first","alpha":"alpha"}}"#; + let second_source = r#"{"scripts":{"build":"second","beta":"beta"}}"#; + let source = |text: &str, path: &AbsoluteSystemPathBuf| PackageSource { + text: text.to_string(), + package_json: parse_package_json(text, path), + }; + let forward = LspPackages::unscoped(HashMap::from([ + (first_path.clone(), source(first_source, &first_path)), + (second_path.clone(), source(second_source, &second_path)), + ])); + let reverse = LspPackages::unscoped(HashMap::from([ + (second_path.clone(), source(second_source, &second_path)), + (first_path.clone(), source(first_source, &first_path)), + ])); + + assert_eq!(forward.completion_labels(), reverse.completion_labels()); + assert_eq!(forward.task_index(), reverse.task_index()); + assert_eq!( + forward + .references("build") + .into_iter() + .map(|reference| reference.uri) + .collect::>(), + reverse + .references("build") + .into_iter() + .map(|reference| reference.uri) + .collect::>() + ); + } + + #[tokio::test] + async fn completion_references_and_file_update_index_include_root_and_packages() { + let repository = TestRepository::new(); + let packages = repository + .lsp_packages( + r#"{"name":"ignored-root-name","scripts":{"lint":"root lint"}}"#, + &[( + "packages/ui/package.json", + r#"{"name":"@repo/ui","scripts":{"lint":"eslint","test":"vitest"}}"#, + )], + ) + .await; + + let labels = packages.completion_labels(); + assert!(labels.contains(&"//#lint".to_string())); + assert!(labels.contains(&"@repo/ui#test".to_string())); + assert_eq!(labels.iter().filter(|label| *label == "lint").count(), 1); + assert_eq!(packages.references("lint").len(), 2); + assert_eq!(packages.references("//#lint").len(), 1); + + let tasks = packages.task_index(); + assert_eq!( + tasks.get("lint"), + Some(&vec![Some("//".to_string()), Some("@repo/ui".to_string())]) + ); + assert_eq!(tasks.get("test"), Some(&vec![Some("@repo/ui".to_string())])); + } + + #[tokio::test] + async fn pure_cargo_graph_does_not_invent_javascript_packages() { + let repository = TestRepository::new(); + let graph = PackageGraph::builder_optional(&repository.root, None) + .build() + .await + .expect("pure Cargo graph"); + let packages = LspPackages::from_graph(&graph, HashMap::new()); + + assert!(packages.packages.is_empty()); + assert!(packages.completion_labels().is_empty()); + assert!(packages.task_index().is_empty()); + } + fn diagnostic_code(diagnostic: &Diagnostic) -> Option<&str> { match diagnostic.code.as_ref()? { NumberOrString::String(code) => Some(code.as_str()),