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 let filter_range = item
781 .filter_text
782 .as_deref()
783 .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
784 .unwrap_or(0..len);
785 Some(language::CodeLabel {
786 text,
787 runs: vec![(0..len, highlight_id)],
788 filter_range,
789 })
790 }
791
792 async fn initialization_options(
793 self: Arc<Self>,
794 adapter: &Arc<dyn LspAdapterDelegate>,
795 ) -> Result<Option<serde_json::Value>> {
796 let tsdk_path = self.tsdk_path(adapter).await;
797 Ok(Some(json!({
798 "provideFormatter": true,
799 "hostInfo": "zed",
800 "tsserver": {
801 "path": tsdk_path,
802 },
803 "preferences": {
804 "includeInlayParameterNameHints": "all",
805 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
806 "includeInlayFunctionParameterTypeHints": true,
807 "includeInlayVariableTypeHints": true,
808 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
809 "includeInlayPropertyDeclarationTypeHints": true,
810 "includeInlayFunctionLikeReturnTypeHints": true,
811 "includeInlayEnumMemberValueHints": true,
812 }
813 })))
814 }
815
816 async fn workspace_configuration(
817 self: Arc<Self>,
818
819 delegate: &Arc<dyn LspAdapterDelegate>,
820 _: Option<Toolchain>,
821 cx: &mut AsyncApp,
822 ) -> Result<Value> {
823 let override_options = cx.update(|cx| {
824 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
825 .and_then(|s| s.settings.clone())
826 })?;
827 if let Some(options) = override_options {
828 return Ok(options);
829 }
830 Ok(json!({
831 "completions": {
832 "completeFunctionCalls": true
833 }
834 }))
835 }
836
837 fn language_ids(&self) -> HashMap<LanguageName, String> {
838 HashMap::from_iter([
839 (LanguageName::new("TypeScript"), "typescript".into()),
840 (LanguageName::new("JavaScript"), "javascript".into()),
841 (LanguageName::new("TSX"), "typescriptreact".into()),
842 ])
843 }
844}
845
846async fn get_cached_ts_server_binary(
847 container_dir: PathBuf,
848 node: &NodeRuntime,
849) -> Option<LanguageServerBinary> {
850 maybe!(async {
851 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
852 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
853 if new_server_path.exists() {
854 Ok(LanguageServerBinary {
855 path: node.binary_path().await?,
856 env: None,
857 arguments: typescript_server_binary_arguments(&new_server_path),
858 })
859 } else if old_server_path.exists() {
860 Ok(LanguageServerBinary {
861 path: node.binary_path().await?,
862 env: None,
863 arguments: typescript_server_binary_arguments(&old_server_path),
864 })
865 } else {
866 anyhow::bail!("missing executable in directory {container_dir:?}")
867 }
868 })
869 .await
870 .log_err()
871}
872
873pub struct EsLintLspAdapter {
874 node: NodeRuntime,
875}
876
877impl EsLintLspAdapter {
878 const CURRENT_VERSION: &'static str = "2.4.4";
879 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
880
881 #[cfg(not(windows))]
882 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
883 #[cfg(windows)]
884 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
885
886 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
887 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
888
889 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
890 "eslint.config.js",
891 "eslint.config.mjs",
892 "eslint.config.cjs",
893 "eslint.config.ts",
894 "eslint.config.cts",
895 "eslint.config.mts",
896 ];
897
898 pub fn new(node: NodeRuntime) -> Self {
899 EsLintLspAdapter { node }
900 }
901
902 fn build_destination_path(container_dir: &Path) -> PathBuf {
903 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
904 }
905}
906
907impl LspInstaller for EsLintLspAdapter {
908 type BinaryVersion = GitHubLspBinaryVersion;
909
910 async fn fetch_latest_server_version(
911 &self,
912 _delegate: &dyn LspAdapterDelegate,
913 _: bool,
914 _: &mut AsyncApp,
915 ) -> Result<GitHubLspBinaryVersion> {
916 let url = build_asset_url(
917 "zed-industries/vscode-eslint",
918 Self::CURRENT_VERSION_TAG_NAME,
919 Self::GITHUB_ASSET_KIND,
920 )?;
921
922 Ok(GitHubLspBinaryVersion {
923 name: Self::CURRENT_VERSION.into(),
924 digest: None,
925 url,
926 })
927 }
928
929 async fn fetch_server_binary(
930 &self,
931 version: GitHubLspBinaryVersion,
932 container_dir: PathBuf,
933 delegate: &dyn LspAdapterDelegate,
934 ) -> Result<LanguageServerBinary> {
935 let destination_path = Self::build_destination_path(&container_dir);
936 let server_path = destination_path.join(Self::SERVER_PATH);
937
938 if fs::metadata(&server_path).await.is_err() {
939 remove_matching(&container_dir, |_| true).await;
940
941 download_server_binary(
942 &*delegate.http_client(),
943 &version.url,
944 None,
945 &destination_path,
946 Self::GITHUB_ASSET_KIND,
947 )
948 .await?;
949
950 let mut dir = fs::read_dir(&destination_path).await?;
951 let first = dir.next().await.context("missing first file")??;
952 let repo_root = destination_path.join("vscode-eslint");
953 fs::rename(first.path(), &repo_root).await?;
954
955 #[cfg(target_os = "windows")]
956 {
957 handle_symlink(
958 repo_root.join("$shared"),
959 repo_root.join("client").join("src").join("shared"),
960 )
961 .await?;
962 handle_symlink(
963 repo_root.join("$shared"),
964 repo_root.join("server").join("src").join("shared"),
965 )
966 .await?;
967 }
968
969 self.node
970 .run_npm_subcommand(&repo_root, "install", &[])
971 .await?;
972
973 self.node
974 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
975 .await?;
976 }
977
978 Ok(LanguageServerBinary {
979 path: self.node.binary_path().await?,
980 env: None,
981 arguments: eslint_server_binary_arguments(&server_path),
982 })
983 }
984
985 async fn cached_server_binary(
986 &self,
987 container_dir: PathBuf,
988 _: &dyn LspAdapterDelegate,
989 ) -> Option<LanguageServerBinary> {
990 let server_path =
991 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
992 Some(LanguageServerBinary {
993 path: self.node.binary_path().await.ok()?,
994 env: None,
995 arguments: eslint_server_binary_arguments(&server_path),
996 })
997 }
998}
999
1000#[async_trait(?Send)]
1001impl LspAdapter for EsLintLspAdapter {
1002 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
1003 Some(vec![
1004 CodeActionKind::QUICKFIX,
1005 CodeActionKind::new("source.fixAll.eslint"),
1006 ])
1007 }
1008
1009 async fn workspace_configuration(
1010 self: Arc<Self>,
1011 delegate: &Arc<dyn LspAdapterDelegate>,
1012 _: Option<Toolchain>,
1013 cx: &mut AsyncApp,
1014 ) -> Result<Value> {
1015 let workspace_root = delegate.worktree_root_path();
1016 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
1017 .iter()
1018 .any(|file| workspace_root.join(file).is_file());
1019
1020 let mut default_workspace_configuration = json!({
1021 "validate": "on",
1022 "rulesCustomizations": [],
1023 "run": "onType",
1024 "nodePath": null,
1025 "workingDirectory": {
1026 "mode": "auto"
1027 },
1028 "workspaceFolder": {
1029 "uri": workspace_root,
1030 "name": workspace_root.file_name()
1031 .unwrap_or(workspace_root.as_os_str())
1032 .to_string_lossy(),
1033 },
1034 "problems": {},
1035 "codeActionOnSave": {
1036 // We enable this, but without also configuring code_actions_on_format
1037 // in the Zed configuration, it doesn't have an effect.
1038 "enable": true,
1039 },
1040 "codeAction": {
1041 "disableRuleComment": {
1042 "enable": true,
1043 "location": "separateLine",
1044 },
1045 "showDocumentation": {
1046 "enable": true
1047 }
1048 },
1049 "experimental": {
1050 "useFlatConfig": use_flat_config,
1051 }
1052 });
1053
1054 let override_options = cx.update(|cx| {
1055 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
1056 .and_then(|s| s.settings.clone())
1057 })?;
1058
1059 if let Some(override_options) = override_options {
1060 merge_json_value_into(override_options, &mut default_workspace_configuration);
1061 }
1062
1063 Ok(json!({
1064 "": default_workspace_configuration
1065 }))
1066 }
1067
1068 fn name(&self) -> LanguageServerName {
1069 Self::SERVER_NAME
1070 }
1071}
1072
1073#[cfg(target_os = "windows")]
1074async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1075 anyhow::ensure!(
1076 fs::metadata(&src_dir).await.is_ok(),
1077 "Directory {src_dir:?} is not present"
1078 );
1079 if fs::metadata(&dest_dir).await.is_ok() {
1080 fs::remove_file(&dest_dir).await?;
1081 }
1082 fs::create_dir_all(&dest_dir).await?;
1083 let mut entries = fs::read_dir(&src_dir).await?;
1084 while let Some(entry) = entries.try_next().await? {
1085 let entry_path = entry.path();
1086 let entry_name = entry.file_name();
1087 let dest_path = dest_dir.join(&entry_name);
1088 fs::copy(&entry_path, &dest_path).await?;
1089 }
1090 Ok(())
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use std::path::Path;
1096
1097 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1098 use language::language_settings;
1099 use project::{FakeFs, Project};
1100 use serde_json::json;
1101 use task::TaskTemplates;
1102 use unindent::Unindent;
1103 use util::{path, rel_path::rel_path};
1104
1105 use crate::typescript::{
1106 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
1107 };
1108
1109 #[gpui::test]
1110 async fn test_outline(cx: &mut TestAppContext) {
1111 let language = crate::language(
1112 "typescript",
1113 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1114 );
1115
1116 let text = r#"
1117 function a() {
1118 // local variables are omitted
1119 let a1 = 1;
1120 // all functions are included
1121 async function a2() {}
1122 }
1123 // top-level variables are included
1124 let b: C
1125 function getB() {}
1126 // exported variables are included
1127 export const d = e;
1128 "#
1129 .unindent();
1130
1131 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1132 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1133 assert_eq!(
1134 outline
1135 .items
1136 .iter()
1137 .map(|item| (item.text.as_str(), item.depth))
1138 .collect::<Vec<_>>(),
1139 &[
1140 ("function a()", 0),
1141 ("async function a2()", 1),
1142 ("let b", 0),
1143 ("function getB()", 0),
1144 ("const d", 0),
1145 ]
1146 );
1147 }
1148
1149 #[gpui::test]
1150 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1151 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1152
1153 let text = r#"
1154 function normalFunction() {
1155 console.log("normal");
1156 }
1157
1158 function* simpleGenerator() {
1159 yield 1;
1160 yield 2;
1161 }
1162
1163 async function* asyncGenerator() {
1164 yield await Promise.resolve(1);
1165 }
1166
1167 function* generatorWithParams(start, end) {
1168 for (let i = start; i <= end; i++) {
1169 yield i;
1170 }
1171 }
1172
1173 class TestClass {
1174 *methodGenerator() {
1175 yield "method";
1176 }
1177
1178 async *asyncMethodGenerator() {
1179 yield "async method";
1180 }
1181 }
1182 "#
1183 .unindent();
1184
1185 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1186 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1187 assert_eq!(
1188 outline
1189 .items
1190 .iter()
1191 .map(|item| (item.text.as_str(), item.depth))
1192 .collect::<Vec<_>>(),
1193 &[
1194 ("function normalFunction()", 0),
1195 ("function* simpleGenerator()", 0),
1196 ("async function* asyncGenerator()", 0),
1197 ("function* generatorWithParams( )", 0),
1198 ("class TestClass", 0),
1199 ("*methodGenerator()", 1),
1200 ("async *asyncMethodGenerator()", 1),
1201 ]
1202 );
1203 }
1204
1205 #[gpui::test]
1206 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1207 cx.update(|cx| {
1208 settings::init(cx);
1209 Project::init_settings(cx);
1210 language_settings::init(cx);
1211 });
1212
1213 let package_json_1 = json!({
1214 "dependencies": {
1215 "mocha": "1.0.0",
1216 "vitest": "1.0.0"
1217 },
1218 "scripts": {
1219 "test": ""
1220 }
1221 })
1222 .to_string();
1223
1224 let package_json_2 = json!({
1225 "devDependencies": {
1226 "vitest": "2.0.0"
1227 },
1228 "scripts": {
1229 "test": ""
1230 }
1231 })
1232 .to_string();
1233
1234 let fs = FakeFs::new(executor);
1235 fs.insert_tree(
1236 path!("/root"),
1237 json!({
1238 "package.json": package_json_1,
1239 "sub": {
1240 "package.json": package_json_2,
1241 "file.js": "",
1242 }
1243 }),
1244 )
1245 .await;
1246
1247 let provider = TypeScriptContextProvider::new(fs.clone());
1248 let package_json_data = cx
1249 .update(|cx| {
1250 provider.combined_package_json_data(
1251 fs.clone(),
1252 path!("/root").as_ref(),
1253 rel_path("sub/file1.js"),
1254 cx,
1255 )
1256 })
1257 .await
1258 .unwrap();
1259 pretty_assertions::assert_eq!(
1260 package_json_data,
1261 PackageJsonData {
1262 jest_package_path: None,
1263 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1264 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1265 jasmine_package_path: None,
1266 bun_package_path: None,
1267 node_package_path: None,
1268 scripts: [
1269 (
1270 Path::new(path!("/root/package.json")).into(),
1271 "test".to_owned()
1272 ),
1273 (
1274 Path::new(path!("/root/sub/package.json")).into(),
1275 "test".to_owned()
1276 )
1277 ]
1278 .into_iter()
1279 .collect(),
1280 package_manager: None,
1281 }
1282 );
1283
1284 let mut task_templates = TaskTemplates::default();
1285 package_json_data.fill_task_templates(&mut task_templates);
1286 let task_templates = task_templates
1287 .0
1288 .into_iter()
1289 .map(|template| (template.label, template.cwd))
1290 .collect::<Vec<_>>();
1291 pretty_assertions::assert_eq!(
1292 task_templates,
1293 [
1294 (
1295 "vitest file test".into(),
1296 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1297 ),
1298 (
1299 "vitest test $ZED_SYMBOL".into(),
1300 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1301 ),
1302 (
1303 "mocha file test".into(),
1304 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1305 ),
1306 (
1307 "mocha test $ZED_SYMBOL".into(),
1308 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1309 ),
1310 (
1311 "root/package.json > test".into(),
1312 Some(path!("/root").into())
1313 ),
1314 (
1315 "sub/package.json > test".into(),
1316 Some(path!("/root/sub").into())
1317 ),
1318 ]
1319 );
1320 }
1321
1322 #[test]
1323 fn test_escaping_name() {
1324 let cases = [
1325 ("plain test name", "plain test name"),
1326 ("test name with $param_name", "test name with (.+?)"),
1327 ("test name with $nested.param.name", "test name with (.+?)"),
1328 ("test name with $#", "test name with (.+?)"),
1329 ("test name with $##", "test name with (.+?)\\#"),
1330 ("test name with %p", "test name with (.+?)"),
1331 ("test name with %s", "test name with (.+?)"),
1332 ("test name with %d", "test name with (.+?)"),
1333 ("test name with %i", "test name with (.+?)"),
1334 ("test name with %f", "test name with (.+?)"),
1335 ("test name with %j", "test name with (.+?)"),
1336 ("test name with %o", "test name with (.+?)"),
1337 ("test name with %#", "test name with (.+?)"),
1338 ("test name with %$", "test name with (.+?)"),
1339 ("test name with %%", "test name with (.+?)"),
1340 ("test name with %q", "test name with %q"),
1341 (
1342 "test name with regex chars .*+?^${}()|[]\\",
1343 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1344 ),
1345 (
1346 "test name with multiple $params and %pretty and %b and (.+?)",
1347 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1348 ),
1349 ];
1350
1351 for (input, expected) in cases {
1352 assert_eq!(replace_test_name_parameters(input), expected);
1353 }
1354 }
1355
1356 // The order of test runner tasks is based on inferred user preference:
1357 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1358 // 2. Bun's built-in test runner (`bun test`) comes next.
1359 // 3. Node.js's built-in test runner (`node --test`) is last.
1360 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1361 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1362 // typically preferred over `node --test` when @types/bun is present.
1363 #[gpui::test]
1364 async fn test_task_ordering_with_multiple_test_runners(
1365 executor: BackgroundExecutor,
1366 cx: &mut TestAppContext,
1367 ) {
1368 cx.update(|cx| {
1369 settings::init(cx);
1370 Project::init_settings(cx);
1371 language_settings::init(cx);
1372 });
1373
1374 // Test case with all test runners present
1375 let package_json_all_runners = json!({
1376 "devDependencies": {
1377 "@types/bun": "1.0.0",
1378 "@types/node": "^20.0.0",
1379 "jest": "29.0.0",
1380 "mocha": "10.0.0",
1381 "vitest": "1.0.0",
1382 "jasmine": "5.0.0",
1383 },
1384 "scripts": {
1385 "test": "jest"
1386 }
1387 })
1388 .to_string();
1389
1390 let fs = FakeFs::new(executor);
1391 fs.insert_tree(
1392 path!("/root"),
1393 json!({
1394 "package.json": package_json_all_runners,
1395 "file.js": "",
1396 }),
1397 )
1398 .await;
1399
1400 let provider = TypeScriptContextProvider::new(fs.clone());
1401
1402 let package_json_data = cx
1403 .update(|cx| {
1404 provider.combined_package_json_data(
1405 fs.clone(),
1406 path!("/root").as_ref(),
1407 rel_path("file.js"),
1408 cx,
1409 )
1410 })
1411 .await
1412 .unwrap();
1413
1414 assert!(package_json_data.jest_package_path.is_some());
1415 assert!(package_json_data.mocha_package_path.is_some());
1416 assert!(package_json_data.vitest_package_path.is_some());
1417 assert!(package_json_data.jasmine_package_path.is_some());
1418 assert!(package_json_data.bun_package_path.is_some());
1419 assert!(package_json_data.node_package_path.is_some());
1420
1421 let mut task_templates = TaskTemplates::default();
1422 package_json_data.fill_task_templates(&mut task_templates);
1423
1424 let test_tasks: Vec<_> = task_templates
1425 .0
1426 .iter()
1427 .filter(|template| {
1428 template.tags.contains(&"ts-test".to_owned())
1429 || template.tags.contains(&"js-test".to_owned())
1430 })
1431 .map(|template| &template.label)
1432 .collect();
1433
1434 let node_test_index = test_tasks
1435 .iter()
1436 .position(|label| label.contains("node test"));
1437 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1438 let bun_test_index = test_tasks
1439 .iter()
1440 .position(|label| label.contains("bun test"));
1441
1442 assert!(
1443 node_test_index.is_some(),
1444 "Node test tasks should be present"
1445 );
1446 assert!(
1447 jest_test_index.is_some(),
1448 "Jest test tasks should be present"
1449 );
1450 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1451
1452 assert!(
1453 jest_test_index.unwrap() < bun_test_index.unwrap(),
1454 "Jest should come before Bun"
1455 );
1456 assert!(
1457 bun_test_index.unwrap() < node_test_index.unwrap(),
1458 "Bun should come before Node"
1459 );
1460 }
1461}