typescript.rs

  1use anyhow::{Context as _, Result};
  2use async_compression::futures::bufread::GzipDecoder;
  3use async_tar::Archive;
  4use async_trait::async_trait;
  5use collections::HashMap;
  6use gpui::AsyncApp;
  7use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
  8use language::{LanguageToolchainStore, LspAdapter, LspAdapterDelegate};
  9use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
 10use node_runtime::NodeRuntime;
 11use project::ContextProviderWithTasks;
 12use project::{Fs, lsp_store::language_server_settings};
 13use serde_json::{Value, json};
 14use smol::{fs, io::BufReader, stream::StreamExt};
 15use std::{
 16    any::Any,
 17    ffi::OsString,
 18    path::{Path, PathBuf},
 19    sync::Arc,
 20};
 21use task::{TaskTemplate, TaskTemplates, VariableName};
 22use util::{ResultExt, fs::remove_matching, maybe};
 23
 24pub(super) fn typescript_task_context() -> ContextProviderWithTasks {
 25    ContextProviderWithTasks::new(TaskTemplates(vec![
 26        TaskTemplate {
 27            label: "jest file test".to_owned(),
 28            command: "npx jest".to_owned(),
 29            args: vec![VariableName::File.template_value()],
 30            ..TaskTemplate::default()
 31        },
 32        TaskTemplate {
 33            label: "jest test $ZED_SYMBOL".to_owned(),
 34            command: "npx jest".to_owned(),
 35            args: vec![
 36                "--testNamePattern".into(),
 37                format!("\"{}\"", VariableName::Symbol.template_value()),
 38                VariableName::File.template_value(),
 39            ],
 40            tags: vec!["ts-test".into(), "js-test".into(), "tsx-test".into()],
 41            ..TaskTemplate::default()
 42        },
 43        TaskTemplate {
 44            label: "execute selection $ZED_SELECTED_TEXT".to_owned(),
 45            command: "node".to_owned(),
 46            args: vec![
 47                "-e".into(),
 48                format!("\"{}\"", VariableName::SelectedText.template_value()),
 49            ],
 50            ..TaskTemplate::default()
 51        },
 52    ]))
 53}
 54
 55fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 56    vec![server_path.into(), "--stdio".into()]
 57}
 58
 59fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 60    vec![
 61        "--max-old-space-size=8192".into(),
 62        server_path.into(),
 63        "--stdio".into(),
 64    ]
 65}
 66
 67pub struct TypeScriptLspAdapter {
 68    node: NodeRuntime,
 69}
 70
 71impl TypeScriptLspAdapter {
 72    const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
 73    const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
 74    const SERVER_NAME: LanguageServerName =
 75        LanguageServerName::new_static("typescript-language-server");
 76    const PACKAGE_NAME: &str = "typescript";
 77    pub fn new(node: NodeRuntime) -> Self {
 78        TypeScriptLspAdapter { node }
 79    }
 80    async fn tsdk_path(fs: &dyn Fs, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
 81        let is_yarn = adapter
 82            .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js"))
 83            .await
 84            .is_ok();
 85
 86        let tsdk_path = if is_yarn {
 87            ".yarn/sdks/typescript/lib"
 88        } else {
 89            "node_modules/typescript/lib"
 90        };
 91
 92        if fs
 93            .is_dir(&adapter.worktree_root_path().join(tsdk_path))
 94            .await
 95        {
 96            Some(tsdk_path)
 97        } else {
 98            None
 99        }
100    }
101}
102
103struct TypeScriptVersions {
104    typescript_version: String,
105    server_version: String,
106}
107
108#[async_trait(?Send)]
109impl LspAdapter for TypeScriptLspAdapter {
110    fn name(&self) -> LanguageServerName {
111        Self::SERVER_NAME.clone()
112    }
113
114    async fn fetch_latest_server_version(
115        &self,
116        _: &dyn LspAdapterDelegate,
117    ) -> Result<Box<dyn 'static + Send + Any>> {
118        Ok(Box::new(TypeScriptVersions {
119            typescript_version: self.node.npm_package_latest_version("typescript").await?,
120            server_version: self
121                .node
122                .npm_package_latest_version("typescript-language-server")
123                .await?,
124        }) as Box<_>)
125    }
126
127    async fn check_if_version_installed(
128        &self,
129        version: &(dyn 'static + Send + Any),
130        container_dir: &PathBuf,
131        _: &dyn LspAdapterDelegate,
132    ) -> Option<LanguageServerBinary> {
133        let version = version.downcast_ref::<TypeScriptVersions>().unwrap();
134        let server_path = container_dir.join(Self::NEW_SERVER_PATH);
135
136        let should_install_language_server = self
137            .node
138            .should_install_npm_package(
139                Self::PACKAGE_NAME,
140                &server_path,
141                &container_dir,
142                version.typescript_version.as_str(),
143            )
144            .await;
145
146        if should_install_language_server {
147            None
148        } else {
149            Some(LanguageServerBinary {
150                path: self.node.binary_path().await.ok()?,
151                env: None,
152                arguments: typescript_server_binary_arguments(&server_path),
153            })
154        }
155    }
156
157    async fn fetch_server_binary(
158        &self,
159        latest_version: Box<dyn 'static + Send + Any>,
160        container_dir: PathBuf,
161        _: &dyn LspAdapterDelegate,
162    ) -> Result<LanguageServerBinary> {
163        let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
164        let server_path = container_dir.join(Self::NEW_SERVER_PATH);
165
166        self.node
167            .npm_install_packages(
168                &container_dir,
169                &[
170                    (
171                        Self::PACKAGE_NAME,
172                        latest_version.typescript_version.as_str(),
173                    ),
174                    (
175                        "typescript-language-server",
176                        latest_version.server_version.as_str(),
177                    ),
178                ],
179            )
180            .await?;
181
182        Ok(LanguageServerBinary {
183            path: self.node.binary_path().await?,
184            env: None,
185            arguments: typescript_server_binary_arguments(&server_path),
186        })
187    }
188
189    async fn cached_server_binary(
190        &self,
191        container_dir: PathBuf,
192        _: &dyn LspAdapterDelegate,
193    ) -> Option<LanguageServerBinary> {
194        get_cached_ts_server_binary(container_dir, &self.node).await
195    }
196
197    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
198        Some(vec![
199            CodeActionKind::QUICKFIX,
200            CodeActionKind::REFACTOR,
201            CodeActionKind::REFACTOR_EXTRACT,
202            CodeActionKind::SOURCE,
203        ])
204    }
205
206    async fn label_for_completion(
207        &self,
208        item: &lsp::CompletionItem,
209        language: &Arc<language::Language>,
210    ) -> Option<language::CodeLabel> {
211        use lsp::CompletionItemKind as Kind;
212        let len = item.label.len();
213        let grammar = language.grammar()?;
214        let highlight_id = match item.kind? {
215            Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
216            Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
217            Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
218            Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
219            Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
220            Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
221            _ => None,
222        }?;
223
224        let text = if let Some(description) = item
225            .label_details
226            .as_ref()
227            .and_then(|label_details| label_details.description.as_ref())
228        {
229            format!("{} {}", item.label, description)
230        } else if let Some(detail) = &item.detail {
231            format!("{} {}", item.label, detail)
232        } else {
233            item.label.clone()
234        };
235
236        Some(language::CodeLabel {
237            text,
238            runs: vec![(0..len, highlight_id)],
239            filter_range: 0..len,
240        })
241    }
242
243    async fn initialization_options(
244        self: Arc<Self>,
245        fs: &dyn Fs,
246        adapter: &Arc<dyn LspAdapterDelegate>,
247    ) -> Result<Option<serde_json::Value>> {
248        let tsdk_path = Self::tsdk_path(fs, adapter).await;
249        Ok(Some(json!({
250            "provideFormatter": true,
251            "hostInfo": "zed",
252            "tsserver": {
253                "path": tsdk_path,
254            },
255            "preferences": {
256                "includeInlayParameterNameHints": "all",
257                "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
258                "includeInlayFunctionParameterTypeHints": true,
259                "includeInlayVariableTypeHints": true,
260                "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
261                "includeInlayPropertyDeclarationTypeHints": true,
262                "includeInlayFunctionLikeReturnTypeHints": true,
263                "includeInlayEnumMemberValueHints": true,
264            }
265        })))
266    }
267
268    async fn workspace_configuration(
269        self: Arc<Self>,
270        _: &dyn Fs,
271        delegate: &Arc<dyn LspAdapterDelegate>,
272        _: Arc<dyn LanguageToolchainStore>,
273        cx: &mut AsyncApp,
274    ) -> Result<Value> {
275        let override_options = cx.update(|cx| {
276            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
277                .and_then(|s| s.settings.clone())
278        })?;
279        if let Some(options) = override_options {
280            return Ok(options);
281        }
282        Ok(json!({
283            "completions": {
284              "completeFunctionCalls": true
285            }
286        }))
287    }
288
289    fn language_ids(&self) -> HashMap<String, String> {
290        HashMap::from_iter([
291            ("TypeScript".into(), "typescript".into()),
292            ("JavaScript".into(), "javascript".into()),
293            ("TSX".into(), "typescriptreact".into()),
294        ])
295    }
296}
297
298async fn get_cached_ts_server_binary(
299    container_dir: PathBuf,
300    node: &NodeRuntime,
301) -> Option<LanguageServerBinary> {
302    maybe!(async {
303        let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
304        let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
305        if new_server_path.exists() {
306            Ok(LanguageServerBinary {
307                path: node.binary_path().await?,
308                env: None,
309                arguments: typescript_server_binary_arguments(&new_server_path),
310            })
311        } else if old_server_path.exists() {
312            Ok(LanguageServerBinary {
313                path: node.binary_path().await?,
314                env: None,
315                arguments: typescript_server_binary_arguments(&old_server_path),
316            })
317        } else {
318            anyhow::bail!("missing executable in directory {container_dir:?}")
319        }
320    })
321    .await
322    .log_err()
323}
324
325pub struct EsLintLspAdapter {
326    node: NodeRuntime,
327}
328
329impl EsLintLspAdapter {
330    const CURRENT_VERSION: &'static str = "2.4.4";
331    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
332
333    #[cfg(not(windows))]
334    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
335    #[cfg(windows)]
336    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
337
338    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
339    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
340
341    const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
342        "eslint.config.js",
343        "eslint.config.mjs",
344        "eslint.config.cjs",
345        "eslint.config.ts",
346        "eslint.config.cts",
347        "eslint.config.mts",
348    ];
349
350    pub fn new(node: NodeRuntime) -> Self {
351        EsLintLspAdapter { node }
352    }
353
354    fn build_destination_path(container_dir: &Path) -> PathBuf {
355        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
356    }
357}
358
359#[async_trait(?Send)]
360impl LspAdapter for EsLintLspAdapter {
361    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
362        Some(vec![
363            CodeActionKind::QUICKFIX,
364            CodeActionKind::new("source.fixAll.eslint"),
365        ])
366    }
367
368    async fn workspace_configuration(
369        self: Arc<Self>,
370        _: &dyn Fs,
371        delegate: &Arc<dyn LspAdapterDelegate>,
372        _: Arc<dyn LanguageToolchainStore>,
373        cx: &mut AsyncApp,
374    ) -> Result<Value> {
375        let workspace_root = delegate.worktree_root_path();
376
377        let eslint_user_settings = cx.update(|cx| {
378            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
379                .and_then(|s| s.settings.clone())
380                .unwrap_or_default()
381        })?;
382
383        let mut code_action_on_save = json!({
384            // We enable this, but without also configuring `code_actions_on_format`
385            // in the Zed configuration, it doesn't have an effect.
386            "enable": true,
387        });
388
389        if let Some(code_action_settings) = eslint_user_settings
390            .get("codeActionOnSave")
391            .and_then(|settings| settings.as_object())
392        {
393            if let Some(enable) = code_action_settings.get("enable") {
394                code_action_on_save["enable"] = enable.clone();
395            }
396            if let Some(mode) = code_action_settings.get("mode") {
397                code_action_on_save["mode"] = mode.clone();
398            }
399            if let Some(rules) = code_action_settings.get("rules") {
400                code_action_on_save["rules"] = rules.clone();
401            }
402        }
403
404        let working_directory = eslint_user_settings
405            .get("workingDirectory")
406            .cloned()
407            .unwrap_or_else(|| json!({"mode": "auto"}));
408
409        let problems = eslint_user_settings
410            .get("problems")
411            .cloned()
412            .unwrap_or_else(|| json!({}));
413
414        let rules_customizations = eslint_user_settings
415            .get("rulesCustomizations")
416            .cloned()
417            .unwrap_or_else(|| json!([]));
418
419        let node_path = eslint_user_settings.get("nodePath").unwrap_or(&Value::Null);
420        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
421            .iter()
422            .any(|file| workspace_root.join(file).is_file());
423
424        Ok(json!({
425            "": {
426                "validate": "on",
427                "rulesCustomizations": rules_customizations,
428                "run": "onType",
429                "nodePath": node_path,
430                "workingDirectory": working_directory,
431                "workspaceFolder": {
432                    "uri": workspace_root,
433                    "name": workspace_root.file_name()
434                        .unwrap_or(workspace_root.as_os_str()),
435                },
436                "problems": problems,
437                "codeActionOnSave": code_action_on_save,
438                "codeAction": {
439                    "disableRuleComment": {
440                        "enable": true,
441                        "location": "separateLine",
442                    },
443                    "showDocumentation": {
444                        "enable": true
445                    }
446                },
447                "experimental": {
448                    "useFlatConfig": use_flat_config,
449                },
450            }
451        }))
452    }
453
454    fn name(&self) -> LanguageServerName {
455        Self::SERVER_NAME.clone()
456    }
457
458    async fn fetch_latest_server_version(
459        &self,
460        _delegate: &dyn LspAdapterDelegate,
461    ) -> Result<Box<dyn 'static + Send + Any>> {
462        let url = build_asset_url(
463            "zed-industries/vscode-eslint",
464            Self::CURRENT_VERSION_TAG_NAME,
465            Self::GITHUB_ASSET_KIND,
466        )?;
467
468        Ok(Box::new(GitHubLspBinaryVersion {
469            name: Self::CURRENT_VERSION.into(),
470            url,
471        }))
472    }
473
474    async fn fetch_server_binary(
475        &self,
476        version: Box<dyn 'static + Send + Any>,
477        container_dir: PathBuf,
478        delegate: &dyn LspAdapterDelegate,
479    ) -> Result<LanguageServerBinary> {
480        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
481        let destination_path = Self::build_destination_path(&container_dir);
482        let server_path = destination_path.join(Self::SERVER_PATH);
483
484        if fs::metadata(&server_path).await.is_err() {
485            remove_matching(&container_dir, |entry| entry != destination_path).await;
486
487            let mut response = delegate
488                .http_client()
489                .get(&version.url, Default::default(), true)
490                .await
491                .context("downloading release")?;
492            match Self::GITHUB_ASSET_KIND {
493                AssetKind::TarGz => {
494                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
495                    let archive = Archive::new(decompressed_bytes);
496                    archive.unpack(&destination_path).await.with_context(|| {
497                        format!("extracting {} to {:?}", version.url, destination_path)
498                    })?;
499                }
500                AssetKind::Gz => {
501                    let mut decompressed_bytes =
502                        GzipDecoder::new(BufReader::new(response.body_mut()));
503                    let mut file =
504                        fs::File::create(&destination_path).await.with_context(|| {
505                            format!(
506                                "creating a file {:?} for a download from {}",
507                                destination_path, version.url,
508                            )
509                        })?;
510                    futures::io::copy(&mut decompressed_bytes, &mut file)
511                        .await
512                        .with_context(|| {
513                            format!("extracting {} to {:?}", version.url, destination_path)
514                        })?;
515                }
516                AssetKind::Zip => {
517                    node_runtime::extract_zip(
518                        &destination_path,
519                        BufReader::new(response.body_mut()),
520                    )
521                    .await
522                    .with_context(|| {
523                        format!("unzipping {} to {:?}", version.url, destination_path)
524                    })?;
525                }
526            }
527
528            let mut dir = fs::read_dir(&destination_path).await?;
529            let first = dir.next().await.context("missing first file")??;
530            let repo_root = destination_path.join("vscode-eslint");
531            fs::rename(first.path(), &repo_root).await?;
532
533            #[cfg(target_os = "windows")]
534            {
535                handle_symlink(
536                    repo_root.join("$shared"),
537                    repo_root.join("client").join("src").join("shared"),
538                )
539                .await?;
540                handle_symlink(
541                    repo_root.join("$shared"),
542                    repo_root.join("server").join("src").join("shared"),
543                )
544                .await?;
545            }
546
547            self.node
548                .run_npm_subcommand(&repo_root, "install", &[])
549                .await?;
550
551            self.node
552                .run_npm_subcommand(&repo_root, "run-script", &["compile"])
553                .await?;
554        }
555
556        Ok(LanguageServerBinary {
557            path: self.node.binary_path().await?,
558            env: None,
559            arguments: eslint_server_binary_arguments(&server_path),
560        })
561    }
562
563    async fn cached_server_binary(
564        &self,
565        container_dir: PathBuf,
566        _: &dyn LspAdapterDelegate,
567    ) -> Option<LanguageServerBinary> {
568        let server_path =
569            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
570        Some(LanguageServerBinary {
571            path: self.node.binary_path().await.ok()?,
572            env: None,
573            arguments: eslint_server_binary_arguments(&server_path),
574        })
575    }
576}
577
578#[cfg(target_os = "windows")]
579async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
580    anyhow::ensure!(
581        fs::metadata(&src_dir).await.is_ok(),
582        "Directory {src_dir:?} is not present"
583    );
584    if fs::metadata(&dest_dir).await.is_ok() {
585        fs::remove_file(&dest_dir).await?;
586    }
587    fs::create_dir_all(&dest_dir).await?;
588    let mut entries = fs::read_dir(&src_dir).await?;
589    while let Some(entry) = entries.try_next().await? {
590        let entry_path = entry.path();
591        let entry_name = entry.file_name();
592        let dest_path = dest_dir.join(&entry_name);
593        fs::copy(&entry_path, &dest_path).await?;
594    }
595    Ok(())
596}
597
598#[cfg(test)]
599mod tests {
600    use gpui::{AppContext as _, TestAppContext};
601    use unindent::Unindent;
602
603    #[gpui::test]
604    async fn test_outline(cx: &mut TestAppContext) {
605        let language = crate::language(
606            "typescript",
607            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
608        );
609
610        let text = r#"
611            function a() {
612              // local variables are omitted
613              let a1 = 1;
614              // all functions are included
615              async function a2() {}
616            }
617            // top-level variables are included
618            let b: C
619            function getB() {}
620            // exported variables are included
621            export const d = e;
622        "#
623        .unindent();
624
625        let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
626        let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
627        assert_eq!(
628            outline
629                .items
630                .iter()
631                .map(|item| (item.text.as_str(), item.depth))
632                .collect::<Vec<_>>(),
633            &[
634                ("function a()", 0),
635                ("async function a2()", 1),
636                ("let b", 0),
637                ("function getB()", 0),
638                ("const d", 0),
639            ]
640        );
641    }
642}