html: Add support for autoclosing of tags (#11761)

Piotr Osiewicz created

Fixes #5267 
TODO:
- [x] Publish our fork of vscode-langservers-extracted on GH and wire
that through as a language server of choice for HTML extension.
- [x] Figure out how to prevent edits made by remote participants from
moving the cursor of a host.

Release Notes:

- Added support for autoclosing of HTML tags in local projects.

Change summary

Cargo.lock                                  |  9 +
crates/editor/src/editor.rs                 | 18 +++
crates/languages/src/javascript/config.toml |  4 
crates/languages/src/tsx/config.toml        |  4 
crates/lsp/Cargo.toml                       |  2 
crates/lsp/src/lsp.rs                       |  2 
crates/project/Cargo.toml                   |  1 
crates/project/src/project.rs               | 75 ++++++++++++++++-
crates/snippet/src/snippet.rs               |  2 
extensions/html/Cargo.toml                  |  4 
extensions/html/extension.toml              | 11 ++
extensions/html/languages/html/config.toml  |  2 
extensions/html/src/html.rs                 | 96 +++++++++++++---------
13 files changed, 164 insertions(+), 66 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6060,8 +6060,8 @@ dependencies = [
 
 [[package]]
 name = "lsp-types"
-version = "0.94.1"
-source = "git+https://github.com/zed-industries/lsp-types?branch=updated-completion-list-item-defaults#90a040a1d195687bd19e1df47463320a44e93d7a"
+version = "0.95.1"
+source = "git+https://github.com/zed-industries/lsp-types?branch=apply-snippet-edit#853c7881d200777e20799026651ca36727144646"
 dependencies = [
  "bitflags 1.3.2",
  "serde",
@@ -7683,6 +7683,7 @@ dependencies = [
  "sha2 0.10.7",
  "similar",
  "smol",
+ "snippet",
  "task",
  "terminal",
  "text",
@@ -13200,9 +13201,9 @@ dependencies = [
 
 [[package]]
 name = "zed_html"
-version = "0.0.1"
+version = "0.0.2"
 dependencies = [
- "zed_extension_api 0.0.4",
+ "zed_extension_api 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [[package]]

crates/editor/src/editor.rs 🔗

@@ -1587,7 +1587,21 @@ impl Editor {
                 project_subscriptions.push(cx.subscribe(project, |editor, _, event, cx| {
                     if let project::Event::RefreshInlayHints = event {
                         editor.refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
-                    };
+                    } else if let project::Event::SnippetEdit(id, snippet_edits) = event {
+                        if let Some(buffer) = editor.buffer.read(cx).buffer(*id) {
+                            let focus_handle = editor.focus_handle(cx);
+                            if focus_handle.is_focused(cx) {
+                                let snapshot = buffer.read(cx).snapshot();
+                                for (range, snippet) in snippet_edits {
+                                    let editor_range =
+                                        language::range_from_lsp(*range).to_offset(&snapshot);
+                                    editor
+                                        .insert_snippet(&[editor_range], snippet.clone(), cx)
+                                        .ok();
+                                }
+                            }
+                        }
+                    }
                 }));
                 let task_inventory = project.read(cx).task_inventory().clone();
                 project_subscriptions.push(cx.observe(&task_inventory, |editor, _, cx| {
@@ -1601,7 +1615,6 @@ impl Editor {
             &buffer.read(cx).snapshot(cx),
             cx,
         );
-
         let focus_handle = cx.focus_handle();
         cx.on_focus(&focus_handle, Self::handle_focus).detach();
         cx.on_blur(&focus_handle, Self::handle_blur).detach();
@@ -10728,7 +10741,6 @@ impl Editor {
 
     fn handle_focus(&mut self, cx: &mut ViewContext<Self>) {
         cx.emit(EditorEvent::Focused);
-
         if let Some(rename) = self.pending_rename.as_ref() {
             let rename_editor_focus_handle = rename.editor.read(cx).focus_handle.clone();
             cx.focus(&rename_editor_focus_handle);

crates/languages/src/javascript/config.toml 🔗

@@ -16,12 +16,12 @@ brackets = [
 ]
 word_characters = ["$", "#"]
 tab_size = 2
-scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"]
+scope_opt_in_language_servers = ["tailwindcss-language-server","vscode-html-language-server", "emmet-language-server"]
 
 [overrides.element]
 line_comments = { remove = true }
 block_comment = ["{/* ", " */}"]
-opt_into_language_servers = ["emmet-language-server"]
+opt_into_language_servers = ["emmet-language-server", "vscode-html-language-server"]
 
 [overrides.string]
 word_characters = ["-"]

crates/languages/src/tsx/config.toml 🔗

@@ -14,13 +14,13 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 word_characters = ["#", "$"]
-scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"]
+scope_opt_in_language_servers = ["vscode-html-language-server", "tailwindcss-language-server", "emmet-language-server"]
 tab_size = 2
 
 [overrides.element]
 line_comments = { remove = true }
 block_comment = ["{/* ", " */}"]
-opt_into_language_servers = ["emmet-language-server"]
+opt_into_language_servers = ["vscode-html-language-server", "emmet-language-server"]
 
 [overrides.string]
 word_characters = ["-"]

crates/lsp/Cargo.toml 🔗

@@ -22,7 +22,7 @@ collections.workspace = true
 futures.workspace = true
 gpui.workspace = true
 log.workspace = true
-lsp-types = { git = "https://github.com/zed-industries/lsp-types", branch = "updated-completion-list-item-defaults" }
+lsp-types = { git = "https://github.com/zed-industries/lsp-types", branch = "apply-snippet-edit" }
 parking_lot.workspace = true
 postage.workspace = true
 serde.workspace = true

crates/lsp/src/lsp.rs 🔗

@@ -601,6 +601,7 @@ impl LanguageServer {
                             ResourceOperationKind::Delete,
                         ]),
                         document_changes: Some(true),
+                        snippet_edit_support: Some(true),
                         ..WorkspaceEditClientCapabilities::default()
                     }),
                     ..Default::default()
@@ -712,6 +713,7 @@ impl LanguageServer {
                 }
             }),
             locale: None,
+            ..Default::default()
         };
 
         cx.spawn(|_| async move {

crates/project/Cargo.toml 🔗

@@ -58,6 +58,7 @@ settings.workspace = true
 sha2.workspace = true
 similar = "1.3"
 smol.workspace = true
+snippet.workspace = true
 terminal.workspace = true
 text.workspace = true
 util.workspace = true

crates/project/src/project.rs 🔗

@@ -55,9 +55,9 @@ use language::{
 use log::error;
 use lsp::{
     DiagnosticSeverity, DiagnosticTag, DidChangeWatchedFilesRegistrationOptions,
-    DocumentHighlightKind, LanguageServer, LanguageServerBinary, LanguageServerId,
+    DocumentHighlightKind, Edit, LanguageServer, LanguageServerBinary, LanguageServerId,
     LspRequestFuture, MessageActionItem, OneOf, ServerCapabilities, ServerHealthStatus,
-    ServerStatus,
+    ServerStatus, TextEdit,
 };
 use lsp_command::*;
 use node_runtime::NodeRuntime;
@@ -67,6 +67,7 @@ use prettier_support::{DefaultPrettier, PrettierInstance};
 use project_settings::{LspSettings, ProjectSettings};
 use rand::prelude::*;
 use search_history::SearchHistory;
+use snippet::Snippet;
 use worktree::LocalSnapshot;
 
 use http::{HttpClient, Url};
@@ -332,6 +333,7 @@ pub enum Event {
     CollaboratorLeft(proto::PeerId),
     RefreshInlayHints,
     RevealInProjectPanel(ProjectEntryId),
+    SnippetEdit(BufferId, Vec<(lsp::Range, Snippet)>),
 }
 
 pub enum LanguageServerState {
@@ -2694,7 +2696,6 @@ impl Project {
                     };
 
                     let next_version = previous_snapshot.version + 1;
-
                     buffer_snapshots.push(LspBufferSnapshot {
                         version: next_version,
                         snapshot: next_snapshot.clone(),
@@ -6209,7 +6210,7 @@ impl Project {
                         uri,
                         version: None,
                     },
-                    edits: edits.into_iter().map(OneOf::Left).collect(),
+                    edits: edits.into_iter().map(Edit::Plain).collect(),
                 })
             }));
         }
@@ -6287,7 +6288,7 @@ impl Project {
                     let buffer_to_edit = this
                         .update(cx, |this, cx| {
                             this.open_local_buffer_via_lsp(
-                                op.text_document.uri,
+                                op.text_document.uri.clone(),
                                 language_server.server_id(),
                                 lsp_adapter.name.clone(),
                                 cx,
@@ -6297,10 +6298,68 @@ impl Project {
 
                     let edits = this
                         .update(cx, |this, cx| {
-                            let edits = op.edits.into_iter().map(|edit| match edit {
-                                OneOf::Left(edit) => edit,
-                                OneOf::Right(edit) => edit.text_edit,
+                            let path = buffer_to_edit.read(cx).project_path(cx);
+                            let active_entry = this.active_entry;
+                            let is_active_entry = path.clone().map_or(false, |project_path| {
+                                this.entry_for_path(&project_path, cx)
+                                    .map_or(false, |entry| Some(entry.id) == active_entry)
                             });
+
+                            let (mut edits, mut snippet_edits) = (vec![], vec![]);
+                            for edit in op.edits {
+                                match edit {
+                                    Edit::Plain(edit) => edits.push(edit),
+                                    Edit::Annotated(edit) => edits.push(edit.text_edit),
+                                    Edit::Snippet(edit) => {
+                                        let Ok(snippet) = Snippet::parse(&edit.snippet.value)
+                                        else {
+                                            continue;
+                                        };
+
+                                        if is_active_entry {
+                                            snippet_edits.push((edit.range, snippet));
+                                        } else {
+                                            // Since this buffer is not focused, apply a normal edit.
+                                            edits.push(TextEdit {
+                                                range: edit.range,
+                                                new_text: snippet.text,
+                                            });
+                                        }
+                                    }
+                                }
+                            }
+                            if !snippet_edits.is_empty() {
+                                if let Some(buffer_version) = op.text_document.version {
+                                    let buffer_id = buffer_to_edit.read(cx).remote_id();
+                                    // Check if the edit that triggered that edit has been made by this participant.
+                                    let should_apply_edit = this
+                                        .buffer_snapshots
+                                        .get(&buffer_id)
+                                        .and_then(|server_to_snapshots| {
+                                            let all_snapshots = server_to_snapshots
+                                                .get(&language_server.server_id())?;
+                                            all_snapshots
+                                                .binary_search_by_key(&buffer_version, |snapshot| {
+                                                    snapshot.version
+                                                })
+                                                .ok()
+                                                .and_then(|index| all_snapshots.get(index))
+                                        })
+                                        .map_or(false, |lsp_snapshot| {
+                                            let version = lsp_snapshot.snapshot.version();
+                                            let most_recent_edit = version
+                                                .iter()
+                                                .max_by_key(|timestamp| timestamp.value);
+                                            most_recent_edit.map_or(false, |edit| {
+                                                edit.replica_id == this.replica_id()
+                                            })
+                                        });
+                                    if should_apply_edit {
+                                        cx.emit(Event::SnippetEdit(buffer_id, snippet_edits));
+                                    }
+                                }
+                            }
+
                             this.edits_from_lsp(
                                 &buffer_to_edit,
                                 edits,

crates/snippet/src/snippet.rs 🔗

@@ -2,7 +2,7 @@ use anyhow::{anyhow, Context, Result};
 use smallvec::SmallVec;
 use std::{collections::BTreeMap, ops::Range};
 
-#[derive(Default)]
+#[derive(Clone, Debug, Default, PartialEq)]
 pub struct Snippet {
     pub text: String,
     pub tabstops: Vec<TabStop>,

extensions/html/Cargo.toml 🔗

@@ -1,6 +1,6 @@
 [package]
 name = "zed_html"
-version = "0.0.1"
+version = "0.0.2"
 edition = "2021"
 publish = false
 license = "Apache-2.0"
@@ -13,4 +13,4 @@ path = "src/html.rs"
 crate-type = ["cdylib"]
 
 [dependencies]
-zed_extension_api = "0.0.4"
+zed_extension_api = "0.0.6"

extensions/html/extension.toml 🔗

@@ -1,7 +1,7 @@
 id = "html"
 name = "HTML"
 description = "HTML support."
-version = "0.0.1"
+version = "0.0.2"
 schema_version = 1
 authors = ["Isaac Clayton <slightknack@gmail.com>"]
 repository = "https://github.com/zed-industries/zed"
@@ -9,6 +9,15 @@ repository = "https://github.com/zed-industries/zed"
 [language_servers.vscode-html-language-server]
 name = "vscode-html-language-server"
 language = "HTML"
+languages = ["TypeScript", "HTML", "TSX", "JavaScript", "JSDoc"]
+
+[language_servers.vscode-html-language-server.language_ids]
+"HTML" = "html"
+"PHP" = "php"
+"ERB" = "eruby"
+"JavaScript" = "javascriptreact"
+"TSX" = "typescriptreact"
+"CSS" = "css"
 
 [grammars.html]
 repository = "https://github.com/tree-sitter/tree-sitter-html"

extensions/html/languages/html/config.toml 🔗

@@ -8,7 +8,7 @@ brackets = [
     { start = "[", end = "]", close = true, newline = true },
     { start = "(", end = ")", close = true, newline = true },
     { start = "\"", end = "\"", close = true, newline = false, not_in = ["comment", "string"] },
-    { start = "<", end = ">", close = true, newline = true, not_in = ["comment", "string"] },
+    { start = "<", end = ">", close = false, newline = true, not_in = ["comment", "string"] },
     { start = "!--", end = " --", close = true, newline = false, not_in = ["comment", "string"] },
 ]
 word_characters = ["-"]

extensions/html/src/html.rs 🔗

@@ -1,79 +1,93 @@
-use std::{env, fs};
+use std::{env, fs, path::PathBuf};
 use zed_extension_api::{self as zed, Result};
 
-const SERVER_PATH: &str =
-    "node_modules/vscode-langservers-extracted/bin/vscode-html-language-server";
-const PACKAGE_NAME: &str = "vscode-langservers-extracted";
+const PACKAGE_NAME: &str = "vscode-language-server";
 
 struct HtmlExtension {
-    did_find_server: bool,
+    path: Option<PathBuf>,
 }
 
 impl HtmlExtension {
-    fn server_exists(&self) -> bool {
-        fs::metadata(SERVER_PATH).map_or(false, |stat| stat.is_file())
-    }
-
-    fn server_script_path(&mut self, config: zed::LanguageServerConfig) -> Result<String> {
-        let server_exists = self.server_exists();
-        if self.did_find_server && server_exists {
-            return Ok(SERVER_PATH.to_string());
+    fn server_script_path(&self, language_server_id: &zed::LanguageServerId) -> Result<PathBuf> {
+        if let Some(path) = self.path.as_ref() {
+            if fs::metadata(path).map_or(false, |stat| stat.is_dir()) {
+                return Ok(path.clone());
+            }
         }
 
         zed::set_language_server_installation_status(
-            &config.name,
+            language_server_id,
             &zed::LanguageServerInstallationStatus::CheckingForUpdate,
         );
-        let version = zed::npm_package_latest_version(PACKAGE_NAME)?;
+        let release = zed::latest_github_release(
+            "zed-industries/vscode-langservers-extracted",
+            zed::GithubReleaseOptions {
+                require_assets: true,
+                pre_release: false,
+            },
+        )?;
 
-        if !server_exists
-            || zed::npm_package_installed_version(PACKAGE_NAME)?.as_ref() != Some(&version)
-        {
+        let asset_name = "vscode-language-server.tar.gz";
+
+        let asset = release
+            .assets
+            .iter()
+            .find(|asset| asset.name == asset_name)
+            .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?;
+        let version_dir = format!("{}-{}", PACKAGE_NAME, release.version);
+        if !fs::metadata(&version_dir).map_or(false, |stat| stat.is_dir()) {
             zed::set_language_server_installation_status(
-                &config.name,
+                &language_server_id,
                 &zed::LanguageServerInstallationStatus::Downloading,
             );
-            let result = zed::npm_install_package(PACKAGE_NAME, &version);
-            match result {
-                Ok(()) => {
-                    if !self.server_exists() {
-                        Err(format!(
-                            "installed package '{PACKAGE_NAME}' did not contain expected path '{SERVER_PATH}'",
-                        ))?;
-                    }
-                }
-                Err(error) => {
-                    if !self.server_exists() {
-                        Err(error)?;
-                    }
+
+            zed::download_file(
+                &asset.download_url,
+                &version_dir,
+                zed::DownloadedFileType::GzipTar,
+            )
+            .map_err(|e| format!("failed to download file: {e}"))?;
+
+            let entries =
+                fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;
+            for entry in entries {
+                let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?;
+                if entry.file_name().to_str() != Some(&version_dir) {
+                    fs::remove_dir_all(&entry.path()).ok();
                 }
             }
         }
-
-        self.did_find_server = true;
-        Ok(SERVER_PATH.to_string())
+        Ok(PathBuf::from(version_dir)
+            .join("bin")
+            .join("vscode-html-language-server"))
     }
 }
 
 impl zed::Extension for HtmlExtension {
     fn new() -> Self {
-        Self {
-            did_find_server: false,
-        }
+        Self { path: None }
     }
 
     fn language_server_command(
         &mut self,
-        config: zed::LanguageServerConfig,
+        language_server_id: &zed::LanguageServerId,
         _worktree: &zed::Worktree,
     ) -> Result<zed::Command> {
-        let server_path = self.server_script_path(config)?;
+        let path = match &self.path {
+            Some(path) => path,
+            None => {
+                let path = self.server_script_path(language_server_id)?;
+                self.path = Some(path);
+                self.path.as_ref().unwrap()
+            }
+        };
+
         Ok(zed::Command {
             command: zed::node_binary_path()?,
             args: vec![
                 env::current_dir()
                     .unwrap()
-                    .join(&server_path)
+                    .join(path)
                     .to_string_lossy()
                     .to_string(),
                 "--stdio".to_string(),