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