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