Improve TypeScript task detection (#31711)

Parses project's package.json to better detect Jasmine, Jest, Vitest and
Mocha and `test`, `build` scripts presence.
Also tries to detect `pnpm` and `npx` as test runners, falls back to
`npm`.


https://github.com/user-attachments/assets/112d3d8b-8daa-4ba5-8cb5-2f483036bd98

Release Notes:

- Improved TypeScript task detection
This commit is contained in:
Kirill Bulatov 2025-05-29 23:51:20 +03:00 committed by GitHub
parent a23ee61a4b
commit 2abc5893c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 469 additions and 43 deletions

View file

@ -38,6 +38,7 @@ anyhow.workspace = true
async-compression.workspace = true
async-tar.workspace = true
async-trait.workspace = true
chrono.workspace = true
collections.workspace = true
dap.workspace = true
futures.workspace = true
@ -87,6 +88,7 @@ tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
tree-sitter-yaml = { workspace = true, optional = true }
util.workspace = true
which.workspace = true
workspace-hack.workspace = true
[dev-dependencies]

View file

@ -444,12 +444,13 @@ impl ContextProvider for GoContextProvider {
fn build_context(
&self,
variables: &TaskVariables,
location: &Location,
location: ContextLocation<'_>,
_: Option<HashMap<String, String>>,
_: Arc<dyn LanguageToolchainStore>,
cx: &mut gpui::App,
) -> Task<Result<TaskVariables>> {
let local_abs_path = location
.file_location
.buffer
.read(cx)
.file()

View file

@ -88,7 +88,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node: NodeRuntime, cx: &mut App) {
let rust_context_provider = Arc::new(rust::RustContextProvider);
let rust_lsp_adapter = Arc::new(rust::RustLspAdapter);
let tailwind_adapter = Arc::new(tailwind::TailwindLspAdapter::new(node.clone()));
let typescript_context = Arc::new(typescript::typescript_task_context());
let typescript_context = Arc::new(typescript::TypeScriptContextProvider::new());
let typescript_lsp_adapter = Arc::new(typescript::TypeScriptLspAdapter::new(node.clone()));
let vtsls_adapter = Arc::new(vtsls::VtslsLspAdapter::new(node.clone()));
let yaml_lsp_adapter = Arc::new(yaml::YamlLspAdapter::new(node.clone()));

View file

@ -4,11 +4,11 @@ use async_trait::async_trait;
use collections::HashMap;
use gpui::{App, Task};
use gpui::{AsyncApp, SharedString};
use language::LanguageToolchainStore;
use language::Toolchain;
use language::ToolchainList;
use language::ToolchainLister;
use language::language_settings::language_settings;
use language::{ContextLocation, LanguageToolchainStore};
use language::{ContextProvider, LspAdapter, LspAdapterDelegate};
use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery};
use lsp::LanguageServerBinary;
@ -367,18 +367,24 @@ impl ContextProvider for PythonContextProvider {
fn build_context(
&self,
variables: &task::TaskVariables,
location: &project::Location,
location: ContextLocation<'_>,
_: Option<HashMap<String, String>>,
toolchains: Arc<dyn LanguageToolchainStore>,
cx: &mut gpui::App,
) -> Task<Result<task::TaskVariables>> {
let test_target = match selected_test_runner(location.buffer.read(cx).file(), cx) {
TestRunner::UNITTEST => self.build_unittest_target(variables),
TestRunner::PYTEST => self.build_pytest_target(variables),
};
let test_target =
match selected_test_runner(location.file_location.buffer.read(cx).file(), cx) {
TestRunner::UNITTEST => self.build_unittest_target(variables),
TestRunner::PYTEST => self.build_pytest_target(variables),
};
let module_target = self.build_module_target(variables);
let worktree_id = location.buffer.read(cx).file().map(|f| f.worktree_id(cx));
let worktree_id = location
.file_location
.buffer
.read(cx)
.file()
.map(|f| f.worktree_id(cx));
cx.spawn(async move |cx| {
let raw_toolchain = if let Some(worktree_id) = worktree_id {

View file

@ -557,12 +557,13 @@ impl ContextProvider for RustContextProvider {
fn build_context(
&self,
task_variables: &TaskVariables,
location: &Location,
location: ContextLocation<'_>,
project_env: Option<HashMap<String, String>>,
_: Arc<dyn LanguageToolchainStore>,
cx: &mut gpui::App,
) -> Task<Result<TaskVariables>> {
let local_abs_path = location
.file_location
.buffer
.read(cx)
.file()

View file

@ -2,56 +2,407 @@ use anyhow::{Context as _, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use async_trait::async_trait;
use chrono::{DateTime, Local};
use collections::HashMap;
use gpui::AsyncApp;
use gpui::{App, AppContext, AsyncApp, Task};
use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
use language::{LanguageToolchainStore, LspAdapter, LspAdapterDelegate};
use language::{
ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
};
use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
use node_runtime::NodeRuntime;
use project::ContextProviderWithTasks;
use project::{Fs, lsp_store::language_server_settings};
use serde_json::{Value, json};
use smol::{fs, io::BufReader, stream::StreamExt};
use smol::{fs, io::BufReader, lock::RwLock, stream::StreamExt};
use std::{
any::Any,
borrow::Cow,
ffi::OsString,
path::{Path, PathBuf},
sync::Arc,
};
use task::{TaskTemplate, TaskTemplates, VariableName};
use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName};
use util::archive::extract_zip;
use util::merge_json_value_into;
use util::{ResultExt, fs::remove_matching, maybe};
pub(super) fn typescript_task_context() -> ContextProviderWithTasks {
ContextProviderWithTasks::new(TaskTemplates(vec![
TaskTemplate {
label: "jest file test".to_owned(),
command: "npx jest".to_owned(),
args: vec![VariableName::File.template_value()],
..TaskTemplate::default()
},
TaskTemplate {
label: "jest test $ZED_SYMBOL".to_owned(),
command: "npx jest".to_owned(),
pub(crate) struct TypeScriptContextProvider {
last_package_json: PackageJsonContents,
}
const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
const TYPESCRIPT_JEST_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST"));
const TYPESCRIPT_MOCHA_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA"));
const TYPESCRIPT_VITEST_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST"));
const TYPESCRIPT_JASMINE_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE"));
const TYPESCRIPT_BUILD_SCRIPT_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUILD_SCRIPT"));
const TYPESCRIPT_TEST_SCRIPT_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_TEST_SCRIPT"));
#[derive(Clone, Default)]
struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
struct PackageJson {
mtime: DateTime<Local>,
data: PackageJsonData,
}
#[derive(Clone, Copy, Default)]
struct PackageJsonData {
jest: bool,
mocha: bool,
vitest: bool,
jasmine: bool,
build_script: bool,
test_script: bool,
runner: Runner,
}
#[derive(Clone, Copy, Default)]
enum Runner {
#[default]
Npm,
Npx,
Pnpm,
}
impl PackageJsonData {
fn new(package_json: HashMap<String, Value>) -> Self {
let mut build_script = false;
let mut test_script = false;
if let Some(serde_json::Value::Object(scripts)) = package_json.get("scripts") {
build_script |= scripts.contains_key("build");
test_script |= scripts.contains_key("test");
}
let mut jest = false;
let mut mocha = false;
let mut vitest = false;
let mut jasmine = false;
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 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");
}
let mut runner = Runner::Npm;
if which::which("pnpm").is_ok() {
runner = Runner::Pnpm;
} else if which::which("npx").is_ok() {
runner = Runner::Npx;
}
Self {
jest,
mocha,
vitest,
jasmine,
build_script,
test_script,
runner,
}
}
fn fill_variables(&self, variables: &mut TaskVariables) {
let runner = match self.runner {
Runner::Npm => "npm",
Runner::Npx => "npx",
Runner::Pnpm => "pnpm",
};
variables.insert(TYPESCRIPT_RUNNER_VARIABLE, runner.to_owned());
if self.jest {
variables.insert(TYPESCRIPT_JEST_TASK_VARIABLE, "jest".to_owned());
}
if self.mocha {
variables.insert(TYPESCRIPT_MOCHA_TASK_VARIABLE, "mocha".to_owned());
}
if self.vitest {
variables.insert(TYPESCRIPT_VITEST_TASK_VARIABLE, "vitest".to_owned());
}
if self.jasmine {
variables.insert(TYPESCRIPT_JASMINE_TASK_VARIABLE, "jasmine".to_owned());
}
if self.build_script {
variables.insert(TYPESCRIPT_BUILD_SCRIPT_TASK_VARIABLE, "build".to_owned());
}
if self.test_script {
variables.insert(TYPESCRIPT_TEST_SCRIPT_TASK_VARIABLE, "test".to_owned());
}
}
}
impl TypeScriptContextProvider {
pub fn new() -> Self {
TypeScriptContextProvider {
last_package_json: PackageJsonContents::default(),
}
}
}
impl ContextProvider for TypeScriptContextProvider {
fn associated_tasks(&self, _: Option<Arc<dyn File>>, _: &App) -> Option<TaskTemplates> {
let mut task_templates = TaskTemplates(Vec::new());
// Jest tasks
task_templates.0.push(TaskTemplate {
label: format!(
"{} file test",
TYPESCRIPT_JEST_TASK_VARIABLE.template_value()
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"--testNamePattern".into(),
TYPESCRIPT_JEST_TASK_VARIABLE.template_value(),
VariableName::File.template_value(),
],
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
label: format!(
"{} test {}",
TYPESCRIPT_JEST_TASK_VARIABLE.template_value(),
VariableName::Symbol.template_value(),
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_JEST_TASK_VARIABLE.template_value(),
"--testNamePattern".to_owned(),
format!("\"{}\"", VariableName::Symbol.template_value()),
VariableName::File.template_value(),
],
tags: vec!["ts-test".into(), "js-test".into(), "tsx-test".into()],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
..TaskTemplate::default()
},
TaskTemplate {
label: "execute selection $ZED_SELECTED_TEXT".to_owned(),
});
// Vitest tasks
task_templates.0.push(TaskTemplate {
label: format!(
"{} file test",
TYPESCRIPT_VITEST_TASK_VARIABLE.template_value()
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_VITEST_TASK_VARIABLE.template_value(),
"run".to_owned(),
VariableName::File.template_value(),
],
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
label: format!(
"{} test {}",
TYPESCRIPT_VITEST_TASK_VARIABLE.template_value(),
VariableName::Symbol.template_value(),
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_VITEST_TASK_VARIABLE.template_value(),
"run".to_owned(),
"--testNamePattern".to_owned(),
format!("\"{}\"", VariableName::Symbol.template_value()),
VariableName::File.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
..TaskTemplate::default()
});
// Mocha tasks
task_templates.0.push(TaskTemplate {
label: format!(
"{} file test",
TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value()
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value(),
VariableName::File.template_value(),
],
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
label: format!(
"{} test {}",
TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value(),
VariableName::Symbol.template_value(),
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value(),
"--grep".to_owned(),
format!("\"{}\"", VariableName::Symbol.template_value()),
VariableName::File.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
..TaskTemplate::default()
});
// Jasmine tasks
task_templates.0.push(TaskTemplate {
label: format!(
"{} file test",
TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value()
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value(),
VariableName::File.template_value(),
],
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
label: format!(
"{} test {}",
TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value(),
VariableName::Symbol.template_value(),
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value(),
format!("--filter={}", VariableName::Symbol.template_value()),
VariableName::File.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
..TaskTemplate::default()
});
for package_json_script in [
TYPESCRIPT_TEST_SCRIPT_TASK_VARIABLE,
TYPESCRIPT_BUILD_SCRIPT_TASK_VARIABLE,
] {
task_templates.0.push(TaskTemplate {
label: format!(
"package.json script {}",
package_json_script.template_value()
),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"--prefix".to_owned(),
VariableName::WorktreeRoot.template_value(),
"run".to_owned(),
package_json_script.template_value(),
],
tags: vec!["package-script".into()],
..TaskTemplate::default()
});
}
task_templates.0.push(TaskTemplate {
label: format!(
"execute selection {}",
VariableName::SelectedText.template_value()
),
command: "node".to_owned(),
args: vec![
"-e".into(),
"-e".to_owned(),
format!("\"{}\"", VariableName::SelectedText.template_value()),
],
..TaskTemplate::default()
},
]))
});
Some(task_templates)
}
fn build_context(
&self,
_variables: &task::TaskVariables,
location: ContextLocation<'_>,
_project_env: Option<HashMap<String, String>>,
_toolchains: Arc<dyn LanguageToolchainStore>,
cx: &mut App,
) -> Task<Result<task::TaskVariables>> {
let Some((fs, worktree_root)) = location.fs.zip(location.worktree_root) else {
return Task::ready(Ok(task::TaskVariables::default()));
};
let package_json_contents = self.last_package_json.clone();
cx.background_spawn(async move {
let variables = package_json_variables(fs, worktree_root, package_json_contents)
.await
.context("package.json context retrieval")
.log_err()
.unwrap_or_else(task::TaskVariables::default);
Ok(variables)
})
}
}
async fn package_json_variables(
fs: Arc<dyn Fs>,
worktree_root: PathBuf,
package_json_contents: PackageJsonContents,
) -> anyhow::Result<task::TaskVariables> {
let package_json_path = worktree_root.join("package.json");
let metadata = fs
.metadata(&package_json_path)
.await
.with_context(|| format!("getting metadata for {package_json_path:?}"))?
.with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
let existing_data = {
let contents = package_json_contents.0.read().await;
contents
.get(&package_json_path)
.filter(|package_json| package_json.mtime == mtime)
.map(|package_json| package_json.data)
};
let mut variables = TaskVariables::default();
if let Some(existing_data) = existing_data {
existing_data.fill_variables(&mut variables);
} else {
let package_json_string = fs
.load(&package_json_path)
.await
.with_context(|| format!("loading package.json from {package_json_path:?}"))?;
let package_json: HashMap<String, serde_json::Value> =
serde_json::from_str(&package_json_string)
.with_context(|| format!("parsing package.json from {package_json_path:?}"))?;
let new_data = PackageJsonData::new(package_json);
new_data.fill_variables(&mut variables);
{
let mut contents = package_json_contents.0.write().await;
contents.insert(
package_json_path,
PackageJson {
mtime,
data: new_data,
},
);
}
}
Ok(variables)
}
fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {