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