1use anyhow::{Context as _, Result};
2use async_compression::futures::bufread::GzipDecoder;
3use async_tar::Archive;
4use async_trait::async_trait;
5use chrono::{DateTime, Local};
6use collections::HashMap;
7use futures::future::join_all;
8use gpui::{App, AppContext, AsyncApp, Task};
9use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
10use language::{
11 ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
12};
13use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
14use node_runtime::NodeRuntime;
15use project::{Fs, lsp_store::language_server_settings};
16use serde_json::{Value, json};
17use smol::{fs, io::BufReader, lock::RwLock, stream::StreamExt};
18use std::{
19 any::Any,
20 borrow::Cow,
21 collections::BTreeSet,
22 ffi::OsString,
23 path::{Path, PathBuf},
24 sync::Arc,
25};
26use task::{TaskTemplate, TaskTemplates, VariableName};
27use util::archive::extract_zip;
28use util::merge_json_value_into;
29use util::{ResultExt, fs::remove_matching, maybe};
30
31#[derive(Debug)]
32pub(crate) struct TypeScriptContextProvider {
33 last_package_json: PackageJsonContents,
34}
35
36const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
37 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
38
39const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
40 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
41
42const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
43 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
44
45const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
46 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
47
48const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
49 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
50
51const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
52 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
53
54const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
55 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
56
57#[derive(Clone, Debug, Default)]
58struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
59
60#[derive(Clone, Debug)]
61struct PackageJson {
62 mtime: DateTime<Local>,
63 data: PackageJsonData,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Eq)]
67struct PackageJsonData {
68 jest_package_path: Option<Arc<Path>>,
69 mocha_package_path: Option<Arc<Path>>,
70 vitest_package_path: Option<Arc<Path>>,
71 jasmine_package_path: Option<Arc<Path>>,
72 scripts: BTreeSet<(Arc<Path>, String)>,
73 package_manager: Option<&'static str>,
74}
75
76impl PackageJsonData {
77 fn new(path: Arc<Path>, package_json: HashMap<String, Value>) -> Self {
78 let mut scripts = BTreeSet::new();
79 if let Some(serde_json::Value::Object(package_json_scripts)) = package_json.get("scripts") {
80 scripts.extend(
81 package_json_scripts
82 .keys()
83 .cloned()
84 .map(|name| (path.clone(), name)),
85 );
86 }
87
88 let mut jest_package_path = None;
89 let mut mocha_package_path = None;
90 let mut vitest_package_path = None;
91 let mut jasmine_package_path = None;
92 if let Some(serde_json::Value::Object(dependencies)) = package_json.get("devDependencies") {
93 if dependencies.contains_key("jest") {
94 jest_package_path.get_or_insert_with(|| path.clone());
95 }
96 if dependencies.contains_key("mocha") {
97 mocha_package_path.get_or_insert_with(|| path.clone());
98 }
99 if dependencies.contains_key("vitest") {
100 vitest_package_path.get_or_insert_with(|| path.clone());
101 }
102 if dependencies.contains_key("jasmine") {
103 jasmine_package_path.get_or_insert_with(|| path.clone());
104 }
105 }
106 if let Some(serde_json::Value::Object(dev_dependencies)) = package_json.get("dependencies")
107 {
108 if dev_dependencies.contains_key("jest") {
109 jest_package_path.get_or_insert_with(|| path.clone());
110 }
111 if dev_dependencies.contains_key("mocha") {
112 mocha_package_path.get_or_insert_with(|| path.clone());
113 }
114 if dev_dependencies.contains_key("vitest") {
115 vitest_package_path.get_or_insert_with(|| path.clone());
116 }
117 if dev_dependencies.contains_key("jasmine") {
118 jasmine_package_path.get_or_insert_with(|| path.clone());
119 }
120 }
121
122 let package_manager = package_json
123 .get("packageManager")
124 .and_then(|value| value.as_str())
125 .and_then(|value| {
126 if value.starts_with("pnpm") {
127 Some("pnpm")
128 } else if value.starts_with("yarn") {
129 Some("yarn")
130 } else if value.starts_with("npm") {
131 Some("npm")
132 } else {
133 None
134 }
135 });
136
137 Self {
138 jest_package_path,
139 mocha_package_path,
140 vitest_package_path,
141 jasmine_package_path,
142 scripts,
143 package_manager,
144 }
145 }
146
147 fn merge(&mut self, other: Self) {
148 self.jest_package_path = self.jest_package_path.take().or(other.jest_package_path);
149 self.mocha_package_path = self.mocha_package_path.take().or(other.mocha_package_path);
150 self.vitest_package_path = self
151 .vitest_package_path
152 .take()
153 .or(other.vitest_package_path);
154 self.jasmine_package_path = self
155 .jasmine_package_path
156 .take()
157 .or(other.jasmine_package_path);
158 self.scripts.extend(other.scripts);
159 self.package_manager = self.package_manager.or(other.package_manager);
160 }
161
162 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
163 if self.jest_package_path.is_some() {
164 task_templates.0.push(TaskTemplate {
165 label: "jest file test".to_owned(),
166 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
167 args: vec![
168 "exec".to_owned(),
169 "--".to_owned(),
170 "jest".to_owned(),
171 "--runInBand".to_owned(),
172 VariableName::File.template_value(),
173 ],
174 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
175 ..TaskTemplate::default()
176 });
177 task_templates.0.push(TaskTemplate {
178 label: format!("jest test {}", VariableName::Symbol.template_value()),
179 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
180 args: vec![
181 "exec".to_owned(),
182 "--".to_owned(),
183 "jest".to_owned(),
184 "--runInBand".to_owned(),
185 "--testNamePattern".to_owned(),
186 format!(
187 "\"{}\"",
188 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
189 ),
190 VariableName::File.template_value(),
191 ],
192 tags: vec![
193 "ts-test".to_owned(),
194 "js-test".to_owned(),
195 "tsx-test".to_owned(),
196 ],
197 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
198 ..TaskTemplate::default()
199 });
200 }
201
202 if self.vitest_package_path.is_some() {
203 task_templates.0.push(TaskTemplate {
204 label: format!("{} file test", "vitest".to_owned()),
205 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
206 args: vec![
207 "exec".to_owned(),
208 "--".to_owned(),
209 "vitest".to_owned(),
210 "run".to_owned(),
211 "--poolOptions.forks.minForks=0".to_owned(),
212 "--poolOptions.forks.maxForks=1".to_owned(),
213 VariableName::File.template_value(),
214 ],
215 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
216 ..TaskTemplate::default()
217 });
218 task_templates.0.push(TaskTemplate {
219 label: format!(
220 "{} test {}",
221 "vitest".to_owned(),
222 VariableName::Symbol.template_value(),
223 ),
224 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
225 args: vec![
226 "exec".to_owned(),
227 "--".to_owned(),
228 "vitest".to_owned(),
229 "run".to_owned(),
230 "--poolOptions.forks.minForks=0".to_owned(),
231 "--poolOptions.forks.maxForks=1".to_owned(),
232 "--testNamePattern".to_owned(),
233 format!(
234 "\"{}\"",
235 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
236 ),
237 VariableName::File.template_value(),
238 ],
239 tags: vec![
240 "ts-test".to_owned(),
241 "js-test".to_owned(),
242 "tsx-test".to_owned(),
243 ],
244 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
245 ..TaskTemplate::default()
246 });
247 }
248
249 if self.mocha_package_path.is_some() {
250 task_templates.0.push(TaskTemplate {
251 label: format!("{} file test", "mocha".to_owned()),
252 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
253 args: vec![
254 "exec".to_owned(),
255 "--".to_owned(),
256 "mocha".to_owned(),
257 VariableName::File.template_value(),
258 ],
259 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
260 ..TaskTemplate::default()
261 });
262 task_templates.0.push(TaskTemplate {
263 label: format!(
264 "{} test {}",
265 "mocha".to_owned(),
266 VariableName::Symbol.template_value(),
267 ),
268 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
269 args: vec![
270 "exec".to_owned(),
271 "--".to_owned(),
272 "mocha".to_owned(),
273 "--grep".to_owned(),
274 format!("\"{}\"", VariableName::Symbol.template_value()),
275 VariableName::File.template_value(),
276 ],
277 tags: vec![
278 "ts-test".to_owned(),
279 "js-test".to_owned(),
280 "tsx-test".to_owned(),
281 ],
282 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
283 ..TaskTemplate::default()
284 });
285 }
286
287 if self.jasmine_package_path.is_some() {
288 task_templates.0.push(TaskTemplate {
289 label: format!("{} file test", "jasmine".to_owned()),
290 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
291 args: vec![
292 "exec".to_owned(),
293 "--".to_owned(),
294 "jasmine".to_owned(),
295 VariableName::File.template_value(),
296 ],
297 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
298 ..TaskTemplate::default()
299 });
300 task_templates.0.push(TaskTemplate {
301 label: format!(
302 "{} test {}",
303 "jasmine".to_owned(),
304 VariableName::Symbol.template_value(),
305 ),
306 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
307 args: vec![
308 "exec".to_owned(),
309 "--".to_owned(),
310 "jasmine".to_owned(),
311 format!("--filter={}", VariableName::Symbol.template_value()),
312 VariableName::File.template_value(),
313 ],
314 tags: vec![
315 "ts-test".to_owned(),
316 "js-test".to_owned(),
317 "tsx-test".to_owned(),
318 ],
319 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
320 ..TaskTemplate::default()
321 });
322 }
323
324 for (path, script) in &self.scripts {
325 task_templates.0.push(TaskTemplate {
326 label: format!("package.json > {script}",),
327 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
328 args: vec!["run".to_owned(), script.to_owned()],
329 tags: vec!["package-script".into()],
330 cwd: Some(
331 path.parent()
332 .unwrap_or(Path::new(""))
333 .to_string_lossy()
334 .to_string(),
335 ),
336 ..TaskTemplate::default()
337 });
338 }
339 }
340}
341
342impl TypeScriptContextProvider {
343 pub fn new() -> Self {
344 Self {
345 last_package_json: PackageJsonContents::default(),
346 }
347 }
348
349 fn combined_package_json_data(
350 &self,
351 fs: Arc<dyn Fs>,
352 worktree_root: &Path,
353 file_relative_path: &Path,
354 cx: &App,
355 ) -> Task<anyhow::Result<PackageJsonData>> {
356 let new_json_data = file_relative_path
357 .ancestors()
358 .map(|path| worktree_root.join(path))
359 .map(|parent_path| {
360 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
361 })
362 .collect::<Vec<_>>();
363
364 cx.background_spawn(async move {
365 let mut package_json_data = PackageJsonData::default();
366 for new_data in join_all(new_json_data).await.into_iter().flatten() {
367 package_json_data.merge(new_data);
368 }
369 Ok(package_json_data)
370 })
371 }
372
373 fn package_json_data(
374 &self,
375 directory_path: &Path,
376 existing_package_json: PackageJsonContents,
377 fs: Arc<dyn Fs>,
378 cx: &App,
379 ) -> Task<anyhow::Result<PackageJsonData>> {
380 let package_json_path = directory_path.join("package.json");
381 let metadata_check_fs = fs.clone();
382 cx.background_spawn(async move {
383 let metadata = metadata_check_fs
384 .metadata(&package_json_path)
385 .await
386 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
387 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
388 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
389 let existing_data = {
390 let contents = existing_package_json.0.read().await;
391 contents
392 .get(&package_json_path)
393 .filter(|package_json| package_json.mtime == mtime)
394 .map(|package_json| package_json.data.clone())
395 };
396 match existing_data {
397 Some(existing_data) => Ok(existing_data),
398 None => {
399 let package_json_string =
400 fs.load(&package_json_path).await.with_context(|| {
401 format!("loading package.json from {package_json_path:?}")
402 })?;
403 let package_json: HashMap<String, serde_json::Value> =
404 serde_json::from_str(&package_json_string).with_context(|| {
405 format!("parsing package.json from {package_json_path:?}")
406 })?;
407 let new_data =
408 PackageJsonData::new(package_json_path.as_path().into(), package_json);
409 {
410 let mut contents = existing_package_json.0.write().await;
411 contents.insert(
412 package_json_path,
413 PackageJson {
414 mtime,
415 data: new_data.clone(),
416 },
417 );
418 }
419 Ok(new_data)
420 }
421 }
422 })
423 }
424}
425
426async fn detect_package_manager(
427 worktree_root: PathBuf,
428 fs: Arc<dyn Fs>,
429 package_json_data: Option<PackageJsonData>,
430) -> &'static str {
431 if let Some(package_json_data) = package_json_data {
432 if let Some(package_manager) = package_json_data.package_manager {
433 return package_manager;
434 }
435 }
436 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
437 return "pnpm";
438 }
439 if fs.is_file(&worktree_root.join("yarn.lock")).await {
440 return "yarn";
441 }
442 "npm"
443}
444
445impl ContextProvider for TypeScriptContextProvider {
446 fn associated_tasks(
447 &self,
448 fs: Arc<dyn Fs>,
449 file: Option<Arc<dyn File>>,
450 cx: &App,
451 ) -> Task<Option<TaskTemplates>> {
452 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
453 return Task::ready(None);
454 };
455 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
456 return Task::ready(None);
457 };
458 let file_relative_path = file.path().clone();
459 let package_json_data =
460 self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx);
461
462 cx.background_spawn(async move {
463 let mut task_templates = TaskTemplates(Vec::new());
464 task_templates.0.push(TaskTemplate {
465 label: format!(
466 "execute selection {}",
467 VariableName::SelectedText.template_value()
468 ),
469 command: "node".to_owned(),
470 args: vec![
471 "-e".to_owned(),
472 format!("\"{}\"", VariableName::SelectedText.template_value()),
473 ],
474 ..TaskTemplate::default()
475 });
476
477 match package_json_data.await {
478 Ok(package_json) => {
479 package_json.fill_task_templates(&mut task_templates);
480 }
481 Err(e) => {
482 log::error!(
483 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
484 );
485 }
486 }
487
488 Some(task_templates)
489 })
490 }
491
492 fn build_context(
493 &self,
494 current_vars: &task::TaskVariables,
495 location: ContextLocation<'_>,
496 _project_env: Option<HashMap<String, String>>,
497 _toolchains: Arc<dyn LanguageToolchainStore>,
498 cx: &mut App,
499 ) -> Task<Result<task::TaskVariables>> {
500 let mut vars = task::TaskVariables::default();
501
502 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
503 vars.insert(
504 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
505 replace_test_name_parameters(symbol),
506 );
507 vars.insert(
508 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
509 replace_test_name_parameters(symbol),
510 );
511 }
512 let file_path = location
513 .file_location
514 .buffer
515 .read(cx)
516 .file()
517 .map(|file| file.path());
518
519 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
520 |((worktree_root, fs), file_path)| {
521 (
522 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
523 worktree_root,
524 fs,
525 )
526 },
527 );
528 cx.background_spawn(async move {
529 if let Some((task, worktree_root, fs)) = args {
530 let package_json_data = task.await.log_err();
531 vars.insert(
532 TYPESCRIPT_RUNNER_VARIABLE,
533 detect_package_manager(worktree_root, fs, package_json_data.clone())
534 .await
535 .to_owned(),
536 );
537
538 if let Some(package_json_data) = package_json_data {
539 if let Some(path) = package_json_data.jest_package_path {
540 vars.insert(
541 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
542 path.parent()
543 .unwrap_or(Path::new(""))
544 .to_string_lossy()
545 .to_string(),
546 );
547 }
548
549 if let Some(path) = package_json_data.mocha_package_path {
550 vars.insert(
551 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
552 path.parent()
553 .unwrap_or(Path::new(""))
554 .to_string_lossy()
555 .to_string(),
556 );
557 }
558
559 if let Some(path) = package_json_data.vitest_package_path {
560 vars.insert(
561 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
562 path.parent()
563 .unwrap_or(Path::new(""))
564 .to_string_lossy()
565 .to_string(),
566 );
567 }
568
569 if let Some(path) = package_json_data.jasmine_package_path {
570 vars.insert(
571 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
572 path.parent()
573 .unwrap_or(Path::new(""))
574 .to_string_lossy()
575 .to_string(),
576 );
577 }
578 }
579 }
580 Ok(vars)
581 })
582 }
583}
584
585fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
586 vec![server_path.into(), "--stdio".into()]
587}
588
589fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
590 vec![
591 "--max-old-space-size=8192".into(),
592 server_path.into(),
593 "--stdio".into(),
594 ]
595}
596
597fn replace_test_name_parameters(test_name: &str) -> String {
598 let pattern = regex::Regex::new(r"(%|\$)[0-9a-zA-Z]+").unwrap();
599
600 pattern.replace_all(test_name, "(.+?)").to_string()
601}
602
603pub struct TypeScriptLspAdapter {
604 node: NodeRuntime,
605}
606
607impl TypeScriptLspAdapter {
608 const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
609 const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
610 const SERVER_NAME: LanguageServerName =
611 LanguageServerName::new_static("typescript-language-server");
612 const PACKAGE_NAME: &str = "typescript";
613 pub fn new(node: NodeRuntime) -> Self {
614 TypeScriptLspAdapter { node }
615 }
616 async fn tsdk_path(fs: &dyn Fs, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
617 let is_yarn = adapter
618 .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js"))
619 .await
620 .is_ok();
621
622 let tsdk_path = if is_yarn {
623 ".yarn/sdks/typescript/lib"
624 } else {
625 "node_modules/typescript/lib"
626 };
627
628 if fs
629 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
630 .await
631 {
632 Some(tsdk_path)
633 } else {
634 None
635 }
636 }
637}
638
639struct TypeScriptVersions {
640 typescript_version: String,
641 server_version: String,
642}
643
644#[async_trait(?Send)]
645impl LspAdapter for TypeScriptLspAdapter {
646 fn name(&self) -> LanguageServerName {
647 Self::SERVER_NAME.clone()
648 }
649
650 async fn fetch_latest_server_version(
651 &self,
652 _: &dyn LspAdapterDelegate,
653 ) -> Result<Box<dyn 'static + Send + Any>> {
654 Ok(Box::new(TypeScriptVersions {
655 typescript_version: self.node.npm_package_latest_version("typescript").await?,
656 server_version: self
657 .node
658 .npm_package_latest_version("typescript-language-server")
659 .await?,
660 }) as Box<_>)
661 }
662
663 async fn check_if_version_installed(
664 &self,
665 version: &(dyn 'static + Send + Any),
666 container_dir: &PathBuf,
667 _: &dyn LspAdapterDelegate,
668 ) -> Option<LanguageServerBinary> {
669 let version = version.downcast_ref::<TypeScriptVersions>().unwrap();
670 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
671
672 let should_install_language_server = self
673 .node
674 .should_install_npm_package(
675 Self::PACKAGE_NAME,
676 &server_path,
677 &container_dir,
678 version.typescript_version.as_str(),
679 )
680 .await;
681
682 if should_install_language_server {
683 None
684 } else {
685 Some(LanguageServerBinary {
686 path: self.node.binary_path().await.ok()?,
687 env: None,
688 arguments: typescript_server_binary_arguments(&server_path),
689 })
690 }
691 }
692
693 async fn fetch_server_binary(
694 &self,
695 latest_version: Box<dyn 'static + Send + Any>,
696 container_dir: PathBuf,
697 _: &dyn LspAdapterDelegate,
698 ) -> Result<LanguageServerBinary> {
699 let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
700 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
701
702 self.node
703 .npm_install_packages(
704 &container_dir,
705 &[
706 (
707 Self::PACKAGE_NAME,
708 latest_version.typescript_version.as_str(),
709 ),
710 (
711 "typescript-language-server",
712 latest_version.server_version.as_str(),
713 ),
714 ],
715 )
716 .await?;
717
718 Ok(LanguageServerBinary {
719 path: self.node.binary_path().await?,
720 env: None,
721 arguments: typescript_server_binary_arguments(&server_path),
722 })
723 }
724
725 async fn cached_server_binary(
726 &self,
727 container_dir: PathBuf,
728 _: &dyn LspAdapterDelegate,
729 ) -> Option<LanguageServerBinary> {
730 get_cached_ts_server_binary(container_dir, &self.node).await
731 }
732
733 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
734 Some(vec![
735 CodeActionKind::QUICKFIX,
736 CodeActionKind::REFACTOR,
737 CodeActionKind::REFACTOR_EXTRACT,
738 CodeActionKind::SOURCE,
739 ])
740 }
741
742 async fn label_for_completion(
743 &self,
744 item: &lsp::CompletionItem,
745 language: &Arc<language::Language>,
746 ) -> Option<language::CodeLabel> {
747 use lsp::CompletionItemKind as Kind;
748 let len = item.label.len();
749 let grammar = language.grammar()?;
750 let highlight_id = match item.kind? {
751 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
752 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
753 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
754 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
755 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
756 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
757 _ => None,
758 }?;
759
760 let text = if let Some(description) = item
761 .label_details
762 .as_ref()
763 .and_then(|label_details| label_details.description.as_ref())
764 {
765 format!("{} {}", item.label, description)
766 } else if let Some(detail) = &item.detail {
767 format!("{} {}", item.label, detail)
768 } else {
769 item.label.clone()
770 };
771
772 Some(language::CodeLabel {
773 text,
774 runs: vec![(0..len, highlight_id)],
775 filter_range: 0..len,
776 })
777 }
778
779 async fn initialization_options(
780 self: Arc<Self>,
781 fs: &dyn Fs,
782 adapter: &Arc<dyn LspAdapterDelegate>,
783 ) -> Result<Option<serde_json::Value>> {
784 let tsdk_path = Self::tsdk_path(fs, adapter).await;
785 Ok(Some(json!({
786 "provideFormatter": true,
787 "hostInfo": "zed",
788 "tsserver": {
789 "path": tsdk_path,
790 },
791 "preferences": {
792 "includeInlayParameterNameHints": "all",
793 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
794 "includeInlayFunctionParameterTypeHints": true,
795 "includeInlayVariableTypeHints": true,
796 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
797 "includeInlayPropertyDeclarationTypeHints": true,
798 "includeInlayFunctionLikeReturnTypeHints": true,
799 "includeInlayEnumMemberValueHints": true,
800 }
801 })))
802 }
803
804 async fn workspace_configuration(
805 self: Arc<Self>,
806 _: &dyn Fs,
807 delegate: &Arc<dyn LspAdapterDelegate>,
808 _: Arc<dyn LanguageToolchainStore>,
809 cx: &mut AsyncApp,
810 ) -> Result<Value> {
811 let override_options = cx.update(|cx| {
812 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
813 .and_then(|s| s.settings.clone())
814 })?;
815 if let Some(options) = override_options {
816 return Ok(options);
817 }
818 Ok(json!({
819 "completions": {
820 "completeFunctionCalls": true
821 }
822 }))
823 }
824
825 fn language_ids(&self) -> HashMap<String, String> {
826 HashMap::from_iter([
827 ("TypeScript".into(), "typescript".into()),
828 ("JavaScript".into(), "javascript".into()),
829 ("TSX".into(), "typescriptreact".into()),
830 ])
831 }
832}
833
834async fn get_cached_ts_server_binary(
835 container_dir: PathBuf,
836 node: &NodeRuntime,
837) -> Option<LanguageServerBinary> {
838 maybe!(async {
839 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
840 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
841 if new_server_path.exists() {
842 Ok(LanguageServerBinary {
843 path: node.binary_path().await?,
844 env: None,
845 arguments: typescript_server_binary_arguments(&new_server_path),
846 })
847 } else if old_server_path.exists() {
848 Ok(LanguageServerBinary {
849 path: node.binary_path().await?,
850 env: None,
851 arguments: typescript_server_binary_arguments(&old_server_path),
852 })
853 } else {
854 anyhow::bail!("missing executable in directory {container_dir:?}")
855 }
856 })
857 .await
858 .log_err()
859}
860
861pub struct EsLintLspAdapter {
862 node: NodeRuntime,
863}
864
865impl EsLintLspAdapter {
866 const CURRENT_VERSION: &'static str = "2.4.4";
867 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
868
869 #[cfg(not(windows))]
870 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
871 #[cfg(windows)]
872 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
873
874 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
875 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
876
877 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
878 "eslint.config.js",
879 "eslint.config.mjs",
880 "eslint.config.cjs",
881 "eslint.config.ts",
882 "eslint.config.cts",
883 "eslint.config.mts",
884 ];
885
886 pub fn new(node: NodeRuntime) -> Self {
887 EsLintLspAdapter { node }
888 }
889
890 fn build_destination_path(container_dir: &Path) -> PathBuf {
891 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
892 }
893}
894
895#[async_trait(?Send)]
896impl LspAdapter for EsLintLspAdapter {
897 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
898 Some(vec![
899 CodeActionKind::QUICKFIX,
900 CodeActionKind::new("source.fixAll.eslint"),
901 ])
902 }
903
904 async fn workspace_configuration(
905 self: Arc<Self>,
906 _: &dyn Fs,
907 delegate: &Arc<dyn LspAdapterDelegate>,
908 _: Arc<dyn LanguageToolchainStore>,
909 cx: &mut AsyncApp,
910 ) -> Result<Value> {
911 let workspace_root = delegate.worktree_root_path();
912 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
913 .iter()
914 .any(|file| workspace_root.join(file).is_file());
915
916 let mut default_workspace_configuration = json!({
917 "validate": "on",
918 "rulesCustomizations": [],
919 "run": "onType",
920 "nodePath": null,
921 "workingDirectory": {
922 "mode": "auto"
923 },
924 "workspaceFolder": {
925 "uri": workspace_root,
926 "name": workspace_root.file_name()
927 .unwrap_or(workspace_root.as_os_str())
928 .to_string_lossy(),
929 },
930 "problems": {},
931 "codeActionOnSave": {
932 // We enable this, but without also configuring code_actions_on_format
933 // in the Zed configuration, it doesn't have an effect.
934 "enable": true,
935 },
936 "codeAction": {
937 "disableRuleComment": {
938 "enable": true,
939 "location": "separateLine",
940 },
941 "showDocumentation": {
942 "enable": true
943 }
944 },
945 "experimental": {
946 "useFlatConfig": use_flat_config,
947 },
948 });
949
950 let override_options = cx.update(|cx| {
951 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
952 .and_then(|s| s.settings.clone())
953 })?;
954
955 if let Some(override_options) = override_options {
956 merge_json_value_into(override_options, &mut default_workspace_configuration);
957 }
958
959 Ok(json!({
960 "": default_workspace_configuration
961 }))
962 }
963
964 fn name(&self) -> LanguageServerName {
965 Self::SERVER_NAME.clone()
966 }
967
968 async fn fetch_latest_server_version(
969 &self,
970 _delegate: &dyn LspAdapterDelegate,
971 ) -> Result<Box<dyn 'static + Send + Any>> {
972 let url = build_asset_url(
973 "zed-industries/vscode-eslint",
974 Self::CURRENT_VERSION_TAG_NAME,
975 Self::GITHUB_ASSET_KIND,
976 )?;
977
978 Ok(Box::new(GitHubLspBinaryVersion {
979 name: Self::CURRENT_VERSION.into(),
980 url,
981 }))
982 }
983
984 async fn fetch_server_binary(
985 &self,
986 version: Box<dyn 'static + Send + Any>,
987 container_dir: PathBuf,
988 delegate: &dyn LspAdapterDelegate,
989 ) -> Result<LanguageServerBinary> {
990 let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
991 let destination_path = Self::build_destination_path(&container_dir);
992 let server_path = destination_path.join(Self::SERVER_PATH);
993
994 if fs::metadata(&server_path).await.is_err() {
995 remove_matching(&container_dir, |entry| entry != destination_path).await;
996
997 let mut response = delegate
998 .http_client()
999 .get(&version.url, Default::default(), true)
1000 .await
1001 .context("downloading release")?;
1002 match Self::GITHUB_ASSET_KIND {
1003 AssetKind::TarGz => {
1004 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
1005 let archive = Archive::new(decompressed_bytes);
1006 archive.unpack(&destination_path).await.with_context(|| {
1007 format!("extracting {} to {:?}", version.url, destination_path)
1008 })?;
1009 }
1010 AssetKind::Gz => {
1011 let mut decompressed_bytes =
1012 GzipDecoder::new(BufReader::new(response.body_mut()));
1013 let mut file =
1014 fs::File::create(&destination_path).await.with_context(|| {
1015 format!(
1016 "creating a file {:?} for a download from {}",
1017 destination_path, version.url,
1018 )
1019 })?;
1020 futures::io::copy(&mut decompressed_bytes, &mut file)
1021 .await
1022 .with_context(|| {
1023 format!("extracting {} to {:?}", version.url, destination_path)
1024 })?;
1025 }
1026 AssetKind::Zip => {
1027 extract_zip(&destination_path, response.body_mut())
1028 .await
1029 .with_context(|| {
1030 format!("unzipping {} to {:?}", version.url, destination_path)
1031 })?;
1032 }
1033 }
1034
1035 let mut dir = fs::read_dir(&destination_path).await?;
1036 let first = dir.next().await.context("missing first file")??;
1037 let repo_root = destination_path.join("vscode-eslint");
1038 fs::rename(first.path(), &repo_root).await?;
1039
1040 #[cfg(target_os = "windows")]
1041 {
1042 handle_symlink(
1043 repo_root.join("$shared"),
1044 repo_root.join("client").join("src").join("shared"),
1045 )
1046 .await?;
1047 handle_symlink(
1048 repo_root.join("$shared"),
1049 repo_root.join("server").join("src").join("shared"),
1050 )
1051 .await?;
1052 }
1053
1054 self.node
1055 .run_npm_subcommand(&repo_root, "install", &[])
1056 .await?;
1057
1058 self.node
1059 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
1060 .await?;
1061 }
1062
1063 Ok(LanguageServerBinary {
1064 path: self.node.binary_path().await?,
1065 env: None,
1066 arguments: eslint_server_binary_arguments(&server_path),
1067 })
1068 }
1069
1070 async fn cached_server_binary(
1071 &self,
1072 container_dir: PathBuf,
1073 _: &dyn LspAdapterDelegate,
1074 ) -> Option<LanguageServerBinary> {
1075 let server_path =
1076 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
1077 Some(LanguageServerBinary {
1078 path: self.node.binary_path().await.ok()?,
1079 env: None,
1080 arguments: eslint_server_binary_arguments(&server_path),
1081 })
1082 }
1083}
1084
1085#[cfg(target_os = "windows")]
1086async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1087 anyhow::ensure!(
1088 fs::metadata(&src_dir).await.is_ok(),
1089 "Directory {src_dir:?} is not present"
1090 );
1091 if fs::metadata(&dest_dir).await.is_ok() {
1092 fs::remove_file(&dest_dir).await?;
1093 }
1094 fs::create_dir_all(&dest_dir).await?;
1095 let mut entries = fs::read_dir(&src_dir).await?;
1096 while let Some(entry) = entries.try_next().await? {
1097 let entry_path = entry.path();
1098 let entry_name = entry.file_name();
1099 let dest_path = dest_dir.join(&entry_name);
1100 fs::copy(&entry_path, &dest_path).await?;
1101 }
1102 Ok(())
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use std::path::Path;
1108
1109 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1110 use language::language_settings;
1111 use project::{FakeFs, Project};
1112 use serde_json::json;
1113 use unindent::Unindent;
1114 use util::path;
1115
1116 use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
1117
1118 #[gpui::test]
1119 async fn test_outline(cx: &mut TestAppContext) {
1120 let language = crate::language(
1121 "typescript",
1122 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1123 );
1124
1125 let text = r#"
1126 function a() {
1127 // local variables are omitted
1128 let a1 = 1;
1129 // all functions are included
1130 async function a2() {}
1131 }
1132 // top-level variables are included
1133 let b: C
1134 function getB() {}
1135 // exported variables are included
1136 export const d = e;
1137 "#
1138 .unindent();
1139
1140 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1141 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
1142 assert_eq!(
1143 outline
1144 .items
1145 .iter()
1146 .map(|item| (item.text.as_str(), item.depth))
1147 .collect::<Vec<_>>(),
1148 &[
1149 ("function a()", 0),
1150 ("async function a2()", 1),
1151 ("let b", 0),
1152 ("function getB()", 0),
1153 ("const d", 0),
1154 ]
1155 );
1156 }
1157
1158 #[gpui::test]
1159 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1160 cx.update(|cx| {
1161 settings::init(cx);
1162 Project::init_settings(cx);
1163 language_settings::init(cx);
1164 });
1165
1166 let package_json_1 = json!({
1167 "dependencies": {
1168 "mocha": "1.0.0",
1169 "vitest": "1.0.0"
1170 },
1171 "scripts": {
1172 "test": ""
1173 }
1174 })
1175 .to_string();
1176
1177 let package_json_2 = json!({
1178 "devDependencies": {
1179 "vitest": "2.0.0"
1180 },
1181 "scripts": {
1182 "test": ""
1183 }
1184 })
1185 .to_string();
1186
1187 let fs = FakeFs::new(executor);
1188 fs.insert_tree(
1189 path!("/root"),
1190 json!({
1191 "package.json": package_json_1,
1192 "sub": {
1193 "package.json": package_json_2,
1194 "file.js": "",
1195 }
1196 }),
1197 )
1198 .await;
1199
1200 let provider = TypeScriptContextProvider::new();
1201 let package_json_data = cx
1202 .update(|cx| {
1203 provider.combined_package_json_data(
1204 fs.clone(),
1205 path!("/root").as_ref(),
1206 "sub/file1.js".as_ref(),
1207 cx,
1208 )
1209 })
1210 .await
1211 .unwrap();
1212 pretty_assertions::assert_eq!(
1213 package_json_data,
1214 PackageJsonData {
1215 jest_package_path: None,
1216 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1217 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1218 jasmine_package_path: None,
1219 scripts: [
1220 (
1221 Path::new(path!("/root/package.json")).into(),
1222 "test".to_owned()
1223 ),
1224 (
1225 Path::new(path!("/root/sub/package.json")).into(),
1226 "test".to_owned()
1227 )
1228 ]
1229 .into_iter()
1230 .collect(),
1231 package_manager: None,
1232 }
1233 );
1234 }
1235}