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 label_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 label_len,
783 item.filter_text.as_deref(),
784 vec![(0..label_len, highlight_id)],
785 ))
786 }
787
788 async fn initialization_options(
789 self: Arc<Self>,
790 adapter: &Arc<dyn LspAdapterDelegate>,
791 ) -> Result<Option<serde_json::Value>> {
792 let tsdk_path = self.tsdk_path(adapter).await;
793 Ok(Some(json!({
794 "provideFormatter": true,
795 "hostInfo": "zed",
796 "tsserver": {
797 "path": tsdk_path,
798 },
799 "preferences": {
800 "includeInlayParameterNameHints": "all",
801 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
802 "includeInlayFunctionParameterTypeHints": true,
803 "includeInlayVariableTypeHints": true,
804 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
805 "includeInlayPropertyDeclarationTypeHints": true,
806 "includeInlayFunctionLikeReturnTypeHints": true,
807 "includeInlayEnumMemberValueHints": true,
808 }
809 })))
810 }
811
812 async fn workspace_configuration(
813 self: Arc<Self>,
814
815 delegate: &Arc<dyn LspAdapterDelegate>,
816 _: Option<Toolchain>,
817 cx: &mut AsyncApp,
818 ) -> Result<Value> {
819 let override_options = cx.update(|cx| {
820 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
821 .and_then(|s| s.settings.clone())
822 })?;
823 if let Some(options) = override_options {
824 return Ok(options);
825 }
826 Ok(json!({
827 "completions": {
828 "completeFunctionCalls": true
829 }
830 }))
831 }
832
833 fn language_ids(&self) -> HashMap<LanguageName, String> {
834 HashMap::from_iter([
835 (LanguageName::new("TypeScript"), "typescript".into()),
836 (LanguageName::new("JavaScript"), "javascript".into()),
837 (LanguageName::new("TSX"), "typescriptreact".into()),
838 ])
839 }
840}
841
842async fn get_cached_ts_server_binary(
843 container_dir: PathBuf,
844 node: &NodeRuntime,
845) -> Option<LanguageServerBinary> {
846 maybe!(async {
847 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
848 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
849 if new_server_path.exists() {
850 Ok(LanguageServerBinary {
851 path: node.binary_path().await?,
852 env: None,
853 arguments: typescript_server_binary_arguments(&new_server_path),
854 })
855 } else if old_server_path.exists() {
856 Ok(LanguageServerBinary {
857 path: node.binary_path().await?,
858 env: None,
859 arguments: typescript_server_binary_arguments(&old_server_path),
860 })
861 } else {
862 anyhow::bail!("missing executable in directory {container_dir:?}")
863 }
864 })
865 .await
866 .log_err()
867}
868
869pub struct EsLintLspAdapter {
870 node: NodeRuntime,
871}
872
873impl EsLintLspAdapter {
874 const CURRENT_VERSION: &'static str = "2.4.4";
875 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
876
877 #[cfg(not(windows))]
878 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
879 #[cfg(windows)]
880 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
881
882 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
883 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
884
885 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
886 "eslint.config.js",
887 "eslint.config.mjs",
888 "eslint.config.cjs",
889 "eslint.config.ts",
890 "eslint.config.cts",
891 "eslint.config.mts",
892 ];
893
894 pub fn new(node: NodeRuntime) -> Self {
895 EsLintLspAdapter { node }
896 }
897
898 fn build_destination_path(container_dir: &Path) -> PathBuf {
899 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
900 }
901}
902
903impl LspInstaller for EsLintLspAdapter {
904 type BinaryVersion = GitHubLspBinaryVersion;
905
906 async fn fetch_latest_server_version(
907 &self,
908 _delegate: &dyn LspAdapterDelegate,
909 _: bool,
910 _: &mut AsyncApp,
911 ) -> Result<GitHubLspBinaryVersion> {
912 let url = build_asset_url(
913 "zed-industries/vscode-eslint",
914 Self::CURRENT_VERSION_TAG_NAME,
915 Self::GITHUB_ASSET_KIND,
916 )?;
917
918 Ok(GitHubLspBinaryVersion {
919 name: Self::CURRENT_VERSION.into(),
920 digest: None,
921 url,
922 })
923 }
924
925 async fn fetch_server_binary(
926 &self,
927 version: GitHubLspBinaryVersion,
928 container_dir: PathBuf,
929 delegate: &dyn LspAdapterDelegate,
930 ) -> Result<LanguageServerBinary> {
931 let destination_path = Self::build_destination_path(&container_dir);
932 let server_path = destination_path.join(Self::SERVER_PATH);
933
934 if fs::metadata(&server_path).await.is_err() {
935 remove_matching(&container_dir, |_| true).await;
936
937 download_server_binary(
938 &*delegate.http_client(),
939 &version.url,
940 None,
941 &destination_path,
942 Self::GITHUB_ASSET_KIND,
943 )
944 .await?;
945
946 let mut dir = fs::read_dir(&destination_path).await?;
947 let first = dir.next().await.context("missing first file")??;
948 let repo_root = destination_path.join("vscode-eslint");
949 fs::rename(first.path(), &repo_root).await?;
950
951 #[cfg(target_os = "windows")]
952 {
953 handle_symlink(
954 repo_root.join("$shared"),
955 repo_root.join("client").join("src").join("shared"),
956 )
957 .await?;
958 handle_symlink(
959 repo_root.join("$shared"),
960 repo_root.join("server").join("src").join("shared"),
961 )
962 .await?;
963 }
964
965 self.node
966 .run_npm_subcommand(&repo_root, "install", &[])
967 .await?;
968
969 self.node
970 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
971 .await?;
972 }
973
974 Ok(LanguageServerBinary {
975 path: self.node.binary_path().await?,
976 env: None,
977 arguments: eslint_server_binary_arguments(&server_path),
978 })
979 }
980
981 async fn cached_server_binary(
982 &self,
983 container_dir: PathBuf,
984 _: &dyn LspAdapterDelegate,
985 ) -> Option<LanguageServerBinary> {
986 let server_path =
987 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
988 Some(LanguageServerBinary {
989 path: self.node.binary_path().await.ok()?,
990 env: None,
991 arguments: eslint_server_binary_arguments(&server_path),
992 })
993 }
994}
995
996#[async_trait(?Send)]
997impl LspAdapter for EsLintLspAdapter {
998 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
999 Some(vec![
1000 CodeActionKind::QUICKFIX,
1001 CodeActionKind::new("source.fixAll.eslint"),
1002 ])
1003 }
1004
1005 async fn workspace_configuration(
1006 self: Arc<Self>,
1007 delegate: &Arc<dyn LspAdapterDelegate>,
1008 _: Option<Toolchain>,
1009 cx: &mut AsyncApp,
1010 ) -> Result<Value> {
1011 let workspace_root = delegate.worktree_root_path();
1012 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
1013 .iter()
1014 .any(|file| workspace_root.join(file).is_file());
1015
1016 let mut default_workspace_configuration = json!({
1017 "validate": "on",
1018 "rulesCustomizations": [],
1019 "run": "onType",
1020 "nodePath": null,
1021 "workingDirectory": {
1022 "mode": "auto"
1023 },
1024 "workspaceFolder": {
1025 "uri": workspace_root,
1026 "name": workspace_root.file_name()
1027 .unwrap_or(workspace_root.as_os_str())
1028 .to_string_lossy(),
1029 },
1030 "problems": {},
1031 "codeActionOnSave": {
1032 // We enable this, but without also configuring code_actions_on_format
1033 // in the Zed configuration, it doesn't have an effect.
1034 "enable": true,
1035 },
1036 "codeAction": {
1037 "disableRuleComment": {
1038 "enable": true,
1039 "location": "separateLine",
1040 },
1041 "showDocumentation": {
1042 "enable": true
1043 }
1044 },
1045 "experimental": {
1046 "useFlatConfig": use_flat_config,
1047 }
1048 });
1049
1050 let override_options = cx.update(|cx| {
1051 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
1052 .and_then(|s| s.settings.clone())
1053 })?;
1054
1055 if let Some(override_options) = override_options {
1056 merge_json_value_into(override_options, &mut default_workspace_configuration);
1057 }
1058
1059 Ok(json!({
1060 "": default_workspace_configuration
1061 }))
1062 }
1063
1064 fn name(&self) -> LanguageServerName {
1065 Self::SERVER_NAME
1066 }
1067}
1068
1069#[cfg(target_os = "windows")]
1070async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1071 anyhow::ensure!(
1072 fs::metadata(&src_dir).await.is_ok(),
1073 "Directory {src_dir:?} is not present"
1074 );
1075 if fs::metadata(&dest_dir).await.is_ok() {
1076 fs::remove_file(&dest_dir).await?;
1077 }
1078 fs::create_dir_all(&dest_dir).await?;
1079 let mut entries = fs::read_dir(&src_dir).await?;
1080 while let Some(entry) = entries.try_next().await? {
1081 let entry_path = entry.path();
1082 let entry_name = entry.file_name();
1083 let dest_path = dest_dir.join(&entry_name);
1084 fs::copy(&entry_path, &dest_path).await?;
1085 }
1086 Ok(())
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use std::path::Path;
1092
1093 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1094 use language::language_settings;
1095 use project::{FakeFs, Project};
1096 use serde_json::json;
1097 use task::TaskTemplates;
1098 use unindent::Unindent;
1099 use util::{path, rel_path::rel_path};
1100
1101 use crate::typescript::{
1102 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
1103 };
1104
1105 #[gpui::test]
1106 async fn test_outline(cx: &mut TestAppContext) {
1107 for language in [
1108 crate::language(
1109 "typescript",
1110 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1111 ),
1112 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1113 ] {
1114 let text = r#"
1115 function a() {
1116 // local variables are included
1117 let a1 = 1;
1118 // all functions are included
1119 async function a2() {}
1120 }
1121 // top-level variables are included
1122 let b: C
1123 function getB() {}
1124 // exported variables are included
1125 export const d = e;
1126 "#
1127 .unindent();
1128
1129 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1130 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1131 assert_eq!(
1132 outline
1133 .items
1134 .iter()
1135 .map(|item| (item.text.as_str(), item.depth))
1136 .collect::<Vec<_>>(),
1137 &[
1138 ("function a()", 0),
1139 ("let a1", 1),
1140 ("async function a2()", 1),
1141 ("let b", 0),
1142 ("function getB()", 0),
1143 ("const d", 0),
1144 ]
1145 );
1146 }
1147 }
1148
1149 #[gpui::test]
1150 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
1151 for language in [
1152 crate::language(
1153 "typescript",
1154 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1155 ),
1156 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1157 ] {
1158 let text = r#"
1159 // Top-level destructuring
1160 const { a1, a2 } = a;
1161 const [b1, b2] = b;
1162
1163 // Defaults and rest
1164 const [c1 = 1, , c2, ...rest1] = c;
1165 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
1166
1167 function processData() {
1168 // Nested object destructuring
1169 const { c1, c2 } = c;
1170 // Nested array destructuring
1171 const [d1, d2, d3] = d;
1172 // Destructuring with renaming
1173 const { f1: g1 } = f;
1174 // With defaults
1175 const [x = 10, y] = xy;
1176 }
1177
1178 class DataHandler {
1179 method() {
1180 // Destructuring in class method
1181 const { a1, a2 } = a;
1182 const [b1, ...b2] = b;
1183 }
1184 }
1185 "#
1186 .unindent();
1187
1188 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1189 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1190 assert_eq!(
1191 outline
1192 .items
1193 .iter()
1194 .map(|item| (item.text.as_str(), item.depth))
1195 .collect::<Vec<_>>(),
1196 &[
1197 ("const a1", 0),
1198 ("const a2", 0),
1199 ("const b1", 0),
1200 ("const b2", 0),
1201 ("const c1", 0),
1202 ("const c2", 0),
1203 ("const rest1", 0),
1204 ("const d1", 0),
1205 ("const e1", 0),
1206 ("const h1", 0),
1207 ("const rest2", 0),
1208 ("function processData()", 0),
1209 ("const c1", 1),
1210 ("const c2", 1),
1211 ("const d1", 1),
1212 ("const d2", 1),
1213 ("const d3", 1),
1214 ("const g1", 1),
1215 ("const x", 1),
1216 ("const y", 1),
1217 ("class DataHandler", 0),
1218 ("method()", 1),
1219 ("const a1", 2),
1220 ("const a2", 2),
1221 ("const b1", 2),
1222 ("const b2", 2),
1223 ]
1224 );
1225 }
1226 }
1227
1228 #[gpui::test]
1229 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1230 for language in [
1231 crate::language(
1232 "typescript",
1233 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1234 ),
1235 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1236 ] {
1237 let text = r#"
1238 // Object with function properties
1239 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1240
1241 // Object with primitive properties
1242 const p = { p1: 1, p2: "hello", p3: true };
1243
1244 // Nested objects
1245 const q = {
1246 r: {
1247 // won't be included due to one-level depth limit
1248 s: 1
1249 },
1250 t: 2
1251 };
1252
1253 function getData() {
1254 const local = { x: 1, y: 2 };
1255 return local;
1256 }
1257 "#
1258 .unindent();
1259
1260 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1261 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1262 assert_eq!(
1263 outline
1264 .items
1265 .iter()
1266 .map(|item| (item.text.as_str(), item.depth))
1267 .collect::<Vec<_>>(),
1268 &[
1269 ("const o", 0),
1270 ("m()", 1),
1271 ("async n()", 1),
1272 ("g", 1),
1273 ("h", 1),
1274 ("k", 1),
1275 ("const p", 0),
1276 ("p1", 1),
1277 ("p2", 1),
1278 ("p3", 1),
1279 ("const q", 0),
1280 ("r", 1),
1281 ("s", 2),
1282 ("t", 1),
1283 ("function getData()", 0),
1284 ("const local", 1),
1285 ("x", 2),
1286 ("y", 2),
1287 ]
1288 );
1289 }
1290 }
1291
1292 #[gpui::test]
1293 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1294 for language in [
1295 crate::language(
1296 "typescript",
1297 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1298 ),
1299 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1300 ] {
1301 let text = r#"
1302 // Symbols as object keys
1303 const sym = Symbol("test");
1304 const obj1 = {
1305 [sym]: 1,
1306 [Symbol("inline")]: 2,
1307 normalKey: 3
1308 };
1309
1310 // Enums as object keys
1311 enum Color { Red, Blue, Green }
1312
1313 const obj2 = {
1314 [Color.Red]: "red value",
1315 [Color.Blue]: "blue value",
1316 regularProp: "normal"
1317 };
1318
1319 // Mixed computed properties
1320 const key = "dynamic";
1321 const obj3 = {
1322 [key]: 1,
1323 ["string" + "concat"]: 2,
1324 [1 + 1]: 3,
1325 static: 4
1326 };
1327
1328 // Nested objects with computed properties
1329 const obj4 = {
1330 [sym]: {
1331 nested: 1
1332 },
1333 regular: {
1334 [key]: 2
1335 }
1336 };
1337 "#
1338 .unindent();
1339
1340 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1341 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1342 assert_eq!(
1343 outline
1344 .items
1345 .iter()
1346 .map(|item| (item.text.as_str(), item.depth))
1347 .collect::<Vec<_>>(),
1348 &[
1349 ("const sym", 0),
1350 ("const obj1", 0),
1351 ("[sym]", 1),
1352 ("[Symbol(\"inline\")]", 1),
1353 ("normalKey", 1),
1354 ("enum Color", 0),
1355 ("const obj2", 0),
1356 ("[Color.Red]", 1),
1357 ("[Color.Blue]", 1),
1358 ("regularProp", 1),
1359 ("const key", 0),
1360 ("const obj3", 0),
1361 ("[key]", 1),
1362 ("[\"string\" + \"concat\"]", 1),
1363 ("[1 + 1]", 1),
1364 ("static", 1),
1365 ("const obj4", 0),
1366 ("[sym]", 1),
1367 ("nested", 2),
1368 ("regular", 1),
1369 ("[key]", 2),
1370 ]
1371 );
1372 }
1373 }
1374
1375 #[gpui::test]
1376 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1377 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1378
1379 let text = r#"
1380 function normalFunction() {
1381 console.log("normal");
1382 }
1383
1384 function* simpleGenerator() {
1385 yield 1;
1386 yield 2;
1387 }
1388
1389 async function* asyncGenerator() {
1390 yield await Promise.resolve(1);
1391 }
1392
1393 function* generatorWithParams(start, end) {
1394 for (let i = start; i <= end; i++) {
1395 yield i;
1396 }
1397 }
1398
1399 class TestClass {
1400 *methodGenerator() {
1401 yield "method";
1402 }
1403
1404 async *asyncMethodGenerator() {
1405 yield "async method";
1406 }
1407 }
1408 "#
1409 .unindent();
1410
1411 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1412 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1413 assert_eq!(
1414 outline
1415 .items
1416 .iter()
1417 .map(|item| (item.text.as_str(), item.depth))
1418 .collect::<Vec<_>>(),
1419 &[
1420 ("function normalFunction()", 0),
1421 ("function* simpleGenerator()", 0),
1422 ("async function* asyncGenerator()", 0),
1423 ("function* generatorWithParams( )", 0),
1424 ("class TestClass", 0),
1425 ("*methodGenerator()", 1),
1426 ("async *asyncMethodGenerator()", 1),
1427 ]
1428 );
1429 }
1430
1431 #[gpui::test]
1432 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1433 cx.update(|cx| {
1434 settings::init(cx);
1435 Project::init_settings(cx);
1436 language_settings::init(cx);
1437 });
1438
1439 let package_json_1 = json!({
1440 "dependencies": {
1441 "mocha": "1.0.0",
1442 "vitest": "1.0.0"
1443 },
1444 "scripts": {
1445 "test": ""
1446 }
1447 })
1448 .to_string();
1449
1450 let package_json_2 = json!({
1451 "devDependencies": {
1452 "vitest": "2.0.0"
1453 },
1454 "scripts": {
1455 "test": ""
1456 }
1457 })
1458 .to_string();
1459
1460 let fs = FakeFs::new(executor);
1461 fs.insert_tree(
1462 path!("/root"),
1463 json!({
1464 "package.json": package_json_1,
1465 "sub": {
1466 "package.json": package_json_2,
1467 "file.js": "",
1468 }
1469 }),
1470 )
1471 .await;
1472
1473 let provider = TypeScriptContextProvider::new(fs.clone());
1474 let package_json_data = cx
1475 .update(|cx| {
1476 provider.combined_package_json_data(
1477 fs.clone(),
1478 path!("/root").as_ref(),
1479 rel_path("sub/file1.js"),
1480 cx,
1481 )
1482 })
1483 .await
1484 .unwrap();
1485 pretty_assertions::assert_eq!(
1486 package_json_data,
1487 PackageJsonData {
1488 jest_package_path: None,
1489 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1490 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1491 jasmine_package_path: None,
1492 bun_package_path: None,
1493 node_package_path: None,
1494 scripts: [
1495 (
1496 Path::new(path!("/root/package.json")).into(),
1497 "test".to_owned()
1498 ),
1499 (
1500 Path::new(path!("/root/sub/package.json")).into(),
1501 "test".to_owned()
1502 )
1503 ]
1504 .into_iter()
1505 .collect(),
1506 package_manager: None,
1507 }
1508 );
1509
1510 let mut task_templates = TaskTemplates::default();
1511 package_json_data.fill_task_templates(&mut task_templates);
1512 let task_templates = task_templates
1513 .0
1514 .into_iter()
1515 .map(|template| (template.label, template.cwd))
1516 .collect::<Vec<_>>();
1517 pretty_assertions::assert_eq!(
1518 task_templates,
1519 [
1520 (
1521 "vitest file test".into(),
1522 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1523 ),
1524 (
1525 "vitest test $ZED_SYMBOL".into(),
1526 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1527 ),
1528 (
1529 "mocha file test".into(),
1530 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1531 ),
1532 (
1533 "mocha test $ZED_SYMBOL".into(),
1534 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1535 ),
1536 (
1537 "root/package.json > test".into(),
1538 Some(path!("/root").into())
1539 ),
1540 (
1541 "sub/package.json > test".into(),
1542 Some(path!("/root/sub").into())
1543 ),
1544 ]
1545 );
1546 }
1547
1548 #[test]
1549 fn test_escaping_name() {
1550 let cases = [
1551 ("plain test name", "plain test name"),
1552 ("test name with $param_name", "test name with (.+?)"),
1553 ("test name with $nested.param.name", "test name with (.+?)"),
1554 ("test name with $#", "test name with (.+?)"),
1555 ("test name with $##", "test name with (.+?)\\#"),
1556 ("test name with %p", "test name with (.+?)"),
1557 ("test name with %s", "test name with (.+?)"),
1558 ("test name with %d", "test name with (.+?)"),
1559 ("test name with %i", "test name with (.+?)"),
1560 ("test name with %f", "test name with (.+?)"),
1561 ("test name with %j", "test name with (.+?)"),
1562 ("test name with %o", "test name with (.+?)"),
1563 ("test name with %#", "test name with (.+?)"),
1564 ("test name with %$", "test name with (.+?)"),
1565 ("test name with %%", "test name with (.+?)"),
1566 ("test name with %q", "test name with %q"),
1567 (
1568 "test name with regex chars .*+?^${}()|[]\\",
1569 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1570 ),
1571 (
1572 "test name with multiple $params and %pretty and %b and (.+?)",
1573 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1574 ),
1575 ];
1576
1577 for (input, expected) in cases {
1578 assert_eq!(replace_test_name_parameters(input), expected);
1579 }
1580 }
1581
1582 // The order of test runner tasks is based on inferred user preference:
1583 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1584 // 2. Bun's built-in test runner (`bun test`) comes next.
1585 // 3. Node.js's built-in test runner (`node --test`) is last.
1586 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1587 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1588 // typically preferred over `node --test` when @types/bun is present.
1589 #[gpui::test]
1590 async fn test_task_ordering_with_multiple_test_runners(
1591 executor: BackgroundExecutor,
1592 cx: &mut TestAppContext,
1593 ) {
1594 cx.update(|cx| {
1595 settings::init(cx);
1596 Project::init_settings(cx);
1597 language_settings::init(cx);
1598 });
1599
1600 // Test case with all test runners present
1601 let package_json_all_runners = json!({
1602 "devDependencies": {
1603 "@types/bun": "1.0.0",
1604 "@types/node": "^20.0.0",
1605 "jest": "29.0.0",
1606 "mocha": "10.0.0",
1607 "vitest": "1.0.0",
1608 "jasmine": "5.0.0",
1609 },
1610 "scripts": {
1611 "test": "jest"
1612 }
1613 })
1614 .to_string();
1615
1616 let fs = FakeFs::new(executor);
1617 fs.insert_tree(
1618 path!("/root"),
1619 json!({
1620 "package.json": package_json_all_runners,
1621 "file.js": "",
1622 }),
1623 )
1624 .await;
1625
1626 let provider = TypeScriptContextProvider::new(fs.clone());
1627
1628 let package_json_data = cx
1629 .update(|cx| {
1630 provider.combined_package_json_data(
1631 fs.clone(),
1632 path!("/root").as_ref(),
1633 rel_path("file.js"),
1634 cx,
1635 )
1636 })
1637 .await
1638 .unwrap();
1639
1640 assert!(package_json_data.jest_package_path.is_some());
1641 assert!(package_json_data.mocha_package_path.is_some());
1642 assert!(package_json_data.vitest_package_path.is_some());
1643 assert!(package_json_data.jasmine_package_path.is_some());
1644 assert!(package_json_data.bun_package_path.is_some());
1645 assert!(package_json_data.node_package_path.is_some());
1646
1647 let mut task_templates = TaskTemplates::default();
1648 package_json_data.fill_task_templates(&mut task_templates);
1649
1650 let test_tasks: Vec<_> = task_templates
1651 .0
1652 .iter()
1653 .filter(|template| {
1654 template.tags.contains(&"ts-test".to_owned())
1655 || template.tags.contains(&"js-test".to_owned())
1656 })
1657 .map(|template| &template.label)
1658 .collect();
1659
1660 let node_test_index = test_tasks
1661 .iter()
1662 .position(|label| label.contains("node test"));
1663 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1664 let bun_test_index = test_tasks
1665 .iter()
1666 .position(|label| label.contains("bun test"));
1667
1668 assert!(
1669 node_test_index.is_some(),
1670 "Node test tasks should be present"
1671 );
1672 assert!(
1673 jest_test_index.is_some(),
1674 "Jest test tasks should be present"
1675 );
1676 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1677
1678 assert!(
1679 jest_test_index.unwrap() < bun_test_index.unwrap(),
1680 "Jest should come before Bun"
1681 );
1682 assert!(
1683 bun_test_index.unwrap() < node_test_index.unwrap(),
1684 "Bun should come before Node"
1685 );
1686 }
1687}