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 http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
8use http_client::github_download::download_server_binary;
9use itertools::Itertools as _;
10use language::{
11 ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
12 LspAdapterDelegate, LspInstaller, Toolchain,
13};
14use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
15use node_runtime::{NodeRuntime, VersionStrategy};
16use project::{Fs, lsp_store::language_server_settings};
17use serde_json::{Value, json};
18use smol::{fs, lock::RwLock, stream::StreamExt};
19use std::{
20 borrow::Cow,
21 ffi::OsString,
22 path::{Path, PathBuf},
23 sync::{Arc, LazyLock},
24};
25use task::{TaskTemplate, TaskTemplates, VariableName};
26use util::{ResultExt, fs::remove_matching, maybe};
27use util::{merge_json_value_into, rel_path::RelPath};
28
29use crate::{PackageJson, PackageJsonData};
30
31pub(crate) struct TypeScriptContextProvider {
32 fs: Arc<dyn Fs>,
33 last_package_json: PackageJsonContents,
34}
35
36const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
37 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
38
39const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
40 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
41
42const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
43 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
44
45const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
46 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
47
48const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
49 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
50
51const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
52 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
53
54const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
55 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
56
57const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
58 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
59
60const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
61 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
62
63#[derive(Clone, Debug, Default)]
64struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
65
66impl PackageJsonData {
67 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
68 if self.jest_package_path.is_some() {
69 task_templates.0.push(TaskTemplate {
70 label: "jest file test".to_owned(),
71 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
72 args: vec![
73 "exec".to_owned(),
74 "--".to_owned(),
75 "jest".to_owned(),
76 "--runInBand".to_owned(),
77 VariableName::File.template_value(),
78 ],
79 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
80 ..TaskTemplate::default()
81 });
82 task_templates.0.push(TaskTemplate {
83 label: format!("jest test {}", VariableName::Symbol.template_value()),
84 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
85 args: vec![
86 "exec".to_owned(),
87 "--".to_owned(),
88 "jest".to_owned(),
89 "--runInBand".to_owned(),
90 "--testNamePattern".to_owned(),
91 format!(
92 "\"{}\"",
93 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
94 ),
95 VariableName::File.template_value(),
96 ],
97 tags: vec![
98 "ts-test".to_owned(),
99 "js-test".to_owned(),
100 "tsx-test".to_owned(),
101 ],
102 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
103 ..TaskTemplate::default()
104 });
105 }
106
107 if self.vitest_package_path.is_some() {
108 task_templates.0.push(TaskTemplate {
109 label: format!("{} file test", "vitest".to_owned()),
110 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
111 args: vec![
112 "exec".to_owned(),
113 "--".to_owned(),
114 "vitest".to_owned(),
115 "run".to_owned(),
116 "--poolOptions.forks.minForks=0".to_owned(),
117 "--poolOptions.forks.maxForks=1".to_owned(),
118 VariableName::File.template_value(),
119 ],
120 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
121 ..TaskTemplate::default()
122 });
123 task_templates.0.push(TaskTemplate {
124 label: format!(
125 "{} test {}",
126 "vitest".to_owned(),
127 VariableName::Symbol.template_value(),
128 ),
129 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
130 args: vec![
131 "exec".to_owned(),
132 "--".to_owned(),
133 "vitest".to_owned(),
134 "run".to_owned(),
135 "--poolOptions.forks.minForks=0".to_owned(),
136 "--poolOptions.forks.maxForks=1".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!("\"{}\"", VariableName::Symbol.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 }
495 let file_path = location
496 .file_location
497 .buffer
498 .read(cx)
499 .file()
500 .map(|file| file.path());
501
502 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
503 |((worktree_root, fs), file_path)| {
504 (
505 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
506 worktree_root,
507 fs,
508 )
509 },
510 );
511 cx.background_spawn(async move {
512 if let Some((task, worktree_root, fs)) = args {
513 let package_json_data = task.await.log_err();
514 vars.insert(
515 TYPESCRIPT_RUNNER_VARIABLE,
516 detect_package_manager(worktree_root, fs, package_json_data.clone())
517 .await
518 .to_owned(),
519 );
520
521 if let Some(package_json_data) = package_json_data {
522 if let Some(path) = package_json_data.jest_package_path {
523 vars.insert(
524 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
525 path.parent()
526 .unwrap_or(Path::new(""))
527 .to_string_lossy()
528 .to_string(),
529 );
530 }
531
532 if let Some(path) = package_json_data.mocha_package_path {
533 vars.insert(
534 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
535 path.parent()
536 .unwrap_or(Path::new(""))
537 .to_string_lossy()
538 .to_string(),
539 );
540 }
541
542 if let Some(path) = package_json_data.vitest_package_path {
543 vars.insert(
544 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
545 path.parent()
546 .unwrap_or(Path::new(""))
547 .to_string_lossy()
548 .to_string(),
549 );
550 }
551
552 if let Some(path) = package_json_data.jasmine_package_path {
553 vars.insert(
554 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
555 path.parent()
556 .unwrap_or(Path::new(""))
557 .to_string_lossy()
558 .to_string(),
559 );
560 }
561
562 if let Some(path) = package_json_data.bun_package_path {
563 vars.insert(
564 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
565 path.parent()
566 .unwrap_or(Path::new(""))
567 .to_string_lossy()
568 .to_string(),
569 );
570 }
571
572 if let Some(path) = package_json_data.node_package_path {
573 vars.insert(
574 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
575 path.parent()
576 .unwrap_or(Path::new(""))
577 .to_string_lossy()
578 .to_string(),
579 );
580 }
581 }
582 }
583 Ok(vars)
584 })
585 }
586}
587
588fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
589 vec![server_path.into(), "--stdio".into()]
590}
591
592fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
593 vec![
594 "--max-old-space-size=8192".into(),
595 server_path.into(),
596 "--stdio".into(),
597 ]
598}
599
600fn replace_test_name_parameters(test_name: &str) -> String {
601 static PATTERN: LazyLock<regex::Regex> =
602 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
603 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
604}
605
606pub struct TypeScriptLspAdapter {
607 fs: Arc<dyn Fs>,
608 node: NodeRuntime,
609}
610
611impl TypeScriptLspAdapter {
612 const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
613 const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
614 const SERVER_NAME: LanguageServerName =
615 LanguageServerName::new_static("typescript-language-server");
616 const PACKAGE_NAME: &str = "typescript";
617 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
618 TypeScriptLspAdapter { fs, node }
619 }
620 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
621 let is_yarn = adapter
622 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
623 .await
624 .is_ok();
625
626 let tsdk_path = if is_yarn {
627 ".yarn/sdks/typescript/lib"
628 } else {
629 "node_modules/typescript/lib"
630 };
631
632 if self
633 .fs
634 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
635 .await
636 {
637 Some(tsdk_path)
638 } else {
639 None
640 }
641 }
642}
643
644pub struct TypeScriptVersions {
645 typescript_version: String,
646 server_version: String,
647}
648
649impl LspInstaller for TypeScriptLspAdapter {
650 type BinaryVersion = TypeScriptVersions;
651
652 async fn fetch_latest_server_version(
653 &self,
654 _: &dyn LspAdapterDelegate,
655 _: bool,
656 _: &mut AsyncApp,
657 ) -> Result<TypeScriptVersions> {
658 Ok(TypeScriptVersions {
659 typescript_version: self.node.npm_package_latest_version("typescript").await?,
660 server_version: self
661 .node
662 .npm_package_latest_version("typescript-language-server")
663 .await?,
664 })
665 }
666
667 async fn check_if_version_installed(
668 &self,
669 version: &TypeScriptVersions,
670 container_dir: &PathBuf,
671 _: &dyn LspAdapterDelegate,
672 ) -> Option<LanguageServerBinary> {
673 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
674
675 let should_install_language_server = self
676 .node
677 .should_install_npm_package(
678 Self::PACKAGE_NAME,
679 &server_path,
680 container_dir,
681 VersionStrategy::Latest(version.typescript_version.as_str()),
682 )
683 .await;
684
685 if should_install_language_server {
686 None
687 } else {
688 Some(LanguageServerBinary {
689 path: self.node.binary_path().await.ok()?,
690 env: None,
691 arguments: typescript_server_binary_arguments(&server_path),
692 })
693 }
694 }
695
696 async fn fetch_server_binary(
697 &self,
698 latest_version: TypeScriptVersions,
699 container_dir: PathBuf,
700 _: &dyn LspAdapterDelegate,
701 ) -> Result<LanguageServerBinary> {
702 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
703
704 self.node
705 .npm_install_packages(
706 &container_dir,
707 &[
708 (
709 Self::PACKAGE_NAME,
710 latest_version.typescript_version.as_str(),
711 ),
712 (
713 "typescript-language-server",
714 latest_version.server_version.as_str(),
715 ),
716 ],
717 )
718 .await?;
719
720 Ok(LanguageServerBinary {
721 path: self.node.binary_path().await?,
722 env: None,
723 arguments: typescript_server_binary_arguments(&server_path),
724 })
725 }
726
727 async fn cached_server_binary(
728 &self,
729 container_dir: PathBuf,
730 _: &dyn LspAdapterDelegate,
731 ) -> Option<LanguageServerBinary> {
732 get_cached_ts_server_binary(container_dir, &self.node).await
733 }
734}
735
736#[async_trait(?Send)]
737impl LspAdapter for TypeScriptLspAdapter {
738 fn name(&self) -> LanguageServerName {
739 Self::SERVER_NAME
740 }
741
742 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
743 Some(vec![
744 CodeActionKind::QUICKFIX,
745 CodeActionKind::REFACTOR,
746 CodeActionKind::REFACTOR_EXTRACT,
747 CodeActionKind::SOURCE,
748 ])
749 }
750
751 async fn label_for_completion(
752 &self,
753 item: &lsp::CompletionItem,
754 language: &Arc<language::Language>,
755 ) -> Option<language::CodeLabel> {
756 use lsp::CompletionItemKind as Kind;
757 let len = item.label.len();
758 let grammar = language.grammar()?;
759 let highlight_id = match item.kind? {
760 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
761 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
762 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
763 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
764 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
765 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
766 _ => None,
767 }?;
768
769 let text = if let Some(description) = item
770 .label_details
771 .as_ref()
772 .and_then(|label_details| label_details.description.as_ref())
773 {
774 format!("{} {}", item.label, description)
775 } else if let Some(detail) = &item.detail {
776 format!("{} {}", item.label, detail)
777 } else {
778 item.label.clone()
779 };
780 Some(language::CodeLabel::filtered(
781 text,
782 item.filter_text.as_deref(),
783 vec![(0..len, highlight_id)],
784 ))
785 }
786
787 async fn initialization_options(
788 self: Arc<Self>,
789 adapter: &Arc<dyn LspAdapterDelegate>,
790 ) -> Result<Option<serde_json::Value>> {
791 let tsdk_path = self.tsdk_path(adapter).await;
792 Ok(Some(json!({
793 "provideFormatter": true,
794 "hostInfo": "zed",
795 "tsserver": {
796 "path": tsdk_path,
797 },
798 "preferences": {
799 "includeInlayParameterNameHints": "all",
800 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
801 "includeInlayFunctionParameterTypeHints": true,
802 "includeInlayVariableTypeHints": true,
803 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
804 "includeInlayPropertyDeclarationTypeHints": true,
805 "includeInlayFunctionLikeReturnTypeHints": true,
806 "includeInlayEnumMemberValueHints": true,
807 }
808 })))
809 }
810
811 async fn workspace_configuration(
812 self: Arc<Self>,
813
814 delegate: &Arc<dyn LspAdapterDelegate>,
815 _: Option<Toolchain>,
816 cx: &mut AsyncApp,
817 ) -> Result<Value> {
818 let override_options = cx.update(|cx| {
819 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
820 .and_then(|s| s.settings.clone())
821 })?;
822 if let Some(options) = override_options {
823 return Ok(options);
824 }
825 Ok(json!({
826 "completions": {
827 "completeFunctionCalls": true
828 }
829 }))
830 }
831
832 fn language_ids(&self) -> HashMap<LanguageName, String> {
833 HashMap::from_iter([
834 (LanguageName::new("TypeScript"), "typescript".into()),
835 (LanguageName::new("JavaScript"), "javascript".into()),
836 (LanguageName::new("TSX"), "typescriptreact".into()),
837 ])
838 }
839}
840
841async fn get_cached_ts_server_binary(
842 container_dir: PathBuf,
843 node: &NodeRuntime,
844) -> Option<LanguageServerBinary> {
845 maybe!(async {
846 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
847 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
848 if new_server_path.exists() {
849 Ok(LanguageServerBinary {
850 path: node.binary_path().await?,
851 env: None,
852 arguments: typescript_server_binary_arguments(&new_server_path),
853 })
854 } else if old_server_path.exists() {
855 Ok(LanguageServerBinary {
856 path: node.binary_path().await?,
857 env: None,
858 arguments: typescript_server_binary_arguments(&old_server_path),
859 })
860 } else {
861 anyhow::bail!("missing executable in directory {container_dir:?}")
862 }
863 })
864 .await
865 .log_err()
866}
867
868pub struct EsLintLspAdapter {
869 node: NodeRuntime,
870}
871
872impl EsLintLspAdapter {
873 const CURRENT_VERSION: &'static str = "2.4.4";
874 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
875
876 #[cfg(not(windows))]
877 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
878 #[cfg(windows)]
879 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
880
881 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
882 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
883
884 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
885 "eslint.config.js",
886 "eslint.config.mjs",
887 "eslint.config.cjs",
888 "eslint.config.ts",
889 "eslint.config.cts",
890 "eslint.config.mts",
891 ];
892
893 pub const fn new(node: NodeRuntime) -> Self {
894 EsLintLspAdapter { node }
895 }
896
897 fn build_destination_path(container_dir: &Path) -> PathBuf {
898 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
899 }
900}
901
902impl LspInstaller for EsLintLspAdapter {
903 type BinaryVersion = GitHubLspBinaryVersion;
904
905 async fn fetch_latest_server_version(
906 &self,
907 _delegate: &dyn LspAdapterDelegate,
908 _: bool,
909 _: &mut AsyncApp,
910 ) -> Result<GitHubLspBinaryVersion> {
911 let url = build_asset_url(
912 "zed-industries/vscode-eslint",
913 Self::CURRENT_VERSION_TAG_NAME,
914 Self::GITHUB_ASSET_KIND,
915 )?;
916
917 Ok(GitHubLspBinaryVersion {
918 name: Self::CURRENT_VERSION.into(),
919 digest: None,
920 url,
921 })
922 }
923
924 async fn fetch_server_binary(
925 &self,
926 version: GitHubLspBinaryVersion,
927 container_dir: PathBuf,
928 delegate: &dyn LspAdapterDelegate,
929 ) -> Result<LanguageServerBinary> {
930 let destination_path = Self::build_destination_path(&container_dir);
931 let server_path = destination_path.join(Self::SERVER_PATH);
932
933 if fs::metadata(&server_path).await.is_err() {
934 remove_matching(&container_dir, |_| true).await;
935
936 download_server_binary(
937 &*delegate.http_client(),
938 &version.url,
939 None,
940 &destination_path,
941 Self::GITHUB_ASSET_KIND,
942 )
943 .await?;
944
945 let mut dir = fs::read_dir(&destination_path).await?;
946 let first = dir.next().await.context("missing first file")??;
947 let repo_root = destination_path.join("vscode-eslint");
948 fs::rename(first.path(), &repo_root).await?;
949
950 #[cfg(target_os = "windows")]
951 {
952 handle_symlink(
953 repo_root.join("$shared"),
954 repo_root.join("client").join("src").join("shared"),
955 )
956 .await?;
957 handle_symlink(
958 repo_root.join("$shared"),
959 repo_root.join("server").join("src").join("shared"),
960 )
961 .await?;
962 }
963
964 self.node
965 .run_npm_subcommand(&repo_root, "install", &[])
966 .await?;
967
968 self.node
969 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
970 .await?;
971 }
972
973 Ok(LanguageServerBinary {
974 path: self.node.binary_path().await?,
975 env: None,
976 arguments: eslint_server_binary_arguments(&server_path),
977 })
978 }
979
980 async fn cached_server_binary(
981 &self,
982 container_dir: PathBuf,
983 _: &dyn LspAdapterDelegate,
984 ) -> Option<LanguageServerBinary> {
985 let server_path =
986 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
987 Some(LanguageServerBinary {
988 path: self.node.binary_path().await.ok()?,
989 env: None,
990 arguments: eslint_server_binary_arguments(&server_path),
991 })
992 }
993}
994
995#[async_trait(?Send)]
996impl LspAdapter for EsLintLspAdapter {
997 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
998 Some(vec![
999 CodeActionKind::QUICKFIX,
1000 CodeActionKind::new("source.fixAll.eslint"),
1001 ])
1002 }
1003
1004 async fn workspace_configuration(
1005 self: Arc<Self>,
1006 delegate: &Arc<dyn LspAdapterDelegate>,
1007 _: Option<Toolchain>,
1008 cx: &mut AsyncApp,
1009 ) -> Result<Value> {
1010 let workspace_root = delegate.worktree_root_path();
1011 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
1012 .iter()
1013 .any(|file| workspace_root.join(file).is_file());
1014
1015 let mut default_workspace_configuration = json!({
1016 "validate": "on",
1017 "rulesCustomizations": [],
1018 "run": "onType",
1019 "nodePath": null,
1020 "workingDirectory": {
1021 "mode": "auto"
1022 },
1023 "workspaceFolder": {
1024 "uri": workspace_root,
1025 "name": workspace_root.file_name()
1026 .unwrap_or(workspace_root.as_os_str())
1027 .to_string_lossy(),
1028 },
1029 "problems": {},
1030 "codeActionOnSave": {
1031 // We enable this, but without also configuring code_actions_on_format
1032 // in the Zed configuration, it doesn't have an effect.
1033 "enable": true,
1034 },
1035 "codeAction": {
1036 "disableRuleComment": {
1037 "enable": true,
1038 "location": "separateLine",
1039 },
1040 "showDocumentation": {
1041 "enable": true
1042 }
1043 },
1044 "experimental": {
1045 "useFlatConfig": use_flat_config,
1046 }
1047 });
1048
1049 let override_options = cx.update(|cx| {
1050 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
1051 .and_then(|s| s.settings.clone())
1052 })?;
1053
1054 if let Some(override_options) = override_options {
1055 merge_json_value_into(override_options, &mut default_workspace_configuration);
1056 }
1057
1058 Ok(json!({
1059 "": default_workspace_configuration
1060 }))
1061 }
1062
1063 fn name(&self) -> LanguageServerName {
1064 Self::SERVER_NAME
1065 }
1066}
1067
1068#[cfg(target_os = "windows")]
1069async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1070 anyhow::ensure!(
1071 fs::metadata(&src_dir).await.is_ok(),
1072 "Directory {src_dir:?} is not present"
1073 );
1074 if fs::metadata(&dest_dir).await.is_ok() {
1075 fs::remove_file(&dest_dir).await?;
1076 }
1077 fs::create_dir_all(&dest_dir).await?;
1078 let mut entries = fs::read_dir(&src_dir).await?;
1079 while let Some(entry) = entries.try_next().await? {
1080 let entry_path = entry.path();
1081 let entry_name = entry.file_name();
1082 let dest_path = dest_dir.join(&entry_name);
1083 fs::copy(&entry_path, &dest_path).await?;
1084 }
1085 Ok(())
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use std::path::Path;
1091
1092 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1093 use language::language_settings;
1094 use project::{FakeFs, Project};
1095 use serde_json::json;
1096 use task::TaskTemplates;
1097 use unindent::Unindent;
1098 use util::{path, rel_path::rel_path};
1099
1100 use crate::typescript::{
1101 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
1102 };
1103
1104 #[gpui::test]
1105 async fn test_outline(cx: &mut TestAppContext) {
1106 let language = crate::language(
1107 "typescript",
1108 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1109 );
1110
1111 let text = r#"
1112 function a() {
1113 // local variables are omitted
1114 let a1 = 1;
1115 // all functions are included
1116 async function a2() {}
1117 }
1118 // top-level variables are included
1119 let b: C
1120 function getB() {}
1121 // exported variables are included
1122 export const d = e;
1123 "#
1124 .unindent();
1125
1126 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1127 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1128 assert_eq!(
1129 outline
1130 .items
1131 .iter()
1132 .map(|item| (item.text.as_str(), item.depth))
1133 .collect::<Vec<_>>(),
1134 &[
1135 ("function a()", 0),
1136 ("async function a2()", 1),
1137 ("let b", 0),
1138 ("function getB()", 0),
1139 ("const d", 0),
1140 ]
1141 );
1142 }
1143
1144 #[gpui::test]
1145 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1146 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1147
1148 let text = r#"
1149 function normalFunction() {
1150 console.log("normal");
1151 }
1152
1153 function* simpleGenerator() {
1154 yield 1;
1155 yield 2;
1156 }
1157
1158 async function* asyncGenerator() {
1159 yield await Promise.resolve(1);
1160 }
1161
1162 function* generatorWithParams(start, end) {
1163 for (let i = start; i <= end; i++) {
1164 yield i;
1165 }
1166 }
1167
1168 class TestClass {
1169 *methodGenerator() {
1170 yield "method";
1171 }
1172
1173 async *asyncMethodGenerator() {
1174 yield "async method";
1175 }
1176 }
1177 "#
1178 .unindent();
1179
1180 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1181 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1182 assert_eq!(
1183 outline
1184 .items
1185 .iter()
1186 .map(|item| (item.text.as_str(), item.depth))
1187 .collect::<Vec<_>>(),
1188 &[
1189 ("function normalFunction()", 0),
1190 ("function* simpleGenerator()", 0),
1191 ("async function* asyncGenerator()", 0),
1192 ("function* generatorWithParams( )", 0),
1193 ("class TestClass", 0),
1194 ("*methodGenerator()", 1),
1195 ("async *asyncMethodGenerator()", 1),
1196 ]
1197 );
1198 }
1199
1200 #[gpui::test]
1201 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1202 cx.update(|cx| {
1203 settings::init(cx);
1204 Project::init_settings(cx);
1205 language_settings::init(cx);
1206 });
1207
1208 let package_json_1 = json!({
1209 "dependencies": {
1210 "mocha": "1.0.0",
1211 "vitest": "1.0.0"
1212 },
1213 "scripts": {
1214 "test": ""
1215 }
1216 })
1217 .to_string();
1218
1219 let package_json_2 = json!({
1220 "devDependencies": {
1221 "vitest": "2.0.0"
1222 },
1223 "scripts": {
1224 "test": ""
1225 }
1226 })
1227 .to_string();
1228
1229 let fs = FakeFs::new(executor);
1230 fs.insert_tree(
1231 path!("/root"),
1232 json!({
1233 "package.json": package_json_1,
1234 "sub": {
1235 "package.json": package_json_2,
1236 "file.js": "",
1237 }
1238 }),
1239 )
1240 .await;
1241
1242 let provider = TypeScriptContextProvider::new(fs.clone());
1243 let package_json_data = cx
1244 .update(|cx| {
1245 provider.combined_package_json_data(
1246 fs.clone(),
1247 path!("/root").as_ref(),
1248 rel_path("sub/file1.js"),
1249 cx,
1250 )
1251 })
1252 .await
1253 .unwrap();
1254 pretty_assertions::assert_eq!(
1255 package_json_data,
1256 PackageJsonData {
1257 jest_package_path: None,
1258 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1259 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1260 jasmine_package_path: None,
1261 bun_package_path: None,
1262 node_package_path: None,
1263 scripts: [
1264 (
1265 Path::new(path!("/root/package.json")).into(),
1266 "test".to_owned()
1267 ),
1268 (
1269 Path::new(path!("/root/sub/package.json")).into(),
1270 "test".to_owned()
1271 )
1272 ]
1273 .into_iter()
1274 .collect(),
1275 package_manager: None,
1276 }
1277 );
1278
1279 let mut task_templates = TaskTemplates::default();
1280 package_json_data.fill_task_templates(&mut task_templates);
1281 let task_templates = task_templates
1282 .0
1283 .into_iter()
1284 .map(|template| (template.label, template.cwd))
1285 .collect::<Vec<_>>();
1286 pretty_assertions::assert_eq!(
1287 task_templates,
1288 [
1289 (
1290 "vitest file test".into(),
1291 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1292 ),
1293 (
1294 "vitest test $ZED_SYMBOL".into(),
1295 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1296 ),
1297 (
1298 "mocha file test".into(),
1299 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1300 ),
1301 (
1302 "mocha test $ZED_SYMBOL".into(),
1303 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1304 ),
1305 (
1306 "root/package.json > test".into(),
1307 Some(path!("/root").into())
1308 ),
1309 (
1310 "sub/package.json > test".into(),
1311 Some(path!("/root/sub").into())
1312 ),
1313 ]
1314 );
1315 }
1316
1317 #[test]
1318 fn test_escaping_name() {
1319 let cases = [
1320 ("plain test name", "plain test name"),
1321 ("test name with $param_name", "test name with (.+?)"),
1322 ("test name with $nested.param.name", "test name with (.+?)"),
1323 ("test name with $#", "test name with (.+?)"),
1324 ("test name with $##", "test name with (.+?)\\#"),
1325 ("test name with %p", "test name with (.+?)"),
1326 ("test name with %s", "test name with (.+?)"),
1327 ("test name with %d", "test name with (.+?)"),
1328 ("test name with %i", "test name with (.+?)"),
1329 ("test name with %f", "test name with (.+?)"),
1330 ("test name with %j", "test name with (.+?)"),
1331 ("test name with %o", "test name with (.+?)"),
1332 ("test name with %#", "test name with (.+?)"),
1333 ("test name with %$", "test name with (.+?)"),
1334 ("test name with %%", "test name with (.+?)"),
1335 ("test name with %q", "test name with %q"),
1336 (
1337 "test name with regex chars .*+?^${}()|[]\\",
1338 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1339 ),
1340 (
1341 "test name with multiple $params and %pretty and %b and (.+?)",
1342 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1343 ),
1344 ];
1345
1346 for (input, expected) in cases {
1347 assert_eq!(replace_test_name_parameters(input), expected);
1348 }
1349 }
1350
1351 // The order of test runner tasks is based on inferred user preference:
1352 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1353 // 2. Bun's built-in test runner (`bun test`) comes next.
1354 // 3. Node.js's built-in test runner (`node --test`) is last.
1355 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1356 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1357 // typically preferred over `node --test` when @types/bun is present.
1358 #[gpui::test]
1359 async fn test_task_ordering_with_multiple_test_runners(
1360 executor: BackgroundExecutor,
1361 cx: &mut TestAppContext,
1362 ) {
1363 cx.update(|cx| {
1364 settings::init(cx);
1365 Project::init_settings(cx);
1366 language_settings::init(cx);
1367 });
1368
1369 // Test case with all test runners present
1370 let package_json_all_runners = json!({
1371 "devDependencies": {
1372 "@types/bun": "1.0.0",
1373 "@types/node": "^20.0.0",
1374 "jest": "29.0.0",
1375 "mocha": "10.0.0",
1376 "vitest": "1.0.0",
1377 "jasmine": "5.0.0",
1378 },
1379 "scripts": {
1380 "test": "jest"
1381 }
1382 })
1383 .to_string();
1384
1385 let fs = FakeFs::new(executor);
1386 fs.insert_tree(
1387 path!("/root"),
1388 json!({
1389 "package.json": package_json_all_runners,
1390 "file.js": "",
1391 }),
1392 )
1393 .await;
1394
1395 let provider = TypeScriptContextProvider::new(fs.clone());
1396
1397 let package_json_data = cx
1398 .update(|cx| {
1399 provider.combined_package_json_data(
1400 fs.clone(),
1401 path!("/root").as_ref(),
1402 rel_path("file.js"),
1403 cx,
1404 )
1405 })
1406 .await
1407 .unwrap();
1408
1409 assert!(package_json_data.jest_package_path.is_some());
1410 assert!(package_json_data.mocha_package_path.is_some());
1411 assert!(package_json_data.vitest_package_path.is_some());
1412 assert!(package_json_data.jasmine_package_path.is_some());
1413 assert!(package_json_data.bun_package_path.is_some());
1414 assert!(package_json_data.node_package_path.is_some());
1415
1416 let mut task_templates = TaskTemplates::default();
1417 package_json_data.fill_task_templates(&mut task_templates);
1418
1419 let test_tasks: Vec<_> = task_templates
1420 .0
1421 .iter()
1422 .filter(|template| {
1423 template.tags.contains(&"ts-test".to_owned())
1424 || template.tags.contains(&"js-test".to_owned())
1425 })
1426 .map(|template| &template.label)
1427 .collect();
1428
1429 let node_test_index = test_tasks
1430 .iter()
1431 .position(|label| label.contains("node test"));
1432 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1433 let bun_test_index = test_tasks
1434 .iter()
1435 .position(|label| label.contains("bun test"));
1436
1437 assert!(
1438 node_test_index.is_some(),
1439 "Node test tasks should be present"
1440 );
1441 assert!(
1442 jest_test_index.is_some(),
1443 "Jest test tasks should be present"
1444 );
1445 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1446
1447 assert!(
1448 jest_test_index.unwrap() < bun_test_index.unwrap(),
1449 "Jest should come before Bun"
1450 );
1451 assert!(
1452 bun_test_index.unwrap() < node_test_index.unwrap(),
1453 "Bun should come before Node"
1454 );
1455 }
1456}