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 {
347 return package_manager;
348 }
349 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
350 return "pnpm";
351 }
352 if fs.is_file(&worktree_root.join("yarn.lock")).await {
353 return "yarn";
354 }
355 "npm"
356}
357
358impl ContextProvider for TypeScriptContextProvider {
359 fn associated_tasks(
360 &self,
361 fs: Arc<dyn Fs>,
362 file: Option<Arc<dyn File>>,
363 cx: &App,
364 ) -> Task<Option<TaskTemplates>> {
365 let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
366 return Task::ready(None);
367 };
368 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
369 return Task::ready(None);
370 };
371 let file_relative_path = file.path().clone();
372 let package_json_data =
373 self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx);
374
375 cx.background_spawn(async move {
376 let mut task_templates = TaskTemplates(Vec::new());
377 task_templates.0.push(TaskTemplate {
378 label: format!(
379 "execute selection {}",
380 VariableName::SelectedText.template_value()
381 ),
382 command: "node".to_owned(),
383 args: vec![
384 "-e".to_owned(),
385 format!("\"{}\"", VariableName::SelectedText.template_value()),
386 ],
387 ..TaskTemplate::default()
388 });
389
390 match package_json_data.await {
391 Ok(package_json) => {
392 package_json.fill_task_templates(&mut task_templates);
393 }
394 Err(e) => {
395 log::error!(
396 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
397 );
398 }
399 }
400
401 Some(task_templates)
402 })
403 }
404
405 fn build_context(
406 &self,
407 current_vars: &task::TaskVariables,
408 location: ContextLocation<'_>,
409 _project_env: Option<HashMap<String, String>>,
410 _toolchains: Arc<dyn LanguageToolchainStore>,
411 cx: &mut App,
412 ) -> Task<Result<task::TaskVariables>> {
413 let mut vars = task::TaskVariables::default();
414
415 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
416 vars.insert(
417 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
418 replace_test_name_parameters(symbol),
419 );
420 vars.insert(
421 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
422 replace_test_name_parameters(symbol),
423 );
424 }
425 let file_path = location
426 .file_location
427 .buffer
428 .read(cx)
429 .file()
430 .map(|file| file.path());
431
432 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
433 |((worktree_root, fs), file_path)| {
434 (
435 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
436 worktree_root,
437 fs,
438 )
439 },
440 );
441 cx.background_spawn(async move {
442 if let Some((task, worktree_root, fs)) = args {
443 let package_json_data = task.await.log_err();
444 vars.insert(
445 TYPESCRIPT_RUNNER_VARIABLE,
446 detect_package_manager(worktree_root, fs, package_json_data.clone())
447 .await
448 .to_owned(),
449 );
450
451 if let Some(package_json_data) = package_json_data {
452 if let Some(path) = package_json_data.jest_package_path {
453 vars.insert(
454 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
455 path.parent()
456 .unwrap_or(Path::new(""))
457 .to_string_lossy()
458 .to_string(),
459 );
460 }
461
462 if let Some(path) = package_json_data.mocha_package_path {
463 vars.insert(
464 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
465 path.parent()
466 .unwrap_or(Path::new(""))
467 .to_string_lossy()
468 .to_string(),
469 );
470 }
471
472 if let Some(path) = package_json_data.vitest_package_path {
473 vars.insert(
474 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
475 path.parent()
476 .unwrap_or(Path::new(""))
477 .to_string_lossy()
478 .to_string(),
479 );
480 }
481
482 if let Some(path) = package_json_data.jasmine_package_path {
483 vars.insert(
484 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
485 path.parent()
486 .unwrap_or(Path::new(""))
487 .to_string_lossy()
488 .to_string(),
489 );
490 }
491 }
492 }
493 Ok(vars)
494 })
495 }
496}
497
498fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
499 vec![server_path.into(), "--stdio".into()]
500}
501
502fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
503 vec![
504 "--max-old-space-size=8192".into(),
505 server_path.into(),
506 "--stdio".into(),
507 ]
508}
509
510fn replace_test_name_parameters(test_name: &str) -> String {
511 let pattern = regex::Regex::new(r"(%|\$)[0-9a-zA-Z]+").unwrap();
512
513 regex::escape(&pattern.replace_all(test_name, "(.+?)"))
514}
515
516pub struct TypeScriptLspAdapter {
517 node: NodeRuntime,
518}
519
520impl TypeScriptLspAdapter {
521 const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
522 const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
523 const SERVER_NAME: LanguageServerName =
524 LanguageServerName::new_static("typescript-language-server");
525 const PACKAGE_NAME: &str = "typescript";
526 pub fn new(node: NodeRuntime) -> Self {
527 TypeScriptLspAdapter { node }
528 }
529 async fn tsdk_path(fs: &dyn Fs, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
530 let is_yarn = adapter
531 .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js"))
532 .await
533 .is_ok();
534
535 let tsdk_path = if is_yarn {
536 ".yarn/sdks/typescript/lib"
537 } else {
538 "node_modules/typescript/lib"
539 };
540
541 if fs
542 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
543 .await
544 {
545 Some(tsdk_path)
546 } else {
547 None
548 }
549 }
550}
551
552struct TypeScriptVersions {
553 typescript_version: String,
554 server_version: String,
555}
556
557#[async_trait(?Send)]
558impl LspAdapter for TypeScriptLspAdapter {
559 fn name(&self) -> LanguageServerName {
560 Self::SERVER_NAME
561 }
562
563 async fn fetch_latest_server_version(
564 &self,
565 _: &dyn LspAdapterDelegate,
566 _: &AsyncApp,
567 ) -> Result<Box<dyn 'static + Send + Any>> {
568 Ok(Box::new(TypeScriptVersions {
569 typescript_version: self.node.npm_package_latest_version("typescript").await?,
570 server_version: self
571 .node
572 .npm_package_latest_version("typescript-language-server")
573 .await?,
574 }) as Box<_>)
575 }
576
577 async fn check_if_version_installed(
578 &self,
579 version: &(dyn 'static + Send + Any),
580 container_dir: &PathBuf,
581 _: &dyn LspAdapterDelegate,
582 ) -> Option<LanguageServerBinary> {
583 let version = version.downcast_ref::<TypeScriptVersions>().unwrap();
584 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
585
586 let should_install_language_server = self
587 .node
588 .should_install_npm_package(
589 Self::PACKAGE_NAME,
590 &server_path,
591 container_dir,
592 VersionStrategy::Latest(version.typescript_version.as_str()),
593 )
594 .await;
595
596 if should_install_language_server {
597 None
598 } else {
599 Some(LanguageServerBinary {
600 path: self.node.binary_path().await.ok()?,
601 env: None,
602 arguments: typescript_server_binary_arguments(&server_path),
603 })
604 }
605 }
606
607 async fn fetch_server_binary(
608 &self,
609 latest_version: Box<dyn 'static + Send + Any>,
610 container_dir: PathBuf,
611 _: &dyn LspAdapterDelegate,
612 ) -> Result<LanguageServerBinary> {
613 let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
614 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
615
616 self.node
617 .npm_install_packages(
618 &container_dir,
619 &[
620 (
621 Self::PACKAGE_NAME,
622 latest_version.typescript_version.as_str(),
623 ),
624 (
625 "typescript-language-server",
626 latest_version.server_version.as_str(),
627 ),
628 ],
629 )
630 .await?;
631
632 Ok(LanguageServerBinary {
633 path: self.node.binary_path().await?,
634 env: None,
635 arguments: typescript_server_binary_arguments(&server_path),
636 })
637 }
638
639 async fn cached_server_binary(
640 &self,
641 container_dir: PathBuf,
642 _: &dyn LspAdapterDelegate,
643 ) -> Option<LanguageServerBinary> {
644 get_cached_ts_server_binary(container_dir, &self.node).await
645 }
646
647 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
648 Some(vec![
649 CodeActionKind::QUICKFIX,
650 CodeActionKind::REFACTOR,
651 CodeActionKind::REFACTOR_EXTRACT,
652 CodeActionKind::SOURCE,
653 ])
654 }
655
656 async fn label_for_completion(
657 &self,
658 item: &lsp::CompletionItem,
659 language: &Arc<language::Language>,
660 ) -> Option<language::CodeLabel> {
661 use lsp::CompletionItemKind as Kind;
662 let len = item.label.len();
663 let grammar = language.grammar()?;
664 let highlight_id = match item.kind? {
665 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
666 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
667 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
668 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
669 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
670 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
671 _ => None,
672 }?;
673
674 let text = if let Some(description) = item
675 .label_details
676 .as_ref()
677 .and_then(|label_details| label_details.description.as_ref())
678 {
679 format!("{} {}", item.label, description)
680 } else if let Some(detail) = &item.detail {
681 format!("{} {}", item.label, detail)
682 } else {
683 item.label.clone()
684 };
685 let filter_range = item
686 .filter_text
687 .as_deref()
688 .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len()))
689 .unwrap_or(0..len);
690 Some(language::CodeLabel {
691 text,
692 runs: vec![(0..len, highlight_id)],
693 filter_range,
694 })
695 }
696
697 async fn initialization_options(
698 self: Arc<Self>,
699 fs: &dyn Fs,
700 adapter: &Arc<dyn LspAdapterDelegate>,
701 ) -> Result<Option<serde_json::Value>> {
702 let tsdk_path = Self::tsdk_path(fs, adapter).await;
703 Ok(Some(json!({
704 "provideFormatter": true,
705 "hostInfo": "zed",
706 "tsserver": {
707 "path": tsdk_path,
708 },
709 "preferences": {
710 "includeInlayParameterNameHints": "all",
711 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
712 "includeInlayFunctionParameterTypeHints": true,
713 "includeInlayVariableTypeHints": true,
714 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
715 "includeInlayPropertyDeclarationTypeHints": true,
716 "includeInlayFunctionLikeReturnTypeHints": true,
717 "includeInlayEnumMemberValueHints": true,
718 }
719 })))
720 }
721
722 async fn workspace_configuration(
723 self: Arc<Self>,
724 _: &dyn Fs,
725 delegate: &Arc<dyn LspAdapterDelegate>,
726 _: Option<Toolchain>,
727 cx: &mut AsyncApp,
728 ) -> Result<Value> {
729 let override_options = cx.update(|cx| {
730 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
731 .and_then(|s| s.settings.clone())
732 })?;
733 if let Some(options) = override_options {
734 return Ok(options);
735 }
736 Ok(json!({
737 "completions": {
738 "completeFunctionCalls": true
739 }
740 }))
741 }
742
743 fn language_ids(&self) -> HashMap<LanguageName, String> {
744 HashMap::from_iter([
745 (LanguageName::new("TypeScript"), "typescript".into()),
746 (LanguageName::new("JavaScript"), "javascript".into()),
747 (LanguageName::new("TSX"), "typescriptreact".into()),
748 ])
749 }
750}
751
752async fn get_cached_ts_server_binary(
753 container_dir: PathBuf,
754 node: &NodeRuntime,
755) -> Option<LanguageServerBinary> {
756 maybe!(async {
757 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
758 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
759 if new_server_path.exists() {
760 Ok(LanguageServerBinary {
761 path: node.binary_path().await?,
762 env: None,
763 arguments: typescript_server_binary_arguments(&new_server_path),
764 })
765 } else if old_server_path.exists() {
766 Ok(LanguageServerBinary {
767 path: node.binary_path().await?,
768 env: None,
769 arguments: typescript_server_binary_arguments(&old_server_path),
770 })
771 } else {
772 anyhow::bail!("missing executable in directory {container_dir:?}")
773 }
774 })
775 .await
776 .log_err()
777}
778
779pub struct EsLintLspAdapter {
780 node: NodeRuntime,
781}
782
783impl EsLintLspAdapter {
784 const CURRENT_VERSION: &'static str = "2.4.4";
785 const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
786
787 #[cfg(not(windows))]
788 const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
789 #[cfg(windows)]
790 const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
791
792 const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
793 const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
794
795 const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
796 "eslint.config.js",
797 "eslint.config.mjs",
798 "eslint.config.cjs",
799 "eslint.config.ts",
800 "eslint.config.cts",
801 "eslint.config.mts",
802 ];
803
804 pub fn new(node: NodeRuntime) -> Self {
805 EsLintLspAdapter { node }
806 }
807
808 fn build_destination_path(container_dir: &Path) -> PathBuf {
809 container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
810 }
811}
812
813#[async_trait(?Send)]
814impl LspAdapter for EsLintLspAdapter {
815 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
816 Some(vec![
817 CodeActionKind::QUICKFIX,
818 CodeActionKind::new("source.fixAll.eslint"),
819 ])
820 }
821
822 async fn workspace_configuration(
823 self: Arc<Self>,
824 _: &dyn Fs,
825 delegate: &Arc<dyn LspAdapterDelegate>,
826 _: Option<Toolchain>,
827 cx: &mut AsyncApp,
828 ) -> Result<Value> {
829 let workspace_root = delegate.worktree_root_path();
830 let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
831 .iter()
832 .any(|file| workspace_root.join(file).is_file());
833
834 let mut default_workspace_configuration = json!({
835 "validate": "on",
836 "rulesCustomizations": [],
837 "run": "onType",
838 "nodePath": null,
839 "workingDirectory": {
840 "mode": "auto"
841 },
842 "workspaceFolder": {
843 "uri": workspace_root,
844 "name": workspace_root.file_name()
845 .unwrap_or(workspace_root.as_os_str())
846 .to_string_lossy(),
847 },
848 "problems": {},
849 "codeActionOnSave": {
850 // We enable this, but without also configuring code_actions_on_format
851 // in the Zed configuration, it doesn't have an effect.
852 "enable": true,
853 },
854 "codeAction": {
855 "disableRuleComment": {
856 "enable": true,
857 "location": "separateLine",
858 },
859 "showDocumentation": {
860 "enable": true
861 }
862 },
863 "experimental": {
864 "useFlatConfig": use_flat_config,
865 }
866 });
867
868 let override_options = cx.update(|cx| {
869 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
870 .and_then(|s| s.settings.clone())
871 })?;
872
873 if let Some(override_options) = override_options {
874 merge_json_value_into(override_options, &mut default_workspace_configuration);
875 }
876
877 Ok(json!({
878 "": default_workspace_configuration
879 }))
880 }
881
882 fn name(&self) -> LanguageServerName {
883 Self::SERVER_NAME
884 }
885
886 async fn fetch_latest_server_version(
887 &self,
888 _delegate: &dyn LspAdapterDelegate,
889 _: &AsyncApp,
890 ) -> Result<Box<dyn 'static + Send + Any>> {
891 let url = build_asset_url(
892 "zed-industries/vscode-eslint",
893 Self::CURRENT_VERSION_TAG_NAME,
894 Self::GITHUB_ASSET_KIND,
895 )?;
896
897 Ok(Box::new(GitHubLspBinaryVersion {
898 name: Self::CURRENT_VERSION.into(),
899 digest: None,
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, |_| true).await;
916
917 download_server_binary(
918 delegate,
919 &version.url,
920 None,
921 &destination_path,
922 Self::GITHUB_ASSET_KIND,
923 )
924 .await?;
925
926 let mut dir = fs::read_dir(&destination_path).await?;
927 let first = dir.next().await.context("missing first file")??;
928 let repo_root = destination_path.join("vscode-eslint");
929 fs::rename(first.path(), &repo_root).await?;
930
931 #[cfg(target_os = "windows")]
932 {
933 handle_symlink(
934 repo_root.join("$shared"),
935 repo_root.join("client").join("src").join("shared"),
936 )
937 .await?;
938 handle_symlink(
939 repo_root.join("$shared"),
940 repo_root.join("server").join("src").join("shared"),
941 )
942 .await?;
943 }
944
945 self.node
946 .run_npm_subcommand(&repo_root, "install", &[])
947 .await?;
948
949 self.node
950 .run_npm_subcommand(&repo_root, "run-script", &["compile"])
951 .await?;
952 }
953
954 Ok(LanguageServerBinary {
955 path: self.node.binary_path().await?,
956 env: None,
957 arguments: eslint_server_binary_arguments(&server_path),
958 })
959 }
960
961 async fn cached_server_binary(
962 &self,
963 container_dir: PathBuf,
964 _: &dyn LspAdapterDelegate,
965 ) -> Option<LanguageServerBinary> {
966 let server_path =
967 Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
968 Some(LanguageServerBinary {
969 path: self.node.binary_path().await.ok()?,
970 env: None,
971 arguments: eslint_server_binary_arguments(&server_path),
972 })
973 }
974}
975
976#[cfg(target_os = "windows")]
977async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
978 anyhow::ensure!(
979 fs::metadata(&src_dir).await.is_ok(),
980 "Directory {src_dir:?} is not present"
981 );
982 if fs::metadata(&dest_dir).await.is_ok() {
983 fs::remove_file(&dest_dir).await?;
984 }
985 fs::create_dir_all(&dest_dir).await?;
986 let mut entries = fs::read_dir(&src_dir).await?;
987 while let Some(entry) = entries.try_next().await? {
988 let entry_path = entry.path();
989 let entry_name = entry.file_name();
990 let dest_path = dest_dir.join(&entry_name);
991 fs::copy(&entry_path, &dest_path).await?;
992 }
993 Ok(())
994}
995
996#[cfg(test)]
997mod tests {
998 use std::path::Path;
999
1000 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1001 use language::language_settings;
1002 use project::{FakeFs, Project};
1003 use serde_json::json;
1004 use task::TaskTemplates;
1005 use unindent::Unindent;
1006 use util::path;
1007
1008 use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
1009
1010 #[gpui::test]
1011 async fn test_outline(cx: &mut TestAppContext) {
1012 let language = crate::language(
1013 "typescript",
1014 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1015 );
1016
1017 let text = r#"
1018 function a() {
1019 // local variables are omitted
1020 let a1 = 1;
1021 // all functions are included
1022 async function a2() {}
1023 }
1024 // top-level variables are included
1025 let b: C
1026 function getB() {}
1027 // exported variables are included
1028 export const d = e;
1029 "#
1030 .unindent();
1031
1032 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1033 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
1034 assert_eq!(
1035 outline
1036 .items
1037 .iter()
1038 .map(|item| (item.text.as_str(), item.depth))
1039 .collect::<Vec<_>>(),
1040 &[
1041 ("function a()", 0),
1042 ("async function a2()", 1),
1043 ("let b", 0),
1044 ("function getB()", 0),
1045 ("const d", 0),
1046 ]
1047 );
1048 }
1049
1050 #[gpui::test]
1051 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1052 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1053
1054 let text = r#"
1055 function normalFunction() {
1056 console.log("normal");
1057 }
1058
1059 function* simpleGenerator() {
1060 yield 1;
1061 yield 2;
1062 }
1063
1064 async function* asyncGenerator() {
1065 yield await Promise.resolve(1);
1066 }
1067
1068 function* generatorWithParams(start, end) {
1069 for (let i = start; i <= end; i++) {
1070 yield i;
1071 }
1072 }
1073
1074 class TestClass {
1075 *methodGenerator() {
1076 yield "method";
1077 }
1078
1079 async *asyncMethodGenerator() {
1080 yield "async method";
1081 }
1082 }
1083 "#
1084 .unindent();
1085
1086 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1087 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
1088 assert_eq!(
1089 outline
1090 .items
1091 .iter()
1092 .map(|item| (item.text.as_str(), item.depth))
1093 .collect::<Vec<_>>(),
1094 &[
1095 ("function normalFunction()", 0),
1096 ("function* simpleGenerator()", 0),
1097 ("async function* asyncGenerator()", 0),
1098 ("function* generatorWithParams( )", 0),
1099 ("class TestClass", 0),
1100 ("*methodGenerator()", 1),
1101 ("async *asyncMethodGenerator()", 1),
1102 ]
1103 );
1104 }
1105
1106 #[gpui::test]
1107 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1108 cx.update(|cx| {
1109 settings::init(cx);
1110 Project::init_settings(cx);
1111 language_settings::init(cx);
1112 });
1113
1114 let package_json_1 = json!({
1115 "dependencies": {
1116 "mocha": "1.0.0",
1117 "vitest": "1.0.0"
1118 },
1119 "scripts": {
1120 "test": ""
1121 }
1122 })
1123 .to_string();
1124
1125 let package_json_2 = json!({
1126 "devDependencies": {
1127 "vitest": "2.0.0"
1128 },
1129 "scripts": {
1130 "test": ""
1131 }
1132 })
1133 .to_string();
1134
1135 let fs = FakeFs::new(executor);
1136 fs.insert_tree(
1137 path!("/root"),
1138 json!({
1139 "package.json": package_json_1,
1140 "sub": {
1141 "package.json": package_json_2,
1142 "file.js": "",
1143 }
1144 }),
1145 )
1146 .await;
1147
1148 let provider = TypeScriptContextProvider::new();
1149 let package_json_data = cx
1150 .update(|cx| {
1151 provider.combined_package_json_data(
1152 fs.clone(),
1153 path!("/root").as_ref(),
1154 "sub/file1.js".as_ref(),
1155 cx,
1156 )
1157 })
1158 .await
1159 .unwrap();
1160 pretty_assertions::assert_eq!(
1161 package_json_data,
1162 PackageJsonData {
1163 jest_package_path: None,
1164 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1165 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1166 jasmine_package_path: None,
1167 scripts: [
1168 (
1169 Path::new(path!("/root/package.json")).into(),
1170 "test".to_owned()
1171 ),
1172 (
1173 Path::new(path!("/root/sub/package.json")).into(),
1174 "test".to_owned()
1175 )
1176 ]
1177 .into_iter()
1178 .collect(),
1179 package_manager: None,
1180 }
1181 );
1182
1183 let mut task_templates = TaskTemplates::default();
1184 package_json_data.fill_task_templates(&mut task_templates);
1185 let task_templates = task_templates
1186 .0
1187 .into_iter()
1188 .map(|template| (template.label, template.cwd))
1189 .collect::<Vec<_>>();
1190 pretty_assertions::assert_eq!(
1191 task_templates,
1192 [
1193 (
1194 "vitest file test".into(),
1195 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1196 ),
1197 (
1198 "vitest test $ZED_SYMBOL".into(),
1199 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1200 ),
1201 (
1202 "mocha file test".into(),
1203 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1204 ),
1205 (
1206 "mocha test $ZED_SYMBOL".into(),
1207 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1208 ),
1209 (
1210 "root/package.json > test".into(),
1211 Some(path!("/root").into())
1212 ),
1213 (
1214 "sub/package.json > test".into(),
1215 Some(path!("/root/sub").into())
1216 ),
1217 ]
1218 );
1219 }
1220}