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 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 for language in [
1107 crate::language(
1108 "typescript",
1109 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1110 ),
1111 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1112 ] {
1113 let text = r#"
1114 function a() {
1115 // local variables are included
1116 let a1 = 1;
1117 // all functions are included
1118 async function a2() {}
1119 }
1120 // top-level variables are included
1121 let b: C
1122 function getB() {}
1123 // exported variables are included
1124 export const d = e;
1125 "#
1126 .unindent();
1127
1128 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1129 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1130 assert_eq!(
1131 outline
1132 .items
1133 .iter()
1134 .map(|item| (item.text.as_str(), item.depth))
1135 .collect::<Vec<_>>(),
1136 &[
1137 ("function a()", 0),
1138 ("let a1", 1),
1139 ("async function a2()", 1),
1140 ("let b", 0),
1141 ("function getB()", 0),
1142 ("const d", 0),
1143 ]
1144 );
1145 }
1146 }
1147
1148 #[gpui::test]
1149 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
1150 for language in [
1151 crate::language(
1152 "typescript",
1153 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1154 ),
1155 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1156 ] {
1157 let text = r#"
1158 // Top-level destructuring
1159 const { a1, a2 } = a;
1160 const [b1, b2] = b;
1161
1162 // Defaults and rest
1163 const [c1 = 1, , c2, ...rest1] = c;
1164 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
1165
1166 function processData() {
1167 // Nested object destructuring
1168 const { c1, c2 } = c;
1169 // Nested array destructuring
1170 const [d1, d2, d3] = d;
1171 // Destructuring with renaming
1172 const { f1: g1 } = f;
1173 // With defaults
1174 const [x = 10, y] = xy;
1175 }
1176
1177 class DataHandler {
1178 method() {
1179 // Destructuring in class method
1180 const { a1, a2 } = a;
1181 const [b1, ...b2] = b;
1182 }
1183 }
1184 "#
1185 .unindent();
1186
1187 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1188 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1189 assert_eq!(
1190 outline
1191 .items
1192 .iter()
1193 .map(|item| (item.text.as_str(), item.depth))
1194 .collect::<Vec<_>>(),
1195 &[
1196 ("const a1", 0),
1197 ("const a2", 0),
1198 ("const b1", 0),
1199 ("const b2", 0),
1200 ("const c1", 0),
1201 ("const c2", 0),
1202 ("const rest1", 0),
1203 ("const d1", 0),
1204 ("const e1", 0),
1205 ("const h1", 0),
1206 ("const rest2", 0),
1207 ("function processData()", 0),
1208 ("const c1", 1),
1209 ("const c2", 1),
1210 ("const d1", 1),
1211 ("const d2", 1),
1212 ("const d3", 1),
1213 ("const g1", 1),
1214 ("const x", 1),
1215 ("const y", 1),
1216 ("class DataHandler", 0),
1217 ("method()", 1),
1218 ("const a1", 2),
1219 ("const a2", 2),
1220 ("const b1", 2),
1221 ("const b2", 2),
1222 ]
1223 );
1224 }
1225 }
1226
1227 #[gpui::test]
1228 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1229 for language in [
1230 crate::language(
1231 "typescript",
1232 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1233 ),
1234 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1235 ] {
1236 let text = r#"
1237 // Object with function properties
1238 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1239
1240 // Object with primitive properties
1241 const p = { p1: 1, p2: "hello", p3: true };
1242
1243 // Nested objects
1244 const q = {
1245 r: {
1246 // won't be included due to one-level depth limit
1247 s: 1
1248 },
1249 t: 2
1250 };
1251
1252 function getData() {
1253 const local = { x: 1, y: 2 };
1254 return local;
1255 }
1256 "#
1257 .unindent();
1258
1259 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1260 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1261 assert_eq!(
1262 outline
1263 .items
1264 .iter()
1265 .map(|item| (item.text.as_str(), item.depth))
1266 .collect::<Vec<_>>(),
1267 &[
1268 ("const o", 0),
1269 ("m()", 1),
1270 ("async n()", 1),
1271 ("g", 1),
1272 ("h", 1),
1273 ("k", 1),
1274 ("const p", 0),
1275 ("p1", 1),
1276 ("p2", 1),
1277 ("p3", 1),
1278 ("const q", 0),
1279 ("r", 1),
1280 ("s", 2),
1281 ("t", 1),
1282 ("function getData()", 0),
1283 ("const local", 1),
1284 ("x", 2),
1285 ("y", 2),
1286 ]
1287 );
1288 }
1289 }
1290
1291 #[gpui::test]
1292 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1293 for language in [
1294 crate::language(
1295 "typescript",
1296 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1297 ),
1298 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1299 ] {
1300 let text = r#"
1301 // Symbols as object keys
1302 const sym = Symbol("test");
1303 const obj1 = {
1304 [sym]: 1,
1305 [Symbol("inline")]: 2,
1306 normalKey: 3
1307 };
1308
1309 // Enums as object keys
1310 enum Color { Red, Blue, Green }
1311
1312 const obj2 = {
1313 [Color.Red]: "red value",
1314 [Color.Blue]: "blue value",
1315 regularProp: "normal"
1316 };
1317
1318 // Mixed computed properties
1319 const key = "dynamic";
1320 const obj3 = {
1321 [key]: 1,
1322 ["string" + "concat"]: 2,
1323 [1 + 1]: 3,
1324 static: 4
1325 };
1326
1327 // Nested objects with computed properties
1328 const obj4 = {
1329 [sym]: {
1330 nested: 1
1331 },
1332 regular: {
1333 [key]: 2
1334 }
1335 };
1336 "#
1337 .unindent();
1338
1339 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1340 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1341 assert_eq!(
1342 outline
1343 .items
1344 .iter()
1345 .map(|item| (item.text.as_str(), item.depth))
1346 .collect::<Vec<_>>(),
1347 &[
1348 ("const sym", 0),
1349 ("const obj1", 0),
1350 ("[sym]", 1),
1351 ("[Symbol(\"inline\")]", 1),
1352 ("normalKey", 1),
1353 ("enum Color", 0),
1354 ("const obj2", 0),
1355 ("[Color.Red]", 1),
1356 ("[Color.Blue]", 1),
1357 ("regularProp", 1),
1358 ("const key", 0),
1359 ("const obj3", 0),
1360 ("[key]", 1),
1361 ("[\"string\" + \"concat\"]", 1),
1362 ("[1 + 1]", 1),
1363 ("static", 1),
1364 ("const obj4", 0),
1365 ("[sym]", 1),
1366 ("nested", 2),
1367 ("regular", 1),
1368 ("[key]", 2),
1369 ]
1370 );
1371 }
1372 }
1373
1374 #[gpui::test]
1375 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1376 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1377
1378 let text = r#"
1379 function normalFunction() {
1380 console.log("normal");
1381 }
1382
1383 function* simpleGenerator() {
1384 yield 1;
1385 yield 2;
1386 }
1387
1388 async function* asyncGenerator() {
1389 yield await Promise.resolve(1);
1390 }
1391
1392 function* generatorWithParams(start, end) {
1393 for (let i = start; i <= end; i++) {
1394 yield i;
1395 }
1396 }
1397
1398 class TestClass {
1399 *methodGenerator() {
1400 yield "method";
1401 }
1402
1403 async *asyncMethodGenerator() {
1404 yield "async method";
1405 }
1406 }
1407 "#
1408 .unindent();
1409
1410 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1411 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1412 assert_eq!(
1413 outline
1414 .items
1415 .iter()
1416 .map(|item| (item.text.as_str(), item.depth))
1417 .collect::<Vec<_>>(),
1418 &[
1419 ("function normalFunction()", 0),
1420 ("function* simpleGenerator()", 0),
1421 ("async function* asyncGenerator()", 0),
1422 ("function* generatorWithParams( )", 0),
1423 ("class TestClass", 0),
1424 ("*methodGenerator()", 1),
1425 ("async *asyncMethodGenerator()", 1),
1426 ]
1427 );
1428 }
1429
1430 #[gpui::test]
1431 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1432 cx.update(|cx| {
1433 settings::init(cx);
1434 Project::init_settings(cx);
1435 language_settings::init(cx);
1436 });
1437
1438 let package_json_1 = json!({
1439 "dependencies": {
1440 "mocha": "1.0.0",
1441 "vitest": "1.0.0"
1442 },
1443 "scripts": {
1444 "test": ""
1445 }
1446 })
1447 .to_string();
1448
1449 let package_json_2 = json!({
1450 "devDependencies": {
1451 "vitest": "2.0.0"
1452 },
1453 "scripts": {
1454 "test": ""
1455 }
1456 })
1457 .to_string();
1458
1459 let fs = FakeFs::new(executor);
1460 fs.insert_tree(
1461 path!("/root"),
1462 json!({
1463 "package.json": package_json_1,
1464 "sub": {
1465 "package.json": package_json_2,
1466 "file.js": "",
1467 }
1468 }),
1469 )
1470 .await;
1471
1472 let provider = TypeScriptContextProvider::new(fs.clone());
1473 let package_json_data = cx
1474 .update(|cx| {
1475 provider.combined_package_json_data(
1476 fs.clone(),
1477 path!("/root").as_ref(),
1478 rel_path("sub/file1.js"),
1479 cx,
1480 )
1481 })
1482 .await
1483 .unwrap();
1484 pretty_assertions::assert_eq!(
1485 package_json_data,
1486 PackageJsonData {
1487 jest_package_path: None,
1488 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1489 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1490 jasmine_package_path: None,
1491 bun_package_path: None,
1492 node_package_path: None,
1493 scripts: [
1494 (
1495 Path::new(path!("/root/package.json")).into(),
1496 "test".to_owned()
1497 ),
1498 (
1499 Path::new(path!("/root/sub/package.json")).into(),
1500 "test".to_owned()
1501 )
1502 ]
1503 .into_iter()
1504 .collect(),
1505 package_manager: None,
1506 }
1507 );
1508
1509 let mut task_templates = TaskTemplates::default();
1510 package_json_data.fill_task_templates(&mut task_templates);
1511 let task_templates = task_templates
1512 .0
1513 .into_iter()
1514 .map(|template| (template.label, template.cwd))
1515 .collect::<Vec<_>>();
1516 pretty_assertions::assert_eq!(
1517 task_templates,
1518 [
1519 (
1520 "vitest file test".into(),
1521 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1522 ),
1523 (
1524 "vitest test $ZED_SYMBOL".into(),
1525 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1526 ),
1527 (
1528 "mocha file test".into(),
1529 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1530 ),
1531 (
1532 "mocha test $ZED_SYMBOL".into(),
1533 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1534 ),
1535 (
1536 "root/package.json > test".into(),
1537 Some(path!("/root").into())
1538 ),
1539 (
1540 "sub/package.json > test".into(),
1541 Some(path!("/root/sub").into())
1542 ),
1543 ]
1544 );
1545 }
1546
1547 #[test]
1548 fn test_escaping_name() {
1549 let cases = [
1550 ("plain test name", "plain test name"),
1551 ("test name with $param_name", "test name with (.+?)"),
1552 ("test name with $nested.param.name", "test name with (.+?)"),
1553 ("test name with $#", "test name with (.+?)"),
1554 ("test name with $##", "test name with (.+?)\\#"),
1555 ("test name with %p", "test name with (.+?)"),
1556 ("test name with %s", "test name with (.+?)"),
1557 ("test name with %d", "test name with (.+?)"),
1558 ("test name with %i", "test name with (.+?)"),
1559 ("test name with %f", "test name with (.+?)"),
1560 ("test name with %j", "test name with (.+?)"),
1561 ("test name with %o", "test name with (.+?)"),
1562 ("test name with %#", "test name with (.+?)"),
1563 ("test name with %$", "test name with (.+?)"),
1564 ("test name with %%", "test name with (.+?)"),
1565 ("test name with %q", "test name with %q"),
1566 (
1567 "test name with regex chars .*+?^${}()|[]\\",
1568 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1569 ),
1570 (
1571 "test name with multiple $params and %pretty and %b and (.+?)",
1572 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1573 ),
1574 ];
1575
1576 for (input, expected) in cases {
1577 assert_eq!(replace_test_name_parameters(input), expected);
1578 }
1579 }
1580
1581 // The order of test runner tasks is based on inferred user preference:
1582 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1583 // 2. Bun's built-in test runner (`bun test`) comes next.
1584 // 3. Node.js's built-in test runner (`node --test`) is last.
1585 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1586 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1587 // typically preferred over `node --test` when @types/bun is present.
1588 #[gpui::test]
1589 async fn test_task_ordering_with_multiple_test_runners(
1590 executor: BackgroundExecutor,
1591 cx: &mut TestAppContext,
1592 ) {
1593 cx.update(|cx| {
1594 settings::init(cx);
1595 Project::init_settings(cx);
1596 language_settings::init(cx);
1597 });
1598
1599 // Test case with all test runners present
1600 let package_json_all_runners = json!({
1601 "devDependencies": {
1602 "@types/bun": "1.0.0",
1603 "@types/node": "^20.0.0",
1604 "jest": "29.0.0",
1605 "mocha": "10.0.0",
1606 "vitest": "1.0.0",
1607 "jasmine": "5.0.0",
1608 },
1609 "scripts": {
1610 "test": "jest"
1611 }
1612 })
1613 .to_string();
1614
1615 let fs = FakeFs::new(executor);
1616 fs.insert_tree(
1617 path!("/root"),
1618 json!({
1619 "package.json": package_json_all_runners,
1620 "file.js": "",
1621 }),
1622 )
1623 .await;
1624
1625 let provider = TypeScriptContextProvider::new(fs.clone());
1626
1627 let package_json_data = cx
1628 .update(|cx| {
1629 provider.combined_package_json_data(
1630 fs.clone(),
1631 path!("/root").as_ref(),
1632 rel_path("file.js"),
1633 cx,
1634 )
1635 })
1636 .await
1637 .unwrap();
1638
1639 assert!(package_json_data.jest_package_path.is_some());
1640 assert!(package_json_data.mocha_package_path.is_some());
1641 assert!(package_json_data.vitest_package_path.is_some());
1642 assert!(package_json_data.jasmine_package_path.is_some());
1643 assert!(package_json_data.bun_package_path.is_some());
1644 assert!(package_json_data.node_package_path.is_some());
1645
1646 let mut task_templates = TaskTemplates::default();
1647 package_json_data.fill_task_templates(&mut task_templates);
1648
1649 let test_tasks: Vec<_> = task_templates
1650 .0
1651 .iter()
1652 .filter(|template| {
1653 template.tags.contains(&"ts-test".to_owned())
1654 || template.tags.contains(&"js-test".to_owned())
1655 })
1656 .map(|template| &template.label)
1657 .collect();
1658
1659 let node_test_index = test_tasks
1660 .iter()
1661 .position(|label| label.contains("node test"));
1662 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1663 let bun_test_index = test_tasks
1664 .iter()
1665 .position(|label| label.contains("bun test"));
1666
1667 assert!(
1668 node_test_index.is_some(),
1669 "Node test tasks should be present"
1670 );
1671 assert!(
1672 jest_test_index.is_some(),
1673 "Jest test tasks should be present"
1674 );
1675 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1676
1677 assert!(
1678 jest_test_index.unwrap() < bun_test_index.unwrap(),
1679 "Jest should come before Bun"
1680 );
1681 assert!(
1682 bun_test_index.unwrap() < node_test_index.unwrap(),
1683 "Bun should come before Node"
1684 );
1685 }
1686}