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
899 .new(|cx| language::Buffer::local(text, cx).with_language_immediate(language, cx));
900 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
901 assert_eq!(
902 outline
903 .items
904 .iter()
905 .map(|item| (item.text.as_str(), item.depth))
906 .collect::<Vec<_>>(),
907 &[
908 ("function a()", 0),
909 ("let a1", 1),
910 ("async function a2()", 1),
911 ("let b", 0),
912 ("function getB()", 0),
913 ("const d", 0),
914 ]
915 );
916 }
917 }
918
919 #[gpui::test]
920 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
921 for language in [
922 crate::language(
923 "typescript",
924 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
925 ),
926 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
927 ] {
928 let text = r#"
929 // Top-level destructuring
930 const { a1, a2 } = a;
931 const [b1, b2] = b;
932
933 // Defaults and rest
934 const [c1 = 1, , c2, ...rest1] = c;
935 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
936
937 function processData() {
938 // Nested object destructuring
939 const { c1, c2 } = c;
940 // Nested array destructuring
941 const [d1, d2, d3] = d;
942 // Destructuring with renaming
943 const { f1: g1 } = f;
944 // With defaults
945 const [x = 10, y] = xy;
946 }
947
948 class DataHandler {
949 method() {
950 // Destructuring in class method
951 const { a1, a2 } = a;
952 const [b1, ...b2] = b;
953 }
954 }
955 "#
956 .unindent();
957
958 let buffer = cx
959 .new(|cx| language::Buffer::local(text, cx).with_language_immediate(language, cx));
960 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
961 assert_eq!(
962 outline
963 .items
964 .iter()
965 .map(|item| (item.text.as_str(), item.depth))
966 .collect::<Vec<_>>(),
967 &[
968 ("const a1", 0),
969 ("const a2", 0),
970 ("const b1", 0),
971 ("const b2", 0),
972 ("const c1", 0),
973 ("const c2", 0),
974 ("const rest1", 0),
975 ("const d1", 0),
976 ("const e1", 0),
977 ("const h1", 0),
978 ("const rest2", 0),
979 ("function processData()", 0),
980 ("const c1", 1),
981 ("const c2", 1),
982 ("const d1", 1),
983 ("const d2", 1),
984 ("const d3", 1),
985 ("const g1", 1),
986 ("const x", 1),
987 ("const y", 1),
988 ("class DataHandler", 0),
989 ("method()", 1),
990 ("const a1", 2),
991 ("const a2", 2),
992 ("const b1", 2),
993 ("const b2", 2),
994 ]
995 );
996 }
997 }
998
999 #[gpui::test]
1000 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1001 for language in [
1002 crate::language(
1003 "typescript",
1004 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1005 ),
1006 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1007 ] {
1008 let text = r#"
1009 // Object with function properties
1010 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1011
1012 // Object with primitive properties
1013 const p = { p1: 1, p2: "hello", p3: true };
1014
1015 // Nested objects
1016 const q = {
1017 r: {
1018 // won't be included due to one-level depth limit
1019 s: 1
1020 },
1021 t: 2
1022 };
1023
1024 function getData() {
1025 const local = { x: 1, y: 2 };
1026 return local;
1027 }
1028 "#
1029 .unindent();
1030
1031 let buffer = cx
1032 .new(|cx| language::Buffer::local(text, cx).with_language_immediate(language, cx));
1033 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1034 assert_eq!(
1035 outline
1036 .items
1037 .iter()
1038 .map(|item| (item.text.as_str(), item.depth))
1039 .collect::<Vec<_>>(),
1040 &[
1041 ("const o", 0),
1042 ("m()", 1),
1043 ("async n()", 1),
1044 ("g", 1),
1045 ("h", 1),
1046 ("k", 1),
1047 ("const p", 0),
1048 ("p1", 1),
1049 ("p2", 1),
1050 ("p3", 1),
1051 ("const q", 0),
1052 ("r", 1),
1053 ("s", 2),
1054 ("t", 1),
1055 ("function getData()", 0),
1056 ("const local", 1),
1057 ("x", 2),
1058 ("y", 2),
1059 ]
1060 );
1061 }
1062 }
1063
1064 #[gpui::test]
1065 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1066 for language in [
1067 crate::language(
1068 "typescript",
1069 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1070 ),
1071 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1072 ] {
1073 let text = r#"
1074 // Symbols as object keys
1075 const sym = Symbol("test");
1076 const obj1 = {
1077 [sym]: 1,
1078 [Symbol("inline")]: 2,
1079 normalKey: 3
1080 };
1081
1082 // Enums as object keys
1083 enum Color { Red, Blue, Green }
1084
1085 const obj2 = {
1086 [Color.Red]: "red value",
1087 [Color.Blue]: "blue value",
1088 regularProp: "normal"
1089 };
1090
1091 // Mixed computed properties
1092 const key = "dynamic";
1093 const obj3 = {
1094 [key]: 1,
1095 ["string" + "concat"]: 2,
1096 [1 + 1]: 3,
1097 static: 4
1098 };
1099
1100 // Nested objects with computed properties
1101 const obj4 = {
1102 [sym]: {
1103 nested: 1
1104 },
1105 regular: {
1106 [key]: 2
1107 }
1108 };
1109 "#
1110 .unindent();
1111
1112 let buffer = cx
1113 .new(|cx| language::Buffer::local(text, cx).with_language_immediate(language, cx));
1114 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1115 assert_eq!(
1116 outline
1117 .items
1118 .iter()
1119 .map(|item| (item.text.as_str(), item.depth))
1120 .collect::<Vec<_>>(),
1121 &[
1122 ("const sym", 0),
1123 ("const obj1", 0),
1124 ("[sym]", 1),
1125 ("[Symbol(\"inline\")]", 1),
1126 ("normalKey", 1),
1127 ("enum Color", 0),
1128 ("const obj2", 0),
1129 ("[Color.Red]", 1),
1130 ("[Color.Blue]", 1),
1131 ("regularProp", 1),
1132 ("const key", 0),
1133 ("const obj3", 0),
1134 ("[key]", 1),
1135 ("[\"string\" + \"concat\"]", 1),
1136 ("[1 + 1]", 1),
1137 ("static", 1),
1138 ("const obj4", 0),
1139 ("[sym]", 1),
1140 ("nested", 2),
1141 ("regular", 1),
1142 ("[key]", 2),
1143 ]
1144 );
1145 }
1146 }
1147
1148 #[gpui::test]
1149 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1150 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1151
1152 let text = r#"
1153 function normalFunction() {
1154 console.log("normal");
1155 }
1156
1157 function* simpleGenerator() {
1158 yield 1;
1159 yield 2;
1160 }
1161
1162 async function* asyncGenerator() {
1163 yield await Promise.resolve(1);
1164 }
1165
1166 function* generatorWithParams(start, end) {
1167 for (let i = start; i <= end; i++) {
1168 yield i;
1169 }
1170 }
1171
1172 class TestClass {
1173 *methodGenerator() {
1174 yield "method";
1175 }
1176
1177 async *asyncMethodGenerator() {
1178 yield "async method";
1179 }
1180 }
1181 "#
1182 .unindent();
1183
1184 let buffer =
1185 cx.new(|cx| language::Buffer::local(text, cx).with_language_immediate(language, cx));
1186 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1187 assert_eq!(
1188 outline
1189 .items
1190 .iter()
1191 .map(|item| (item.text.as_str(), item.depth))
1192 .collect::<Vec<_>>(),
1193 &[
1194 ("function normalFunction()", 0),
1195 ("function* simpleGenerator()", 0),
1196 ("async function* asyncGenerator()", 0),
1197 ("function* generatorWithParams( )", 0),
1198 ("class TestClass", 0),
1199 ("*methodGenerator()", 1),
1200 ("async *asyncMethodGenerator()", 1),
1201 ]
1202 );
1203 }
1204
1205 #[gpui::test]
1206 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1207 cx.update(|cx| {
1208 settings::init(cx);
1209 });
1210
1211 let package_json_1 = json!({
1212 "dependencies": {
1213 "mocha": "1.0.0",
1214 "vitest": "1.0.0"
1215 },
1216 "scripts": {
1217 "test": ""
1218 }
1219 })
1220 .to_string();
1221
1222 let package_json_2 = json!({
1223 "devDependencies": {
1224 "vitest": "2.0.0"
1225 },
1226 "scripts": {
1227 "test": ""
1228 }
1229 })
1230 .to_string();
1231
1232 let fs = FakeFs::new(executor);
1233 fs.insert_tree(
1234 path!("/root"),
1235 json!({
1236 "package.json": package_json_1,
1237 "sub": {
1238 "package.json": package_json_2,
1239 "file.js": "",
1240 }
1241 }),
1242 )
1243 .await;
1244
1245 let provider = TypeScriptContextProvider::new(fs.clone());
1246 let package_json_data = cx
1247 .update(|cx| {
1248 provider.combined_package_json_data(
1249 fs.clone(),
1250 path!("/root").as_ref(),
1251 rel_path("sub/file1.js"),
1252 cx,
1253 )
1254 })
1255 .await
1256 .unwrap();
1257 pretty_assertions::assert_eq!(
1258 package_json_data,
1259 PackageJsonData {
1260 jest_package_path: None,
1261 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1262 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1263 jasmine_package_path: None,
1264 bun_package_path: None,
1265 node_package_path: None,
1266 scripts: [
1267 (
1268 Path::new(path!("/root/package.json")).into(),
1269 "test".to_owned()
1270 ),
1271 (
1272 Path::new(path!("/root/sub/package.json")).into(),
1273 "test".to_owned()
1274 )
1275 ]
1276 .into_iter()
1277 .collect(),
1278 package_manager: None,
1279 }
1280 );
1281
1282 let mut task_templates = TaskTemplates::default();
1283 package_json_data.fill_task_templates(&mut task_templates);
1284 let task_templates = task_templates
1285 .0
1286 .into_iter()
1287 .map(|template| (template.label, template.cwd))
1288 .collect::<Vec<_>>();
1289 pretty_assertions::assert_eq!(
1290 task_templates,
1291 [
1292 (
1293 "vitest file test".into(),
1294 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1295 ),
1296 (
1297 "vitest test $ZED_SYMBOL".into(),
1298 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1299 ),
1300 (
1301 "mocha file test".into(),
1302 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1303 ),
1304 (
1305 "mocha test $ZED_SYMBOL".into(),
1306 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1307 ),
1308 (
1309 "root/package.json > test".into(),
1310 Some(path!("/root").into())
1311 ),
1312 (
1313 "sub/package.json > test".into(),
1314 Some(path!("/root/sub").into())
1315 ),
1316 ]
1317 );
1318 }
1319
1320 #[test]
1321 fn test_escaping_name() {
1322 let cases = [
1323 ("plain test name", "plain test name"),
1324 ("test name with $param_name", "test name with (.+?)"),
1325 ("test name with $nested.param.name", "test name with (.+?)"),
1326 ("test name with $#", "test name with (.+?)"),
1327 ("test name with $##", "test name with (.+?)\\#"),
1328 ("test name with %p", "test name with (.+?)"),
1329 ("test name with %s", "test name with (.+?)"),
1330 ("test name with %d", "test name with (.+?)"),
1331 ("test name with %i", "test name with (.+?)"),
1332 ("test name with %f", "test name with (.+?)"),
1333 ("test name with %j", "test name with (.+?)"),
1334 ("test name with %o", "test name with (.+?)"),
1335 ("test name with %#", "test name with (.+?)"),
1336 ("test name with %$", "test name with (.+?)"),
1337 ("test name with %%", "test name with (.+?)"),
1338 ("test name with %q", "test name with %q"),
1339 (
1340 "test name with regex chars .*+?^${}()|[]\\",
1341 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1342 ),
1343 (
1344 "test name with multiple $params and %pretty and %b and (.+?)",
1345 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1346 ),
1347 ];
1348
1349 for (input, expected) in cases {
1350 assert_eq!(replace_test_name_parameters(input), expected);
1351 }
1352 }
1353
1354 // The order of test runner tasks is based on inferred user preference:
1355 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1356 // 2. Bun's built-in test runner (`bun test`) comes next.
1357 // 3. Node.js's built-in test runner (`node --test`) is last.
1358 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1359 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1360 // typically preferred over `node --test` when @types/bun is present.
1361 #[gpui::test]
1362 async fn test_task_ordering_with_multiple_test_runners(
1363 executor: BackgroundExecutor,
1364 cx: &mut TestAppContext,
1365 ) {
1366 cx.update(|cx| {
1367 settings::init(cx);
1368 });
1369
1370 // Test case with all test runners present
1371 let package_json_all_runners = json!({
1372 "devDependencies": {
1373 "@types/bun": "1.0.0",
1374 "@types/node": "^20.0.0",
1375 "jest": "29.0.0",
1376 "mocha": "10.0.0",
1377 "vitest": "1.0.0",
1378 "jasmine": "5.0.0",
1379 },
1380 "scripts": {
1381 "test": "jest"
1382 }
1383 })
1384 .to_string();
1385
1386 let fs = FakeFs::new(executor);
1387 fs.insert_tree(
1388 path!("/root"),
1389 json!({
1390 "package.json": package_json_all_runners,
1391 "file.js": "",
1392 }),
1393 )
1394 .await;
1395
1396 let provider = TypeScriptContextProvider::new(fs.clone());
1397
1398 let package_json_data = cx
1399 .update(|cx| {
1400 provider.combined_package_json_data(
1401 fs.clone(),
1402 path!("/root").as_ref(),
1403 rel_path("file.js"),
1404 cx,
1405 )
1406 })
1407 .await
1408 .unwrap();
1409
1410 assert!(package_json_data.jest_package_path.is_some());
1411 assert!(package_json_data.mocha_package_path.is_some());
1412 assert!(package_json_data.vitest_package_path.is_some());
1413 assert!(package_json_data.jasmine_package_path.is_some());
1414 assert!(package_json_data.bun_package_path.is_some());
1415 assert!(package_json_data.node_package_path.is_some());
1416
1417 let mut task_templates = TaskTemplates::default();
1418 package_json_data.fill_task_templates(&mut task_templates);
1419
1420 let test_tasks: Vec<_> = task_templates
1421 .0
1422 .iter()
1423 .filter(|template| {
1424 template.tags.contains(&"ts-test".to_owned())
1425 || template.tags.contains(&"js-test".to_owned())
1426 })
1427 .map(|template| &template.label)
1428 .collect();
1429
1430 let node_test_index = test_tasks
1431 .iter()
1432 .position(|label| label.contains("node test"));
1433 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1434 let bun_test_index = test_tasks
1435 .iter()
1436 .position(|label| label.contains("bun test"));
1437
1438 assert!(
1439 node_test_index.is_some(),
1440 "Node test tasks should be present"
1441 );
1442 assert!(
1443 jest_test_index.is_some(),
1444 "Jest test tasks should be present"
1445 );
1446 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1447
1448 assert!(
1449 jest_test_index.unwrap() < bun_test_index.unwrap(),
1450 "Jest should come before Bun"
1451 );
1452 assert!(
1453 bun_test_index.unwrap() < node_test_index.unwrap(),
1454 "Bun should come before Node"
1455 );
1456 }
1457}