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, Entity, Task};
7use itertools::Itertools as _;
8use language::{
9 Buffer, ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore,
10 LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain,
11};
12use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
13use node_runtime::{NodeRuntime, VersionStrategy};
14use project::{Fs, lsp_store::language_server_settings};
15use semver::Version;
16use serde_json::{Value, json};
17use smol::lock::RwLock;
18use std::{
19 borrow::Cow,
20 ffi::OsString,
21 path::{Path, PathBuf},
22 sync::{Arc, LazyLock},
23};
24use task::{TaskTemplate, TaskTemplates, VariableName};
25use util::rel_path::RelPath;
26use util::{ResultExt, maybe};
27
28use crate::{PackageJson, PackageJsonData};
29
30pub(crate) struct TypeScriptContextProvider {
31 fs: Arc<dyn Fs>,
32 last_package_json: PackageJsonContents,
33}
34
35const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
36 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
37
38const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
39 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
40
41const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
42 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
43
44const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
45 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
46
47const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
48 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
49
50const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
51 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
52
53const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
54 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
55
56const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
57 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
58
59const TYPESCRIPT_BUN_TEST_NAME_VARIABLE: VariableName =
60 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_TEST_NAME"));
61
62const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
63 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
64
65#[derive(Clone, Debug, Default)]
66struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
67
68impl PackageJsonData {
69 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
70 if self.jest_package_path.is_some() {
71 task_templates.0.push(TaskTemplate {
72 label: "jest file test".to_owned(),
73 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
74 args: vec![
75 "exec".to_owned(),
76 "--".to_owned(),
77 "jest".to_owned(),
78 "--runInBand".to_owned(),
79 VariableName::File.template_value(),
80 ],
81 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
82 ..TaskTemplate::default()
83 });
84 task_templates.0.push(TaskTemplate {
85 label: format!("jest test {}", VariableName::Symbol.template_value()),
86 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
87 args: vec![
88 "exec".to_owned(),
89 "--".to_owned(),
90 "jest".to_owned(),
91 "--runInBand".to_owned(),
92 "--testNamePattern".to_owned(),
93 format!(
94 "\"{}\"",
95 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
96 ),
97 VariableName::File.template_value(),
98 ],
99 tags: vec![
100 "ts-test".to_owned(),
101 "js-test".to_owned(),
102 "tsx-test".to_owned(),
103 ],
104 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
105 ..TaskTemplate::default()
106 });
107 }
108
109 if self.vitest_package_path.is_some() {
110 task_templates.0.push(TaskTemplate {
111 label: format!("{} file test", "vitest".to_owned()),
112 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
113 args: vec![
114 "exec".to_owned(),
115 "--".to_owned(),
116 "vitest".to_owned(),
117 "run".to_owned(),
118 "--no-file-parallelism".to_owned(),
119 VariableName::File.template_value(),
120 ],
121 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
122 ..TaskTemplate::default()
123 });
124 task_templates.0.push(TaskTemplate {
125 label: format!(
126 "{} test {}",
127 "vitest".to_owned(),
128 VariableName::Symbol.template_value(),
129 ),
130 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
131 args: vec![
132 "exec".to_owned(),
133 "--".to_owned(),
134 "vitest".to_owned(),
135 "run".to_owned(),
136 "--no-file-parallelism".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!("\"{}\"", TYPESCRIPT_BUN_TEST_NAME_VARIABLE.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 buffer: Option<Entity<Buffer>>,
429 cx: &App,
430 ) -> Task<Option<TaskTemplates>> {
431 let file = buffer.and_then(|buffer| buffer.read(cx).file());
432 let Some(file) = project::File::from_dyn(file).cloned() else {
433 return Task::ready(None);
434 };
435 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
436 return Task::ready(None);
437 };
438 let file_relative_path = file.path().clone();
439 let package_json_data = self.combined_package_json_data(
440 self.fs.clone(),
441 &worktree_root,
442 &file_relative_path,
443 cx,
444 );
445
446 cx.background_spawn(async move {
447 let mut task_templates = TaskTemplates(Vec::new());
448 task_templates.0.push(TaskTemplate {
449 label: format!(
450 "execute selection {}",
451 VariableName::SelectedText.template_value()
452 ),
453 command: "node".to_owned(),
454 args: vec![
455 "-e".to_owned(),
456 format!("\"{}\"", VariableName::SelectedText.template_value()),
457 ],
458 ..TaskTemplate::default()
459 });
460
461 match package_json_data.await {
462 Ok(package_json) => {
463 package_json.fill_task_templates(&mut task_templates);
464 }
465 Err(e) => {
466 log::error!(
467 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
468 );
469 }
470 }
471
472 Some(task_templates)
473 })
474 }
475
476 fn build_context(
477 &self,
478 current_vars: &task::TaskVariables,
479 location: ContextLocation<'_>,
480 _project_env: Option<HashMap<String, String>>,
481 _toolchains: Arc<dyn LanguageToolchainStore>,
482 cx: &mut App,
483 ) -> Task<Result<task::TaskVariables>> {
484 let mut vars = task::TaskVariables::default();
485
486 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
487 vars.insert(
488 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
489 replace_test_name_parameters(symbol),
490 );
491 vars.insert(
492 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
493 replace_test_name_parameters(symbol),
494 );
495 vars.insert(
496 TYPESCRIPT_BUN_TEST_NAME_VARIABLE,
497 replace_test_name_parameters(symbol),
498 );
499 }
500 let file_path = location
501 .file_location
502 .buffer
503 .read(cx)
504 .file()
505 .map(|file| file.path());
506
507 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
508 |((worktree_root, fs), file_path)| {
509 (
510 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
511 worktree_root,
512 fs,
513 )
514 },
515 );
516 cx.background_spawn(async move {
517 if let Some((task, worktree_root, fs)) = args {
518 let package_json_data = task.await.log_err();
519 vars.insert(
520 TYPESCRIPT_RUNNER_VARIABLE,
521 detect_package_manager(worktree_root, fs, package_json_data.clone())
522 .await
523 .to_owned(),
524 );
525
526 if let Some(package_json_data) = package_json_data {
527 if let Some(path) = package_json_data.jest_package_path {
528 vars.insert(
529 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
530 path.parent()
531 .unwrap_or(Path::new(""))
532 .to_string_lossy()
533 .to_string(),
534 );
535 }
536
537 if let Some(path) = package_json_data.mocha_package_path {
538 vars.insert(
539 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
540 path.parent()
541 .unwrap_or(Path::new(""))
542 .to_string_lossy()
543 .to_string(),
544 );
545 }
546
547 if let Some(path) = package_json_data.vitest_package_path {
548 vars.insert(
549 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
550 path.parent()
551 .unwrap_or(Path::new(""))
552 .to_string_lossy()
553 .to_string(),
554 );
555 }
556
557 if let Some(path) = package_json_data.jasmine_package_path {
558 vars.insert(
559 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
560 path.parent()
561 .unwrap_or(Path::new(""))
562 .to_string_lossy()
563 .to_string(),
564 );
565 }
566
567 if let Some(path) = package_json_data.bun_package_path {
568 vars.insert(
569 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
570 path.parent()
571 .unwrap_or(Path::new(""))
572 .to_string_lossy()
573 .to_string(),
574 );
575 }
576
577 if let Some(path) = package_json_data.node_package_path {
578 vars.insert(
579 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
580 path.parent()
581 .unwrap_or(Path::new(""))
582 .to_string_lossy()
583 .to_string(),
584 );
585 }
586 }
587 }
588 Ok(vars)
589 })
590 }
591}
592
593fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
594 vec![server_path.into(), "--stdio".into()]
595}
596
597fn replace_test_name_parameters(test_name: &str) -> String {
598 static PATTERN: LazyLock<regex::Regex> =
599 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
600 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
601}
602
603pub struct TypeScriptLspAdapter {
604 fs: Arc<dyn Fs>,
605 node: NodeRuntime,
606}
607
608impl TypeScriptLspAdapter {
609 const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
610 const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
611
612 const PACKAGE_NAME: &str = "typescript";
613 const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
614
615 const SERVER_NAME: LanguageServerName =
616 LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
617
618 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
619 TypeScriptLspAdapter { fs, node }
620 }
621
622 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
623 let is_yarn = adapter
624 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
625 .await
626 .is_ok();
627
628 let tsdk_path = if is_yarn {
629 ".yarn/sdks/typescript/lib"
630 } else {
631 "node_modules/typescript/lib"
632 };
633
634 if self
635 .fs
636 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
637 .await
638 {
639 Some(tsdk_path)
640 } else {
641 None
642 }
643 }
644}
645
646pub struct TypeScriptVersions {
647 typescript_version: Version,
648 server_version: Version,
649}
650
651impl LspInstaller for TypeScriptLspAdapter {
652 type BinaryVersion = TypeScriptVersions;
653
654 async fn fetch_latest_server_version(
655 &self,
656 _: &dyn LspAdapterDelegate,
657 _: bool,
658 _: &mut AsyncApp,
659 ) -> Result<Self::BinaryVersion> {
660 Ok(TypeScriptVersions {
661 typescript_version: self
662 .node
663 .npm_package_latest_version(Self::PACKAGE_NAME)
664 .await?,
665 server_version: self
666 .node
667 .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
668 .await?,
669 })
670 }
671
672 async fn check_if_version_installed(
673 &self,
674 version: &Self::BinaryVersion,
675 container_dir: &PathBuf,
676 _: &dyn LspAdapterDelegate,
677 ) -> Option<LanguageServerBinary> {
678 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
679
680 if self
681 .node
682 .should_install_npm_package(
683 Self::PACKAGE_NAME,
684 &server_path,
685 container_dir,
686 VersionStrategy::Latest(&version.typescript_version),
687 )
688 .await
689 {
690 return None;
691 }
692
693 if self
694 .node
695 .should_install_npm_package(
696 Self::SERVER_PACKAGE_NAME,
697 &server_path,
698 container_dir,
699 VersionStrategy::Latest(&version.server_version),
700 )
701 .await
702 {
703 return None;
704 }
705
706 Some(LanguageServerBinary {
707 path: self.node.binary_path().await.ok()?,
708 env: None,
709 arguments: typescript_server_binary_arguments(&server_path),
710 })
711 }
712
713 async fn fetch_server_binary(
714 &self,
715 latest_version: Self::BinaryVersion,
716 container_dir: PathBuf,
717 _: &dyn LspAdapterDelegate,
718 ) -> Result<LanguageServerBinary> {
719 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
720
721 self.node
722 .npm_install_packages(
723 &container_dir,
724 &[
725 (
726 Self::PACKAGE_NAME,
727 &latest_version.typescript_version.to_string(),
728 ),
729 (
730 Self::SERVER_PACKAGE_NAME,
731 &latest_version.server_version.to_string(),
732 ),
733 ],
734 )
735 .await?;
736
737 Ok(LanguageServerBinary {
738 path: self.node.binary_path().await?,
739 env: None,
740 arguments: typescript_server_binary_arguments(&server_path),
741 })
742 }
743
744 async fn cached_server_binary(
745 &self,
746 container_dir: PathBuf,
747 _: &dyn LspAdapterDelegate,
748 ) -> Option<LanguageServerBinary> {
749 get_cached_ts_server_binary(container_dir, &self.node).await
750 }
751}
752
753#[async_trait(?Send)]
754impl LspAdapter for TypeScriptLspAdapter {
755 fn name(&self) -> LanguageServerName {
756 Self::SERVER_NAME
757 }
758
759 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
760 Some(vec![
761 CodeActionKind::QUICKFIX,
762 CodeActionKind::REFACTOR,
763 CodeActionKind::REFACTOR_EXTRACT,
764 CodeActionKind::SOURCE,
765 ])
766 }
767
768 async fn label_for_completion(
769 &self,
770 item: &lsp::CompletionItem,
771 language: &Arc<language::Language>,
772 ) -> Option<language::CodeLabel> {
773 use lsp::CompletionItemKind as Kind;
774 let label_len = item.label.len();
775 let grammar = language.grammar()?;
776 let highlight_id = match item.kind? {
777 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
778 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
779 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
780 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
781 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
782 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
783 _ => None,
784 }?;
785
786 let text = if let Some(description) = item
787 .label_details
788 .as_ref()
789 .and_then(|label_details| label_details.description.as_ref())
790 {
791 format!("{} {}", item.label, description)
792 } else if let Some(detail) = &item.detail {
793 format!("{} {}", item.label, detail)
794 } else {
795 item.label.clone()
796 };
797 Some(language::CodeLabel::filtered(
798 text,
799 label_len,
800 item.filter_text.as_deref(),
801 vec![(0..label_len, highlight_id)],
802 ))
803 }
804
805 async fn initialization_options(
806 self: Arc<Self>,
807 adapter: &Arc<dyn LspAdapterDelegate>,
808 _: &mut AsyncApp,
809 ) -> Result<Option<serde_json::Value>> {
810 let tsdk_path = self.tsdk_path(adapter).await;
811 Ok(Some(json!({
812 "provideFormatter": true,
813 "hostInfo": "zed",
814 "tsserver": {
815 "path": tsdk_path,
816 },
817 "preferences": {
818 "includeInlayParameterNameHints": "all",
819 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
820 "includeInlayFunctionParameterTypeHints": true,
821 "includeInlayVariableTypeHints": true,
822 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
823 "includeInlayPropertyDeclarationTypeHints": true,
824 "includeInlayFunctionLikeReturnTypeHints": true,
825 "includeInlayEnumMemberValueHints": true,
826 }
827 })))
828 }
829
830 async fn workspace_configuration(
831 self: Arc<Self>,
832 delegate: &Arc<dyn LspAdapterDelegate>,
833 _: Option<Toolchain>,
834 _: Option<Uri>,
835 cx: &mut AsyncApp,
836 ) -> Result<Value> {
837 let override_options = cx.update(|cx| {
838 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
839 .and_then(|s| s.settings.clone())
840 });
841 if let Some(options) = override_options {
842 return Ok(options);
843 }
844 Ok(json!({
845 "completions": {
846 "completeFunctionCalls": true
847 }
848 }))
849 }
850
851 fn language_ids(&self) -> HashMap<LanguageName, String> {
852 HashMap::from_iter([
853 (LanguageName::new_static("TypeScript"), "typescript".into()),
854 (LanguageName::new_static("JavaScript"), "javascript".into()),
855 (LanguageName::new_static("TSX"), "typescriptreact".into()),
856 ])
857 }
858}
859
860async fn get_cached_ts_server_binary(
861 container_dir: PathBuf,
862 node: &NodeRuntime,
863) -> Option<LanguageServerBinary> {
864 maybe!(async {
865 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
866 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
867 if new_server_path.exists() {
868 Ok(LanguageServerBinary {
869 path: node.binary_path().await?,
870 env: None,
871 arguments: typescript_server_binary_arguments(&new_server_path),
872 })
873 } else if old_server_path.exists() {
874 Ok(LanguageServerBinary {
875 path: node.binary_path().await?,
876 env: None,
877 arguments: typescript_server_binary_arguments(&old_server_path),
878 })
879 } else {
880 anyhow::bail!("missing executable in directory {container_dir:?}")
881 }
882 })
883 .await
884 .log_err()
885}
886
887#[cfg(test)]
888mod tests {
889 use std::path::Path;
890
891 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
892 use project::FakeFs;
893 use serde_json::json;
894 use task::TaskTemplates;
895 use unindent::Unindent;
896 use util::{path, rel_path::rel_path};
897
898 use crate::typescript::{
899 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
900 };
901
902 #[gpui::test]
903 async fn test_outline(cx: &mut TestAppContext) {
904 for language in [
905 crate::language(
906 "typescript",
907 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
908 ),
909 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
910 ] {
911 let text = r#"
912 function a() {
913 // local variables are included
914 let a1 = 1;
915 // all functions are included
916 async function a2() {}
917 }
918 // top-level variables are included
919 let b: C
920 function getB() {}
921 // exported variables are included
922 export const d = e;
923 "#
924 .unindent();
925
926 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
927 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
928 assert_eq!(
929 outline
930 .items
931 .iter()
932 .map(|item| (item.text.as_str(), item.depth))
933 .collect::<Vec<_>>(),
934 &[
935 ("function a()", 0),
936 ("let a1", 1),
937 ("async function a2()", 1),
938 ("let b", 0),
939 ("function getB()", 0),
940 ("const d", 0),
941 ]
942 );
943 }
944 }
945
946 #[gpui::test]
947 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
948 for language in [
949 crate::language(
950 "typescript",
951 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
952 ),
953 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
954 ] {
955 let text = r#"
956 // Top-level destructuring
957 const { a1, a2 } = a;
958 const [b1, b2] = b;
959
960 // Defaults and rest
961 const [c1 = 1, , c2, ...rest1] = c;
962 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
963
964 function processData() {
965 // Nested object destructuring
966 const { c1, c2 } = c;
967 // Nested array destructuring
968 const [d1, d2, d3] = d;
969 // Destructuring with renaming
970 const { f1: g1 } = f;
971 // With defaults
972 const [x = 10, y] = xy;
973 }
974
975 class DataHandler {
976 method() {
977 // Destructuring in class method
978 const { a1, a2 } = a;
979 const [b1, ...b2] = b;
980 }
981 }
982 "#
983 .unindent();
984
985 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
986 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
987 assert_eq!(
988 outline
989 .items
990 .iter()
991 .map(|item| (item.text.as_str(), item.depth))
992 .collect::<Vec<_>>(),
993 &[
994 ("const a1", 0),
995 ("const a2", 0),
996 ("const b1", 0),
997 ("const b2", 0),
998 ("const c1", 0),
999 ("const c2", 0),
1000 ("const rest1", 0),
1001 ("const d1", 0),
1002 ("const e1", 0),
1003 ("const h1", 0),
1004 ("const rest2", 0),
1005 ("function processData()", 0),
1006 ("const c1", 1),
1007 ("const c2", 1),
1008 ("const d1", 1),
1009 ("const d2", 1),
1010 ("const d3", 1),
1011 ("const g1", 1),
1012 ("const x", 1),
1013 ("const y", 1),
1014 ("class DataHandler", 0),
1015 ("method()", 1),
1016 ("const a1", 2),
1017 ("const a2", 2),
1018 ("const b1", 2),
1019 ("const b2", 2),
1020 ]
1021 );
1022 }
1023 }
1024
1025 #[gpui::test]
1026 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1027 for language in [
1028 crate::language(
1029 "typescript",
1030 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1031 ),
1032 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1033 ] {
1034 let text = r#"
1035 // Object with function properties
1036 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1037
1038 // Object with primitive properties
1039 const p = { p1: 1, p2: "hello", p3: true };
1040
1041 // Nested objects
1042 const q = {
1043 r: {
1044 // won't be included due to one-level depth limit
1045 s: 1
1046 },
1047 t: 2
1048 };
1049
1050 function getData() {
1051 const local = { x: 1, y: 2 };
1052 return local;
1053 }
1054 "#
1055 .unindent();
1056
1057 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1058 cx.run_until_parked();
1059 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1060 assert_eq!(
1061 outline
1062 .items
1063 .iter()
1064 .map(|item| (item.text.as_str(), item.depth))
1065 .collect::<Vec<_>>(),
1066 &[
1067 ("const o", 0),
1068 ("m()", 1),
1069 ("async n()", 1),
1070 ("g", 1),
1071 ("h", 1),
1072 ("k", 1),
1073 ("const p", 0),
1074 ("p1", 1),
1075 ("p2", 1),
1076 ("p3", 1),
1077 ("const q", 0),
1078 ("r", 1),
1079 ("s", 2),
1080 ("t", 1),
1081 ("function getData()", 0),
1082 ("const local", 1),
1083 ("x", 2),
1084 ("y", 2),
1085 ]
1086 );
1087 }
1088 }
1089
1090 #[gpui::test]
1091 async fn test_outline_with_nested_object_methods(cx: &mut TestAppContext) {
1092 for language in [
1093 crate::language(
1094 "typescript",
1095 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1096 ),
1097 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1098 crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()),
1099 ] {
1100 let text = r#"
1101 // Reproduction from https://github.com/zed-industries/zed/issues/48711
1102 const a = {
1103 p01: '01',
1104 fn01: () => {},
1105 fn02() {},
1106 deep: {
1107 subFn01: () => {},
1108 subFn02() {},
1109 subP03: '03',
1110 deep2: {
1111 subFn01: () => {},
1112 subFn02() {},
1113 subP03: '03',
1114 },
1115 },
1116 };
1117
1118 // Edge case: async methods in nested objects
1119 const b = {
1120 async topAsync() {},
1121 nested: { async nestedAsync() {} },
1122 };
1123
1124 // Edge case: object literal in function argument
1125 foo({ bar() {}, inner: { baz() {} } });
1126 "#
1127 .unindent();
1128
1129 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1130 cx.run_until_parked();
1131 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1132
1133 let items: Vec<_> = outline
1134 .items
1135 .iter()
1136 .map(|item| (item.text.as_str(), item.depth))
1137 .collect();
1138
1139 assert_eq!(
1140 items,
1141 &[
1142 ("const a", 0),
1143 ("p01", 1),
1144 ("fn01", 1),
1145 ("fn02()", 1),
1146 ("deep", 1),
1147 ("subFn01", 2),
1148 ("subFn02()", 2),
1149 ("subP03", 2),
1150 ("deep2", 2),
1151 ("subFn01", 3),
1152 ("subFn02()", 3),
1153 ("subP03", 3),
1154 ("const b", 0),
1155 ("async topAsync()", 1),
1156 ("nested", 1),
1157 ("async nestedAsync()", 2),
1158 ("bar()", 0),
1159 ("inner", 0),
1160 ("baz()", 1),
1161 ]
1162 );
1163 }
1164 }
1165
1166 #[gpui::test]
1167 async fn test_outline_with_complex_nested_objects(cx: &mut TestAppContext) {
1168 for language in [
1169 crate::language(
1170 "typescript",
1171 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1172 ),
1173 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1174 crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into()),
1175 ] {
1176 let text = r#"
1177 const config = {
1178 init() {},
1179 destroy() {},
1180 api: {
1181 baseUrl: "x",
1182 fetchData() {},
1183 async submitForm() {},
1184 errorHandler() {},
1185 },
1186 features: {
1187 auth: {
1188 login() {},
1189 logout() {},
1190 refreshToken() {},
1191 },
1192 cache: {
1193 get() {},
1194 set() {},
1195 invalidate() {},
1196 },
1197 },
1198 watch: {
1199 value() {},
1200 },
1201 computed: {
1202 fullName() {},
1203 displayValue() {},
1204 },
1205 };
1206
1207 registerPlugin({
1208 name: "my-plugin",
1209 setup() {},
1210 teardown() {},
1211 hooks: {
1212 beforeMount() {},
1213 mounted() {},
1214 beforeUnmount() {},
1215 },
1216 });
1217
1218 export const store = {
1219 state: {},
1220 mutations: {
1221 setUser() {},
1222 clearUser() {},
1223 },
1224 actions: {
1225 async fetchUser() {},
1226 logout() {},
1227 },
1228 getters: {
1229 currentUser() {},
1230 isAuthenticated() {},
1231 },
1232 };
1233
1234 function registerPlugin(_plugin: unknown) {}
1235 "#
1236 .unindent();
1237
1238 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1239 cx.run_until_parked();
1240 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1241
1242 let items: Vec<_> = outline
1243 .items
1244 .iter()
1245 .map(|item| (item.text.as_str(), item.depth))
1246 .collect();
1247
1248 assert_eq!(
1249 items,
1250 &[
1251 ("const config", 0),
1252 ("init()", 1),
1253 ("destroy()", 1),
1254 ("api", 1),
1255 ("baseUrl", 2),
1256 ("fetchData()", 2),
1257 ("async submitForm()", 2),
1258 ("errorHandler()", 2),
1259 ("features", 1),
1260 ("auth", 2),
1261 ("login()", 3),
1262 ("logout()", 3),
1263 ("refreshToken()", 3),
1264 ("cache", 2),
1265 ("get()", 3),
1266 ("set()", 3),
1267 ("invalidate()", 3),
1268 ("watch", 1),
1269 ("value()", 2),
1270 ("computed", 1),
1271 ("fullName()", 2),
1272 ("displayValue()", 2),
1273 ("name", 0),
1274 ("setup()", 0),
1275 ("teardown()", 0),
1276 ("hooks", 0),
1277 ("beforeMount()", 1),
1278 ("mounted()", 1),
1279 ("beforeUnmount()", 1),
1280 ("const store", 0),
1281 ("state", 1),
1282 ("mutations", 1),
1283 ("setUser()", 2),
1284 ("clearUser()", 2),
1285 ("actions", 1),
1286 ("async fetchUser()", 2),
1287 ("logout()", 2),
1288 ("getters", 1),
1289 ("currentUser()", 2),
1290 ("isAuthenticated()", 2),
1291 ("function registerPlugin( )", 0),
1292 ]
1293 );
1294 }
1295 }
1296
1297 #[gpui::test]
1298 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1299 for language in [
1300 crate::language(
1301 "typescript",
1302 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1303 ),
1304 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1305 ] {
1306 let text = r#"
1307 // Symbols as object keys
1308 const sym = Symbol("test");
1309 const obj1 = {
1310 [sym]: 1,
1311 [Symbol("inline")]: 2,
1312 normalKey: 3
1313 };
1314
1315 // Enums as object keys
1316 enum Color { Red, Blue, Green }
1317
1318 const obj2 = {
1319 [Color.Red]: "red value",
1320 [Color.Blue]: "blue value",
1321 regularProp: "normal"
1322 };
1323
1324 // Mixed computed properties
1325 const key = "dynamic";
1326 const obj3 = {
1327 [key]: 1,
1328 ["string" + "concat"]: 2,
1329 [1 + 1]: 3,
1330 static: 4
1331 };
1332
1333 // Nested objects with computed properties
1334 const obj4 = {
1335 [sym]: {
1336 nested: 1
1337 },
1338 regular: {
1339 [key]: 2
1340 }
1341 };
1342 "#
1343 .unindent();
1344
1345 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1346 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1347 assert_eq!(
1348 outline
1349 .items
1350 .iter()
1351 .map(|item| (item.text.as_str(), item.depth))
1352 .collect::<Vec<_>>(),
1353 &[
1354 ("const sym", 0),
1355 ("const obj1", 0),
1356 ("[sym]", 1),
1357 ("[Symbol(\"inline\")]", 1),
1358 ("normalKey", 1),
1359 ("enum Color", 0),
1360 ("const obj2", 0),
1361 ("[Color.Red]", 1),
1362 ("[Color.Blue]", 1),
1363 ("regularProp", 1),
1364 ("const key", 0),
1365 ("const obj3", 0),
1366 ("[key]", 1),
1367 ("[\"string\" + \"concat\"]", 1),
1368 ("[1 + 1]", 1),
1369 ("static", 1),
1370 ("const obj4", 0),
1371 ("[sym]", 1),
1372 ("nested", 2),
1373 ("regular", 1),
1374 ("[key]", 2),
1375 ]
1376 );
1377 }
1378 }
1379
1380 #[gpui::test]
1381 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1382 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1383
1384 let text = r#"
1385 function normalFunction() {
1386 console.log("normal");
1387 }
1388
1389 function* simpleGenerator() {
1390 yield 1;
1391 yield 2;
1392 }
1393
1394 async function* asyncGenerator() {
1395 yield await Promise.resolve(1);
1396 }
1397
1398 function* generatorWithParams(start, end) {
1399 for (let i = start; i <= end; i++) {
1400 yield i;
1401 }
1402 }
1403
1404 class TestClass {
1405 *methodGenerator() {
1406 yield "method";
1407 }
1408
1409 async *asyncMethodGenerator() {
1410 yield "async method";
1411 }
1412 }
1413 "#
1414 .unindent();
1415
1416 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1417 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1418 assert_eq!(
1419 outline
1420 .items
1421 .iter()
1422 .map(|item| (item.text.as_str(), item.depth))
1423 .collect::<Vec<_>>(),
1424 &[
1425 ("function normalFunction()", 0),
1426 ("function* simpleGenerator()", 0),
1427 ("async function* asyncGenerator()", 0),
1428 ("function* generatorWithParams( )", 0),
1429 ("class TestClass", 0),
1430 ("*methodGenerator()", 1),
1431 ("async *asyncMethodGenerator()", 1),
1432 ]
1433 );
1434 }
1435
1436 #[gpui::test]
1437 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1438 cx.update(|cx| {
1439 settings::init(cx);
1440 });
1441
1442 let package_json_1 = json!({
1443 "dependencies": {
1444 "mocha": "1.0.0",
1445 "vitest": "1.0.0"
1446 },
1447 "scripts": {
1448 "test": ""
1449 }
1450 })
1451 .to_string();
1452
1453 let package_json_2 = json!({
1454 "devDependencies": {
1455 "vitest": "2.0.0"
1456 },
1457 "scripts": {
1458 "test": ""
1459 }
1460 })
1461 .to_string();
1462
1463 let fs = FakeFs::new(executor);
1464 fs.insert_tree(
1465 path!("/root"),
1466 json!({
1467 "package.json": package_json_1,
1468 "sub": {
1469 "package.json": package_json_2,
1470 "file.js": "",
1471 }
1472 }),
1473 )
1474 .await;
1475
1476 let provider = TypeScriptContextProvider::new(fs.clone());
1477 let package_json_data = cx
1478 .update(|cx| {
1479 provider.combined_package_json_data(
1480 fs.clone(),
1481 path!("/root").as_ref(),
1482 rel_path("sub/file1.js"),
1483 cx,
1484 )
1485 })
1486 .await
1487 .unwrap();
1488 pretty_assertions::assert_eq!(
1489 package_json_data,
1490 PackageJsonData {
1491 jest_package_path: None,
1492 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1493 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1494 jasmine_package_path: None,
1495 bun_package_path: None,
1496 node_package_path: None,
1497 scripts: [
1498 (
1499 Path::new(path!("/root/package.json")).into(),
1500 "test".to_owned()
1501 ),
1502 (
1503 Path::new(path!("/root/sub/package.json")).into(),
1504 "test".to_owned()
1505 )
1506 ]
1507 .into_iter()
1508 .collect(),
1509 package_manager: None,
1510 }
1511 );
1512
1513 let mut task_templates = TaskTemplates::default();
1514 package_json_data.fill_task_templates(&mut task_templates);
1515 let task_templates = task_templates
1516 .0
1517 .into_iter()
1518 .map(|template| (template.label, template.cwd))
1519 .collect::<Vec<_>>();
1520 pretty_assertions::assert_eq!(
1521 task_templates,
1522 [
1523 (
1524 "vitest file test".into(),
1525 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1526 ),
1527 (
1528 "vitest test $ZED_SYMBOL".into(),
1529 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1530 ),
1531 (
1532 "mocha file test".into(),
1533 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1534 ),
1535 (
1536 "mocha test $ZED_SYMBOL".into(),
1537 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1538 ),
1539 (
1540 "root/package.json > test".into(),
1541 Some(path!("/root").into())
1542 ),
1543 (
1544 "sub/package.json > test".into(),
1545 Some(path!("/root/sub").into())
1546 ),
1547 ]
1548 );
1549 }
1550
1551 #[test]
1552 fn test_escaping_name() {
1553 let cases = [
1554 ("plain test name", "plain test name"),
1555 ("test name with $param_name", "test name with (.+?)"),
1556 ("test name with $nested.param.name", "test name with (.+?)"),
1557 ("test name with $#", "test name with (.+?)"),
1558 ("test name with $##", "test name with (.+?)\\#"),
1559 ("test name with %p", "test name with (.+?)"),
1560 ("test name with %s", "test name with (.+?)"),
1561 ("test name with %d", "test name with (.+?)"),
1562 ("test name with %i", "test name with (.+?)"),
1563 ("test name with %f", "test name with (.+?)"),
1564 ("test name with %j", "test name with (.+?)"),
1565 ("test name with %o", "test name with (.+?)"),
1566 ("test name with %#", "test name with (.+?)"),
1567 ("test name with %$", "test name with (.+?)"),
1568 ("test name with %%", "test name with (.+?)"),
1569 ("test name with %q", "test name with %q"),
1570 (
1571 "test name with regex chars .*+?^${}()|[]\\",
1572 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1573 ),
1574 (
1575 "test name with multiple $params and %pretty and %b and (.+?)",
1576 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1577 ),
1578 ];
1579
1580 for (input, expected) in cases {
1581 assert_eq!(replace_test_name_parameters(input), expected);
1582 }
1583 }
1584
1585 // The order of test runner tasks is based on inferred user preference:
1586 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1587 // 2. Bun's built-in test runner (`bun test`) comes next.
1588 // 3. Node.js's built-in test runner (`node --test`) is last.
1589 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1590 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1591 // typically preferred over `node --test` when @types/bun is present.
1592 #[gpui::test]
1593 async fn test_task_ordering_with_multiple_test_runners(
1594 executor: BackgroundExecutor,
1595 cx: &mut TestAppContext,
1596 ) {
1597 cx.update(|cx| {
1598 settings::init(cx);
1599 });
1600
1601 // Test case with all test runners present
1602 let package_json_all_runners = json!({
1603 "devDependencies": {
1604 "@types/bun": "1.0.0",
1605 "@types/node": "^20.0.0",
1606 "jest": "29.0.0",
1607 "mocha": "10.0.0",
1608 "vitest": "1.0.0",
1609 "jasmine": "5.0.0",
1610 },
1611 "scripts": {
1612 "test": "jest"
1613 }
1614 })
1615 .to_string();
1616
1617 let fs = FakeFs::new(executor);
1618 fs.insert_tree(
1619 path!("/root"),
1620 json!({
1621 "package.json": package_json_all_runners,
1622 "file.js": "",
1623 }),
1624 )
1625 .await;
1626
1627 let provider = TypeScriptContextProvider::new(fs.clone());
1628
1629 let package_json_data = cx
1630 .update(|cx| {
1631 provider.combined_package_json_data(
1632 fs.clone(),
1633 path!("/root").as_ref(),
1634 rel_path("file.js"),
1635 cx,
1636 )
1637 })
1638 .await
1639 .unwrap();
1640
1641 assert!(package_json_data.jest_package_path.is_some());
1642 assert!(package_json_data.mocha_package_path.is_some());
1643 assert!(package_json_data.vitest_package_path.is_some());
1644 assert!(package_json_data.jasmine_package_path.is_some());
1645 assert!(package_json_data.bun_package_path.is_some());
1646 assert!(package_json_data.node_package_path.is_some());
1647
1648 let mut task_templates = TaskTemplates::default();
1649 package_json_data.fill_task_templates(&mut task_templates);
1650
1651 let test_tasks: Vec<_> = task_templates
1652 .0
1653 .iter()
1654 .filter(|template| {
1655 template.tags.contains(&"ts-test".to_owned())
1656 || template.tags.contains(&"js-test".to_owned())
1657 })
1658 .map(|template| &template.label)
1659 .collect();
1660
1661 let node_test_index = test_tasks
1662 .iter()
1663 .position(|label| label.contains("node test"));
1664 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1665 let bun_test_index = test_tasks
1666 .iter()
1667 .position(|label| label.contains("bun test"));
1668
1669 assert!(
1670 node_test_index.is_some(),
1671 "Node test tasks should be present"
1672 );
1673 assert!(
1674 jest_test_index.is_some(),
1675 "Jest test tasks should be present"
1676 );
1677 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1678
1679 assert!(
1680 jest_test_index.unwrap() < bun_test_index.unwrap(),
1681 "Jest should come before Bun"
1682 );
1683 assert!(
1684 bun_test_index.unwrap() < node_test_index.unwrap(),
1685 "Bun should come before Node"
1686 );
1687 }
1688}