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