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 lsp_types;
55use serde::{Deserialize, Serialize};
56use serde_json::Value as JsonValue;
57use std::collections::HashMap;
58use std::ops::Range;
59use std::path::PathBuf;
60use std::sync::{Arc, RwLock};
61use ts_rs::TS;
62
63/// Minimal command registry for PluginApi.
64/// This is a stub that provides basic command storage for plugin use.
65/// The editor's full CommandRegistry lives in fresh-editor.
66pub struct CommandRegistry {
67    commands: std::sync::RwLock<Vec<Command>>,
68}
69
70impl CommandRegistry {
71    /// Create a new empty command registry
72    pub fn new() -> Self {
73        Self {
74            commands: std::sync::RwLock::new(Vec::new()),
75        }
76    }
77
78    /// Register a command
79    pub fn register(&self, command: Command) {
80        let mut commands = self.commands.write().unwrap();
81        commands.retain(|c| c.name != command.name);
82        commands.push(command);
83    }
84
85    /// Unregister a command by name  
86    pub fn unregister(&self, name: &str) {
87        let mut commands = self.commands.write().unwrap();
88        commands.retain(|c| c.name != name);
89    }
90}
91
92impl Default for CommandRegistry {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// A callback ID for JavaScript promises in the plugin runtime.
99///
100/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
101/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
102/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
104#[ts(export)]
105pub struct JsCallbackId(pub u64);
106
107impl JsCallbackId {
108    /// Create a new JS callback ID
109    pub fn new(id: u64) -> Self {
110        Self(id)
111    }
112
113    /// Get the underlying u64 value
114    pub fn as_u64(self) -> u64 {
115        self.0
116    }
117}
118
119impl From<u64> for JsCallbackId {
120    fn from(id: u64) -> Self {
121        Self(id)
122    }
123}
124
125impl From<JsCallbackId> for u64 {
126    fn from(id: JsCallbackId) -> u64 {
127        id.0
128    }
129}
130
131impl std::fmt::Display for JsCallbackId {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{}", self.0)
134    }
135}
136
137/// Result of creating a virtual buffer
138#[derive(Debug, Clone, Serialize, Deserialize, TS)]
139#[serde(rename_all = "camelCase")]
140#[ts(export, rename_all = "camelCase")]
141pub struct VirtualBufferResult {
142    /// The created buffer ID
143    #[ts(type = "number")]
144    pub buffer_id: u64,
145    /// The split ID (if created in a new split)
146    #[ts(type = "number | null")]
147    pub split_id: Option<u64>,
148}
149
150/// Response from the editor for async plugin operations
151#[derive(Debug, Clone, Serialize, Deserialize, TS)]
152#[ts(export)]
153pub enum PluginResponse {
154    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
155    VirtualBufferCreated {
156        request_id: u64,
157        buffer_id: BufferId,
158        split_id: Option<SplitId>,
159    },
160    /// Response to a plugin-initiated LSP request
161    LspRequest {
162        request_id: u64,
163        #[ts(type = "any")]
164        result: Result<JsonValue, String>,
165    },
166    /// Response to RequestHighlights
167    HighlightsComputed {
168        request_id: u64,
169        spans: Vec<TsHighlightSpan>,
170    },
171    /// Response to GetBufferText with the text content
172    BufferText {
173        request_id: u64,
174        text: Result<String, String>,
175    },
176    /// Response to GetLineStartPosition with the byte offset
177    LineStartPosition {
178        request_id: u64,
179        /// None if line is out of range, Some(offset) for valid line
180        position: Option<usize>,
181    },
182    /// Response to CreateCompositeBuffer with the created buffer ID
183    CompositeBufferCreated {
184        request_id: u64,
185        buffer_id: BufferId,
186    },
187}
188
189/// Messages sent from async plugin tasks to the synchronous main loop
190#[derive(Debug, Clone, Serialize, Deserialize, TS)]
191#[ts(export)]
192pub enum PluginAsyncMessage {
193    /// Plugin process completed with output
194    ProcessOutput {
195        /// Unique ID for this process
196        process_id: u64,
197        /// Standard output
198        stdout: String,
199        /// Standard error
200        stderr: String,
201        /// Exit code
202        exit_code: i32,
203    },
204    /// Plugin delay/timer completed
205    DelayComplete {
206        /// Callback ID to resolve
207        callback_id: u64,
208    },
209    /// Background process stdout data
210    ProcessStdout { process_id: u64, data: String },
211    /// Background process stderr data
212    ProcessStderr { process_id: u64, data: String },
213    /// Background process exited
214    ProcessExit {
215        process_id: u64,
216        callback_id: u64,
217        exit_code: i32,
218    },
219    /// Response for a plugin-initiated LSP request
220    LspResponse {
221        language: String,
222        request_id: u64,
223        #[ts(type = "any")]
224        result: Result<JsonValue, String>,
225    },
226    /// Generic plugin response (e.g., GetBufferText result)
227    PluginResponse(crate::api::PluginResponse),
228}
229
230/// Information about a cursor in the editor
231#[derive(Debug, Clone, Serialize, Deserialize, TS)]
232#[ts(export)]
233pub struct CursorInfo {
234    /// Byte position of the cursor
235    pub position: usize,
236    /// Selection range (if any)
237    #[cfg_attr(
238        feature = "plugins",
239        ts(type = "{ start: number; end: number } | null")
240    )]
241    pub selection: Option<Range<usize>>,
242}
243
244/// Specification for an action to execute, with optional repeat count
245#[derive(Debug, Clone, Serialize, Deserialize, TS)]
246#[serde(deny_unknown_fields)]
247#[ts(export)]
248pub struct ActionSpec {
249    /// Action name (e.g., "move_word_right", "delete_line")
250    pub action: String,
251    /// Number of times to repeat the action (default 1)
252    #[serde(default = "default_action_count")]
253    pub count: u32,
254}
255
256fn default_action_count() -> u32 {
257    1
258}
259
260/// Information about a buffer
261#[derive(Debug, Clone, Serialize, Deserialize, TS)]
262#[ts(export)]
263pub struct BufferInfo {
264    /// Buffer ID
265    #[ts(type = "number")]
266    pub id: BufferId,
267    /// File path (if any)
268    #[serde(serialize_with = "serialize_path")]
269    #[ts(type = "string")]
270    pub path: Option<PathBuf>,
271    /// Whether the buffer has been modified
272    pub modified: bool,
273    /// Length of buffer in bytes
274    pub length: usize,
275}
276
277fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
278    s.serialize_str(
279        &path
280            .as_ref()
281            .map(|p| p.to_string_lossy().to_string())
282            .unwrap_or_default(),
283    )
284}
285
286/// Serialize ranges as [start, end] tuples for JS compatibility
287fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
288where
289    S: serde::Serializer,
290{
291    use serde::ser::SerializeSeq;
292    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
293    for range in ranges {
294        seq.serialize_element(&(range.start, range.end))?;
295    }
296    seq.end()
297}
298
299/// Serialize optional ranges as [start, end] tuples for JS compatibility
300fn serialize_opt_ranges_as_tuples<S>(
301    ranges: &Option<Vec<Range<usize>>>,
302    serializer: S,
303) -> Result<S::Ok, S::Error>
304where
305    S: serde::Serializer,
306{
307    match ranges {
308        Some(ranges) => {
309            use serde::ser::SerializeSeq;
310            let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
311            for range in ranges {
312                seq.serialize_element(&(range.start, range.end))?;
313            }
314            seq.end()
315        }
316        None => serializer.serialize_none(),
317    }
318}
319
320/// Diff between current buffer content and last saved snapshot
321#[derive(Debug, Clone, Serialize, Deserialize, TS)]
322#[ts(export)]
323pub struct BufferSavedDiff {
324    pub equal: bool,
325    #[serde(serialize_with = "serialize_ranges_as_tuples")]
326    #[ts(type = "Array<[number, number]>")]
327    pub byte_ranges: Vec<Range<usize>>,
328    #[serde(serialize_with = "serialize_opt_ranges_as_tuples")]
329    #[ts(type = "Array<[number, number]> | null")]
330    pub line_ranges: Option<Vec<Range<usize>>>,
331}
332
333/// Information about the viewport
334#[derive(Debug, Clone, Serialize, Deserialize, TS)]
335#[serde(rename_all = "camelCase")]
336#[ts(export, rename_all = "camelCase")]
337pub struct ViewportInfo {
338    /// Byte position of the first visible line
339    pub top_byte: usize,
340    /// Left column offset (horizontal scroll)
341    pub left_column: usize,
342    /// Viewport width
343    pub width: u16,
344    /// Viewport height
345    pub height: u16,
346}
347
348/// Layout hints supplied by plugins (e.g., Compose mode)
349#[derive(Debug, Clone, Serialize, Deserialize, TS)]
350#[serde(rename_all = "camelCase")]
351#[ts(export, rename_all = "camelCase")]
352pub struct LayoutHints {
353    /// Optional compose width for centering/wrapping
354    pub compose_width: Option<u16>,
355    /// Optional column guides for aligned tables
356    pub column_guides: Option<Vec<u16>>,
357}
358
359// ============================================================================
360// Overlay Types with Theme Support
361// ============================================================================
362
363/// Color specification that can be either RGB values or a theme key.
364///
365/// Theme keys reference colors from the current theme, e.g.:
366/// - "ui.status_bar_bg" - UI status bar background
367/// - "editor.selection_bg" - Editor selection background
368/// - "syntax.keyword" - Syntax highlighting for keywords
369/// - "diagnostic.error" - Error diagnostic color
370///
371/// When a theme key is used, the color is resolved at render time,
372/// so overlays automatically update when the theme changes.
373#[derive(Debug, Clone, Serialize, Deserialize, TS)]
374#[serde(untagged)]
375#[ts(export)]
376pub enum OverlayColorSpec {
377    /// RGB color as [r, g, b] array
378    #[ts(type = "[number, number, number]")]
379    Rgb(u8, u8, u8),
380    /// Theme key reference (e.g., "ui.status_bar_bg")
381    ThemeKey(String),
382}
383
384impl OverlayColorSpec {
385    /// Create an RGB color spec
386    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
387        Self::Rgb(r, g, b)
388    }
389
390    /// Create a theme key color spec
391    pub fn theme_key(key: impl Into<String>) -> Self {
392        Self::ThemeKey(key.into())
393    }
394
395    /// Convert to RGB if this is an RGB spec, None if it's a theme key
396    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
397        match self {
398            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
399            Self::ThemeKey(_) => None,
400        }
401    }
402
403    /// Get the theme key if this is a theme key spec
404    pub fn as_theme_key(&self) -> Option<&str> {
405        match self {
406            Self::ThemeKey(key) => Some(key),
407            Self::Rgb(_, _, _) => None,
408        }
409    }
410}
411
412/// Options for adding an overlay with theme support.
413///
414/// This struct provides a type-safe way to specify overlay styling
415/// with optional theme key references for colors.
416#[derive(Debug, Clone, Serialize, Deserialize, TS)]
417#[serde(deny_unknown_fields, rename_all = "camelCase")]
418#[ts(export, rename_all = "camelCase")]
419pub struct OverlayOptions {
420    /// Foreground color - RGB array or theme key string
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub fg: Option<OverlayColorSpec>,
423
424    /// Background color - RGB array or theme key string
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub bg: Option<OverlayColorSpec>,
427
428    /// Whether to render with underline
429    #[serde(default)]
430    pub underline: bool,
431
432    /// Whether to render in bold
433    #[serde(default)]
434    pub bold: bool,
435
436    /// Whether to render in italic
437    #[serde(default)]
438    pub italic: bool,
439
440    /// Whether to extend background color to end of line
441    #[serde(default)]
442    pub extend_to_line_end: bool,
443}
444
445impl Default for OverlayOptions {
446    fn default() -> Self {
447        Self {
448            fg: None,
449            bg: None,
450            underline: false,
451            bold: false,
452            italic: false,
453            extend_to_line_end: false,
454        }
455    }
456}
457
458// ============================================================================
459// Composite Buffer Configuration (for multi-buffer single-tab views)
460// ============================================================================
461
462/// Layout configuration for composite buffers
463#[derive(Debug, Clone, Serialize, Deserialize, TS)]
464#[serde(deny_unknown_fields)]
465#[ts(export, rename = "TsCompositeLayoutConfig")]
466pub struct CompositeLayoutConfig {
467    /// Layout type: "side-by-side", "stacked", or "unified"
468    #[serde(rename = "type")]
469    #[ts(rename = "type")]
470    pub layout_type: String,
471    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
472    #[serde(default)]
473    pub ratios: Option<Vec<f32>>,
474    /// Show separator between panes
475    #[serde(default = "default_true", rename = "showSeparator")]
476    #[ts(rename = "showSeparator")]
477    pub show_separator: bool,
478    /// Spacing for stacked layout
479    #[serde(default)]
480    pub spacing: Option<u16>,
481}
482
483fn default_true() -> bool {
484    true
485}
486
487/// Source pane configuration for composite buffers
488#[derive(Debug, Clone, Serialize, Deserialize, TS)]
489#[serde(deny_unknown_fields)]
490#[ts(export, rename = "TsCompositeSourceConfig")]
491pub struct CompositeSourceConfig {
492    /// Buffer ID of the source buffer (required)
493    #[serde(rename = "bufferId")]
494    #[ts(rename = "bufferId")]
495    pub buffer_id: usize,
496    /// Label for this pane (e.g., "OLD", "NEW")
497    pub label: String,
498    /// Whether this pane is editable
499    #[serde(default)]
500    pub editable: bool,
501    /// Style configuration
502    #[serde(default)]
503    pub style: Option<CompositePaneStyle>,
504}
505
506/// Style configuration for a composite pane
507#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
508#[serde(deny_unknown_fields)]
509#[ts(export, rename = "TsCompositePaneStyle")]
510pub struct CompositePaneStyle {
511    /// Background color for added lines (RGB)
512    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
513    #[serde(default, rename = "addBg")]
514    #[ts(rename = "addBg", type = "[number, number, number] | null")]
515    pub add_bg: Option<[u8; 3]>,
516    /// Background color for removed lines (RGB)
517    #[serde(default, rename = "removeBg")]
518    #[ts(rename = "removeBg", type = "[number, number, number] | null")]
519    pub remove_bg: Option<[u8; 3]>,
520    /// Background color for modified lines (RGB)
521    #[serde(default, rename = "modifyBg")]
522    #[ts(rename = "modifyBg", type = "[number, number, number] | null")]
523    pub modify_bg: Option<[u8; 3]>,
524    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
525    #[serde(default, rename = "gutterStyle")]
526    #[ts(rename = "gutterStyle")]
527    pub gutter_style: Option<String>,
528}
529
530/// Diff hunk for composite buffer alignment
531#[derive(Debug, Clone, Serialize, Deserialize, TS)]
532#[serde(deny_unknown_fields)]
533#[ts(export, rename = "TsCompositeHunk")]
534pub struct CompositeHunk {
535    /// Starting line in old buffer (0-indexed)
536    #[serde(rename = "oldStart")]
537    #[ts(rename = "oldStart")]
538    pub old_start: usize,
539    /// Number of lines in old buffer
540    #[serde(rename = "oldCount")]
541    #[ts(rename = "oldCount")]
542    pub old_count: usize,
543    /// Starting line in new buffer (0-indexed)
544    #[serde(rename = "newStart")]
545    #[ts(rename = "newStart")]
546    pub new_start: usize,
547    /// Number of lines in new buffer
548    #[serde(rename = "newCount")]
549    #[ts(rename = "newCount")]
550    pub new_count: usize,
551}
552
553/// Options for creating a composite buffer (used by plugin API)
554#[derive(Debug, Clone, Serialize, Deserialize, TS)]
555#[serde(deny_unknown_fields)]
556#[ts(export, rename = "TsCreateCompositeBufferOptions")]
557pub struct CreateCompositeBufferOptions {
558    /// Buffer name (displayed in tabs/title)
559    #[serde(default)]
560    pub name: String,
561    /// Mode for keybindings
562    #[serde(default)]
563    pub mode: String,
564    /// Layout configuration
565    pub layout: CompositeLayoutConfig,
566    /// Source pane configurations
567    pub sources: Vec<CompositeSourceConfig>,
568    /// Diff hunks for alignment (optional)
569    #[serde(default)]
570    pub hunks: Option<Vec<CompositeHunk>>,
571}
572
573/// Wire-format view token kind (serialized for plugin transforms)
574#[derive(Debug, Clone, Serialize, Deserialize, TS)]
575#[ts(export)]
576pub enum ViewTokenWireKind {
577    Text(String),
578    Newline,
579    Space,
580    /// Visual line break inserted by wrapping (not from source)
581    /// Always has source_offset: None
582    Break,
583    /// A single binary byte that should be rendered as <XX>
584    /// Used in binary file mode to ensure cursor positioning works correctly
585    /// (all 4 display chars of <XX> map to the same source byte)
586    BinaryByte(u8),
587}
588
589/// Styling for view tokens (used for injected annotations)
590///
591/// This allows plugins to specify styling for tokens that don't have a source
592/// mapping (sourceOffset: None), such as annotation headers in git blame.
593/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
594#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
595#[serde(deny_unknown_fields)]
596#[ts(export)]
597pub struct ViewTokenStyle {
598    /// Foreground color as RGB tuple
599    #[serde(default)]
600    #[ts(type = "[number, number, number] | null")]
601    pub fg: Option<(u8, u8, u8)>,
602    /// Background color as RGB tuple
603    #[serde(default)]
604    #[ts(type = "[number, number, number] | null")]
605    pub bg: Option<(u8, u8, u8)>,
606    /// Whether to render in bold
607    #[serde(default)]
608    pub bold: bool,
609    /// Whether to render in italic
610    #[serde(default)]
611    pub italic: bool,
612}
613
614/// Wire-format view token with optional source mapping and styling
615#[derive(Debug, Clone, Serialize, Deserialize, TS)]
616#[serde(deny_unknown_fields)]
617#[ts(export)]
618pub struct ViewTokenWire {
619    /// Source byte offset in the buffer. None for injected content (annotations).
620    #[ts(type = "number | null")]
621    pub source_offset: Option<usize>,
622    /// The token content
623    pub kind: ViewTokenWireKind,
624    /// Optional styling for injected content (only used when source_offset is None)
625    #[serde(default)]
626    #[ts(optional)]
627    pub style: Option<ViewTokenStyle>,
628}
629
630/// Transformed view stream payload (plugin-provided)
631#[derive(Debug, Clone, Serialize, Deserialize, TS)]
632#[ts(export)]
633pub struct ViewTransformPayload {
634    /// Byte range this transform applies to (viewport)
635    pub range: Range<usize>,
636    /// Tokens in wire format
637    pub tokens: Vec<ViewTokenWire>,
638    /// Layout hints
639    pub layout_hints: Option<LayoutHints>,
640}
641
642/// Snapshot of editor state for plugin queries
643/// This is updated by the editor on each loop iteration
644#[derive(Debug, Clone, Serialize, Deserialize, TS)]
645#[ts(export)]
646pub struct EditorStateSnapshot {
647    /// Currently active buffer ID
648    pub active_buffer_id: BufferId,
649    /// Currently active split ID
650    pub active_split_id: usize,
651    /// Information about all open buffers
652    pub buffers: HashMap<BufferId, BufferInfo>,
653    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
654    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
655    /// Primary cursor position for the active buffer
656    pub primary_cursor: Option<CursorInfo>,
657    /// All cursor positions for the active buffer
658    pub all_cursors: Vec<CursorInfo>,
659    /// Viewport information for the active buffer
660    pub viewport: Option<ViewportInfo>,
661    /// Cursor positions per buffer (for buffers other than active)
662    pub buffer_cursor_positions: HashMap<BufferId, usize>,
663    /// Text properties per buffer (for virtual buffers with properties)
664    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
665    /// Selected text from the primary cursor (if any selection exists)
666    /// This is populated on each update to avoid needing full buffer access
667    pub selected_text: Option<String>,
668    /// Internal clipboard content (for plugins that need clipboard access)
669    pub clipboard: String,
670    /// Editor's working directory (for file operations and spawning processes)
671    pub working_dir: PathBuf,
672    /// LSP diagnostics per file URI
673    /// Maps file URI string to Vec of diagnostics for that file
674    #[ts(type = "any")]
675    pub diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
676    /// Runtime config as serde_json::Value (merged user config + defaults)
677    /// This is the runtime config, not just the user's config file
678    #[ts(type = "any")]
679    pub config: serde_json::Value,
680    /// User config as serde_json::Value (only what's in the user's config file)
681    /// Fields not present here are using default values
682    #[ts(type = "any")]
683    pub user_config: serde_json::Value,
684    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
685    /// When set, this mode's keybindings take precedence over normal key handling
686    pub editor_mode: Option<String>,
687}
688
689impl EditorStateSnapshot {
690    pub fn new() -> Self {
691        Self {
692            active_buffer_id: BufferId(0),
693            active_split_id: 0,
694            buffers: HashMap::new(),
695            buffer_saved_diffs: HashMap::new(),
696            primary_cursor: None,
697            all_cursors: Vec::new(),
698            viewport: None,
699            buffer_cursor_positions: HashMap::new(),
700            buffer_text_properties: HashMap::new(),
701            selected_text: None,
702            clipboard: String::new(),
703            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
704            diagnostics: HashMap::new(),
705            config: serde_json::Value::Null,
706            user_config: serde_json::Value::Null,
707            editor_mode: None,
708        }
709    }
710}
711
712impl Default for EditorStateSnapshot {
713    fn default() -> Self {
714        Self::new()
715    }
716}
717
718/// Position for inserting menu items or menus
719#[derive(Debug, Clone, Serialize, Deserialize, TS)]
720#[ts(export)]
721pub enum MenuPosition {
722    /// Add at the beginning
723    Top,
724    /// Add at the end
725    Bottom,
726    /// Add before a specific label
727    Before(String),
728    /// Add after a specific label
729    After(String),
730}
731
732/// Plugin command - allows plugins to send commands to the editor
733#[derive(Debug, Clone, Serialize, Deserialize, TS)]
734#[ts(export)]
735pub enum PluginCommand {
736    /// Insert text at a position in a buffer
737    InsertText {
738        buffer_id: BufferId,
739        position: usize,
740        text: String,
741    },
742
743    /// Delete a range of text from a buffer
744    DeleteRange {
745        buffer_id: BufferId,
746        range: Range<usize>,
747    },
748
749    /// Add an overlay to a buffer, returns handle via response channel
750    ///
751    /// Colors can be specified as RGB tuples or theme keys. When theme keys
752    /// are provided, they take precedence and are resolved at render time.
753    AddOverlay {
754        buffer_id: BufferId,
755        namespace: Option<OverlayNamespace>,
756        range: Range<usize>,
757        /// Overlay styling options (colors, modifiers, etc.)
758        options: OverlayOptions,
759    },
760
761    /// Remove an overlay by its opaque handle
762    RemoveOverlay {
763        buffer_id: BufferId,
764        handle: OverlayHandle,
765    },
766
767    /// Set status message
768    SetStatus { message: String },
769
770    /// Apply a theme by name
771    ApplyTheme { theme_name: String },
772
773    /// Reload configuration from file
774    /// After a plugin saves config changes, it should call this to reload the config
775    ReloadConfig,
776
777    /// Register a custom command
778    RegisterCommand { command: Command },
779
780    /// Unregister a command by name
781    UnregisterCommand { name: String },
782
783    /// Open a file in the editor (in background, without switching focus)
784    OpenFileInBackground { path: PathBuf },
785
786    /// Insert text at the current cursor position in the active buffer
787    InsertAtCursor { text: String },
788
789    /// Spawn an async process
790    SpawnProcess {
791        command: String,
792        args: Vec<String>,
793        cwd: Option<String>,
794        callback_id: JsCallbackId,
795    },
796
797    /// Delay/sleep for a duration (async, resolves callback when done)
798    Delay {
799        callback_id: JsCallbackId,
800        duration_ms: u64,
801    },
802
803    /// Spawn a long-running background process
804    /// Unlike SpawnProcess, this returns immediately with a process handle
805    /// and provides streaming output via hooks
806    SpawnBackgroundProcess {
807        /// Unique ID for this process (generated by plugin runtime)
808        process_id: u64,
809        /// Command to execute
810        command: String,
811        /// Arguments to pass
812        args: Vec<String>,
813        /// Working directory (optional)
814        cwd: Option<String>,
815        /// Callback ID to call when process exits
816        callback_id: JsCallbackId,
817    },
818
819    /// Kill a background process by ID
820    KillBackgroundProcess { process_id: u64 },
821
822    /// Wait for a process to complete and get its result
823    /// Used with processes started via SpawnProcess
824    SpawnProcessWait {
825        /// Process ID to wait for
826        process_id: u64,
827        /// Callback ID for async response
828        callback_id: JsCallbackId,
829    },
830
831    /// Set layout hints for a buffer/viewport
832    SetLayoutHints {
833        buffer_id: BufferId,
834        split_id: Option<SplitId>,
835        range: Range<usize>,
836        hints: LayoutHints,
837    },
838
839    /// Enable/disable line numbers for a buffer
840    SetLineNumbers { buffer_id: BufferId, enabled: bool },
841
842    /// Enable/disable line wrapping for a buffer
843    SetLineWrap {
844        buffer_id: BufferId,
845        split_id: Option<SplitId>,
846        enabled: bool,
847    },
848
849    /// Submit a transformed view stream for a viewport
850    SubmitViewTransform {
851        buffer_id: BufferId,
852        split_id: Option<SplitId>,
853        payload: ViewTransformPayload,
854    },
855
856    /// Clear view transform for a buffer/split (returns to normal rendering)
857    ClearViewTransform {
858        buffer_id: BufferId,
859        split_id: Option<SplitId>,
860    },
861
862    /// Remove all overlays from a buffer
863    ClearAllOverlays { buffer_id: BufferId },
864
865    /// Remove all overlays in a namespace
866    ClearNamespace {
867        buffer_id: BufferId,
868        namespace: OverlayNamespace,
869    },
870
871    /// Remove all overlays that overlap with a byte range
872    /// Used for targeted invalidation when content in a range changes
873    ClearOverlaysInRange {
874        buffer_id: BufferId,
875        start: usize,
876        end: usize,
877    },
878
879    /// Add virtual text (inline text that doesn't exist in the buffer)
880    /// Used for color swatches, type hints, parameter hints, etc.
881    AddVirtualText {
882        buffer_id: BufferId,
883        virtual_text_id: String,
884        position: usize,
885        text: String,
886        color: (u8, u8, u8),
887        use_bg: bool, // true = use color as background, false = use as foreground
888        before: bool, // true = before char, false = after char
889    },
890
891    /// Remove a virtual text by ID
892    RemoveVirtualText {
893        buffer_id: BufferId,
894        virtual_text_id: String,
895    },
896
897    /// Remove virtual texts whose ID starts with the given prefix
898    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
899
900    /// Clear all virtual texts from a buffer
901    ClearVirtualTexts { buffer_id: BufferId },
902
903    /// Add a virtual LINE (full line above/below a position)
904    /// Used for git blame headers, code coverage, inline documentation, etc.
905    /// These lines do NOT show line numbers in the gutter.
906    AddVirtualLine {
907        buffer_id: BufferId,
908        /// Byte position to anchor the line to
909        position: usize,
910        /// Full line content to display
911        text: String,
912        /// Foreground color (RGB)
913        fg_color: (u8, u8, u8),
914        /// Background color (RGB), None = transparent
915        bg_color: Option<(u8, u8, u8)>,
916        /// true = above the line containing position, false = below
917        above: bool,
918        /// Namespace for bulk removal (e.g., "git-blame")
919        namespace: String,
920        /// Priority for ordering multiple lines at same position (higher = later)
921        priority: i32,
922    },
923
924    /// Clear all virtual texts in a namespace
925    /// This is the primary way to remove a plugin's virtual lines before updating them.
926    ClearVirtualTextNamespace {
927        buffer_id: BufferId,
928        namespace: String,
929    },
930
931    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
932    RefreshLines { buffer_id: BufferId },
933
934    /// Set a line indicator in the gutter's indicator column
935    /// Used for git gutter, breakpoints, bookmarks, etc.
936    SetLineIndicator {
937        buffer_id: BufferId,
938        /// Line number (0-indexed)
939        line: usize,
940        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
941        namespace: String,
942        /// Symbol to display (e.g., "│", "●", "★")
943        symbol: String,
944        /// Color as RGB tuple
945        color: (u8, u8, u8),
946        /// Priority for display when multiple indicators exist (higher wins)
947        priority: i32,
948    },
949
950    /// Clear all line indicators for a specific namespace
951    ClearLineIndicators {
952        buffer_id: BufferId,
953        /// Namespace to clear (e.g., "git-gutter")
954        namespace: String,
955    },
956
957    /// Set file explorer decorations for a namespace
958    SetFileExplorerDecorations {
959        /// Namespace for grouping (e.g., "git-status")
960        namespace: String,
961        /// Decorations to apply
962        decorations: Vec<FileExplorerDecoration>,
963    },
964
965    /// Clear file explorer decorations for a namespace
966    ClearFileExplorerDecorations {
967        /// Namespace to clear (e.g., "git-status")
968        namespace: String,
969    },
970
971    /// Open a file at a specific line and column
972    /// Line and column are 1-indexed to match git grep output
973    OpenFileAtLocation {
974        path: PathBuf,
975        line: Option<usize>,   // 1-indexed, None = go to start
976        column: Option<usize>, // 1-indexed, None = go to line start
977    },
978
979    /// Open a file in a specific split at a given line and column
980    /// Line and column are 1-indexed to match git grep output
981    OpenFileInSplit {
982        split_id: usize,
983        path: PathBuf,
984        line: Option<usize>,   // 1-indexed, None = go to start
985        column: Option<usize>, // 1-indexed, None = go to line start
986    },
987
988    /// Start a prompt (minibuffer) with a custom type identifier
989    /// This allows plugins to create interactive prompts
990    StartPrompt {
991        label: String,
992        prompt_type: String, // e.g., "git-grep", "git-find-file"
993    },
994
995    /// Start a prompt with pre-filled initial value
996    StartPromptWithInitial {
997        label: String,
998        prompt_type: String,
999        initial_value: String,
1000    },
1001
1002    /// Start an async prompt that returns result via callback
1003    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1004    StartPromptAsync {
1005        label: String,
1006        initial_value: String,
1007        callback_id: JsCallbackId,
1008    },
1009
1010    /// Update the suggestions list for the current prompt
1011    /// Uses the editor's Suggestion type
1012    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1013
1014    /// Add a menu item to an existing menu
1015    /// Add a menu item to an existing menu
1016    AddMenuItem {
1017        menu_label: String,
1018        item: MenuItem,
1019        position: MenuPosition,
1020    },
1021
1022    /// Add a new top-level menu
1023    AddMenu { menu: Menu, position: MenuPosition },
1024
1025    /// Remove a menu item from a menu
1026    RemoveMenuItem {
1027        menu_label: String,
1028        item_label: String,
1029    },
1030
1031    /// Remove a top-level menu
1032    RemoveMenu { menu_label: String },
1033
1034    /// Create a new virtual buffer (not backed by a file)
1035    CreateVirtualBuffer {
1036        /// Display name (e.g., "*Diagnostics*")
1037        name: String,
1038        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1039        mode: String,
1040        /// Whether the buffer is read-only
1041        read_only: bool,
1042    },
1043
1044    /// Create a virtual buffer and set its content in one operation
1045    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1046    /// because it doesn't require tracking the buffer ID
1047    CreateVirtualBufferWithContent {
1048        /// Display name (e.g., "*Diagnostics*")
1049        name: String,
1050        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1051        mode: String,
1052        /// Whether the buffer is read-only
1053        read_only: bool,
1054        /// Entries with text and embedded properties
1055        entries: Vec<TextPropertyEntry>,
1056        /// Whether to show line numbers in the gutter
1057        show_line_numbers: bool,
1058        /// Whether to show cursors in the buffer
1059        show_cursors: bool,
1060        /// Whether editing is disabled (blocks editing commands)
1061        editing_disabled: bool,
1062        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1063        hidden_from_tabs: bool,
1064        /// Optional request ID for async response
1065        request_id: Option<u64>,
1066    },
1067
1068    /// Create a virtual buffer in a horizontal split
1069    /// Opens the buffer in a new pane below the current one
1070    CreateVirtualBufferInSplit {
1071        /// Display name (e.g., "*Diagnostics*")
1072        name: String,
1073        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1074        mode: String,
1075        /// Whether the buffer is read-only
1076        read_only: bool,
1077        /// Entries with text and embedded properties
1078        entries: Vec<TextPropertyEntry>,
1079        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1080        ratio: f32,
1081        /// Split direction ("horizontal" or "vertical"), default horizontal
1082        direction: Option<String>,
1083        /// Optional panel ID for idempotent operations (if panel exists, update content)
1084        panel_id: Option<String>,
1085        /// Whether to show line numbers in the buffer (default true)
1086        show_line_numbers: bool,
1087        /// Whether to show cursors in the buffer (default true)
1088        show_cursors: bool,
1089        /// Whether editing is disabled for this buffer (default false)
1090        editing_disabled: bool,
1091        /// Whether line wrapping is enabled for this split (None = use global setting)
1092        line_wrap: Option<bool>,
1093        /// Optional request ID for async response (if set, editor will send back buffer ID)
1094        request_id: Option<u64>,
1095    },
1096
1097    /// Set the content of a virtual buffer with text properties
1098    SetVirtualBufferContent {
1099        buffer_id: BufferId,
1100        /// Entries with text and embedded properties
1101        entries: Vec<TextPropertyEntry>,
1102    },
1103
1104    /// Get text properties at the cursor position in a buffer
1105    GetTextPropertiesAtCursor { buffer_id: BufferId },
1106
1107    /// Define a buffer mode with keybindings
1108    DefineMode {
1109        name: String,
1110        parent: Option<String>,
1111        bindings: Vec<(String, String)>, // (key_string, command_name)
1112        read_only: bool,
1113    },
1114
1115    /// Switch the current split to display a buffer
1116    ShowBuffer { buffer_id: BufferId },
1117
1118    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1119    CreateVirtualBufferInExistingSplit {
1120        /// Display name (e.g., "*Commit Details*")
1121        name: String,
1122        /// Mode name for buffer-local keybindings
1123        mode: String,
1124        /// Whether the buffer is read-only
1125        read_only: bool,
1126        /// Entries with text and embedded properties
1127        entries: Vec<TextPropertyEntry>,
1128        /// Target split ID where the buffer should be displayed
1129        split_id: SplitId,
1130        /// Whether to show line numbers in the buffer (default true)
1131        show_line_numbers: bool,
1132        /// Whether to show cursors in the buffer (default true)
1133        show_cursors: bool,
1134        /// Whether editing is disabled for this buffer (default false)
1135        editing_disabled: bool,
1136        /// Whether line wrapping is enabled for this split (None = use global setting)
1137        line_wrap: Option<bool>,
1138        /// Optional request ID for async response
1139        request_id: Option<u64>,
1140    },
1141
1142    /// Close a buffer and remove it from all splits
1143    CloseBuffer { buffer_id: BufferId },
1144
1145    /// Create a composite buffer that displays multiple source buffers
1146    /// Used for side-by-side diff, unified diff, and 3-way merge views
1147    CreateCompositeBuffer {
1148        /// Display name (shown in tab bar)
1149        name: String,
1150        /// Mode name for keybindings (e.g., "diff-view")
1151        mode: String,
1152        /// Layout configuration
1153        layout: CompositeLayoutConfig,
1154        /// Source pane configurations
1155        sources: Vec<CompositeSourceConfig>,
1156        /// Diff hunks for line alignment (optional)
1157        hunks: Option<Vec<CompositeHunk>>,
1158        /// Request ID for async response
1159        request_id: Option<u64>,
1160    },
1161
1162    /// Update alignment for a composite buffer (e.g., after source edit)
1163    UpdateCompositeAlignment {
1164        buffer_id: BufferId,
1165        hunks: Vec<CompositeHunk>,
1166    },
1167
1168    /// Close a composite buffer
1169    CloseCompositeBuffer { buffer_id: BufferId },
1170
1171    /// Focus a specific split
1172    FocusSplit { split_id: SplitId },
1173
1174    /// Set the buffer displayed in a specific split
1175    SetSplitBuffer {
1176        split_id: SplitId,
1177        buffer_id: BufferId,
1178    },
1179
1180    /// Set the scroll position of a specific split
1181    SetSplitScroll { split_id: SplitId, top_byte: usize },
1182
1183    /// Request syntax highlights for a buffer range
1184    RequestHighlights {
1185        buffer_id: BufferId,
1186        range: Range<usize>,
1187        request_id: u64,
1188    },
1189
1190    /// Close a split (if not the last one)
1191    CloseSplit { split_id: SplitId },
1192
1193    /// Set the ratio of a split container
1194    SetSplitRatio {
1195        split_id: SplitId,
1196        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1197        ratio: f32,
1198    },
1199
1200    /// Distribute splits evenly - make all given splits equal size
1201    DistributeSplitsEvenly {
1202        /// Split IDs to distribute evenly
1203        split_ids: Vec<SplitId>,
1204    },
1205
1206    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1207    SetBufferCursor {
1208        buffer_id: BufferId,
1209        /// Byte offset position for the cursor
1210        position: usize,
1211    },
1212
1213    /// Send an arbitrary LSP request and return the raw JSON response
1214    SendLspRequest {
1215        language: String,
1216        method: String,
1217        #[ts(type = "any")]
1218        params: Option<JsonValue>,
1219        request_id: u64,
1220    },
1221
1222    /// Set the internal clipboard content
1223    SetClipboard { text: String },
1224
1225    /// Delete the current selection in the active buffer
1226    /// This deletes all selected text across all cursors
1227    DeleteSelection,
1228
1229    /// Set or unset a custom context
1230    /// Custom contexts are plugin-defined states that can be used to control command visibility
1231    /// For example, "config-editor" context could make config editor commands available
1232    SetContext {
1233        /// Context name (e.g., "config-editor")
1234        name: String,
1235        /// Whether the context is active
1236        active: bool,
1237    },
1238
1239    /// Set the hunks for the Review Diff tool
1240    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1241
1242    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1243    /// Used by vi mode plugin to run motions and calculate cursor ranges
1244    ExecuteAction {
1245        /// Action name (e.g., "move_word_right", "move_line_end")
1246        action_name: String,
1247    },
1248
1249    /// Execute multiple actions in sequence, each with an optional repeat count
1250    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1251    /// All actions execute atomically with no plugin roundtrips between them
1252    ExecuteActions {
1253        /// List of actions to execute in sequence
1254        actions: Vec<ActionSpec>,
1255    },
1256
1257    /// Get text from a buffer range (for yank operations)
1258    GetBufferText {
1259        /// Buffer ID
1260        buffer_id: BufferId,
1261        /// Start byte offset
1262        start: usize,
1263        /// End byte offset
1264        end: usize,
1265        /// Request ID for async response
1266        request_id: u64,
1267    },
1268
1269    /// Get byte offset of the start of a line (async)
1270    /// Line is 0-indexed (0 = first line)
1271    GetLineStartPosition {
1272        /// Buffer ID (0 for active buffer)
1273        buffer_id: BufferId,
1274        /// Line number (0-indexed)
1275        line: u32,
1276        /// Request ID for async response
1277        request_id: u64,
1278    },
1279
1280    /// Set the global editor mode (for modal editing like vi mode)
1281    /// When set, the mode's keybindings take precedence over normal editing
1282    SetEditorMode {
1283        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1284        mode: Option<String>,
1285    },
1286
1287    /// Show an action popup with buttons for user interaction
1288    /// When the user selects an action, the ActionPopupResult hook is fired
1289    ShowActionPopup {
1290        /// Unique identifier for the popup (used in ActionPopupResult)
1291        popup_id: String,
1292        /// Title text for the popup
1293        title: String,
1294        /// Body message (supports basic formatting)
1295        message: String,
1296        /// Action buttons to display
1297        actions: Vec<ActionPopupAction>,
1298    },
1299
1300    /// Disable LSP for a specific language and persist to config
1301    DisableLspForLanguage {
1302        /// The language to disable LSP for (e.g., "python", "rust")
1303        language: String,
1304    },
1305
1306    /// Set the workspace root URI for a specific language's LSP server
1307    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1308    /// If the LSP is already running, it will be restarted with the new root
1309    SetLspRootUri {
1310        /// The language to set root URI for (e.g., "csharp", "rust")
1311        language: String,
1312        /// The root URI (file:// URL format)
1313        uri: String,
1314    },
1315
1316    /// Create a scroll sync group for anchor-based synchronized scrolling
1317    /// Used for side-by-side diff views where two panes need to scroll together
1318    /// The plugin provides the group ID (must be unique per plugin)
1319    CreateScrollSyncGroup {
1320        /// Plugin-assigned group ID
1321        group_id: u32,
1322        /// The left (primary) split - scroll position is tracked in this split's line space
1323        left_split: SplitId,
1324        /// The right (secondary) split - position is derived from anchors
1325        right_split: SplitId,
1326    },
1327
1328    /// Set sync anchors for a scroll sync group
1329    /// Anchors map corresponding line numbers between left and right buffers
1330    SetScrollSyncAnchors {
1331        /// The group ID returned by CreateScrollSyncGroup
1332        group_id: u32,
1333        /// List of (left_line, right_line) pairs marking corresponding positions
1334        anchors: Vec<(usize, usize)>,
1335    },
1336
1337    /// Remove a scroll sync group
1338    RemoveScrollSyncGroup {
1339        /// The group ID returned by CreateScrollSyncGroup
1340        group_id: u32,
1341    },
1342
1343    /// Save a buffer to a specific file path
1344    /// Used by :w filename command to save unnamed buffers or save-as
1345    SaveBufferToPath {
1346        /// Buffer ID to save
1347        buffer_id: BufferId,
1348        /// Path to save to
1349        path: PathBuf,
1350    },
1351
1352    /// Load a plugin from a file path
1353    /// The plugin will be initialized and start receiving events
1354    LoadPlugin {
1355        /// Path to the plugin file (.ts or .js)
1356        path: PathBuf,
1357        /// Callback ID for async response (success/failure)
1358        callback_id: JsCallbackId,
1359    },
1360
1361    /// Unload a plugin by name
1362    /// The plugin will stop receiving events and be removed from memory
1363    UnloadPlugin {
1364        /// Plugin name (as registered)
1365        name: String,
1366        /// Callback ID for async response (success/failure)
1367        callback_id: JsCallbackId,
1368    },
1369
1370    /// Reload a plugin by name (unload + load)
1371    /// Useful for development when plugin code changes
1372    ReloadPlugin {
1373        /// Plugin name (as registered)
1374        name: String,
1375        /// Callback ID for async response (success/failure)
1376        callback_id: JsCallbackId,
1377    },
1378
1379    /// List all loaded plugins
1380    /// Returns plugin info (name, path, enabled) for all loaded plugins
1381    ListPlugins {
1382        /// Callback ID for async response (JSON array of plugin info)
1383        callback_id: JsCallbackId,
1384    },
1385
1386    /// Reload the theme registry from disk
1387    /// Call this after installing a theme package or saving a new theme
1388    ReloadThemes,
1389
1390    /// Register a TextMate grammar file for a language
1391    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1392    RegisterGrammar {
1393        /// Language identifier (e.g., "elixir", "zig")
1394        language: String,
1395        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
1396        grammar_path: String,
1397        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
1398        extensions: Vec<String>,
1399    },
1400
1401    /// Register language configuration (comment prefix, indentation, formatter)
1402    /// This is applied immediately to the runtime config
1403    RegisterLanguageConfig {
1404        /// Language identifier (e.g., "elixir")
1405        language: String,
1406        /// Language configuration
1407        config: LanguagePackConfig,
1408    },
1409
1410    /// Register an LSP server for a language
1411    /// This is applied immediately to the LSP manager and runtime config
1412    RegisterLspServer {
1413        /// Language identifier (e.g., "elixir")
1414        language: String,
1415        /// LSP server configuration
1416        config: LspServerPackConfig,
1417    },
1418
1419    /// Reload the grammar registry to apply registered grammars
1420    /// Call this after registering one or more grammars to rebuild the syntax set
1421    ReloadGrammars,
1422}
1423
1424// =============================================================================
1425// Language Pack Configuration Types
1426// =============================================================================
1427
1428/// Language configuration for language packs
1429///
1430/// This is a simplified version of the full LanguageConfig, containing only
1431/// the fields that can be set via the plugin API.
1432#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
1433#[serde(rename_all = "camelCase")]
1434#[ts(export)]
1435pub struct LanguagePackConfig {
1436    /// Comment prefix for line comments (e.g., "//" or "#")
1437    #[serde(default)]
1438    pub comment_prefix: Option<String>,
1439
1440    /// Block comment start marker (e.g., slash-star)
1441    #[serde(default)]
1442    pub block_comment_start: Option<String>,
1443
1444    /// Block comment end marker (e.g., star-slash)
1445    #[serde(default)]
1446    pub block_comment_end: Option<String>,
1447
1448    /// Whether to use tabs instead of spaces for indentation
1449    #[serde(default)]
1450    pub use_tabs: Option<bool>,
1451
1452    /// Tab size (number of spaces per tab level)
1453    #[serde(default)]
1454    pub tab_size: Option<usize>,
1455
1456    /// Whether auto-indent is enabled
1457    #[serde(default)]
1458    pub auto_indent: Option<bool>,
1459
1460    /// Whether to show whitespace tab indicators (→) for this language
1461    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
1462    #[serde(default)]
1463    pub show_whitespace_tabs: Option<bool>,
1464
1465    /// Formatter configuration
1466    #[serde(default)]
1467    pub formatter: Option<FormatterPackConfig>,
1468}
1469
1470/// Formatter configuration for language packs
1471#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1472#[serde(rename_all = "camelCase")]
1473#[ts(export)]
1474pub struct FormatterPackConfig {
1475    /// Command to run (e.g., "prettier", "rustfmt")
1476    pub command: String,
1477
1478    /// Arguments to pass to the formatter
1479    #[serde(default)]
1480    pub args: Vec<String>,
1481}
1482
1483/// LSP server configuration for language packs
1484#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1485#[serde(rename_all = "camelCase")]
1486#[ts(export)]
1487pub struct LspServerPackConfig {
1488    /// Command to start the LSP server
1489    pub command: String,
1490
1491    /// Arguments to pass to the command
1492    #[serde(default)]
1493    pub args: Vec<String>,
1494
1495    /// Whether to auto-start the server when a matching file is opened
1496    #[serde(default)]
1497    pub auto_start: Option<bool>,
1498
1499    /// LSP initialization options
1500    #[serde(default)]
1501    #[ts(type = "Record<string, unknown> | null")]
1502    pub initialization_options: Option<JsonValue>,
1503}
1504
1505/// Hunk status for Review Diff
1506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
1507#[ts(export)]
1508pub enum HunkStatus {
1509    Pending,
1510    Staged,
1511    Discarded,
1512}
1513
1514/// A high-level hunk directive for the Review Diff tool
1515#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1516#[ts(export)]
1517pub struct ReviewHunk {
1518    pub id: String,
1519    pub file: String,
1520    pub context_header: String,
1521    pub status: HunkStatus,
1522    /// 0-indexed line range in the base (HEAD) version
1523    pub base_range: Option<(usize, usize)>,
1524    /// 0-indexed line range in the modified (Working) version
1525    pub modified_range: Option<(usize, usize)>,
1526}
1527
1528/// Action button for action popups
1529#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1530#[serde(deny_unknown_fields)]
1531#[ts(export, rename = "TsActionPopupAction")]
1532pub struct ActionPopupAction {
1533    /// Unique action identifier (returned in ActionPopupResult)
1534    pub id: String,
1535    /// Display text for the button (can include command hints)
1536    pub label: String,
1537}
1538
1539/// Options for showActionPopup
1540#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1541#[serde(deny_unknown_fields)]
1542#[ts(export)]
1543pub struct ActionPopupOptions {
1544    /// Unique identifier for the popup (used in ActionPopupResult)
1545    pub id: String,
1546    /// Title text for the popup
1547    pub title: String,
1548    /// Body message (supports basic formatting)
1549    pub message: String,
1550    /// Action buttons to display
1551    pub actions: Vec<ActionPopupAction>,
1552}
1553
1554/// Syntax highlight span for a buffer range
1555#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1556#[ts(export)]
1557pub struct TsHighlightSpan {
1558    pub start: u32,
1559    pub end: u32,
1560    #[ts(type = "[number, number, number]")]
1561    pub color: (u8, u8, u8),
1562    pub bold: bool,
1563    pub italic: bool,
1564}
1565
1566/// Result from spawning a process with spawnProcess
1567#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1568#[ts(export)]
1569pub struct SpawnResult {
1570    /// Complete stdout as string
1571    pub stdout: String,
1572    /// Complete stderr as string
1573    pub stderr: String,
1574    /// Process exit code (0 usually means success, -1 if killed)
1575    pub exit_code: i32,
1576}
1577
1578/// Result from spawning a background process
1579#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1580#[ts(export)]
1581pub struct BackgroundProcessResult {
1582    /// Unique process ID for later reference
1583    #[ts(type = "number")]
1584    pub process_id: u64,
1585    /// Process exit code (0 usually means success, -1 if killed)
1586    /// Only present when the process has exited
1587    pub exit_code: i32,
1588}
1589
1590/// Entry for virtual buffer content with optional text properties (JS API version)
1591#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1592#[serde(deny_unknown_fields)]
1593#[ts(export, rename = "TextPropertyEntry")]
1594pub struct JsTextPropertyEntry {
1595    /// Text content for this entry
1596    pub text: String,
1597    /// Optional properties attached to this text (e.g., file path, line number)
1598    #[serde(default)]
1599    #[ts(optional, type = "Record<string, unknown>")]
1600    pub properties: Option<HashMap<String, JsonValue>>,
1601}
1602
1603/// Directory entry returned by readDir
1604#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1605#[ts(export)]
1606pub struct DirEntry {
1607    /// File/directory name
1608    pub name: String,
1609    /// True if this is a file
1610    pub is_file: bool,
1611    /// True if this is a directory
1612    pub is_dir: bool,
1613}
1614
1615/// Position in a document (line and character)
1616#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1617#[ts(export)]
1618pub struct JsPosition {
1619    /// Zero-indexed line number
1620    pub line: u32,
1621    /// Zero-indexed character offset
1622    pub character: u32,
1623}
1624
1625/// Range in a document (start and end positions)
1626#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1627#[ts(export)]
1628pub struct JsRange {
1629    /// Start position
1630    pub start: JsPosition,
1631    /// End position
1632    pub end: JsPosition,
1633}
1634
1635/// Diagnostic from LSP
1636#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1637#[ts(export)]
1638pub struct JsDiagnostic {
1639    /// Document URI
1640    pub uri: String,
1641    /// Diagnostic message
1642    pub message: String,
1643    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
1644    pub severity: Option<u8>,
1645    /// Range in the document
1646    pub range: JsRange,
1647    /// Source of the diagnostic (e.g., "typescript", "eslint")
1648    #[ts(optional)]
1649    pub source: Option<String>,
1650}
1651
1652/// Options for createVirtualBuffer
1653#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1654#[serde(deny_unknown_fields)]
1655#[ts(export)]
1656pub struct CreateVirtualBufferOptions {
1657    /// Buffer name (displayed in tabs/title)
1658    pub name: String,
1659    /// Mode for keybindings (e.g., "git-log", "search-results")
1660    #[serde(default)]
1661    #[ts(optional)]
1662    pub mode: Option<String>,
1663    /// Whether buffer is read-only (default: false)
1664    #[serde(default, rename = "readOnly")]
1665    #[ts(optional, rename = "readOnly")]
1666    pub read_only: Option<bool>,
1667    /// Show line numbers in gutter (default: false)
1668    #[serde(default, rename = "showLineNumbers")]
1669    #[ts(optional, rename = "showLineNumbers")]
1670    pub show_line_numbers: Option<bool>,
1671    /// Show cursor (default: true)
1672    #[serde(default, rename = "showCursors")]
1673    #[ts(optional, rename = "showCursors")]
1674    pub show_cursors: Option<bool>,
1675    /// Disable text editing (default: false)
1676    #[serde(default, rename = "editingDisabled")]
1677    #[ts(optional, rename = "editingDisabled")]
1678    pub editing_disabled: Option<bool>,
1679    /// Hide from tab bar (default: false)
1680    #[serde(default, rename = "hiddenFromTabs")]
1681    #[ts(optional, rename = "hiddenFromTabs")]
1682    pub hidden_from_tabs: Option<bool>,
1683    /// Initial content entries with optional properties
1684    #[serde(default)]
1685    #[ts(optional)]
1686    pub entries: Option<Vec<JsTextPropertyEntry>>,
1687}
1688
1689/// Options for createVirtualBufferInSplit
1690#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1691#[serde(deny_unknown_fields)]
1692#[ts(export)]
1693pub struct CreateVirtualBufferInSplitOptions {
1694    /// Buffer name (displayed in tabs/title)
1695    pub name: String,
1696    /// Mode for keybindings (e.g., "git-log", "search-results")
1697    #[serde(default)]
1698    #[ts(optional)]
1699    pub mode: Option<String>,
1700    /// Whether buffer is read-only (default: false)
1701    #[serde(default, rename = "readOnly")]
1702    #[ts(optional, rename = "readOnly")]
1703    pub read_only: Option<bool>,
1704    /// Split ratio 0.0-1.0 (default: 0.5)
1705    #[serde(default)]
1706    #[ts(optional)]
1707    pub ratio: Option<f32>,
1708    /// Split direction: "horizontal" or "vertical"
1709    #[serde(default)]
1710    #[ts(optional)]
1711    pub direction: Option<String>,
1712    /// Panel ID to split from
1713    #[serde(default, rename = "panelId")]
1714    #[ts(optional, rename = "panelId")]
1715    pub panel_id: Option<String>,
1716    /// Show line numbers in gutter (default: true)
1717    #[serde(default, rename = "showLineNumbers")]
1718    #[ts(optional, rename = "showLineNumbers")]
1719    pub show_line_numbers: Option<bool>,
1720    /// Show cursor (default: true)
1721    #[serde(default, rename = "showCursors")]
1722    #[ts(optional, rename = "showCursors")]
1723    pub show_cursors: Option<bool>,
1724    /// Disable text editing (default: false)
1725    #[serde(default, rename = "editingDisabled")]
1726    #[ts(optional, rename = "editingDisabled")]
1727    pub editing_disabled: Option<bool>,
1728    /// Enable line wrapping
1729    #[serde(default, rename = "lineWrap")]
1730    #[ts(optional, rename = "lineWrap")]
1731    pub line_wrap: Option<bool>,
1732    /// Initial content entries with optional properties
1733    #[serde(default)]
1734    #[ts(optional)]
1735    pub entries: Option<Vec<JsTextPropertyEntry>>,
1736}
1737
1738/// Options for createVirtualBufferInExistingSplit
1739#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1740#[serde(deny_unknown_fields)]
1741#[ts(export)]
1742pub struct CreateVirtualBufferInExistingSplitOptions {
1743    /// Buffer name (displayed in tabs/title)
1744    pub name: String,
1745    /// Target split ID (required)
1746    #[serde(rename = "splitId")]
1747    #[ts(rename = "splitId")]
1748    pub split_id: usize,
1749    /// Mode for keybindings (e.g., "git-log", "search-results")
1750    #[serde(default)]
1751    #[ts(optional)]
1752    pub mode: Option<String>,
1753    /// Whether buffer is read-only (default: false)
1754    #[serde(default, rename = "readOnly")]
1755    #[ts(optional, rename = "readOnly")]
1756    pub read_only: Option<bool>,
1757    /// Show line numbers in gutter (default: true)
1758    #[serde(default, rename = "showLineNumbers")]
1759    #[ts(optional, rename = "showLineNumbers")]
1760    pub show_line_numbers: Option<bool>,
1761    /// Show cursor (default: true)
1762    #[serde(default, rename = "showCursors")]
1763    #[ts(optional, rename = "showCursors")]
1764    pub show_cursors: Option<bool>,
1765    /// Disable text editing (default: false)
1766    #[serde(default, rename = "editingDisabled")]
1767    #[ts(optional, rename = "editingDisabled")]
1768    pub editing_disabled: Option<bool>,
1769    /// Enable line wrapping
1770    #[serde(default, rename = "lineWrap")]
1771    #[ts(optional, rename = "lineWrap")]
1772    pub line_wrap: Option<bool>,
1773    /// Initial content entries with optional properties
1774    #[serde(default)]
1775    #[ts(optional)]
1776    pub entries: Option<Vec<JsTextPropertyEntry>>,
1777}
1778
1779/// Result of getTextPropertiesAtCursor - array of property objects
1780///
1781/// Each element contains the properties from a text property span that overlaps
1782/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
1783#[derive(Debug, Clone, Serialize, TS)]
1784#[ts(export, type = "Array<Record<string, unknown>>")]
1785pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
1786
1787// Implement FromJs for option types using rquickjs_serde
1788#[cfg(feature = "plugins")]
1789mod fromjs_impls {
1790    use super::*;
1791    use rquickjs::{Ctx, FromJs, Value};
1792
1793    impl<'js> FromJs<'js> for JsTextPropertyEntry {
1794        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1795            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1796                from: "object",
1797                to: "JsTextPropertyEntry",
1798                message: Some(e.to_string()),
1799            })
1800        }
1801    }
1802
1803    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
1804        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1805            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1806                from: "object",
1807                to: "CreateVirtualBufferOptions",
1808                message: Some(e.to_string()),
1809            })
1810        }
1811    }
1812
1813    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
1814        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1815            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1816                from: "object",
1817                to: "CreateVirtualBufferInSplitOptions",
1818                message: Some(e.to_string()),
1819            })
1820        }
1821    }
1822
1823    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
1824        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1825            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1826                from: "object",
1827                to: "CreateVirtualBufferInExistingSplitOptions",
1828                message: Some(e.to_string()),
1829            })
1830        }
1831    }
1832
1833    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
1834        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1835            rquickjs_serde::to_value(ctx.clone(), &self.0)
1836                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1837        }
1838    }
1839
1840    // === Additional input types for type-safe plugin API ===
1841
1842    impl<'js> FromJs<'js> for ActionSpec {
1843        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1844            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1845                from: "object",
1846                to: "ActionSpec",
1847                message: Some(e.to_string()),
1848            })
1849        }
1850    }
1851
1852    impl<'js> FromJs<'js> for ActionPopupAction {
1853        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1854            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1855                from: "object",
1856                to: "ActionPopupAction",
1857                message: Some(e.to_string()),
1858            })
1859        }
1860    }
1861
1862    impl<'js> FromJs<'js> for ActionPopupOptions {
1863        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1864            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1865                from: "object",
1866                to: "ActionPopupOptions",
1867                message: Some(e.to_string()),
1868            })
1869        }
1870    }
1871
1872    impl<'js> FromJs<'js> for ViewTokenWire {
1873        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1874            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1875                from: "object",
1876                to: "ViewTokenWire",
1877                message: Some(e.to_string()),
1878            })
1879        }
1880    }
1881
1882    impl<'js> FromJs<'js> for ViewTokenStyle {
1883        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1884            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1885                from: "object",
1886                to: "ViewTokenStyle",
1887                message: Some(e.to_string()),
1888            })
1889        }
1890    }
1891
1892    impl<'js> FromJs<'js> for LayoutHints {
1893        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1894            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1895                from: "object",
1896                to: "LayoutHints",
1897                message: Some(e.to_string()),
1898            })
1899        }
1900    }
1901
1902    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
1903        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1904            // Use two-step deserialization for complex nested structures
1905            let json: serde_json::Value =
1906                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1907                    from: "object",
1908                    to: "CreateCompositeBufferOptions (json)",
1909                    message: Some(e.to_string()),
1910                })?;
1911            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
1912                from: "json",
1913                to: "CreateCompositeBufferOptions",
1914                message: Some(e.to_string()),
1915            })
1916        }
1917    }
1918
1919    impl<'js> FromJs<'js> for CompositeHunk {
1920        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1921            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1922                from: "object",
1923                to: "CompositeHunk",
1924                message: Some(e.to_string()),
1925            })
1926        }
1927    }
1928
1929    impl<'js> FromJs<'js> for LanguagePackConfig {
1930        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1931            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1932                from: "object",
1933                to: "LanguagePackConfig",
1934                message: Some(e.to_string()),
1935            })
1936        }
1937    }
1938
1939    impl<'js> FromJs<'js> for LspServerPackConfig {
1940        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1941            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1942                from: "object",
1943                to: "LspServerPackConfig",
1944                message: Some(e.to_string()),
1945            })
1946        }
1947    }
1948}
1949
1950/// Plugin API context - provides safe access to editor functionality
1951pub struct PluginApi {
1952    /// Hook registry (shared with editor)
1953    hooks: Arc<RwLock<HookRegistry>>,
1954
1955    /// Command registry (shared with editor)
1956    commands: Arc<RwLock<CommandRegistry>>,
1957
1958    /// Command queue for sending commands to editor
1959    command_sender: std::sync::mpsc::Sender<PluginCommand>,
1960
1961    /// Snapshot of editor state (read-only for plugins)
1962    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
1963}
1964
1965impl PluginApi {
1966    /// Create a new plugin API context
1967    pub fn new(
1968        hooks: Arc<RwLock<HookRegistry>>,
1969        commands: Arc<RwLock<CommandRegistry>>,
1970        command_sender: std::sync::mpsc::Sender<PluginCommand>,
1971        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
1972    ) -> Self {
1973        Self {
1974            hooks,
1975            commands,
1976            command_sender,
1977            state_snapshot,
1978        }
1979    }
1980
1981    /// Register a hook callback
1982    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
1983        let mut hooks = self.hooks.write().unwrap();
1984        hooks.add_hook(hook_name, callback);
1985    }
1986
1987    /// Remove all hooks for a specific name
1988    pub fn unregister_hooks(&self, hook_name: &str) {
1989        let mut hooks = self.hooks.write().unwrap();
1990        hooks.remove_hooks(hook_name);
1991    }
1992
1993    /// Register a command
1994    pub fn register_command(&self, command: Command) {
1995        let commands = self.commands.read().unwrap();
1996        commands.register(command);
1997    }
1998
1999    /// Unregister a command by name
2000    pub fn unregister_command(&self, name: &str) {
2001        let commands = self.commands.read().unwrap();
2002        commands.unregister(name);
2003    }
2004
2005    /// Send a command to the editor (async/non-blocking)
2006    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
2007        self.command_sender
2008            .send(command)
2009            .map_err(|e| format!("Failed to send command: {}", e))
2010    }
2011
2012    /// Insert text at a position in a buffer
2013    pub fn insert_text(
2014        &self,
2015        buffer_id: BufferId,
2016        position: usize,
2017        text: String,
2018    ) -> Result<(), String> {
2019        self.send_command(PluginCommand::InsertText {
2020            buffer_id,
2021            position,
2022            text,
2023        })
2024    }
2025
2026    /// Delete a range of text from a buffer
2027    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
2028        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
2029    }
2030
2031    /// Add an overlay (decoration) to a buffer
2032    /// Add an overlay to a buffer with styling options
2033    ///
2034    /// Returns an opaque handle that can be used to remove the overlay later.
2035    ///
2036    /// Colors can be specified as RGB arrays or theme key strings.
2037    /// Theme keys are resolved at render time, so overlays update with theme changes.
2038    pub fn add_overlay(
2039        &self,
2040        buffer_id: BufferId,
2041        namespace: Option<String>,
2042        range: Range<usize>,
2043        options: OverlayOptions,
2044    ) -> Result<(), String> {
2045        self.send_command(PluginCommand::AddOverlay {
2046            buffer_id,
2047            namespace: namespace.map(OverlayNamespace::from_string),
2048            range,
2049            options,
2050        })
2051    }
2052
2053    /// Remove an overlay from a buffer by its handle
2054    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
2055        self.send_command(PluginCommand::RemoveOverlay {
2056            buffer_id,
2057            handle: OverlayHandle::from_string(handle),
2058        })
2059    }
2060
2061    /// Clear all overlays in a namespace from a buffer
2062    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
2063        self.send_command(PluginCommand::ClearNamespace {
2064            buffer_id,
2065            namespace: OverlayNamespace::from_string(namespace),
2066        })
2067    }
2068
2069    /// Clear all overlays that overlap with a byte range
2070    /// Used for targeted invalidation when content changes
2071    pub fn clear_overlays_in_range(
2072        &self,
2073        buffer_id: BufferId,
2074        start: usize,
2075        end: usize,
2076    ) -> Result<(), String> {
2077        self.send_command(PluginCommand::ClearOverlaysInRange {
2078            buffer_id,
2079            start,
2080            end,
2081        })
2082    }
2083
2084    /// Set the status message
2085    pub fn set_status(&self, message: String) -> Result<(), String> {
2086        self.send_command(PluginCommand::SetStatus { message })
2087    }
2088
2089    /// Open a file at a specific line and column (1-indexed)
2090    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
2091    pub fn open_file_at_location(
2092        &self,
2093        path: PathBuf,
2094        line: Option<usize>,
2095        column: Option<usize>,
2096    ) -> Result<(), String> {
2097        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
2098    }
2099
2100    /// Open a file in a specific split at a line and column
2101    ///
2102    /// Similar to open_file_at_location but targets a specific split pane.
2103    /// The split_id is the ID of the split pane to open the file in.
2104    pub fn open_file_in_split(
2105        &self,
2106        split_id: usize,
2107        path: PathBuf,
2108        line: Option<usize>,
2109        column: Option<usize>,
2110    ) -> Result<(), String> {
2111        self.send_command(PluginCommand::OpenFileInSplit {
2112            split_id,
2113            path,
2114            line,
2115            column,
2116        })
2117    }
2118
2119    /// Start a prompt (minibuffer) with a custom type identifier
2120    /// The prompt_type is used to filter hooks in plugin code
2121    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
2122        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
2123    }
2124
2125    /// Set the suggestions for the current prompt
2126    /// This updates the prompt's autocomplete/selection list
2127    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
2128        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
2129    }
2130
2131    /// Add a menu item to an existing menu
2132    pub fn add_menu_item(
2133        &self,
2134        menu_label: String,
2135        item: MenuItem,
2136        position: MenuPosition,
2137    ) -> Result<(), String> {
2138        self.send_command(PluginCommand::AddMenuItem {
2139            menu_label,
2140            item,
2141            position,
2142        })
2143    }
2144
2145    /// Add a new top-level menu
2146    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
2147        self.send_command(PluginCommand::AddMenu { menu, position })
2148    }
2149
2150    /// Remove a menu item from a menu
2151    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
2152        self.send_command(PluginCommand::RemoveMenuItem {
2153            menu_label,
2154            item_label,
2155        })
2156    }
2157
2158    /// Remove a top-level menu
2159    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
2160        self.send_command(PluginCommand::RemoveMenu { menu_label })
2161    }
2162
2163    // === Virtual Buffer Methods ===
2164
2165    /// Create a new virtual buffer (not backed by a file)
2166    ///
2167    /// Virtual buffers are used for special displays like diagnostic lists,
2168    /// search results, etc. They have their own mode for keybindings.
2169    pub fn create_virtual_buffer(
2170        &self,
2171        name: String,
2172        mode: String,
2173        read_only: bool,
2174    ) -> Result<(), String> {
2175        self.send_command(PluginCommand::CreateVirtualBuffer {
2176            name,
2177            mode,
2178            read_only,
2179        })
2180    }
2181
2182    /// Create a virtual buffer and set its content in one operation
2183    ///
2184    /// This is the preferred way to create virtual buffers since it doesn't
2185    /// require tracking the buffer ID. The buffer is created and populated
2186    /// atomically.
2187    pub fn create_virtual_buffer_with_content(
2188        &self,
2189        name: String,
2190        mode: String,
2191        read_only: bool,
2192        entries: Vec<TextPropertyEntry>,
2193    ) -> Result<(), String> {
2194        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
2195            name,
2196            mode,
2197            read_only,
2198            entries,
2199            show_line_numbers: true,
2200            show_cursors: true,
2201            editing_disabled: false,
2202            hidden_from_tabs: false,
2203            request_id: None,
2204        })
2205    }
2206
2207    /// Set the content of a virtual buffer with text properties
2208    ///
2209    /// Each entry contains text and metadata properties (e.g., source location).
2210    pub fn set_virtual_buffer_content(
2211        &self,
2212        buffer_id: BufferId,
2213        entries: Vec<TextPropertyEntry>,
2214    ) -> Result<(), String> {
2215        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
2216    }
2217
2218    /// Get text properties at cursor position in a buffer
2219    ///
2220    /// This triggers a command that will make properties available to plugins.
2221    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
2222        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
2223    }
2224
2225    /// Define a buffer mode with keybindings
2226    ///
2227    /// Modes can inherit from parent modes (e.g., "diagnostics-list" inherits from "special").
2228    /// Bindings are specified as (key_string, command_name) pairs.
2229    pub fn define_mode(
2230        &self,
2231        name: String,
2232        parent: Option<String>,
2233        bindings: Vec<(String, String)>,
2234        read_only: bool,
2235    ) -> Result<(), String> {
2236        self.send_command(PluginCommand::DefineMode {
2237            name,
2238            parent,
2239            bindings,
2240            read_only,
2241        })
2242    }
2243
2244    /// Switch the current split to display a buffer
2245    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
2246        self.send_command(PluginCommand::ShowBuffer { buffer_id })
2247    }
2248
2249    /// Set the scroll position of a specific split
2250    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
2251        self.send_command(PluginCommand::SetSplitScroll {
2252            split_id: SplitId(split_id),
2253            top_byte,
2254        })
2255    }
2256
2257    /// Request syntax highlights for a buffer range
2258    pub fn get_highlights(
2259        &self,
2260        buffer_id: BufferId,
2261        range: Range<usize>,
2262        request_id: u64,
2263    ) -> Result<(), String> {
2264        self.send_command(PluginCommand::RequestHighlights {
2265            buffer_id,
2266            range,
2267            request_id,
2268        })
2269    }
2270
2271    // === Query Methods ===
2272
2273    /// Get the currently active buffer ID
2274    pub fn get_active_buffer_id(&self) -> BufferId {
2275        let snapshot = self.state_snapshot.read().unwrap();
2276        snapshot.active_buffer_id
2277    }
2278
2279    /// Get the currently active split ID
2280    pub fn get_active_split_id(&self) -> usize {
2281        let snapshot = self.state_snapshot.read().unwrap();
2282        snapshot.active_split_id
2283    }
2284
2285    /// Get information about a specific buffer
2286    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
2287        let snapshot = self.state_snapshot.read().unwrap();
2288        snapshot.buffers.get(&buffer_id).cloned()
2289    }
2290
2291    /// Get all buffer IDs
2292    pub fn list_buffers(&self) -> Vec<BufferInfo> {
2293        let snapshot = self.state_snapshot.read().unwrap();
2294        snapshot.buffers.values().cloned().collect()
2295    }
2296
2297    /// Get primary cursor information for the active buffer
2298    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
2299        let snapshot = self.state_snapshot.read().unwrap();
2300        snapshot.primary_cursor.clone()
2301    }
2302
2303    /// Get all cursor information for the active buffer
2304    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
2305        let snapshot = self.state_snapshot.read().unwrap();
2306        snapshot.all_cursors.clone()
2307    }
2308
2309    /// Get viewport information for the active buffer
2310    pub fn get_viewport(&self) -> Option<ViewportInfo> {
2311        let snapshot = self.state_snapshot.read().unwrap();
2312        snapshot.viewport.clone()
2313    }
2314
2315    /// Get access to the state snapshot Arc (for internal use)
2316    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
2317        Arc::clone(&self.state_snapshot)
2318    }
2319}
2320
2321impl Clone for PluginApi {
2322    fn clone(&self) -> Self {
2323        Self {
2324            hooks: Arc::clone(&self.hooks),
2325            commands: Arc::clone(&self.commands),
2326            command_sender: self.command_sender.clone(),
2327            state_snapshot: Arc::clone(&self.state_snapshot),
2328        }
2329    }
2330}
2331
2332#[cfg(test)]
2333mod tests {
2334    use super::*;
2335
2336    #[test]
2337    fn test_plugin_api_creation() {
2338        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2339        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2340        let (tx, _rx) = std::sync::mpsc::channel();
2341        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2342
2343        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2344
2345        // Should not panic
2346        let _clone = api.clone();
2347    }
2348
2349    #[test]
2350    fn test_register_hook() {
2351        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2352        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2353        let (tx, _rx) = std::sync::mpsc::channel();
2354        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2355
2356        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
2357
2358        api.register_hook("test-hook", Box::new(|_| true));
2359
2360        let hook_registry = hooks.read().unwrap();
2361        assert_eq!(hook_registry.hook_count("test-hook"), 1);
2362    }
2363
2364    #[test]
2365    fn test_send_command() {
2366        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2367        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2368        let (tx, rx) = std::sync::mpsc::channel();
2369        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2370
2371        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2372
2373        let result = api.insert_text(BufferId(1), 0, "test".to_string());
2374        assert!(result.is_ok());
2375
2376        // Verify command was sent
2377        let received = rx.try_recv();
2378        assert!(received.is_ok());
2379
2380        match received.unwrap() {
2381            PluginCommand::InsertText {
2382                buffer_id,
2383                position,
2384                text,
2385            } => {
2386                assert_eq!(buffer_id.0, 1);
2387                assert_eq!(position, 0);
2388                assert_eq!(text, "test");
2389            }
2390            _ => panic!("Wrong command type"),
2391        }
2392    }
2393
2394    #[test]
2395    fn test_add_overlay_command() {
2396        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2397        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2398        let (tx, rx) = std::sync::mpsc::channel();
2399        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2400
2401        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2402
2403        let result = api.add_overlay(
2404            BufferId(1),
2405            Some("test-overlay".to_string()),
2406            0..10,
2407            OverlayOptions {
2408                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
2409                bg: None,
2410                underline: true,
2411                bold: false,
2412                italic: false,
2413                extend_to_line_end: false,
2414            },
2415        );
2416        assert!(result.is_ok());
2417
2418        let received = rx.try_recv().unwrap();
2419        match received {
2420            PluginCommand::AddOverlay {
2421                buffer_id,
2422                namespace,
2423                range,
2424                options,
2425            } => {
2426                assert_eq!(buffer_id.0, 1);
2427                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
2428                assert_eq!(range, 0..10);
2429                assert!(matches!(
2430                    options.fg,
2431                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
2432                ));
2433                assert!(options.bg.is_none());
2434                assert!(options.underline);
2435                assert!(!options.bold);
2436                assert!(!options.italic);
2437                assert!(!options.extend_to_line_end);
2438            }
2439            _ => panic!("Wrong command type"),
2440        }
2441    }
2442
2443    #[test]
2444    fn test_set_status_command() {
2445        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2446        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2447        let (tx, rx) = std::sync::mpsc::channel();
2448        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2449
2450        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2451
2452        let result = api.set_status("Test status".to_string());
2453        assert!(result.is_ok());
2454
2455        let received = rx.try_recv().unwrap();
2456        match received {
2457            PluginCommand::SetStatus { message } => {
2458                assert_eq!(message, "Test status");
2459            }
2460            _ => panic!("Wrong command type"),
2461        }
2462    }
2463
2464    #[test]
2465    fn test_get_active_buffer_id() {
2466        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2467        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2468        let (tx, _rx) = std::sync::mpsc::channel();
2469        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2470
2471        // Set active buffer to 5
2472        {
2473            let mut snapshot = state_snapshot.write().unwrap();
2474            snapshot.active_buffer_id = BufferId(5);
2475        }
2476
2477        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2478
2479        let active_id = api.get_active_buffer_id();
2480        assert_eq!(active_id.0, 5);
2481    }
2482
2483    #[test]
2484    fn test_get_buffer_info() {
2485        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2486        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2487        let (tx, _rx) = std::sync::mpsc::channel();
2488        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2489
2490        // Add buffer info
2491        {
2492            let mut snapshot = state_snapshot.write().unwrap();
2493            let buffer_info = BufferInfo {
2494                id: BufferId(1),
2495                path: Some(std::path::PathBuf::from("/test/file.txt")),
2496                modified: true,
2497                length: 100,
2498            };
2499            snapshot.buffers.insert(BufferId(1), buffer_info);
2500        }
2501
2502        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2503
2504        let info = api.get_buffer_info(BufferId(1));
2505        assert!(info.is_some());
2506        let info = info.unwrap();
2507        assert_eq!(info.id.0, 1);
2508        assert_eq!(
2509            info.path.as_ref().unwrap().to_str().unwrap(),
2510            "/test/file.txt"
2511        );
2512        assert!(info.modified);
2513        assert_eq!(info.length, 100);
2514
2515        // Non-existent buffer
2516        let no_info = api.get_buffer_info(BufferId(999));
2517        assert!(no_info.is_none());
2518    }
2519
2520    #[test]
2521    fn test_list_buffers() {
2522        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2523        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2524        let (tx, _rx) = std::sync::mpsc::channel();
2525        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2526
2527        // Add multiple buffers
2528        {
2529            let mut snapshot = state_snapshot.write().unwrap();
2530            snapshot.buffers.insert(
2531                BufferId(1),
2532                BufferInfo {
2533                    id: BufferId(1),
2534                    path: Some(std::path::PathBuf::from("/file1.txt")),
2535                    modified: false,
2536                    length: 50,
2537                },
2538            );
2539            snapshot.buffers.insert(
2540                BufferId(2),
2541                BufferInfo {
2542                    id: BufferId(2),
2543                    path: Some(std::path::PathBuf::from("/file2.txt")),
2544                    modified: true,
2545                    length: 100,
2546                },
2547            );
2548            snapshot.buffers.insert(
2549                BufferId(3),
2550                BufferInfo {
2551                    id: BufferId(3),
2552                    path: None,
2553                    modified: false,
2554                    length: 0,
2555                },
2556            );
2557        }
2558
2559        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2560
2561        let buffers = api.list_buffers();
2562        assert_eq!(buffers.len(), 3);
2563
2564        // Verify all buffers are present
2565        assert!(buffers.iter().any(|b| b.id.0 == 1));
2566        assert!(buffers.iter().any(|b| b.id.0 == 2));
2567        assert!(buffers.iter().any(|b| b.id.0 == 3));
2568    }
2569
2570    #[test]
2571    fn test_get_primary_cursor() {
2572        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2573        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2574        let (tx, _rx) = std::sync::mpsc::channel();
2575        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2576
2577        // Add cursor info
2578        {
2579            let mut snapshot = state_snapshot.write().unwrap();
2580            snapshot.primary_cursor = Some(CursorInfo {
2581                position: 42,
2582                selection: Some(10..42),
2583            });
2584        }
2585
2586        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2587
2588        let cursor = api.get_primary_cursor();
2589        assert!(cursor.is_some());
2590        let cursor = cursor.unwrap();
2591        assert_eq!(cursor.position, 42);
2592        assert_eq!(cursor.selection, Some(10..42));
2593    }
2594
2595    #[test]
2596    fn test_get_all_cursors() {
2597        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2598        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2599        let (tx, _rx) = std::sync::mpsc::channel();
2600        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2601
2602        // Add multiple cursors
2603        {
2604            let mut snapshot = state_snapshot.write().unwrap();
2605            snapshot.all_cursors = vec![
2606                CursorInfo {
2607                    position: 10,
2608                    selection: None,
2609                },
2610                CursorInfo {
2611                    position: 20,
2612                    selection: Some(15..20),
2613                },
2614                CursorInfo {
2615                    position: 30,
2616                    selection: Some(25..30),
2617                },
2618            ];
2619        }
2620
2621        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2622
2623        let cursors = api.get_all_cursors();
2624        assert_eq!(cursors.len(), 3);
2625        assert_eq!(cursors[0].position, 10);
2626        assert_eq!(cursors[0].selection, None);
2627        assert_eq!(cursors[1].position, 20);
2628        assert_eq!(cursors[1].selection, Some(15..20));
2629        assert_eq!(cursors[2].position, 30);
2630        assert_eq!(cursors[2].selection, Some(25..30));
2631    }
2632
2633    #[test]
2634    fn test_get_viewport() {
2635        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2636        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2637        let (tx, _rx) = std::sync::mpsc::channel();
2638        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2639
2640        // Add viewport info
2641        {
2642            let mut snapshot = state_snapshot.write().unwrap();
2643            snapshot.viewport = Some(ViewportInfo {
2644                top_byte: 100,
2645                left_column: 5,
2646                width: 80,
2647                height: 24,
2648            });
2649        }
2650
2651        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2652
2653        let viewport = api.get_viewport();
2654        assert!(viewport.is_some());
2655        let viewport = viewport.unwrap();
2656        assert_eq!(viewport.top_byte, 100);
2657        assert_eq!(viewport.left_column, 5);
2658        assert_eq!(viewport.width, 80);
2659        assert_eq!(viewport.height, 24);
2660    }
2661
2662    #[test]
2663    fn test_composite_buffer_options_rejects_unknown_fields() {
2664        // Valid JSON with correct field names
2665        let valid_json = r#"{
2666            "name": "test",
2667            "mode": "diff",
2668            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2669            "sources": [{"bufferId": 1, "label": "old"}]
2670        }"#;
2671        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
2672        assert!(
2673            result.is_ok(),
2674            "Valid JSON should parse: {:?}",
2675            result.err()
2676        );
2677
2678        // Invalid JSON with unknown field (buffer_id instead of bufferId)
2679        let invalid_json = r#"{
2680            "name": "test",
2681            "mode": "diff",
2682            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2683            "sources": [{"buffer_id": 1, "label": "old"}]
2684        }"#;
2685        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
2686        assert!(
2687            result.is_err(),
2688            "JSON with unknown field should fail to parse"
2689        );
2690        let err = result.unwrap_err().to_string();
2691        assert!(
2692            err.contains("unknown field") || err.contains("buffer_id"),
2693            "Error should mention unknown field: {}",
2694            err
2695        );
2696    }
2697
2698    #[test]
2699    fn test_composite_hunk_rejects_unknown_fields() {
2700        // Valid JSON with correct field names
2701        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
2702        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
2703        assert!(
2704            result.is_ok(),
2705            "Valid JSON should parse: {:?}",
2706            result.err()
2707        );
2708
2709        // Invalid JSON with unknown field (old_start instead of oldStart)
2710        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
2711        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
2712        assert!(
2713            result.is_err(),
2714            "JSON with unknown field should fail to parse"
2715        );
2716        let err = result.unwrap_err().to_string();
2717        assert!(
2718            err.contains("unknown field") || err.contains("old_start"),
2719            "Error should mention unknown field: {}",
2720            err
2721        );
2722    }
2723}