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 serde_json::{Value, json};
16use smol::lock::RwLock;
17use std::{
18 borrow::Cow,
19 ffi::OsString,
20 path::{Path, PathBuf},
21 sync::{Arc, LazyLock},
22};
23use task::{TaskTemplate, TaskTemplates, VariableName};
24use util::rel_path::RelPath;
25use util::{ResultExt, maybe};
26
27use crate::{PackageJson, PackageJsonData};
28
29pub(crate) struct TypeScriptContextProvider {
30 fs: Arc<dyn Fs>,
31 last_package_json: PackageJsonContents,
32}
33
34const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
35 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
36
37const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
38 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
39
40const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
41 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
42
43const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
44 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
45
46const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
47 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
48
49const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
50 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
51
52const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
53 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
54
55const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
56 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
57
58const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
59 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
60
61#[derive(Clone, Debug, Default)]
62struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
63
64impl PackageJsonData {
65 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
66 if self.jest_package_path.is_some() {
67 task_templates.0.push(TaskTemplate {
68 label: "jest file test".to_owned(),
69 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
70 args: vec![
71 "exec".to_owned(),
72 "--".to_owned(),
73 "jest".to_owned(),
74 "--runInBand".to_owned(),
75 VariableName::File.template_value(),
76 ],
77 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
78 ..TaskTemplate::default()
79 });
80 task_templates.0.push(TaskTemplate {
81 label: format!("jest test {}", VariableName::Symbol.template_value()),
82 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
83 args: vec![
84 "exec".to_owned(),
85 "--".to_owned(),
86 "jest".to_owned(),
87 "--runInBand".to_owned(),
88 "--testNamePattern".to_owned(),
89 format!(
90 "\"{}\"",
91 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
92 ),
93 VariableName::File.template_value(),
94 ],
95 tags: vec![
96 "ts-test".to_owned(),
97 "js-test".to_owned(),
98 "tsx-test".to_owned(),
99 ],
100 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
101 ..TaskTemplate::default()
102 });
103 }
104
105 if self.vitest_package_path.is_some() {
106 task_templates.0.push(TaskTemplate {
107 label: format!("{} file test", "vitest".to_owned()),
108 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
109 args: vec![
110 "exec".to_owned(),
111 "--".to_owned(),
112 "vitest".to_owned(),
113 "run".to_owned(),
114 "--poolOptions.forks.minForks=0".to_owned(),
115 "--poolOptions.forks.maxForks=1".to_owned(),
116 VariableName::File.template_value(),
117 ],
118 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
119 ..TaskTemplate::default()
120 });
121 task_templates.0.push(TaskTemplate {
122 label: format!(
123 "{} test {}",
124 "vitest".to_owned(),
125 VariableName::Symbol.template_value(),
126 ),
127 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
128 args: vec![
129 "exec".to_owned(),
130 "--".to_owned(),
131 "vitest".to_owned(),
132 "run".to_owned(),
133 "--poolOptions.forks.minForks=0".to_owned(),
134 "--poolOptions.forks.maxForks=1".to_owned(),
135 "--testNamePattern".to_owned(),
136 format!(
137 "\"{}\"",
138 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
139 ),
140 VariableName::File.template_value(),
141 ],
142 tags: vec![
143 "ts-test".to_owned(),
144 "js-test".to_owned(),
145 "tsx-test".to_owned(),
146 ],
147 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
148 ..TaskTemplate::default()
149 });
150 }
151
152 if self.mocha_package_path.is_some() {
153 task_templates.0.push(TaskTemplate {
154 label: format!("{} file test", "mocha".to_owned()),
155 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
156 args: vec![
157 "exec".to_owned(),
158 "--".to_owned(),
159 "mocha".to_owned(),
160 VariableName::File.template_value(),
161 ],
162 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
163 ..TaskTemplate::default()
164 });
165 task_templates.0.push(TaskTemplate {
166 label: format!(
167 "{} test {}",
168 "mocha".to_owned(),
169 VariableName::Symbol.template_value(),
170 ),
171 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
172 args: vec![
173 "exec".to_owned(),
174 "--".to_owned(),
175 "mocha".to_owned(),
176 "--grep".to_owned(),
177 format!("\"{}\"", VariableName::Symbol.template_value()),
178 VariableName::File.template_value(),
179 ],
180 tags: vec![
181 "ts-test".to_owned(),
182 "js-test".to_owned(),
183 "tsx-test".to_owned(),
184 ],
185 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
186 ..TaskTemplate::default()
187 });
188 }
189
190 if self.jasmine_package_path.is_some() {
191 task_templates.0.push(TaskTemplate {
192 label: format!("{} file test", "jasmine".to_owned()),
193 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
194 args: vec![
195 "exec".to_owned(),
196 "--".to_owned(),
197 "jasmine".to_owned(),
198 VariableName::File.template_value(),
199 ],
200 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
201 ..TaskTemplate::default()
202 });
203 task_templates.0.push(TaskTemplate {
204 label: format!(
205 "{} test {}",
206 "jasmine".to_owned(),
207 VariableName::Symbol.template_value(),
208 ),
209 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
210 args: vec![
211 "exec".to_owned(),
212 "--".to_owned(),
213 "jasmine".to_owned(),
214 format!("--filter={}", VariableName::Symbol.template_value()),
215 VariableName::File.template_value(),
216 ],
217 tags: vec![
218 "ts-test".to_owned(),
219 "js-test".to_owned(),
220 "tsx-test".to_owned(),
221 ],
222 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
223 ..TaskTemplate::default()
224 });
225 }
226
227 if self.bun_package_path.is_some() {
228 task_templates.0.push(TaskTemplate {
229 label: format!("{} file test", "bun test".to_owned()),
230 command: "bun".to_owned(),
231 args: vec!["test".to_owned(), VariableName::File.template_value()],
232 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
233 ..TaskTemplate::default()
234 });
235 task_templates.0.push(TaskTemplate {
236 label: format!("bun test {}", VariableName::Symbol.template_value(),),
237 command: "bun".to_owned(),
238 args: vec![
239 "test".to_owned(),
240 "--test-name-pattern".to_owned(),
241 format!("\"{}\"", VariableName::Symbol.template_value()),
242 VariableName::File.template_value(),
243 ],
244 tags: vec![
245 "ts-test".to_owned(),
246 "js-test".to_owned(),
247 "tsx-test".to_owned(),
248 ],
249 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
250 ..TaskTemplate::default()
251 });
252 }
253
254 if self.node_package_path.is_some() {
255 task_templates.0.push(TaskTemplate {
256 label: format!("{} file test", "node test".to_owned()),
257 command: "node".to_owned(),
258 args: vec!["--test".to_owned(), VariableName::File.template_value()],
259 tags: vec![
260 "ts-test".to_owned(),
261 "js-test".to_owned(),
262 "tsx-test".to_owned(),
263 ],
264 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
265 ..TaskTemplate::default()
266 });
267 task_templates.0.push(TaskTemplate {
268 label: format!("node test {}", VariableName::Symbol.template_value()),
269 command: "node".to_owned(),
270 args: vec![
271 "--test".to_owned(),
272 "--test-name-pattern".to_owned(),
273 format!("\"{}\"", VariableName::Symbol.template_value()),
274 VariableName::File.template_value(),
275 ],
276 tags: vec![
277 "ts-test".to_owned(),
278 "js-test".to_owned(),
279 "tsx-test".to_owned(),
280 ],
281 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
282 ..TaskTemplate::default()
283 });
284 }
285
286 let script_name_counts: HashMap<_, usize> =
287 self.scripts
288 .iter()
289 .fold(HashMap::default(), |mut acc, (_, script)| {
290 *acc.entry(script).or_default() += 1;
291 acc
292 });
293 for (path, script) in &self.scripts {
294 let label = if script_name_counts.get(script).copied().unwrap_or_default() > 1
295 && let Some(parent) = path.parent().and_then(|parent| parent.file_name())
296 {
297 let parent = parent.to_string_lossy();
298 format!("{parent}/package.json > {script}")
299 } else {
300 format!("package.json > {script}")
301 };
302 task_templates.0.push(TaskTemplate {
303 label,
304 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
305 args: vec!["run".to_owned(), script.to_owned()],
306 tags: vec!["package-script".into()],
307 cwd: Some(
308 path.parent()
309 .unwrap_or(Path::new("/"))
310 .to_string_lossy()
311 .to_string(),
312 ),
313 ..TaskTemplate::default()
314 });
315 }
316 }
317}
318
319impl TypeScriptContextProvider {
320 pub fn new(fs: Arc<dyn Fs>) -> Self {
321 Self {
322 fs,
323 last_package_json: PackageJsonContents::default(),
324 }
325 }
326
327 fn combined_package_json_data(
328 &self,
329 fs: Arc<dyn Fs>,
330 worktree_root: &Path,
331 file_relative_path: &RelPath,
332 cx: &App,
333 ) -> Task<anyhow::Result<PackageJsonData>> {
334 let new_json_data = file_relative_path
335 .ancestors()
336 .map(|path| worktree_root.join(path.as_std_path()))
337 .map(|parent_path| {
338 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
339 })
340 .collect::<Vec<_>>();
341
342 cx.background_spawn(async move {
343 let mut package_json_data = PackageJsonData::default();
344 for new_data in join_all(new_json_data).await.into_iter().flatten() {
345 package_json_data.merge(new_data);
346 }
347 Ok(package_json_data)
348 })
349 }
350
351 fn package_json_data(
352 &self,
353 directory_path: &Path,
354 existing_package_json: PackageJsonContents,
355 fs: Arc<dyn Fs>,
356 cx: &App,
357 ) -> Task<anyhow::Result<PackageJsonData>> {
358 let package_json_path = directory_path.join("package.json");
359 let metadata_check_fs = fs.clone();
360 cx.background_spawn(async move {
361 let metadata = metadata_check_fs
362 .metadata(&package_json_path)
363 .await
364 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
365 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
366 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
367 let existing_data = {
368 let contents = existing_package_json.0.read().await;
369 contents
370 .get(&package_json_path)
371 .filter(|package_json| package_json.mtime == mtime)
372 .map(|package_json| package_json.data.clone())
373 };
374 match existing_data {
375 Some(existing_data) => Ok(existing_data),
376 None => {
377 let package_json_string =
378 fs.load(&package_json_path).await.with_context(|| {
379 format!("loading package.json from {package_json_path:?}")
380 })?;
381 let package_json: HashMap<String, serde_json_lenient::Value> =
382 serde_json_lenient::from_str(&package_json_string).with_context(|| {
383 format!("parsing package.json from {package_json_path:?}")
384 })?;
385 let new_data =
386 PackageJsonData::new(package_json_path.as_path().into(), package_json);
387 {
388 let mut contents = existing_package_json.0.write().await;
389 contents.insert(
390 package_json_path,
391 PackageJson {
392 mtime,
393 data: new_data.clone(),
394 },
395 );
396 }
397 Ok(new_data)
398 }
399 }
400 })
401 }
402}
403
404async fn detect_package_manager(
405 worktree_root: PathBuf,
406 fs: Arc<dyn Fs>,
407 package_json_data: Option<PackageJsonData>,
408) -> &'static str {
409 if let Some(package_json_data) = package_json_data
410 && let Some(package_manager) = package_json_data.package_manager
411 {
412 return package_manager;
413 }
414 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
415 return "pnpm";
416 }
417 if fs.is_file(&worktree_root.join("yarn.lock")).await {
418 return "yarn";
419 }
420 "npm"
421}
422
423impl ContextProvider for TypeScriptContextProvider {
424 fn associated_tasks(
425 &self,
426 file: Option<Arc<dyn File>>,
427 cx: &App,
428 ) -> Task<Option<TaskTemplates>> {
429 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
430 return Task::ready(None);
431 };
432 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
433 return Task::ready(None);
434 };
435 let file_relative_path = file.path().clone();
436 let package_json_data = self.combined_package_json_data(
437 self.fs.clone(),
438 &worktree_root,
439 &file_relative_path,
440 cx,
441 );
442
443 cx.background_spawn(async move {
444 let mut task_templates = TaskTemplates(Vec::new());
445 task_templates.0.push(TaskTemplate {
446 label: format!(
447 "execute selection {}",
448 VariableName::SelectedText.template_value()
449 ),
450 command: "node".to_owned(),
451 args: vec![
452 "-e".to_owned(),
453 format!("\"{}\"", VariableName::SelectedText.template_value()),
454 ],
455 ..TaskTemplate::default()
456 });
457
458 match package_json_data.await {
459 Ok(package_json) => {
460 package_json.fill_task_templates(&mut task_templates);
461 }
462 Err(e) => {
463 log::error!(
464 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
465 );
466 }
467 }
468
469 Some(task_templates)
470 })
471 }
472
473 fn build_context(
474 &self,
475 current_vars: &task::TaskVariables,
476 location: ContextLocation<'_>,
477 _project_env: Option<HashMap<String, String>>,
478 _toolchains: Arc<dyn LanguageToolchainStore>,
479 cx: &mut App,
480 ) -> Task<Result<task::TaskVariables>> {
481 let mut vars = task::TaskVariables::default();
482
483 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
484 vars.insert(
485 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
486 replace_test_name_parameters(symbol),
487 );
488 vars.insert(
489 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
490 replace_test_name_parameters(symbol),
491 );
492 }
493 let file_path = location
494 .file_location
495 .buffer
496 .read(cx)
497 .file()
498 .map(|file| file.path());
499
500 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
501 |((worktree_root, fs), file_path)| {
502 (
503 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
504 worktree_root,
505 fs,
506 )
507 },
508 );
509 cx.background_spawn(async move {
510 if let Some((task, worktree_root, fs)) = args {
511 let package_json_data = task.await.log_err();
512 vars.insert(
513 TYPESCRIPT_RUNNER_VARIABLE,
514 detect_package_manager(worktree_root, fs, package_json_data.clone())
515 .await
516 .to_owned(),
517 );
518
519 if let Some(package_json_data) = package_json_data {
520 if let Some(path) = package_json_data.jest_package_path {
521 vars.insert(
522 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
523 path.parent()
524 .unwrap_or(Path::new(""))
525 .to_string_lossy()
526 .to_string(),
527 );
528 }
529
530 if let Some(path) = package_json_data.mocha_package_path {
531 vars.insert(
532 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
533 path.parent()
534 .unwrap_or(Path::new(""))
535 .to_string_lossy()
536 .to_string(),
537 );
538 }
539
540 if let Some(path) = package_json_data.vitest_package_path {
541 vars.insert(
542 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
543 path.parent()
544 .unwrap_or(Path::new(""))
545 .to_string_lossy()
546 .to_string(),
547 );
548 }
549
550 if let Some(path) = package_json_data.jasmine_package_path {
551 vars.insert(
552 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
553 path.parent()
554 .unwrap_or(Path::new(""))
555 .to_string_lossy()
556 .to_string(),
557 );
558 }
559
560 if let Some(path) = package_json_data.bun_package_path {
561 vars.insert(
562 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
563 path.parent()
564 .unwrap_or(Path::new(""))
565 .to_string_lossy()
566 .to_string(),
567 );
568 }
569
570 if let Some(path) = package_json_data.node_package_path {
571 vars.insert(
572 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
573 path.parent()
574 .unwrap_or(Path::new(""))
575 .to_string_lossy()
576 .to_string(),
577 );
578 }
579 }
580 }
581 Ok(vars)
582 })
583 }
584}
585
586fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
587 vec![server_path.into(), "--stdio".into()]
588}
589
590fn replace_test_name_parameters(test_name: &str) -> String {
591 static PATTERN: LazyLock<regex::Regex> =
592 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
593 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
594}
595
596pub struct TypeScriptLspAdapter {
597 fs: Arc<dyn Fs>,
598 node: NodeRuntime,
599}
600
601impl TypeScriptLspAdapter {
602 const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
603 const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
604
605 const PACKAGE_NAME: &str = "typescript";
606 const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
607
608 const SERVER_NAME: LanguageServerName =
609 LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
610
611 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
612 TypeScriptLspAdapter { fs, node }
613 }
614
615 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
616 let is_yarn = adapter
617 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
618 .await
619 .is_ok();
620
621 let tsdk_path = if is_yarn {
622 ".yarn/sdks/typescript/lib"
623 } else {
624 "node_modules/typescript/lib"
625 };
626
627 if self
628 .fs
629 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
630 .await
631 {
632 Some(tsdk_path)
633 } else {
634 None
635 }
636 }
637}
638
639pub struct TypeScriptVersions {
640 typescript_version: String,
641 server_version: String,
642}
643
644impl LspInstaller for TypeScriptLspAdapter {
645 type BinaryVersion = TypeScriptVersions;
646
647 async fn fetch_latest_server_version(
648 &self,
649 _: &dyn LspAdapterDelegate,
650 _: bool,
651 _: &mut AsyncApp,
652 ) -> Result<TypeScriptVersions> {
653 Ok(TypeScriptVersions {
654 typescript_version: self
655 .node
656 .npm_package_latest_version(Self::PACKAGE_NAME)
657 .await?,
658 server_version: self
659 .node
660 .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
661 .await?,
662 })
663 }
664
665 async fn check_if_version_installed(
666 &self,
667 version: &TypeScriptVersions,
668 container_dir: &PathBuf,
669 _: &dyn LspAdapterDelegate,
670 ) -> Option<LanguageServerBinary> {
671 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
672
673 if self
674 .node
675 .should_install_npm_package(
676 Self::PACKAGE_NAME,
677 &server_path,
678 container_dir,
679 VersionStrategy::Latest(version.typescript_version.as_str()),
680 )
681 .await
682 {
683 return None;
684 }
685
686 if self
687 .node
688 .should_install_npm_package(
689 Self::SERVER_PACKAGE_NAME,
690 &server_path,
691 container_dir,
692 VersionStrategy::Latest(version.server_version.as_str()),
693 )
694 .await
695 {
696 return None;
697 }
698
699 Some(LanguageServerBinary {
700 path: self.node.binary_path().await.ok()?,
701 env: None,
702 arguments: typescript_server_binary_arguments(&server_path),
703 })
704 }
705
706 async fn fetch_server_binary(
707 &self,
708 latest_version: TypeScriptVersions,
709 container_dir: PathBuf,
710 _: &dyn LspAdapterDelegate,
711 ) -> Result<LanguageServerBinary> {
712 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
713
714 self.node
715 .npm_install_packages(
716 &container_dir,
717 &[
718 (
719 Self::PACKAGE_NAME,
720 latest_version.typescript_version.as_str(),
721 ),
722 (
723 Self::SERVER_PACKAGE_NAME,
724 latest_version.server_version.as_str(),
725 ),
726 ],
727 )
728 .await?;
729
730 Ok(LanguageServerBinary {
731 path: self.node.binary_path().await?,
732 env: None,
733 arguments: typescript_server_binary_arguments(&server_path),
734 })
735 }
736
737 async fn cached_server_binary(
738 &self,
739 container_dir: PathBuf,
740 _: &dyn LspAdapterDelegate,
741 ) -> Option<LanguageServerBinary> {
742 get_cached_ts_server_binary(container_dir, &self.node).await
743 }
744}
745
746#[async_trait(?Send)]
747impl LspAdapter for TypeScriptLspAdapter {
748 fn name(&self) -> LanguageServerName {
749 Self::SERVER_NAME
750 }
751
752 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
753 Some(vec![
754 CodeActionKind::QUICKFIX,
755 CodeActionKind::REFACTOR,
756 CodeActionKind::REFACTOR_EXTRACT,
757 CodeActionKind::SOURCE,
758 ])
759 }
760
761 async fn label_for_completion(
762 &self,
763 item: &lsp::CompletionItem,
764 language: &Arc<language::Language>,
765 ) -> Option<language::CodeLabel> {
766 use lsp::CompletionItemKind as Kind;
767 let label_len = item.label.len();
768 let grammar = language.grammar()?;
769 let highlight_id = match item.kind? {
770 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
771 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
772 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
773 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
774 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
775 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
776 _ => None,
777 }?;
778
779 let text = if let Some(description) = item
780 .label_details
781 .as_ref()
782 .and_then(|label_details| label_details.description.as_ref())
783 {
784 format!("{} {}", item.label, description)
785 } else if let Some(detail) = &item.detail {
786 format!("{} {}", item.label, detail)
787 } else {
788 item.label.clone()
789 };
790 Some(language::CodeLabel::filtered(
791 text,
792 label_len,
793 item.filter_text.as_deref(),
794 vec![(0..label_len, highlight_id)],
795 ))
796 }
797
798 async fn initialization_options(
799 self: Arc<Self>,
800 adapter: &Arc<dyn LspAdapterDelegate>,
801 ) -> Result<Option<serde_json::Value>> {
802 let tsdk_path = self.tsdk_path(adapter).await;
803 Ok(Some(json!({
804 "provideFormatter": true,
805 "hostInfo": "zed",
806 "tsserver": {
807 "path": tsdk_path,
808 },
809 "preferences": {
810 "includeInlayParameterNameHints": "all",
811 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
812 "includeInlayFunctionParameterTypeHints": true,
813 "includeInlayVariableTypeHints": true,
814 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
815 "includeInlayPropertyDeclarationTypeHints": true,
816 "includeInlayFunctionLikeReturnTypeHints": true,
817 "includeInlayEnumMemberValueHints": true,
818 }
819 })))
820 }
821
822 async fn workspace_configuration(
823 self: Arc<Self>,
824 delegate: &Arc<dyn LspAdapterDelegate>,
825 _: Option<Toolchain>,
826 _: Option<Uri>,
827 cx: &mut AsyncApp,
828 ) -> Result<Value> {
829 let override_options = cx.update(|cx| {
830 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
831 .and_then(|s| s.settings.clone())
832 })?;
833 if let Some(options) = override_options {
834 return Ok(options);
835 }
836 Ok(json!({
837 "completions": {
838 "completeFunctionCalls": true
839 }
840 }))
841 }
842
843 fn language_ids(&self) -> HashMap<LanguageName, String> {
844 HashMap::from_iter([
845 (LanguageName::new_static("TypeScript"), "typescript".into()),
846 (LanguageName::new_static("JavaScript"), "javascript".into()),
847 (LanguageName::new_static("TSX"), "typescriptreact".into()),
848 ])
849 }
850}
851
852async fn get_cached_ts_server_binary(
853 container_dir: PathBuf,
854 node: &NodeRuntime,
855) -> Option<LanguageServerBinary> {
856 maybe!(async {
857 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
858 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
859 if new_server_path.exists() {
860 Ok(LanguageServerBinary {
861 path: node.binary_path().await?,
862 env: None,
863 arguments: typescript_server_binary_arguments(&new_server_path),
864 })
865 } else if old_server_path.exists() {
866 Ok(LanguageServerBinary {
867 path: node.binary_path().await?,
868 env: None,
869 arguments: typescript_server_binary_arguments(&old_server_path),
870 })
871 } else {
872 anyhow::bail!("missing executable in directory {container_dir:?}")
873 }
874 })
875 .await
876 .log_err()
877}
878
879#[cfg(test)]
880mod tests {
881 use std::path::Path;
882
883 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
884 use project::FakeFs;
885 use serde_json::json;
886 use task::TaskTemplates;
887 use unindent::Unindent;
888 use util::{path, rel_path::rel_path};
889
890 use crate::typescript::{
891 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
892 };
893
894 #[gpui::test]
895 async fn test_outline(cx: &mut TestAppContext) {
896 for language in [
897 crate::language(
898 "typescript",
899 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
900 ),
901 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
902 ] {
903 let text = r#"
904 function a() {
905 // local variables are included
906 let a1 = 1;
907 // all functions are included
908 async function a2() {}
909 }
910 // top-level variables are included
911 let b: C
912 function getB() {}
913 // exported variables are included
914 export const d = e;
915 "#
916 .unindent();
917
918 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
919 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
920 assert_eq!(
921 outline
922 .items
923 .iter()
924 .map(|item| (item.text.as_str(), item.depth))
925 .collect::<Vec<_>>(),
926 &[
927 ("function a()", 0),
928 ("let a1", 1),
929 ("async function a2()", 1),
930 ("let b", 0),
931 ("function getB()", 0),
932 ("const d", 0),
933 ]
934 );
935 }
936 }
937
938 #[gpui::test]
939 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
940 for language in [
941 crate::language(
942 "typescript",
943 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
944 ),
945 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
946 ] {
947 let text = r#"
948 // Top-level destructuring
949 const { a1, a2 } = a;
950 const [b1, b2] = b;
951
952 // Defaults and rest
953 const [c1 = 1, , c2, ...rest1] = c;
954 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
955
956 function processData() {
957 // Nested object destructuring
958 const { c1, c2 } = c;
959 // Nested array destructuring
960 const [d1, d2, d3] = d;
961 // Destructuring with renaming
962 const { f1: g1 } = f;
963 // With defaults
964 const [x = 10, y] = xy;
965 }
966
967 class DataHandler {
968 method() {
969 // Destructuring in class method
970 const { a1, a2 } = a;
971 const [b1, ...b2] = b;
972 }
973 }
974 "#
975 .unindent();
976
977 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
978 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
979 assert_eq!(
980 outline
981 .items
982 .iter()
983 .map(|item| (item.text.as_str(), item.depth))
984 .collect::<Vec<_>>(),
985 &[
986 ("const a1", 0),
987 ("const a2", 0),
988 ("const b1", 0),
989 ("const b2", 0),
990 ("const c1", 0),
991 ("const c2", 0),
992 ("const rest1", 0),
993 ("const d1", 0),
994 ("const e1", 0),
995 ("const h1", 0),
996 ("const rest2", 0),
997 ("function processData()", 0),
998 ("const c1", 1),
999 ("const c2", 1),
1000 ("const d1", 1),
1001 ("const d2", 1),
1002 ("const d3", 1),
1003 ("const g1", 1),
1004 ("const x", 1),
1005 ("const y", 1),
1006 ("class DataHandler", 0),
1007 ("method()", 1),
1008 ("const a1", 2),
1009 ("const a2", 2),
1010 ("const b1", 2),
1011 ("const b2", 2),
1012 ]
1013 );
1014 }
1015 }
1016
1017 #[gpui::test]
1018 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1019 for language in [
1020 crate::language(
1021 "typescript",
1022 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1023 ),
1024 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1025 ] {
1026 let text = r#"
1027 // Object with function properties
1028 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1029
1030 // Object with primitive properties
1031 const p = { p1: 1, p2: "hello", p3: true };
1032
1033 // Nested objects
1034 const q = {
1035 r: {
1036 // won't be included due to one-level depth limit
1037 s: 1
1038 },
1039 t: 2
1040 };
1041
1042 function getData() {
1043 const local = { x: 1, y: 2 };
1044 return local;
1045 }
1046 "#
1047 .unindent();
1048
1049 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1050 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1051 assert_eq!(
1052 outline
1053 .items
1054 .iter()
1055 .map(|item| (item.text.as_str(), item.depth))
1056 .collect::<Vec<_>>(),
1057 &[
1058 ("const o", 0),
1059 ("m()", 1),
1060 ("async n()", 1),
1061 ("g", 1),
1062 ("h", 1),
1063 ("k", 1),
1064 ("const p", 0),
1065 ("p1", 1),
1066 ("p2", 1),
1067 ("p3", 1),
1068 ("const q", 0),
1069 ("r", 1),
1070 ("s", 2),
1071 ("t", 1),
1072 ("function getData()", 0),
1073 ("const local", 1),
1074 ("x", 2),
1075 ("y", 2),
1076 ]
1077 );
1078 }
1079 }
1080
1081 #[gpui::test]
1082 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1083 for language in [
1084 crate::language(
1085 "typescript",
1086 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1087 ),
1088 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1089 ] {
1090 let text = r#"
1091 // Symbols as object keys
1092 const sym = Symbol("test");
1093 const obj1 = {
1094 [sym]: 1,
1095 [Symbol("inline")]: 2,
1096 normalKey: 3
1097 };
1098
1099 // Enums as object keys
1100 enum Color { Red, Blue, Green }
1101
1102 const obj2 = {
1103 [Color.Red]: "red value",
1104 [Color.Blue]: "blue value",
1105 regularProp: "normal"
1106 };
1107
1108 // Mixed computed properties
1109 const key = "dynamic";
1110 const obj3 = {
1111 [key]: 1,
1112 ["string" + "concat"]: 2,
1113 [1 + 1]: 3,
1114 static: 4
1115 };
1116
1117 // Nested objects with computed properties
1118 const obj4 = {
1119 [sym]: {
1120 nested: 1
1121 },
1122 regular: {
1123 [key]: 2
1124 }
1125 };
1126 "#
1127 .unindent();
1128
1129 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1130 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1131 assert_eq!(
1132 outline
1133 .items
1134 .iter()
1135 .map(|item| (item.text.as_str(), item.depth))
1136 .collect::<Vec<_>>(),
1137 &[
1138 ("const sym", 0),
1139 ("const obj1", 0),
1140 ("[sym]", 1),
1141 ("[Symbol(\"inline\")]", 1),
1142 ("normalKey", 1),
1143 ("enum Color", 0),
1144 ("const obj2", 0),
1145 ("[Color.Red]", 1),
1146 ("[Color.Blue]", 1),
1147 ("regularProp", 1),
1148 ("const key", 0),
1149 ("const obj3", 0),
1150 ("[key]", 1),
1151 ("[\"string\" + \"concat\"]", 1),
1152 ("[1 + 1]", 1),
1153 ("static", 1),
1154 ("const obj4", 0),
1155 ("[sym]", 1),
1156 ("nested", 2),
1157 ("regular", 1),
1158 ("[key]", 2),
1159 ]
1160 );
1161 }
1162 }
1163
1164 #[gpui::test]
1165 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1166 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1167
1168 let text = r#"
1169 function normalFunction() {
1170 console.log("normal");
1171 }
1172
1173 function* simpleGenerator() {
1174 yield 1;
1175 yield 2;
1176 }
1177
1178 async function* asyncGenerator() {
1179 yield await Promise.resolve(1);
1180 }
1181
1182 function* generatorWithParams(start, end) {
1183 for (let i = start; i <= end; i++) {
1184 yield i;
1185 }
1186 }
1187
1188 class TestClass {
1189 *methodGenerator() {
1190 yield "method";
1191 }
1192
1193 async *asyncMethodGenerator() {
1194 yield "async method";
1195 }
1196 }
1197 "#
1198 .unindent();
1199
1200 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1201 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1202 assert_eq!(
1203 outline
1204 .items
1205 .iter()
1206 .map(|item| (item.text.as_str(), item.depth))
1207 .collect::<Vec<_>>(),
1208 &[
1209 ("function normalFunction()", 0),
1210 ("function* simpleGenerator()", 0),
1211 ("async function* asyncGenerator()", 0),
1212 ("function* generatorWithParams( )", 0),
1213 ("class TestClass", 0),
1214 ("*methodGenerator()", 1),
1215 ("async *asyncMethodGenerator()", 1),
1216 ]
1217 );
1218 }
1219
1220 #[gpui::test]
1221 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1222 cx.update(|cx| {
1223 settings::init(cx);
1224 });
1225
1226 let package_json_1 = json!({
1227 "dependencies": {
1228 "mocha": "1.0.0",
1229 "vitest": "1.0.0"
1230 },
1231 "scripts": {
1232 "test": ""
1233 }
1234 })
1235 .to_string();
1236
1237 let package_json_2 = json!({
1238 "devDependencies": {
1239 "vitest": "2.0.0"
1240 },
1241 "scripts": {
1242 "test": ""
1243 }
1244 })
1245 .to_string();
1246
1247 let fs = FakeFs::new(executor);
1248 fs.insert_tree(
1249 path!("/root"),
1250 json!({
1251 "package.json": package_json_1,
1252 "sub": {
1253 "package.json": package_json_2,
1254 "file.js": "",
1255 }
1256 }),
1257 )
1258 .await;
1259
1260 let provider = TypeScriptContextProvider::new(fs.clone());
1261 let package_json_data = cx
1262 .update(|cx| {
1263 provider.combined_package_json_data(
1264 fs.clone(),
1265 path!("/root").as_ref(),
1266 rel_path("sub/file1.js"),
1267 cx,
1268 )
1269 })
1270 .await
1271 .unwrap();
1272 pretty_assertions::assert_eq!(
1273 package_json_data,
1274 PackageJsonData {
1275 jest_package_path: None,
1276 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1277 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1278 jasmine_package_path: None,
1279 bun_package_path: None,
1280 node_package_path: None,
1281 scripts: [
1282 (
1283 Path::new(path!("/root/package.json")).into(),
1284 "test".to_owned()
1285 ),
1286 (
1287 Path::new(path!("/root/sub/package.json")).into(),
1288 "test".to_owned()
1289 )
1290 ]
1291 .into_iter()
1292 .collect(),
1293 package_manager: None,
1294 }
1295 );
1296
1297 let mut task_templates = TaskTemplates::default();
1298 package_json_data.fill_task_templates(&mut task_templates);
1299 let task_templates = task_templates
1300 .0
1301 .into_iter()
1302 .map(|template| (template.label, template.cwd))
1303 .collect::<Vec<_>>();
1304 pretty_assertions::assert_eq!(
1305 task_templates,
1306 [
1307 (
1308 "vitest file test".into(),
1309 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1310 ),
1311 (
1312 "vitest test $ZED_SYMBOL".into(),
1313 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1314 ),
1315 (
1316 "mocha file test".into(),
1317 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1318 ),
1319 (
1320 "mocha test $ZED_SYMBOL".into(),
1321 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1322 ),
1323 (
1324 "root/package.json > test".into(),
1325 Some(path!("/root").into())
1326 ),
1327 (
1328 "sub/package.json > test".into(),
1329 Some(path!("/root/sub").into())
1330 ),
1331 ]
1332 );
1333 }
1334
1335 #[test]
1336 fn test_escaping_name() {
1337 let cases = [
1338 ("plain test name", "plain test name"),
1339 ("test name with $param_name", "test name with (.+?)"),
1340 ("test name with $nested.param.name", "test name with (.+?)"),
1341 ("test name with $#", "test name with (.+?)"),
1342 ("test name with $##", "test name with (.+?)\\#"),
1343 ("test name with %p", "test name with (.+?)"),
1344 ("test name with %s", "test name with (.+?)"),
1345 ("test name with %d", "test name with (.+?)"),
1346 ("test name with %i", "test name with (.+?)"),
1347 ("test name with %f", "test name with (.+?)"),
1348 ("test name with %j", "test name with (.+?)"),
1349 ("test name with %o", "test name with (.+?)"),
1350 ("test name with %#", "test name with (.+?)"),
1351 ("test name with %$", "test name with (.+?)"),
1352 ("test name with %%", "test name with (.+?)"),
1353 ("test name with %q", "test name with %q"),
1354 (
1355 "test name with regex chars .*+?^${}()|[]\\",
1356 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1357 ),
1358 (
1359 "test name with multiple $params and %pretty and %b and (.+?)",
1360 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1361 ),
1362 ];
1363
1364 for (input, expected) in cases {
1365 assert_eq!(replace_test_name_parameters(input), expected);
1366 }
1367 }
1368
1369 // The order of test runner tasks is based on inferred user preference:
1370 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1371 // 2. Bun's built-in test runner (`bun test`) comes next.
1372 // 3. Node.js's built-in test runner (`node --test`) is last.
1373 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1374 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1375 // typically preferred over `node --test` when @types/bun is present.
1376 #[gpui::test]
1377 async fn test_task_ordering_with_multiple_test_runners(
1378 executor: BackgroundExecutor,
1379 cx: &mut TestAppContext,
1380 ) {
1381 cx.update(|cx| {
1382 settings::init(cx);
1383 });
1384
1385 // Test case with all test runners present
1386 let package_json_all_runners = json!({
1387 "devDependencies": {
1388 "@types/bun": "1.0.0",
1389 "@types/node": "^20.0.0",
1390 "jest": "29.0.0",
1391 "mocha": "10.0.0",
1392 "vitest": "1.0.0",
1393 "jasmine": "5.0.0",
1394 },
1395 "scripts": {
1396 "test": "jest"
1397 }
1398 })
1399 .to_string();
1400
1401 let fs = FakeFs::new(executor);
1402 fs.insert_tree(
1403 path!("/root"),
1404 json!({
1405 "package.json": package_json_all_runners,
1406 "file.js": "",
1407 }),
1408 )
1409 .await;
1410
1411 let provider = TypeScriptContextProvider::new(fs.clone());
1412
1413 let package_json_data = cx
1414 .update(|cx| {
1415 provider.combined_package_json_data(
1416 fs.clone(),
1417 path!("/root").as_ref(),
1418 rel_path("file.js"),
1419 cx,
1420 )
1421 })
1422 .await
1423 .unwrap();
1424
1425 assert!(package_json_data.jest_package_path.is_some());
1426 assert!(package_json_data.mocha_package_path.is_some());
1427 assert!(package_json_data.vitest_package_path.is_some());
1428 assert!(package_json_data.jasmine_package_path.is_some());
1429 assert!(package_json_data.bun_package_path.is_some());
1430 assert!(package_json_data.node_package_path.is_some());
1431
1432 let mut task_templates = TaskTemplates::default();
1433 package_json_data.fill_task_templates(&mut task_templates);
1434
1435 let test_tasks: Vec<_> = task_templates
1436 .0
1437 .iter()
1438 .filter(|template| {
1439 template.tags.contains(&"ts-test".to_owned())
1440 || template.tags.contains(&"js-test".to_owned())
1441 })
1442 .map(|template| &template.label)
1443 .collect();
1444
1445 let node_test_index = test_tasks
1446 .iter()
1447 .position(|label| label.contains("node test"));
1448 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1449 let bun_test_index = test_tasks
1450 .iter()
1451 .position(|label| label.contains("bun test"));
1452
1453 assert!(
1454 node_test_index.is_some(),
1455 "Node test tasks should be present"
1456 );
1457 assert!(
1458 jest_test_index.is_some(),
1459 "Jest test tasks should be present"
1460 );
1461 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1462
1463 assert!(
1464 jest_test_index.unwrap() < bun_test_index.unwrap(),
1465 "Jest should come before Bun"
1466 );
1467 assert!(
1468 bun_test_index.unwrap() < node_test_index.unwrap(),
1469 "Bun should come before Node"
1470 );
1471 }
1472}