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