typescript.rs

  1use anyhow::{Context as _, Result, anyhow};
  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            Err(anyhow!(
319                "missing executable in directory {:?}",
320                container_dir
321            ))
322        }
323    })
324    .await
325    .log_err()
326}
327
328pub struct EsLintLspAdapter {
329    node: NodeRuntime,
330}
331
332impl EsLintLspAdapter {
333    const CURRENT_VERSION: &'static str = "2.4.4";
334    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
335
336    #[cfg(not(windows))]
337    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
338    #[cfg(windows)]
339    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
340
341    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
342    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
343
344    const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
345        "eslint.config.js",
346        "eslint.config.mjs",
347        "eslint.config.cjs",
348        "eslint.config.ts",
349        "eslint.config.cts",
350        "eslint.config.mts",
351    ];
352
353    pub fn new(node: NodeRuntime) -> Self {
354        EsLintLspAdapter { node }
355    }
356
357    fn build_destination_path(container_dir: &Path) -> PathBuf {
358        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
359    }
360}
361
362#[async_trait(?Send)]
363impl LspAdapter for EsLintLspAdapter {
364    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
365        Some(vec![
366            CodeActionKind::QUICKFIX,
367            CodeActionKind::new("source.fixAll.eslint"),
368        ])
369    }
370
371    async fn workspace_configuration(
372        self: Arc<Self>,
373        _: &dyn Fs,
374        delegate: &Arc<dyn LspAdapterDelegate>,
375        _: Arc<dyn LanguageToolchainStore>,
376        cx: &mut AsyncApp,
377    ) -> Result<Value> {
378        let workspace_root = delegate.worktree_root_path();
379
380        let eslint_user_settings = cx.update(|cx| {
381            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
382                .and_then(|s| s.settings.clone())
383                .unwrap_or_default()
384        })?;
385
386        let mut code_action_on_save = json!({
387            // We enable this, but without also configuring `code_actions_on_format`
388            // in the Zed configuration, it doesn't have an effect.
389            "enable": true,
390        });
391
392        if let Some(code_action_settings) = eslint_user_settings
393            .get("codeActionOnSave")
394            .and_then(|settings| settings.as_object())
395        {
396            if let Some(enable) = code_action_settings.get("enable") {
397                code_action_on_save["enable"] = enable.clone();
398            }
399            if let Some(mode) = code_action_settings.get("mode") {
400                code_action_on_save["mode"] = mode.clone();
401            }
402            if let Some(rules) = code_action_settings.get("rules") {
403                code_action_on_save["rules"] = rules.clone();
404            }
405        }
406
407        let working_directory = eslint_user_settings
408            .get("workingDirectory")
409            .cloned()
410            .unwrap_or_else(|| json!({"mode": "auto"}));
411
412        let problems = eslint_user_settings
413            .get("problems")
414            .cloned()
415            .unwrap_or_else(|| json!({}));
416
417        let rules_customizations = eslint_user_settings
418            .get("rulesCustomizations")
419            .cloned()
420            .unwrap_or_else(|| json!([]));
421
422        let node_path = eslint_user_settings.get("nodePath").unwrap_or(&Value::Null);
423        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
424            .iter()
425            .any(|file| workspace_root.join(file).is_file());
426
427        Ok(json!({
428            "": {
429                "validate": "on",
430                "rulesCustomizations": rules_customizations,
431                "run": "onType",
432                "nodePath": node_path,
433                "workingDirectory": working_directory,
434                "workspaceFolder": {
435                    "uri": workspace_root,
436                    "name": workspace_root.file_name()
437                        .unwrap_or(workspace_root.as_os_str()),
438                },
439                "problems": problems,
440                "codeActionOnSave": code_action_on_save,
441                "codeAction": {
442                    "disableRuleComment": {
443                        "enable": true,
444                        "location": "separateLine",
445                    },
446                    "showDocumentation": {
447                        "enable": true
448                    }
449                },
450                "experimental": {
451                    "useFlatConfig": use_flat_config,
452                },
453            }
454        }))
455    }
456
457    fn name(&self) -> LanguageServerName {
458        Self::SERVER_NAME.clone()
459    }
460
461    async fn fetch_latest_server_version(
462        &self,
463        _delegate: &dyn LspAdapterDelegate,
464    ) -> Result<Box<dyn 'static + Send + Any>> {
465        let url = build_asset_url(
466            "zed-industries/vscode-eslint",
467            Self::CURRENT_VERSION_TAG_NAME,
468            Self::GITHUB_ASSET_KIND,
469        )?;
470
471        Ok(Box::new(GitHubLspBinaryVersion {
472            name: Self::CURRENT_VERSION.into(),
473            url,
474        }))
475    }
476
477    async fn fetch_server_binary(
478        &self,
479        version: Box<dyn 'static + Send + Any>,
480        container_dir: PathBuf,
481        delegate: &dyn LspAdapterDelegate,
482    ) -> Result<LanguageServerBinary> {
483        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
484        let destination_path = Self::build_destination_path(&container_dir);
485        let server_path = destination_path.join(Self::SERVER_PATH);
486
487        if fs::metadata(&server_path).await.is_err() {
488            remove_matching(&container_dir, |entry| entry != destination_path).await;
489
490            let mut response = delegate
491                .http_client()
492                .get(&version.url, Default::default(), true)
493                .await
494                .map_err(|err| anyhow!("error downloading release: {}", err))?;
495            match Self::GITHUB_ASSET_KIND {
496                AssetKind::TarGz => {
497                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
498                    let archive = Archive::new(decompressed_bytes);
499                    archive.unpack(&destination_path).await.with_context(|| {
500                        format!("extracting {} to {:?}", version.url, destination_path)
501                    })?;
502                }
503                AssetKind::Gz => {
504                    let mut decompressed_bytes =
505                        GzipDecoder::new(BufReader::new(response.body_mut()));
506                    let mut file =
507                        fs::File::create(&destination_path).await.with_context(|| {
508                            format!(
509                                "creating a file {:?} for a download from {}",
510                                destination_path, version.url,
511                            )
512                        })?;
513                    futures::io::copy(&mut decompressed_bytes, &mut file)
514                        .await
515                        .with_context(|| {
516                            format!("extracting {} to {:?}", version.url, destination_path)
517                        })?;
518                }
519                AssetKind::Zip => {
520                    node_runtime::extract_zip(
521                        &destination_path,
522                        BufReader::new(response.body_mut()),
523                    )
524                    .await
525                    .with_context(|| {
526                        format!("unzipping {} to {:?}", version.url, destination_path)
527                    })?;
528                }
529            }
530
531            let mut dir = fs::read_dir(&destination_path).await?;
532            let first = dir.next().await.ok_or(anyhow!("missing first file"))??;
533            let repo_root = destination_path.join("vscode-eslint");
534            fs::rename(first.path(), &repo_root).await?;
535
536            #[cfg(target_os = "windows")]
537            {
538                handle_symlink(
539                    repo_root.join("$shared"),
540                    repo_root.join("client").join("src").join("shared"),
541                )
542                .await?;
543                handle_symlink(
544                    repo_root.join("$shared"),
545                    repo_root.join("server").join("src").join("shared"),
546                )
547                .await?;
548            }
549
550            self.node
551                .run_npm_subcommand(&repo_root, "install", &[])
552                .await?;
553
554            self.node
555                .run_npm_subcommand(&repo_root, "run-script", &["compile"])
556                .await?;
557        }
558
559        Ok(LanguageServerBinary {
560            path: self.node.binary_path().await?,
561            env: None,
562            arguments: eslint_server_binary_arguments(&server_path),
563        })
564    }
565
566    async fn cached_server_binary(
567        &self,
568        container_dir: PathBuf,
569        _: &dyn LspAdapterDelegate,
570    ) -> Option<LanguageServerBinary> {
571        let server_path =
572            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
573        Some(LanguageServerBinary {
574            path: self.node.binary_path().await.ok()?,
575            env: None,
576            arguments: eslint_server_binary_arguments(&server_path),
577        })
578    }
579}
580
581#[cfg(target_os = "windows")]
582async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
583    if fs::metadata(&src_dir).await.is_err() {
584        return Err(anyhow!("Directory {} not present.", src_dir.display()));
585    }
586    if fs::metadata(&dest_dir).await.is_ok() {
587        fs::remove_file(&dest_dir).await?;
588    }
589    fs::create_dir_all(&dest_dir).await?;
590    let mut entries = fs::read_dir(&src_dir).await?;
591    while let Some(entry) = entries.try_next().await? {
592        let entry_path = entry.path();
593        let entry_name = entry.file_name();
594        let dest_path = dest_dir.join(&entry_name);
595        fs::copy(&entry_path, &dest_path).await?;
596    }
597    Ok(())
598}
599
600#[cfg(test)]
601mod tests {
602    use gpui::{AppContext as _, TestAppContext};
603    use unindent::Unindent;
604
605    #[gpui::test]
606    async fn test_outline(cx: &mut TestAppContext) {
607        let language = crate::language(
608            "typescript",
609            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
610        );
611
612        let text = r#"
613            function a() {
614              // local variables are omitted
615              let a1 = 1;
616              // all functions are included
617              async function a2() {}
618            }
619            // top-level variables are included
620            let b: C
621            function getB() {}
622            // exported variables are included
623            export const d = e;
624        "#
625        .unindent();
626
627        let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
628        let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
629        assert_eq!(
630            outline
631                .items
632                .iter()
633                .map(|item| (item.text.as_str(), item.depth))
634                .collect::<Vec<_>>(),
635            &[
636                ("function a()", 0),
637                ("async function a2()", 1),
638                ("let b", 0),
639                ("function getB()", 0),
640                ("const d", 0),
641            ]
642        );
643    }
644}