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 itertools::Itertools as _;
8use language::{
9 ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
10 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 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 vars.insert(
495 TYPESCRIPT_BUN_TEST_NAME_VARIABLE,
496 replace_test_name_parameters(symbol),
497 );
498 }
499 let file_path = location
500 .file_location
501 .buffer
502 .read(cx)
503 .file()
504 .map(|file| file.path());
505
506 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
507 |((worktree_root, fs), file_path)| {
508 (
509 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
510 worktree_root,
511 fs,
512 )
513 },
514 );
515 cx.background_spawn(async move {
516 if let Some((task, worktree_root, fs)) = args {
517 let package_json_data = task.await.log_err();
518 vars.insert(
519 TYPESCRIPT_RUNNER_VARIABLE,
520 detect_package_manager(worktree_root, fs, package_json_data.clone())
521 .await
522 .to_owned(),
523 );
524
525 if let Some(package_json_data) = package_json_data {
526 if let Some(path) = package_json_data.jest_package_path {
527 vars.insert(
528 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
529 path.parent()
530 .unwrap_or(Path::new(""))
531 .to_string_lossy()
532 .to_string(),
533 );
534 }
535
536 if let Some(path) = package_json_data.mocha_package_path {
537 vars.insert(
538 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
539 path.parent()
540 .unwrap_or(Path::new(""))
541 .to_string_lossy()
542 .to_string(),
543 );
544 }
545
546 if let Some(path) = package_json_data.vitest_package_path {
547 vars.insert(
548 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
549 path.parent()
550 .unwrap_or(Path::new(""))
551 .to_string_lossy()
552 .to_string(),
553 );
554 }
555
556 if let Some(path) = package_json_data.jasmine_package_path {
557 vars.insert(
558 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
559 path.parent()
560 .unwrap_or(Path::new(""))
561 .to_string_lossy()
562 .to_string(),
563 );
564 }
565
566 if let Some(path) = package_json_data.bun_package_path {
567 vars.insert(
568 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
569 path.parent()
570 .unwrap_or(Path::new(""))
571 .to_string_lossy()
572 .to_string(),
573 );
574 }
575
576 if let Some(path) = package_json_data.node_package_path {
577 vars.insert(
578 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
579 path.parent()
580 .unwrap_or(Path::new(""))
581 .to_string_lossy()
582 .to_string(),
583 );
584 }
585 }
586 }
587 Ok(vars)
588 })
589 }
590}
591
592fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
593 vec![server_path.into(), "--stdio".into()]
594}
595
596fn replace_test_name_parameters(test_name: &str) -> String {
597 static PATTERN: LazyLock<regex::Regex> =
598 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
599 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
600}
601
602pub struct TypeScriptLspAdapter {
603 fs: Arc<dyn Fs>,
604 node: NodeRuntime,
605}
606
607impl TypeScriptLspAdapter {
608 const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
609 const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
610
611 const PACKAGE_NAME: &str = "typescript";
612 const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
613
614 const SERVER_NAME: LanguageServerName =
615 LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
616
617 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
618 TypeScriptLspAdapter { fs, node }
619 }
620
621 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
622 let is_yarn = adapter
623 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
624 .await
625 .is_ok();
626
627 let tsdk_path = if is_yarn {
628 ".yarn/sdks/typescript/lib"
629 } else {
630 "node_modules/typescript/lib"
631 };
632
633 if self
634 .fs
635 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
636 .await
637 {
638 Some(tsdk_path)
639 } else {
640 None
641 }
642 }
643}
644
645pub struct TypeScriptVersions {
646 typescript_version: Version,
647 server_version: Version,
648}
649
650impl LspInstaller for TypeScriptLspAdapter {
651 type BinaryVersion = TypeScriptVersions;
652
653 async fn fetch_latest_server_version(
654 &self,
655 _: &dyn LspAdapterDelegate,
656 _: bool,
657 _: &mut AsyncApp,
658 ) -> Result<Self::BinaryVersion> {
659 Ok(TypeScriptVersions {
660 typescript_version: self
661 .node
662 .npm_package_latest_version(Self::PACKAGE_NAME)
663 .await?,
664 server_version: self
665 .node
666 .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
667 .await?,
668 })
669 }
670
671 async fn check_if_version_installed(
672 &self,
673 version: &Self::BinaryVersion,
674 container_dir: &PathBuf,
675 _: &dyn LspAdapterDelegate,
676 ) -> Option<LanguageServerBinary> {
677 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
678
679 if self
680 .node
681 .should_install_npm_package(
682 Self::PACKAGE_NAME,
683 &server_path,
684 container_dir,
685 VersionStrategy::Latest(&version.typescript_version),
686 )
687 .await
688 {
689 return None;
690 }
691
692 if self
693 .node
694 .should_install_npm_package(
695 Self::SERVER_PACKAGE_NAME,
696 &server_path,
697 container_dir,
698 VersionStrategy::Latest(&version.server_version),
699 )
700 .await
701 {
702 return None;
703 }
704
705 Some(LanguageServerBinary {
706 path: self.node.binary_path().await.ok()?,
707 env: None,
708 arguments: typescript_server_binary_arguments(&server_path),
709 })
710 }
711
712 async fn fetch_server_binary(
713 &self,
714 latest_version: Self::BinaryVersion,
715 container_dir: PathBuf,
716 _: &dyn LspAdapterDelegate,
717 ) -> Result<LanguageServerBinary> {
718 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
719
720 self.node
721 .npm_install_packages(
722 &container_dir,
723 &[
724 (
725 Self::PACKAGE_NAME,
726 &latest_version.typescript_version.to_string(),
727 ),
728 (
729 Self::SERVER_PACKAGE_NAME,
730 &latest_version.server_version.to_string(),
731 ),
732 ],
733 )
734 .await?;
735
736 Ok(LanguageServerBinary {
737 path: self.node.binary_path().await?,
738 env: None,
739 arguments: typescript_server_binary_arguments(&server_path),
740 })
741 }
742
743 async fn cached_server_binary(
744 &self,
745 container_dir: PathBuf,
746 _: &dyn LspAdapterDelegate,
747 ) -> Option<LanguageServerBinary> {
748 get_cached_ts_server_binary(container_dir, &self.node).await
749 }
750}
751
752#[async_trait(?Send)]
753impl LspAdapter for TypeScriptLspAdapter {
754 fn name(&self) -> LanguageServerName {
755 Self::SERVER_NAME
756 }
757
758 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
759 Some(vec![
760 CodeActionKind::QUICKFIX,
761 CodeActionKind::REFACTOR,
762 CodeActionKind::REFACTOR_EXTRACT,
763 CodeActionKind::SOURCE,
764 ])
765 }
766
767 async fn label_for_completion(
768 &self,
769 item: &lsp::CompletionItem,
770 language: &Arc<language::Language>,
771 ) -> Option<language::CodeLabel> {
772 use lsp::CompletionItemKind as Kind;
773 let label_len = item.label.len();
774 let grammar = language.grammar()?;
775 let highlight_id = match item.kind? {
776 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
777 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
778 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
779 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
780 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
781 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
782 _ => None,
783 }?;
784
785 let text = if let Some(description) = item
786 .label_details
787 .as_ref()
788 .and_then(|label_details| label_details.description.as_ref())
789 {
790 format!("{} {}", item.label, description)
791 } else if let Some(detail) = &item.detail {
792 format!("{} {}", item.label, detail)
793 } else {
794 item.label.clone()
795 };
796 Some(language::CodeLabel::filtered(
797 text,
798 label_len,
799 item.filter_text.as_deref(),
800 vec![(0..label_len, highlight_id)],
801 ))
802 }
803
804 async fn initialization_options(
805 self: Arc<Self>,
806 adapter: &Arc<dyn LspAdapterDelegate>,
807 ) -> Result<Option<serde_json::Value>> {
808 let tsdk_path = self.tsdk_path(adapter).await;
809 Ok(Some(json!({
810 "provideFormatter": true,
811 "hostInfo": "zed",
812 "tsserver": {
813 "path": tsdk_path,
814 },
815 "preferences": {
816 "includeInlayParameterNameHints": "all",
817 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
818 "includeInlayFunctionParameterTypeHints": true,
819 "includeInlayVariableTypeHints": true,
820 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
821 "includeInlayPropertyDeclarationTypeHints": true,
822 "includeInlayFunctionLikeReturnTypeHints": true,
823 "includeInlayEnumMemberValueHints": true,
824 }
825 })))
826 }
827
828 async fn workspace_configuration(
829 self: Arc<Self>,
830 delegate: &Arc<dyn LspAdapterDelegate>,
831 _: Option<Toolchain>,
832 _: Option<Uri>,
833 cx: &mut AsyncApp,
834 ) -> Result<Value> {
835 let override_options = cx.update(|cx| {
836 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
837 .and_then(|s| s.settings.clone())
838 });
839 if let Some(options) = override_options {
840 return Ok(options);
841 }
842 Ok(json!({
843 "completions": {
844 "completeFunctionCalls": true
845 }
846 }))
847 }
848
849 fn language_ids(&self) -> HashMap<LanguageName, String> {
850 HashMap::from_iter([
851 (LanguageName::new_static("TypeScript"), "typescript".into()),
852 (LanguageName::new_static("JavaScript"), "javascript".into()),
853 (LanguageName::new_static("TSX"), "typescriptreact".into()),
854 ])
855 }
856}
857
858async fn get_cached_ts_server_binary(
859 container_dir: PathBuf,
860 node: &NodeRuntime,
861) -> Option<LanguageServerBinary> {
862 maybe!(async {
863 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
864 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
865 if new_server_path.exists() {
866 Ok(LanguageServerBinary {
867 path: node.binary_path().await?,
868 env: None,
869 arguments: typescript_server_binary_arguments(&new_server_path),
870 })
871 } else if old_server_path.exists() {
872 Ok(LanguageServerBinary {
873 path: node.binary_path().await?,
874 env: None,
875 arguments: typescript_server_binary_arguments(&old_server_path),
876 })
877 } else {
878 anyhow::bail!("missing executable in directory {container_dir:?}")
879 }
880 })
881 .await
882 .log_err()
883}
884
885#[cfg(test)]
886mod tests {
887 use std::path::Path;
888
889 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
890 use project::FakeFs;
891 use serde_json::json;
892 use task::TaskTemplates;
893 use unindent::Unindent;
894 use util::{path, rel_path::rel_path};
895
896 use crate::typescript::{
897 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
898 };
899
900 #[gpui::test]
901 async fn test_outline(cx: &mut TestAppContext) {
902 for language in [
903 crate::language(
904 "typescript",
905 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
906 ),
907 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
908 ] {
909 let text = r#"
910 function a() {
911 // local variables are included
912 let a1 = 1;
913 // all functions are included
914 async function a2() {}
915 }
916 // top-level variables are included
917 let b: C
918 function getB() {}
919 // exported variables are included
920 export const d = e;
921 "#
922 .unindent();
923
924 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
925 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
926 assert_eq!(
927 outline
928 .items
929 .iter()
930 .map(|item| (item.text.as_str(), item.depth))
931 .collect::<Vec<_>>(),
932 &[
933 ("function a()", 0),
934 ("let a1", 1),
935 ("async function a2()", 1),
936 ("let b", 0),
937 ("function getB()", 0),
938 ("const d", 0),
939 ]
940 );
941 }
942 }
943
944 #[gpui::test]
945 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
946 for language in [
947 crate::language(
948 "typescript",
949 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
950 ),
951 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
952 ] {
953 let text = r#"
954 // Top-level destructuring
955 const { a1, a2 } = a;
956 const [b1, b2] = b;
957
958 // Defaults and rest
959 const [c1 = 1, , c2, ...rest1] = c;
960 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
961
962 function processData() {
963 // Nested object destructuring
964 const { c1, c2 } = c;
965 // Nested array destructuring
966 const [d1, d2, d3] = d;
967 // Destructuring with renaming
968 const { f1: g1 } = f;
969 // With defaults
970 const [x = 10, y] = xy;
971 }
972
973 class DataHandler {
974 method() {
975 // Destructuring in class method
976 const { a1, a2 } = a;
977 const [b1, ...b2] = b;
978 }
979 }
980 "#
981 .unindent();
982
983 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
984 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
985 assert_eq!(
986 outline
987 .items
988 .iter()
989 .map(|item| (item.text.as_str(), item.depth))
990 .collect::<Vec<_>>(),
991 &[
992 ("const a1", 0),
993 ("const a2", 0),
994 ("const b1", 0),
995 ("const b2", 0),
996 ("const c1", 0),
997 ("const c2", 0),
998 ("const rest1", 0),
999 ("const d1", 0),
1000 ("const e1", 0),
1001 ("const h1", 0),
1002 ("const rest2", 0),
1003 ("function processData()", 0),
1004 ("const c1", 1),
1005 ("const c2", 1),
1006 ("const d1", 1),
1007 ("const d2", 1),
1008 ("const d3", 1),
1009 ("const g1", 1),
1010 ("const x", 1),
1011 ("const y", 1),
1012 ("class DataHandler", 0),
1013 ("method()", 1),
1014 ("const a1", 2),
1015 ("const a2", 2),
1016 ("const b1", 2),
1017 ("const b2", 2),
1018 ]
1019 );
1020 }
1021 }
1022
1023 #[gpui::test]
1024 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1025 for language in [
1026 crate::language(
1027 "typescript",
1028 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1029 ),
1030 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1031 ] {
1032 let text = r#"
1033 // Object with function properties
1034 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1035
1036 // Object with primitive properties
1037 const p = { p1: 1, p2: "hello", p3: true };
1038
1039 // Nested objects
1040 const q = {
1041 r: {
1042 // won't be included due to one-level depth limit
1043 s: 1
1044 },
1045 t: 2
1046 };
1047
1048 function getData() {
1049 const local = { x: 1, y: 2 };
1050 return local;
1051 }
1052 "#
1053 .unindent();
1054
1055 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1056 cx.run_until_parked();
1057 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1058 assert_eq!(
1059 outline
1060 .items
1061 .iter()
1062 .map(|item| (item.text.as_str(), item.depth))
1063 .collect::<Vec<_>>(),
1064 &[
1065 ("const o", 0),
1066 ("m()", 1),
1067 ("async n()", 1),
1068 ("g", 1),
1069 ("h", 1),
1070 ("k", 1),
1071 ("const p", 0),
1072 ("p1", 1),
1073 ("p2", 1),
1074 ("p3", 1),
1075 ("const q", 0),
1076 ("r", 1),
1077 ("s", 2),
1078 ("t", 1),
1079 ("function getData()", 0),
1080 ("const local", 1),
1081 ("x", 2),
1082 ("y", 2),
1083 ]
1084 );
1085 }
1086 }
1087
1088 #[gpui::test]
1089 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1090 for language in [
1091 crate::language(
1092 "typescript",
1093 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1094 ),
1095 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1096 ] {
1097 let text = r#"
1098 // Symbols as object keys
1099 const sym = Symbol("test");
1100 const obj1 = {
1101 [sym]: 1,
1102 [Symbol("inline")]: 2,
1103 normalKey: 3
1104 };
1105
1106 // Enums as object keys
1107 enum Color { Red, Blue, Green }
1108
1109 const obj2 = {
1110 [Color.Red]: "red value",
1111 [Color.Blue]: "blue value",
1112 regularProp: "normal"
1113 };
1114
1115 // Mixed computed properties
1116 const key = "dynamic";
1117 const obj3 = {
1118 [key]: 1,
1119 ["string" + "concat"]: 2,
1120 [1 + 1]: 3,
1121 static: 4
1122 };
1123
1124 // Nested objects with computed properties
1125 const obj4 = {
1126 [sym]: {
1127 nested: 1
1128 },
1129 regular: {
1130 [key]: 2
1131 }
1132 };
1133 "#
1134 .unindent();
1135
1136 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1137 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1138 assert_eq!(
1139 outline
1140 .items
1141 .iter()
1142 .map(|item| (item.text.as_str(), item.depth))
1143 .collect::<Vec<_>>(),
1144 &[
1145 ("const sym", 0),
1146 ("const obj1", 0),
1147 ("[sym]", 1),
1148 ("[Symbol(\"inline\")]", 1),
1149 ("normalKey", 1),
1150 ("enum Color", 0),
1151 ("const obj2", 0),
1152 ("[Color.Red]", 1),
1153 ("[Color.Blue]", 1),
1154 ("regularProp", 1),
1155 ("const key", 0),
1156 ("const obj3", 0),
1157 ("[key]", 1),
1158 ("[\"string\" + \"concat\"]", 1),
1159 ("[1 + 1]", 1),
1160 ("static", 1),
1161 ("const obj4", 0),
1162 ("[sym]", 1),
1163 ("nested", 2),
1164 ("regular", 1),
1165 ("[key]", 2),
1166 ]
1167 );
1168 }
1169 }
1170
1171 #[gpui::test]
1172 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1173 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1174
1175 let text = r#"
1176 function normalFunction() {
1177 console.log("normal");
1178 }
1179
1180 function* simpleGenerator() {
1181 yield 1;
1182 yield 2;
1183 }
1184
1185 async function* asyncGenerator() {
1186 yield await Promise.resolve(1);
1187 }
1188
1189 function* generatorWithParams(start, end) {
1190 for (let i = start; i <= end; i++) {
1191 yield i;
1192 }
1193 }
1194
1195 class TestClass {
1196 *methodGenerator() {
1197 yield "method";
1198 }
1199
1200 async *asyncMethodGenerator() {
1201 yield "async method";
1202 }
1203 }
1204 "#
1205 .unindent();
1206
1207 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1208 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1209 assert_eq!(
1210 outline
1211 .items
1212 .iter()
1213 .map(|item| (item.text.as_str(), item.depth))
1214 .collect::<Vec<_>>(),
1215 &[
1216 ("function normalFunction()", 0),
1217 ("function* simpleGenerator()", 0),
1218 ("async function* asyncGenerator()", 0),
1219 ("function* generatorWithParams( )", 0),
1220 ("class TestClass", 0),
1221 ("*methodGenerator()", 1),
1222 ("async *asyncMethodGenerator()", 1),
1223 ]
1224 );
1225 }
1226
1227 #[gpui::test]
1228 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1229 cx.update(|cx| {
1230 settings::init(cx);
1231 });
1232
1233 let package_json_1 = json!({
1234 "dependencies": {
1235 "mocha": "1.0.0",
1236 "vitest": "1.0.0"
1237 },
1238 "scripts": {
1239 "test": ""
1240 }
1241 })
1242 .to_string();
1243
1244 let package_json_2 = json!({
1245 "devDependencies": {
1246 "vitest": "2.0.0"
1247 },
1248 "scripts": {
1249 "test": ""
1250 }
1251 })
1252 .to_string();
1253
1254 let fs = FakeFs::new(executor);
1255 fs.insert_tree(
1256 path!("/root"),
1257 json!({
1258 "package.json": package_json_1,
1259 "sub": {
1260 "package.json": package_json_2,
1261 "file.js": "",
1262 }
1263 }),
1264 )
1265 .await;
1266
1267 let provider = TypeScriptContextProvider::new(fs.clone());
1268 let package_json_data = cx
1269 .update(|cx| {
1270 provider.combined_package_json_data(
1271 fs.clone(),
1272 path!("/root").as_ref(),
1273 rel_path("sub/file1.js"),
1274 cx,
1275 )
1276 })
1277 .await
1278 .unwrap();
1279 pretty_assertions::assert_eq!(
1280 package_json_data,
1281 PackageJsonData {
1282 jest_package_path: None,
1283 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1284 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1285 jasmine_package_path: None,
1286 bun_package_path: None,
1287 node_package_path: None,
1288 scripts: [
1289 (
1290 Path::new(path!("/root/package.json")).into(),
1291 "test".to_owned()
1292 ),
1293 (
1294 Path::new(path!("/root/sub/package.json")).into(),
1295 "test".to_owned()
1296 )
1297 ]
1298 .into_iter()
1299 .collect(),
1300 package_manager: None,
1301 }
1302 );
1303
1304 let mut task_templates = TaskTemplates::default();
1305 package_json_data.fill_task_templates(&mut task_templates);
1306 let task_templates = task_templates
1307 .0
1308 .into_iter()
1309 .map(|template| (template.label, template.cwd))
1310 .collect::<Vec<_>>();
1311 pretty_assertions::assert_eq!(
1312 task_templates,
1313 [
1314 (
1315 "vitest file test".into(),
1316 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1317 ),
1318 (
1319 "vitest test $ZED_SYMBOL".into(),
1320 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1321 ),
1322 (
1323 "mocha file test".into(),
1324 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1325 ),
1326 (
1327 "mocha test $ZED_SYMBOL".into(),
1328 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1329 ),
1330 (
1331 "root/package.json > test".into(),
1332 Some(path!("/root").into())
1333 ),
1334 (
1335 "sub/package.json > test".into(),
1336 Some(path!("/root/sub").into())
1337 ),
1338 ]
1339 );
1340 }
1341
1342 #[test]
1343 fn test_escaping_name() {
1344 let cases = [
1345 ("plain test name", "plain test name"),
1346 ("test name with $param_name", "test name with (.+?)"),
1347 ("test name with $nested.param.name", "test name with (.+?)"),
1348 ("test name with $#", "test name with (.+?)"),
1349 ("test name with $##", "test name with (.+?)\\#"),
1350 ("test name with %p", "test name with (.+?)"),
1351 ("test name with %s", "test name with (.+?)"),
1352 ("test name with %d", "test name with (.+?)"),
1353 ("test name with %i", "test name with (.+?)"),
1354 ("test name with %f", "test name with (.+?)"),
1355 ("test name with %j", "test name with (.+?)"),
1356 ("test name with %o", "test name with (.+?)"),
1357 ("test name with %#", "test name with (.+?)"),
1358 ("test name with %$", "test name with (.+?)"),
1359 ("test name with %%", "test name with (.+?)"),
1360 ("test name with %q", "test name with %q"),
1361 (
1362 "test name with regex chars .*+?^${}()|[]\\",
1363 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1364 ),
1365 (
1366 "test name with multiple $params and %pretty and %b and (.+?)",
1367 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1368 ),
1369 ];
1370
1371 for (input, expected) in cases {
1372 assert_eq!(replace_test_name_parameters(input), expected);
1373 }
1374 }
1375
1376 // The order of test runner tasks is based on inferred user preference:
1377 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1378 // 2. Bun's built-in test runner (`bun test`) comes next.
1379 // 3. Node.js's built-in test runner (`node --test`) is last.
1380 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1381 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1382 // typically preferred over `node --test` when @types/bun is present.
1383 #[gpui::test]
1384 async fn test_task_ordering_with_multiple_test_runners(
1385 executor: BackgroundExecutor,
1386 cx: &mut TestAppContext,
1387 ) {
1388 cx.update(|cx| {
1389 settings::init(cx);
1390 });
1391
1392 // Test case with all test runners present
1393 let package_json_all_runners = json!({
1394 "devDependencies": {
1395 "@types/bun": "1.0.0",
1396 "@types/node": "^20.0.0",
1397 "jest": "29.0.0",
1398 "mocha": "10.0.0",
1399 "vitest": "1.0.0",
1400 "jasmine": "5.0.0",
1401 },
1402 "scripts": {
1403 "test": "jest"
1404 }
1405 })
1406 .to_string();
1407
1408 let fs = FakeFs::new(executor);
1409 fs.insert_tree(
1410 path!("/root"),
1411 json!({
1412 "package.json": package_json_all_runners,
1413 "file.js": "",
1414 }),
1415 )
1416 .await;
1417
1418 let provider = TypeScriptContextProvider::new(fs.clone());
1419
1420 let package_json_data = cx
1421 .update(|cx| {
1422 provider.combined_package_json_data(
1423 fs.clone(),
1424 path!("/root").as_ref(),
1425 rel_path("file.js"),
1426 cx,
1427 )
1428 })
1429 .await
1430 .unwrap();
1431
1432 assert!(package_json_data.jest_package_path.is_some());
1433 assert!(package_json_data.mocha_package_path.is_some());
1434 assert!(package_json_data.vitest_package_path.is_some());
1435 assert!(package_json_data.jasmine_package_path.is_some());
1436 assert!(package_json_data.bun_package_path.is_some());
1437 assert!(package_json_data.node_package_path.is_some());
1438
1439 let mut task_templates = TaskTemplates::default();
1440 package_json_data.fill_task_templates(&mut task_templates);
1441
1442 let test_tasks: Vec<_> = task_templates
1443 .0
1444 .iter()
1445 .filter(|template| {
1446 template.tags.contains(&"ts-test".to_owned())
1447 || template.tags.contains(&"js-test".to_owned())
1448 })
1449 .map(|template| &template.label)
1450 .collect();
1451
1452 let node_test_index = test_tasks
1453 .iter()
1454 .position(|label| label.contains("node test"));
1455 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1456 let bun_test_index = test_tasks
1457 .iter()
1458 .position(|label| label.contains("bun test"));
1459
1460 assert!(
1461 node_test_index.is_some(),
1462 "Node test tasks should be present"
1463 );
1464 assert!(
1465 jest_test_index.is_some(),
1466 "Jest test tasks should be present"
1467 );
1468 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1469
1470 assert!(
1471 jest_test_index.unwrap() < bun_test_index.unwrap(),
1472 "Jest should come before Bun"
1473 );
1474 assert!(
1475 bun_test_index.unwrap() < node_test_index.unwrap(),
1476 "Bun should come before Node"
1477 );
1478 }
1479}