diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 8a9c5f6961..09016cb303 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -8,8 +8,7 @@ use futures::future::join_all; use gpui::{App, AppContext, AsyncApp, Task}; use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url}; use language::{ - ContextLocation, ContextProvider, File, LanguageToolchainStore, LocalFile, LspAdapter, - LspAdapterDelegate, + ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate, }; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName}; use node_runtime::NodeRuntime; @@ -29,6 +28,7 @@ use util::archive::extract_zip; use util::merge_json_value_into; use util::{ResultExt, fs::remove_matching, maybe}; +#[derive(Debug)] pub(crate) struct TypeScriptContextProvider { last_package_json: PackageJsonContents, } @@ -42,47 +42,81 @@ const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName = const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME")); -#[derive(Clone, Default)] +const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH")); + +const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH")); + +const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH")); + +const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH")); + +#[derive(Clone, Debug, Default)] struct PackageJsonContents(Arc>>); +#[derive(Clone, Debug)] struct PackageJson { mtime: DateTime, data: PackageJsonData, } -#[derive(Clone, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] struct PackageJsonData { - jest: bool, - mocha: bool, - vitest: bool, - jasmine: bool, - scripts: BTreeSet, + jest_package_path: Option>, + mocha_package_path: Option>, + vitest_package_path: Option>, + jasmine_package_path: Option>, + scripts: BTreeSet<(Arc, String)>, package_manager: Option<&'static str>, } impl PackageJsonData { - fn new(package_json: HashMap) -> Self { + fn new(path: Arc, package_json: HashMap) -> Self { let mut scripts = BTreeSet::new(); if let Some(serde_json::Value::Object(package_json_scripts)) = package_json.get("scripts") { - scripts.extend(package_json_scripts.keys().cloned()); + scripts.extend( + package_json_scripts + .keys() + .cloned() + .map(|name| (path.clone(), name)), + ); } - let mut jest = false; - let mut mocha = false; - let mut vitest = false; - let mut jasmine = false; + let mut jest_package_path = None; + let mut mocha_package_path = None; + let mut vitest_package_path = None; + let mut jasmine_package_path = None; if let Some(serde_json::Value::Object(dependencies)) = package_json.get("devDependencies") { - jest |= dependencies.contains_key("jest"); - mocha |= dependencies.contains_key("mocha"); - vitest |= dependencies.contains_key("vitest"); - jasmine |= dependencies.contains_key("jasmine"); + if dependencies.contains_key("jest") { + jest_package_path.get_or_insert_with(|| path.clone()); + } + if dependencies.contains_key("mocha") { + mocha_package_path.get_or_insert_with(|| path.clone()); + } + if dependencies.contains_key("vitest") { + vitest_package_path.get_or_insert_with(|| path.clone()); + } + if dependencies.contains_key("jasmine") { + jasmine_package_path.get_or_insert_with(|| path.clone()); + } } if let Some(serde_json::Value::Object(dev_dependencies)) = package_json.get("dependencies") { - jest |= dev_dependencies.contains_key("jest"); - mocha |= dev_dependencies.contains_key("mocha"); - vitest |= dev_dependencies.contains_key("vitest"); - jasmine |= dev_dependencies.contains_key("jasmine"); + if dev_dependencies.contains_key("jest") { + jest_package_path.get_or_insert_with(|| path.clone()); + } + if dev_dependencies.contains_key("mocha") { + mocha_package_path.get_or_insert_with(|| path.clone()); + } + if dev_dependencies.contains_key("vitest") { + vitest_package_path.get_or_insert_with(|| path.clone()); + } + if dev_dependencies.contains_key("jasmine") { + jasmine_package_path.get_or_insert_with(|| path.clone()); + } } let package_manager = package_json @@ -101,33 +135,37 @@ impl PackageJsonData { }); Self { - jest, - mocha, - vitest, - jasmine, + jest_package_path, + mocha_package_path, + vitest_package_path, + jasmine_package_path, scripts, package_manager, } } fn merge(&mut self, other: Self) { - self.jest |= other.jest; - self.mocha |= other.mocha; - self.vitest |= other.vitest; - self.jasmine |= other.jasmine; + self.jest_package_path = self.jest_package_path.take().or(other.jest_package_path); + self.mocha_package_path = self.mocha_package_path.take().or(other.mocha_package_path); + self.vitest_package_path = self + .vitest_package_path + .take() + .or(other.vitest_package_path); + self.jasmine_package_path = self + .jasmine_package_path + .take() + .or(other.jasmine_package_path); self.scripts.extend(other.scripts); + self.package_manager = self.package_manager.or(other.package_manager); } fn fill_task_templates(&self, task_templates: &mut TaskTemplates) { - if self.jest { + if self.jest_package_path.is_some() { task_templates.0.push(TaskTemplate { label: "jest file test".to_owned(), command: TYPESCRIPT_RUNNER_VARIABLE.template_value(), - args: vec![ - "jest".to_owned(), - VariableName::RelativeFile.template_value(), - ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + args: vec!["jest".to_owned(), VariableName::Filename.template_value()], + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); task_templates.0.push(TaskTemplate { @@ -140,28 +178,28 @@ impl PackageJsonData { "\"{}\"", TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value() ), - VariableName::RelativeFile.template_value(), + VariableName::Filename.template_value(), ], tags: vec![ "ts-test".to_owned(), "js-test".to_owned(), "tsx-test".to_owned(), ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); } - if self.vitest { + if self.vitest_package_path.is_some() { task_templates.0.push(TaskTemplate { label: format!("{} file test", "vitest".to_owned()), command: TYPESCRIPT_RUNNER_VARIABLE.template_value(), args: vec![ "vitest".to_owned(), "run".to_owned(), - VariableName::RelativeFile.template_value(), + VariableName::Filename.template_value(), ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); task_templates.0.push(TaskTemplate { @@ -179,27 +217,24 @@ impl PackageJsonData { "\"{}\"", TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value() ), - VariableName::RelativeFile.template_value(), + VariableName::Filename.template_value(), ], tags: vec![ "ts-test".to_owned(), "js-test".to_owned(), "tsx-test".to_owned(), ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); } - if self.mocha { + if self.mocha_package_path.is_some() { task_templates.0.push(TaskTemplate { label: format!("{} file test", "mocha".to_owned()), command: TYPESCRIPT_RUNNER_VARIABLE.template_value(), - args: vec![ - "mocha".to_owned(), - VariableName::RelativeFile.template_value(), - ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + args: vec!["mocha".to_owned(), VariableName::Filename.template_value()], + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); task_templates.0.push(TaskTemplate { @@ -213,27 +248,27 @@ impl PackageJsonData { "mocha".to_owned(), "--grep".to_owned(), format!("\"{}\"", VariableName::Symbol.template_value()), - VariableName::RelativeFile.template_value(), + VariableName::Filename.template_value(), ], tags: vec![ "ts-test".to_owned(), "js-test".to_owned(), "tsx-test".to_owned(), ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); } - if self.jasmine { + if self.jasmine_package_path.is_some() { task_templates.0.push(TaskTemplate { label: format!("{} file test", "jasmine".to_owned()), command: TYPESCRIPT_RUNNER_VARIABLE.template_value(), args: vec![ "jasmine".to_owned(), - VariableName::RelativeFile.template_value(), + VariableName::Filename.template_value(), ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); task_templates.0.push(TaskTemplate { @@ -246,30 +281,31 @@ impl PackageJsonData { args: vec![ "jasmine".to_owned(), format!("--filter={}", VariableName::Symbol.template_value()), - VariableName::RelativeFile.template_value(), + VariableName::Filename.template_value(), ], tags: vec![ "ts-test".to_owned(), "js-test".to_owned(), "tsx-test".to_owned(), + "jasmine-test".to_owned(), ], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some(VariableName::Dirname.template_value()), ..TaskTemplate::default() }); } - for script in &self.scripts { + for (path, script) in &self.scripts { task_templates.0.push(TaskTemplate { label: format!("package.json > {script}",), command: TYPESCRIPT_RUNNER_VARIABLE.template_value(), - args: vec![ - "--prefix".to_owned(), - VariableName::WorktreeRoot.template_value(), - "run".to_owned(), - script.to_owned(), - ], + args: vec!["run".to_owned(), script.to_owned()], tags: vec!["package-script".into()], - cwd: Some(VariableName::WorktreeRoot.template_value()), + cwd: Some( + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ), ..TaskTemplate::default() }); } @@ -287,13 +323,9 @@ impl TypeScriptContextProvider { &self, fs: Arc, worktree_root: &Path, - file_abs_path: &Path, + file_relative_path: &Path, cx: &App, ) -> Task> { - let Some(file_relative_path) = file_abs_path.strip_prefix(&worktree_root).ok() else { - log::debug!("No package json data for off-worktree files"); - return Task::ready(Ok(PackageJsonData::default())); - }; let new_json_data = file_relative_path .ancestors() .map(|path| worktree_root.join(path)) @@ -345,7 +377,8 @@ impl TypeScriptContextProvider { serde_json::from_str(&package_json_string).with_context(|| { format!("parsing package.json from {package_json_path:?}") })?; - let new_data = PackageJsonData::new(package_json); + let new_data = + PackageJsonData::new(package_json_path.as_path().into(), package_json); { let mut contents = existing_package_json.0.write().await; contents.insert( @@ -361,31 +394,25 @@ impl TypeScriptContextProvider { } }) } +} - fn detect_package_manager( - &self, - worktree_root: PathBuf, - fs: Arc, - cx: &App, - ) -> Task<&'static str> { - let last_package_json = self.last_package_json.clone(); - let package_json_data = - self.package_json_data(&worktree_root, last_package_json, fs.clone(), cx); - cx.background_spawn(async move { - if let Ok(package_json_data) = package_json_data.await { - if let Some(package_manager) = package_json_data.package_manager { - return package_manager; - } - } - if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await { - return "pnpm"; - } - if fs.is_file(&worktree_root.join("yarn.lock")).await { - return "yarn"; - } - "npm" - }) +async fn detect_package_manager( + worktree_root: PathBuf, + fs: Arc, + package_json_data: Option, +) -> &'static str { + if let Some(package_json_data) = package_json_data { + if let Some(package_manager) = package_json_data.package_manager { + return package_manager; + } } + if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await { + return "pnpm"; + } + if fs.is_file(&worktree_root.join("yarn.lock")).await { + return "yarn"; + } + "npm" } impl ContextProvider for TypeScriptContextProvider { @@ -401,9 +428,9 @@ impl ContextProvider for TypeScriptContextProvider { let Some(worktree_root) = file.worktree.read(cx).root_dir() else { return Task::ready(None); }; - let file_abs_path = file.abs_path(cx); + let file_relative_path = file.path().clone(); let package_json_data = - self.combined_package_json_data(fs.clone(), &worktree_root, &file_abs_path, cx); + self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx); cx.background_spawn(async move { let mut task_templates = TaskTemplates(Vec::new()); @@ -426,7 +453,7 @@ impl ContextProvider for TypeScriptContextProvider { } Err(e) => { log::error!( - "Failed to read package.json for worktree {file_abs_path:?}: {e:#}" + "Failed to read package.json for worktree {file_relative_path:?}: {e:#}" ); } } @@ -455,14 +482,73 @@ impl ContextProvider for TypeScriptContextProvider { replace_test_name_parameters(symbol), ); } + let file_path = location + .file_location + .buffer + .read(cx) + .file() + .map(|file| file.path()); - let task = location - .worktree_root - .zip(location.fs) - .map(|(worktree_root, fs)| self.detect_package_manager(worktree_root, fs, cx)); + let args = location.worktree_root.zip(location.fs).zip(file_path).map( + |((worktree_root, fs), file_path)| { + ( + self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx), + worktree_root, + fs, + ) + }, + ); cx.background_spawn(async move { - if let Some(task) = task { - vars.insert(TYPESCRIPT_RUNNER_VARIABLE, task.await.to_owned()); + if let Some((task, worktree_root, fs)) = args { + let package_json_data = task.await.log_err(); + vars.insert( + TYPESCRIPT_RUNNER_VARIABLE, + detect_package_manager(worktree_root, fs, package_json_data.clone()) + .await + .to_owned(), + ); + + if let Some(package_json_data) = package_json_data { + if let Some(path) = package_json_data.jest_package_path { + vars.insert( + TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE, + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ); + } + + if let Some(path) = package_json_data.mocha_package_path { + vars.insert( + TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE, + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ); + } + + if let Some(path) = package_json_data.vitest_package_path { + vars.insert( + TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE, + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ); + } + + if let Some(path) = package_json_data.jasmine_package_path { + vars.insert( + TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE, + path.parent() + .unwrap_or(Path::new("")) + .to_string_lossy() + .to_string(), + ); + } + } } Ok(vars) }) @@ -991,8 +1077,16 @@ async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> { #[cfg(test)] mod tests { - use gpui::{AppContext as _, TestAppContext}; + use std::path::Path; + + use gpui::{AppContext as _, BackgroundExecutor, TestAppContext}; + use language::language_settings; + use project::{FakeFs, Project}; + use serde_json::json; use unindent::Unindent; + use util::path; + + use crate::typescript::{PackageJsonData, TypeScriptContextProvider}; #[gpui::test] async fn test_outline(cx: &mut TestAppContext) { @@ -1033,4 +1127,82 @@ mod tests { ] ); } + + #[gpui::test] + async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) { + cx.update(|cx| { + settings::init(cx); + Project::init_settings(cx); + language_settings::init(cx); + }); + + let package_json_1 = json!({ + "dependencies": { + "mocha": "1.0.0", + "vitest": "1.0.0" + }, + "scripts": { + "test": "" + } + }) + .to_string(); + + let package_json_2 = json!({ + "devDependencies": { + "vitest": "2.0.0" + }, + "scripts": { + "test": "" + } + }) + .to_string(); + + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/root"), + json!({ + "package.json": package_json_1, + "sub": { + "package.json": package_json_2, + "file.js": "", + } + }), + ) + .await; + + let provider = TypeScriptContextProvider::new(); + let package_json_data = cx + .update(|cx| { + provider.combined_package_json_data( + fs.clone(), + path!("/root").as_ref(), + "sub/file1.js".as_ref(), + cx, + ) + }) + .await + .unwrap(); + pretty_assertions::assert_eq!( + package_json_data, + PackageJsonData { + jest_package_path: None, + mocha_package_path: Some(Path::new(path!("/root/package.json")).into()), + vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()), + jasmine_package_path: None, + scripts: [ + ( + Path::new(path!("/root/package.json")).into(), + "test".to_owned() + ), + ( + Path::new(path!("/root/sub/package.json")).into(), + "test".to_owned() + ) + ] + .into_iter() + .collect(), + package_manager: None, + } + ); + } } diff --git a/crates/project/src/debugger/locators/node.rs b/crates/project/src/debugger/locators/node.rs index 2be9fe9bdc..cf81991acf 100644 --- a/crates/project/src/debugger/locators/node.rs +++ b/crates/project/src/debugger/locators/node.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, path::Path}; +use std::{borrow::Cow, path::PathBuf}; use anyhow::{Result, bail}; use async_trait::async_trait; @@ -11,8 +11,6 @@ pub(crate) struct NodeLocator; const TYPESCRIPT_RUNNER_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER")); -const TYPESCRIPT_JEST_TASK_VARIABLE: VariableName = - VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST")); #[async_trait] impl DapLocator for NodeLocator { @@ -34,14 +32,21 @@ impl DapLocator for NodeLocator { return None; } let test_library = build_config.args.first()?; - let program_path = Path::new("$ZED_WORKTREE_ROOT") + let program_path_base: PathBuf = match test_library.as_str() { + "jest" => "${ZED_CUSTOM_TYPESCRIPT_JEST_PACKAGE_PATH}".to_owned(), + "mocha" => "${ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH}".to_owned(), + "vitest" => "${ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH}".to_owned(), + "jasmine" => "${ZED_CUSTOM_TYPESCRIPT_JASMINE_PACKAGE_PATH}".to_owned(), + _ => VariableName::WorktreeRoot.template_value(), + } + .into(); + + let program_path = program_path_base .join("node_modules") .join(".bin") .join(test_library); - let mut args = if test_library == "jest" - || test_library == &TYPESCRIPT_JEST_TASK_VARIABLE.template_value() - { + let mut args = if test_library == "jest" { vec!["--runInBand".to_owned()] } else { vec![] diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 7be0aa4262..61a005520d 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -278,7 +278,7 @@ impl language::LanguageToolchainStore for RemoteStore { } } -pub(crate) struct EmptyToolchainStore; +pub struct EmptyToolchainStore; #[async_trait(?Send)] impl language::LanguageToolchainStore for EmptyToolchainStore { async fn active_toolchain(