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