Skip to main content

fresh_plugin_runtime/
ts_export.rs

1//! TypeScript type generation using ts-rs
2//!
3//! This module collects all API types with `#[derive(TS)]` and generates
4//! TypeScript declarations that are combined with the proc macro output.
5//! The generated TypeScript is validated and formatted using oxc.
6//!
7//! Types are automatically collected based on `JSEDITORAPI_REFERENCED_TYPES`
8//! from the proc macro, so when you add a new type to method signatures,
9//! it will automatically be included if it has `#[derive(TS)]`.
10
11use oxc_allocator::Allocator;
12use oxc_codegen::Codegen;
13use oxc_parser::Parser;
14use oxc_span::SourceType;
15use ts_rs::{Config as TsConfig, TS};
16
17use fresh_core::api::{
18    ActionPopupAction, ActionPopupOptions, ActionSpec, AnimationRect, BackgroundProcessResult,
19    BufferGroupResult, BufferInfo, BufferSavedDiff, CompositeHunk, CompositeLayoutConfig,
20    CompositePaneStyle, CompositeSourceConfig, CreateCompositeBufferOptions, CreateTerminalOptions,
21    CreateVirtualBufferInExistingSplitOptions, CreateVirtualBufferInSplitOptions,
22    CreateVirtualBufferOptions, CursorInfo, DirEntry, FormatterPackConfig, GrammarInfoSnapshot,
23    GrepMatch, JsDiagnostic, JsPosition, JsRange, JsTextPropertyEntry, KeyEventPayload,
24    LanguagePackConfig, LayoutHints, LspServerPackConfig, OverlayColorSpec, OverlayOptions,
25    PluginAnimationEdge, PluginAnimationKind, ProcessLimitsPackConfig, ReplaceResult,
26    SearchTakeResult, SpawnResult, SplitSnapshot, TerminalResult, TextPropertiesAtCursor,
27    TokenColor, TsHighlightSpan, ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewportInfo,
28    VirtualBufferResult, WindowInfo,
29};
30use fresh_core::command::Suggestion;
31use fresh_core::file_explorer::FileExplorerDecoration;
32use fresh_core::text_property::InlineOverlay;
33
34/// Get the TypeScript declaration for a type by name
35///
36/// Returns None if the type is not known (not registered in this mapping).
37/// Add new types here when they're added to api.rs with `#[derive(TS)]`.
38fn get_type_decl(type_name: &str) -> Option<String> {
39    let cfg = TsConfig::default();
40    // Map TypeScript type names to their ts-rs declarations
41    // The type name should match either the Rust struct name or the ts(rename = "...") value
42    match type_name {
43        // Animation types
44        "AnimationRect" => Some(AnimationRect::decl(&cfg)),
45        "PluginAnimationEdge" => Some(PluginAnimationEdge::decl(&cfg)),
46        "PluginAnimationKind" => Some(PluginAnimationKind::decl(&cfg)),
47
48        // Core types
49        "BufferInfo" => Some(BufferInfo::decl(&cfg)),
50        "WindowInfo" => Some(WindowInfo::decl(&cfg)),
51        "CursorInfo" => Some(CursorInfo::decl(&cfg)),
52        "ViewportInfo" => Some(ViewportInfo::decl(&cfg)),
53        "KeyEventPayload" => Some(KeyEventPayload::decl(&cfg)),
54        "SplitSnapshot" => Some(SplitSnapshot::decl(&cfg)),
55        "ActionSpec" => Some(ActionSpec::decl(&cfg)),
56        "BufferSavedDiff" => Some(BufferSavedDiff::decl(&cfg)),
57        "LayoutHints" => Some(LayoutHints::decl(&cfg)),
58
59        // Process types
60        "SpawnResult" => Some(SpawnResult::decl(&cfg)),
61        "BackgroundProcessResult" => Some(BackgroundProcessResult::decl(&cfg)),
62
63        // Grep/Replace types
64        "GrepMatch" => Some(GrepMatch::decl(&cfg)),
65        "ReplaceResult" => Some(ReplaceResult::decl(&cfg)),
66        "SearchTakeResult" => Some(SearchTakeResult::decl(&cfg)),
67        // SearchHandle is the JS-side wrapper over a numeric handle id.
68        // The Rust type can't be exported (non-serializable runtime state).
69        "SearchHandle" => Some(
70            "interface SearchHandle { searchId: number; take(): SearchTakeResult; cancel(): void; }".to_string(),
71        ),
72
73        // Terminal types
74        "TerminalResult" => Some(TerminalResult::decl(&cfg)),
75        "CreateTerminalOptions" => Some(CreateTerminalOptions::decl(&cfg)),
76
77        // Composite buffer types (ts-rs renames these with Ts prefix)
78        "TsCompositeLayoutConfig" | "CompositeLayoutConfig" => {
79            Some(CompositeLayoutConfig::decl(&cfg))
80        }
81        "TsCompositeSourceConfig" | "CompositeSourceConfig" => {
82            Some(CompositeSourceConfig::decl(&cfg))
83        }
84        "TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl(&cfg)),
85        "TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl(&cfg)),
86        "TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
87            Some(CreateCompositeBufferOptions::decl(&cfg))
88        }
89
90        // View transform types
91        "ViewTokenWireKind" => Some(ViewTokenWireKind::decl(&cfg)),
92        "TokenColor" => Some(TokenColor::decl(&cfg)),
93        "ViewTokenStyle" => Some(ViewTokenStyle::decl(&cfg)),
94        "ViewTokenWire" => Some(ViewTokenWire::decl(&cfg)),
95
96        // UI types (ts-rs renames these with Ts prefix)
97        "TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl(&cfg)),
98        "ActionPopupOptions" => Some(ActionPopupOptions::decl(&cfg)),
99        "TsLspMenuItem" | "LspMenuItem" => Some(fresh_core::api::LspMenuItem::decl(&cfg)),
100        "TsHighlightSpan" => Some(TsHighlightSpan::decl(&cfg)),
101        "FileExplorerDecoration" => Some(FileExplorerDecoration::decl(&cfg)),
102
103        // Virtual buffer option types
104        "TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl(&cfg)),
105        "CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl(&cfg)),
106        "CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl(&cfg)),
107        "CreateVirtualBufferInExistingSplitOptions" => {
108            Some(CreateVirtualBufferInExistingSplitOptions::decl(&cfg))
109        }
110
111        // Return types
112        "TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl(&cfg)),
113        "VirtualBufferResult" => Some(VirtualBufferResult::decl(&cfg)),
114        "BufferGroupResult" => Some(BufferGroupResult::decl(&cfg)),
115
116        // Prompt and directory types
117        "PromptSuggestion" | "Suggestion" => Some(Suggestion::decl(&cfg)),
118        "DirEntry" => Some(DirEntry::decl(&cfg)),
119
120        // Diagnostic types
121        "JsDiagnostic" => Some(JsDiagnostic::decl(&cfg)),
122        "JsRange" => Some(JsRange::decl(&cfg)),
123        "JsPosition" => Some(JsPosition::decl(&cfg)),
124
125        // Grammar info types
126        "GrammarInfoSnapshot" => Some(GrammarInfoSnapshot::decl(&cfg)),
127
128        // Language pack types
129        "LanguagePackConfig" => Some(LanguagePackConfig::decl(&cfg)),
130        "LspServerPackConfig" => Some(LspServerPackConfig::decl(&cfg)),
131        "ProcessLimitsPackConfig" => Some(ProcessLimitsPackConfig::decl(&cfg)),
132        "FormatterPackConfig" => Some(FormatterPackConfig::decl(&cfg)),
133
134        // Overlay/inline styling types
135        "OverlayOptions" => Some(OverlayOptions::decl(&cfg)),
136        "OverlayColorSpec" => Some(OverlayColorSpec::decl(&cfg)),
137        "InlineOverlay" => Some(InlineOverlay::decl(&cfg)),
138        "OffsetUnit" => Some(fresh_core::text_property::OffsetUnit::decl(&cfg)),
139        "StyledSegment" => Some(fresh_core::text_property::StyledSegment::decl(&cfg)),
140        "StyledText" => Some(fresh_core::api::StyledText::decl(&cfg)),
141
142        // Widget library types — declarative plugin UI.
143        // See docs/internal/plugin-widget-library-design.md.
144        "WidgetSpec" => Some(fresh_core::api::WidgetSpec::decl(&cfg)),
145        "HintEntry" => Some(fresh_core::api::HintEntry::decl(&cfg)),
146        "ButtonKind" => Some(fresh_core::api::ButtonKind::decl(&cfg)),
147        "WidgetAction" => Some(fresh_core::api::WidgetAction::decl(&cfg)),
148        "WidgetMutation" => Some(fresh_core::api::WidgetMutation::decl(&cfg)),
149        "TreeNode" => Some(fresh_core::api::TreeNode::decl(&cfg)),
150
151        // Authority — payload schema for `editor.setAuthority(...)`.
152        // Hand-written because the authoritative struct lives in
153        // `fresh-editor` and this crate must not depend on it
154        // (principle 3: core is opaque to backend kinds). Keep this in
155        // sync with `crates/fresh-editor/src/services/authority/mod.rs`.
156        "AuthorityPayload" => Some(AUTHORITY_PAYLOAD_DECL.to_string()),
157
158        // Remote Indicator override — payload for
159        // `editor.setRemoteIndicatorState(...)`. Same hand-written
160        // rationale: the authoritative enum lives in
161        // `fresh-editor::view::ui::status_bar::RemoteIndicatorOverride`
162        // and this crate must not depend on it. Keep in sync.
163        "RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
164
165        _ => None,
166    }
167}
168
169/// Hand-written declaration for `AuthorityPayload` and its helpers.
170/// See the doc comment on the match arm for why this isn't ts-rs.
171///
172/// Emitted as plain `type …` (not `export type …`) to match the rest of
173/// the file — the generated d.ts lives in global scope and plugins
174/// reference types by bare name without importing them.
175const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
176
177type AuthoritySpawner =
178  | { kind: "local" }
179  | {
180      kind: "docker-exec";
181      container_id: string;
182      user?: string | null;
183      workspace?: string | null;
184      env?: [string, string][];
185    };
186
187type AuthorityTerminalWrapper =
188  | { kind: "host-shell" }
189  | {
190      kind: "explicit";
191      command: string;
192      args: string[];
193      manages_cwd?: boolean;
194    };
195
196type AuthorityPayload = {
197  filesystem: AuthorityFilesystem;
198  spawner: AuthoritySpawner;
199  terminal_wrapper: AuthorityTerminalWrapper;
200  display_label?: string;
201  /**
202  * Optional host↔remote workspace path mapping. The dev-container
203  * authority sets both roots (editor.getCwd() on host;
204  * remoteWorkspaceFolder on container) so LSP URIs translate at the
205  * host/container boundary. Local and SSH authorities omit it.
206  */
207  path_translation?: PathTranslationSpec;
208};
209type PathTranslationSpec = {
210  host_root: string;
211  remote_root: string;
212};"#;
213
214/// Hand-written declaration for `RemoteIndicatorStatePayload`. Keep in
215/// sync with
216/// `crates/fresh-editor/src/view/ui/status_bar.rs::RemoteIndicatorOverride`
217/// (the struct this crate must not depend on).
218const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
219  | { kind: "local" }
220  | { kind: "connecting"; label?: string | null }
221  | { kind: "connected"; label?: string | null }
222  | { kind: "failed_attach"; error?: string | null }
223  | { kind: "disconnected"; label?: string | null };"#;
224
225/// Types that are dependencies of other types and must always be included.
226/// These are types referenced inside option structs or other complex types
227/// that aren't directly in method signatures.
228const DEPENDENCY_TYPES: &[&str] = &[
229    "TextPropertyEntry",              // Used in CreateVirtualBuffer*Options.entries
230    "TsCompositeLayoutConfig",        // Used in createCompositeBuffer opts
231    "TsCompositeSourceConfig",        // Used in createCompositeBuffer opts.sources
232    "TsCompositePaneStyle",           // Used in TsCompositeSourceConfig.style
233    "TsCompositeHunk",                // Used in createCompositeBuffer opts.hunks
234    "TsCreateCompositeBufferOptions", // Options for createCompositeBuffer
235    "ViewportInfo",                   // Used by plugins for viewport queries
236    "KeyEventPayload",                // Used by editor.getNextKey()
237    "SplitSnapshot",                  // Used by editor.listSplits()
238    "LayoutHints",                    // Used by plugins for view transforms
239    "ViewTokenWire",                  // Used by plugins for view transforms
240    "ViewTokenWireKind",              // Used by ViewTokenWire
241    "TokenColor",                     // Used by ViewTokenStyle fg/bg
242    "ViewTokenStyle",                 // Used by ViewTokenWire
243    "PromptSuggestion",               // Used by plugins for prompt suggestions
244    "DirEntry",                       // Used by plugins for directory entries
245    "BufferInfo",                     // Used by listBuffers, getBufferInfo
246    "WindowInfo",                     // Used by listWindows
247    "JsDiagnostic",                   // Used by getAllDiagnostics
248    "JsRange",                        // Used by JsDiagnostic
249    "JsPosition",                     // Used by JsRange
250    "ActionSpec",                     // Used by executeActions
251    "TsActionPopupAction",            // Used by ActionPopupOptions.actions
252    "ActionPopupOptions",             // Used by showActionPopup
253    "TsLspMenuItem",                  // Used by setLspMenuContributions
254    "FileExplorerDecoration",         // Used by setFileExplorerDecorations
255    "FormatterPackConfig",            // Used by LanguagePackConfig.formatter
256    "ProcessLimitsPackConfig",        // Used by LspServerPackConfig.process_limits
257    "TerminalResult",                 // Used by createTerminal return type
258    "CreateTerminalOptions",          // Used by createTerminal opts parameter
259    "CursorInfo",                     // Used by getPrimaryCursor, getAllCursors
260    "OverlayOptions",                 // Used by TextPropertyEntry.style and InlineOverlay
261    "OverlayColorSpec",               // Used by OverlayOptions.fg/bg
262    "InlineOverlay",                  // Used by TextPropertyEntry.inlineOverlays
263    "OffsetUnit",                     // Used by InlineOverlay.unit
264    "StyledSegment",                  // Used by TextPropertyEntry.segments
265    "GrammarInfoSnapshot",            // Used by listGrammars
266    "AnimationRect",                  // Used by animateArea
267    "PluginAnimationEdge",            // Used by PluginAnimationKind
268    "PluginAnimationKind",            // Used by animateArea/animateVirtualBuffer
269    // Widget library types (see docs/internal/plugin-widget-library-design.md)
270    "HintEntry",      // Used by WidgetSpec::HintBar
271    "ButtonKind",     // Used by WidgetSpec::Button.intent
272    "TreeNode",       // Used by WidgetSpec::Tree.nodes
273    "WidgetSpec",     // Used by mountWidgetPanel/updateWidgetPanel
274    "WidgetAction",   // Used by widgetCommand
275    "WidgetMutation", // Used by widgetMutate
276    // Streaming-search pull handle (referenced via ts_raw on beginSearch)
277    "SearchTakeResult",
278    "SearchHandle",
279];
280
281/// Collect TypeScript type declarations based on referenced types from proc macro
282///
283/// Uses `JSEDITORAPI_REFERENCED_TYPES` to determine which types to include.
284/// Also includes dependency types that are referenced by other types.
285pub fn collect_ts_types() -> String {
286    use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
287
288    let mut types = Vec::new();
289    // Track by declaration content to prevent duplicates from aliases
290    // (e.g., "CompositeHunk" and "TsCompositeHunk" both resolve to the same decl)
291    let mut included_decls = std::collections::HashSet::new();
292
293    // First, include dependency types (order matters - dependencies first)
294    for type_name in DEPENDENCY_TYPES {
295        if let Some(decl) = get_type_decl(type_name) {
296            if included_decls.insert(decl.clone()) {
297                types.push(decl);
298            }
299        }
300    }
301
302    // Collect types referenced by the API
303    for type_name in JSEDITORAPI_REFERENCED_TYPES {
304        if let Some(decl) = get_type_decl(type_name) {
305            if included_decls.insert(decl.clone()) {
306                types.push(decl);
307            }
308        } else {
309            // Log warning for unknown types (these need to be added to get_type_decl)
310            eprintln!(
311                "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
312                type_name
313            );
314        }
315    }
316
317    types.join("\n\n")
318}
319
320/// Validate TypeScript syntax using oxc parser
321///
322/// Returns Ok(()) if the syntax is valid, or an error with the parse errors.
323pub fn validate_typescript(source: &str) -> Result<(), String> {
324    let allocator = Allocator::default();
325    let source_type = SourceType::d_ts();
326
327    let parser_ret = Parser::new(&allocator, source, source_type).parse();
328
329    if parser_ret.errors.is_empty() {
330        Ok(())
331    } else {
332        let errors: Vec<String> = parser_ret
333            .errors
334            .iter()
335            .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
336            .collect();
337        Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
338    }
339}
340
341/// Format TypeScript source code using oxc codegen
342///
343/// Parses the TypeScript and regenerates it with consistent formatting.
344/// Returns the original source if parsing fails.
345pub fn format_typescript(source: &str) -> String {
346    let allocator = Allocator::default();
347    let source_type = SourceType::d_ts();
348
349    let parser_ret = Parser::new(&allocator, source, source_type).parse();
350
351    if !parser_ret.errors.is_empty() {
352        // Return original source if parsing fails
353        return source.to_string();
354    }
355
356    // Generate formatted code from AST
357    Codegen::new().build(&parser_ret.program).code
358}
359
360/// Generate and write the complete fresh.d.ts file
361///
362/// Combines ts-rs generated types with proc macro output,
363/// validates the syntax, formats the output, and writes to disk.
364pub fn write_fresh_dts() -> Result<(), String> {
365    use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
366
367    let ts_types = collect_ts_types();
368
369    // After the macro-generated EditorAPI interface, merge in a
370    // typed overload of `getPluginApi` that looks through the
371    // `FreshPluginRegistry` interface (declared in the preamble,
372    // augmented by each loaded plugin's `plugins.d.ts`). Declared
373    // AFTER the base interface so TypeScript's overload resolution
374    // prefers the typed form when the name is a known key; the
375    // untyped `getPluginApi(name: string): unknown | null` from the
376    // macro output is the fallback.
377    let plugin_api_trailer = r#"
378
379/**
380 * Typed overload of `editor.getPluginApi`. When the caller passes a
381 * key that some loaded plugin declared in `FreshPluginRegistry`, the
382 * return type is narrowed to that plugin's API. Unknown names fall
383 * through to the untyped `unknown | null` signature.
384 */
385interface EditorAPI {
386  getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
387}
388
389/**
390 * Maps every hook event name to its payload type.
391 *
392 * Payloads match the flat JSON produced by `hook_args_to_json` on the Rust
393 * side (`HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
394 * fields only). The TypeScript types here are derived directly from the Rust
395 * field definitions and must be kept in sync with `fresh-core/src/hooks.rs`.
396 *
397 * `action` in `pre_command`/`post_command` is the serde JSON of the `Action`
398 * enum: unit variants serialize as a plain string (e.g. `"MoveLeft"`),
399 * tuple variants as a single-key object (e.g. `{"InsertChar": "a"}`).
400 */
401interface HookEventMap {
402  // ── lifecycle ────────────────────────────────────────────────────────────
403  editor_initialized: Record<string, never>;
404  plugins_loaded: Record<string, never>;
405  ready: Record<string, never>;
406  focus_gained: Record<string, never>;
407  authority_changed: { label: string };
408
409  // ── buffer lifecycle ─────────────────────────────────────────────────────
410  buffer_activated: { buffer_id: number };
411  buffer_deactivated: { buffer_id: number };
412  buffer_closed: { buffer_id: number };
413
414  // ── file I/O ─────────────────────────────────────────────────────────────
415  before_file_open: { path: string };
416  after_file_open: { path: string; buffer_id: number };
417  before_file_save: { path: string; buffer_id: number };
418  after_file_save: { path: string; buffer_id: number };
419  /**
420   * Fired by the file explorer after a paste/duplicate/etc. mutates
421   * the filesystem without going through a buffer save. Plugins that
422   * surface FS-derived state (git status badges, etc.) should
423   * subscribe in addition to `after_file_save` to refresh on
424   * explorer-driven changes too.
425   */
426  after_file_explorer_change: { path: string };
427
428  // ── text edits ───────────────────────────────────────────────────────────
429  before_insert: { buffer_id: number; position: number; text: string };
430  after_insert: {
431    buffer_id: number;
432    position: number;
433    text: string;
434    affected_start: number;
435    affected_end: number;
436    start_line: number;
437    end_line: number;
438    lines_added: number;
439  };
440  before_delete: { buffer_id: number; start: number; end: number };
441  after_delete: {
442    buffer_id: number;
443    start: number;
444    end: number;
445    deleted_text: string;
446    affected_start: number;
447    deleted_len: number;
448    start_line: number;
449    end_line: number;
450    lines_removed: number;
451  };
452
453  // ── cursor & viewport ────────────────────────────────────────────────────
454  cursor_moved: {
455    buffer_id: number;
456    cursor_id: number;
457    old_position: number;
458    new_position: number;
459    line: number;
460    text_properties: Record<string, unknown>[];
461  };
462  viewport_changed: {
463    split_id: number;
464    buffer_id: number;
465    top_byte: number;
466    top_line: number | null;
467    width: number;
468    height: number;
469  };
470
471  // ── rendering ────────────────────────────────────────────────────────────
472  render_start: { buffer_id: number };
473  render_line: {
474    buffer_id: number;
475    line_number: number;
476    byte_start: number;
477    byte_end: number;
478    content: string;
479  };
480  lines_changed: {
481    buffer_id: number;
482    lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
483  };
484  view_transform_request: {
485    buffer_id: number;
486    split_id: number;
487    viewport_start: number;
488    viewport_end: number;
489    tokens: ViewTokenWire[];
490    cursor_positions: number[];
491  };
492
493  // ── commands ─────────────────────────────────────────────────────────────
494  pre_command: { action: string | Record<string, unknown> };
495  post_command: { action: string | Record<string, unknown> };
496  idle: { milliseconds: number };
497  resize: { width: number; height: number };
498
499  // ── prompts ──────────────────────────────────────────────────────────────
500  prompt_changed: { prompt_type: string; input: string };
501  prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
502  prompt_cancelled: { prompt_type: string; input: string };
503  prompt_selection_changed: { prompt_type: string; selected_index: number };
504
505  // ── mouse ────────────────────────────────────────────────────────────────
506  mouse_click: MouseClickHookArgs;
507  mouse_move: { column: number; row: number; content_x: number; content_y: number };
508  mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
509
510  // ── LSP ──────────────────────────────────────────────────────────────────
511  diagnostics_updated: { uri: string; count: number };
512  lsp_references: {
513    symbol: string;
514    locations: { file: string; line: number; column: number }[];
515  };
516  lsp_server_request: {
517    language: string;
518    method: string;
519    server_command: string;
520    params: string | null;
521  };
522  lsp_server_error: {
523    language: string;
524    server_command: string;
525    error_type: string;
526    message: string;
527  };
528  lsp_status_clicked: {
529    language: string;
530    has_error: boolean;
531    missing_servers: string[];
532    user_dismissed: boolean;
533  };
534
535  // ── UI events ────────────────────────────────────────────────────────────
536  action_popup_result: { popup_id: string; action_id: string };
537  process_output: { process_id: number; data: string };
538  language_changed: { buffer_id: number; language: string };
539  theme_inspect_key: { theme_name: string; key: string };
540  keyboard_shortcuts: { bindings: { key: string; action: string }[] };
541
542  // ── PTY terminals (see crates/fresh-core/src/hooks.rs) ───────────────────
543  terminal_output: { terminal_id: number; last_line: string };
544  terminal_exit: { terminal_id: number; exit_code: number | null };
545
546  // ── filesystem watching (watchPath plugin API) ────────────────────────────
547  path_changed: {
548    handle: number;
549    path: string;
550    /** "modify" | "create" | "delete" | "rename" | "other" */
551    kind: string;
552  };
553
554  // ── editor sessions (Orchestrator; see orchestrator-sessions-design.md) ────────
555  window_created: { id: number; label: string; root: string };
556  window_closed: { id: number };
557  active_window_changed: { previous_id: number | null; active_id: number };
558
559  // ── widget runtime ───────────────────────────────────────────────────────
560  /**
561   * A widget mounted via `editor.mountWidgetPanel` emitted a
562   * semantic event. Fired when the host's hit-test routes a mouse
563   * click to a `Toggle` / `Button` widget node within a mounted
564   * widget panel. See `docs/internal/plugin-widget-library-design.md`.
565   *
566   * Routing is by `panel_id` (matches the id the plugin allocated
567   * at mount time) plus `widget_key` (the stable `key` set on the
568   * widget spec node, or empty when the spec did not assign one).
569   *
570   * `event_type` and `payload` shapes:
571   *   * Toggle: `event_type = "toggle"`, `payload = { checked: <new> }`.
572   *   * Button: `event_type = "activate"`, `payload = {}`.
573   */
574  widget_event: {
575    panel_id: number;
576    widget_key: string;
577    event_type: string;
578    payload: Record<string, unknown>;
579  };
580}
581
582/**
583 * Typed overloads of `editor.on` / `editor.off`.
584 *
585 * When the event name is a key of `HookEventMap` the handler receives a
586 * fully-typed payload — TypeScript will flag misspelled field accesses at
587 * compile time. Unknown event names fall through to the untyped base
588 * signatures in the EditorAPI interface.
589 *
590 * Both function-value and handler-name forms are supported:
591 *
592 * ```ts
593 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
594 * editor.on("buffer_activated", "myHandler");   // registerHandler("myHandler", fn)
595 * ```
596 */
597interface EditorAPI {
598  on<K extends keyof HookEventMap>(
599    eventName: K,
600    handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
601  ): void;
602  on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
603  off<K extends keyof HookEventMap>(
604    eventName: K,
605    handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
606  ): void;
607  off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
608  /**
609   * Create a buffer group: multiple panels appearing as one tab.
610   * This is an async runtime binding (not a direct #[qjs] method).
611   */
612  createBufferGroup(
613    name: string,
614    mode: string,
615    layout: unknown,
616  ): Promise<BufferGroupResult>;
617}
618"#;
619
620    let content = format!(
621        "{}\n{}\n{}{}",
622        JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
623    );
624
625    // Validate the generated TypeScript syntax
626    validate_typescript(&content)?;
627
628    // Format the TypeScript
629    let formatted = format_typescript(&content);
630
631    // Determine output path - write to fresh-editor/plugins/lib/fresh.d.ts
632    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
633    let output_path = std::path::Path::new(&manifest_dir)
634        .parent() // crates/
635        .and_then(|p| p.parent()) // workspace root
636        .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
637        .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
638
639    // Only write if content changed
640    let should_write = match std::fs::read_to_string(&output_path) {
641        Ok(existing) => existing != formatted,
642        Err(_) => true,
643    };
644
645    if should_write {
646        if let Some(parent) = output_path.parent() {
647            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
648        }
649        std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
650    }
651
652    Ok(())
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    /// Generate, validate, format, and write fresh.d.ts
660    /// Run with: cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored --nocapture
661    #[test]
662    #[ignore]
663    fn write_fresh_dts_file() {
664        // write_fresh_dts validates syntax and formats before writing
665        write_fresh_dts().expect("Failed to write fresh.d.ts");
666        println!("Successfully generated, validated, and formatted fresh.d.ts");
667    }
668
669    /// Type check all plugins using TypeScript compiler
670    /// Skips if tsc is not available in PATH
671    /// Run with: cargo test -p fresh-plugin-runtime type_check_plugins -- --ignored --nocapture
672    #[test]
673    #[ignore]
674    fn type_check_plugins() {
675        // Check if tsc is available
676        let tsc_check = std::process::Command::new("tsc").arg("--version").output();
677
678        match tsc_check {
679            Ok(output) if output.status.success() => {
680                println!(
681                    "Found tsc: {}",
682                    String::from_utf8_lossy(&output.stdout).trim()
683                );
684            }
685            _ => {
686                println!("tsc not found in PATH, skipping type check test");
687                return;
688            }
689        }
690
691        // Find the check-types.sh script
692        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
693        let script_path = std::path::Path::new(&manifest_dir)
694            .parent()
695            .and_then(|p| p.parent())
696            .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
697            .expect("Failed to find check-types.sh");
698
699        println!("Running type check script: {}", script_path.display());
700
701        // Run the check-types.sh script
702        let output = std::process::Command::new("bash")
703            .arg(&script_path)
704            .output()
705            .expect("Failed to run check-types.sh");
706
707        let stdout = String::from_utf8_lossy(&output.stdout);
708        let stderr = String::from_utf8_lossy(&output.stderr);
709
710        println!("stdout:\n{}", stdout);
711        if !stderr.is_empty() {
712            println!("stderr:\n{}", stderr);
713        }
714
715        // The script outputs "X file(s) had type errors" if there are errors
716        if stdout.contains("had type errors") || !output.status.success() {
717            panic!(
718                "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
719            );
720        }
721
722        println!("All plugins type check successfully!");
723    }
724
725    // ========================================================================
726    // Type declaration tests
727    // ========================================================================
728
729    #[test]
730    fn test_get_type_decl_returns_all_expected_types() {
731        let expected_types = vec![
732            "BufferInfo",
733            "WindowInfo",
734            "CursorInfo",
735            "ViewportInfo",
736            "KeyEventPayload",
737            "SplitSnapshot",
738            "ActionSpec",
739            "BufferSavedDiff",
740            "LayoutHints",
741            "SpawnResult",
742            "BackgroundProcessResult",
743            "TerminalResult",
744            "CreateTerminalOptions",
745            "TsCompositeLayoutConfig",
746            "TsCompositeSourceConfig",
747            "TsCompositePaneStyle",
748            "TsCompositeHunk",
749            "TsCreateCompositeBufferOptions",
750            "ViewTokenWireKind",
751            "TokenColor",
752            "ViewTokenStyle",
753            "ViewTokenWire",
754            "TsActionPopupAction",
755            "ActionPopupOptions",
756            "TsHighlightSpan",
757            "FileExplorerDecoration",
758            "TextPropertyEntry",
759            "CreateVirtualBufferOptions",
760            "CreateVirtualBufferInSplitOptions",
761            "CreateVirtualBufferInExistingSplitOptions",
762            "TextPropertiesAtCursor",
763            "VirtualBufferResult",
764            "PromptSuggestion",
765            "DirEntry",
766            "JsDiagnostic",
767            "JsRange",
768            "JsPosition",
769            "LanguagePackConfig",
770            "LspServerPackConfig",
771            "ProcessLimitsPackConfig",
772            "FormatterPackConfig",
773        ];
774
775        for type_name in &expected_types {
776            assert!(
777                get_type_decl(type_name).is_some(),
778                "get_type_decl should return a declaration for '{}'",
779                type_name
780            );
781        }
782    }
783
784    #[test]
785    fn test_get_type_decl_aliases_resolve_same() {
786        // Rust name aliases should produce the same declaration as ts-rs name
787        let alias_pairs = vec![
788            ("CompositeHunk", "TsCompositeHunk"),
789            ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
790            ("CompositeSourceConfig", "TsCompositeSourceConfig"),
791            ("CompositePaneStyle", "TsCompositePaneStyle"),
792            (
793                "CreateCompositeBufferOptions",
794                "TsCreateCompositeBufferOptions",
795            ),
796            ("ActionPopupAction", "TsActionPopupAction"),
797            ("Suggestion", "PromptSuggestion"),
798            ("JsTextPropertyEntry", "TextPropertyEntry"),
799        ];
800
801        for (rust_name, ts_name) in &alias_pairs {
802            let rust_decl = get_type_decl(rust_name);
803            let ts_decl = get_type_decl(ts_name);
804            assert!(
805                rust_decl.is_some(),
806                "get_type_decl should handle Rust name '{}'",
807                rust_name
808            );
809            assert_eq!(
810                rust_decl, ts_decl,
811                "Alias '{}' and '{}' should produce identical declarations",
812                rust_name, ts_name
813            );
814        }
815    }
816
817    #[test]
818    fn test_terminal_types_exist() {
819        let terminal_result = get_type_decl("TerminalResult");
820        assert!(
821            terminal_result.is_some(),
822            "TerminalResult should be defined"
823        );
824        let decl = terminal_result.unwrap();
825        assert!(
826            decl.contains("bufferId"),
827            "TerminalResult should have bufferId field"
828        );
829        assert!(
830            decl.contains("terminalId"),
831            "TerminalResult should have terminalId field"
832        );
833        assert!(
834            decl.contains("splitId"),
835            "TerminalResult should have splitId field"
836        );
837
838        let terminal_opts = get_type_decl("CreateTerminalOptions");
839        assert!(
840            terminal_opts.is_some(),
841            "CreateTerminalOptions should be defined"
842        );
843    }
844
845    #[test]
846    fn test_cursor_info_type_exists() {
847        let cursor_info = get_type_decl("CursorInfo");
848        assert!(cursor_info.is_some(), "CursorInfo should be defined");
849        let decl = cursor_info.unwrap();
850        assert!(
851            decl.contains("position"),
852            "CursorInfo should have position field"
853        );
854        assert!(
855            decl.contains("selection"),
856            "CursorInfo should have selection field"
857        );
858    }
859
860    #[test]
861    fn test_collect_ts_types_no_duplicates() {
862        let output = collect_ts_types();
863        let lines: Vec<&str> = output.lines().collect();
864
865        // Check for duplicate type/interface declarations
866        let mut declarations = std::collections::HashSet::new();
867        for line in &lines {
868            let trimmed = line.trim();
869            // Match type declarations: "type Foo = {" or "type Foo ="
870            if trimmed.starts_with("type ") && trimmed.contains('=') {
871                let name = trimmed
872                    .strip_prefix("type ")
873                    .unwrap()
874                    .split(|c: char| c == '=' || c.is_whitespace())
875                    .next()
876                    .unwrap();
877                assert!(
878                    declarations.insert(name.to_string()),
879                    "Duplicate type declaration found: '{}'",
880                    name
881                );
882            }
883        }
884    }
885
886    #[test]
887    fn test_collect_ts_types_includes_dependency_types() {
888        let output = collect_ts_types();
889        let required_types = [
890            "TextPropertyEntry",
891            "TsCompositeLayoutConfig",
892            "TsCompositeSourceConfig",
893            "TsCompositePaneStyle",
894            "TsCompositeHunk",
895            "TsCreateCompositeBufferOptions",
896            "PromptSuggestion",
897            "BufferInfo",
898            "CursorInfo",
899            "TerminalResult",
900            "CreateTerminalOptions",
901        ];
902
903        for type_name in &required_types {
904            assert!(
905                output.contains(type_name),
906                "collect_ts_types output should contain type '{}'",
907                type_name
908            );
909        }
910    }
911
912    #[test]
913    fn test_generated_dts_validates_as_typescript() {
914        use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
915
916        let ts_types = collect_ts_types();
917        let content = format!(
918            "{}\n{}\n{}",
919            JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
920        );
921
922        validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
923    }
924
925    #[test]
926    fn test_generated_dts_no_undefined_type_references() {
927        use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
928
929        let ts_types = collect_ts_types();
930        let content = format!(
931            "{}\n{}\n{}",
932            JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
933        );
934
935        // Collect all defined type names
936        let mut defined_types = std::collections::HashSet::new();
937        // Built-in types
938        for builtin in &[
939            "number",
940            "string",
941            "boolean",
942            "void",
943            "unknown",
944            "null",
945            "undefined",
946            "Record",
947            "Array",
948            "Promise",
949            "ProcessHandle",
950            "PromiseLike",
951            "BufferId",
952            "SplitId",
953            "EditorAPI",
954        ] {
955            defined_types.insert(builtin.to_string());
956        }
957
958        // Extract defined types from declarations
959        for line in content.lines() {
960            let trimmed = line.trim();
961            if trimmed.starts_with("type ") && trimmed.contains('=') {
962                if let Some(name) = trimmed
963                    .strip_prefix("type ")
964                    .unwrap()
965                    .split(|c: char| c == '=' || c.is_whitespace())
966                    .next()
967                {
968                    defined_types.insert(name.to_string());
969                }
970            }
971            if trimmed.starts_with("interface ") {
972                if let Some(name) = trimmed
973                    .strip_prefix("interface ")
974                    .unwrap()
975                    .split(|c: char| !c.is_alphanumeric() && c != '_')
976                    .next()
977                {
978                    defined_types.insert(name.to_string());
979                }
980            }
981        }
982
983        // Extract capitalized identifiers from EditorAPI method signature lines only
984        // (skip JSDoc comment lines which contain prose with capitalized words)
985        let interface_section = JSEDITORAPI_TS_EDITOR_API;
986        let mut undefined_refs = Vec::new();
987
988        for line in interface_section.lines() {
989            let trimmed = line.trim();
990
991            // Skip JSDoc comments and blank lines
992            if trimmed.starts_with('*')
993                || trimmed.starts_with("/*")
994                || trimmed.starts_with("//")
995                || trimmed.is_empty()
996                || trimmed == "{"
997                || trimmed == "}"
998            {
999                continue;
1000            }
1001
1002            // This should be a method signature line
1003            for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
1004                if word.is_empty() {
1005                    continue;
1006                }
1007                // Type references start with uppercase letter
1008                if word.chars().next().is_some_and(|c| c.is_uppercase())
1009                    && !defined_types.contains(word)
1010                {
1011                    undefined_refs.push(word.to_string());
1012                }
1013            }
1014        }
1015
1016        // Remove duplicates for clearer error message
1017        undefined_refs.sort();
1018        undefined_refs.dedup();
1019
1020        assert!(
1021            undefined_refs.is_empty(),
1022            "Found undefined type references in EditorAPI interface: {:?}",
1023            undefined_refs
1024        );
1025    }
1026
1027    #[test]
1028    fn test_editor_api_cursor_methods_have_typed_returns() {
1029        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1030
1031        let api = JSEDITORAPI_TS_EDITOR_API;
1032
1033        // getPrimaryCursor should return CursorInfo | null, not unknown
1034        assert!(
1035            api.contains("getPrimaryCursor(): CursorInfo | null;"),
1036            "getPrimaryCursor should return CursorInfo | null, got: {}",
1037            api.lines()
1038                .find(|l| l.contains("getPrimaryCursor"))
1039                .unwrap_or("not found")
1040        );
1041
1042        // getAllCursors should return CursorInfo[], not unknown
1043        assert!(
1044            api.contains("getAllCursors(): CursorInfo[];"),
1045            "getAllCursors should return CursorInfo[], got: {}",
1046            api.lines()
1047                .find(|l| l.contains("getAllCursors"))
1048                .unwrap_or("not found")
1049        );
1050
1051        // getAllCursorPositions should return number[], not unknown
1052        assert!(
1053            api.contains("getAllCursorPositions(): number[];"),
1054            "getAllCursorPositions should return number[], got: {}",
1055            api.lines()
1056                .find(|l| l.contains("getAllCursorPositions"))
1057                .unwrap_or("not found")
1058        );
1059    }
1060
1061    #[test]
1062    fn test_editor_api_terminal_methods_use_defined_types() {
1063        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1064
1065        let api = JSEDITORAPI_TS_EDITOR_API;
1066
1067        // createTerminal should use CreateTerminalOptions and TerminalResult
1068        assert!(
1069            api.contains("CreateTerminalOptions"),
1070            "createTerminal should reference CreateTerminalOptions"
1071        );
1072        assert!(
1073            api.contains("TerminalResult"),
1074            "createTerminal should reference TerminalResult"
1075        );
1076    }
1077
1078    #[test]
1079    fn test_editor_api_composite_methods_use_ts_prefix_types() {
1080        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1081
1082        let api = JSEDITORAPI_TS_EDITOR_API;
1083
1084        // updateCompositeAlignment should use TsCompositeHunk (not CompositeHunk)
1085        assert!(
1086            api.contains("TsCompositeHunk[]"),
1087            "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
1088        );
1089
1090        // createCompositeBuffer should use TsCreateCompositeBufferOptions
1091        assert!(
1092            api.contains("TsCreateCompositeBufferOptions"),
1093            "createCompositeBuffer should use TsCreateCompositeBufferOptions"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1099        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1100
1101        let api = JSEDITORAPI_TS_EDITOR_API;
1102
1103        // setPromptSuggestions should use PromptSuggestion (not Suggestion)
1104        assert!(
1105            api.contains("PromptSuggestion[]"),
1106            "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_all_editor_api_methods_present() {
1112        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1113
1114        let api = JSEDITORAPI_TS_EDITOR_API;
1115
1116        // Comprehensive list of all expected methods
1117        let expected_methods = vec![
1118            "apiVersion",
1119            "getActiveBufferId",
1120            "getActiveSplitId",
1121            "listBuffers",
1122            "debug",
1123            "info",
1124            "warn",
1125            "error",
1126            "setStatus",
1127            "copyToClipboard",
1128            "setClipboard",
1129            "registerCommand",
1130            "unregisterCommand",
1131            "setContext",
1132            "executeAction",
1133            "getCursorPosition",
1134            "getBufferPath",
1135            "getBufferLength",
1136            "isBufferModified",
1137            "saveBufferToPath",
1138            "getBufferInfo",
1139            "getPrimaryCursor",
1140            "getAllCursors",
1141            "getAllCursorPositions",
1142            "getViewport",
1143            "getCursorLine",
1144            "getLineStartPosition",
1145            "getLineEndPosition",
1146            "getBufferLineCount",
1147            "scrollToLineCenter",
1148            "findBufferByPath",
1149            "getBufferSavedDiff",
1150            "insertText",
1151            "deleteRange",
1152            "insertAtCursor",
1153            "openFile",
1154            "openFileInSplit",
1155            "showBuffer",
1156            "closeBuffer",
1157            "animateArea",
1158            "animateVirtualBuffer",
1159            "cancelAnimation",
1160            "on",
1161            "off",
1162            "getEnv",
1163            "getCwd",
1164            "pathJoin",
1165            "pathDirname",
1166            "pathBasename",
1167            "pathExtname",
1168            "pathIsAbsolute",
1169            "utf8ByteLength",
1170            "fileExists",
1171            "readFile",
1172            "writeFile",
1173            "readDir",
1174            "createDir",
1175            "removePath",
1176            "renamePath",
1177            "copyPath",
1178            "getTempDir",
1179            "getConfig",
1180            "getUserConfig",
1181            "reloadConfig",
1182            "reloadThemes",
1183            "reloadAndApplyTheme",
1184            "registerGrammar",
1185            "registerLanguageConfig",
1186            "registerLspServer",
1187            "reloadGrammars",
1188            "getConfigDir",
1189            "getDataDir",
1190            "getThemesDir",
1191            "applyTheme",
1192            "getThemeSchema",
1193            "getBuiltinThemes",
1194            "getAllThemes",
1195            "getThemeData",
1196            "saveThemeFile",
1197            "themeFileExists",
1198            "deleteTheme",
1199            "fileStat",
1200            "isProcessRunning",
1201            "killProcess",
1202            "pluginTranslate",
1203            "createCompositeBuffer",
1204            "updateCompositeAlignment",
1205            "closeCompositeBuffer",
1206            "flushLayout",
1207            "compositeNextHunk",
1208            "compositePrevHunk",
1209            "getHighlights",
1210            "addOverlay",
1211            "clearNamespace",
1212            "clearAllOverlays",
1213            "clearOverlaysInRange",
1214            "removeOverlay",
1215            "addConceal",
1216            "clearConcealNamespace",
1217            "clearConcealsInRange",
1218            "addSoftBreak",
1219            "clearSoftBreakNamespace",
1220            "clearSoftBreaksInRange",
1221            "submitViewTransform",
1222            "clearViewTransform",
1223            "setLayoutHints",
1224            "setFileExplorerDecorations",
1225            "clearFileExplorerDecorations",
1226            "addVirtualText",
1227            "removeVirtualText",
1228            "removeVirtualTextsByPrefix",
1229            "clearVirtualTexts",
1230            "clearVirtualTextNamespace",
1231            "addVirtualLine",
1232            "prompt",
1233            "startPrompt",
1234            "startPromptWithInitial",
1235            "setPromptSuggestions",
1236            "setPromptSelectedIndex",
1237            "setPromptInputSync",
1238            "defineMode",
1239            "setEditorMode",
1240            "getEditorMode",
1241            "closeSplit",
1242            "setSplitBuffer",
1243            "focusSplit",
1244            "setSplitScroll",
1245            "setSplitRatio",
1246            "setSplitLabel",
1247            "clearSplitLabel",
1248            "getSplitByLabel",
1249            "distributeSplitsEvenly",
1250            "setBufferCursor",
1251            "setLineIndicator",
1252            "clearLineIndicators",
1253            "setLineNumbers",
1254            "setViewMode",
1255            "setViewState",
1256            "getViewState",
1257            "setGlobalState",
1258            "getGlobalState",
1259            "setLineWrap",
1260            "createScrollSyncGroup",
1261            "setScrollSyncAnchors",
1262            "removeScrollSyncGroup",
1263            "executeActions",
1264            "showActionPopup",
1265            "setLspMenuContributions",
1266            "disableLspForLanguage",
1267            "setLspRootUri",
1268            "getAllDiagnostics",
1269            "getHandlers",
1270            "createVirtualBuffer",
1271            "createVirtualBufferInSplit",
1272            "createVirtualBufferInExistingSplit",
1273            "setVirtualBufferContent",
1274            "getTextPropertiesAtCursor",
1275            "spawnProcess",
1276            "spawnProcessWait",
1277            "spawnHostProcess",
1278            "setAuthority",
1279            "clearAuthority",
1280            "setRemoteIndicatorState",
1281            "clearRemoteIndicatorState",
1282            "getBufferText",
1283            "delay",
1284            "sendLspRequest",
1285            "spawnBackgroundProcess",
1286            "killBackgroundProcess",
1287            "createTerminal",
1288            "sendTerminalInput",
1289            "closeTerminal",
1290            "signalWindow",
1291            "refreshLines",
1292            "getCurrentLocale",
1293            "loadPlugin",
1294            "unloadPlugin",
1295            "reloadPlugin",
1296            "listPlugins",
1297            "mountFloatingWidget",
1298            "updateFloatingWidget",
1299            "unmountFloatingWidget",
1300        ];
1301
1302        let mut missing = Vec::new();
1303        for method in &expected_methods {
1304            // Check that the method name appears followed by ( in the API
1305            let pattern = format!("{}(", method);
1306            if !api.contains(&pattern) {
1307                missing.push(*method);
1308            }
1309        }
1310
1311        assert!(
1312            missing.is_empty(),
1313            "Missing methods in EditorAPI interface: {:?}",
1314            missing
1315        );
1316    }
1317}