Skip to main content

fresh_core/
api.rs

1//! Plugin API: Safe interface for plugins to interact with the editor
2//!
3//! This module provides a safe, controlled API for plugins (Lua, WASM, etc.)
4//! to interact with the editor without direct access to internal state.
5//!
6//! # Type Safety Architecture
7//!
8//! Rust structs in this module serve as the **single source of truth** for the
9//! TypeScript plugin API. The type safety system works as follows:
10//!
11//! ```text
12//! Rust struct                  Generated TypeScript
13//! ───────────                  ────────────────────
14//! #[derive(TS, Deserialize)]   type ActionPopupOptions = {
15//! #[serde(deny_unknown_fields)]    id: string;
16//! struct ActionPopupOptions {      title: string;
17//!     id: String,                  message: string;
18//!     title: String,               actions: TsActionPopupAction[];
19//!     ...                      };
20//! }
21//! ```
22//!
23//! ## Key Patterns
24//!
25//! 1. **`#[derive(TS)]`** - Generates TypeScript type definitions via ts-rs
26//! 2. **`#[serde(deny_unknown_fields)]`** - Rejects typos/unknown fields at runtime
27//! 3. **`impl FromJs`** - Bridges rquickjs values to typed Rust structs
28//!
29//! ## Validation Layers
30//!
31//! | Layer                  | What it catches                          |
32//! |------------------------|------------------------------------------|
33//! | TypeScript compile     | Wrong field names, missing required fields |
34//! | Rust runtime (serde)   | Typos like `popup_id` instead of `id`    |
35//! | Rust compile           | Type mismatches in method signatures     |
36//!
37//! ## Limitations & Tradeoffs
38//!
39//! - **Manual parsing for complex types**: Some methods (e.g., `submitViewTransform`)
40//!   still use manual object parsing due to enum serialization complexity
41//! - **Two-step deserialization**: Complex nested structs may need
42//!   `rquickjs::Value → serde_json::Value → typed struct` due to rquickjs_serde limits
43//! - **Duplicate attributes**: Both `#[serde(...)]` and `#[ts(...)]` needed since
44//!   they control different things (runtime serialization vs compile-time codegen)
45
46use crate::command::{Command, Suggestion};
47use crate::file_explorer::FileExplorerDecoration;
48use crate::hooks::{HookCallback, HookRegistry};
49use crate::menu::{Menu, MenuItem};
50use crate::overlay::{OverlayHandle, OverlayNamespace};
51use crate::text_property::{TextProperty, TextPropertyEntry};
52use crate::BufferId;
53use crate::SplitId;
54use crate::TerminalId;
55use lsp_types;
56use serde::{Deserialize, Serialize};
57use serde_json::Value as JsonValue;
58use std::collections::HashMap;
59use std::ops::Range;
60use std::path::PathBuf;
61use std::sync::{Arc, RwLock};
62use ts_rs::TS;
63
64/// Minimal command registry for PluginApi.
65/// This is a stub that provides basic command storage for plugin use.
66/// The editor's full CommandRegistry lives in fresh-editor.
67pub struct CommandRegistry {
68    commands: std::sync::RwLock<Vec<Command>>,
69}
70
71impl CommandRegistry {
72    /// Create a new empty command registry
73    pub fn new() -> Self {
74        Self {
75            commands: std::sync::RwLock::new(Vec::new()),
76        }
77    }
78
79    /// Register a command
80    pub fn register(&self, command: Command) {
81        let mut commands = self.commands.write().unwrap();
82        commands.retain(|c| c.name != command.name);
83        commands.push(command);
84    }
85
86    /// Unregister a command by name  
87    pub fn unregister(&self, name: &str) {
88        let mut commands = self.commands.write().unwrap();
89        commands.retain(|c| c.name != name);
90    }
91}
92
93impl Default for CommandRegistry {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99/// A callback ID for JavaScript promises in the plugin runtime.
100///
101/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
102/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
103/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
105#[ts(export)]
106pub struct JsCallbackId(pub u64);
107
108impl JsCallbackId {
109    /// Create a new JS callback ID
110    pub fn new(id: u64) -> Self {
111        Self(id)
112    }
113
114    /// Get the underlying u64 value
115    pub fn as_u64(self) -> u64 {
116        self.0
117    }
118}
119
120impl From<u64> for JsCallbackId {
121    fn from(id: u64) -> Self {
122        Self(id)
123    }
124}
125
126impl From<JsCallbackId> for u64 {
127    fn from(id: JsCallbackId) -> u64 {
128        id.0
129    }
130}
131
132impl std::fmt::Display for JsCallbackId {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}", self.0)
135    }
136}
137
138/// Result of creating a terminal
139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase")]
141#[ts(export, rename_all = "camelCase")]
142pub struct TerminalResult {
143    /// The created buffer ID (for use with setSplitBuffer, etc.)
144    #[ts(type = "number")]
145    pub buffer_id: u64,
146    /// The terminal ID (for use with sendTerminalInput, closeTerminal)
147    #[ts(type = "number")]
148    pub terminal_id: u64,
149    /// The split ID (if created in a new split)
150    #[ts(type = "number | null")]
151    pub split_id: Option<u64>,
152}
153
154/// Result of creating a virtual buffer
155#[derive(Debug, Clone, Serialize, Deserialize, TS)]
156#[serde(rename_all = "camelCase")]
157#[ts(export, rename_all = "camelCase")]
158pub struct VirtualBufferResult {
159    /// The created buffer ID
160    #[ts(type = "number")]
161    pub buffer_id: u64,
162    /// The split ID (if created in a new split)
163    #[ts(type = "number | null")]
164    pub split_id: Option<u64>,
165}
166
167/// Result of creating a buffer group
168#[derive(Debug, Clone, Serialize, Deserialize, TS)]
169#[serde(rename_all = "camelCase")]
170#[ts(export, rename_all = "camelCase")]
171pub struct BufferGroupResult {
172    /// The group ID
173    #[ts(type = "number")]
174    pub group_id: u64,
175    /// Panel buffer IDs, keyed by panel name
176    #[ts(type = "Record<string, number>")]
177    pub panels: HashMap<String, u64>,
178}
179
180/// Response from the editor for async plugin operations
181#[derive(Debug, Clone, Serialize, Deserialize, TS)]
182#[ts(export)]
183pub enum PluginResponse {
184    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
185    VirtualBufferCreated {
186        request_id: u64,
187        buffer_id: BufferId,
188        split_id: Option<SplitId>,
189    },
190    /// Response to CreateTerminal with the created buffer, terminal, and split IDs
191    TerminalCreated {
192        request_id: u64,
193        buffer_id: BufferId,
194        terminal_id: TerminalId,
195        split_id: Option<SplitId>,
196    },
197    /// Response to a plugin-initiated LSP request
198    LspRequest {
199        request_id: u64,
200        #[ts(type = "any")]
201        result: Result<JsonValue, String>,
202    },
203    /// Response to RequestHighlights
204    HighlightsComputed {
205        request_id: u64,
206        spans: Vec<TsHighlightSpan>,
207    },
208    /// Response to GetBufferText with the text content
209    BufferText {
210        request_id: u64,
211        text: Result<String, String>,
212    },
213    /// Response to GetLineStartPosition with the byte offset
214    LineStartPosition {
215        request_id: u64,
216        /// None if line is out of range, Some(offset) for valid line
217        position: Option<usize>,
218    },
219    /// Response to GetLineEndPosition with the byte offset
220    LineEndPosition {
221        request_id: u64,
222        /// None if line is out of range, Some(offset) for valid line
223        position: Option<usize>,
224    },
225    /// Response to GetBufferLineCount with the total number of lines
226    BufferLineCount {
227        request_id: u64,
228        /// None if buffer not found, Some(count) for valid buffer
229        count: Option<usize>,
230    },
231    /// Response to CreateCompositeBuffer with the created buffer ID
232    CompositeBufferCreated {
233        request_id: u64,
234        buffer_id: BufferId,
235    },
236    /// Response to GetSplitByLabel with the found split ID (if any)
237    SplitByLabel {
238        request_id: u64,
239        split_id: Option<SplitId>,
240    },
241}
242
243/// Messages sent from async plugin tasks to the synchronous main loop
244#[derive(Debug, Clone, Serialize, Deserialize, TS)]
245#[ts(export)]
246pub enum PluginAsyncMessage {
247    /// Plugin process completed with output
248    ProcessOutput {
249        /// Unique ID for this process
250        process_id: u64,
251        /// Standard output
252        stdout: String,
253        /// Standard error
254        stderr: String,
255        /// Exit code
256        exit_code: i32,
257    },
258    /// Plugin delay/timer completed
259    DelayComplete {
260        /// Callback ID to resolve
261        callback_id: u64,
262    },
263    /// Background process stdout data
264    ProcessStdout { process_id: u64, data: String },
265    /// Background process stderr data
266    ProcessStderr { process_id: u64, data: String },
267    /// Background process exited
268    ProcessExit {
269        process_id: u64,
270        callback_id: u64,
271        exit_code: i32,
272    },
273    /// Response for a plugin-initiated LSP request
274    LspResponse {
275        language: String,
276        request_id: u64,
277        #[ts(type = "any")]
278        result: Result<JsonValue, String>,
279    },
280    /// Generic plugin response (e.g., GetBufferText result)
281    PluginResponse(crate::api::PluginResponse),
282
283    /// Streaming grep: partial results for one file
284    GrepStreamingProgress {
285        /// Search ID to route to the correct progress callback
286        search_id: u64,
287        /// Matches from a single file
288        matches_json: String,
289    },
290
291    /// Streaming grep: search complete
292    GrepStreamingComplete {
293        /// Search ID
294        search_id: u64,
295        /// Callback ID for the completion promise
296        callback_id: u64,
297        /// Total number of matches found
298        total_matches: usize,
299        /// Whether the search was stopped early due to reaching max_results
300        truncated: bool,
301    },
302}
303
304/// Information about a cursor in the editor
305#[derive(Debug, Clone, Serialize, Deserialize, TS)]
306#[ts(export)]
307pub struct CursorInfo {
308    /// Byte position of the cursor
309    pub position: usize,
310    /// Selection range (if any)
311    #[cfg_attr(
312        feature = "plugins",
313        ts(type = "{ start: number; end: number } | null")
314    )]
315    pub selection: Option<Range<usize>>,
316}
317
318/// Specification for an action to execute, with optional repeat count
319#[derive(Debug, Clone, Serialize, Deserialize, TS)]
320#[serde(deny_unknown_fields)]
321#[ts(export)]
322pub struct ActionSpec {
323    /// Action name (e.g., "move_word_right", "delete_line")
324    pub action: String,
325    /// Number of times to repeat the action (default 1)
326    #[serde(default = "default_action_count")]
327    pub count: u32,
328}
329
330fn default_action_count() -> u32 {
331    1
332}
333
334/// Information about a buffer
335#[derive(Debug, Clone, Serialize, Deserialize, TS)]
336#[ts(export)]
337pub struct BufferInfo {
338    /// Buffer ID
339    #[ts(type = "number")]
340    pub id: BufferId,
341    /// File path (if any)
342    #[serde(serialize_with = "serialize_path")]
343    #[ts(type = "string")]
344    pub path: Option<PathBuf>,
345    /// Whether the buffer has been modified
346    pub modified: bool,
347    /// Length of buffer in bytes
348    pub length: usize,
349    /// Whether this is a virtual buffer (not backed by a file)
350    pub is_virtual: bool,
351    /// Current view mode of the active split: "source" or "compose"
352    pub view_mode: String,
353    /// True if any split showing this buffer has compose mode enabled.
354    /// Plugins should use this (not `view_mode`) to decide whether to maintain
355    /// decorations, since decorations live on the buffer and are filtered
356    /// per-split at render time.
357    pub is_composing_in_any_split: bool,
358    /// Compose width (if set), from the active split's view state
359    pub compose_width: Option<u16>,
360    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
361    pub language: String,
362    /// Whether this tab was opened in "preview" (ephemeral) mode — true when
363    /// opened via single-click in the file explorer and not yet committed
364    /// (no edit, no double-click, no tab-click, no layout change). Plugins
365    /// that react to buffer lifecycle events should generally treat preview
366    /// buffers as transient; e.g. a diagnostics panel may want to skip
367    /// refreshing itself for a preview tab.
368    #[serde(default)]
369    pub is_preview: bool,
370}
371
372fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
373    s.serialize_str(
374        &path
375            .as_ref()
376            .map(|p| p.to_string_lossy().to_string())
377            .unwrap_or_default(),
378    )
379}
380
381/// Serialize ranges as [start, end] tuples for JS compatibility
382fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
383where
384    S: serde::Serializer,
385{
386    use serde::ser::SerializeSeq;
387    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
388    for range in ranges {
389        seq.serialize_element(&(range.start, range.end))?;
390    }
391    seq.end()
392}
393
394/// Diff between current buffer content and last saved snapshot
395#[derive(Debug, Clone, Serialize, Deserialize, TS)]
396#[ts(export)]
397pub struct BufferSavedDiff {
398    pub equal: bool,
399    #[serde(serialize_with = "serialize_ranges_as_tuples")]
400    #[ts(type = "Array<[number, number]>")]
401    pub byte_ranges: Vec<Range<usize>>,
402}
403
404/// Information about the viewport
405#[derive(Debug, Clone, Serialize, Deserialize, TS)]
406#[serde(rename_all = "camelCase")]
407#[ts(export, rename_all = "camelCase")]
408pub struct ViewportInfo {
409    /// Byte position of the first visible line
410    pub top_byte: usize,
411    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
412    pub top_line: Option<usize>,
413    /// Left column offset (horizontal scroll)
414    pub left_column: usize,
415    /// Viewport width
416    pub width: u16,
417    /// Viewport height
418    pub height: u16,
419}
420
421/// Layout hints supplied by plugins (e.g., Compose mode)
422#[derive(Debug, Clone, Serialize, Deserialize, TS)]
423#[serde(rename_all = "camelCase")]
424#[ts(export, rename_all = "camelCase")]
425pub struct LayoutHints {
426    /// Optional compose width for centering/wrapping
427    #[ts(optional)]
428    pub compose_width: Option<u16>,
429    /// Optional column guides for aligned tables
430    #[ts(optional)]
431    pub column_guides: Option<Vec<u16>>,
432}
433
434// ============================================================================
435// Overlay Types with Theme Support
436// ============================================================================
437
438/// Color specification that can be either RGB values or a theme key.
439///
440/// Theme keys reference colors from the current theme, e.g.:
441/// - "ui.status_bar_bg" - UI status bar background
442/// - "editor.selection_bg" - Editor selection background
443/// - "syntax.keyword" - Syntax highlighting for keywords
444/// - "diagnostic.error" - Error diagnostic color
445///
446/// When a theme key is used, the color is resolved at render time,
447/// so overlays automatically update when the theme changes.
448#[derive(Debug, Clone, Serialize, Deserialize, TS)]
449#[serde(untagged)]
450#[ts(export)]
451pub enum OverlayColorSpec {
452    /// RGB color as [r, g, b] array
453    #[ts(type = "[number, number, number]")]
454    Rgb(u8, u8, u8),
455    /// Theme key reference (e.g., "ui.status_bar_bg")
456    ThemeKey(String),
457}
458
459impl OverlayColorSpec {
460    /// Create an RGB color spec
461    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
462        Self::Rgb(r, g, b)
463    }
464
465    /// Create a theme key color spec
466    pub fn theme_key(key: impl Into<String>) -> Self {
467        Self::ThemeKey(key.into())
468    }
469
470    /// Convert to RGB if this is an RGB spec, None if it's a theme key
471    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
472        match self {
473            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
474            Self::ThemeKey(_) => None,
475        }
476    }
477
478    /// Get the theme key if this is a theme key spec
479    pub fn as_theme_key(&self) -> Option<&str> {
480        match self {
481            Self::ThemeKey(key) => Some(key),
482            Self::Rgb(_, _, _) => None,
483        }
484    }
485}
486
487/// Options for adding an overlay with theme support.
488///
489/// This struct provides a type-safe way to specify overlay styling
490/// with optional theme key references for colors.
491#[derive(Debug, Clone, Serialize, Deserialize, TS)]
492#[serde(deny_unknown_fields, rename_all = "camelCase")]
493#[ts(export, rename_all = "camelCase")]
494#[derive(Default)]
495pub struct OverlayOptions {
496    /// Foreground color - RGB array or theme key string
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub fg: Option<OverlayColorSpec>,
499
500    /// Background color - RGB array or theme key string
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub bg: Option<OverlayColorSpec>,
503
504    /// Whether to render with underline
505    #[serde(default)]
506    pub underline: bool,
507
508    /// Whether to render in bold
509    #[serde(default)]
510    pub bold: bool,
511
512    /// Whether to render in italic
513    #[serde(default)]
514    pub italic: bool,
515
516    /// Whether to render with strikethrough
517    #[serde(default)]
518    pub strikethrough: bool,
519
520    /// Whether to extend background color to end of line
521    #[serde(default)]
522    pub extend_to_line_end: bool,
523
524    /// Optional URL for OSC 8 terminal hyperlinks.
525    /// When set, the overlay text becomes a clickable hyperlink in terminals
526    /// that support OSC 8 escape sequences.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub url: Option<String>,
529}
530
531// ============================================================================
532// Composite Buffer Configuration (for multi-buffer single-tab views)
533// ============================================================================
534
535/// Layout configuration for composite buffers
536#[derive(Debug, Clone, Serialize, Deserialize, TS)]
537#[serde(deny_unknown_fields)]
538#[ts(export, rename = "TsCompositeLayoutConfig")]
539pub struct CompositeLayoutConfig {
540    /// Layout type: "side-by-side", "stacked", or "unified"
541    #[serde(rename = "type")]
542    #[ts(rename = "type")]
543    pub layout_type: String,
544    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
545    #[serde(default)]
546    #[ts(optional)]
547    pub ratios: Option<Vec<f32>>,
548    /// Show separator between panes
549    #[serde(default = "default_true", rename = "showSeparator")]
550    #[ts(rename = "showSeparator")]
551    pub show_separator: bool,
552    /// Spacing for stacked layout
553    #[serde(default)]
554    #[ts(optional)]
555    pub spacing: Option<u16>,
556}
557
558fn default_true() -> bool {
559    true
560}
561
562/// Source pane configuration for composite buffers
563#[derive(Debug, Clone, Serialize, Deserialize, TS)]
564#[serde(deny_unknown_fields)]
565#[ts(export, rename = "TsCompositeSourceConfig")]
566pub struct CompositeSourceConfig {
567    /// Buffer ID of the source buffer (required)
568    #[serde(rename = "bufferId")]
569    #[ts(rename = "bufferId")]
570    pub buffer_id: usize,
571    /// Label for this pane (e.g., "OLD", "NEW")
572    pub label: String,
573    /// Whether this pane is editable
574    #[serde(default)]
575    pub editable: bool,
576    /// Style configuration
577    #[serde(default)]
578    pub style: Option<CompositePaneStyle>,
579}
580
581/// Style configuration for a composite pane
582#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
583#[serde(deny_unknown_fields)]
584#[ts(export, rename = "TsCompositePaneStyle")]
585pub struct CompositePaneStyle {
586    /// Background color for added lines (RGB)
587    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
588    #[serde(default, rename = "addBg")]
589    #[ts(optional, rename = "addBg", type = "[number, number, number]")]
590    pub add_bg: Option<[u8; 3]>,
591    /// Background color for removed lines (RGB)
592    #[serde(default, rename = "removeBg")]
593    #[ts(optional, rename = "removeBg", type = "[number, number, number]")]
594    pub remove_bg: Option<[u8; 3]>,
595    /// Background color for modified lines (RGB)
596    #[serde(default, rename = "modifyBg")]
597    #[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
598    pub modify_bg: Option<[u8; 3]>,
599    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
600    #[serde(default, rename = "gutterStyle")]
601    #[ts(optional, rename = "gutterStyle")]
602    pub gutter_style: Option<String>,
603}
604
605/// Diff hunk for composite buffer alignment
606#[derive(Debug, Clone, Serialize, Deserialize, TS)]
607#[serde(deny_unknown_fields)]
608#[ts(export, rename = "TsCompositeHunk")]
609pub struct CompositeHunk {
610    /// Starting line in old buffer (0-indexed)
611    #[serde(rename = "oldStart")]
612    #[ts(rename = "oldStart")]
613    pub old_start: usize,
614    /// Number of lines in old buffer
615    #[serde(rename = "oldCount")]
616    #[ts(rename = "oldCount")]
617    pub old_count: usize,
618    /// Starting line in new buffer (0-indexed)
619    #[serde(rename = "newStart")]
620    #[ts(rename = "newStart")]
621    pub new_start: usize,
622    /// Number of lines in new buffer
623    #[serde(rename = "newCount")]
624    #[ts(rename = "newCount")]
625    pub new_count: usize,
626}
627
628/// Options for creating a composite buffer (used by plugin API)
629#[derive(Debug, Clone, Serialize, Deserialize, TS)]
630#[serde(deny_unknown_fields)]
631#[ts(export, rename = "TsCreateCompositeBufferOptions")]
632pub struct CreateCompositeBufferOptions {
633    /// Buffer name (displayed in tabs/title)
634    #[serde(default)]
635    pub name: String,
636    /// Mode for keybindings
637    #[serde(default)]
638    pub mode: String,
639    /// Layout configuration
640    pub layout: CompositeLayoutConfig,
641    /// Source pane configurations
642    pub sources: Vec<CompositeSourceConfig>,
643    /// Diff hunks for alignment (optional)
644    #[serde(default)]
645    pub hunks: Option<Vec<CompositeHunk>>,
646    /// When set, the first render will scroll to center the Nth hunk (0-indexed).
647    /// This avoids timing issues with imperative scroll commands that depend on
648    /// render-created state (viewport dimensions, view state).
649    #[serde(default, rename = "initialFocusHunk")]
650    #[ts(optional, rename = "initialFocusHunk")]
651    pub initial_focus_hunk: Option<usize>,
652}
653
654/// Wire-format view token kind (serialized for plugin transforms)
655#[derive(Debug, Clone, Serialize, Deserialize, TS)]
656#[ts(export)]
657pub enum ViewTokenWireKind {
658    Text(String),
659    Newline,
660    Space,
661    /// Visual line break inserted by wrapping (not from source)
662    /// Always has source_offset: None
663    Break,
664    /// A single binary byte that should be rendered as <XX>
665    /// Used in binary file mode to ensure cursor positioning works correctly
666    /// (all 4 display chars of <XX> map to the same source byte)
667    BinaryByte(u8),
668}
669
670/// Styling for view tokens (used for injected annotations)
671///
672/// This allows plugins to specify styling for tokens that don't have a source
673/// mapping (sourceOffset: None), such as annotation headers in git blame.
674/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
675#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
676#[serde(deny_unknown_fields)]
677#[ts(export)]
678pub struct ViewTokenStyle {
679    /// Foreground color as RGB tuple
680    #[serde(default)]
681    #[ts(type = "[number, number, number] | null")]
682    pub fg: Option<(u8, u8, u8)>,
683    /// Background color as RGB tuple
684    #[serde(default)]
685    #[ts(type = "[number, number, number] | null")]
686    pub bg: Option<(u8, u8, u8)>,
687    /// Whether to render in bold
688    #[serde(default)]
689    pub bold: bool,
690    /// Whether to render in italic
691    #[serde(default)]
692    pub italic: bool,
693}
694
695/// Wire-format view token with optional source mapping and styling
696#[derive(Debug, Clone, Serialize, Deserialize, TS)]
697#[serde(deny_unknown_fields)]
698#[ts(export)]
699pub struct ViewTokenWire {
700    /// Source byte offset in the buffer. None for injected content (annotations).
701    #[ts(type = "number | null")]
702    pub source_offset: Option<usize>,
703    /// The token content
704    pub kind: ViewTokenWireKind,
705    /// Optional styling for injected content (only used when source_offset is None)
706    #[serde(default)]
707    #[ts(optional)]
708    pub style: Option<ViewTokenStyle>,
709}
710
711/// Transformed view stream payload (plugin-provided)
712#[derive(Debug, Clone, Serialize, Deserialize, TS)]
713#[ts(export)]
714pub struct ViewTransformPayload {
715    /// Byte range this transform applies to (viewport)
716    pub range: Range<usize>,
717    /// Tokens in wire format
718    pub tokens: Vec<ViewTokenWire>,
719    /// Layout hints
720    pub layout_hints: Option<LayoutHints>,
721}
722
723/// Snapshot of editor state for plugin queries
724/// This is updated by the editor on each loop iteration
725#[derive(Debug, Clone, Serialize, Deserialize, TS)]
726#[ts(export)]
727pub struct EditorStateSnapshot {
728    /// Currently active buffer ID
729    pub active_buffer_id: BufferId,
730    /// Currently active split ID
731    pub active_split_id: usize,
732    /// Information about all open buffers
733    pub buffers: HashMap<BufferId, BufferInfo>,
734    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
735    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
736    /// Primary cursor position for the active buffer
737    pub primary_cursor: Option<CursorInfo>,
738    /// All cursor positions for the active buffer
739    pub all_cursors: Vec<CursorInfo>,
740    /// Viewport information for the active buffer
741    pub viewport: Option<ViewportInfo>,
742    /// Cursor positions per buffer (for buffers other than active)
743    pub buffer_cursor_positions: HashMap<BufferId, usize>,
744    /// Text properties per buffer (for virtual buffers with properties)
745    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
746    /// Selected text from the primary cursor (if any selection exists)
747    /// This is populated on each update to avoid needing full buffer access
748    pub selected_text: Option<String>,
749    /// Internal clipboard content (for plugins that need clipboard access)
750    pub clipboard: String,
751    /// Editor's working directory (for file operations and spawning processes)
752    pub working_dir: PathBuf,
753    /// LSP diagnostics per file URI.
754    /// Maps file URI string to Vec of diagnostics for that file.
755    ///
756    /// Wrapped in `Arc` so snapshot refresh is a refcount bump rather than
757    /// a deep clone. The editor only mutates its own map through
758    /// `Arc::make_mut`, which CoW-clones while this snapshot still holds
759    /// a reference — a reader can never observe an in-place mutation.
760    ///
761    /// `#[serde(skip)]`: serde out-of-the-box can't serialize `Arc<T>`
762    /// (behind the `rc` cargo feature we don't enable). We never serialize
763    /// the snapshot as a whole — plugin readers pull out these Arcs and
764    /// serialize the *inner* value directly (e.g. `get_all_diagnostics`).
765    #[serde(skip)]
766    #[ts(type = "any")]
767    pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
768    /// LSP folding ranges per file URI.
769    /// Maps file URI string to Vec of folding ranges for that file.
770    /// Arc-wrapped for the same CoW invariant as `diagnostics`; see that
771    /// field for why this is `#[serde(skip)]`.
772    #[serde(skip)]
773    #[ts(type = "any")]
774    pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
775    /// Runtime config as serde_json::Value (merged user config + defaults).
776    /// This is the runtime config, not just the user's config file.
777    ///
778    /// Wrapped in `Arc` so the snapshot update is a refcount bump. The
779    /// editor reserializes its source `Config` only when the underlying
780    /// `Arc<Config>` pointer has moved (i.e., after a real mutation), and
781    /// swaps the whole `Arc<Value>` atomically — callers never see a
782    /// partially-updated blob. `#[serde(skip)]` for the same reason as
783    /// `diagnostics`.
784    #[serde(skip)]
785    #[ts(type = "any")]
786    pub config: Arc<serde_json::Value>,
787    /// User config as serde_json::Value (only what's in the user's config file).
788    /// Fields not present here are using default values.
789    /// Arc-wrapped; swapped as a whole when the user's file is reloaded.
790    /// `#[serde(skip)]` for the same reason as `diagnostics`.
791    #[serde(skip)]
792    #[ts(type = "any")]
793    pub user_config: Arc<serde_json::Value>,
794    /// Available grammars with provenance info, updated when grammar registry changes
795    #[ts(type = "GrammarInfo[]")]
796    pub available_grammars: Vec<GrammarInfoSnapshot>,
797    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
798    /// When set, this mode's keybindings take precedence over normal key handling
799    pub editor_mode: Option<String>,
800
801    /// Plugin-managed per-buffer view state for the active split.
802    /// Updated from BufferViewState.plugin_state during snapshot updates.
803    /// Also written directly by JS plugins via setViewState for immediate read-back.
804    #[ts(type = "any")]
805    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
806
807    /// Tracks which split was active when plugin_view_states was last populated.
808    /// When the active split changes, plugin_view_states is fully repopulated.
809    #[serde(skip)]
810    #[ts(skip)]
811    pub plugin_view_states_split: usize,
812
813    /// Keybinding labels for plugin modes, keyed by "action\0mode" for fast lookup.
814    /// Updated when modes are registered via defineMode().
815    #[serde(skip)]
816    #[ts(skip)]
817    pub keybinding_labels: HashMap<String, String>,
818
819    /// Plugin-managed global state, isolated per plugin.
820    /// Outer key is plugin name, inner key is the state key set by the plugin.
821    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
822    /// Currently we isolate by plugin name, but we may want a more robust approach
823    /// (e.g. preventing plugins from reading each other's state, or providing
824    /// explicit cross-plugin state sharing APIs).
825    #[ts(type = "any")]
826    pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
827}
828
829impl EditorStateSnapshot {
830    pub fn new() -> Self {
831        Self {
832            active_buffer_id: BufferId(0),
833            active_split_id: 0,
834            buffers: HashMap::new(),
835            buffer_saved_diffs: HashMap::new(),
836            primary_cursor: None,
837            all_cursors: Vec::new(),
838            viewport: None,
839            buffer_cursor_positions: HashMap::new(),
840            buffer_text_properties: HashMap::new(),
841            selected_text: None,
842            clipboard: String::new(),
843            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
844            diagnostics: Arc::new(HashMap::new()),
845            folding_ranges: Arc::new(HashMap::new()),
846            config: Arc::new(serde_json::Value::Null),
847            user_config: Arc::new(serde_json::Value::Null),
848            available_grammars: Vec::new(),
849            editor_mode: None,
850            plugin_view_states: HashMap::new(),
851            plugin_view_states_split: 0,
852            keybinding_labels: HashMap::new(),
853            plugin_global_states: HashMap::new(),
854        }
855    }
856}
857
858impl Default for EditorStateSnapshot {
859    fn default() -> Self {
860        Self::new()
861    }
862}
863
864/// Grammar info exposed to plugins, mirroring the editor's grammar provenance tracking.
865#[derive(Debug, Clone, Serialize, Deserialize, TS)]
866#[ts(export)]
867pub struct GrammarInfoSnapshot {
868    /// The grammar name as used in config files (case-insensitive matching)
869    pub name: String,
870    /// Where this grammar was loaded from (e.g. "built-in", "plugin (myplugin)")
871    pub source: String,
872    /// File extensions associated with this grammar
873    pub file_extensions: Vec<String>,
874    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
875    pub short_name: Option<String>,
876}
877
878/// Position for inserting menu items or menus
879#[derive(Debug, Clone, Serialize, Deserialize, TS)]
880#[ts(export)]
881pub enum MenuPosition {
882    /// Add at the beginning
883    Top,
884    /// Add at the end
885    Bottom,
886    /// Add before a specific label
887    Before(String),
888    /// Add after a specific label
889    After(String),
890}
891
892/// Plugin command - allows plugins to send commands to the editor
893#[derive(Debug, Clone, Serialize, Deserialize, TS)]
894#[ts(export)]
895pub enum PluginCommand {
896    /// Insert text at a position in a buffer
897    InsertText {
898        buffer_id: BufferId,
899        position: usize,
900        text: String,
901    },
902
903    /// Delete a range of text from a buffer
904    DeleteRange {
905        buffer_id: BufferId,
906        range: Range<usize>,
907    },
908
909    /// Add an overlay to a buffer, returns handle via response channel
910    ///
911    /// Colors can be specified as RGB tuples or theme keys. When theme keys
912    /// are provided, they take precedence and are resolved at render time.
913    AddOverlay {
914        buffer_id: BufferId,
915        namespace: Option<OverlayNamespace>,
916        range: Range<usize>,
917        /// Overlay styling options (colors, modifiers, etc.)
918        options: OverlayOptions,
919    },
920
921    /// Remove an overlay by its opaque handle
922    RemoveOverlay {
923        buffer_id: BufferId,
924        handle: OverlayHandle,
925    },
926
927    /// Set status message
928    SetStatus { message: String },
929
930    /// Apply a theme by name
931    ApplyTheme { theme_name: String },
932
933    /// Reload configuration from file
934    /// After a plugin saves config changes, it should call this to reload the config
935    ReloadConfig,
936
937    /// Register a custom command
938    RegisterCommand { command: Command },
939
940    /// Unregister a command by name
941    UnregisterCommand { name: String },
942
943    /// Open a file in the editor (in background, without switching focus)
944    OpenFileInBackground { path: PathBuf },
945
946    /// Insert text at the current cursor position in the active buffer
947    InsertAtCursor { text: String },
948
949    /// Spawn an async process
950    SpawnProcess {
951        command: String,
952        args: Vec<String>,
953        cwd: Option<String>,
954        callback_id: JsCallbackId,
955    },
956
957    /// Delay/sleep for a duration (async, resolves callback when done)
958    Delay {
959        callback_id: JsCallbackId,
960        duration_ms: u64,
961    },
962
963    /// Spawn a long-running background process
964    /// Unlike SpawnProcess, this returns immediately with a process handle
965    /// and provides streaming output via hooks
966    SpawnBackgroundProcess {
967        /// Unique ID for this process (generated by plugin runtime)
968        process_id: u64,
969        /// Command to execute
970        command: String,
971        /// Arguments to pass
972        args: Vec<String>,
973        /// Working directory (optional)
974        cwd: Option<String>,
975        /// Callback ID to call when process exits
976        callback_id: JsCallbackId,
977    },
978
979    /// Kill a background process by ID
980    KillBackgroundProcess { process_id: u64 },
981
982    /// Wait for a process to complete and get its result
983    /// Used with processes started via SpawnProcess
984    SpawnProcessWait {
985        /// Process ID to wait for
986        process_id: u64,
987        /// Callback ID for async response
988        callback_id: JsCallbackId,
989    },
990
991    /// Set layout hints for a buffer/viewport
992    SetLayoutHints {
993        buffer_id: BufferId,
994        split_id: Option<SplitId>,
995        range: Range<usize>,
996        hints: LayoutHints,
997    },
998
999    /// Enable/disable line numbers for a buffer
1000    SetLineNumbers { buffer_id: BufferId, enabled: bool },
1001
1002    /// Set the view mode for a buffer ("source" or "compose")
1003    SetViewMode { buffer_id: BufferId, mode: String },
1004
1005    /// Enable/disable line wrapping for a buffer
1006    SetLineWrap {
1007        buffer_id: BufferId,
1008        split_id: Option<SplitId>,
1009        enabled: bool,
1010    },
1011
1012    /// Submit a transformed view stream for a viewport
1013    SubmitViewTransform {
1014        buffer_id: BufferId,
1015        split_id: Option<SplitId>,
1016        payload: ViewTransformPayload,
1017    },
1018
1019    /// Clear view transform for a buffer/split (returns to normal rendering)
1020    ClearViewTransform {
1021        buffer_id: BufferId,
1022        split_id: Option<SplitId>,
1023    },
1024
1025    /// Set plugin-managed view state for a buffer in the active split.
1026    /// Stored in BufferViewState.plugin_state and persisted across sessions.
1027    SetViewState {
1028        buffer_id: BufferId,
1029        key: String,
1030        #[ts(type = "any")]
1031        value: Option<serde_json::Value>,
1032    },
1033
1034    /// Set plugin-managed global state (not tied to any buffer or split).
1035    /// Isolated per plugin by plugin_name.
1036    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
1037    SetGlobalState {
1038        plugin_name: String,
1039        key: String,
1040        #[ts(type = "any")]
1041        value: Option<serde_json::Value>,
1042    },
1043
1044    /// Remove all overlays from a buffer
1045    ClearAllOverlays { buffer_id: BufferId },
1046
1047    /// Remove all overlays in a namespace
1048    ClearNamespace {
1049        buffer_id: BufferId,
1050        namespace: OverlayNamespace,
1051    },
1052
1053    /// Remove all overlays that overlap with a byte range
1054    /// Used for targeted invalidation when content in a range changes
1055    ClearOverlaysInRange {
1056        buffer_id: BufferId,
1057        start: usize,
1058        end: usize,
1059    },
1060
1061    /// Add virtual text (inline text that doesn't exist in the buffer)
1062    /// Used for color swatches, type hints, parameter hints, etc.
1063    AddVirtualText {
1064        buffer_id: BufferId,
1065        virtual_text_id: String,
1066        position: usize,
1067        text: String,
1068        color: (u8, u8, u8),
1069        use_bg: bool, // true = use color as background, false = use as foreground
1070        before: bool, // true = before char, false = after char
1071    },
1072
1073    /// Remove a virtual text by ID
1074    RemoveVirtualText {
1075        buffer_id: BufferId,
1076        virtual_text_id: String,
1077    },
1078
1079    /// Remove virtual texts whose ID starts with the given prefix
1080    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
1081
1082    /// Clear all virtual texts from a buffer
1083    ClearVirtualTexts { buffer_id: BufferId },
1084
1085    /// Add a virtual LINE (full line above/below a position)
1086    /// Used for git blame headers, code coverage, inline documentation, etc.
1087    /// These lines do NOT show line numbers in the gutter.
1088    AddVirtualLine {
1089        buffer_id: BufferId,
1090        /// Byte position to anchor the line to
1091        position: usize,
1092        /// Full line content to display
1093        text: String,
1094        /// Foreground color — RGB tuple or theme key string (e.g.
1095        /// `"editor.line_number_fg"`).  Resolved at render time so the line
1096        /// follows theme changes.
1097        fg_color: Option<OverlayColorSpec>,
1098        /// Background color — RGB tuple or theme key string.  None =
1099        /// transparent (inherits from underlying viewport background).
1100        bg_color: Option<OverlayColorSpec>,
1101        /// true = above the line containing position, false = below
1102        above: bool,
1103        /// Namespace for bulk removal (e.g., "git-blame")
1104        namespace: String,
1105        /// Priority for ordering multiple lines at same position (higher = later)
1106        priority: i32,
1107    },
1108
1109    /// Clear all virtual texts in a namespace
1110    /// This is the primary way to remove a plugin's virtual lines before updating them.
1111    ClearVirtualTextNamespace {
1112        buffer_id: BufferId,
1113        namespace: String,
1114    },
1115
1116    /// Add a conceal range that hides or replaces a byte range during rendering.
1117    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
1118    AddConceal {
1119        buffer_id: BufferId,
1120        /// Namespace for bulk removal (shared with overlay namespace system)
1121        namespace: OverlayNamespace,
1122        /// Byte range to conceal
1123        start: usize,
1124        end: usize,
1125        /// Optional replacement text to show instead. None = hide completely.
1126        replacement: Option<String>,
1127    },
1128
1129    /// Clear all conceal ranges in a namespace
1130    ClearConcealNamespace {
1131        buffer_id: BufferId,
1132        namespace: OverlayNamespace,
1133    },
1134
1135    /// Remove all conceal ranges that overlap with a byte range
1136    /// Used for targeted invalidation when content in a range changes
1137    ClearConcealsInRange {
1138        buffer_id: BufferId,
1139        start: usize,
1140        end: usize,
1141    },
1142
1143    /// Add a collapsed fold range. Hides the byte range
1144    /// `[start, end)` from rendering — the line containing `start - 1`
1145    /// (the fold's "header") stays visible while the lines covered by
1146    /// the range are skipped. Used by plugins that want to expose
1147    /// outline-style collapse without rebuilding buffer content.
1148    AddFold {
1149        buffer_id: BufferId,
1150        start: usize,
1151        end: usize,
1152        /// Optional placeholder text to show on the header line
1153        /// (currently unused by the renderer; reserved for future use).
1154        placeholder: Option<String>,
1155    },
1156
1157    /// Clear every collapsed fold range on the buffer.
1158    ClearFolds { buffer_id: BufferId },
1159
1160    /// Add a soft break point for marker-based line wrapping.
1161    /// The break is stored as a marker that auto-adjusts on buffer edits,
1162    /// eliminating the flicker caused by async view_transform round-trips.
1163    AddSoftBreak {
1164        buffer_id: BufferId,
1165        /// Namespace for bulk removal (shared with overlay namespace system)
1166        namespace: OverlayNamespace,
1167        /// Byte offset where the break should be injected
1168        position: usize,
1169        /// Number of hanging indent spaces after the break
1170        indent: u16,
1171    },
1172
1173    /// Clear all soft breaks in a namespace
1174    ClearSoftBreakNamespace {
1175        buffer_id: BufferId,
1176        namespace: OverlayNamespace,
1177    },
1178
1179    /// Remove all soft breaks that fall within a byte range
1180    ClearSoftBreaksInRange {
1181        buffer_id: BufferId,
1182        start: usize,
1183        end: usize,
1184    },
1185
1186    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
1187    RefreshLines { buffer_id: BufferId },
1188
1189    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
1190    /// Sent when a plugin registers for the lines_changed hook to handle the race
1191    /// where render marks lines as "seen" before the plugin has registered.
1192    RefreshAllLines,
1193
1194    /// Sentinel sent by the plugin thread after a hook has been fully processed.
1195    /// Used by the render loop to wait deterministically for plugin responses
1196    /// (e.g., conceal commands from `lines_changed`) instead of polling.
1197    HookCompleted { hook_name: String },
1198
1199    /// Set a line indicator in the gutter's indicator column
1200    /// Used for git gutter, breakpoints, bookmarks, etc.
1201    SetLineIndicator {
1202        buffer_id: BufferId,
1203        /// Line number (0-indexed)
1204        line: usize,
1205        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1206        namespace: String,
1207        /// Symbol to display (e.g., "│", "●", "★")
1208        symbol: String,
1209        /// Color as RGB tuple
1210        color: (u8, u8, u8),
1211        /// Priority for display when multiple indicators exist (higher wins)
1212        priority: i32,
1213    },
1214
1215    /// Batch set line indicators in the gutter's indicator column
1216    /// Optimized for setting many lines with the same namespace/symbol/color/priority
1217    SetLineIndicators {
1218        buffer_id: BufferId,
1219        /// Line numbers (0-indexed)
1220        lines: Vec<usize>,
1221        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1222        namespace: String,
1223        /// Symbol to display (e.g., "│", "●", "★")
1224        symbol: String,
1225        /// Color as RGB tuple
1226        color: (u8, u8, u8),
1227        /// Priority for display when multiple indicators exist (higher wins)
1228        priority: i32,
1229    },
1230
1231    /// Clear all line indicators for a specific namespace
1232    ClearLineIndicators {
1233        buffer_id: BufferId,
1234        /// Namespace to clear (e.g., "git-gutter")
1235        namespace: String,
1236    },
1237
1238    /// Set file explorer decorations for a namespace
1239    SetFileExplorerDecorations {
1240        /// Namespace for grouping (e.g., "git-status")
1241        namespace: String,
1242        /// Decorations to apply
1243        decorations: Vec<FileExplorerDecoration>,
1244    },
1245
1246    /// Clear file explorer decorations for a namespace
1247    ClearFileExplorerDecorations {
1248        /// Namespace to clear (e.g., "git-status")
1249        namespace: String,
1250    },
1251
1252    /// Open a file at a specific line and column
1253    /// Line and column are 1-indexed to match git grep output
1254    OpenFileAtLocation {
1255        path: PathBuf,
1256        line: Option<usize>,   // 1-indexed, None = go to start
1257        column: Option<usize>, // 1-indexed, None = go to line start
1258    },
1259
1260    /// Open a file in a specific split at a given line and column
1261    /// Line and column are 1-indexed to match git grep output
1262    OpenFileInSplit {
1263        split_id: usize,
1264        path: PathBuf,
1265        line: Option<usize>,   // 1-indexed, None = go to start
1266        column: Option<usize>, // 1-indexed, None = go to line start
1267    },
1268
1269    /// Start a prompt (minibuffer) with a custom type identifier
1270    /// This allows plugins to create interactive prompts
1271    StartPrompt {
1272        label: String,
1273        prompt_type: String, // e.g., "git-grep", "git-find-file"
1274    },
1275
1276    /// Start a prompt with pre-filled initial value
1277    StartPromptWithInitial {
1278        label: String,
1279        prompt_type: String,
1280        initial_value: String,
1281    },
1282
1283    /// Start an async prompt that returns result via callback
1284    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1285    StartPromptAsync {
1286        label: String,
1287        initial_value: String,
1288        callback_id: JsCallbackId,
1289    },
1290
1291    /// Update the suggestions list for the current prompt
1292    /// Uses the editor's Suggestion type
1293    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1294
1295    /// When enabled, navigating suggestions updates the prompt input text
1296    SetPromptInputSync { sync: bool },
1297
1298    /// Add a menu item to an existing menu
1299    /// Add a menu item to an existing menu
1300    AddMenuItem {
1301        menu_label: String,
1302        item: MenuItem,
1303        position: MenuPosition,
1304    },
1305
1306    /// Add a new top-level menu
1307    AddMenu { menu: Menu, position: MenuPosition },
1308
1309    /// Remove a menu item from a menu
1310    RemoveMenuItem {
1311        menu_label: String,
1312        item_label: String,
1313    },
1314
1315    /// Remove a top-level menu
1316    RemoveMenu { menu_label: String },
1317
1318    /// Create a new virtual buffer (not backed by a file)
1319    CreateVirtualBuffer {
1320        /// Display name (e.g., "*Diagnostics*")
1321        name: String,
1322        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1323        mode: String,
1324        /// Whether the buffer is read-only
1325        read_only: bool,
1326    },
1327
1328    /// Create a virtual buffer and set its content in one operation
1329    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1330    /// because it doesn't require tracking the buffer ID
1331    CreateVirtualBufferWithContent {
1332        /// Display name (e.g., "*Diagnostics*")
1333        name: String,
1334        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1335        mode: String,
1336        /// Whether the buffer is read-only
1337        read_only: bool,
1338        /// Entries with text and embedded properties
1339        entries: Vec<TextPropertyEntry>,
1340        /// Whether to show line numbers in the gutter
1341        show_line_numbers: bool,
1342        /// Whether to show cursors in the buffer
1343        show_cursors: bool,
1344        /// Whether editing is disabled (blocks editing commands)
1345        editing_disabled: bool,
1346        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1347        hidden_from_tabs: bool,
1348        /// Optional request ID for async response
1349        request_id: Option<u64>,
1350    },
1351
1352    /// Create a virtual buffer in a horizontal split
1353    /// Opens the buffer in a new pane below the current one
1354    CreateVirtualBufferInSplit {
1355        /// Display name (e.g., "*Diagnostics*")
1356        name: String,
1357        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1358        mode: String,
1359        /// Whether the buffer is read-only
1360        read_only: bool,
1361        /// Entries with text and embedded properties
1362        entries: Vec<TextPropertyEntry>,
1363        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1364        ratio: f32,
1365        /// Split direction ("horizontal" or "vertical"), default horizontal
1366        direction: Option<String>,
1367        /// Optional panel ID for idempotent operations (if panel exists, update content)
1368        panel_id: Option<String>,
1369        /// Whether to show line numbers in the buffer (default true)
1370        show_line_numbers: bool,
1371        /// Whether to show cursors in the buffer (default true)
1372        show_cursors: bool,
1373        /// Whether editing is disabled for this buffer (default false)
1374        editing_disabled: bool,
1375        /// Whether line wrapping is enabled for this split (None = use global setting)
1376        line_wrap: Option<bool>,
1377        /// Place the new buffer before (left/top of) the existing content (default: false/after)
1378        before: bool,
1379        /// Optional request ID for async response (if set, editor will send back buffer ID)
1380        request_id: Option<u64>,
1381    },
1382
1383    /// Set the content of a virtual buffer with text properties
1384    SetVirtualBufferContent {
1385        buffer_id: BufferId,
1386        /// Entries with text and embedded properties
1387        entries: Vec<TextPropertyEntry>,
1388    },
1389
1390    /// Get text properties at the cursor position in a buffer
1391    GetTextPropertiesAtCursor { buffer_id: BufferId },
1392
1393    /// Create a buffer group: multiple panels appearing as one tab.
1394    /// Each panel is a real buffer with its own scrollbar and viewport.
1395    CreateBufferGroup {
1396        /// Display name (shown in tab bar)
1397        name: String,
1398        /// Mode for keybindings
1399        mode: String,
1400        /// Layout tree as JSON string (parsed by the handler)
1401        layout_json: String,
1402        /// Optional request ID for async response
1403        request_id: Option<u64>,
1404    },
1405
1406    /// Set the content of a panel within a buffer group.
1407    SetPanelContent {
1408        /// Group ID
1409        group_id: usize,
1410        /// Panel name (e.g., "tree", "picker")
1411        panel_name: String,
1412        /// Content entries
1413        entries: Vec<TextPropertyEntry>,
1414    },
1415
1416    /// Close a buffer group (closes all panels and splits)
1417    CloseBufferGroup { group_id: usize },
1418
1419    /// Focus a specific panel within a buffer group
1420    FocusPanel { group_id: usize, panel_name: String },
1421
1422    /// Define a buffer mode with keybindings
1423    DefineMode {
1424        name: String,
1425        bindings: Vec<(String, String)>, // (key_string, command_name)
1426        read_only: bool,
1427        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
1428        allow_text_input: bool,
1429        /// When true, keys not bound by this mode fall through to the Normal
1430        /// context (motion, selection, copy) instead of being dropped.
1431        inherit_normal_bindings: bool,
1432        /// Name of the plugin that defined this mode (for attribution)
1433        plugin_name: Option<String>,
1434    },
1435
1436    /// Switch the current split to display a buffer
1437    ShowBuffer { buffer_id: BufferId },
1438
1439    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1440    CreateVirtualBufferInExistingSplit {
1441        /// Display name (e.g., "*Commit Details*")
1442        name: String,
1443        /// Mode name for buffer-local keybindings
1444        mode: String,
1445        /// Whether the buffer is read-only
1446        read_only: bool,
1447        /// Entries with text and embedded properties
1448        entries: Vec<TextPropertyEntry>,
1449        /// Target split ID where the buffer should be displayed
1450        split_id: SplitId,
1451        /// Whether to show line numbers in the buffer (default true)
1452        show_line_numbers: bool,
1453        /// Whether to show cursors in the buffer (default true)
1454        show_cursors: bool,
1455        /// Whether editing is disabled for this buffer (default false)
1456        editing_disabled: bool,
1457        /// Whether line wrapping is enabled for this split (None = use global setting)
1458        line_wrap: Option<bool>,
1459        /// Optional request ID for async response
1460        request_id: Option<u64>,
1461    },
1462
1463    /// Close a buffer and remove it from all splits
1464    CloseBuffer { buffer_id: BufferId },
1465
1466    /// Create a composite buffer that displays multiple source buffers
1467    /// Used for side-by-side diff, unified diff, and 3-way merge views
1468    CreateCompositeBuffer {
1469        /// Display name (shown in tab bar)
1470        name: String,
1471        /// Mode name for keybindings (e.g., "diff-view")
1472        mode: String,
1473        /// Layout configuration
1474        layout: CompositeLayoutConfig,
1475        /// Source pane configurations
1476        sources: Vec<CompositeSourceConfig>,
1477        /// Diff hunks for line alignment (optional)
1478        hunks: Option<Vec<CompositeHunk>>,
1479        /// When set, first render scrolls to center this hunk (0-indexed)
1480        initial_focus_hunk: Option<usize>,
1481        /// Request ID for async response
1482        request_id: Option<u64>,
1483    },
1484
1485    /// Update alignment for a composite buffer (e.g., after source edit)
1486    UpdateCompositeAlignment {
1487        buffer_id: BufferId,
1488        hunks: Vec<CompositeHunk>,
1489    },
1490
1491    /// Close a composite buffer
1492    CloseCompositeBuffer { buffer_id: BufferId },
1493
1494    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
1495    ///
1496    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
1497    /// have one, and syncs viewport dimensions from split layout. This ensures
1498    /// subsequent commands can read/modify view state that is normally created
1499    /// lazily during the render cycle.
1500    FlushLayout,
1501
1502    /// Navigate to the next hunk in a composite buffer
1503    CompositeNextHunk { buffer_id: BufferId },
1504
1505    /// Navigate to the previous hunk in a composite buffer
1506    CompositePrevHunk { buffer_id: BufferId },
1507
1508    /// Focus a specific split
1509    FocusSplit { split_id: SplitId },
1510
1511    /// Set the buffer displayed in a specific split
1512    SetSplitBuffer {
1513        split_id: SplitId,
1514        buffer_id: BufferId,
1515    },
1516
1517    /// Set the scroll position of a specific split
1518    SetSplitScroll { split_id: SplitId, top_byte: usize },
1519
1520    /// Request syntax highlights for a buffer range
1521    RequestHighlights {
1522        buffer_id: BufferId,
1523        range: Range<usize>,
1524        request_id: u64,
1525    },
1526
1527    /// Close a split (if not the last one)
1528    CloseSplit { split_id: SplitId },
1529
1530    /// Set the ratio of a split container
1531    SetSplitRatio {
1532        split_id: SplitId,
1533        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1534        ratio: f32,
1535    },
1536
1537    /// Set a label on a leaf split (e.g., "sidebar")
1538    SetSplitLabel { split_id: SplitId, label: String },
1539
1540    /// Remove a label from a split
1541    ClearSplitLabel { split_id: SplitId },
1542
1543    /// Find a split by its label (async)
1544    GetSplitByLabel { label: String, request_id: u64 },
1545
1546    /// Distribute splits evenly - make all given splits equal size
1547    DistributeSplitsEvenly {
1548        /// Split IDs to distribute evenly
1549        split_ids: Vec<SplitId>,
1550    },
1551
1552    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1553    SetBufferCursor {
1554        buffer_id: BufferId,
1555        /// Byte offset position for the cursor
1556        position: usize,
1557    },
1558
1559    /// Toggle whether the editor draws a native caret for this buffer.
1560    ///
1561    /// Buffer-group panel buffers default to `show_cursors = false`, which not
1562    /// only hides the caret but also blocks all movement actions in
1563    /// `action_to_events`. Plugins that want native cursor motion in a panel
1564    /// buffer (e.g. for magit-style row navigation) flip this to `true` after
1565    /// `createBufferGroup` returns.
1566    SetBufferShowCursors { buffer_id: BufferId, show: bool },
1567
1568    /// Send an arbitrary LSP request and return the raw JSON response
1569    SendLspRequest {
1570        language: String,
1571        method: String,
1572        #[ts(type = "any")]
1573        params: Option<JsonValue>,
1574        request_id: u64,
1575    },
1576
1577    /// Set the internal clipboard content
1578    SetClipboard { text: String },
1579
1580    /// Delete the current selection in the active buffer
1581    /// This deletes all selected text across all cursors
1582    DeleteSelection,
1583
1584    /// Set or unset a custom context
1585    /// Custom contexts are plugin-defined states that can be used to control command visibility
1586    /// For example, "config-editor" context could make config editor commands available
1587    SetContext {
1588        /// Context name (e.g., "config-editor")
1589        name: String,
1590        /// Whether the context is active
1591        active: bool,
1592    },
1593
1594    /// Set the hunks for the Review Diff tool
1595    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1596
1597    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1598    /// Used by vi mode plugin to run motions and calculate cursor ranges
1599    ExecuteAction {
1600        /// Action name (e.g., "move_word_right", "move_line_end")
1601        action_name: String,
1602    },
1603
1604    /// Execute multiple actions in sequence, each with an optional repeat count
1605    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1606    /// All actions execute atomically with no plugin roundtrips between them
1607    ExecuteActions {
1608        /// List of actions to execute in sequence
1609        actions: Vec<ActionSpec>,
1610    },
1611
1612    /// Get text from a buffer range (for yank operations)
1613    GetBufferText {
1614        /// Buffer ID
1615        buffer_id: BufferId,
1616        /// Start byte offset
1617        start: usize,
1618        /// End byte offset
1619        end: usize,
1620        /// Request ID for async response
1621        request_id: u64,
1622    },
1623
1624    /// Get byte offset of the start of a line (async)
1625    /// Line is 0-indexed (0 = first line)
1626    GetLineStartPosition {
1627        /// Buffer ID (0 for active buffer)
1628        buffer_id: BufferId,
1629        /// Line number (0-indexed)
1630        line: u32,
1631        /// Request ID for async response
1632        request_id: u64,
1633    },
1634
1635    /// Get byte offset of the end of a line (async)
1636    /// Line is 0-indexed (0 = first line)
1637    /// Returns the byte offset after the last character of the line (before newline)
1638    GetLineEndPosition {
1639        /// Buffer ID (0 for active buffer)
1640        buffer_id: BufferId,
1641        /// Line number (0-indexed)
1642        line: u32,
1643        /// Request ID for async response
1644        request_id: u64,
1645    },
1646
1647    /// Get the total number of lines in a buffer (async)
1648    GetBufferLineCount {
1649        /// Buffer ID (0 for active buffer)
1650        buffer_id: BufferId,
1651        /// Request ID for async response
1652        request_id: u64,
1653    },
1654
1655    /// Scroll a split to center a specific line in the viewport
1656    /// Line is 0-indexed (0 = first line)
1657    ScrollToLineCenter {
1658        /// Split ID to scroll
1659        split_id: SplitId,
1660        /// Buffer ID containing the line
1661        buffer_id: BufferId,
1662        /// Line number to center (0-indexed)
1663        line: usize,
1664    },
1665
1666    /// Scroll any split/panel that displays `buffer_id` so the given
1667    /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
1668    /// does not require a split id — it walks all splits (including
1669    /// inner panels of a buffer group) and updates every viewport that
1670    /// shows this buffer. Line is 0-indexed.
1671    ScrollBufferToLine {
1672        /// Buffer ID to scroll
1673        buffer_id: BufferId,
1674        /// Line number to bring into view (0-indexed)
1675        line: usize,
1676    },
1677
1678    /// Set the global editor mode (for modal editing like vi mode)
1679    /// When set, the mode's keybindings take precedence over normal editing
1680    SetEditorMode {
1681        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1682        mode: Option<String>,
1683    },
1684
1685    /// Show an action popup with buttons for user interaction
1686    /// When the user selects an action, the ActionPopupResult hook is fired
1687    ShowActionPopup {
1688        /// Unique identifier for the popup (used in ActionPopupResult)
1689        popup_id: String,
1690        /// Title text for the popup
1691        title: String,
1692        /// Body message (supports basic formatting)
1693        message: String,
1694        /// Action buttons to display
1695        actions: Vec<ActionPopupAction>,
1696    },
1697
1698    /// Disable LSP for a specific language and persist to config
1699    DisableLspForLanguage {
1700        /// The language to disable LSP for (e.g., "python", "rust")
1701        language: String,
1702    },
1703
1704    /// Restart LSP server for a specific language
1705    RestartLspForLanguage {
1706        /// The language to restart LSP for (e.g., "python", "rust")
1707        language: String,
1708    },
1709
1710    /// Set the workspace root URI for a specific language's LSP server
1711    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1712    /// If the LSP is already running, it will be restarted with the new root
1713    SetLspRootUri {
1714        /// The language to set root URI for (e.g., "csharp", "rust")
1715        language: String,
1716        /// The root URI (file:// URL format)
1717        uri: String,
1718    },
1719
1720    /// Create a scroll sync group for anchor-based synchronized scrolling
1721    /// Used for side-by-side diff views where two panes need to scroll together
1722    /// The plugin provides the group ID (must be unique per plugin)
1723    CreateScrollSyncGroup {
1724        /// Plugin-assigned group ID
1725        group_id: u32,
1726        /// The left (primary) split - scroll position is tracked in this split's line space
1727        left_split: SplitId,
1728        /// The right (secondary) split - position is derived from anchors
1729        right_split: SplitId,
1730    },
1731
1732    /// Set sync anchors for a scroll sync group
1733    /// Anchors map corresponding line numbers between left and right buffers
1734    SetScrollSyncAnchors {
1735        /// The group ID returned by CreateScrollSyncGroup
1736        group_id: u32,
1737        /// List of (left_line, right_line) pairs marking corresponding positions
1738        anchors: Vec<(usize, usize)>,
1739    },
1740
1741    /// Remove a scroll sync group
1742    RemoveScrollSyncGroup {
1743        /// The group ID returned by CreateScrollSyncGroup
1744        group_id: u32,
1745    },
1746
1747    /// Save a buffer to a specific file path
1748    /// Used by :w filename command to save unnamed buffers or save-as
1749    SaveBufferToPath {
1750        /// Buffer ID to save
1751        buffer_id: BufferId,
1752        /// Path to save to
1753        path: PathBuf,
1754    },
1755
1756    /// Load a plugin from a file path
1757    /// The plugin will be initialized and start receiving events
1758    LoadPlugin {
1759        /// Path to the plugin file (.ts or .js)
1760        path: PathBuf,
1761        /// Callback ID for async response (success/failure)
1762        callback_id: JsCallbackId,
1763    },
1764
1765    /// Unload a plugin by name
1766    /// The plugin will stop receiving events and be removed from memory
1767    UnloadPlugin {
1768        /// Plugin name (as registered)
1769        name: String,
1770        /// Callback ID for async response (success/failure)
1771        callback_id: JsCallbackId,
1772    },
1773
1774    /// Reload a plugin by name (unload + load)
1775    /// Useful for development when plugin code changes
1776    ReloadPlugin {
1777        /// Plugin name (as registered)
1778        name: String,
1779        /// Callback ID for async response (success/failure)
1780        callback_id: JsCallbackId,
1781    },
1782
1783    /// List all loaded plugins
1784    /// Returns plugin info (name, path, enabled) for all loaded plugins
1785    ListPlugins {
1786        /// Callback ID for async response (JSON array of plugin info)
1787        callback_id: JsCallbackId,
1788    },
1789
1790    /// Reload the theme registry from disk
1791    /// Call this after installing a theme package or saving a new theme.
1792    /// If `apply_theme` is set, apply that theme immediately after reloading.
1793    ReloadThemes { apply_theme: Option<String> },
1794
1795    /// Register a TextMate grammar file for a language
1796    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1797    RegisterGrammar {
1798        /// Language identifier (e.g., "elixir", "zig")
1799        language: String,
1800        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
1801        grammar_path: String,
1802        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
1803        extensions: Vec<String>,
1804    },
1805
1806    /// Register language configuration (comment prefix, indentation, formatter)
1807    /// This is applied immediately to the runtime config
1808    RegisterLanguageConfig {
1809        /// Language identifier (e.g., "elixir")
1810        language: String,
1811        /// Language configuration
1812        config: LanguagePackConfig,
1813    },
1814
1815    /// Register an LSP server for a language
1816    /// This is applied immediately to the LSP manager and runtime config
1817    RegisterLspServer {
1818        /// Language identifier (e.g., "elixir")
1819        language: String,
1820        /// LSP server configuration
1821        config: LspServerPackConfig,
1822    },
1823
1824    /// Reload the grammar registry to apply registered grammars (async)
1825    /// Call this after registering one or more grammars to rebuild the syntax set.
1826    /// The callback is resolved when the background grammar build completes.
1827    ReloadGrammars { callback_id: JsCallbackId },
1828
1829    // ==================== Terminal Commands ====================
1830    /// Create a new terminal in a split (async, returns TerminalResult)
1831    /// This spawns a PTY-backed terminal that plugins can write to and read from.
1832    CreateTerminal {
1833        /// Working directory for the terminal (defaults to editor cwd)
1834        cwd: Option<String>,
1835        /// Split direction ("horizontal" or "vertical"), default vertical
1836        direction: Option<String>,
1837        /// Split ratio (0.0 to 1.0), default 0.5
1838        ratio: Option<f32>,
1839        /// Whether to focus the new terminal split (default true)
1840        focus: Option<bool>,
1841        /// Callback ID for async response
1842        request_id: u64,
1843    },
1844
1845    /// Send input data to a terminal by its terminal ID
1846    SendTerminalInput {
1847        /// The terminal ID (from TerminalResult)
1848        terminal_id: TerminalId,
1849        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
1850        data: String,
1851    },
1852
1853    /// Close a terminal by its terminal ID
1854    CloseTerminal {
1855        /// The terminal ID to close
1856        terminal_id: TerminalId,
1857    },
1858
1859    /// Project-wide grep search (async)
1860    /// Searches all project files via FileSystem trait, respecting .gitignore.
1861    /// For open buffers with dirty edits, searches the buffer's piece tree.
1862    GrepProject {
1863        /// Search pattern (literal string)
1864        pattern: String,
1865        /// Whether the pattern is a fixed string (true) or regex (false)
1866        fixed_string: bool,
1867        /// Whether the search is case-sensitive
1868        case_sensitive: bool,
1869        /// Maximum number of results to return
1870        max_results: usize,
1871        /// Whether to match whole words only
1872        whole_words: bool,
1873        /// Callback ID for async response
1874        callback_id: JsCallbackId,
1875    },
1876
1877    /// Project-wide streaming grep search (async, parallel)
1878    /// Like GrepProject but streams results incrementally via progress callback.
1879    /// Searches files in parallel using tokio tasks, sending per-file results
1880    /// back to the plugin as they complete.
1881    GrepProjectStreaming {
1882        /// Search pattern
1883        pattern: String,
1884        /// Whether the pattern is a fixed string (true) or regex (false)
1885        fixed_string: bool,
1886        /// Whether the search is case-sensitive
1887        case_sensitive: bool,
1888        /// Maximum number of results to return
1889        max_results: usize,
1890        /// Whether to match whole words only
1891        whole_words: bool,
1892        /// Search ID — used to route progress callbacks and for cancellation
1893        search_id: u64,
1894        /// Callback ID for the completion promise
1895        callback_id: JsCallbackId,
1896    },
1897
1898    /// Replace matches in a buffer (async)
1899    /// Opens the file if not already open, applies edits through the buffer model,
1900    /// groups as a single undo action, and saves via FileSystem trait.
1901    ReplaceInBuffer {
1902        /// File path to edit (will open if not already in a buffer)
1903        file_path: PathBuf,
1904        /// Matches to replace, each is (byte_offset, length)
1905        matches: Vec<(usize, usize)>,
1906        /// Replacement text
1907        replacement: String,
1908        /// Callback ID for async response
1909        callback_id: JsCallbackId,
1910    },
1911}
1912
1913impl PluginCommand {
1914    /// Extract the enum variant name from the Debug representation.
1915    pub fn debug_variant_name(&self) -> String {
1916        let dbg = format!("{:?}", self);
1917        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
1918    }
1919}
1920
1921// =============================================================================
1922// Language Pack Configuration Types
1923// =============================================================================
1924
1925/// Language configuration for language packs
1926///
1927/// This is a simplified version of the full LanguageConfig, containing only
1928/// the fields that can be set via the plugin API.
1929#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
1930#[serde(rename_all = "camelCase")]
1931#[ts(export)]
1932pub struct LanguagePackConfig {
1933    /// Comment prefix for line comments (e.g., "//" or "#")
1934    #[serde(default)]
1935    pub comment_prefix: Option<String>,
1936
1937    /// Block comment start marker (e.g., slash-star)
1938    #[serde(default)]
1939    pub block_comment_start: Option<String>,
1940
1941    /// Block comment end marker (e.g., star-slash)
1942    #[serde(default)]
1943    pub block_comment_end: Option<String>,
1944
1945    /// Whether to use tabs instead of spaces for indentation
1946    #[serde(default)]
1947    pub use_tabs: Option<bool>,
1948
1949    /// Tab size (number of spaces per tab level)
1950    #[serde(default)]
1951    pub tab_size: Option<usize>,
1952
1953    /// Whether auto-indent is enabled
1954    #[serde(default)]
1955    pub auto_indent: Option<bool>,
1956
1957    /// Whether to show whitespace tab indicators (→) for this language
1958    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
1959    #[serde(default)]
1960    pub show_whitespace_tabs: Option<bool>,
1961
1962    /// Formatter configuration
1963    #[serde(default)]
1964    pub formatter: Option<FormatterPackConfig>,
1965}
1966
1967/// Formatter configuration for language packs
1968#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1969#[serde(rename_all = "camelCase")]
1970#[ts(export)]
1971pub struct FormatterPackConfig {
1972    /// Command to run (e.g., "prettier", "rustfmt")
1973    pub command: String,
1974
1975    /// Arguments to pass to the formatter
1976    #[serde(default)]
1977    pub args: Vec<String>,
1978}
1979
1980/// Process resource limits for LSP servers
1981#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1982#[serde(rename_all = "camelCase")]
1983#[ts(export)]
1984pub struct ProcessLimitsPackConfig {
1985    /// Maximum memory usage as percentage of total system memory (null = no limit)
1986    #[serde(default)]
1987    pub max_memory_percent: Option<u32>,
1988
1989    /// Maximum CPU usage as percentage of total CPU (null = no limit)
1990    #[serde(default)]
1991    pub max_cpu_percent: Option<u32>,
1992
1993    /// Enable resource limiting
1994    #[serde(default)]
1995    pub enabled: Option<bool>,
1996}
1997
1998/// LSP server configuration for language packs
1999#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2000#[serde(rename_all = "camelCase")]
2001#[ts(export)]
2002pub struct LspServerPackConfig {
2003    /// Command to start the LSP server
2004    pub command: String,
2005
2006    /// Arguments to pass to the command
2007    #[serde(default)]
2008    pub args: Vec<String>,
2009
2010    /// Whether to auto-start the server when a matching file is opened
2011    #[serde(default)]
2012    pub auto_start: Option<bool>,
2013
2014    /// LSP initialization options
2015    #[serde(default)]
2016    #[ts(type = "Record<string, unknown> | null")]
2017    pub initialization_options: Option<JsonValue>,
2018
2019    /// Process resource limits (memory and CPU)
2020    #[serde(default)]
2021    pub process_limits: Option<ProcessLimitsPackConfig>,
2022}
2023
2024/// Hunk status for Review Diff
2025#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
2026#[ts(export)]
2027pub enum HunkStatus {
2028    Pending,
2029    Staged,
2030    Discarded,
2031}
2032
2033/// A high-level hunk directive for the Review Diff tool
2034#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2035#[ts(export)]
2036pub struct ReviewHunk {
2037    pub id: String,
2038    pub file: String,
2039    pub context_header: String,
2040    pub status: HunkStatus,
2041    /// 0-indexed line range in the base (HEAD) version
2042    pub base_range: Option<(usize, usize)>,
2043    /// 0-indexed line range in the modified (Working) version
2044    pub modified_range: Option<(usize, usize)>,
2045}
2046
2047/// Action button for action popups
2048#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2049#[serde(deny_unknown_fields)]
2050#[ts(export, rename = "TsActionPopupAction")]
2051pub struct ActionPopupAction {
2052    /// Unique action identifier (returned in ActionPopupResult)
2053    pub id: String,
2054    /// Display text for the button (can include command hints)
2055    pub label: String,
2056}
2057
2058/// Options for showActionPopup
2059#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2060#[serde(deny_unknown_fields)]
2061#[ts(export)]
2062pub struct ActionPopupOptions {
2063    /// Unique identifier for the popup (used in ActionPopupResult)
2064    pub id: String,
2065    /// Title text for the popup
2066    pub title: String,
2067    /// Body message (supports basic formatting)
2068    pub message: String,
2069    /// Action buttons to display
2070    pub actions: Vec<ActionPopupAction>,
2071}
2072
2073/// Syntax highlight span for a buffer range
2074#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2075#[ts(export)]
2076pub struct TsHighlightSpan {
2077    pub start: u32,
2078    pub end: u32,
2079    #[ts(type = "[number, number, number]")]
2080    pub color: (u8, u8, u8),
2081    pub bold: bool,
2082    pub italic: bool,
2083}
2084
2085/// Result from spawning a process with spawnProcess
2086#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2087#[ts(export)]
2088pub struct SpawnResult {
2089    /// Complete stdout as string
2090    pub stdout: String,
2091    /// Complete stderr as string
2092    pub stderr: String,
2093    /// Process exit code (0 usually means success, -1 if killed)
2094    pub exit_code: i32,
2095}
2096
2097/// Result from spawning a background process
2098#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2099#[ts(export)]
2100pub struct BackgroundProcessResult {
2101    /// Unique process ID for later reference
2102    #[ts(type = "number")]
2103    pub process_id: u64,
2104    /// Process exit code (0 usually means success, -1 if killed)
2105    /// Only present when the process has exited
2106    pub exit_code: i32,
2107}
2108
2109/// A single match from project-wide grep
2110#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2111#[serde(rename_all = "camelCase")]
2112#[ts(export, rename_all = "camelCase")]
2113pub struct GrepMatch {
2114    /// Absolute file path
2115    pub file: String,
2116    /// Buffer ID if the file is open (0 if not)
2117    #[ts(type = "number")]
2118    pub buffer_id: usize,
2119    /// Byte offset of match start in the file/buffer content
2120    #[ts(type = "number")]
2121    pub byte_offset: usize,
2122    /// Match length in bytes
2123    #[ts(type = "number")]
2124    pub length: usize,
2125    /// 1-indexed line number
2126    #[ts(type = "number")]
2127    pub line: usize,
2128    /// 1-indexed column number
2129    #[ts(type = "number")]
2130    pub column: usize,
2131    /// The matched line content (for display)
2132    pub context: String,
2133}
2134
2135/// Result from replacing matches in a buffer
2136#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2137#[serde(rename_all = "camelCase")]
2138#[ts(export, rename_all = "camelCase")]
2139pub struct ReplaceResult {
2140    /// Number of replacements made
2141    #[ts(type = "number")]
2142    pub replacements: usize,
2143    /// Buffer ID of the edited buffer
2144    #[ts(type = "number")]
2145    pub buffer_id: usize,
2146}
2147
2148/// Entry for virtual buffer content with optional text properties (JS API version)
2149#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2150#[serde(deny_unknown_fields, rename_all = "camelCase")]
2151#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
2152pub struct JsTextPropertyEntry {
2153    /// Text content for this entry
2154    pub text: String,
2155    /// Optional properties attached to this text (e.g., file path, line number)
2156    #[serde(default)]
2157    #[ts(optional, type = "Record<string, unknown>")]
2158    pub properties: Option<HashMap<String, JsonValue>>,
2159    /// Optional whole-entry styling
2160    #[serde(default)]
2161    #[ts(optional, type = "Partial<OverlayOptions>")]
2162    pub style: Option<OverlayOptions>,
2163    /// Optional sub-range styling within this entry
2164    #[serde(default)]
2165    #[ts(optional)]
2166    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
2167}
2168
2169/// Directory entry returned by readDir
2170#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2171#[ts(export)]
2172pub struct DirEntry {
2173    /// File/directory name
2174    pub name: String,
2175    /// True if this is a file
2176    pub is_file: bool,
2177    /// True if this is a directory
2178    pub is_dir: bool,
2179}
2180
2181/// Position in a document (line and character)
2182#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2183#[ts(export)]
2184pub struct JsPosition {
2185    /// Zero-indexed line number
2186    pub line: u32,
2187    /// Zero-indexed character offset
2188    pub character: u32,
2189}
2190
2191/// Range in a document (start and end positions)
2192#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2193#[ts(export)]
2194pub struct JsRange {
2195    /// Start position
2196    pub start: JsPosition,
2197    /// End position
2198    pub end: JsPosition,
2199}
2200
2201/// Diagnostic from LSP
2202#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2203#[ts(export)]
2204pub struct JsDiagnostic {
2205    /// Document URI
2206    pub uri: String,
2207    /// Diagnostic message
2208    pub message: String,
2209    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
2210    pub severity: Option<u8>,
2211    /// Range in the document
2212    pub range: JsRange,
2213    /// Source of the diagnostic (e.g., "typescript", "eslint")
2214    #[ts(optional)]
2215    pub source: Option<String>,
2216}
2217
2218/// Options for createVirtualBuffer
2219#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2220#[serde(deny_unknown_fields)]
2221#[ts(export)]
2222pub struct CreateVirtualBufferOptions {
2223    /// Buffer name (displayed in tabs/title)
2224    pub name: String,
2225    /// Mode for keybindings (e.g., "git-log", "search-results")
2226    #[serde(default)]
2227    #[ts(optional)]
2228    pub mode: Option<String>,
2229    /// Whether buffer is read-only (default: false)
2230    #[serde(default, rename = "readOnly")]
2231    #[ts(optional, rename = "readOnly")]
2232    pub read_only: Option<bool>,
2233    /// Show line numbers in gutter (default: false)
2234    #[serde(default, rename = "showLineNumbers")]
2235    #[ts(optional, rename = "showLineNumbers")]
2236    pub show_line_numbers: Option<bool>,
2237    /// Show cursor (default: true)
2238    #[serde(default, rename = "showCursors")]
2239    #[ts(optional, rename = "showCursors")]
2240    pub show_cursors: Option<bool>,
2241    /// Disable text editing (default: false)
2242    #[serde(default, rename = "editingDisabled")]
2243    #[ts(optional, rename = "editingDisabled")]
2244    pub editing_disabled: Option<bool>,
2245    /// Hide from tab bar (default: false)
2246    #[serde(default, rename = "hiddenFromTabs")]
2247    #[ts(optional, rename = "hiddenFromTabs")]
2248    pub hidden_from_tabs: Option<bool>,
2249    /// Initial content entries with optional properties
2250    #[serde(default)]
2251    #[ts(optional)]
2252    pub entries: Option<Vec<JsTextPropertyEntry>>,
2253}
2254
2255/// Options for createVirtualBufferInSplit
2256#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2257#[serde(deny_unknown_fields)]
2258#[ts(export)]
2259pub struct CreateVirtualBufferInSplitOptions {
2260    /// Buffer name (displayed in tabs/title)
2261    pub name: String,
2262    /// Mode for keybindings (e.g., "git-log", "search-results")
2263    #[serde(default)]
2264    #[ts(optional)]
2265    pub mode: Option<String>,
2266    /// Whether buffer is read-only (default: false)
2267    #[serde(default, rename = "readOnly")]
2268    #[ts(optional, rename = "readOnly")]
2269    pub read_only: Option<bool>,
2270    /// Split ratio 0.0-1.0 (default: 0.5)
2271    #[serde(default)]
2272    #[ts(optional)]
2273    pub ratio: Option<f32>,
2274    /// Split direction: "horizontal" or "vertical"
2275    #[serde(default)]
2276    #[ts(optional)]
2277    pub direction: Option<String>,
2278    /// Panel ID to split from
2279    #[serde(default, rename = "panelId")]
2280    #[ts(optional, rename = "panelId")]
2281    pub panel_id: Option<String>,
2282    /// Show line numbers in gutter (default: true)
2283    #[serde(default, rename = "showLineNumbers")]
2284    #[ts(optional, rename = "showLineNumbers")]
2285    pub show_line_numbers: Option<bool>,
2286    /// Show cursor (default: true)
2287    #[serde(default, rename = "showCursors")]
2288    #[ts(optional, rename = "showCursors")]
2289    pub show_cursors: Option<bool>,
2290    /// Disable text editing (default: false)
2291    #[serde(default, rename = "editingDisabled")]
2292    #[ts(optional, rename = "editingDisabled")]
2293    pub editing_disabled: Option<bool>,
2294    /// Enable line wrapping
2295    #[serde(default, rename = "lineWrap")]
2296    #[ts(optional, rename = "lineWrap")]
2297    pub line_wrap: Option<bool>,
2298    /// Place the new buffer before (left/top of) the existing content (default: false)
2299    #[serde(default)]
2300    #[ts(optional)]
2301    pub before: Option<bool>,
2302    /// Initial content entries with optional properties
2303    #[serde(default)]
2304    #[ts(optional)]
2305    pub entries: Option<Vec<JsTextPropertyEntry>>,
2306}
2307
2308/// Options for createVirtualBufferInExistingSplit
2309#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2310#[serde(deny_unknown_fields)]
2311#[ts(export)]
2312pub struct CreateVirtualBufferInExistingSplitOptions {
2313    /// Buffer name (displayed in tabs/title)
2314    pub name: String,
2315    /// Target split ID (required)
2316    #[serde(rename = "splitId")]
2317    #[ts(rename = "splitId")]
2318    pub split_id: usize,
2319    /// Mode for keybindings (e.g., "git-log", "search-results")
2320    #[serde(default)]
2321    #[ts(optional)]
2322    pub mode: Option<String>,
2323    /// Whether buffer is read-only (default: false)
2324    #[serde(default, rename = "readOnly")]
2325    #[ts(optional, rename = "readOnly")]
2326    pub read_only: Option<bool>,
2327    /// Show line numbers in gutter (default: true)
2328    #[serde(default, rename = "showLineNumbers")]
2329    #[ts(optional, rename = "showLineNumbers")]
2330    pub show_line_numbers: Option<bool>,
2331    /// Show cursor (default: true)
2332    #[serde(default, rename = "showCursors")]
2333    #[ts(optional, rename = "showCursors")]
2334    pub show_cursors: Option<bool>,
2335    /// Disable text editing (default: false)
2336    #[serde(default, rename = "editingDisabled")]
2337    #[ts(optional, rename = "editingDisabled")]
2338    pub editing_disabled: Option<bool>,
2339    /// Enable line wrapping
2340    #[serde(default, rename = "lineWrap")]
2341    #[ts(optional, rename = "lineWrap")]
2342    pub line_wrap: Option<bool>,
2343    /// Initial content entries with optional properties
2344    #[serde(default)]
2345    #[ts(optional)]
2346    pub entries: Option<Vec<JsTextPropertyEntry>>,
2347}
2348
2349/// Options for createTerminal
2350#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2351#[serde(deny_unknown_fields)]
2352#[ts(export)]
2353pub struct CreateTerminalOptions {
2354    /// Working directory for the terminal (defaults to editor cwd)
2355    #[serde(default)]
2356    #[ts(optional)]
2357    pub cwd: Option<String>,
2358    /// Split direction: "horizontal" or "vertical" (default: "vertical")
2359    #[serde(default)]
2360    #[ts(optional)]
2361    pub direction: Option<String>,
2362    /// Split ratio 0.0-1.0 (default: 0.5)
2363    #[serde(default)]
2364    #[ts(optional)]
2365    pub ratio: Option<f32>,
2366    /// Whether to focus the new terminal split (default: true)
2367    #[serde(default)]
2368    #[ts(optional)]
2369    pub focus: Option<bool>,
2370}
2371
2372/// Result of getTextPropertiesAtCursor - array of property objects
2373///
2374/// Each element contains the properties from a text property span that overlaps
2375/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2376#[derive(Debug, Clone, Serialize, TS)]
2377#[ts(export, type = "Array<Record<string, unknown>>")]
2378pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2379
2380// Implement FromJs for option types using rquickjs_serde
2381#[cfg(feature = "plugins")]
2382mod fromjs_impls {
2383    use super::*;
2384    use rquickjs::{Ctx, FromJs, Value};
2385
2386    impl<'js> FromJs<'js> for JsTextPropertyEntry {
2387        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2388            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2389                from: "object",
2390                to: "JsTextPropertyEntry",
2391                message: Some(e.to_string()),
2392            })
2393        }
2394    }
2395
2396    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
2397        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2398            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2399                from: "object",
2400                to: "CreateVirtualBufferOptions",
2401                message: Some(e.to_string()),
2402            })
2403        }
2404    }
2405
2406    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
2407        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2408            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2409                from: "object",
2410                to: "CreateVirtualBufferInSplitOptions",
2411                message: Some(e.to_string()),
2412            })
2413        }
2414    }
2415
2416    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
2417        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2418            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2419                from: "object",
2420                to: "CreateVirtualBufferInExistingSplitOptions",
2421                message: Some(e.to_string()),
2422            })
2423        }
2424    }
2425
2426    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2427        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2428            rquickjs_serde::to_value(ctx.clone(), &self.0)
2429                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2430        }
2431    }
2432
2433    // === Additional input types for type-safe plugin API ===
2434
2435    impl<'js> FromJs<'js> for ActionSpec {
2436        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2437            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2438                from: "object",
2439                to: "ActionSpec",
2440                message: Some(e.to_string()),
2441            })
2442        }
2443    }
2444
2445    impl<'js> FromJs<'js> for ActionPopupAction {
2446        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2447            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2448                from: "object",
2449                to: "ActionPopupAction",
2450                message: Some(e.to_string()),
2451            })
2452        }
2453    }
2454
2455    impl<'js> FromJs<'js> for ActionPopupOptions {
2456        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2457            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2458                from: "object",
2459                to: "ActionPopupOptions",
2460                message: Some(e.to_string()),
2461            })
2462        }
2463    }
2464
2465    impl<'js> FromJs<'js> for ViewTokenWire {
2466        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2467            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2468                from: "object",
2469                to: "ViewTokenWire",
2470                message: Some(e.to_string()),
2471            })
2472        }
2473    }
2474
2475    impl<'js> FromJs<'js> for ViewTokenStyle {
2476        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2477            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2478                from: "object",
2479                to: "ViewTokenStyle",
2480                message: Some(e.to_string()),
2481            })
2482        }
2483    }
2484
2485    impl<'js> FromJs<'js> for LayoutHints {
2486        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2487            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2488                from: "object",
2489                to: "LayoutHints",
2490                message: Some(e.to_string()),
2491            })
2492        }
2493    }
2494
2495    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2496        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2497            // Use two-step deserialization for complex nested structures
2498            let json: serde_json::Value =
2499                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2500                    from: "object",
2501                    to: "CreateCompositeBufferOptions (json)",
2502                    message: Some(e.to_string()),
2503                })?;
2504            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2505                from: "json",
2506                to: "CreateCompositeBufferOptions",
2507                message: Some(e.to_string()),
2508            })
2509        }
2510    }
2511
2512    impl<'js> FromJs<'js> for CompositeHunk {
2513        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2514            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2515                from: "object",
2516                to: "CompositeHunk",
2517                message: Some(e.to_string()),
2518            })
2519        }
2520    }
2521
2522    impl<'js> FromJs<'js> for LanguagePackConfig {
2523        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2524            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2525                from: "object",
2526                to: "LanguagePackConfig",
2527                message: Some(e.to_string()),
2528            })
2529        }
2530    }
2531
2532    impl<'js> FromJs<'js> for LspServerPackConfig {
2533        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2534            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2535                from: "object",
2536                to: "LspServerPackConfig",
2537                message: Some(e.to_string()),
2538            })
2539        }
2540    }
2541
2542    impl<'js> FromJs<'js> for ProcessLimitsPackConfig {
2543        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2544            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2545                from: "object",
2546                to: "ProcessLimitsPackConfig",
2547                message: Some(e.to_string()),
2548            })
2549        }
2550    }
2551
2552    impl<'js> FromJs<'js> for CreateTerminalOptions {
2553        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2554            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2555                from: "object",
2556                to: "CreateTerminalOptions",
2557                message: Some(e.to_string()),
2558            })
2559        }
2560    }
2561
2562    // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
2563    //
2564    // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
2565    // that replaces the body with `Ok(Default::default())` drops the
2566    // decoded payload on the floor. Every test below asserts a
2567    // non-defaultable field value, so the mutant cannot pass.
2568    //
2569    // Note: many of the target structs do not implement `Default`, making
2570    // those mutants unviable (they fail to compile) — cargo-mutants still
2571    // lists them as candidates. The tests below serve double-duty as
2572    // behavioural regression protection for the JS → Rust conversion layer.
2573    #[cfg(test)]
2574    mod tests {
2575        use super::*;
2576        use rquickjs::{Context, Runtime};
2577
2578        /// Run a closure within a fresh QuickJS context so that `FromJs`
2579        /// impls can be exercised end-to-end.
2580        fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
2581            let rt = Runtime::new().expect("create rquickjs runtime");
2582            let ctx = Context::full(&rt).expect("create rquickjs context");
2583            ctx.with(f)
2584        }
2585
2586        /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
2587        fn eval_as<T>(src: &str) -> T
2588        where
2589            for<'js> T: rquickjs::FromJs<'js>,
2590        {
2591            with_js(|ctx| {
2592                let value: Value = ctx
2593                    .eval::<Value, _>(src.as_bytes())
2594                    .expect("eval JS source");
2595                T::from_js(&ctx, value).expect("from_js decode")
2596            })
2597        }
2598
2599        #[test]
2600        fn js_text_property_entry_decodes_text_and_properties() {
2601            let got: JsTextPropertyEntry =
2602                eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
2603            assert_eq!(got.text, "hello");
2604            let props = got.properties.expect("properties present");
2605            assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
2606        }
2607
2608        #[test]
2609        fn create_virtual_buffer_options_decodes_name() {
2610            let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
2611            assert_eq!(got.name, "logs");
2612            assert_eq!(got.read_only, Some(true));
2613        }
2614
2615        #[test]
2616        fn create_virtual_buffer_in_split_options_decodes_ratio() {
2617            let got: CreateVirtualBufferInSplitOptions =
2618                eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
2619            assert_eq!(got.name, "diag");
2620            assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
2621            assert_eq!(got.direction.as_deref(), Some("horizontal"));
2622        }
2623
2624        #[test]
2625        fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
2626            let got: CreateVirtualBufferInExistingSplitOptions =
2627                eval_as("({name: 'n', splitId: 7})");
2628            assert_eq!(got.name, "n");
2629            assert_eq!(got.split_id, 7);
2630        }
2631
2632        #[test]
2633        fn create_terminal_options_decodes_cwd_and_focus() {
2634            let got: CreateTerminalOptions =
2635                eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
2636            assert_eq!(got.cwd.as_deref(), Some("/tmp"));
2637            assert_eq!(got.direction.as_deref(), Some("vertical"));
2638            assert_eq!(got.focus, Some(false));
2639        }
2640
2641        #[test]
2642        fn action_spec_decodes_action_and_count() {
2643            let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
2644            assert_eq!(got.action, "move_word_right");
2645            assert_eq!(got.count, 5);
2646        }
2647
2648        #[test]
2649        fn action_popup_action_decodes_id_and_label() {
2650            let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
2651            assert_eq!(got.id, "ok");
2652            assert_eq!(got.label, "OK");
2653        }
2654
2655        #[test]
2656        fn action_popup_options_decodes_actions_list() {
2657            let got: ActionPopupOptions = eval_as(
2658                "({id: 'p', title: 't', message: 'm', \
2659                   actions: [{id: 'ok', label: 'OK'}]})",
2660            );
2661            assert_eq!(got.id, "p");
2662            assert_eq!(got.title, "t");
2663            assert_eq!(got.message, "m");
2664            assert_eq!(got.actions.len(), 1);
2665            assert_eq!(got.actions[0].id, "ok");
2666        }
2667
2668        #[test]
2669        fn view_token_wire_decodes_offset_and_kind() {
2670            // Using `Newline` (a unit variant) avoids the tuple-variant
2671            // wire-format ambiguity in rquickjs_serde while still exercising
2672            // the `FromJs` impl end-to-end.
2673            let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
2674            assert_eq!(got.source_offset, Some(42));
2675            assert!(matches!(got.kind, ViewTokenWireKind::Newline));
2676        }
2677
2678        #[test]
2679        fn view_token_style_decodes_boolean_flags() {
2680            // `fg`/`bg` are `Option<(u8, u8, u8)>` which rquickjs_serde does
2681            // not decode from plain JS arrays, so we pin down the boolean
2682            // flags — enough to prove the body actually ran.
2683            let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
2684            assert!(got.bold);
2685            assert!(got.italic);
2686            assert!(got.fg.is_none());
2687        }
2688
2689        #[test]
2690        fn layout_hints_decodes_compose_width() {
2691            let got: LayoutHints = eval_as("({composeWidth: 120})");
2692            assert_eq!(got.compose_width, Some(120));
2693            assert!(got.column_guides.is_none());
2694        }
2695
2696        #[test]
2697        fn create_composite_buffer_options_decodes_name_and_sources() {
2698            let got: CreateCompositeBufferOptions = eval_as(
2699                "({name: 'diff', mode: 'm', \
2700                   layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
2701                   sources: [{bufferId: 3, label: 'OLD'}]})",
2702            );
2703            assert_eq!(got.name, "diff");
2704            assert_eq!(got.layout.layout_type, "side-by-side");
2705            assert_eq!(got.sources.len(), 1);
2706            assert_eq!(got.sources[0].buffer_id, 3);
2707            assert_eq!(got.sources[0].label, "OLD");
2708        }
2709
2710        #[test]
2711        fn composite_hunk_decodes_all_fields() {
2712            let got: CompositeHunk =
2713                eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
2714            assert_eq!(got.old_start, 1);
2715            assert_eq!(got.old_count, 2);
2716            assert_eq!(got.new_start, 3);
2717            assert_eq!(got.new_count, 4);
2718        }
2719
2720        #[test]
2721        fn language_pack_config_decodes_comment_prefix_and_tab_size() {
2722            let got: LanguagePackConfig =
2723                eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
2724            assert_eq!(got.comment_prefix.as_deref(), Some("//"));
2725            assert_eq!(got.tab_size, Some(7));
2726            assert_eq!(got.use_tabs, Some(true));
2727        }
2728
2729        #[test]
2730        fn lsp_server_pack_config_decodes_command_and_args() {
2731            let got: LspServerPackConfig =
2732                eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
2733            assert_eq!(got.command, "rust-analyzer");
2734            assert_eq!(got.args, vec!["--log".to_string()]);
2735            assert_eq!(got.auto_start, Some(true));
2736        }
2737
2738        #[test]
2739        fn process_limits_pack_config_decodes_percentages() {
2740            let got: ProcessLimitsPackConfig =
2741                eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
2742            assert_eq!(got.max_memory_percent, Some(75));
2743            assert_eq!(got.max_cpu_percent, Some(50));
2744            assert_eq!(got.enabled, Some(true));
2745        }
2746
2747        /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
2748        /// into a JS array whose length matches the payload. A mutant that
2749        /// returns a default (`undefined` / empty) value would fail either
2750        /// the array check or the length check.
2751        #[test]
2752        fn text_properties_at_cursor_into_js_preserves_length() {
2753            use rquickjs::IntoJs;
2754            with_js(|ctx| {
2755                let mut entry = std::collections::HashMap::new();
2756                entry.insert("k".to_string(), serde_json::json!("v"));
2757                let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
2758
2759                let v = payload.into_js(&ctx).expect("into_js");
2760                let arr = v.as_array().expect("expected JS array");
2761                assert_eq!(arr.len(), 2);
2762            });
2763        }
2764    }
2765}
2766
2767/// Plugin API context - provides safe access to editor functionality
2768pub struct PluginApi {
2769    /// Hook registry (shared with editor)
2770    hooks: Arc<RwLock<HookRegistry>>,
2771
2772    /// Command registry (shared with editor)
2773    commands: Arc<RwLock<CommandRegistry>>,
2774
2775    /// Command queue for sending commands to editor
2776    command_sender: std::sync::mpsc::Sender<PluginCommand>,
2777
2778    /// Snapshot of editor state (read-only for plugins)
2779    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2780}
2781
2782impl PluginApi {
2783    /// Create a new plugin API context
2784    pub fn new(
2785        hooks: Arc<RwLock<HookRegistry>>,
2786        commands: Arc<RwLock<CommandRegistry>>,
2787        command_sender: std::sync::mpsc::Sender<PluginCommand>,
2788        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2789    ) -> Self {
2790        Self {
2791            hooks,
2792            commands,
2793            command_sender,
2794            state_snapshot,
2795        }
2796    }
2797
2798    /// Register a hook callback
2799    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
2800        let mut hooks = self.hooks.write().unwrap();
2801        hooks.add_hook(hook_name, callback);
2802    }
2803
2804    /// Remove all hooks for a specific name
2805    pub fn unregister_hooks(&self, hook_name: &str) {
2806        let mut hooks = self.hooks.write().unwrap();
2807        hooks.remove_hooks(hook_name);
2808    }
2809
2810    /// Register a command
2811    pub fn register_command(&self, command: Command) {
2812        let commands = self.commands.read().unwrap();
2813        commands.register(command);
2814    }
2815
2816    /// Unregister a command by name
2817    pub fn unregister_command(&self, name: &str) {
2818        let commands = self.commands.read().unwrap();
2819        commands.unregister(name);
2820    }
2821
2822    /// Send a command to the editor (async/non-blocking)
2823    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
2824        self.command_sender
2825            .send(command)
2826            .map_err(|e| format!("Failed to send command: {}", e))
2827    }
2828
2829    /// Insert text at a position in a buffer
2830    pub fn insert_text(
2831        &self,
2832        buffer_id: BufferId,
2833        position: usize,
2834        text: String,
2835    ) -> Result<(), String> {
2836        self.send_command(PluginCommand::InsertText {
2837            buffer_id,
2838            position,
2839            text,
2840        })
2841    }
2842
2843    /// Delete a range of text from a buffer
2844    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
2845        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
2846    }
2847
2848    /// Add an overlay (decoration) to a buffer
2849    /// Add an overlay to a buffer with styling options
2850    ///
2851    /// Returns an opaque handle that can be used to remove the overlay later.
2852    ///
2853    /// Colors can be specified as RGB arrays or theme key strings.
2854    /// Theme keys are resolved at render time, so overlays update with theme changes.
2855    pub fn add_overlay(
2856        &self,
2857        buffer_id: BufferId,
2858        namespace: Option<String>,
2859        range: Range<usize>,
2860        options: OverlayOptions,
2861    ) -> Result<(), String> {
2862        self.send_command(PluginCommand::AddOverlay {
2863            buffer_id,
2864            namespace: namespace.map(OverlayNamespace::from_string),
2865            range,
2866            options,
2867        })
2868    }
2869
2870    /// Remove an overlay from a buffer by its handle
2871    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
2872        self.send_command(PluginCommand::RemoveOverlay {
2873            buffer_id,
2874            handle: OverlayHandle::from_string(handle),
2875        })
2876    }
2877
2878    /// Clear all overlays in a namespace from a buffer
2879    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
2880        self.send_command(PluginCommand::ClearNamespace {
2881            buffer_id,
2882            namespace: OverlayNamespace::from_string(namespace),
2883        })
2884    }
2885
2886    /// Clear all overlays that overlap with a byte range
2887    /// Used for targeted invalidation when content changes
2888    pub fn clear_overlays_in_range(
2889        &self,
2890        buffer_id: BufferId,
2891        start: usize,
2892        end: usize,
2893    ) -> Result<(), String> {
2894        self.send_command(PluginCommand::ClearOverlaysInRange {
2895            buffer_id,
2896            start,
2897            end,
2898        })
2899    }
2900
2901    /// Set the status message
2902    pub fn set_status(&self, message: String) -> Result<(), String> {
2903        self.send_command(PluginCommand::SetStatus { message })
2904    }
2905
2906    /// Open a file at a specific line and column (1-indexed)
2907    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
2908    pub fn open_file_at_location(
2909        &self,
2910        path: PathBuf,
2911        line: Option<usize>,
2912        column: Option<usize>,
2913    ) -> Result<(), String> {
2914        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
2915    }
2916
2917    /// Open a file in a specific split at a line and column
2918    ///
2919    /// Similar to open_file_at_location but targets a specific split pane.
2920    /// The split_id is the ID of the split pane to open the file in.
2921    pub fn open_file_in_split(
2922        &self,
2923        split_id: usize,
2924        path: PathBuf,
2925        line: Option<usize>,
2926        column: Option<usize>,
2927    ) -> Result<(), String> {
2928        self.send_command(PluginCommand::OpenFileInSplit {
2929            split_id,
2930            path,
2931            line,
2932            column,
2933        })
2934    }
2935
2936    /// Start a prompt (minibuffer) with a custom type identifier
2937    /// The prompt_type is used to filter hooks in plugin code
2938    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
2939        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
2940    }
2941
2942    /// Set the suggestions for the current prompt
2943    /// This updates the prompt's autocomplete/selection list
2944    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
2945        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
2946    }
2947
2948    /// Enable/disable syncing prompt input text when navigating suggestions
2949    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
2950        self.send_command(PluginCommand::SetPromptInputSync { sync })
2951    }
2952
2953    /// Add a menu item to an existing menu
2954    pub fn add_menu_item(
2955        &self,
2956        menu_label: String,
2957        item: MenuItem,
2958        position: MenuPosition,
2959    ) -> Result<(), String> {
2960        self.send_command(PluginCommand::AddMenuItem {
2961            menu_label,
2962            item,
2963            position,
2964        })
2965    }
2966
2967    /// Add a new top-level menu
2968    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
2969        self.send_command(PluginCommand::AddMenu { menu, position })
2970    }
2971
2972    /// Remove a menu item from a menu
2973    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
2974        self.send_command(PluginCommand::RemoveMenuItem {
2975            menu_label,
2976            item_label,
2977        })
2978    }
2979
2980    /// Remove a top-level menu
2981    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
2982        self.send_command(PluginCommand::RemoveMenu { menu_label })
2983    }
2984
2985    // === Virtual Buffer Methods ===
2986
2987    /// Create a new virtual buffer (not backed by a file)
2988    ///
2989    /// Virtual buffers are used for special displays like diagnostic lists,
2990    /// search results, etc. They have their own mode for keybindings.
2991    pub fn create_virtual_buffer(
2992        &self,
2993        name: String,
2994        mode: String,
2995        read_only: bool,
2996    ) -> Result<(), String> {
2997        self.send_command(PluginCommand::CreateVirtualBuffer {
2998            name,
2999            mode,
3000            read_only,
3001        })
3002    }
3003
3004    /// Create a virtual buffer and set its content in one operation
3005    ///
3006    /// This is the preferred way to create virtual buffers since it doesn't
3007    /// require tracking the buffer ID. The buffer is created and populated
3008    /// atomically.
3009    pub fn create_virtual_buffer_with_content(
3010        &self,
3011        name: String,
3012        mode: String,
3013        read_only: bool,
3014        entries: Vec<TextPropertyEntry>,
3015    ) -> Result<(), String> {
3016        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
3017            name,
3018            mode,
3019            read_only,
3020            entries,
3021            show_line_numbers: true,
3022            show_cursors: true,
3023            editing_disabled: false,
3024            hidden_from_tabs: false,
3025            request_id: None,
3026        })
3027    }
3028
3029    /// Set the content of a virtual buffer with text properties
3030    ///
3031    /// Each entry contains text and metadata properties (e.g., source location).
3032    pub fn set_virtual_buffer_content(
3033        &self,
3034        buffer_id: BufferId,
3035        entries: Vec<TextPropertyEntry>,
3036    ) -> Result<(), String> {
3037        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
3038    }
3039
3040    /// Get text properties at cursor position in a buffer
3041    ///
3042    /// This triggers a command that will make properties available to plugins.
3043    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
3044        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
3045    }
3046
3047    /// Define a buffer mode with keybindings
3048    ///
3049    /// Bindings are specified as (key_string, command_name) pairs.
3050    pub fn define_mode(
3051        &self,
3052        name: String,
3053        bindings: Vec<(String, String)>,
3054        read_only: bool,
3055        allow_text_input: bool,
3056    ) -> Result<(), String> {
3057        self.send_command(PluginCommand::DefineMode {
3058            name,
3059            bindings,
3060            read_only,
3061            allow_text_input,
3062            inherit_normal_bindings: false,
3063            plugin_name: None,
3064        })
3065    }
3066
3067    /// Switch the current split to display a buffer
3068    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
3069        self.send_command(PluginCommand::ShowBuffer { buffer_id })
3070    }
3071
3072    /// Set the scroll position of a specific split
3073    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
3074        self.send_command(PluginCommand::SetSplitScroll {
3075            split_id: SplitId(split_id),
3076            top_byte,
3077        })
3078    }
3079
3080    /// Request syntax highlights for a buffer range
3081    pub fn get_highlights(
3082        &self,
3083        buffer_id: BufferId,
3084        range: Range<usize>,
3085        request_id: u64,
3086    ) -> Result<(), String> {
3087        self.send_command(PluginCommand::RequestHighlights {
3088            buffer_id,
3089            range,
3090            request_id,
3091        })
3092    }
3093
3094    // === Query Methods ===
3095
3096    /// Get the currently active buffer ID
3097    pub fn get_active_buffer_id(&self) -> BufferId {
3098        let snapshot = self.state_snapshot.read().unwrap();
3099        snapshot.active_buffer_id
3100    }
3101
3102    /// Get the currently active split ID
3103    pub fn get_active_split_id(&self) -> usize {
3104        let snapshot = self.state_snapshot.read().unwrap();
3105        snapshot.active_split_id
3106    }
3107
3108    /// Get information about a specific buffer
3109    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
3110        let snapshot = self.state_snapshot.read().unwrap();
3111        snapshot.buffers.get(&buffer_id).cloned()
3112    }
3113
3114    /// Get all buffer IDs
3115    pub fn list_buffers(&self) -> Vec<BufferInfo> {
3116        let snapshot = self.state_snapshot.read().unwrap();
3117        snapshot.buffers.values().cloned().collect()
3118    }
3119
3120    /// Get primary cursor information for the active buffer
3121    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
3122        let snapshot = self.state_snapshot.read().unwrap();
3123        snapshot.primary_cursor.clone()
3124    }
3125
3126    /// Get all cursor information for the active buffer
3127    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
3128        let snapshot = self.state_snapshot.read().unwrap();
3129        snapshot.all_cursors.clone()
3130    }
3131
3132    /// Get viewport information for the active buffer
3133    pub fn get_viewport(&self) -> Option<ViewportInfo> {
3134        let snapshot = self.state_snapshot.read().unwrap();
3135        snapshot.viewport.clone()
3136    }
3137
3138    /// Get access to the state snapshot Arc (for internal use)
3139    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
3140        Arc::clone(&self.state_snapshot)
3141    }
3142}
3143
3144impl Clone for PluginApi {
3145    fn clone(&self) -> Self {
3146        Self {
3147            hooks: Arc::clone(&self.hooks),
3148            commands: Arc::clone(&self.commands),
3149            command_sender: self.command_sender.clone(),
3150            state_snapshot: Arc::clone(&self.state_snapshot),
3151        }
3152    }
3153}
3154
3155// ============================================================================
3156// Pluggable Completion Service — TypeScript Plugin API Types
3157// ============================================================================
3158//
3159// These types are the bridge between the Rust `CompletionService` and
3160// TypeScript plugins that want to provide completion candidates.  They are
3161// serialised to/from JSON via serde and generate TypeScript definitions via
3162// ts-rs so that the plugin API stays in sync automatically.
3163
3164/// A completion candidate produced by a TypeScript plugin provider.
3165///
3166/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
3167/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
3168#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3169#[serde(rename_all = "camelCase", deny_unknown_fields)]
3170#[ts(export, rename_all = "camelCase")]
3171pub struct TsCompletionCandidate {
3172    /// Display text shown in the completion popup.
3173    pub label: String,
3174
3175    /// Text to insert when accepted. Falls back to `label` if omitted.
3176    #[serde(skip_serializing_if = "Option::is_none")]
3177    pub insert_text: Option<String>,
3178
3179    /// Short detail string shown next to the label.
3180    #[serde(skip_serializing_if = "Option::is_none")]
3181    pub detail: Option<String>,
3182
3183    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
3184    #[serde(skip_serializing_if = "Option::is_none")]
3185    pub icon: Option<String>,
3186
3187    /// Provider-assigned relevance score (higher = better).
3188    #[serde(default)]
3189    pub score: i64,
3190
3191    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
3192    #[serde(default)]
3193    pub is_snippet: bool,
3194
3195    /// Opaque data carried through to the `completionAccepted` hook.
3196    #[serde(skip_serializing_if = "Option::is_none")]
3197    pub provider_data: Option<String>,
3198}
3199
3200/// Context sent to a TypeScript plugin's `provideCompletions` handler.
3201///
3202/// Plugins receive this as a read-only snapshot so they never need direct
3203/// buffer access (which would be unsafe for huge files).
3204#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3205#[serde(rename_all = "camelCase")]
3206#[ts(export, rename_all = "camelCase")]
3207pub struct TsCompletionContext {
3208    /// The word prefix typed so far.
3209    pub prefix: String,
3210
3211    /// Byte offset of the cursor.
3212    pub cursor_byte: usize,
3213
3214    /// Byte offset of the word start (for replacement range).
3215    pub word_start_byte: usize,
3216
3217    /// Total buffer size in bytes.
3218    pub buffer_len: usize,
3219
3220    /// Whether the buffer is a lazily-loaded huge file.
3221    pub is_large_file: bool,
3222
3223    /// A text excerpt around the cursor (the contents of the safe scan window).
3224    /// Plugins should search only this string, not request the full buffer.
3225    pub text_around_cursor: String,
3226
3227    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
3228    pub cursor_offset_in_text: usize,
3229
3230    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
3231    #[serde(skip_serializing_if = "Option::is_none")]
3232    pub language_id: Option<String>,
3233}
3234
3235/// Registration payload sent by a plugin to register a completion provider.
3236#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3237#[serde(rename_all = "camelCase", deny_unknown_fields)]
3238#[ts(export, rename_all = "camelCase")]
3239pub struct TsCompletionProviderRegistration {
3240    /// Unique id for this provider (e.g., `"my-snippets"`).
3241    pub id: String,
3242
3243    /// Human-readable name shown in status/debug UI.
3244    pub display_name: String,
3245
3246    /// Priority tier (lower = higher priority). Convention:
3247    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
3248    #[serde(default = "default_plugin_provider_priority")]
3249    pub priority: u32,
3250
3251    /// Optional list of language ids this provider is active for.
3252    /// If empty/omitted, the provider is active for all languages.
3253    #[serde(default)]
3254    pub language_ids: Vec<String>,
3255}
3256
3257fn default_plugin_provider_priority() -> u32 {
3258    50
3259}
3260
3261#[cfg(test)]
3262mod tests {
3263    use super::*;
3264
3265    #[test]
3266    fn test_plugin_api_creation() {
3267        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3268        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3269        let (tx, _rx) = std::sync::mpsc::channel();
3270        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3271
3272        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3273
3274        // Should not panic
3275        let _clone = api.clone();
3276    }
3277
3278    #[test]
3279    fn test_register_hook() {
3280        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3281        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3282        let (tx, _rx) = std::sync::mpsc::channel();
3283        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3284
3285        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
3286
3287        api.register_hook("test-hook", Box::new(|_| true));
3288
3289        let hook_registry = hooks.read().unwrap();
3290        assert_eq!(hook_registry.hook_count("test-hook"), 1);
3291    }
3292
3293    #[test]
3294    fn test_send_command() {
3295        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3296        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3297        let (tx, rx) = std::sync::mpsc::channel();
3298        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3299
3300        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3301
3302        let result = api.insert_text(BufferId(1), 0, "test".to_string());
3303        assert!(result.is_ok());
3304
3305        // Verify command was sent
3306        let received = rx.try_recv();
3307        assert!(received.is_ok());
3308
3309        match received.unwrap() {
3310            PluginCommand::InsertText {
3311                buffer_id,
3312                position,
3313                text,
3314            } => {
3315                assert_eq!(buffer_id.0, 1);
3316                assert_eq!(position, 0);
3317                assert_eq!(text, "test");
3318            }
3319            _ => panic!("Wrong command type"),
3320        }
3321    }
3322
3323    #[test]
3324    fn test_add_overlay_command() {
3325        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3326        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3327        let (tx, rx) = std::sync::mpsc::channel();
3328        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3329
3330        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3331
3332        let result = api.add_overlay(
3333            BufferId(1),
3334            Some("test-overlay".to_string()),
3335            0..10,
3336            OverlayOptions {
3337                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
3338                bg: None,
3339                underline: true,
3340                bold: false,
3341                italic: false,
3342                strikethrough: false,
3343                extend_to_line_end: false,
3344                url: None,
3345            },
3346        );
3347        assert!(result.is_ok());
3348
3349        let received = rx.try_recv().unwrap();
3350        match received {
3351            PluginCommand::AddOverlay {
3352                buffer_id,
3353                namespace,
3354                range,
3355                options,
3356            } => {
3357                assert_eq!(buffer_id.0, 1);
3358                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
3359                assert_eq!(range, 0..10);
3360                assert!(matches!(
3361                    options.fg,
3362                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
3363                ));
3364                assert!(options.bg.is_none());
3365                assert!(options.underline);
3366                assert!(!options.bold);
3367                assert!(!options.italic);
3368                assert!(!options.extend_to_line_end);
3369            }
3370            _ => panic!("Wrong command type"),
3371        }
3372    }
3373
3374    #[test]
3375    fn test_set_status_command() {
3376        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3377        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3378        let (tx, rx) = std::sync::mpsc::channel();
3379        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3380
3381        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3382
3383        let result = api.set_status("Test status".to_string());
3384        assert!(result.is_ok());
3385
3386        let received = rx.try_recv().unwrap();
3387        match received {
3388            PluginCommand::SetStatus { message } => {
3389                assert_eq!(message, "Test status");
3390            }
3391            _ => panic!("Wrong command type"),
3392        }
3393    }
3394
3395    #[test]
3396    fn test_get_active_buffer_id() {
3397        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3398        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3399        let (tx, _rx) = std::sync::mpsc::channel();
3400        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3401
3402        // Set active buffer to 5
3403        {
3404            let mut snapshot = state_snapshot.write().unwrap();
3405            snapshot.active_buffer_id = BufferId(5);
3406        }
3407
3408        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3409
3410        let active_id = api.get_active_buffer_id();
3411        assert_eq!(active_id.0, 5);
3412    }
3413
3414    #[test]
3415    fn test_get_buffer_info() {
3416        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3417        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3418        let (tx, _rx) = std::sync::mpsc::channel();
3419        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3420
3421        // Add buffer info
3422        {
3423            let mut snapshot = state_snapshot.write().unwrap();
3424            let buffer_info = BufferInfo {
3425                id: BufferId(1),
3426                path: Some(std::path::PathBuf::from("/test/file.txt")),
3427                modified: true,
3428                length: 100,
3429                is_virtual: false,
3430                view_mode: "source".to_string(),
3431                is_composing_in_any_split: false,
3432                compose_width: None,
3433                language: "text".to_string(),
3434                is_preview: false,
3435            };
3436            snapshot.buffers.insert(BufferId(1), buffer_info);
3437        }
3438
3439        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3440
3441        let info = api.get_buffer_info(BufferId(1));
3442        assert!(info.is_some());
3443        let info = info.unwrap();
3444        assert_eq!(info.id.0, 1);
3445        assert_eq!(
3446            info.path.as_ref().unwrap().to_str().unwrap(),
3447            "/test/file.txt"
3448        );
3449        assert!(info.modified);
3450        assert_eq!(info.length, 100);
3451
3452        // Non-existent buffer
3453        let no_info = api.get_buffer_info(BufferId(999));
3454        assert!(no_info.is_none());
3455    }
3456
3457    #[test]
3458    fn test_list_buffers() {
3459        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3460        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3461        let (tx, _rx) = std::sync::mpsc::channel();
3462        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3463
3464        // Add multiple buffers
3465        {
3466            let mut snapshot = state_snapshot.write().unwrap();
3467            snapshot.buffers.insert(
3468                BufferId(1),
3469                BufferInfo {
3470                    id: BufferId(1),
3471                    path: Some(std::path::PathBuf::from("/file1.txt")),
3472                    modified: false,
3473                    length: 50,
3474                    is_virtual: false,
3475                    view_mode: "source".to_string(),
3476                    is_composing_in_any_split: false,
3477                    compose_width: None,
3478                    language: "text".to_string(),
3479                    is_preview: false,
3480                },
3481            );
3482            snapshot.buffers.insert(
3483                BufferId(2),
3484                BufferInfo {
3485                    id: BufferId(2),
3486                    path: Some(std::path::PathBuf::from("/file2.txt")),
3487                    modified: true,
3488                    length: 100,
3489                    is_virtual: false,
3490                    view_mode: "source".to_string(),
3491                    is_composing_in_any_split: false,
3492                    compose_width: None,
3493                    language: "text".to_string(),
3494                    is_preview: false,
3495                },
3496            );
3497            snapshot.buffers.insert(
3498                BufferId(3),
3499                BufferInfo {
3500                    id: BufferId(3),
3501                    path: None,
3502                    modified: false,
3503                    length: 0,
3504                    is_virtual: true,
3505                    view_mode: "source".to_string(),
3506                    is_composing_in_any_split: false,
3507                    compose_width: None,
3508                    language: "text".to_string(),
3509                    is_preview: false,
3510                },
3511            );
3512        }
3513
3514        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3515
3516        let buffers = api.list_buffers();
3517        assert_eq!(buffers.len(), 3);
3518
3519        // Verify all buffers are present
3520        assert!(buffers.iter().any(|b| b.id.0 == 1));
3521        assert!(buffers.iter().any(|b| b.id.0 == 2));
3522        assert!(buffers.iter().any(|b| b.id.0 == 3));
3523    }
3524
3525    #[test]
3526    fn test_get_primary_cursor() {
3527        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3528        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3529        let (tx, _rx) = std::sync::mpsc::channel();
3530        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3531
3532        // Add cursor info
3533        {
3534            let mut snapshot = state_snapshot.write().unwrap();
3535            snapshot.primary_cursor = Some(CursorInfo {
3536                position: 42,
3537                selection: Some(10..42),
3538            });
3539        }
3540
3541        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3542
3543        let cursor = api.get_primary_cursor();
3544        assert!(cursor.is_some());
3545        let cursor = cursor.unwrap();
3546        assert_eq!(cursor.position, 42);
3547        assert_eq!(cursor.selection, Some(10..42));
3548    }
3549
3550    #[test]
3551    fn test_get_all_cursors() {
3552        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3553        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3554        let (tx, _rx) = std::sync::mpsc::channel();
3555        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3556
3557        // Add multiple cursors
3558        {
3559            let mut snapshot = state_snapshot.write().unwrap();
3560            snapshot.all_cursors = vec![
3561                CursorInfo {
3562                    position: 10,
3563                    selection: None,
3564                },
3565                CursorInfo {
3566                    position: 20,
3567                    selection: Some(15..20),
3568                },
3569                CursorInfo {
3570                    position: 30,
3571                    selection: Some(25..30),
3572                },
3573            ];
3574        }
3575
3576        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3577
3578        let cursors = api.get_all_cursors();
3579        assert_eq!(cursors.len(), 3);
3580        assert_eq!(cursors[0].position, 10);
3581        assert_eq!(cursors[0].selection, None);
3582        assert_eq!(cursors[1].position, 20);
3583        assert_eq!(cursors[1].selection, Some(15..20));
3584        assert_eq!(cursors[2].position, 30);
3585        assert_eq!(cursors[2].selection, Some(25..30));
3586    }
3587
3588    #[test]
3589    fn test_get_viewport() {
3590        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3591        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3592        let (tx, _rx) = std::sync::mpsc::channel();
3593        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3594
3595        // Add viewport info
3596        {
3597            let mut snapshot = state_snapshot.write().unwrap();
3598            snapshot.viewport = Some(ViewportInfo {
3599                top_byte: 100,
3600                top_line: Some(5),
3601                left_column: 5,
3602                width: 80,
3603                height: 24,
3604            });
3605        }
3606
3607        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3608
3609        let viewport = api.get_viewport();
3610        assert!(viewport.is_some());
3611        let viewport = viewport.unwrap();
3612        assert_eq!(viewport.top_byte, 100);
3613        assert_eq!(viewport.left_column, 5);
3614        assert_eq!(viewport.width, 80);
3615        assert_eq!(viewport.height, 24);
3616    }
3617
3618    #[test]
3619    fn test_composite_buffer_options_rejects_unknown_fields() {
3620        // Valid JSON with correct field names
3621        let valid_json = r#"{
3622            "name": "test",
3623            "mode": "diff",
3624            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3625            "sources": [{"bufferId": 1, "label": "old"}]
3626        }"#;
3627        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
3628        assert!(
3629            result.is_ok(),
3630            "Valid JSON should parse: {:?}",
3631            result.err()
3632        );
3633
3634        // Invalid JSON with unknown field (buffer_id instead of bufferId)
3635        let invalid_json = r#"{
3636            "name": "test",
3637            "mode": "diff",
3638            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3639            "sources": [{"buffer_id": 1, "label": "old"}]
3640        }"#;
3641        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
3642        assert!(
3643            result.is_err(),
3644            "JSON with unknown field should fail to parse"
3645        );
3646        let err = result.unwrap_err().to_string();
3647        assert!(
3648            err.contains("unknown field") || err.contains("buffer_id"),
3649            "Error should mention unknown field: {}",
3650            err
3651        );
3652    }
3653
3654    #[test]
3655    fn test_composite_hunk_rejects_unknown_fields() {
3656        // Valid JSON with correct field names
3657        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3658        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3659        assert!(
3660            result.is_ok(),
3661            "Valid JSON should parse: {:?}",
3662            result.err()
3663        );
3664
3665        // Invalid JSON with unknown field (old_start instead of oldStart)
3666        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3667        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3668        assert!(
3669            result.is_err(),
3670            "JSON with unknown field should fail to parse"
3671        );
3672        let err = result.unwrap_err().to_string();
3673        assert!(
3674            err.contains("unknown field") || err.contains("old_start"),
3675            "Error should mention unknown field: {}",
3676            err
3677        );
3678    }
3679
3680    #[test]
3681    fn test_plugin_response_line_end_position() {
3682        let response = PluginResponse::LineEndPosition {
3683            request_id: 42,
3684            position: Some(100),
3685        };
3686        let json = serde_json::to_string(&response).unwrap();
3687        assert!(json.contains("LineEndPosition"));
3688        assert!(json.contains("42"));
3689        assert!(json.contains("100"));
3690
3691        // Test None case
3692        let response_none = PluginResponse::LineEndPosition {
3693            request_id: 1,
3694            position: None,
3695        };
3696        let json_none = serde_json::to_string(&response_none).unwrap();
3697        assert!(json_none.contains("null"));
3698    }
3699
3700    #[test]
3701    fn test_plugin_response_buffer_line_count() {
3702        let response = PluginResponse::BufferLineCount {
3703            request_id: 99,
3704            count: Some(500),
3705        };
3706        let json = serde_json::to_string(&response).unwrap();
3707        assert!(json.contains("BufferLineCount"));
3708        assert!(json.contains("99"));
3709        assert!(json.contains("500"));
3710    }
3711
3712    #[test]
3713    fn test_plugin_command_get_line_end_position() {
3714        let command = PluginCommand::GetLineEndPosition {
3715            buffer_id: BufferId(1),
3716            line: 10,
3717            request_id: 123,
3718        };
3719        let json = serde_json::to_string(&command).unwrap();
3720        assert!(json.contains("GetLineEndPosition"));
3721        assert!(json.contains("10"));
3722    }
3723
3724    #[test]
3725    fn test_plugin_command_get_buffer_line_count() {
3726        let command = PluginCommand::GetBufferLineCount {
3727            buffer_id: BufferId(0),
3728            request_id: 456,
3729        };
3730        let json = serde_json::to_string(&command).unwrap();
3731        assert!(json.contains("GetBufferLineCount"));
3732        assert!(json.contains("456"));
3733    }
3734
3735    #[test]
3736    fn test_plugin_command_scroll_to_line_center() {
3737        let command = PluginCommand::ScrollToLineCenter {
3738            split_id: SplitId(1),
3739            buffer_id: BufferId(2),
3740            line: 50,
3741        };
3742        let json = serde_json::to_string(&command).unwrap();
3743        assert!(json.contains("ScrollToLineCenter"));
3744        assert!(json.contains("50"));
3745    }
3746
3747    /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
3748    /// and renders as its underlying integer via `Display`.
3749    #[test]
3750    fn js_callback_id_conversions_and_display() {
3751        for raw in [0u64, 1, 42, u64::MAX] {
3752            let id = JsCallbackId::new(raw);
3753            assert_eq!(id.as_u64(), raw);
3754            assert_eq!(u64::from(id), raw);
3755            assert_eq!(JsCallbackId::from(raw), id);
3756            assert_eq!(id.to_string(), raw.to_string());
3757        }
3758    }
3759
3760    /// Serde `default = ...` helpers fire when the field is omitted and are
3761    /// overridden by explicit values. One test per struct pins each helper
3762    /// to its documented default.
3763    #[test]
3764    fn serde_defaults_fire_when_fields_are_omitted() {
3765        // default_action_count → 1
3766        let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
3767        assert_eq!(spec.count, 1);
3768        let spec: ActionSpec =
3769            serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
3770        assert_eq!(spec.count, 5);
3771
3772        // default_true → showSeparator = true
3773        let layout: CompositeLayoutConfig =
3774            serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
3775        assert!(layout.show_separator);
3776        let layout: CompositeLayoutConfig =
3777            serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
3778        assert!(!layout.show_separator);
3779
3780        // default_plugin_provider_priority → 50
3781        let reg: TsCompletionProviderRegistration =
3782            serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
3783        assert_eq!(reg.priority, 50);
3784        let reg: TsCompletionProviderRegistration =
3785            serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
3786        assert_eq!(reg.priority, 3);
3787    }
3788
3789    // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
3790    //
3791    // These tests pin down observable behaviour for tiny methods whose bodies
3792    // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
3793    // default value) without any existing test noticing.
3794
3795    /// Helper: build a minimal `Command` with a given name.
3796    fn mk_cmd(name: &str) -> Command {
3797        Command {
3798            name: name.to_string(),
3799            description: String::new(),
3800            action_name: String::new(),
3801            plugin_name: String::new(),
3802            custom_contexts: Vec::new(),
3803        }
3804    }
3805
3806    /// `CommandRegistry::register` appends new commands and replaces any
3807    /// existing entry with the same name; `unregister` removes exactly the
3808    /// matching entry and is a no-op for unknown names.
3809    ///
3810    /// Kills: replace register with `()`; `!= → ==` in register;
3811    ///        replace unregister with `()`; `!= → ==` in unregister.
3812    #[test]
3813    fn command_registry_register_and_unregister_semantics() {
3814        let r = CommandRegistry::new();
3815
3816        r.register(mk_cmd("a"));
3817        r.register(mk_cmd("b"));
3818        assert_eq!(r.commands.read().unwrap().len(), 2);
3819
3820        // Re-registering "a" must keep "b" (retain filters by `!=`); the
3821        // `== → !=` mutant would drop "b" and leave two copies of "a".
3822        r.register(mk_cmd("a"));
3823        let names: Vec<String> = r
3824            .commands
3825            .read()
3826            .unwrap()
3827            .iter()
3828            .map(|c| c.name.clone())
3829            .collect();
3830        assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
3831
3832        // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
3833        // mutant would keep "a" and drop "b".
3834        r.unregister("a");
3835        let names: Vec<String> = r
3836            .commands
3837            .read()
3838            .unwrap()
3839            .iter()
3840            .map(|c| c.name.clone())
3841            .collect();
3842        assert_eq!(names, vec!["b".to_string()]);
3843
3844        // Unregistering an unknown name is a no-op.
3845        r.unregister("nope");
3846        assert_eq!(r.commands.read().unwrap().len(), 1);
3847    }
3848
3849    /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
3850    /// variant and `None` for the theme-key variant; `as_theme_key` is the
3851    /// dual. Uses a triple with no zero or one components and a theme key
3852    /// that is neither empty nor `"xyzzy"` to kill every constant-return
3853    /// mutant reported by cargo-mutants at once.
3854    #[test]
3855    fn overlay_color_spec_accessors_are_variant_specific() {
3856        let rgb = OverlayColorSpec::rgb(12, 34, 56);
3857        assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
3858        assert_eq!(rgb.as_theme_key(), None);
3859
3860        let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
3861        assert_eq!(tk.as_rgb(), None);
3862        assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
3863    }
3864
3865    /// `PluginCommand::debug_variant_name` returns the actual variant name
3866    /// derived from the `Debug` impl, not an empty or hard-coded string.
3867    #[test]
3868    fn plugin_command_debug_variant_name_returns_real_variant() {
3869        let c = PluginCommand::SetStatus {
3870            message: "hi".into(),
3871        };
3872        assert_eq!(c.debug_variant_name(), "SetStatus");
3873
3874        let c2 = PluginCommand::InsertText {
3875            buffer_id: BufferId(1),
3876            position: 0,
3877            text: String::new(),
3878        };
3879        assert_eq!(c2.debug_variant_name(), "InsertText");
3880    }
3881
3882    // ── PluginApi dispatch / mutation tests ────────────────────────────────
3883    //
3884    // Each `PluginApi` method is a one-liner that either pushes a
3885    // `PluginCommand` onto the channel or mutates a shared registry. The
3886    // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
3887    // disappears. One assertion per method ties the side effect down.
3888
3889    fn mk_api() -> (
3890        PluginApi,
3891        std::sync::mpsc::Receiver<PluginCommand>,
3892        Arc<RwLock<HookRegistry>>,
3893        Arc<RwLock<CommandRegistry>>,
3894        Arc<RwLock<EditorStateSnapshot>>,
3895    ) {
3896        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3897        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3898        let (tx, rx) = std::sync::mpsc::channel();
3899        let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3900        let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
3901        (api, rx, hooks, commands, snap)
3902    }
3903
3904    /// `unregister_hooks` must actually clear hooks registered under the
3905    /// same name; replacing the body with `()` leaves the count at 1.
3906    #[test]
3907    fn plugin_api_unregister_hooks_clears_registry() {
3908        let (api, _rx, hooks, _cmds, _snap) = mk_api();
3909        api.register_hook("h", Box::new(|_| true));
3910        assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
3911        api.unregister_hooks("h");
3912        assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
3913    }
3914
3915    /// `register_command` / `unregister_command` must actually write through
3916    /// to the shared `CommandRegistry`.
3917    #[test]
3918    fn plugin_api_register_and_unregister_command_write_through() {
3919        let (api, _rx, _hooks, cmds, _snap) = mk_api();
3920
3921        api.register_command(mk_cmd("x"));
3922        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
3923
3924        api.unregister_command("x");
3925        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
3926    }
3927
3928    /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
3929    /// exactly one `PluginCommand` matching `$pattern` with the additional
3930    /// invariants in `$guard`.
3931    macro_rules! assert_dispatches {
3932        ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
3933            let (api, rx, _h, _c, _s) = mk_api();
3934            let _ = $call(&api);
3935            match rx.try_recv().expect("no command sent") {
3936                $pattern $(if $guard)? => {}
3937                other => panic!("unexpected command variant: {:?}", other),
3938            }
3939        }};
3940    }
3941
3942    /// Every simple `send_command`-based method on `PluginApi` translates
3943    /// its arguments into the documented `PluginCommand` variant with the
3944    /// expected fields.
3945    #[test]
3946    fn plugin_api_send_command_methods_dispatch_correctly() {
3947        // delete_range
3948        assert_dispatches!(
3949            |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
3950            PluginCommand::DeleteRange { buffer_id, range }
3951                if buffer_id == BufferId(7) && range == (3..9)
3952        );
3953
3954        // remove_overlay
3955        assert_dispatches!(
3956            |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
3957            PluginCommand::RemoveOverlay { buffer_id, handle }
3958                if buffer_id == BufferId(2) && handle.as_str() == "h-1"
3959        );
3960
3961        // clear_namespace
3962        assert_dispatches!(
3963            |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
3964            PluginCommand::ClearNamespace { buffer_id, namespace }
3965                if buffer_id == BufferId(3) && namespace.as_str() == "diag"
3966        );
3967
3968        // clear_overlays_in_range
3969        assert_dispatches!(
3970            |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
3971            PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
3972                if buffer_id == BufferId(4) && start == 10 && end == 20
3973        );
3974
3975        // open_file_at_location
3976        assert_dispatches!(
3977            |a: &PluginApi| a.open_file_at_location(
3978                PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
3979            ),
3980            PluginCommand::OpenFileAtLocation { path, line, column }
3981                if path == PathBuf::from("/tmp/x.rs")
3982                    && line == Some(4)
3983                    && column == Some(8)
3984        );
3985
3986        // open_file_in_split
3987        assert_dispatches!(
3988            |a: &PluginApi| a.open_file_in_split(
3989                2, PathBuf::from("/tmp/y.rs"), Some(5), None
3990            ),
3991            PluginCommand::OpenFileInSplit { split_id, path, line, column }
3992                if split_id == 2
3993                    && path == PathBuf::from("/tmp/y.rs")
3994                    && line == Some(5)
3995                    && column.is_none()
3996        );
3997
3998        // start_prompt
3999        assert_dispatches!(
4000            |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
4001            PluginCommand::StartPrompt { label, prompt_type }
4002                if label == "label" && prompt_type == "cmd"
4003        );
4004
4005        // set_prompt_suggestions
4006        assert_dispatches!(
4007            |a: &PluginApi| a.set_prompt_suggestions(vec![
4008                Suggestion::new("one".into()),
4009                Suggestion::new("two".into()),
4010            ]),
4011            PluginCommand::SetPromptSuggestions { suggestions }
4012                if suggestions.len() == 2
4013                    && suggestions[0].text == "one"
4014                    && suggestions[1].text == "two"
4015        );
4016
4017        // set_prompt_input_sync
4018        assert_dispatches!(
4019            |a: &PluginApi| a.set_prompt_input_sync(true),
4020            PluginCommand::SetPromptInputSync { sync } if sync
4021        );
4022        assert_dispatches!(
4023            |a: &PluginApi| a.set_prompt_input_sync(false),
4024            PluginCommand::SetPromptInputSync { sync } if !sync
4025        );
4026
4027        // add_menu_item
4028        assert_dispatches!(
4029            |a: &PluginApi| a.add_menu_item(
4030                "File".into(),
4031                MenuItem::Label { info: "info".into() },
4032                MenuPosition::Bottom,
4033            ),
4034            PluginCommand::AddMenuItem { menu_label, item, position }
4035                if menu_label == "File"
4036                    && matches!(item, MenuItem::Label { ref info } if info == "info")
4037                    && matches!(position, MenuPosition::Bottom)
4038        );
4039
4040        // add_menu
4041        assert_dispatches!(
4042            |a: &PluginApi| a.add_menu(
4043                Menu {
4044                    id: None,
4045                    label: "Help".into(),
4046                    items: vec![],
4047                    when: None,
4048                },
4049                MenuPosition::After("Edit".into()),
4050            ),
4051            PluginCommand::AddMenu { menu, position }
4052                if menu.label == "Help"
4053                    && matches!(position, MenuPosition::After(ref s) if s == "Edit")
4054        );
4055
4056        // remove_menu_item
4057        assert_dispatches!(
4058            |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
4059            PluginCommand::RemoveMenuItem { menu_label, item_label }
4060                if menu_label == "File" && item_label == "Open"
4061        );
4062
4063        // remove_menu
4064        assert_dispatches!(
4065            |a: &PluginApi| a.remove_menu("File".into()),
4066            PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
4067        );
4068
4069        // create_virtual_buffer
4070        assert_dispatches!(
4071            |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
4072            PluginCommand::CreateVirtualBuffer { name, mode, read_only }
4073                if name == "buf" && mode == "mode" && read_only
4074        );
4075
4076        // create_virtual_buffer_with_content
4077        assert_dispatches!(
4078            |a: &PluginApi| a.create_virtual_buffer_with_content(
4079                "n".into(), "m".into(), false, vec![]
4080            ),
4081            PluginCommand::CreateVirtualBufferWithContent {
4082                name, mode, read_only, show_line_numbers, show_cursors,
4083                editing_disabled, hidden_from_tabs, request_id, ..
4084            }
4085                if name == "n" && mode == "m" && !read_only
4086                    && show_line_numbers && show_cursors
4087                    && !editing_disabled && !hidden_from_tabs
4088                    && request_id.is_none()
4089        );
4090
4091        // set_virtual_buffer_content
4092        assert_dispatches!(
4093            |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
4094            PluginCommand::SetVirtualBufferContent { buffer_id, entries }
4095                if buffer_id == BufferId(9) && entries.is_empty()
4096        );
4097
4098        // get_text_properties_at_cursor
4099        assert_dispatches!(
4100            |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
4101            PluginCommand::GetTextPropertiesAtCursor { buffer_id }
4102                if buffer_id == BufferId(11)
4103        );
4104
4105        // define_mode
4106        assert_dispatches!(
4107            |a: &PluginApi| a.define_mode(
4108                "m".into(),
4109                vec![("j".into(), "move_down".into())],
4110                true,
4111                false,
4112            ),
4113            PluginCommand::DefineMode {
4114                name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
4115            }
4116                if name == "m"
4117                    && bindings.len() == 1
4118                    && bindings[0].0 == "j"
4119                    && bindings[0].1 == "move_down"
4120                    && read_only
4121                    && !allow_text_input
4122                    && !inherit_normal_bindings
4123                    && plugin_name.is_none()
4124        );
4125
4126        // show_buffer
4127        assert_dispatches!(
4128            |a: &PluginApi| a.show_buffer(BufferId(77)),
4129            PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
4130        );
4131
4132        // set_split_scroll
4133        assert_dispatches!(
4134            |a: &PluginApi| a.set_split_scroll(5, 128),
4135            PluginCommand::SetSplitScroll { split_id, top_byte }
4136                if split_id == SplitId(5) && top_byte == 128
4137        );
4138
4139        // get_highlights
4140        assert_dispatches!(
4141            |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
4142            PluginCommand::RequestHighlights { buffer_id, range, request_id }
4143                if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
4144        );
4145    }
4146
4147    /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
4148    /// sentinel value kills both the `0` and `1` constant-return mutants.
4149    #[test]
4150    fn plugin_api_get_active_split_id_reads_snapshot() {
4151        let (api, _rx, _h, _c, snap) = mk_api();
4152        snap.write().unwrap().active_split_id = 42;
4153        assert_eq!(api.get_active_split_id(), 42);
4154    }
4155
4156    /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
4157    /// freshly-defaulted snapshot. A distinguishing field value on the
4158    /// original state proves that the handle sees it.
4159    #[test]
4160    fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
4161        let (api, _rx, _h, _c, snap) = mk_api();
4162        snap.write().unwrap().active_buffer_id = BufferId(42);
4163
4164        let h = api.state_snapshot_handle();
4165        assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
4166        assert!(Arc::ptr_eq(&h, &snap));
4167    }
4168}