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 itertools::Itertools as _;
8use language::{
9 ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
10 LspAdapterDelegate, LspInstaller, Toolchain,
11};
12use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
13use node_runtime::{NodeRuntime, VersionStrategy};
14use project::{Fs, lsp_store::language_server_settings};
15use serde_json::{Value, json};
16use smol::lock::RwLock;
17use std::{
18 borrow::Cow,
19 ffi::OsString,
20 path::{Path, PathBuf},
21 sync::{Arc, LazyLock},
22};
23use task::{TaskTemplate, TaskTemplates, VariableName};
24use util::rel_path::RelPath;
25use util::{ResultExt, maybe};
26
27use crate::{PackageJson, PackageJsonData};
28
29pub(crate) struct TypeScriptContextProvider {
30 fs: Arc<dyn Fs>,
31 last_package_json: PackageJsonContents,
32}
33
34const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
35 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
36
37const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
38 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
39
40const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
41 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
42
43const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
44 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
45
46const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
47 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
48
49const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
50 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
51
52const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
53 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
54
55const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
56 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
57
58const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
59 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
60
61#[derive(Clone, Debug, Default)]
62struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
63
64impl PackageJsonData {
65 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
66 if self.jest_package_path.is_some() {
67 task_templates.0.push(TaskTemplate {
68 label: "jest file test".to_owned(),
69 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
70 args: vec![
71 "exec".to_owned(),
72 "--".to_owned(),
73 "jest".to_owned(),
74 "--runInBand".to_owned(),
75 VariableName::File.template_value(),
76 ],
77 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
78 ..TaskTemplate::default()
79 });
80 task_templates.0.push(TaskTemplate {
81 label: format!("jest test {}", VariableName::Symbol.template_value()),
82 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
83 args: vec![
84 "exec".to_owned(),
85 "--".to_owned(),
86 "jest".to_owned(),
87 "--runInBand".to_owned(),
88 "--testNamePattern".to_owned(),
89 format!(
90 "\"{}\"",
91 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
92 ),
93 VariableName::File.template_value(),
94 ],
95 tags: vec![
96 "ts-test".to_owned(),
97 "js-test".to_owned(),
98 "tsx-test".to_owned(),
99 ],
100 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
101 ..TaskTemplate::default()
102 });
103 }
104
105 if self.vitest_package_path.is_some() {
106 task_templates.0.push(TaskTemplate {
107 label: format!("{} file test", "vitest".to_owned()),
108 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
109 args: vec![
110 "exec".to_owned(),
111 "--".to_owned(),
112 "vitest".to_owned(),
113 "run".to_owned(),
114 "--no-file-parallelism".to_owned(),
115 VariableName::File.template_value(),
116 ],
117 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
118 ..TaskTemplate::default()
119 });
120 task_templates.0.push(TaskTemplate {
121 label: format!(
122 "{} test {}",
123 "vitest".to_owned(),
124 VariableName::Symbol.template_value(),
125 ),
126 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
127 args: vec![
128 "exec".to_owned(),
129 "--".to_owned(),
130 "vitest".to_owned(),
131 "run".to_owned(),
132 "--no-file-parallelism".to_owned(),
133 "--testNamePattern".to_owned(),
134 format!(
135 "\"{}\"",
136 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
137 ),
138 VariableName::File.template_value(),
139 ],
140 tags: vec![
141 "ts-test".to_owned(),
142 "js-test".to_owned(),
143 "tsx-test".to_owned(),
144 ],
145 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
146 ..TaskTemplate::default()
147 });
148 }
149
150 if self.mocha_package_path.is_some() {
151 task_templates.0.push(TaskTemplate {
152 label: format!("{} file test", "mocha".to_owned()),
153 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
154 args: vec![
155 "exec".to_owned(),
156 "--".to_owned(),
157 "mocha".to_owned(),
158 VariableName::File.template_value(),
159 ],
160 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
161 ..TaskTemplate::default()
162 });
163 task_templates.0.push(TaskTemplate {
164 label: format!(
165 "{} test {}",
166 "mocha".to_owned(),
167 VariableName::Symbol.template_value(),
168 ),
169 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
170 args: vec![
171 "exec".to_owned(),
172 "--".to_owned(),
173 "mocha".to_owned(),
174 "--grep".to_owned(),
175 format!("\"{}\"", VariableName::Symbol.template_value()),
176 VariableName::File.template_value(),
177 ],
178 tags: vec![
179 "ts-test".to_owned(),
180 "js-test".to_owned(),
181 "tsx-test".to_owned(),
182 ],
183 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
184 ..TaskTemplate::default()
185 });
186 }
187
188 if self.jasmine_package_path.is_some() {
189 task_templates.0.push(TaskTemplate {
190 label: format!("{} file test", "jasmine".to_owned()),
191 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
192 args: vec![
193 "exec".to_owned(),
194 "--".to_owned(),
195 "jasmine".to_owned(),
196 VariableName::File.template_value(),
197 ],
198 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
199 ..TaskTemplate::default()
200 });
201 task_templates.0.push(TaskTemplate {
202 label: format!(
203 "{} test {}",
204 "jasmine".to_owned(),
205 VariableName::Symbol.template_value(),
206 ),
207 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
208 args: vec![
209 "exec".to_owned(),
210 "--".to_owned(),
211 "jasmine".to_owned(),
212 format!("--filter={}", VariableName::Symbol.template_value()),
213 VariableName::File.template_value(),
214 ],
215 tags: vec![
216 "ts-test".to_owned(),
217 "js-test".to_owned(),
218 "tsx-test".to_owned(),
219 ],
220 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
221 ..TaskTemplate::default()
222 });
223 }
224
225 if self.bun_package_path.is_some() {
226 task_templates.0.push(TaskTemplate {
227 label: format!("{} file test", "bun test".to_owned()),
228 command: "bun".to_owned(),
229 args: vec!["test".to_owned(), VariableName::File.template_value()],
230 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
231 ..TaskTemplate::default()
232 });
233 task_templates.0.push(TaskTemplate {
234 label: format!("bun test {}", VariableName::Symbol.template_value(),),
235 command: "bun".to_owned(),
236 args: vec![
237 "test".to_owned(),
238 "--test-name-pattern".to_owned(),
239 format!("\"{}\"", VariableName::Symbol.template_value()),
240 VariableName::File.template_value(),
241 ],
242 tags: vec![
243 "ts-test".to_owned(),
244 "js-test".to_owned(),
245 "tsx-test".to_owned(),
246 ],
247 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
248 ..TaskTemplate::default()
249 });
250 }
251
252 if self.node_package_path.is_some() {
253 task_templates.0.push(TaskTemplate {
254 label: format!("{} file test", "node test".to_owned()),
255 command: "node".to_owned(),
256 args: vec!["--test".to_owned(), VariableName::File.template_value()],
257 tags: vec![
258 "ts-test".to_owned(),
259 "js-test".to_owned(),
260 "tsx-test".to_owned(),
261 ],
262 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
263 ..TaskTemplate::default()
264 });
265 task_templates.0.push(TaskTemplate {
266 label: format!("node test {}", VariableName::Symbol.template_value()),
267 command: "node".to_owned(),
268 args: vec![
269 "--test".to_owned(),
270 "--test-name-pattern".to_owned(),
271 format!("\"{}\"", VariableName::Symbol.template_value()),
272 VariableName::File.template_value(),
273 ],
274 tags: vec![
275 "ts-test".to_owned(),
276 "js-test".to_owned(),
277 "tsx-test".to_owned(),
278 ],
279 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
280 ..TaskTemplate::default()
281 });
282 }
283
284 let script_name_counts: HashMap<_, usize> =
285 self.scripts
286 .iter()
287 .fold(HashMap::default(), |mut acc, (_, script)| {
288 *acc.entry(script).or_default() += 1;
289 acc
290 });
291 for (path, script) in &self.scripts {
292 let label = if script_name_counts.get(script).copied().unwrap_or_default() > 1
293 && let Some(parent) = path.parent().and_then(|parent| parent.file_name())
294 {
295 let parent = parent.to_string_lossy();
296 format!("{parent}/package.json > {script}")
297 } else {
298 format!("package.json > {script}")
299 };
300 task_templates.0.push(TaskTemplate {
301 label,
302 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
303 args: vec!["run".to_owned(), script.to_owned()],
304 tags: vec!["package-script".into()],
305 cwd: Some(
306 path.parent()
307 .unwrap_or(Path::new("/"))
308 .to_string_lossy()
309 .to_string(),
310 ),
311 ..TaskTemplate::default()
312 });
313 }
314 }
315}
316
317impl TypeScriptContextProvider {
318 pub fn new(fs: Arc<dyn Fs>) -> Self {
319 Self {
320 fs,
321 last_package_json: PackageJsonContents::default(),
322 }
323 }
324
325 fn combined_package_json_data(
326 &self,
327 fs: Arc<dyn Fs>,
328 worktree_root: &Path,
329 file_relative_path: &RelPath,
330 cx: &App,
331 ) -> Task<anyhow::Result<PackageJsonData>> {
332 let new_json_data = file_relative_path
333 .ancestors()
334 .map(|path| worktree_root.join(path.as_std_path()))
335 .map(|parent_path| {
336 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
337 })
338 .collect::<Vec<_>>();
339
340 cx.background_spawn(async move {
341 let mut package_json_data = PackageJsonData::default();
342 for new_data in join_all(new_json_data).await.into_iter().flatten() {
343 package_json_data.merge(new_data);
344 }
345 Ok(package_json_data)
346 })
347 }
348
349 fn package_json_data(
350 &self,
351 directory_path: &Path,
352 existing_package_json: PackageJsonContents,
353 fs: Arc<dyn Fs>,
354 cx: &App,
355 ) -> Task<anyhow::Result<PackageJsonData>> {
356 let package_json_path = directory_path.join("package.json");
357 let metadata_check_fs = fs.clone();
358 cx.background_spawn(async move {
359 let metadata = metadata_check_fs
360 .metadata(&package_json_path)
361 .await
362 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
363 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
364 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
365 let existing_data = {
366 let contents = existing_package_json.0.read().await;
367 contents
368 .get(&package_json_path)
369 .filter(|package_json| package_json.mtime == mtime)
370 .map(|package_json| package_json.data.clone())
371 };
372 match existing_data {
373 Some(existing_data) => Ok(existing_data),
374 None => {
375 let package_json_string =
376 fs.load(&package_json_path).await.with_context(|| {
377 format!("loading package.json from {package_json_path:?}")
378 })?;
379 let package_json: HashMap<String, serde_json_lenient::Value> =
380 serde_json_lenient::from_str(&package_json_string).with_context(|| {
381 format!("parsing package.json from {package_json_path:?}")
382 })?;
383 let new_data =
384 PackageJsonData::new(package_json_path.as_path().into(), package_json);
385 {
386 let mut contents = existing_package_json.0.write().await;
387 contents.insert(
388 package_json_path,
389 PackageJson {
390 mtime,
391 data: new_data.clone(),
392 },
393 );
394 }
395 Ok(new_data)
396 }
397 }
398 })
399 }
400}
401
402async fn detect_package_manager(
403 worktree_root: PathBuf,
404 fs: Arc<dyn Fs>,
405 package_json_data: Option<PackageJsonData>,
406) -> &'static str {
407 if let Some(package_json_data) = package_json_data
408 && let Some(package_manager) = package_json_data.package_manager
409 {
410 return package_manager;
411 }
412 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
413 return "pnpm";
414 }
415 if fs.is_file(&worktree_root.join("yarn.lock")).await {
416 return "yarn";
417 }
418 "npm"
419}
420
421impl ContextProvider for TypeScriptContextProvider {
422 fn associated_tasks(
423 &self,
424 file: Option<Arc<dyn File>>,
425 cx: &App,
426 ) -> Task<Option<TaskTemplates>> {
427 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
428 return Task::ready(None);
429 };
430 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
431 return Task::ready(None);
432 };
433 let file_relative_path = file.path().clone();
434 let package_json_data = self.combined_package_json_data(
435 self.fs.clone(),
436 &worktree_root,
437 &file_relative_path,
438 cx,
439 );
440
441 cx.background_spawn(async move {
442 let mut task_templates = TaskTemplates(Vec::new());
443 task_templates.0.push(TaskTemplate {
444 label: format!(
445 "execute selection {}",
446 VariableName::SelectedText.template_value()
447 ),
448 command: "node".to_owned(),
449 args: vec![
450 "-e".to_owned(),
451 format!("\"{}\"", VariableName::SelectedText.template_value()),
452 ],
453 ..TaskTemplate::default()
454 });
455
456 match package_json_data.await {
457 Ok(package_json) => {
458 package_json.fill_task_templates(&mut task_templates);
459 }
460 Err(e) => {
461 log::error!(
462 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
463 );
464 }
465 }
466
467 Some(task_templates)
468 })
469 }
470
471 fn build_context(
472 &self,
473 current_vars: &task::TaskVariables,
474 location: ContextLocation<'_>,
475 _project_env: Option<HashMap<String, String>>,
476 _toolchains: Arc<dyn LanguageToolchainStore>,
477 cx: &mut App,
478 ) -> Task<Result<task::TaskVariables>> {
479 let mut vars = task::TaskVariables::default();
480
481 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
482 vars.insert(
483 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
484 replace_test_name_parameters(symbol),
485 );
486 vars.insert(
487 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
488 replace_test_name_parameters(symbol),
489 );
490 }
491 let file_path = location
492 .file_location
493 .buffer
494 .read(cx)
495 .file()
496 .map(|file| file.path());
497
498 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
499 |((worktree_root, fs), file_path)| {
500 (
501 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
502 worktree_root,
503 fs,
504 )
505 },
506 );
507 cx.background_spawn(async move {
508 if let Some((task, worktree_root, fs)) = args {
509 let package_json_data = task.await.log_err();
510 vars.insert(
511 TYPESCRIPT_RUNNER_VARIABLE,
512 detect_package_manager(worktree_root, fs, package_json_data.clone())
513 .await
514 .to_owned(),
515 );
516
517 if let Some(package_json_data) = package_json_data {
518 if let Some(path) = package_json_data.jest_package_path {
519 vars.insert(
520 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
521 path.parent()
522 .unwrap_or(Path::new(""))
523 .to_string_lossy()
524 .to_string(),
525 );
526 }
527
528 if let Some(path) = package_json_data.mocha_package_path {
529 vars.insert(
530 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
531 path.parent()
532 .unwrap_or(Path::new(""))
533 .to_string_lossy()
534 .to_string(),
535 );
536 }
537
538 if let Some(path) = package_json_data.vitest_package_path {
539 vars.insert(
540 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
541 path.parent()
542 .unwrap_or(Path::new(""))
543 .to_string_lossy()
544 .to_string(),
545 );
546 }
547
548 if let Some(path) = package_json_data.jasmine_package_path {
549 vars.insert(
550 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
551 path.parent()
552 .unwrap_or(Path::new(""))
553 .to_string_lossy()
554 .to_string(),
555 );
556 }
557
558 if let Some(path) = package_json_data.bun_package_path {
559 vars.insert(
560 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
561 path.parent()
562 .unwrap_or(Path::new(""))
563 .to_string_lossy()
564 .to_string(),
565 );
566 }
567
568 if let Some(path) = package_json_data.node_package_path {
569 vars.insert(
570 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
571 path.parent()
572 .unwrap_or(Path::new(""))
573 .to_string_lossy()
574 .to_string(),
575 );
576 }
577 }
578 }
579 Ok(vars)
580 })
581 }
582}
583
584fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
585 vec![server_path.into(), "--stdio".into()]
586}
587
588fn replace_test_name_parameters(test_name: &str) -> String {
589 static PATTERN: LazyLock<regex::Regex> =
590 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
591 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
592}
593
594pub struct TypeScriptLspAdapter {
595 fs: Arc<dyn Fs>,
596 node: NodeRuntime,
597}
598
599impl TypeScriptLspAdapter {
600 const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
601 const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
602
603 const PACKAGE_NAME: &str = "typescript";
604 const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
605
606 const SERVER_NAME: LanguageServerName =
607 LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
608
609 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
610 TypeScriptLspAdapter { fs, node }
611 }
612
613 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
614 let is_yarn = adapter
615 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
616 .await
617 .is_ok();
618
619 let tsdk_path = if is_yarn {
620 ".yarn/sdks/typescript/lib"
621 } else {
622 "node_modules/typescript/lib"
623 };
624
625 if self
626 .fs
627 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
628 .await
629 {
630 Some(tsdk_path)
631 } else {
632 None
633 }
634 }
635}
636
637pub struct TypeScriptVersions {
638 typescript_version: String,
639 server_version: String,
640}
641
642impl LspInstaller for TypeScriptLspAdapter {
643 type BinaryVersion = TypeScriptVersions;
644
645 async fn fetch_latest_server_version(
646 &self,
647 _: &dyn LspAdapterDelegate,
648 _: bool,
649 _: &mut AsyncApp,
650 ) -> Result<TypeScriptVersions> {
651 Ok(TypeScriptVersions {
652 typescript_version: self
653 .node
654 .npm_package_latest_version(Self::PACKAGE_NAME)
655 .await?,
656 server_version: self
657 .node
658 .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
659 .await?,
660 })
661 }
662
663 async fn check_if_version_installed(
664 &self,
665 version: &TypeScriptVersions,
666 container_dir: &PathBuf,
667 _: &dyn LspAdapterDelegate,
668 ) -> Option<LanguageServerBinary> {
669 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
670
671 if self
672 .node
673 .should_install_npm_package(
674 Self::PACKAGE_NAME,
675 &server_path,
676 container_dir,
677 VersionStrategy::Latest(version.typescript_version.as_str()),
678 )
679 .await
680 {
681 return None;
682 }
683
684 if self
685 .node
686 .should_install_npm_package(
687 Self::SERVER_PACKAGE_NAME,
688 &server_path,
689 container_dir,
690 VersionStrategy::Latest(version.server_version.as_str()),
691 )
692 .await
693 {
694 return None;
695 }
696
697 Some(LanguageServerBinary {
698 path: self.node.binary_path().await.ok()?,
699 env: None,
700 arguments: typescript_server_binary_arguments(&server_path),
701 })
702 }
703
704 async fn fetch_server_binary(
705 &self,
706 latest_version: TypeScriptVersions,
707 container_dir: PathBuf,
708 _: &dyn LspAdapterDelegate,
709 ) -> Result<LanguageServerBinary> {
710 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
711
712 self.node
713 .npm_install_packages(
714 &container_dir,
715 &[
716 (
717 Self::PACKAGE_NAME,
718 latest_version.typescript_version.as_str(),
719 ),
720 (
721 Self::SERVER_PACKAGE_NAME,
722 latest_version.server_version.as_str(),
723 ),
724 ],
725 )
726 .await?;
727
728 Ok(LanguageServerBinary {
729 path: self.node.binary_path().await?,
730 env: None,
731 arguments: typescript_server_binary_arguments(&server_path),
732 })
733 }
734
735 async fn cached_server_binary(
736 &self,
737 container_dir: PathBuf,
738 _: &dyn LspAdapterDelegate,
739 ) -> Option<LanguageServerBinary> {
740 get_cached_ts_server_binary(container_dir, &self.node).await
741 }
742}
743
744#[async_trait(?Send)]
745impl LspAdapter for TypeScriptLspAdapter {
746 fn name(&self) -> LanguageServerName {
747 Self::SERVER_NAME
748 }
749
750 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
751 Some(vec![
752 CodeActionKind::QUICKFIX,
753 CodeActionKind::REFACTOR,
754 CodeActionKind::REFACTOR_EXTRACT,
755 CodeActionKind::SOURCE,
756 ])
757 }
758
759 async fn label_for_completion(
760 &self,
761 item: &lsp::CompletionItem,
762 language: &Arc<language::Language>,
763 ) -> Option<language::CodeLabel> {
764 use lsp::CompletionItemKind as Kind;
765 let label_len = item.label.len();
766 let grammar = language.grammar()?;
767 let highlight_id = match item.kind? {
768 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
769 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
770 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
771 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
772 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
773 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
774 _ => None,
775 }?;
776
777 let text = if let Some(description) = item
778 .label_details
779 .as_ref()
780 .and_then(|label_details| label_details.description.as_ref())
781 {
782 format!("{} {}", item.label, description)
783 } else if let Some(detail) = &item.detail {
784 format!("{} {}", item.label, detail)
785 } else {
786 item.label.clone()
787 };
788 Some(language::CodeLabel::filtered(
789 text,
790 label_len,
791 item.filter_text.as_deref(),
792 vec![(0..label_len, highlight_id)],
793 ))
794 }
795
796 async fn initialization_options(
797 self: Arc<Self>,
798 adapter: &Arc<dyn LspAdapterDelegate>,
799 ) -> Result<Option<serde_json::Value>> {
800 let tsdk_path = self.tsdk_path(adapter).await;
801 Ok(Some(json!({
802 "provideFormatter": true,
803 "hostInfo": "zed",
804 "tsserver": {
805 "path": tsdk_path,
806 },
807 "preferences": {
808 "includeInlayParameterNameHints": "all",
809 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
810 "includeInlayFunctionParameterTypeHints": true,
811 "includeInlayVariableTypeHints": true,
812 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
813 "includeInlayPropertyDeclarationTypeHints": true,
814 "includeInlayFunctionLikeReturnTypeHints": true,
815 "includeInlayEnumMemberValueHints": true,
816 }
817 })))
818 }
819
820 async fn workspace_configuration(
821 self: Arc<Self>,
822 delegate: &Arc<dyn LspAdapterDelegate>,
823 _: Option<Toolchain>,
824 _: Option<Uri>,
825 cx: &mut AsyncApp,
826 ) -> Result<Value> {
827 let override_options = cx.update(|cx| {
828 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
829 .and_then(|s| s.settings.clone())
830 })?;
831 if let Some(options) = override_options {
832 return Ok(options);
833 }
834 Ok(json!({
835 "completions": {
836 "completeFunctionCalls": true
837 }
838 }))
839 }
840
841 fn language_ids(&self) -> HashMap<LanguageName, String> {
842 HashMap::from_iter([
843 (LanguageName::new_static("TypeScript"), "typescript".into()),
844 (LanguageName::new_static("JavaScript"), "javascript".into()),
845 (LanguageName::new_static("TSX"), "typescriptreact".into()),
846 ])
847 }
848}
849
850async fn get_cached_ts_server_binary(
851 container_dir: PathBuf,
852 node: &NodeRuntime,
853) -> Option<LanguageServerBinary> {
854 maybe!(async {
855 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
856 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
857 if new_server_path.exists() {
858 Ok(LanguageServerBinary {
859 path: node.binary_path().await?,
860 env: None,
861 arguments: typescript_server_binary_arguments(&new_server_path),
862 })
863 } else if old_server_path.exists() {
864 Ok(LanguageServerBinary {
865 path: node.binary_path().await?,
866 env: None,
867 arguments: typescript_server_binary_arguments(&old_server_path),
868 })
869 } else {
870 anyhow::bail!("missing executable in directory {container_dir:?}")
871 }
872 })
873 .await
874 .log_err()
875}
876
877#[cfg(test)]
878mod tests {
879 use std::path::Path;
880
881 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
882 use project::FakeFs;
883 use serde_json::json;
884 use task::TaskTemplates;
885 use unindent::Unindent;
886 use util::{path, rel_path::rel_path};
887
888 use crate::typescript::{
889 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
890 };
891
892 #[gpui::test]
893 async fn test_outline(cx: &mut TestAppContext) {
894 for language in [
895 crate::language(
896 "typescript",
897 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
898 ),
899 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
900 ] {
901 let text = r#"
902 function a() {
903 // local variables are included
904 let a1 = 1;
905 // all functions are included
906 async function a2() {}
907 }
908 // top-level variables are included
909 let b: C
910 function getB() {}
911 // exported variables are included
912 export const d = e;
913 "#
914 .unindent();
915
916 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
917 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
918 assert_eq!(
919 outline
920 .items
921 .iter()
922 .map(|item| (item.text.as_str(), item.depth))
923 .collect::<Vec<_>>(),
924 &[
925 ("function a()", 0),
926 ("let a1", 1),
927 ("async function a2()", 1),
928 ("let b", 0),
929 ("function getB()", 0),
930 ("const d", 0),
931 ]
932 );
933 }
934 }
935
936 #[gpui::test]
937 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
938 for language in [
939 crate::language(
940 "typescript",
941 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
942 ),
943 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
944 ] {
945 let text = r#"
946 // Top-level destructuring
947 const { a1, a2 } = a;
948 const [b1, b2] = b;
949
950 // Defaults and rest
951 const [c1 = 1, , c2, ...rest1] = c;
952 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
953
954 function processData() {
955 // Nested object destructuring
956 const { c1, c2 } = c;
957 // Nested array destructuring
958 const [d1, d2, d3] = d;
959 // Destructuring with renaming
960 const { f1: g1 } = f;
961 // With defaults
962 const [x = 10, y] = xy;
963 }
964
965 class DataHandler {
966 method() {
967 // Destructuring in class method
968 const { a1, a2 } = a;
969 const [b1, ...b2] = b;
970 }
971 }
972 "#
973 .unindent();
974
975 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
976 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
977 assert_eq!(
978 outline
979 .items
980 .iter()
981 .map(|item| (item.text.as_str(), item.depth))
982 .collect::<Vec<_>>(),
983 &[
984 ("const a1", 0),
985 ("const a2", 0),
986 ("const b1", 0),
987 ("const b2", 0),
988 ("const c1", 0),
989 ("const c2", 0),
990 ("const rest1", 0),
991 ("const d1", 0),
992 ("const e1", 0),
993 ("const h1", 0),
994 ("const rest2", 0),
995 ("function processData()", 0),
996 ("const c1", 1),
997 ("const c2", 1),
998 ("const d1", 1),
999 ("const d2", 1),
1000 ("const d3", 1),
1001 ("const g1", 1),
1002 ("const x", 1),
1003 ("const y", 1),
1004 ("class DataHandler", 0),
1005 ("method()", 1),
1006 ("const a1", 2),
1007 ("const a2", 2),
1008 ("const b1", 2),
1009 ("const b2", 2),
1010 ]
1011 );
1012 }
1013 }
1014
1015 #[gpui::test]
1016 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1017 for language in [
1018 crate::language(
1019 "typescript",
1020 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1021 ),
1022 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1023 ] {
1024 let text = r#"
1025 // Object with function properties
1026 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1027
1028 // Object with primitive properties
1029 const p = { p1: 1, p2: "hello", p3: true };
1030
1031 // Nested objects
1032 const q = {
1033 r: {
1034 // won't be included due to one-level depth limit
1035 s: 1
1036 },
1037 t: 2
1038 };
1039
1040 function getData() {
1041 const local = { x: 1, y: 2 };
1042 return local;
1043 }
1044 "#
1045 .unindent();
1046
1047 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1048 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1049 assert_eq!(
1050 outline
1051 .items
1052 .iter()
1053 .map(|item| (item.text.as_str(), item.depth))
1054 .collect::<Vec<_>>(),
1055 &[
1056 ("const o", 0),
1057 ("m()", 1),
1058 ("async n()", 1),
1059 ("g", 1),
1060 ("h", 1),
1061 ("k", 1),
1062 ("const p", 0),
1063 ("p1", 1),
1064 ("p2", 1),
1065 ("p3", 1),
1066 ("const q", 0),
1067 ("r", 1),
1068 ("s", 2),
1069 ("t", 1),
1070 ("function getData()", 0),
1071 ("const local", 1),
1072 ("x", 2),
1073 ("y", 2),
1074 ]
1075 );
1076 }
1077 }
1078
1079 #[gpui::test]
1080 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1081 for language in [
1082 crate::language(
1083 "typescript",
1084 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1085 ),
1086 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1087 ] {
1088 let text = r#"
1089 // Symbols as object keys
1090 const sym = Symbol("test");
1091 const obj1 = {
1092 [sym]: 1,
1093 [Symbol("inline")]: 2,
1094 normalKey: 3
1095 };
1096
1097 // Enums as object keys
1098 enum Color { Red, Blue, Green }
1099
1100 const obj2 = {
1101 [Color.Red]: "red value",
1102 [Color.Blue]: "blue value",
1103 regularProp: "normal"
1104 };
1105
1106 // Mixed computed properties
1107 const key = "dynamic";
1108 const obj3 = {
1109 [key]: 1,
1110 ["string" + "concat"]: 2,
1111 [1 + 1]: 3,
1112 static: 4
1113 };
1114
1115 // Nested objects with computed properties
1116 const obj4 = {
1117 [sym]: {
1118 nested: 1
1119 },
1120 regular: {
1121 [key]: 2
1122 }
1123 };
1124 "#
1125 .unindent();
1126
1127 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1128 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1129 assert_eq!(
1130 outline
1131 .items
1132 .iter()
1133 .map(|item| (item.text.as_str(), item.depth))
1134 .collect::<Vec<_>>(),
1135 &[
1136 ("const sym", 0),
1137 ("const obj1", 0),
1138 ("[sym]", 1),
1139 ("[Symbol(\"inline\")]", 1),
1140 ("normalKey", 1),
1141 ("enum Color", 0),
1142 ("const obj2", 0),
1143 ("[Color.Red]", 1),
1144 ("[Color.Blue]", 1),
1145 ("regularProp", 1),
1146 ("const key", 0),
1147 ("const obj3", 0),
1148 ("[key]", 1),
1149 ("[\"string\" + \"concat\"]", 1),
1150 ("[1 + 1]", 1),
1151 ("static", 1),
1152 ("const obj4", 0),
1153 ("[sym]", 1),
1154 ("nested", 2),
1155 ("regular", 1),
1156 ("[key]", 2),
1157 ]
1158 );
1159 }
1160 }
1161
1162 #[gpui::test]
1163 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1164 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1165
1166 let text = r#"
1167 function normalFunction() {
1168 console.log("normal");
1169 }
1170
1171 function* simpleGenerator() {
1172 yield 1;
1173 yield 2;
1174 }
1175
1176 async function* asyncGenerator() {
1177 yield await Promise.resolve(1);
1178 }
1179
1180 function* generatorWithParams(start, end) {
1181 for (let i = start; i <= end; i++) {
1182 yield i;
1183 }
1184 }
1185
1186 class TestClass {
1187 *methodGenerator() {
1188 yield "method";
1189 }
1190
1191 async *asyncMethodGenerator() {
1192 yield "async method";
1193 }
1194 }
1195 "#
1196 .unindent();
1197
1198 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1199 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1200 assert_eq!(
1201 outline
1202 .items
1203 .iter()
1204 .map(|item| (item.text.as_str(), item.depth))
1205 .collect::<Vec<_>>(),
1206 &[
1207 ("function normalFunction()", 0),
1208 ("function* simpleGenerator()", 0),
1209 ("async function* asyncGenerator()", 0),
1210 ("function* generatorWithParams( )", 0),
1211 ("class TestClass", 0),
1212 ("*methodGenerator()", 1),
1213 ("async *asyncMethodGenerator()", 1),
1214 ]
1215 );
1216 }
1217
1218 #[gpui::test]
1219 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1220 cx.update(|cx| {
1221 settings::init(cx);
1222 });
1223
1224 let package_json_1 = json!({
1225 "dependencies": {
1226 "mocha": "1.0.0",
1227 "vitest": "1.0.0"
1228 },
1229 "scripts": {
1230 "test": ""
1231 }
1232 })
1233 .to_string();
1234
1235 let package_json_2 = json!({
1236 "devDependencies": {
1237 "vitest": "2.0.0"
1238 },
1239 "scripts": {
1240 "test": ""
1241 }
1242 })
1243 .to_string();
1244
1245 let fs = FakeFs::new(executor);
1246 fs.insert_tree(
1247 path!("/root"),
1248 json!({
1249 "package.json": package_json_1,
1250 "sub": {
1251 "package.json": package_json_2,
1252 "file.js": "",
1253 }
1254 }),
1255 )
1256 .await;
1257
1258 let provider = TypeScriptContextProvider::new(fs.clone());
1259 let package_json_data = cx
1260 .update(|cx| {
1261 provider.combined_package_json_data(
1262 fs.clone(),
1263 path!("/root").as_ref(),
1264 rel_path("sub/file1.js"),
1265 cx,
1266 )
1267 })
1268 .await
1269 .unwrap();
1270 pretty_assertions::assert_eq!(
1271 package_json_data,
1272 PackageJsonData {
1273 jest_package_path: None,
1274 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1275 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1276 jasmine_package_path: None,
1277 bun_package_path: None,
1278 node_package_path: None,
1279 scripts: [
1280 (
1281 Path::new(path!("/root/package.json")).into(),
1282 "test".to_owned()
1283 ),
1284 (
1285 Path::new(path!("/root/sub/package.json")).into(),
1286 "test".to_owned()
1287 )
1288 ]
1289 .into_iter()
1290 .collect(),
1291 package_manager: None,
1292 }
1293 );
1294
1295 let mut task_templates = TaskTemplates::default();
1296 package_json_data.fill_task_templates(&mut task_templates);
1297 let task_templates = task_templates
1298 .0
1299 .into_iter()
1300 .map(|template| (template.label, template.cwd))
1301 .collect::<Vec<_>>();
1302 pretty_assertions::assert_eq!(
1303 task_templates,
1304 [
1305 (
1306 "vitest file test".into(),
1307 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1308 ),
1309 (
1310 "vitest test $ZED_SYMBOL".into(),
1311 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1312 ),
1313 (
1314 "mocha file test".into(),
1315 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1316 ),
1317 (
1318 "mocha test $ZED_SYMBOL".into(),
1319 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1320 ),
1321 (
1322 "root/package.json > test".into(),
1323 Some(path!("/root").into())
1324 ),
1325 (
1326 "sub/package.json > test".into(),
1327 Some(path!("/root/sub").into())
1328 ),
1329 ]
1330 );
1331 }
1332
1333 #[test]
1334 fn test_escaping_name() {
1335 let cases = [
1336 ("plain test name", "plain test name"),
1337 ("test name with $param_name", "test name with (.+?)"),
1338 ("test name with $nested.param.name", "test name with (.+?)"),
1339 ("test name with $#", "test name with (.+?)"),
1340 ("test name with $##", "test name with (.+?)\\#"),
1341 ("test name with %p", "test name with (.+?)"),
1342 ("test name with %s", "test name with (.+?)"),
1343 ("test name with %d", "test name with (.+?)"),
1344 ("test name with %i", "test name with (.+?)"),
1345 ("test name with %f", "test name with (.+?)"),
1346 ("test name with %j", "test name with (.+?)"),
1347 ("test name with %o", "test name with (.+?)"),
1348 ("test name with %#", "test name with (.+?)"),
1349 ("test name with %$", "test name with (.+?)"),
1350 ("test name with %%", "test name with (.+?)"),
1351 ("test name with %q", "test name with %q"),
1352 (
1353 "test name with regex chars .*+?^${}()|[]\\",
1354 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1355 ),
1356 (
1357 "test name with multiple $params and %pretty and %b and (.+?)",
1358 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1359 ),
1360 ];
1361
1362 for (input, expected) in cases {
1363 assert_eq!(replace_test_name_parameters(input), expected);
1364 }
1365 }
1366
1367 // The order of test runner tasks is based on inferred user preference:
1368 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1369 // 2. Bun's built-in test runner (`bun test`) comes next.
1370 // 3. Node.js's built-in test runner (`node --test`) is last.
1371 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1372 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1373 // typically preferred over `node --test` when @types/bun is present.
1374 #[gpui::test]
1375 async fn test_task_ordering_with_multiple_test_runners(
1376 executor: BackgroundExecutor,
1377 cx: &mut TestAppContext,
1378 ) {
1379 cx.update(|cx| {
1380 settings::init(cx);
1381 });
1382
1383 // Test case with all test runners present
1384 let package_json_all_runners = json!({
1385 "devDependencies": {
1386 "@types/bun": "1.0.0",
1387 "@types/node": "^20.0.0",
1388 "jest": "29.0.0",
1389 "mocha": "10.0.0",
1390 "vitest": "1.0.0",
1391 "jasmine": "5.0.0",
1392 },
1393 "scripts": {
1394 "test": "jest"
1395 }
1396 })
1397 .to_string();
1398
1399 let fs = FakeFs::new(executor);
1400 fs.insert_tree(
1401 path!("/root"),
1402 json!({
1403 "package.json": package_json_all_runners,
1404 "file.js": "",
1405 }),
1406 )
1407 .await;
1408
1409 let provider = TypeScriptContextProvider::new(fs.clone());
1410
1411 let package_json_data = cx
1412 .update(|cx| {
1413 provider.combined_package_json_data(
1414 fs.clone(),
1415 path!("/root").as_ref(),
1416 rel_path("file.js"),
1417 cx,
1418 )
1419 })
1420 .await
1421 .unwrap();
1422
1423 assert!(package_json_data.jest_package_path.is_some());
1424 assert!(package_json_data.mocha_package_path.is_some());
1425 assert!(package_json_data.vitest_package_path.is_some());
1426 assert!(package_json_data.jasmine_package_path.is_some());
1427 assert!(package_json_data.bun_package_path.is_some());
1428 assert!(package_json_data.node_package_path.is_some());
1429
1430 let mut task_templates = TaskTemplates::default();
1431 package_json_data.fill_task_templates(&mut task_templates);
1432
1433 let test_tasks: Vec<_> = task_templates
1434 .0
1435 .iter()
1436 .filter(|template| {
1437 template.tags.contains(&"ts-test".to_owned())
1438 || template.tags.contains(&"js-test".to_owned())
1439 })
1440 .map(|template| &template.label)
1441 .collect();
1442
1443 let node_test_index = test_tasks
1444 .iter()
1445 .position(|label| label.contains("node test"));
1446 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1447 let bun_test_index = test_tasks
1448 .iter()
1449 .position(|label| label.contains("bun test"));
1450
1451 assert!(
1452 node_test_index.is_some(),
1453 "Node test tasks should be present"
1454 );
1455 assert!(
1456 jest_test_index.is_some(),
1457 "Jest test tasks should be present"
1458 );
1459 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1460
1461 assert!(
1462 jest_test_index.unwrap() < bun_test_index.unwrap(),
1463 "Jest should come before Bun"
1464 );
1465 assert!(
1466 bun_test_index.unwrap() < node_test_index.unwrap(),
1467 "Bun should come before Node"
1468 );
1469 }
1470}