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 _: &mut AsyncApp,
808 ) -> Result<Option<serde_json::Value>> {
809 let tsdk_path = self.tsdk_path(adapter).await;
810 Ok(Some(json!({
811 "provideFormatter": true,
812 "hostInfo": "zed",
813 "tsserver": {
814 "path": tsdk_path,
815 },
816 "preferences": {
817 "includeInlayParameterNameHints": "all",
818 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
819 "includeInlayFunctionParameterTypeHints": true,
820 "includeInlayVariableTypeHints": true,
821 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
822 "includeInlayPropertyDeclarationTypeHints": true,
823 "includeInlayFunctionLikeReturnTypeHints": true,
824 "includeInlayEnumMemberValueHints": true,
825 }
826 })))
827 }
828
829 async fn workspace_configuration(
830 self: Arc<Self>,
831 delegate: &Arc<dyn LspAdapterDelegate>,
832 _: Option<Toolchain>,
833 _: Option<Uri>,
834 cx: &mut AsyncApp,
835 ) -> Result<Value> {
836 let override_options = cx.update(|cx| {
837 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
838 .and_then(|s| s.settings.clone())
839 });
840 if let Some(options) = override_options {
841 return Ok(options);
842 }
843 Ok(json!({
844 "completions": {
845 "completeFunctionCalls": true
846 }
847 }))
848 }
849
850 fn language_ids(&self) -> HashMap<LanguageName, String> {
851 HashMap::from_iter([
852 (LanguageName::new_static("TypeScript"), "typescript".into()),
853 (LanguageName::new_static("JavaScript"), "javascript".into()),
854 (LanguageName::new_static("TSX"), "typescriptreact".into()),
855 ])
856 }
857}
858
859async fn get_cached_ts_server_binary(
860 container_dir: PathBuf,
861 node: &NodeRuntime,
862) -> Option<LanguageServerBinary> {
863 maybe!(async {
864 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
865 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
866 if new_server_path.exists() {
867 Ok(LanguageServerBinary {
868 path: node.binary_path().await?,
869 env: None,
870 arguments: typescript_server_binary_arguments(&new_server_path),
871 })
872 } else if old_server_path.exists() {
873 Ok(LanguageServerBinary {
874 path: node.binary_path().await?,
875 env: None,
876 arguments: typescript_server_binary_arguments(&old_server_path),
877 })
878 } else {
879 anyhow::bail!("missing executable in directory {container_dir:?}")
880 }
881 })
882 .await
883 .log_err()
884}
885
886#[cfg(test)]
887mod tests {
888 use std::path::Path;
889
890 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
891 use project::FakeFs;
892 use serde_json::json;
893 use task::TaskTemplates;
894 use unindent::Unindent;
895 use util::{path, rel_path::rel_path};
896
897 use crate::typescript::{
898 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
899 };
900
901 #[gpui::test]
902 async fn test_outline(cx: &mut TestAppContext) {
903 for language in [
904 crate::language(
905 "typescript",
906 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
907 ),
908 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
909 ] {
910 let text = r#"
911 function a() {
912 // local variables are included
913 let a1 = 1;
914 // all functions are included
915 async function a2() {}
916 }
917 // top-level variables are included
918 let b: C
919 function getB() {}
920 // exported variables are included
921 export const d = e;
922 "#
923 .unindent();
924
925 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
926 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
927 assert_eq!(
928 outline
929 .items
930 .iter()
931 .map(|item| (item.text.as_str(), item.depth))
932 .collect::<Vec<_>>(),
933 &[
934 ("function a()", 0),
935 ("let a1", 1),
936 ("async function a2()", 1),
937 ("let b", 0),
938 ("function getB()", 0),
939 ("const d", 0),
940 ]
941 );
942 }
943 }
944
945 #[gpui::test]
946 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
947 for language in [
948 crate::language(
949 "typescript",
950 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
951 ),
952 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
953 ] {
954 let text = r#"
955 // Top-level destructuring
956 const { a1, a2 } = a;
957 const [b1, b2] = b;
958
959 // Defaults and rest
960 const [c1 = 1, , c2, ...rest1] = c;
961 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
962
963 function processData() {
964 // Nested object destructuring
965 const { c1, c2 } = c;
966 // Nested array destructuring
967 const [d1, d2, d3] = d;
968 // Destructuring with renaming
969 const { f1: g1 } = f;
970 // With defaults
971 const [x = 10, y] = xy;
972 }
973
974 class DataHandler {
975 method() {
976 // Destructuring in class method
977 const { a1, a2 } = a;
978 const [b1, ...b2] = b;
979 }
980 }
981 "#
982 .unindent();
983
984 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
985 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
986 assert_eq!(
987 outline
988 .items
989 .iter()
990 .map(|item| (item.text.as_str(), item.depth))
991 .collect::<Vec<_>>(),
992 &[
993 ("const a1", 0),
994 ("const a2", 0),
995 ("const b1", 0),
996 ("const b2", 0),
997 ("const c1", 0),
998 ("const c2", 0),
999 ("const rest1", 0),
1000 ("const d1", 0),
1001 ("const e1", 0),
1002 ("const h1", 0),
1003 ("const rest2", 0),
1004 ("function processData()", 0),
1005 ("const c1", 1),
1006 ("const c2", 1),
1007 ("const d1", 1),
1008 ("const d2", 1),
1009 ("const d3", 1),
1010 ("const g1", 1),
1011 ("const x", 1),
1012 ("const y", 1),
1013 ("class DataHandler", 0),
1014 ("method()", 1),
1015 ("const a1", 2),
1016 ("const a2", 2),
1017 ("const b1", 2),
1018 ("const b2", 2),
1019 ]
1020 );
1021 }
1022 }
1023
1024 #[gpui::test]
1025 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1026 for language in [
1027 crate::language(
1028 "typescript",
1029 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1030 ),
1031 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1032 ] {
1033 let text = r#"
1034 // Object with function properties
1035 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1036
1037 // Object with primitive properties
1038 const p = { p1: 1, p2: "hello", p3: true };
1039
1040 // Nested objects
1041 const q = {
1042 r: {
1043 // won't be included due to one-level depth limit
1044 s: 1
1045 },
1046 t: 2
1047 };
1048
1049 function getData() {
1050 const local = { x: 1, y: 2 };
1051 return local;
1052 }
1053 "#
1054 .unindent();
1055
1056 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1057 cx.run_until_parked();
1058 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1059 assert_eq!(
1060 outline
1061 .items
1062 .iter()
1063 .map(|item| (item.text.as_str(), item.depth))
1064 .collect::<Vec<_>>(),
1065 &[
1066 ("const o", 0),
1067 ("m()", 1),
1068 ("async n()", 1),
1069 ("g", 1),
1070 ("h", 1),
1071 ("k", 1),
1072 ("const p", 0),
1073 ("p1", 1),
1074 ("p2", 1),
1075 ("p3", 1),
1076 ("const q", 0),
1077 ("r", 1),
1078 ("s", 2),
1079 ("t", 1),
1080 ("function getData()", 0),
1081 ("const local", 1),
1082 ("x", 2),
1083 ("y", 2),
1084 ]
1085 );
1086 }
1087 }
1088
1089 #[gpui::test]
1090 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1091 for language in [
1092 crate::language(
1093 "typescript",
1094 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1095 ),
1096 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1097 ] {
1098 let text = r#"
1099 // Symbols as object keys
1100 const sym = Symbol("test");
1101 const obj1 = {
1102 [sym]: 1,
1103 [Symbol("inline")]: 2,
1104 normalKey: 3
1105 };
1106
1107 // Enums as object keys
1108 enum Color { Red, Blue, Green }
1109
1110 const obj2 = {
1111 [Color.Red]: "red value",
1112 [Color.Blue]: "blue value",
1113 regularProp: "normal"
1114 };
1115
1116 // Mixed computed properties
1117 const key = "dynamic";
1118 const obj3 = {
1119 [key]: 1,
1120 ["string" + "concat"]: 2,
1121 [1 + 1]: 3,
1122 static: 4
1123 };
1124
1125 // Nested objects with computed properties
1126 const obj4 = {
1127 [sym]: {
1128 nested: 1
1129 },
1130 regular: {
1131 [key]: 2
1132 }
1133 };
1134 "#
1135 .unindent();
1136
1137 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1138 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1139 assert_eq!(
1140 outline
1141 .items
1142 .iter()
1143 .map(|item| (item.text.as_str(), item.depth))
1144 .collect::<Vec<_>>(),
1145 &[
1146 ("const sym", 0),
1147 ("const obj1", 0),
1148 ("[sym]", 1),
1149 ("[Symbol(\"inline\")]", 1),
1150 ("normalKey", 1),
1151 ("enum Color", 0),
1152 ("const obj2", 0),
1153 ("[Color.Red]", 1),
1154 ("[Color.Blue]", 1),
1155 ("regularProp", 1),
1156 ("const key", 0),
1157 ("const obj3", 0),
1158 ("[key]", 1),
1159 ("[\"string\" + \"concat\"]", 1),
1160 ("[1 + 1]", 1),
1161 ("static", 1),
1162 ("const obj4", 0),
1163 ("[sym]", 1),
1164 ("nested", 2),
1165 ("regular", 1),
1166 ("[key]", 2),
1167 ]
1168 );
1169 }
1170 }
1171
1172 #[gpui::test]
1173 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1174 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1175
1176 let text = r#"
1177 function normalFunction() {
1178 console.log("normal");
1179 }
1180
1181 function* simpleGenerator() {
1182 yield 1;
1183 yield 2;
1184 }
1185
1186 async function* asyncGenerator() {
1187 yield await Promise.resolve(1);
1188 }
1189
1190 function* generatorWithParams(start, end) {
1191 for (let i = start; i <= end; i++) {
1192 yield i;
1193 }
1194 }
1195
1196 class TestClass {
1197 *methodGenerator() {
1198 yield "method";
1199 }
1200
1201 async *asyncMethodGenerator() {
1202 yield "async method";
1203 }
1204 }
1205 "#
1206 .unindent();
1207
1208 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1209 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1210 assert_eq!(
1211 outline
1212 .items
1213 .iter()
1214 .map(|item| (item.text.as_str(), item.depth))
1215 .collect::<Vec<_>>(),
1216 &[
1217 ("function normalFunction()", 0),
1218 ("function* simpleGenerator()", 0),
1219 ("async function* asyncGenerator()", 0),
1220 ("function* generatorWithParams( )", 0),
1221 ("class TestClass", 0),
1222 ("*methodGenerator()", 1),
1223 ("async *asyncMethodGenerator()", 1),
1224 ]
1225 );
1226 }
1227
1228 #[gpui::test]
1229 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1230 cx.update(|cx| {
1231 settings::init(cx);
1232 });
1233
1234 let package_json_1 = json!({
1235 "dependencies": {
1236 "mocha": "1.0.0",
1237 "vitest": "1.0.0"
1238 },
1239 "scripts": {
1240 "test": ""
1241 }
1242 })
1243 .to_string();
1244
1245 let package_json_2 = json!({
1246 "devDependencies": {
1247 "vitest": "2.0.0"
1248 },
1249 "scripts": {
1250 "test": ""
1251 }
1252 })
1253 .to_string();
1254
1255 let fs = FakeFs::new(executor);
1256 fs.insert_tree(
1257 path!("/root"),
1258 json!({
1259 "package.json": package_json_1,
1260 "sub": {
1261 "package.json": package_json_2,
1262 "file.js": "",
1263 }
1264 }),
1265 )
1266 .await;
1267
1268 let provider = TypeScriptContextProvider::new(fs.clone());
1269 let package_json_data = cx
1270 .update(|cx| {
1271 provider.combined_package_json_data(
1272 fs.clone(),
1273 path!("/root").as_ref(),
1274 rel_path("sub/file1.js"),
1275 cx,
1276 )
1277 })
1278 .await
1279 .unwrap();
1280 pretty_assertions::assert_eq!(
1281 package_json_data,
1282 PackageJsonData {
1283 jest_package_path: None,
1284 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1285 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1286 jasmine_package_path: None,
1287 bun_package_path: None,
1288 node_package_path: None,
1289 scripts: [
1290 (
1291 Path::new(path!("/root/package.json")).into(),
1292 "test".to_owned()
1293 ),
1294 (
1295 Path::new(path!("/root/sub/package.json")).into(),
1296 "test".to_owned()
1297 )
1298 ]
1299 .into_iter()
1300 .collect(),
1301 package_manager: None,
1302 }
1303 );
1304
1305 let mut task_templates = TaskTemplates::default();
1306 package_json_data.fill_task_templates(&mut task_templates);
1307 let task_templates = task_templates
1308 .0
1309 .into_iter()
1310 .map(|template| (template.label, template.cwd))
1311 .collect::<Vec<_>>();
1312 pretty_assertions::assert_eq!(
1313 task_templates,
1314 [
1315 (
1316 "vitest file test".into(),
1317 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1318 ),
1319 (
1320 "vitest test $ZED_SYMBOL".into(),
1321 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1322 ),
1323 (
1324 "mocha file test".into(),
1325 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1326 ),
1327 (
1328 "mocha test $ZED_SYMBOL".into(),
1329 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1330 ),
1331 (
1332 "root/package.json > test".into(),
1333 Some(path!("/root").into())
1334 ),
1335 (
1336 "sub/package.json > test".into(),
1337 Some(path!("/root/sub").into())
1338 ),
1339 ]
1340 );
1341 }
1342
1343 #[test]
1344 fn test_escaping_name() {
1345 let cases = [
1346 ("plain test name", "plain test name"),
1347 ("test name with $param_name", "test name with (.+?)"),
1348 ("test name with $nested.param.name", "test name with (.+?)"),
1349 ("test name with $#", "test name with (.+?)"),
1350 ("test name with $##", "test name with (.+?)\\#"),
1351 ("test name with %p", "test name with (.+?)"),
1352 ("test name with %s", "test name with (.+?)"),
1353 ("test name with %d", "test name with (.+?)"),
1354 ("test name with %i", "test name with (.+?)"),
1355 ("test name with %f", "test name with (.+?)"),
1356 ("test name with %j", "test name with (.+?)"),
1357 ("test name with %o", "test name with (.+?)"),
1358 ("test name with %#", "test name with (.+?)"),
1359 ("test name with %$", "test name with (.+?)"),
1360 ("test name with %%", "test name with (.+?)"),
1361 ("test name with %q", "test name with %q"),
1362 (
1363 "test name with regex chars .*+?^${}()|[]\\",
1364 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1365 ),
1366 (
1367 "test name with multiple $params and %pretty and %b and (.+?)",
1368 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1369 ),
1370 ];
1371
1372 for (input, expected) in cases {
1373 assert_eq!(replace_test_name_parameters(input), expected);
1374 }
1375 }
1376
1377 // The order of test runner tasks is based on inferred user preference:
1378 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1379 // 2. Bun's built-in test runner (`bun test`) comes next.
1380 // 3. Node.js's built-in test runner (`node --test`) is last.
1381 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1382 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1383 // typically preferred over `node --test` when @types/bun is present.
1384 #[gpui::test]
1385 async fn test_task_ordering_with_multiple_test_runners(
1386 executor: BackgroundExecutor,
1387 cx: &mut TestAppContext,
1388 ) {
1389 cx.update(|cx| {
1390 settings::init(cx);
1391 });
1392
1393 // Test case with all test runners present
1394 let package_json_all_runners = json!({
1395 "devDependencies": {
1396 "@types/bun": "1.0.0",
1397 "@types/node": "^20.0.0",
1398 "jest": "29.0.0",
1399 "mocha": "10.0.0",
1400 "vitest": "1.0.0",
1401 "jasmine": "5.0.0",
1402 },
1403 "scripts": {
1404 "test": "jest"
1405 }
1406 })
1407 .to_string();
1408
1409 let fs = FakeFs::new(executor);
1410 fs.insert_tree(
1411 path!("/root"),
1412 json!({
1413 "package.json": package_json_all_runners,
1414 "file.js": "",
1415 }),
1416 )
1417 .await;
1418
1419 let provider = TypeScriptContextProvider::new(fs.clone());
1420
1421 let package_json_data = cx
1422 .update(|cx| {
1423 provider.combined_package_json_data(
1424 fs.clone(),
1425 path!("/root").as_ref(),
1426 rel_path("file.js"),
1427 cx,
1428 )
1429 })
1430 .await
1431 .unwrap();
1432
1433 assert!(package_json_data.jest_package_path.is_some());
1434 assert!(package_json_data.mocha_package_path.is_some());
1435 assert!(package_json_data.vitest_package_path.is_some());
1436 assert!(package_json_data.jasmine_package_path.is_some());
1437 assert!(package_json_data.bun_package_path.is_some());
1438 assert!(package_json_data.node_package_path.is_some());
1439
1440 let mut task_templates = TaskTemplates::default();
1441 package_json_data.fill_task_templates(&mut task_templates);
1442
1443 let test_tasks: Vec<_> = task_templates
1444 .0
1445 .iter()
1446 .filter(|template| {
1447 template.tags.contains(&"ts-test".to_owned())
1448 || template.tags.contains(&"js-test".to_owned())
1449 })
1450 .map(|template| &template.label)
1451 .collect();
1452
1453 let node_test_index = test_tasks
1454 .iter()
1455 .position(|label| label.contains("node test"));
1456 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1457 let bun_test_index = test_tasks
1458 .iter()
1459 .position(|label| label.contains("bun test"));
1460
1461 assert!(
1462 node_test_index.is_some(),
1463 "Node test tasks should be present"
1464 );
1465 assert!(
1466 jest_test_index.is_some(),
1467 "Jest test tasks should be present"
1468 );
1469 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1470
1471 assert!(
1472 jest_test_index.unwrap() < bun_test_index.unwrap(),
1473 "Jest should come before Bun"
1474 );
1475 assert!(
1476 bun_test_index.unwrap() < node_test_index.unwrap(),
1477 "Bun should come before Node"
1478 );
1479 }
1480}