1use anyhow::{Context as _, Result};
2use async_trait::async_trait;
3use chrono::{DateTime, Local};
4use collections::HashMap;
5use futures::future::join_all;
6use gpui::{App, AppContext, AsyncApp, Task};
7use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
8use http_client::github_download::download_server_binary;
9use itertools::Itertools as _;
10use language::{
11 ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
12 LspAdapterDelegate, LspInstaller, Toolchain,
13};
14use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
15use node_runtime::{NodeRuntime, VersionStrategy};
16use project::{Fs, lsp_store::language_server_settings};
17use serde_json::{Value, json};
18use smol::{fs, lock::RwLock, stream::StreamExt};
19use std::{
20 borrow::Cow,
21 ffi::OsString,
22 path::{Path, PathBuf},
23 sync::{Arc, LazyLock},
24};
25use task::{TaskTemplate, TaskTemplates, VariableName};
26use util::{ResultExt, fs::remove_matching, maybe};
27use util::{merge_json_value_into, rel_path::RelPath};
28
29use crate::{PackageJson, PackageJsonData};
30
31pub(crate) struct TypeScriptContextProvider {
32 fs: Arc<dyn Fs>,
33 last_package_json: PackageJsonContents,
34}
35
36const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
37 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
38
39const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
40 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
41
42const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
43 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
44
45const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
46 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
47
48const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
49 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
50
51const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
52 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
53
54const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
55 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
56
57const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
58 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
59
60const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
61 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
62
63#[derive(Clone, Debug, Default)]
64struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
65
66impl PackageJsonData {
67 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
68 if self.jest_package_path.is_some() {
69 task_templates.0.push(TaskTemplate {
70 label: "jest file test".to_owned(),
71 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
72 args: vec![
73 "exec".to_owned(),
74 "--".to_owned(),
75 "jest".to_owned(),
76 "--runInBand".to_owned(),
77 VariableName::File.template_value(),
78 ],
79 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
80 ..TaskTemplate::default()
81 });
82 task_templates.0.push(TaskTemplate {
83 label: format!("jest test {}", VariableName::Symbol.template_value()),
84 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
85 args: vec![
86 "exec".to_owned(),
87 "--".to_owned(),
88 "jest".to_owned(),
89 "--runInBand".to_owned(),
90 "--testNamePattern".to_owned(),
91 format!(
92 "\"{}\"",
93 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
94 ),
95 VariableName::File.template_value(),
96 ],
97 tags: vec![
98 "ts-test".to_owned(),
99 "js-test".to_owned(),
100 "tsx-test".to_owned(),
101 ],
102 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
103 ..TaskTemplate::default()
104 });
105 }
106
107 if self.vitest_package_path.is_some() {
108 task_templates.0.push(TaskTemplate {
109 label: format!("{} file test", "vitest".to_owned()),
110 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
111 args: vec![
112 "exec".to_owned(),
113 "--".to_owned(),
114 "vitest".to_owned(),
115 "run".to_owned(),
116 "--poolOptions.forks.minForks=0".to_owned(),
117 "--poolOptions.forks.maxForks=1".to_owned(),
118 VariableName::File.template_value(),
119 ],
120 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
121 ..TaskTemplate::default()
122 });
123 task_templates.0.push(TaskTemplate {
124 label: format!(
125 "{} test {}",
126 "vitest".to_owned(),
127 VariableName::Symbol.template_value(),
128 ),
129 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
130 args: vec![
131 "exec".to_owned(),
132 "--".to_owned(),
133 "vitest".to_owned(),
134 "run".to_owned(),
135 "--poolOptions.forks.minForks=0".to_owned(),
136 "--poolOptions.forks.maxForks=1".to_owned(),
137 "--testNamePattern".to_owned(),
138 format!(
139 "\"{}\"",
140 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
141 ),
142 VariableName::File.template_value(),
143 ],
144 tags: vec![
145 "ts-test".to_owned(),
146 "js-test".to_owned(),
147 "tsx-test".to_owned(),
148 ],
149 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
150 ..TaskTemplate::default()
151 });
152 }
153
154 if self.mocha_package_path.is_some() {
155 task_templates.0.push(TaskTemplate {
156 label: format!("{} file test", "mocha".to_owned()),
157 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
158 args: vec![
159 "exec".to_owned(),
160 "--".to_owned(),
161 "mocha".to_owned(),
162 VariableName::File.template_value(),
163 ],
164 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
165 ..TaskTemplate::default()
166 });
167 task_templates.0.push(TaskTemplate {
168 label: format!(
169 "{} test {}",
170 "mocha".to_owned(),
171 VariableName::Symbol.template_value(),
172 ),
173 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
174 args: vec![
175 "exec".to_owned(),
176 "--".to_owned(),
177 "mocha".to_owned(),
178 "--grep".to_owned(),
179 format!("\"{}\"", VariableName::Symbol.template_value()),
180 VariableName::File.template_value(),
181 ],
182 tags: vec![
183 "ts-test".to_owned(),
184 "js-test".to_owned(),
185 "tsx-test".to_owned(),
186 ],
187 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
188 ..TaskTemplate::default()
189 });
190 }
191
192 if self.jasmine_package_path.is_some() {
193 task_templates.0.push(TaskTemplate {
194 label: format!("{} file test", "jasmine".to_owned()),
195 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
196 args: vec![
197 "exec".to_owned(),
198 "--".to_owned(),
199 "jasmine".to_owned(),
200 VariableName::File.template_value(),
201 ],
202 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
203 ..TaskTemplate::default()
204 });
205 task_templates.0.push(TaskTemplate {
206 label: format!(
207 "{} test {}",
208 "jasmine".to_owned(),
209 VariableName::Symbol.template_value(),
210 ),
211 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
212 args: vec![
213 "exec".to_owned(),
214 "--".to_owned(),
215 "jasmine".to_owned(),
216 format!("--filter={}", VariableName::Symbol.template_value()),
217 VariableName::File.template_value(),
218 ],
219 tags: vec![
220 "ts-test".to_owned(),
221 "js-test".to_owned(),
222 "tsx-test".to_owned(),
223 ],
224 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
225 ..TaskTemplate::default()
226 });
227 }
228
229 if self.bun_package_path.is_some() {
230 task_templates.0.push(TaskTemplate {
231 label: format!("{} file test", "bun test".to_owned()),
232 command: "bun".to_owned(),
233 args: vec!["test".to_owned(), VariableName::File.template_value()],
234 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
235 ..TaskTemplate::default()
236 });
237 task_templates.0.push(TaskTemplate {
238 label: format!("bun test {}", VariableName::Symbol.template_value(),),
239 command: "bun".to_owned(),
240 args: vec![
241 "test".to_owned(),
242 "--test-name-pattern".to_owned(),
243 format!("\"{}\"", VariableName::Symbol.template_value()),
244 VariableName::File.template_value(),
245 ],
246 tags: vec![
247 "ts-test".to_owned(),
248 "js-test".to_owned(),
249 "tsx-test".to_owned(),
250 ],
251 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
252 ..TaskTemplate::default()
253 });
254 }
255
256 if self.node_package_path.is_some() {
257 task_templates.0.push(TaskTemplate {
258 label: format!("{} file test", "node test".to_owned()),
259 command: "node".to_owned(),
260 args: vec!["--test".to_owned(), VariableName::File.template_value()],
261 tags: vec![
262 "ts-test".to_owned(),
263 "js-test".to_owned(),
264 "tsx-test".to_owned(),
265 ],
266 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
267 ..TaskTemplate::default()
268 });
269 task_templates.0.push(TaskTemplate {
270 label: format!("node test {}", VariableName::Symbol.template_value()),
271 command: "node".to_owned(),
272 args: vec![
273 "--test".to_owned(),
274 "--test-name-pattern".to_owned(),
275 format!("\"{}\"", VariableName::Symbol.template_value()),
276 VariableName::File.template_value(),
277 ],
278 tags: vec![
279 "ts-test".to_owned(),
280 "js-test".to_owned(),
281 "tsx-test".to_owned(),
282 ],
283 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
284 ..TaskTemplate::default()
285 });
286 }
287
288 let script_name_counts: HashMap<_, usize> =
289 self.scripts
290 .iter()
291 .fold(HashMap::default(), |mut acc, (_, script)| {
292 *acc.entry(script).or_default() += 1;
293 acc
294 });
295 for (path, script) in &self.scripts {
296 let label = if script_name_counts.get(script).copied().unwrap_or_default() > 1
297 && let Some(parent) = path.parent().and_then(|parent| parent.file_name())
298 {
299 let parent = parent.to_string_lossy();
300 format!("{parent}/package.json > {script}")
301 } else {
302 format!("package.json > {script}")
303 };
304 task_templates.0.push(TaskTemplate {
305 label,
306 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
307 args: vec!["run".to_owned(), script.to_owned()],
308 tags: vec!["package-script".into()],
309 cwd: Some(
310 path.parent()
311 .unwrap_or(Path::new("/"))
312 .to_string_lossy()
313 .to_string(),
314 ),
315 ..TaskTemplate::default()
316 });
317 }
318 }
319}
320
321impl TypeScriptContextProvider {
322 pub fn new(fs: Arc<dyn Fs>) -> Self {
323 Self {
324 fs,
325 last_package_json: PackageJsonContents::default(),
326 }
327 }
328
329 fn combined_package_json_data(
330 &self,
331 fs: Arc<dyn Fs>,
332 worktree_root: &Path,
333 file_relative_path: &RelPath,
334 cx: &App,
335 ) -> Task<anyhow::Result<PackageJsonData>> {
336 let new_json_data = file_relative_path
337 .ancestors()
338 .map(|path| worktree_root.join(path.as_std_path()))
339 .map(|parent_path| {
340 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
341 })
342 .collect::<Vec<_>>();
343
344 cx.background_spawn(async move {
345 let mut package_json_data = PackageJsonData::default();
346 for new_data in join_all(new_json_data).await.into_iter().flatten() {
347 package_json_data.merge(new_data);
348 }
349 Ok(package_json_data)
350 })
351 }
352
353 fn package_json_data(
354 &self,
355 directory_path: &Path,
356 existing_package_json: PackageJsonContents,
357 fs: Arc<dyn Fs>,
358 cx: &App,
359 ) -> Task<anyhow::Result<PackageJsonData>> {
360 let package_json_path = directory_path.join("package.json");
361 let metadata_check_fs = fs.clone();
362 cx.background_spawn(async move {
363 let metadata = metadata_check_fs
364 .metadata(&package_json_path)
365 .await
366 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
367 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
368 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
369 let existing_data = {
370 let contents = existing_package_json.0.read().await;
371 contents
372 .get(&package_json_path)
373 .filter(|package_json| package_json.mtime == mtime)
374 .map(|package_json| package_json.data.clone())
375 };
376 match existing_data {
377 Some(existing_data) => Ok(existing_data),
378 None => {
379 let package_json_string =
380 fs.load(&package_json_path).await.with_context(|| {
381 format!("loading package.json from {package_json_path:?}")
382 })?;
383 let package_json: HashMap<String, serde_json_lenient::Value> =
384 serde_json_lenient::from_str(&package_json_string).with_context(|| {
385 format!("parsing package.json from {package_json_path:?}")
386 })?;
387 let new_data =
388 PackageJsonData::new(package_json_path.as_path().into(), package_json);
389 {
390 let mut contents = existing_package_json.0.write().await;
391 contents.insert(
392 package_json_path,
393 PackageJson {
394 mtime,
395 data: new_data.clone(),
396 },
397 );
398 }
399 Ok(new_data)
400 }
401 }
402 })
403 }
404}
405
406async fn detect_package_manager(
407 worktree_root: PathBuf,
408 fs: Arc<dyn Fs>,
409 package_json_data: Option<PackageJsonData>,
410) -> &'static str {
411 if let Some(package_json_data) = package_json_data
412 && let Some(package_manager) = package_json_data.package_manager
413 {
414 return package_manager;
415 }
416 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
417 return "pnpm";
418 }
419 if fs.is_file(&worktree_root.join("yarn.lock")).await {
420 return "yarn";
421 }
422 "npm"
423}
424
425impl ContextProvider for TypeScriptContextProvider {
426 fn associated_tasks(
427 &self,
428 file: Option<Arc<dyn File>>,
429 cx: &App,
430 ) -> Task<Option<TaskTemplates>> {
431 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
432 return Task::ready(None);
433 };
434 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
435 return Task::ready(None);
436 };
437 let file_relative_path = file.path().clone();
438 let package_json_data = self.combined_package_json_data(
439 self.fs.clone(),
440 &worktree_root,
441 &file_relative_path,
442 cx,
443 );
444
445 cx.background_spawn(async move {
446 let mut task_templates = TaskTemplates(Vec::new());
447 task_templates.0.push(TaskTemplate {
448 label: format!(
449 "execute selection {}",
450 VariableName::SelectedText.template_value()
451 ),
452 command: "node".to_owned(),
453 args: vec![
454 "-e".to_owned(),
455 format!("\"{}\"", VariableName::SelectedText.template_value()),
456 ],
457 ..TaskTemplate::default()
458 });
459
460 match package_json_data.await {
461 Ok(package_json) => {
462 package_json.fill_task_templates(&mut task_templates);
463 }
464 Err(e) => {
465 log::error!(
466 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
467 );
468 }
469 }
470
471 Some(task_templates)
472 })
473 }
474
475 fn build_context(
476 &self,
477 current_vars: &task::TaskVariables,
478 location: ContextLocation<'_>,
479 _project_env: Option<HashMap<String, String>>,
480 _toolchains: Arc<dyn LanguageToolchainStore>,
481 cx: &mut App,
482 ) -> Task<Result<task::TaskVariables>> {
483 let mut vars = task::TaskVariables::default();
484
485 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
486 vars.insert(
487 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
488 replace_test_name_parameters(symbol),
489 );
490 vars.insert(
491 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
492 replace_test_name_parameters(symbol),
493 );
494 }
495 let file_path = location
496 .file_location
497 .buffer
498 .read(cx)
499 .file()
500 .map(|file| file.path());
501
502 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
503 |((worktree_root, fs), file_path)| {
504 (
505 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
506 worktree_root,
507 fs,
508 )
509 },
510 );
511 cx.background_spawn(async move {
512 if let Some((task, worktree_root, fs)) = args {
513 let package_json_data = task.await.log_err();
514 vars.insert(
515 TYPESCRIPT_RUNNER_VARIABLE,
516 detect_package_manager(worktree_root, fs, package_json_data.clone())
517 .await
518 .to_owned(),
519 );
520
521 if let Some(package_json_data) = package_json_data {
522 if let Some(path) = package_json_data.jest_package_path {
523 vars.insert(
524 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
525 path.parent()
526 .unwrap_or(Path::new(""))
527 .to_string_lossy()
528 .to_string(),
529 );
530 }
531
532 if let Some(path) = package_json_data.mocha_package_path {
533 vars.insert(
534 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
535 path.parent()
536 .unwrap_or(Path::new(""))
537 .to_string_lossy()
538 .to_string(),
539 );
540 }
541
542 if let Some(path) = package_json_data.vitest_package_path {
543 vars.insert(
544 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
545 path.parent()
546 .unwrap_or(Path::new(""))
547 .to_string_lossy()
548 .to_string(),
549 );
550 }
551
552 if let Some(path) = package_json_data.jasmine_package_path {
553 vars.insert(
554 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
555 path.parent()
556 .unwrap_or(Path::new(""))
557 .to_string_lossy()
558 .to_string(),
559 );
560 }
561
562 if let Some(path) = package_json_data.bun_package_path {
563 vars.insert(
564 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
565 path.parent()
566 .unwrap_or(Path::new(""))
567 .to_string_lossy()
568 .to_string(),
569 );
570 }
571
572 if let Some(path) = package_json_data.node_package_path {
573 vars.insert(
574 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
575 path.parent()
576 .unwrap_or(Path::new(""))
577 .to_string_lossy()
578 .to_string(),
579 );
580 }
581 }
582 }
583 Ok(vars)
584 })
585 }
586}
587
588fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
589 vec![server_path.into(), "--stdio".into()]
590}
591
592fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
593 vec![
594 "--max-old-space-size=8192".into(),
595 server_path.into(),
596 "--stdio".into(),
597 ]
598}
599
600fn replace_test_name_parameters(test_name: &str) -> String {
601 static PATTERN: LazyLock<regex::Regex> =
602 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
603 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
604}
605
606pub struct TypeScriptLspAdapter {
607 fs: Arc<dyn Fs>,
608 node: NodeRuntime,
609}
610
611impl TypeScriptLspAdapter {
612 const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
613 const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
614 const SERVER_NAME: LanguageServerName =
615 LanguageServerName::new_static("typescript-language-server");
616 const PACKAGE_NAME: &str = "typescript";
617 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
618 TypeScriptLspAdapter { fs, node }
619 }
620 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
621 let is_yarn = adapter
622 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
623 .await
624 .is_ok();
625
626 let tsdk_path = if is_yarn {
627 ".yarn/sdks/typescript/lib"
628 } else {
629 "node_modules/typescript/lib"
630 };
631
632 if self
633 .fs
634 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
635 .await
636 {
637 Some(tsdk_path)
638 } else {
639 None
640 }
641 }
642}
643
644pub struct TypeScriptVersions {
645 typescript_version: String,
646 server_version: String,
647}
648
649impl LspInstaller for TypeScriptLspAdapter {
650 type BinaryVersion = TypeScriptVersions;
651
652 async fn fetch_latest_server_version(
653 &self,
654 _: &dyn LspAdapterDelegate,
655 _: bool,
656 _: &mut AsyncApp,
657 ) -> Result<TypeScriptVersions> {
658 Ok(TypeScriptVersions {
659 typescript_version: self.node.npm_package_latest_version("typescript").await?,
660 server_version: self
661 .node
662 .npm_package_latest_version("typescript-language-server")
663 .await?,
664 })
665 }
666
667 async fn check_if_version_installed(
668 &self,
669 version: &TypeScriptVersions,
670 container_dir: &PathBuf,
671 _: &dyn LspAdapterDelegate,
672 ) -> Option<LanguageServerBinary> {
673 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
674
675 let should_install_language_server = self
676 .node
677 .should_install_npm_package(
678 Self::PACKAGE_NAME,
679 &server_path,
680 container_dir,
681 VersionStrategy::Latest(version.typescript_version.as_str()),
682 )
683 .await;
684
685 if should_install_language_server {
686 None
687 } else {
688 Some(LanguageServerBinary {
689 path: self.node.binary_path().await.ok()?,
690 env: None,
691 arguments: typescript_server_binary_arguments(&server_path),
692 })
693 }
694 }
695
696 async fn fetch_server_binary(
697 &self,
698 latest_version: TypeScriptVersions,
699 container_dir: PathBuf,
700 _: &dyn LspAdapterDelegate,
701 ) -> Result<LanguageServerBinary> {
702 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
703
704 self.node
705 .npm_install_packages(
706 &container_dir,
707 &[
708 (
709 Self::PACKAGE_NAME,
710 latest_version.typescript_version.as_str(),
711 ),
712 (
713 "typescript-language-server",
714 latest_version.server_version.as_str(),
715 ),
716 ],
717 )
718 .await?;
719
720 Ok(LanguageServerBinary {
721 path: self.node.binary_path().await?,
722 env: None,
723 arguments: typescript_server_binary_arguments(&server_path),
724 })
725 }
726
727 async fn cached_server_binary(
728 &self,
729 container_dir: PathBuf,
730 _: &dyn LspAdapterDelegate,
731 ) -> Option<LanguageServerBinary> {
732 get_cached_ts_server_binary(container_dir, &self.node).await
733 }
734}
735
736#[async_trait(?Send)]
737impl LspAdapter for TypeScriptLspAdapter {
738 fn name(&self) -> LanguageServerName {
739 Self::SERVER_NAME
740 }
741
742 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
743 Some(vec![
744 CodeActionKind::QUICKFIX,
745 CodeActionKind::REFACTOR,
746 CodeActionKind::REFACTOR_EXTRACT,
747 CodeActionKind::SOURCE,
748 ])
749 }
750
751 async fn label_for_completion(
752 &self,
753 item: &lsp::CompletionItem,
754 language: &Arc<language::Language>,
755 ) -> Option<language::CodeLabel> {
756 use lsp::CompletionItemKind as Kind;
757 let label_len = item.label.len();
758 let grammar = language.grammar()?;
759 let highlight_id = match item.kind? {
760 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
761 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
762 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
763 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
764 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
765 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
766 _ => None,
767 }?;
768
769 let text = if let Some(description) = item
770 .label_details
771 .as_ref()
772 .and_then(|label_details| label_details.description.as_ref())
773 {
774 format!("{} {}", item.label, description)
775 } else if let Some(detail) = &item.detail {
776 format!("{} {}", item.label, detail)
777 } else {
778 item.label.clone()
779 };
780 Some(language::CodeLabel::filtered(
781 text,
782 label_len,
783 item.filter_text.as_deref(),
784 vec![(0..label_len, highlight_id)],
785 ))
786 }
787
788 async fn initialization_options(
789 self: Arc<Self>,
790 adapter: &Arc<dyn LspAdapterDelegate>,
791 ) -> Result<Option<serde_json::Value>> {
792 let tsdk_path = self.tsdk_path(adapter).await;
793 Ok(Some(json!({
794 "provideFormatter": true,
795 "hostInfo": "zed",
796 "tsserver": {
797 "path": tsdk_path,
798 },
799 "preferences": {
800 "includeInlayParameterNameHints": "all",
801 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
802 "includeInlayFunctionParameterTypeHints": true,
803 "includeInlayVariableTypeHints": true,
804 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
805 "includeInlayPropertyDeclarationTypeHints": true,
806 "includeInlayFunctionLikeReturnTypeHints": true,
807 "includeInlayEnumMemberValueHints": true,
808 }
809 })))
810 }
811
812 async fn workspace_configuration(
813 self: Arc<Self>,
814
815 delegate: &Arc<dyn LspAdapterDelegate>,
816 _: Option<Toolchain>,
817 cx: &mut AsyncApp,
818 ) -> Result<Value> {
819 let override_options = cx.update(|cx| {
820 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
821 .and_then(|s| s.settings.clone())
822 })?;
823 if let Some(options) = override_options {
824 return Ok(options);
825 }
826 Ok(json!({
827 "completions": {
828 "completeFunctionCalls": true
829 }
830 }))
831 }
832
833 fn language_ids(&self) -> HashMap<LanguageName, String> {
834 HashMap::from_iter([
835 (LanguageName::new("TypeScript"), "typescript".into()),
836 (LanguageName::new("JavaScript"), "javascript".into()),
837 (LanguageName::new("TSX"), "typescriptreact".into()),
838 ])
839 }
840}
841
842async fn get_cached_ts_server_binary(
843 container_dir: PathBuf,
844 node: &NodeRuntime,
845) -> Option<LanguageServerBinary> {
846 maybe!(async {
847 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
848 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
849 if new_server_path.exists() {
850 Ok(LanguageServerBinary {
851 path: node.binary_path().await?,
852 env: None,
853 arguments: typescript_server_binary_arguments(&new_server_path),
854 })
855 } else if old_server_path.exists() {
856 Ok(LanguageServerBinary {
857 path: node.binary_path().await?,
858 env: None,
859 arguments: typescript_server_binary_arguments(&old_server_path),
860 })
861 } else {
862 anyhow::bail!("missing executable in directory {container_dir:?}")
863 }
864 })
865 .await
866 .log_err()
867}
868
869pub struct EsLintLspAdapter {
870 node: NodeRuntime,
871}
872
873impl EsLintLspAdapter {
874 const CURRENT_VERSION: &'static str = "2.4.4";
875 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
876
877 #[cfg(not(windows))]
878 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
879 #[cfg(windows)]
880 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
881
882 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
883 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
884
885 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
886 "eslint.config.js",
887 "eslint.config.mjs",
888 "eslint.config.cjs",
889 "eslint.config.ts",
890 "eslint.config.cts",
891 "eslint.config.mts",
892 ];
893
894 pub fn new(node: NodeRuntime) -> Self {
895 EsLintLspAdapter { node }
896 }
897
898 fn build_destination_path(container_dir: &Path) -> PathBuf {
899 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
900 }
901}
902
903impl LspInstaller for EsLintLspAdapter {
904 type BinaryVersion = GitHubLspBinaryVersion;
905
906 async fn fetch_latest_server_version(
907 &self,
908 _delegate: &dyn LspAdapterDelegate,
909 _: bool,
910 _: &mut AsyncApp,
911 ) -> Result<GitHubLspBinaryVersion> {
912 let url = build_asset_url(
913 "zed-industries/vscode-eslint",
914 Self::CURRENT_VERSION_TAG_NAME,
915 Self::GITHUB_ASSET_KIND,
916 )?;
917
918 Ok(GitHubLspBinaryVersion {
919 name: Self::CURRENT_VERSION.into(),
920 digest: None,
921 url,
922 })
923 }
924
925 async fn fetch_server_binary(
926 &self,
927 version: GitHubLspBinaryVersion,
928 container_dir: PathBuf,
929 delegate: &dyn LspAdapterDelegate,
930 ) -> Result<LanguageServerBinary> {
931 let destination_path = Self::build_destination_path(&container_dir);
932 let server_path = destination_path.join(Self::SERVER_PATH);
933
934 if fs::metadata(&server_path).await.is_err() {
935 remove_matching(&container_dir, |_| true).await;
936
937 download_server_binary(
938 &*delegate.http_client(),
939 &version.url,
940 None,
941 &destination_path,
942 Self::GITHUB_ASSET_KIND,
943 )
944 .await?;
945
946 let mut dir = fs::read_dir(&destination_path).await?;
947 let first = dir.next().await.context("missing first file")??;
948 let repo_root = destination_path.join("vscode-eslint");
949 fs::rename(first.path(), &repo_root).await?;
950
951 #[cfg(target_os = "windows")]
952 {
953 handle_symlink(
954 repo_root.join("$shared"),
955 repo_root.join("client").join("src").join("shared"),
956 )
957 .await?;
958 handle_symlink(
959 repo_root.join("$shared"),
960 repo_root.join("server").join("src").join("shared"),
961 )
962 .await?;
963 }
964
965 self.node
966 .run_npm_subcommand(&repo_root, "install", &[])
967 .await?;
968
969 self.node
970 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
971 .await?;
972 }
973
974 Ok(LanguageServerBinary {
975 path: self.node.binary_path().await?,
976 env: None,
977 arguments: eslint_server_binary_arguments(&server_path),
978 })
979 }
980
981 async fn cached_server_binary(
982 &self,
983 container_dir: PathBuf,
984 _: &dyn LspAdapterDelegate,
985 ) -> Option<LanguageServerBinary> {
986 let server_path =
987 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
988 Some(LanguageServerBinary {
989 path: self.node.binary_path().await.ok()?,
990 env: None,
991 arguments: eslint_server_binary_arguments(&server_path),
992 })
993 }
994}
995
996#[async_trait(?Send)]
997impl LspAdapter for EsLintLspAdapter {
998 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
999 Some(vec![
1000 CodeActionKind::QUICKFIX,
1001 CodeActionKind::new("source.fixAll.eslint"),
1002 ])
1003 }
1004
1005 async fn workspace_configuration(
1006 self: Arc<Self>,
1007 delegate: &Arc<dyn LspAdapterDelegate>,
1008 _: Option<Toolchain>,
1009 cx: &mut AsyncApp,
1010 ) -> Result<Value> {
1011 let workspace_root = delegate.worktree_root_path();
1012 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
1013 .iter()
1014 .any(|file| workspace_root.join(file).is_file());
1015
1016 let mut default_workspace_configuration = json!({
1017 "validate": "on",
1018 "rulesCustomizations": [],
1019 "run": "onType",
1020 "nodePath": null,
1021 "workingDirectory": {
1022 "mode": "auto"
1023 },
1024 "workspaceFolder": {
1025 "uri": workspace_root,
1026 "name": workspace_root.file_name()
1027 .unwrap_or(workspace_root.as_os_str())
1028 .to_string_lossy(),
1029 },
1030 "problems": {},
1031 "codeActionOnSave": {
1032 // We enable this, but without also configuring code_actions_on_format
1033 // in the Zed configuration, it doesn't have an effect.
1034 "enable": true,
1035 },
1036 "codeAction": {
1037 "disableRuleComment": {
1038 "enable": true,
1039 "location": "separateLine",
1040 },
1041 "showDocumentation": {
1042 "enable": true
1043 }
1044 },
1045 "experimental": {
1046 "useFlatConfig": use_flat_config,
1047 }
1048 });
1049
1050 let override_options = cx.update(|cx| {
1051 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
1052 .and_then(|s| s.settings.clone())
1053 })?;
1054
1055 if let Some(override_options) = override_options {
1056 merge_json_value_into(override_options, &mut default_workspace_configuration);
1057 }
1058
1059 Ok(json!({
1060 "": default_workspace_configuration
1061 }))
1062 }
1063
1064 fn name(&self) -> LanguageServerName {
1065 Self::SERVER_NAME
1066 }
1067}
1068
1069#[cfg(target_os = "windows")]
1070async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1071 anyhow::ensure!(
1072 fs::metadata(&src_dir).await.is_ok(),
1073 "Directory {src_dir:?} is not present"
1074 );
1075 if fs::metadata(&dest_dir).await.is_ok() {
1076 fs::remove_file(&dest_dir).await?;
1077 }
1078 fs::create_dir_all(&dest_dir).await?;
1079 let mut entries = fs::read_dir(&src_dir).await?;
1080 while let Some(entry) = entries.try_next().await? {
1081 let entry_path = entry.path();
1082 let entry_name = entry.file_name();
1083 let dest_path = dest_dir.join(&entry_name);
1084 fs::copy(&entry_path, &dest_path).await?;
1085 }
1086 Ok(())
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use std::path::Path;
1092
1093 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1094 use language::language_settings;
1095 use project::{FakeFs, Project};
1096 use serde_json::json;
1097 use task::TaskTemplates;
1098 use unindent::Unindent;
1099 use util::{path, rel_path::rel_path};
1100
1101 use crate::typescript::{
1102 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
1103 };
1104
1105 #[gpui::test]
1106 async fn test_outline(cx: &mut TestAppContext) {
1107 let language = crate::language(
1108 "typescript",
1109 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1110 );
1111
1112 let text = r#"
1113 function a() {
1114 // local variables are included
1115 let a1 = 1;
1116 // all functions are included
1117 async function a2() {}
1118 }
1119 // top-level variables are included
1120 let b: C
1121 function getB() {}
1122 // exported variables are included
1123 export const d = e;
1124 "#
1125 .unindent();
1126
1127 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1128 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1129 assert_eq!(
1130 outline
1131 .items
1132 .iter()
1133 .map(|item| (item.text.as_str(), item.depth))
1134 .collect::<Vec<_>>(),
1135 &[
1136 ("function a()", 0),
1137 ("let a1", 1),
1138 ("async function a2()", 1),
1139 ("let b", 0),
1140 ("function getB()", 0),
1141 ("const d", 0),
1142 ]
1143 );
1144 }
1145
1146 #[gpui::test]
1147 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
1148 let language = crate::language(
1149 "typescript",
1150 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1151 );
1152
1153 let text = r#"
1154 // Top-level destructuring
1155 const { a1, a2 } = a;
1156 const [b1, b2] = b;
1157
1158 // Defaults and rest
1159 const [c1 = 1, , c2, ...rest1] = c;
1160 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
1161
1162 function processData() {
1163 // Nested object destructuring
1164 const { c1, c2 } = c;
1165 // Nested array destructuring
1166 const [d1, d2, d3] = d;
1167 // Destructuring with renaming
1168 const { f1: g1 } = f;
1169 // With defaults
1170 const [x = 10, y] = xy;
1171 }
1172
1173 class DataHandler {
1174 method() {
1175 // Destructuring in class method
1176 const { a1, a2 } = a;
1177 const [b1, ...b2] = b;
1178 }
1179 }
1180 "#
1181 .unindent();
1182
1183 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1184 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1185 assert_eq!(
1186 outline
1187 .items
1188 .iter()
1189 .map(|item| (item.text.as_str(), item.depth))
1190 .collect::<Vec<_>>(),
1191 &[
1192 ("const a1", 0),
1193 ("const a2", 0),
1194 ("const b1", 0),
1195 ("const b2", 0),
1196 ("const c1", 0),
1197 ("const c2", 0),
1198 ("const rest1", 0),
1199 ("const d1", 0),
1200 ("const e1", 0),
1201 ("const h1", 0),
1202 ("const rest2", 0),
1203 ("function processData()", 0),
1204 ("const c1", 1),
1205 ("const c2", 1),
1206 ("const d1", 1),
1207 ("const d2", 1),
1208 ("const d3", 1),
1209 ("const g1", 1),
1210 ("const x", 1),
1211 ("const y", 1),
1212 ("class DataHandler", 0),
1213 ("method()", 1),
1214 ("const a1", 2),
1215 ("const a2", 2),
1216 ("const b1", 2),
1217 ("const b2", 2),
1218 ]
1219 );
1220 }
1221
1222 #[gpui::test]
1223 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1224 let language = crate::language(
1225 "typescript",
1226 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1227 );
1228
1229 let text = r#"
1230 // Object with function properties
1231 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1232
1233 // Object with primitive properties
1234 const p = { p1: 1, p2: "hello", p3: true };
1235
1236 // Nested objects
1237 const q = {
1238 r: {
1239 // won't be included due to one-level depth limit
1240 s: 1
1241 },
1242 t: 2
1243 };
1244
1245 function getData() {
1246 const local = { x: 1, y: 2 };
1247 return local;
1248 }
1249 "#
1250 .unindent();
1251
1252 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1253 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1254 assert_eq!(
1255 outline
1256 .items
1257 .iter()
1258 .map(|item| (item.text.as_str(), item.depth))
1259 .collect::<Vec<_>>(),
1260 &[
1261 ("const o", 0),
1262 ("m()", 1),
1263 ("async n()", 1),
1264 ("g", 1),
1265 ("h", 1),
1266 ("k", 1),
1267 ("const p", 0),
1268 ("p1", 1),
1269 ("p2", 1),
1270 ("p3", 1),
1271 ("const q", 0),
1272 ("r", 1),
1273 ("s", 2),
1274 ("t", 1),
1275 ("function getData()", 0),
1276 ("const local", 1),
1277 ("x", 2),
1278 ("y", 2),
1279 ]
1280 );
1281 }
1282
1283 #[gpui::test]
1284 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1285 let language = crate::language(
1286 "typescript",
1287 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1288 );
1289
1290 let text = r#"
1291 // Symbols as object keys
1292 const sym = Symbol("test");
1293 const obj1 = {
1294 [sym]: 1,
1295 [Symbol("inline")]: 2,
1296 normalKey: 3
1297 };
1298
1299 // Enums as object keys
1300 enum Color { Red, Blue, Green }
1301
1302 const obj2 = {
1303 [Color.Red]: "red value",
1304 [Color.Blue]: "blue value",
1305 regularProp: "normal"
1306 };
1307
1308 // Mixed computed properties
1309 const key = "dynamic";
1310 const obj3 = {
1311 [key]: 1,
1312 ["string" + "concat"]: 2,
1313 [1 + 1]: 3,
1314 static: 4
1315 };
1316
1317 // Nested objects with computed properties
1318 const obj4 = {
1319 [sym]: {
1320 nested: 1
1321 },
1322 regular: {
1323 [key]: 2
1324 }
1325 };
1326 "#
1327 .unindent();
1328
1329 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1330 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1331 assert_eq!(
1332 outline
1333 .items
1334 .iter()
1335 .map(|item| (item.text.as_str(), item.depth))
1336 .collect::<Vec<_>>(),
1337 &[
1338 ("const sym", 0),
1339 ("const obj1", 0),
1340 ("[sym]", 1),
1341 ("[Symbol(\"inline\")]", 1),
1342 ("normalKey", 1),
1343 ("enum Color", 0),
1344 ("const obj2", 0),
1345 ("[Color.Red]", 1),
1346 ("[Color.Blue]", 1),
1347 ("regularProp", 1),
1348 ("const key", 0),
1349 ("const obj3", 0),
1350 ("[key]", 1),
1351 ("[\"string\" + \"concat\"]", 1),
1352 ("[1 + 1]", 1),
1353 ("static", 1),
1354 ("const obj4", 0),
1355 ("[sym]", 1),
1356 ("nested", 2),
1357 ("regular", 1),
1358 ("[key]", 2),
1359 ]
1360 );
1361 }
1362
1363 #[gpui::test]
1364 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1365 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1366
1367 let text = r#"
1368 function normalFunction() {
1369 console.log("normal");
1370 }
1371
1372 function* simpleGenerator() {
1373 yield 1;
1374 yield 2;
1375 }
1376
1377 async function* asyncGenerator() {
1378 yield await Promise.resolve(1);
1379 }
1380
1381 function* generatorWithParams(start, end) {
1382 for (let i = start; i <= end; i++) {
1383 yield i;
1384 }
1385 }
1386
1387 class TestClass {
1388 *methodGenerator() {
1389 yield "method";
1390 }
1391
1392 async *asyncMethodGenerator() {
1393 yield "async method";
1394 }
1395 }
1396 "#
1397 .unindent();
1398
1399 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1400 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1401 assert_eq!(
1402 outline
1403 .items
1404 .iter()
1405 .map(|item| (item.text.as_str(), item.depth))
1406 .collect::<Vec<_>>(),
1407 &[
1408 ("function normalFunction()", 0),
1409 ("function* simpleGenerator()", 0),
1410 ("async function* asyncGenerator()", 0),
1411 ("function* generatorWithParams( )", 0),
1412 ("class TestClass", 0),
1413 ("*methodGenerator()", 1),
1414 ("async *asyncMethodGenerator()", 1),
1415 ]
1416 );
1417 }
1418
1419 #[gpui::test]
1420 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1421 cx.update(|cx| {
1422 settings::init(cx);
1423 Project::init_settings(cx);
1424 language_settings::init(cx);
1425 });
1426
1427 let package_json_1 = json!({
1428 "dependencies": {
1429 "mocha": "1.0.0",
1430 "vitest": "1.0.0"
1431 },
1432 "scripts": {
1433 "test": ""
1434 }
1435 })
1436 .to_string();
1437
1438 let package_json_2 = json!({
1439 "devDependencies": {
1440 "vitest": "2.0.0"
1441 },
1442 "scripts": {
1443 "test": ""
1444 }
1445 })
1446 .to_string();
1447
1448 let fs = FakeFs::new(executor);
1449 fs.insert_tree(
1450 path!("/root"),
1451 json!({
1452 "package.json": package_json_1,
1453 "sub": {
1454 "package.json": package_json_2,
1455 "file.js": "",
1456 }
1457 }),
1458 )
1459 .await;
1460
1461 let provider = TypeScriptContextProvider::new(fs.clone());
1462 let package_json_data = cx
1463 .update(|cx| {
1464 provider.combined_package_json_data(
1465 fs.clone(),
1466 path!("/root").as_ref(),
1467 rel_path("sub/file1.js"),
1468 cx,
1469 )
1470 })
1471 .await
1472 .unwrap();
1473 pretty_assertions::assert_eq!(
1474 package_json_data,
1475 PackageJsonData {
1476 jest_package_path: None,
1477 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1478 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1479 jasmine_package_path: None,
1480 bun_package_path: None,
1481 node_package_path: None,
1482 scripts: [
1483 (
1484 Path::new(path!("/root/package.json")).into(),
1485 "test".to_owned()
1486 ),
1487 (
1488 Path::new(path!("/root/sub/package.json")).into(),
1489 "test".to_owned()
1490 )
1491 ]
1492 .into_iter()
1493 .collect(),
1494 package_manager: None,
1495 }
1496 );
1497
1498 let mut task_templates = TaskTemplates::default();
1499 package_json_data.fill_task_templates(&mut task_templates);
1500 let task_templates = task_templates
1501 .0
1502 .into_iter()
1503 .map(|template| (template.label, template.cwd))
1504 .collect::<Vec<_>>();
1505 pretty_assertions::assert_eq!(
1506 task_templates,
1507 [
1508 (
1509 "vitest file test".into(),
1510 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1511 ),
1512 (
1513 "vitest test $ZED_SYMBOL".into(),
1514 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1515 ),
1516 (
1517 "mocha file test".into(),
1518 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1519 ),
1520 (
1521 "mocha test $ZED_SYMBOL".into(),
1522 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1523 ),
1524 (
1525 "root/package.json > test".into(),
1526 Some(path!("/root").into())
1527 ),
1528 (
1529 "sub/package.json > test".into(),
1530 Some(path!("/root/sub").into())
1531 ),
1532 ]
1533 );
1534 }
1535
1536 #[test]
1537 fn test_escaping_name() {
1538 let cases = [
1539 ("plain test name", "plain test name"),
1540 ("test name with $param_name", "test name with (.+?)"),
1541 ("test name with $nested.param.name", "test name with (.+?)"),
1542 ("test name with $#", "test name with (.+?)"),
1543 ("test name with $##", "test name with (.+?)\\#"),
1544 ("test name with %p", "test name with (.+?)"),
1545 ("test name with %s", "test name with (.+?)"),
1546 ("test name with %d", "test name with (.+?)"),
1547 ("test name with %i", "test name with (.+?)"),
1548 ("test name with %f", "test name with (.+?)"),
1549 ("test name with %j", "test name with (.+?)"),
1550 ("test name with %o", "test name with (.+?)"),
1551 ("test name with %#", "test name with (.+?)"),
1552 ("test name with %$", "test name with (.+?)"),
1553 ("test name with %%", "test name with (.+?)"),
1554 ("test name with %q", "test name with %q"),
1555 (
1556 "test name with regex chars .*+?^${}()|[]\\",
1557 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1558 ),
1559 (
1560 "test name with multiple $params and %pretty and %b and (.+?)",
1561 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1562 ),
1563 ];
1564
1565 for (input, expected) in cases {
1566 assert_eq!(replace_test_name_parameters(input), expected);
1567 }
1568 }
1569
1570 // The order of test runner tasks is based on inferred user preference:
1571 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1572 // 2. Bun's built-in test runner (`bun test`) comes next.
1573 // 3. Node.js's built-in test runner (`node --test`) is last.
1574 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1575 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1576 // typically preferred over `node --test` when @types/bun is present.
1577 #[gpui::test]
1578 async fn test_task_ordering_with_multiple_test_runners(
1579 executor: BackgroundExecutor,
1580 cx: &mut TestAppContext,
1581 ) {
1582 cx.update(|cx| {
1583 settings::init(cx);
1584 Project::init_settings(cx);
1585 language_settings::init(cx);
1586 });
1587
1588 // Test case with all test runners present
1589 let package_json_all_runners = json!({
1590 "devDependencies": {
1591 "@types/bun": "1.0.0",
1592 "@types/node": "^20.0.0",
1593 "jest": "29.0.0",
1594 "mocha": "10.0.0",
1595 "vitest": "1.0.0",
1596 "jasmine": "5.0.0",
1597 },
1598 "scripts": {
1599 "test": "jest"
1600 }
1601 })
1602 .to_string();
1603
1604 let fs = FakeFs::new(executor);
1605 fs.insert_tree(
1606 path!("/root"),
1607 json!({
1608 "package.json": package_json_all_runners,
1609 "file.js": "",
1610 }),
1611 )
1612 .await;
1613
1614 let provider = TypeScriptContextProvider::new(fs.clone());
1615
1616 let package_json_data = cx
1617 .update(|cx| {
1618 provider.combined_package_json_data(
1619 fs.clone(),
1620 path!("/root").as_ref(),
1621 rel_path("file.js"),
1622 cx,
1623 )
1624 })
1625 .await
1626 .unwrap();
1627
1628 assert!(package_json_data.jest_package_path.is_some());
1629 assert!(package_json_data.mocha_package_path.is_some());
1630 assert!(package_json_data.vitest_package_path.is_some());
1631 assert!(package_json_data.jasmine_package_path.is_some());
1632 assert!(package_json_data.bun_package_path.is_some());
1633 assert!(package_json_data.node_package_path.is_some());
1634
1635 let mut task_templates = TaskTemplates::default();
1636 package_json_data.fill_task_templates(&mut task_templates);
1637
1638 let test_tasks: Vec<_> = task_templates
1639 .0
1640 .iter()
1641 .filter(|template| {
1642 template.tags.contains(&"ts-test".to_owned())
1643 || template.tags.contains(&"js-test".to_owned())
1644 })
1645 .map(|template| &template.label)
1646 .collect();
1647
1648 let node_test_index = test_tasks
1649 .iter()
1650 .position(|label| label.contains("node test"));
1651 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1652 let bun_test_index = test_tasks
1653 .iter()
1654 .position(|label| label.contains("bun test"));
1655
1656 assert!(
1657 node_test_index.is_some(),
1658 "Node test tasks should be present"
1659 );
1660 assert!(
1661 jest_test_index.is_some(),
1662 "Jest test tasks should be present"
1663 );
1664 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1665
1666 assert!(
1667 jest_test_index.unwrap() < bun_test_index.unwrap(),
1668 "Jest should come before Bun"
1669 );
1670 assert!(
1671 bun_test_index.unwrap() < node_test_index.unwrap(),
1672 "Bun should come before Node"
1673 );
1674 }
1675}