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_computed_property_names(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 ] {
1099 let text = r#"
1100 // Symbols as object keys
1101 const sym = Symbol("test");
1102 const obj1 = {
1103 [sym]: 1,
1104 [Symbol("inline")]: 2,
1105 normalKey: 3
1106 };
1107
1108 // Enums as object keys
1109 enum Color { Red, Blue, Green }
1110
1111 const obj2 = {
1112 [Color.Red]: "red value",
1113 [Color.Blue]: "blue value",
1114 regularProp: "normal"
1115 };
1116
1117 // Mixed computed properties
1118 const key = "dynamic";
1119 const obj3 = {
1120 [key]: 1,
1121 ["string" + "concat"]: 2,
1122 [1 + 1]: 3,
1123 static: 4
1124 };
1125
1126 // Nested objects with computed properties
1127 const obj4 = {
1128 [sym]: {
1129 nested: 1
1130 },
1131 regular: {
1132 [key]: 2
1133 }
1134 };
1135 "#
1136 .unindent();
1137
1138 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1139 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1140 assert_eq!(
1141 outline
1142 .items
1143 .iter()
1144 .map(|item| (item.text.as_str(), item.depth))
1145 .collect::<Vec<_>>(),
1146 &[
1147 ("const sym", 0),
1148 ("const obj1", 0),
1149 ("[sym]", 1),
1150 ("[Symbol(\"inline\")]", 1),
1151 ("normalKey", 1),
1152 ("enum Color", 0),
1153 ("const obj2", 0),
1154 ("[Color.Red]", 1),
1155 ("[Color.Blue]", 1),
1156 ("regularProp", 1),
1157 ("const key", 0),
1158 ("const obj3", 0),
1159 ("[key]", 1),
1160 ("[\"string\" + \"concat\"]", 1),
1161 ("[1 + 1]", 1),
1162 ("static", 1),
1163 ("const obj4", 0),
1164 ("[sym]", 1),
1165 ("nested", 2),
1166 ("regular", 1),
1167 ("[key]", 2),
1168 ]
1169 );
1170 }
1171 }
1172
1173 #[gpui::test]
1174 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1175 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1176
1177 let text = r#"
1178 function normalFunction() {
1179 console.log("normal");
1180 }
1181
1182 function* simpleGenerator() {
1183 yield 1;
1184 yield 2;
1185 }
1186
1187 async function* asyncGenerator() {
1188 yield await Promise.resolve(1);
1189 }
1190
1191 function* generatorWithParams(start, end) {
1192 for (let i = start; i <= end; i++) {
1193 yield i;
1194 }
1195 }
1196
1197 class TestClass {
1198 *methodGenerator() {
1199 yield "method";
1200 }
1201
1202 async *asyncMethodGenerator() {
1203 yield "async method";
1204 }
1205 }
1206 "#
1207 .unindent();
1208
1209 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1210 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1211 assert_eq!(
1212 outline
1213 .items
1214 .iter()
1215 .map(|item| (item.text.as_str(), item.depth))
1216 .collect::<Vec<_>>(),
1217 &[
1218 ("function normalFunction()", 0),
1219 ("function* simpleGenerator()", 0),
1220 ("async function* asyncGenerator()", 0),
1221 ("function* generatorWithParams( )", 0),
1222 ("class TestClass", 0),
1223 ("*methodGenerator()", 1),
1224 ("async *asyncMethodGenerator()", 1),
1225 ]
1226 );
1227 }
1228
1229 #[gpui::test]
1230 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1231 cx.update(|cx| {
1232 settings::init(cx);
1233 });
1234
1235 let package_json_1 = json!({
1236 "dependencies": {
1237 "mocha": "1.0.0",
1238 "vitest": "1.0.0"
1239 },
1240 "scripts": {
1241 "test": ""
1242 }
1243 })
1244 .to_string();
1245
1246 let package_json_2 = json!({
1247 "devDependencies": {
1248 "vitest": "2.0.0"
1249 },
1250 "scripts": {
1251 "test": ""
1252 }
1253 })
1254 .to_string();
1255
1256 let fs = FakeFs::new(executor);
1257 fs.insert_tree(
1258 path!("/root"),
1259 json!({
1260 "package.json": package_json_1,
1261 "sub": {
1262 "package.json": package_json_2,
1263 "file.js": "",
1264 }
1265 }),
1266 )
1267 .await;
1268
1269 let provider = TypeScriptContextProvider::new(fs.clone());
1270 let package_json_data = cx
1271 .update(|cx| {
1272 provider.combined_package_json_data(
1273 fs.clone(),
1274 path!("/root").as_ref(),
1275 rel_path("sub/file1.js"),
1276 cx,
1277 )
1278 })
1279 .await
1280 .unwrap();
1281 pretty_assertions::assert_eq!(
1282 package_json_data,
1283 PackageJsonData {
1284 jest_package_path: None,
1285 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1286 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1287 jasmine_package_path: None,
1288 bun_package_path: None,
1289 node_package_path: None,
1290 scripts: [
1291 (
1292 Path::new(path!("/root/package.json")).into(),
1293 "test".to_owned()
1294 ),
1295 (
1296 Path::new(path!("/root/sub/package.json")).into(),
1297 "test".to_owned()
1298 )
1299 ]
1300 .into_iter()
1301 .collect(),
1302 package_manager: None,
1303 }
1304 );
1305
1306 let mut task_templates = TaskTemplates::default();
1307 package_json_data.fill_task_templates(&mut task_templates);
1308 let task_templates = task_templates
1309 .0
1310 .into_iter()
1311 .map(|template| (template.label, template.cwd))
1312 .collect::<Vec<_>>();
1313 pretty_assertions::assert_eq!(
1314 task_templates,
1315 [
1316 (
1317 "vitest file test".into(),
1318 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1319 ),
1320 (
1321 "vitest test $ZED_SYMBOL".into(),
1322 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1323 ),
1324 (
1325 "mocha file test".into(),
1326 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1327 ),
1328 (
1329 "mocha test $ZED_SYMBOL".into(),
1330 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1331 ),
1332 (
1333 "root/package.json > test".into(),
1334 Some(path!("/root").into())
1335 ),
1336 (
1337 "sub/package.json > test".into(),
1338 Some(path!("/root/sub").into())
1339 ),
1340 ]
1341 );
1342 }
1343
1344 #[test]
1345 fn test_escaping_name() {
1346 let cases = [
1347 ("plain test name", "plain test name"),
1348 ("test name with $param_name", "test name with (.+?)"),
1349 ("test name with $nested.param.name", "test name with (.+?)"),
1350 ("test name with $#", "test name with (.+?)"),
1351 ("test name with $##", "test name with (.+?)\\#"),
1352 ("test name with %p", "test name with (.+?)"),
1353 ("test name with %s", "test name with (.+?)"),
1354 ("test name with %d", "test name with (.+?)"),
1355 ("test name with %i", "test name with (.+?)"),
1356 ("test name with %f", "test name with (.+?)"),
1357 ("test name with %j", "test name with (.+?)"),
1358 ("test name with %o", "test name with (.+?)"),
1359 ("test name with %#", "test name with (.+?)"),
1360 ("test name with %$", "test name with (.+?)"),
1361 ("test name with %%", "test name with (.+?)"),
1362 ("test name with %q", "test name with %q"),
1363 (
1364 "test name with regex chars .*+?^${}()|[]\\",
1365 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1366 ),
1367 (
1368 "test name with multiple $params and %pretty and %b and (.+?)",
1369 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1370 ),
1371 ];
1372
1373 for (input, expected) in cases {
1374 assert_eq!(replace_test_name_parameters(input), expected);
1375 }
1376 }
1377
1378 // The order of test runner tasks is based on inferred user preference:
1379 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1380 // 2. Bun's built-in test runner (`bun test`) comes next.
1381 // 3. Node.js's built-in test runner (`node --test`) is last.
1382 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1383 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1384 // typically preferred over `node --test` when @types/bun is present.
1385 #[gpui::test]
1386 async fn test_task_ordering_with_multiple_test_runners(
1387 executor: BackgroundExecutor,
1388 cx: &mut TestAppContext,
1389 ) {
1390 cx.update(|cx| {
1391 settings::init(cx);
1392 });
1393
1394 // Test case with all test runners present
1395 let package_json_all_runners = json!({
1396 "devDependencies": {
1397 "@types/bun": "1.0.0",
1398 "@types/node": "^20.0.0",
1399 "jest": "29.0.0",
1400 "mocha": "10.0.0",
1401 "vitest": "1.0.0",
1402 "jasmine": "5.0.0",
1403 },
1404 "scripts": {
1405 "test": "jest"
1406 }
1407 })
1408 .to_string();
1409
1410 let fs = FakeFs::new(executor);
1411 fs.insert_tree(
1412 path!("/root"),
1413 json!({
1414 "package.json": package_json_all_runners,
1415 "file.js": "",
1416 }),
1417 )
1418 .await;
1419
1420 let provider = TypeScriptContextProvider::new(fs.clone());
1421
1422 let package_json_data = cx
1423 .update(|cx| {
1424 provider.combined_package_json_data(
1425 fs.clone(),
1426 path!("/root").as_ref(),
1427 rel_path("file.js"),
1428 cx,
1429 )
1430 })
1431 .await
1432 .unwrap();
1433
1434 assert!(package_json_data.jest_package_path.is_some());
1435 assert!(package_json_data.mocha_package_path.is_some());
1436 assert!(package_json_data.vitest_package_path.is_some());
1437 assert!(package_json_data.jasmine_package_path.is_some());
1438 assert!(package_json_data.bun_package_path.is_some());
1439 assert!(package_json_data.node_package_path.is_some());
1440
1441 let mut task_templates = TaskTemplates::default();
1442 package_json_data.fill_task_templates(&mut task_templates);
1443
1444 let test_tasks: Vec<_> = task_templates
1445 .0
1446 .iter()
1447 .filter(|template| {
1448 template.tags.contains(&"ts-test".to_owned())
1449 || template.tags.contains(&"js-test".to_owned())
1450 })
1451 .map(|template| &template.label)
1452 .collect();
1453
1454 let node_test_index = test_tasks
1455 .iter()
1456 .position(|label| label.contains("node test"));
1457 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1458 let bun_test_index = test_tasks
1459 .iter()
1460 .position(|label| label.contains("bun test"));
1461
1462 assert!(
1463 node_test_index.is_some(),
1464 "Node test tasks should be present"
1465 );
1466 assert!(
1467 jest_test_index.is_some(),
1468 "Jest test tasks should be present"
1469 );
1470 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1471
1472 assert!(
1473 jest_test_index.unwrap() < bun_test_index.unwrap(),
1474 "Jest should come before Bun"
1475 );
1476 assert!(
1477 bun_test_index.unwrap() < node_test_index.unwrap(),
1478 "Bun should come before Node"
1479 );
1480 }
1481}