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