From 6152bced8db39072fcdc6f3640aab1c7016bfdfe Mon Sep 17 00:00:00 2001 From: Ian Goforth Date: Mon, 15 Dec 2025 15:53:04 -0500 Subject: [PATCH] fix: upgrade to zed_extension_api 0.7.0 and improve Linux compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes ### API Compatibility (zed_extension_api 0.1.0 โ†’ 0.7.0) - Upgraded zed_extension_api dependency from 0.1.0 to 0.7.0 - Fixed LanguageServerId construction (private constructor in new API) - Updated Worktree API: path() โ†’ root_path() - Removed references to GithubReleaseAsset.size (field removed in API) - Fixed string lifetime issues with String::from_utf8_lossy().trim() ### Extension Manifest - Added required [lib] section to extension.toml - Specified Rust kind and version 0.7.0 - Added all required metadata fields ### Language Configuration - Fixed grammar reference: "markdown" โ†’ "mermaid" in config.toml - Maintained code_fence_block_name for markdown code block support - Added highlights.scm for syntax highlighting ### Dependency Management - Integrated Zed's npm API for package installation - Use npm_package_latest_version() and npm_install_package() - Dynamic MMDC_PATH derivation from LSP location (no hardcoded paths) - Pass MMDC_PATH as environment variable to LSP server ### Build & Testing - Verified compilation for wasm32-wasip1 target - Tested local installation in Zed remote server environment - Confirmed extension loading and LSP initialization ## Known Issues ### Linux: Puppeteer Chrome Sandbox Error On Linux systems, mmdc fails with Chrome sandbox error: ``` FATAL:zygote_host_impl_linux.cc(128)] No usable sandbox! ``` This is a known issue with headless Chrome on Linux. The extension works on macOS (original author's platform) but requires additional setup on Linux: **Workaround options:** 1. Use `--no-sandbox` flag (security tradeoff for dev environments) 2. Set up Chrome sandbox properly per Puppeteer documentation 3. Run `sudo` commands to configure chrome_sandbox permissions Since this is a Zed extension that should "just work" for users, we're currently using puppeteer-config.json with --no-sandbox for Linux compatibility. The security risk is minimal since users render their own trusted Mermaid diagrams from their own files. ## Testing Environment - Platform: Linux (Ubuntu/Debian-based) - Zed: Remote server setup - Target: wasm32-wasip1 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Cargo.toml | 2 +- extension.toml | 12 +++ languages/mermaid/config.toml | 4 +- languages/mermaid/highlights.scm | 177 +++++++++++++++++++++++++++++++ src/lib.rs | 176 ++++++++++++++++++++++-------- 5 files changed, 323 insertions(+), 48 deletions(-) create mode 100644 languages/mermaid/highlights.scm diff --git a/Cargo.toml b/Cargo.toml index a11b927..0dbc638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,5 +10,5 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.1.0" +zed_extension_api = "0.7.0" dirs = "5" diff --git a/extension.toml b/extension.toml index e92d4a2..fa038ad 100644 --- a/extension.toml +++ b/extension.toml @@ -5,6 +5,14 @@ version = "0.1.24" schema_version = 1 authors = ["Mermaid Preview Maintainers"] repository = "https://github.com/dawsh2/zed-mermaid-preview" +languages = ["languages/mermaid"] +themes = [] +icon_themes = [] +capabilities = [] + +[lib] +kind = "Rust" +version = "0.7.0" [compatibility] zed = "0.210.0" @@ -17,3 +25,7 @@ languages = ["Markdown", "Mermaid"] [language_servers.mermaid.language_ids] "Markdown" = "markdown" "Mermaid" = "mermaid" + +[grammars.mermaid] +repository = "https://github.com/monaqa/tree-sitter-mermaid" +rev = "90ae195b31933ceb9d079abfa8a3ad0a36fee4cc" diff --git a/languages/mermaid/config.toml b/languages/mermaid/config.toml index 87432ea..09b79bc 100644 --- a/languages/mermaid/config.toml +++ b/languages/mermaid/config.toml @@ -1,6 +1,6 @@ name = "Mermaid" -grammar = "markdown" -path_suffixes = ["mmd", "mermaid"] +grammar = "mermaid" +path_suffixes = ["mermaid", "mmd"] line_comments = ["%% "] tab_size = 2 code_fence_block_name = "mermaid" diff --git a/languages/mermaid/highlights.scm b/languages/mermaid/highlights.scm new file mode 100644 index 0000000..4bd7605 --- /dev/null +++ b/languages/mermaid/highlights.scm @@ -0,0 +1,177 @@ +[ + "sequenceDiagram" + "classDiagram" + "classDiagram-v2" + "stateDiagram" + "stateDiagram-v2" + "gantt" + "pie" + "flowchart" + "erdiagram" + + "participant" + "as" + "activate" + "deactivate" + "note " + "over" + "link" + "links" + ; "left of" + ; "right of" + "properties" + "details" + "title" + "loop" + "rect" + "opt" + "alt" + "else" + "par" + "and" + "end" + (sequence_stmt_autonumber) + (note_placement_left) + (note_placement_right) + + "class" + + "state " + + "dateformat" + "inclusiveenddates" + "topaxis" + "axisformat" + "includes" + "excludes" + "todaymarker" + "title" + "section" + + "direction" + "subgraph" + + ] @keyword + +[ + (comment) + ] @comment + +[ + ":" + (sequence_signal_plus_sign) + (sequence_signal_minus_sign) + + (class_visibility_public) + (class_visibility_private) + (class_visibility_protected) + (class_visibility_internal) + + (state_division) + ] @punctuation.delimiter + +[ + "(" + ")" + "{" + "}" + ] @punctuation.bracket + +[ + "-->" + (solid_arrow) + (dotted_arrow) + (solid_open_arrow) + (dotted_open_arrow) + (solid_cross) + (dotted_cross) + (solid_point) + (dotted_point) + ] @operator + +[ + (class_reltype_aggregation) + (class_reltype_extension) + (class_reltype_composition) + (class_reltype_dependency) + (class_linetype_solid) + (class_linetype_dotted) + "&" + ] @operator + +(sequence_actor) @field +(class_name) @field + +(state_name) @field + +(gantt_task_text) @field + +[ + (class_annotation_line) + (class_stmt_annotation) + (class_generics) + + (state_annotation_fork) + (state_annotation_join) + (state_annotation_choice) + ] @type + +(directive) @include + +(pie_label) @string +(pie_value) @float + +[ +(flowchart_direction_lr) +(flowchart_direction_rl) +(flowchart_direction_tb) +(flowchart_direction_bt) + ] @constant + +(flow_vertex_id) @field + +[ + (flow_link_arrow) + (flow_link_arrow_start) + ] @operator + +(flow_link_arrowtext "|" @punctuation.bracket) + +(flow_vertex_square [ "[" "]" ] @punctuation.bracket ) +(flow_vertex_circle ["((" "))"] @punctuation.bracket ) +(flow_vertex_ellipse ["(-" "-)"] @punctuation.bracket ) +(flow_vertex_stadium ["([" "])"] @punctuation.bracket ) +(flow_vertex_subroutine ["[[" "]]"] @punctuation.bracket ) +(flow_vertex_rect ["[|" "|]"] @punctuation.bracket ) +(flow_vertex_cylinder ["[(" ")]"] @punctuation.bracket ) +(flow_vertex_round ["(" ")"] @punctuation.bracket ) +(flow_vertex_diamond ["{" "}"] @punctuation.bracket ) +(flow_vertex_hexagon ["{{" "}}"] @punctuation.bracket ) +(flow_vertex_odd [">" "]"] @punctuation.bracket ) +(flow_vertex_trapezoid ["[/" "\\]"] @punctuation.bracket ) +(flow_vertex_inv_trapezoid ["[\\" "/]"] @punctuation.bracket ) +(flow_vertex_leanright ["[/" "/]"] @punctuation.bracket ) +(flow_vertex_leanleft ["[\\" "\\]"] @punctuation.bracket ) + +(flow_stmt_subgraph ["[" "]"] @punctuation.bracket ) + +[ + (er_cardinarity_zero_or_one) + (er_cardinarity_zero_or_more) + (er_cardinarity_one_or_more) + (er_cardinarity_only_one) + (er_reltype_non_identifying) + (er_reltype_identifying) + ] @operator + +(er_entity_name) @field + +(er_attribute_type) @type +(er_attribute_name) @field + +[ + (er_attribute_key_type_pk) + (er_attribute_key_type_fk) + ] @keyword + +(er_attribute_comment) @string diff --git a/src/lib.rs b/src/lib.rs index 7756a33..37a27f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,11 +39,32 @@ impl zed::Extension for MermaidPreviewExtension { let lsp_path = self.get_lsp_path(worktree, language_server_id)?; + // Derive extension work directory from LSP path + // LSP path: .../work/mermaid-preview/mermaid-lsp-cache/v.../mermaid-lsp + // We want: .../work/mermaid-preview/node_modules/.bin/mmdc + let mut env = Vec::new(); + if let Some(lsp_cache_dir) = PathBuf::from(&lsp_path).parent() { + if let Some(version_dir) = lsp_cache_dir.parent() { + if let Some(extension_work_dir) = version_dir.parent() { + let mmdc_path = extension_work_dir.join("node_modules/.bin/mmdc"); + if mmdc_path.exists() { + env.push(( + "MMDC_PATH".to_string(), + mmdc_path.to_string_lossy().to_string(), + )); + eprintln!("โœ… Setting MMDC_PATH={}", mmdc_path.display()); + } else { + eprintln!("โš ๏ธ mmdc not found at {}", mmdc_path.display()); + } + } + } + } + eprintln!("Starting Mermaid LSP at: {}", lsp_path); Ok(zed::Command { command: lsp_path, args: vec![], - env: Default::default(), + env, }) } } @@ -59,18 +80,28 @@ impl MermaidPreviewExtension { eprintln!("Diagram rendering may fail until @mermaid-js/mermaid-cli is installed manually"); } - // Create a dummy language_server_id for initialization - let dummy_id = LanguageServerId::from("mermaid"); - - // Use current directory as extension directory + // Pre-download LSP binary let current_dir = env::current_dir() .map_err(|e| format!("Failed to get current directory: {}", e))?; - // Try to find or download the binary - match self.get_lsp_path_impl(&dummy_id, ¤t_dir) { + // Try to find or download the binary directly + let lsp_binary_name = Self::lsp_binary_name(); + + // Check for local binary first + if let Some(path) = Self::candidate_paths(¤t_dir, lsp_binary_name) + .into_iter() + .find(|candidate| candidate.is_file()) + { + eprintln!("โœ… Mermaid LSP binary initialized: {}", path.display()); + self.lsp_path = Some(path.to_string_lossy().to_string()); + return Ok(()); + } + + // Download from GitHub if not found locally + match self.download_lsp_preload(¤t_dir, lsp_binary_name) { Ok(path) => { - eprintln!("โœ… Mermaid LSP binary initialized: {}", path); - self.lsp_path = Some(path); + eprintln!("โœ… Mermaid LSP binary initialized: {}", path.display()); + self.lsp_path = Some(path.to_string_lossy().to_string()); Ok(()) } Err(e) => { @@ -87,7 +118,8 @@ impl MermaidPreviewExtension { // Check if mmdc is already available if let Ok(path) = Command::new("which").arg("mmdc").output() { if path.status.success() { - let path_str = String::from_utf8_lossy(&path.stdout).trim(); + let path_str = String::from_utf8_lossy(&path.stdout); + let path_str = path_str.trim(); eprintln!("โœ… Mermaid CLI found at: {}", path_str); return Ok(()); } @@ -120,39 +152,20 @@ impl MermaidPreviewExtension { } } - /// Install Mermaid CLI using npm + /// Install Mermaid CLI using Zed's npm fn install_mermaid_cli(&self) -> Result<()> { eprintln!("Installing @mermaid-js/mermaid-cli globally..."); - // Check if npm is available - if let Ok(output) = Command::new("which").arg("npm").output() { - if output.status.success() { - let npm_path = String::from_utf8_lossy(&output.stdout).trim(); - eprintln!("Found npm at: {}", npm_path); - } else { - return Err("npm not found. Please install Node.js and npm first.".to_string()); - } - } else { - return Err("npm not found. Please install Node.js and npm first.".to_string()); - } + // Get latest version + let version = zed::npm_package_latest_version("@mermaid-js/mermaid-cli") + .map_err(|e| format!("Failed to get latest version: {}", e))?; - // Run npm install globally - let output = Command::new("npm") - .args(["install", "-g", "@mermaid-js/mermaid-cli"]) - .output() - .map_err(|e| format!("Failed to run npm install: {}", e))?; + // Install package using Zed's npm + zed::npm_install_package("@mermaid-js/mermaid-cli", &version) + .map_err(|e| format!("Failed to install package: {}", e))?; - if output.status.success() { - eprintln!("npm install completed successfully"); - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - Err(format!( - "npm install failed. Status: {}. Stdout: {}. Stderr: {}", - output.status, stdout, stderr - )) - } + eprintln!("npm install completed successfully"); + Ok(()) } fn get_lsp_path( @@ -166,9 +179,9 @@ impl MermaidPreviewExtension { } // Otherwise, try to get it now (fallback for first file open) - let worktree_path = worktree.path() - .map_err(|e| format!("Failed to get worktree path: {}", e))?; - self.get_lsp_path_impl(language_server_id, &worktree_path) + let worktree_path = worktree.root_path(); + let path = std::path::Path::new(&worktree_path); + self.get_lsp_path_impl(language_server_id, path) } fn get_lsp_path_impl( @@ -197,7 +210,8 @@ impl MermaidPreviewExtension { // For development, check local PATH before GitHub releases if let Ok(output) = Command::new("which").arg("mermaid-lsp").output() { if output.status.success() { - let path_str = String::from_utf8_lossy(&output.stdout).trim(); + let path_str = String::from_utf8_lossy(&output.stdout); + let path_str = path_str.trim(); return Self::finalize_path( language_server_id, PathBuf::from(path_str), @@ -240,7 +254,7 @@ impl MermaidPreviewExtension { _ => {} } - + let search_locations = Self::candidate_paths(&extension_dir, lsp_binary_name) .into_iter() .map(|candidate| candidate.display().to_string()) @@ -333,7 +347,8 @@ impl MermaidPreviewExtension { { Ok(output) => { if output.status.success() { - let version = String::from_utf8_lossy(&output.stdout).trim(); + let version = String::from_utf8_lossy(&output.stdout); + let version = version.trim(); eprintln!("โœ… Using existing LSP version: {} ({})", release.version, version); zed::set_language_server_installation_status( language_server_id, @@ -358,7 +373,7 @@ impl MermaidPreviewExtension { fs::create_dir_all(&version_dir) .map_err(|err| format!("failed to create cache directory '{version_dir:?}': {err}"))?; - eprintln!("โฌ‡๏ธ Starting download of {} ({:.1}MB)...", asset.name, asset.size as f64 / 1024.0 / 1024.0); + eprintln!("โฌ‡๏ธ Starting download of {}...", asset.name); zed::set_language_server_installation_status( language_server_id, &zed::LanguageServerInstallationStatus::Downloading, @@ -409,6 +424,77 @@ impl MermaidPreviewExtension { Ok(binary_path) } + /// Download LSP during preload (without LanguageServerId) + fn download_lsp_preload( + &mut self, + extension_dir: &Path, + binary_name: &str, + ) -> Result { + eprintln!("๐Ÿ” Preloading Mermaid LSP..."); + + eprintln!("๐Ÿ“ก Fetching latest release information..."); + let release = zed::latest_github_release( + GITHUB_REPOSITORY, + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + eprintln!("๐Ÿ“ฆ Found latest release: v{}", release.version); + let asset = Self::match_asset(&release)?; + eprintln!("๐ŸŽฏ Matched platform asset: {}", asset.name); + + let version_dir = extension_dir.join(CACHE_ROOT).join(&release.version); + let binary_path = version_dir.join(binary_name); + + // Check if we already have the latest version + if binary_path.is_file() { + eprintln!("โœ… LSP v{} already cached", release.version); + return Ok(binary_path); + } + + eprintln!("๐Ÿ“ Creating cache directory: {}", version_dir.display()); + fs::create_dir_all(&version_dir) + .map_err(|err| format!("failed to create cache directory '{version_dir:?}': {err}"))?; + + eprintln!("โฌ‡๏ธ Starting download of {}...", asset.name); + + let start_time = std::time::Instant::now(); + zed::download_file( + &asset.download_url, + version_dir + .to_str() + .ok_or_else(|| "failed to stringify cache directory path".to_string())?, + DownloadedFileType::Zip, + ) + .map_err(|err| format!("failed to download mermaid-lsp asset: {err}"))?; + + let download_duration = start_time.elapsed(); + eprintln!("โœ… Download completed in {:.1}s", download_duration.as_secs_f64()); + + if !binary_path.is_file() { + return Err(format!( + "downloaded asset '{asset_name}' did not contain expected binary '{binary_name}'", + asset_name = asset.name + )); + } + + eprintln!("๐Ÿ”ง Making binary executable..."); + zed::make_file_executable( + binary_path + .to_str() + .ok_or_else(|| "failed to stringify downloaded binary path".to_string())?, + )?; + + eprintln!("๐Ÿงน Cleaning up old cache versions..."); + Self::purge_old_cache_versions(extension_dir, &release.version); + + eprintln!("๐ŸŽ‰ Mermaid LSP v{} preloaded successfully!", release.version); + + Ok(binary_path) + } + fn purge_old_cache_versions(extension_dir: &Path, keep_version: &str) { let cache_root = extension_dir.join(CACHE_ROOT); if let Ok(entries) = fs::read_dir(&cache_root) {