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 = "3.0.10";
867 const CURRENT_VERSION_TAG_NAME: &'static str = "release/3.0.10";
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 "useFlatConfig": use_flat_config,
946 });
947
948 let override_options = cx.update(|cx| {
949 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
950 .and_then(|s| s.settings.clone())
951 })?;
952
953 if let Some(override_options) = override_options {
954 merge_json_value_into(override_options, &mut default_workspace_configuration);
955 }
956
957 Ok(json!({
958 "": default_workspace_configuration
959 }))
960 }
961
962 fn name(&self) -> LanguageServerName {
963 Self::SERVER_NAME.clone()
964 }
965
966 async fn fetch_latest_server_version(
967 &self,
968 _delegate: &dyn LspAdapterDelegate,
969 ) -> Result<Box<dyn 'static + Send + Any>> {
970 let url = build_asset_url(
971 "zed-industries/vscode-eslint",
972 Self::CURRENT_VERSION_TAG_NAME,
973 Self::GITHUB_ASSET_KIND,
974 )?;
975
976 Ok(Box::new(GitHubLspBinaryVersion {
977 name: Self::CURRENT_VERSION.into(),
978 url,
979 }))
980 }
981
982 async fn fetch_server_binary(
983 &self,
984 version: Box<dyn 'static + Send + Any>,
985 container_dir: PathBuf,
986 delegate: &dyn LspAdapterDelegate,
987 ) -> Result<LanguageServerBinary> {
988 let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
989 let destination_path = Self::build_destination_path(&container_dir);
990 let server_path = destination_path.join(Self::SERVER_PATH);
991
992 if fs::metadata(&server_path).await.is_err() {
993 remove_matching(&container_dir, |entry| entry != destination_path).await;
994
995 let mut response = delegate
996 .http_client()
997 .get(&version.url, Default::default(), true)
998 .await
999 .context("downloading release")?;
1000 match Self::GITHUB_ASSET_KIND {
1001 AssetKind::TarGz => {
1002 let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
1003 let archive = Archive::new(decompressed_bytes);
1004 archive.unpack(&destination_path).await.with_context(|| {
1005 format!("extracting {} to {:?}", version.url, destination_path)
1006 })?;
1007 }
1008 AssetKind::Gz => {
1009 let mut decompressed_bytes =
1010 GzipDecoder::new(BufReader::new(response.body_mut()));
1011 let mut file =
1012 fs::File::create(&destination_path).await.with_context(|| {
1013 format!(
1014 "creating a file {:?} for a download from {}",
1015 destination_path, version.url,
1016 )
1017 })?;
1018 futures::io::copy(&mut decompressed_bytes, &mut file)
1019 .await
1020 .with_context(|| {
1021 format!("extracting {} to {:?}", version.url, destination_path)
1022 })?;
1023 }
1024 AssetKind::Zip => {
1025 extract_zip(&destination_path, response.body_mut())
1026 .await
1027 .with_context(|| {
1028 format!("unzipping {} to {:?}", version.url, destination_path)
1029 })?;
1030 }
1031 }
1032
1033 let mut dir = fs::read_dir(&destination_path).await?;
1034 let first = dir.next().await.context("missing first file")??;
1035 let repo_root = destination_path.join("vscode-eslint");
1036 fs::rename(first.path(), &repo_root).await?;
1037
1038 #[cfg(target_os = "windows")]
1039 {
1040 handle_symlink(
1041 repo_root.join("$shared"),
1042 repo_root.join("client").join("src").join("shared"),
1043 )
1044 .await?;
1045 handle_symlink(
1046 repo_root.join("$shared"),
1047 repo_root.join("server").join("src").join("shared"),
1048 )
1049 .await?;
1050 }
1051
1052 self.node
1053 .run_npm_subcommand(&repo_root, "install", &[])
1054 .await?;
1055
1056 self.node
1057 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
1058 .await?;
1059 }
1060
1061 Ok(LanguageServerBinary {
1062 path: self.node.binary_path().await?,
1063 env: None,
1064 arguments: eslint_server_binary_arguments(&server_path),
1065 })
1066 }
1067
1068 async fn cached_server_binary(
1069 &self,
1070 container_dir: PathBuf,
1071 _: &dyn LspAdapterDelegate,
1072 ) -> Option<LanguageServerBinary> {
1073 let server_path =
1074 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
1075 Some(LanguageServerBinary {
1076 path: self.node.binary_path().await.ok()?,
1077 env: None,
1078 arguments: eslint_server_binary_arguments(&server_path),
1079 })
1080 }
1081}
1082
1083#[cfg(target_os = "windows")]
1084async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1085 anyhow::ensure!(
1086 fs::metadata(&src_dir).await.is_ok(),
1087 "Directory {src_dir:?} is not present"
1088 );
1089 if fs::metadata(&dest_dir).await.is_ok() {
1090 fs::remove_file(&dest_dir).await?;
1091 }
1092 fs::create_dir_all(&dest_dir).await?;
1093 let mut entries = fs::read_dir(&src_dir).await?;
1094 while let Some(entry) = entries.try_next().await? {
1095 let entry_path = entry.path();
1096 let entry_name = entry.file_name();
1097 let dest_path = dest_dir.join(&entry_name);
1098 fs::copy(&entry_path, &dest_path).await?;
1099 }
1100 Ok(())
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use std::path::Path;
1106
1107 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1108 use language::language_settings;
1109 use project::{FakeFs, Project};
1110 use serde_json::json;
1111 use unindent::Unindent;
1112 use util::path;
1113
1114 use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
1115
1116 #[gpui::test]
1117 async fn test_outline(cx: &mut TestAppContext) {
1118 let language = crate::language(
1119 "typescript",
1120 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1121 );
1122
1123 let text = r#"
1124 function a() {
1125 // local variables are omitted
1126 let a1 = 1;
1127 // all functions are included
1128 async function a2() {}
1129 }
1130 // top-level variables are included
1131 let b: C
1132 function getB() {}
1133 // exported variables are included
1134 export const d = e;
1135 "#
1136 .unindent();
1137
1138 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1139 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
1140 assert_eq!(
1141 outline
1142 .items
1143 .iter()
1144 .map(|item| (item.text.as_str(), item.depth))
1145 .collect::<Vec<_>>(),
1146 &[
1147 ("function a()", 0),
1148 ("async function a2()", 1),
1149 ("let b", 0),
1150 ("function getB()", 0),
1151 ("const d", 0),
1152 ]
1153 );
1154 }
1155
1156 #[gpui::test]
1157 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1158 cx.update(|cx| {
1159 settings::init(cx);
1160 Project::init_settings(cx);
1161 language_settings::init(cx);
1162 });
1163
1164 let package_json_1 = json!({
1165 "dependencies": {
1166 "mocha": "1.0.0",
1167 "vitest": "1.0.0"
1168 },
1169 "scripts": {
1170 "test": ""
1171 }
1172 })
1173 .to_string();
1174
1175 let package_json_2 = json!({
1176 "devDependencies": {
1177 "vitest": "2.0.0"
1178 },
1179 "scripts": {
1180 "test": ""
1181 }
1182 })
1183 .to_string();
1184
1185 let fs = FakeFs::new(executor);
1186 fs.insert_tree(
1187 path!("/root"),
1188 json!({
1189 "package.json": package_json_1,
1190 "sub": {
1191 "package.json": package_json_2,
1192 "file.js": "",
1193 }
1194 }),
1195 )
1196 .await;
1197
1198 let provider = TypeScriptContextProvider::new();
1199 let package_json_data = cx
1200 .update(|cx| {
1201 provider.combined_package_json_data(
1202 fs.clone(),
1203 path!("/root").as_ref(),
1204 "sub/file1.js".as_ref(),
1205 cx,
1206 )
1207 })
1208 .await
1209 .unwrap();
1210 pretty_assertions::assert_eq!(
1211 package_json_data,
1212 PackageJsonData {
1213 jest_package_path: None,
1214 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1215 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1216 jasmine_package_path: None,
1217 scripts: [
1218 (
1219 Path::new(path!("/root/package.json")).into(),
1220 "test".to_owned()
1221 ),
1222 (
1223 Path::new(path!("/root/sub/package.json")).into(),
1224 "test".to_owned()
1225 )
1226 ]
1227 .into_iter()
1228 .collect(),
1229 package_manager: None,
1230 }
1231 );
1232 }
1233}