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