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