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