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  trust_changed: { level: "trusted" | "restricted" | "blocked" };
517
518  // ── buffer lifecycle ─────────────────────────────────────────────────────
519  buffer_activated: { buffer_id: number };
520  buffer_deactivated: { buffer_id: number };
521  buffer_closed: { buffer_id: number };
522
523  // ── file I/O ─────────────────────────────────────────────────────────────
524  before_file_open: { path: string };
525  after_file_open: { path: string; buffer_id: number };
526  before_file_save: { path: string; buffer_id: number };
527  after_file_save: { path: string; buffer_id: number };
528  /**
529   * Fired by the file explorer after a paste/duplicate/etc. mutates
530   * the filesystem without going through a buffer save. Plugins that
531   * surface FS-derived state (git status badges, etc.) should
532   * subscribe in addition to `after_file_save` to refresh on
533   * explorer-driven changes too.
534   */
535  after_file_explorer_change: { path: string };
536
537  // ── text edits ───────────────────────────────────────────────────────────
538  before_insert: { buffer_id: number; position: number; text: string };
539  after_insert: {
540    buffer_id: number;
541    position: number;
542    text: string;
543    affected_start: number;
544    affected_end: number;
545    start_line: number;
546    end_line: number;
547    lines_added: number;
548  };
549  before_delete: { buffer_id: number; start: number; end: number };
550  after_delete: {
551    buffer_id: number;
552    start: number;
553    end: number;
554    deleted_text: string;
555    affected_start: number;
556    deleted_len: number;
557    start_line: number;
558    end_line: number;
559    lines_removed: number;
560  };
561
562  // ── cursor & viewport ────────────────────────────────────────────────────
563  cursor_moved: {
564    buffer_id: number;
565    cursor_id: number;
566    old_position: number;
567    new_position: number;
568    line: number;
569    text_properties: Record<string, unknown>[];
570  };
571  viewport_changed: {
572    split_id: number;
573    buffer_id: number;
574    top_byte: number;
575    top_line: number | null;
576    width: number;
577    height: number;
578  };
579
580  // ── rendering ────────────────────────────────────────────────────────────
581  render_start: { buffer_id: number };
582  render_line: {
583    buffer_id: number;
584    line_number: number;
585    byte_start: number;
586    byte_end: number;
587    content: string;
588  };
589  lines_changed: {
590    buffer_id: number;
591    lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
592  };
593  view_transform_request: {
594    buffer_id: number;
595    split_id: number;
596    viewport_start: number;
597    viewport_end: number;
598    tokens: ViewTokenWire[];
599    cursor_positions: number[];
600  };
601
602  // ── commands ─────────────────────────────────────────────────────────────
603  pre_command: { action: string | Record<string, unknown> };
604  post_command: { action: string | Record<string, unknown> };
605  idle: { milliseconds: number };
606  resize: { width: number; height: number };
607
608  // ── prompts ──────────────────────────────────────────────────────────────
609  prompt_changed: { prompt_type: string; input: string };
610  prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
611  prompt_cancelled: { prompt_type: string; input: string };
612  prompt_selection_changed: { prompt_type: string; selected_index: number };
613
614  // ── mouse ────────────────────────────────────────────────────────────────
615  mouse_click: MouseClickHookArgs;
616  mouse_move: { column: number; row: number; content_x: number; content_y: number };
617  mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
618
619  // ── LSP ──────────────────────────────────────────────────────────────────
620  diagnostics_updated: { uri: string; count: number };
621  lsp_references: {
622    symbol: string;
623    locations: { file: string; line: number; column: number }[];
624  };
625  lsp_server_request: {
626    language: string;
627    method: string;
628    server_command: string;
629    params: string | null;
630  };
631  lsp_server_error: {
632    language: string;
633    server_command: string;
634    error_type: string;
635    message: string;
636  };
637  lsp_status_clicked: {
638    language: string;
639    has_error: boolean;
640    missing_servers: string[];
641    user_dismissed: boolean;
642  };
643
644  // ── UI events ────────────────────────────────────────────────────────────
645  action_popup_result: { popup_id: string; action_id: string };
646  /**
647   * User clicked a plugin-registered status-bar token. Subscribers
648   * filter by `plugin_name` + `token_name`. Use this to re-open a
649   * deferred prompt or surface the relevant settings UI for whatever
650   * the token represents (e.g. trust chip → trust-elevation popup).
651   */
652  status_bar_token_clicked: { plugin_name: string; token_name: string };
653  process_output: { process_id: number; data: string };
654  language_changed: { buffer_id: number; language: string };
655  theme_inspect_key: { theme_name: string; key: string };
656  keyboard_shortcuts: { bindings: { key: string; action: string }[] };
657
658  // ── PTY terminals (see crates/fresh-core/src/hooks.rs) ───────────────────
659  // `window_id` is the editor window owning the terminal (== session id),
660  // so a plugin can attribute output to a session: output from ANY terminal
661  // in the window counts, and it fires on every PTY read (in-place redraws
662  // and carriage-return progress bars register, not just newlines).
663  terminal_output: { terminal_id: number; window_id: number; last_line: string };
664  terminal_exit: { terminal_id: number; window_id: number; exit_code: number | null };
665
666  // ── filesystem watching (watchPath plugin API) ────────────────────────────
667  path_changed: {
668    handle: number;
669    path: string;
670    /** "modify" | "create" | "delete" | "rename" | "other" */
671    kind: string;
672  };
673
674  // ── editor sessions (Orchestrator; see orchestrator-sessions-design.md) ────────
675  window_created: { id: number; label: string; root: string };
676  window_closed: { id: number };
677  active_window_changed: { previous_id: number | null; active_id: number };
678
679  // ── widget runtime ───────────────────────────────────────────────────────
680  /**
681   * A widget mounted via `editor.mountWidgetPanel` emitted a
682   * semantic event. Fired when the host's hit-test routes a mouse
683   * click to a `Toggle` / `Button` widget node within a mounted
684   * widget panel. See `docs/internal/plugin-widget-library-design.md`.
685   *
686   * Panel ids are plugin-local: the host keys panels by
687   * (plugin, id) and delivers each event only to the plugin that
688   * owns the panel, so ids never need to be globally unique.
689   * Routing is by `panel_id` (matches the id the plugin allocated
690   * at mount time) plus `widget_key` (the stable `key` set on the
691   * widget spec node, or empty when the spec did not assign one).
692   *
693   * `event_type` and `payload` shapes:
694   *   * Toggle: `event_type = "toggle"`, `payload = { checked: <new> }`.
695   *   * Button: `event_type = "activate"`, `payload = {}`.
696   */
697  widget_event: {
698    panel_id: number;
699    widget_key: string;
700    event_type: string;
701    payload: Record<string, unknown>;
702  };
703}
704
705/**
706 * Typed overloads of `editor.on` / `editor.off`.
707 *
708 * When the event name is a key of `HookEventMap` the handler receives a
709 * fully-typed payload — TypeScript will flag misspelled field accesses at
710 * compile time. Unknown event names fall through to the untyped base
711 * signatures in the EditorAPI interface.
712 *
713 * Both function-value and handler-name forms are supported:
714 *
715 * ```ts
716 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
717 * editor.on("buffer_activated", "myHandler");   // registerHandler("myHandler", fn)
718 * ```
719 */
720interface EditorAPI {
721  on<K extends keyof HookEventMap>(
722    eventName: K,
723    handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
724  ): void;
725  on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
726  off<K extends keyof HookEventMap>(
727    eventName: K,
728    handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
729  ): void;
730  off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
731  /**
732   * Create a buffer group: multiple panels appearing as one tab.
733   * This is an async runtime binding (not a direct #[qjs] method).
734   */
735  createBufferGroup(
736    name: string,
737    mode: string,
738    layout: unknown,
739  ): Promise<BufferGroupResult>;
740}
741"#;
742
743    let content = format!(
744        "{}\n{}\n{}{}",
745        JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
746    );
747
748    // Validate the generated TypeScript syntax
749    validate_typescript(&content)?;
750
751    // Format the TypeScript
752    let formatted = format_typescript(&content);
753
754    // Determine output path - write to fresh-editor/plugins/lib/fresh.d.ts
755    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
756    let output_path = std::path::Path::new(&manifest_dir)
757        .parent() // crates/
758        .and_then(|p| p.parent()) // workspace root
759        .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
760        .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
761
762    // Only write if content changed
763    let should_write = match std::fs::read_to_string(&output_path) {
764        Ok(existing) => existing != formatted,
765        Err(_) => true,
766    };
767
768    if should_write {
769        if let Some(parent) = output_path.parent() {
770            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
771        }
772        std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
773    }
774
775    Ok(())
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    /// Generate, validate, format, and write fresh.d.ts
783    /// Run with: cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored --nocapture
784    #[test]
785    #[ignore]
786    fn write_fresh_dts_file() {
787        // write_fresh_dts validates syntax and formats before writing
788        write_fresh_dts().expect("Failed to write fresh.d.ts");
789        println!("Successfully generated, validated, and formatted fresh.d.ts");
790    }
791
792    /// Type check all plugins using TypeScript compiler
793    /// Skips if tsc is not available in PATH
794    /// Run with: cargo test -p fresh-plugin-runtime type_check_plugins -- --ignored --nocapture
795    #[test]
796    #[ignore]
797    fn type_check_plugins() {
798        // Check if tsc is available
799        let tsc_check = std::process::Command::new("tsc").arg("--version").output();
800
801        match tsc_check {
802            Ok(output) if output.status.success() => {
803                println!(
804                    "Found tsc: {}",
805                    String::from_utf8_lossy(&output.stdout).trim()
806                );
807            }
808            _ => {
809                println!("tsc not found in PATH, skipping type check test");
810                return;
811            }
812        }
813
814        // Find the check-types.sh script
815        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
816        let script_path = std::path::Path::new(&manifest_dir)
817            .parent()
818            .and_then(|p| p.parent())
819            .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
820            .expect("Failed to find check-types.sh");
821
822        println!("Running type check script: {}", script_path.display());
823
824        // Run the check-types.sh script
825        let output = std::process::Command::new("bash")
826            .arg(&script_path)
827            .output()
828            .expect("Failed to run check-types.sh");
829
830        let stdout = String::from_utf8_lossy(&output.stdout);
831        let stderr = String::from_utf8_lossy(&output.stderr);
832
833        println!("stdout:\n{}", stdout);
834        if !stderr.is_empty() {
835            println!("stderr:\n{}", stderr);
836        }
837
838        // The script outputs "X file(s) had type errors" if there are errors
839        if stdout.contains("had type errors") || !output.status.success() {
840            panic!(
841                "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
842            );
843        }
844
845        println!("All plugins type check successfully!");
846    }
847
848    // ========================================================================
849    // Type declaration tests
850    // ========================================================================
851
852    #[test]
853    fn test_get_type_decl_returns_all_expected_types() {
854        let expected_types = vec![
855            "BufferInfo",
856            "WindowInfo",
857            "CursorInfo",
858            "ViewportInfo",
859            "ScreenSize",
860            "KeyEventPayload",
861            "SplitSnapshot",
862            "ActionSpec",
863            "BufferSavedDiff",
864            "LayoutHints",
865            "SpawnResult",
866            "BackgroundProcessResult",
867            "TerminalResult",
868            "CreateTerminalOptions",
869            "CreateWindowWithTerminalOptions",
870            "SessionWithTerminalResult",
871            "TsCompositeLayoutConfig",
872            "TsCompositeSourceConfig",
873            "TsCompositePaneStyle",
874            "TsCompositeHunk",
875            "TsCreateCompositeBufferOptions",
876            "ViewTokenWireKind",
877            "TokenColor",
878            "ViewTokenStyle",
879            "ViewTokenWire",
880            "TsActionPopupAction",
881            "ActionPopupOptions",
882            "TsHighlightSpan",
883            "FileExplorerDecoration",
884            "TextPropertyEntry",
885            "CreateVirtualBufferOptions",
886            "CreateVirtualBufferInSplitOptions",
887            "CreateVirtualBufferInExistingSplitOptions",
888            "TextPropertiesAtCursor",
889            "VirtualBufferResult",
890            "PromptSuggestion",
891            "DirEntry",
892            "JsDiagnostic",
893            "JsRange",
894            "JsPosition",
895            "LanguagePackConfig",
896            "LspServerPackConfig",
897            "ProcessLimitsPackConfig",
898            "FormatterPackConfig",
899        ];
900
901        for type_name in &expected_types {
902            assert!(
903                get_type_decl(type_name).is_some(),
904                "get_type_decl should return a declaration for '{}'",
905                type_name
906            );
907        }
908    }
909
910    #[test]
911    fn test_get_type_decl_aliases_resolve_same() {
912        // Rust name aliases should produce the same declaration as ts-rs name
913        let alias_pairs = vec![
914            ("CompositeHunk", "TsCompositeHunk"),
915            ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
916            ("CompositeSourceConfig", "TsCompositeSourceConfig"),
917            ("CompositePaneStyle", "TsCompositePaneStyle"),
918            (
919                "CreateCompositeBufferOptions",
920                "TsCreateCompositeBufferOptions",
921            ),
922            ("ActionPopupAction", "TsActionPopupAction"),
923            ("Suggestion", "PromptSuggestion"),
924            ("JsTextPropertyEntry", "TextPropertyEntry"),
925        ];
926
927        for (rust_name, ts_name) in &alias_pairs {
928            let rust_decl = get_type_decl(rust_name);
929            let ts_decl = get_type_decl(ts_name);
930            assert!(
931                rust_decl.is_some(),
932                "get_type_decl should handle Rust name '{}'",
933                rust_name
934            );
935            assert_eq!(
936                rust_decl, ts_decl,
937                "Alias '{}' and '{}' should produce identical declarations",
938                rust_name, ts_name
939            );
940        }
941    }
942
943    #[test]
944    fn test_terminal_types_exist() {
945        let terminal_result = get_type_decl("TerminalResult");
946        assert!(
947            terminal_result.is_some(),
948            "TerminalResult should be defined"
949        );
950        let decl = terminal_result.unwrap();
951        assert!(
952            decl.contains("bufferId"),
953            "TerminalResult should have bufferId field"
954        );
955        assert!(
956            decl.contains("terminalId"),
957            "TerminalResult should have terminalId field"
958        );
959        assert!(
960            decl.contains("splitId"),
961            "TerminalResult should have splitId field"
962        );
963
964        let terminal_opts = get_type_decl("CreateTerminalOptions");
965        assert!(
966            terminal_opts.is_some(),
967            "CreateTerminalOptions should be defined"
968        );
969    }
970
971    #[test]
972    fn test_cursor_info_type_exists() {
973        let cursor_info = get_type_decl("CursorInfo");
974        assert!(cursor_info.is_some(), "CursorInfo should be defined");
975        let decl = cursor_info.unwrap();
976        assert!(
977            decl.contains("position"),
978            "CursorInfo should have position field"
979        );
980        assert!(
981            decl.contains("selection"),
982            "CursorInfo should have selection field"
983        );
984    }
985
986    #[test]
987    fn test_collect_ts_types_no_duplicates() {
988        let output = collect_ts_types();
989        let lines: Vec<&str> = output.lines().collect();
990
991        // Check for duplicate type/interface declarations
992        let mut declarations = std::collections::HashSet::new();
993        for line in &lines {
994            let trimmed = line.trim();
995            // Match type declarations: "type Foo = {" or "type Foo ="
996            if trimmed.starts_with("type ") && trimmed.contains('=') {
997                let name = trimmed
998                    .strip_prefix("type ")
999                    .unwrap()
1000                    .split(|c: char| c == '=' || c.is_whitespace())
1001                    .next()
1002                    .unwrap();
1003                assert!(
1004                    declarations.insert(name.to_string()),
1005                    "Duplicate type declaration found: '{}'",
1006                    name
1007                );
1008            }
1009        }
1010    }
1011
1012    #[test]
1013    fn test_collect_ts_types_includes_dependency_types() {
1014        let output = collect_ts_types();
1015        let required_types = [
1016            "TextPropertyEntry",
1017            "TsCompositeLayoutConfig",
1018            "TsCompositeSourceConfig",
1019            "TsCompositePaneStyle",
1020            "TsCompositeHunk",
1021            "TsCreateCompositeBufferOptions",
1022            "PromptSuggestion",
1023            "BufferInfo",
1024            "CursorInfo",
1025            "TerminalResult",
1026            "CreateTerminalOptions",
1027        ];
1028
1029        for type_name in &required_types {
1030            assert!(
1031                output.contains(type_name),
1032                "collect_ts_types output should contain type '{}'",
1033                type_name
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn test_generated_dts_validates_as_typescript() {
1040        use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1041
1042        let ts_types = collect_ts_types();
1043        let content = format!(
1044            "{}\n{}\n{}",
1045            JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1046        );
1047
1048        validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
1049    }
1050
1051    #[test]
1052    fn test_generated_dts_no_undefined_type_references() {
1053        use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1054
1055        let ts_types = collect_ts_types();
1056        let content = format!(
1057            "{}\n{}\n{}",
1058            JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1059        );
1060
1061        // Collect all defined type names
1062        let mut defined_types = std::collections::HashSet::new();
1063        // Built-in types
1064        for builtin in &[
1065            "number",
1066            "string",
1067            "boolean",
1068            "void",
1069            "unknown",
1070            "null",
1071            "undefined",
1072            "Record",
1073            "Array",
1074            "Promise",
1075            "ProcessHandle",
1076            "PromiseLike",
1077            "BufferId",
1078            "SplitId",
1079            "EditorAPI",
1080        ] {
1081            defined_types.insert(builtin.to_string());
1082        }
1083
1084        // Extract defined types from declarations
1085        for line in content.lines() {
1086            let trimmed = line.trim();
1087            if trimmed.starts_with("type ") && trimmed.contains('=') {
1088                if let Some(name) = trimmed
1089                    .strip_prefix("type ")
1090                    .unwrap()
1091                    .split(|c: char| c == '=' || c.is_whitespace())
1092                    .next()
1093                {
1094                    defined_types.insert(name.to_string());
1095                }
1096            }
1097            if trimmed.starts_with("interface ") {
1098                if let Some(name) = trimmed
1099                    .strip_prefix("interface ")
1100                    .unwrap()
1101                    .split(|c: char| !c.is_alphanumeric() && c != '_')
1102                    .next()
1103                {
1104                    defined_types.insert(name.to_string());
1105                }
1106            }
1107        }
1108
1109        // Extract capitalized identifiers from EditorAPI method signature lines only
1110        // (skip JSDoc comment lines which contain prose with capitalized words)
1111        let interface_section = JSEDITORAPI_TS_EDITOR_API;
1112        let mut undefined_refs = Vec::new();
1113
1114        for line in interface_section.lines() {
1115            let trimmed = line.trim();
1116
1117            // Skip JSDoc comments and blank lines
1118            if trimmed.starts_with('*')
1119                || trimmed.starts_with("/*")
1120                || trimmed.starts_with("//")
1121                || trimmed.is_empty()
1122                || trimmed == "{"
1123                || trimmed == "}"
1124            {
1125                continue;
1126            }
1127
1128            // This should be a method signature line
1129            for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
1130                if word.is_empty() {
1131                    continue;
1132                }
1133                // Type references start with uppercase letter
1134                if word.chars().next().is_some_and(|c| c.is_uppercase())
1135                    && !defined_types.contains(word)
1136                {
1137                    undefined_refs.push(word.to_string());
1138                }
1139            }
1140        }
1141
1142        // Remove duplicates for clearer error message
1143        undefined_refs.sort();
1144        undefined_refs.dedup();
1145
1146        assert!(
1147            undefined_refs.is_empty(),
1148            "Found undefined type references in EditorAPI interface: {:?}",
1149            undefined_refs
1150        );
1151    }
1152
1153    #[test]
1154    fn test_editor_api_cursor_methods_have_typed_returns() {
1155        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1156
1157        let api = JSEDITORAPI_TS_EDITOR_API;
1158
1159        // getPrimaryCursor should return CursorInfo | null, not unknown
1160        assert!(
1161            api.contains("getPrimaryCursor(): CursorInfo | null;"),
1162            "getPrimaryCursor should return CursorInfo | null, got: {}",
1163            api.lines()
1164                .find(|l| l.contains("getPrimaryCursor"))
1165                .unwrap_or("not found")
1166        );
1167
1168        // getAllCursors should return CursorInfo[], not unknown
1169        assert!(
1170            api.contains("getAllCursors(): CursorInfo[];"),
1171            "getAllCursors should return CursorInfo[], got: {}",
1172            api.lines()
1173                .find(|l| l.contains("getAllCursors"))
1174                .unwrap_or("not found")
1175        );
1176
1177        // getAllCursorPositions should return number[], not unknown
1178        assert!(
1179            api.contains("getAllCursorPositions(): number[];"),
1180            "getAllCursorPositions should return number[], got: {}",
1181            api.lines()
1182                .find(|l| l.contains("getAllCursorPositions"))
1183                .unwrap_or("not found")
1184        );
1185    }
1186
1187    #[test]
1188    fn test_editor_api_terminal_methods_use_defined_types() {
1189        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1190
1191        let api = JSEDITORAPI_TS_EDITOR_API;
1192
1193        // createTerminal should use CreateTerminalOptions and TerminalResult
1194        assert!(
1195            api.contains("CreateTerminalOptions"),
1196            "createTerminal should reference CreateTerminalOptions"
1197        );
1198        assert!(
1199            api.contains("TerminalResult"),
1200            "createTerminal should reference TerminalResult"
1201        );
1202    }
1203
1204    #[test]
1205    fn test_editor_api_composite_methods_use_ts_prefix_types() {
1206        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1207
1208        let api = JSEDITORAPI_TS_EDITOR_API;
1209
1210        // updateCompositeAlignment should use TsCompositeHunk (not CompositeHunk)
1211        assert!(
1212            api.contains("TsCompositeHunk[]"),
1213            "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
1214        );
1215
1216        // createCompositeBuffer should use TsCreateCompositeBufferOptions
1217        assert!(
1218            api.contains("TsCreateCompositeBufferOptions"),
1219            "createCompositeBuffer should use TsCreateCompositeBufferOptions"
1220        );
1221    }
1222
1223    #[test]
1224    fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1225        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1226
1227        let api = JSEDITORAPI_TS_EDITOR_API;
1228
1229        // setPromptSuggestions should use PromptSuggestion (not Suggestion)
1230        assert!(
1231            api.contains("PromptSuggestion[]"),
1232            "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1233        );
1234    }
1235
1236    #[test]
1237    fn test_all_editor_api_methods_present() {
1238        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1239
1240        let api = JSEDITORAPI_TS_EDITOR_API;
1241
1242        // Comprehensive list of all expected methods
1243        let expected_methods = vec![
1244            "apiVersion",
1245            "getActiveBufferId",
1246            "getActiveSplitId",
1247            "listBuffers",
1248            "debug",
1249            "info",
1250            "warn",
1251            "error",
1252            "setStatus",
1253            "copyToClipboard",
1254            "setClipboard",
1255            "registerCommand",
1256            "unregisterCommand",
1257            "setContext",
1258            "executeAction",
1259            "cancelPrompt",
1260            "getCursorPosition",
1261            "getBufferPath",
1262            "getBufferLength",
1263            "isBufferModified",
1264            "saveBufferToPath",
1265            "getBufferInfo",
1266            "getPrimaryCursor",
1267            "getAllCursors",
1268            "getAllCursorPositions",
1269            "getViewport",
1270            "getScreenSize",
1271            "getCursorLine",
1272            "getLineStartPosition",
1273            "getLineEndPosition",
1274            "getBufferLineCount",
1275            "scrollToLineCenter",
1276            "findBufferByPath",
1277            "getBufferSavedDiff",
1278            "insertText",
1279            "deleteRange",
1280            "insertAtCursor",
1281            "openFile",
1282            "openFileInSplit",
1283            "showBuffer",
1284            "closeBuffer",
1285            "animateArea",
1286            "animateVirtualBuffer",
1287            "cancelAnimation",
1288            "on",
1289            "off",
1290            "getEnv",
1291            "getCwd",
1292            "pathJoin",
1293            "pathDirname",
1294            "pathBasename",
1295            "pathExtname",
1296            "pathIsAbsolute",
1297            "utf8ByteLength",
1298            "fileExists",
1299            "readFile",
1300            "writeFile",
1301            "readDir",
1302            "createDir",
1303            "removePath",
1304            "renamePath",
1305            "copyPath",
1306            "getTempDir",
1307            "getConfig",
1308            "getUserConfig",
1309            "getPluginConfig",
1310            "defineConfigBoolean",
1311            "defineConfigInteger",
1312            "defineConfigNumber",
1313            "defineConfigString",
1314            // `defineConfigEnum` is hand-written in the d.ts trailer
1315            // (generic <E> propagates `values` into the return), so it's
1316            // NOT in the macro-generated EditorAPI interface.
1317            "defineConfigStringArray",
1318            "reloadConfig",
1319            "reloadThemes",
1320            "reloadAndApplyTheme",
1321            "registerGrammar",
1322            "registerLanguageConfig",
1323            "registerLspServer",
1324            "reloadGrammars",
1325            "getConfigDir",
1326            "getDataDir",
1327            "getWorkingDataDir",
1328            "getTerminalDir",
1329            "getThemesDir",
1330            "applyTheme",
1331            "getThemeSchema",
1332            "getBuiltinThemes",
1333            "getAllThemes",
1334            "getThemeData",
1335            "saveThemeFile",
1336            "themeFileExists",
1337            "deleteTheme",
1338            "fileStat",
1339            "isProcessRunning",
1340            "killProcess",
1341            "pluginTranslate",
1342            "createCompositeBuffer",
1343            "updateCompositeAlignment",
1344            "closeCompositeBuffer",
1345            "flushLayout",
1346            "compositeNextHunk",
1347            "compositePrevHunk",
1348            "getHighlights",
1349            "addOverlay",
1350            "clearNamespace",
1351            "clearAllOverlays",
1352            "clearOverlaysInRange",
1353            "clearOverlaysInRangeForNamespace",
1354            "removeOverlay",
1355            "addConceal",
1356            "clearConcealNamespace",
1357            "clearConcealsInRange",
1358            "addSoftBreak",
1359            "clearSoftBreakNamespace",
1360            "clearSoftBreaksInRange",
1361            "submitViewTransform",
1362            "clearViewTransform",
1363            "setLayoutHints",
1364            "setFileExplorerDecorations",
1365            "clearFileExplorerDecorations",
1366            "setFileExplorerSlots",
1367            "clearFileExplorerSlots",
1368            "addVirtualText",
1369            "removeVirtualText",
1370            "removeVirtualTextsByPrefix",
1371            "clearVirtualTexts",
1372            "clearVirtualTextNamespace",
1373            "addVirtualLine",
1374            "prompt",
1375            "startPrompt",
1376            "startPromptWithInitial",
1377            "setPromptSuggestions",
1378            "setPromptSelectedIndex",
1379            "setPromptInputSync",
1380            "defineMode",
1381            "setEditorMode",
1382            "getEditorMode",
1383            "closeSplit",
1384            "setSplitBuffer",
1385            "focusSplit",
1386            "setSplitScroll",
1387            "setSplitRatio",
1388            "setSplitLabel",
1389            "clearSplitLabel",
1390            "getSplitByLabel",
1391            "distributeSplitsEvenly",
1392            "setBufferCursor",
1393            "setLineIndicator",
1394            "clearLineIndicators",
1395            "setLineNumbers",
1396            "setViewMode",
1397            "setViewState",
1398            "getViewState",
1399            "setGlobalState",
1400            "getGlobalState",
1401            "setLineWrap",
1402            "createScrollSyncGroup",
1403            "setScrollSyncAnchors",
1404            "removeScrollSyncGroup",
1405            "executeActions",
1406            "showActionPopup",
1407            "setLspMenuContributions",
1408            "disableLspForLanguage",
1409            "setLspRootUri",
1410            "getAllDiagnostics",
1411            "getHandlers",
1412            "createVirtualBuffer",
1413            "createVirtualBufferInSplit",
1414            "createVirtualBufferInExistingSplit",
1415            "setVirtualBufferContent",
1416            "getTextPropertiesAtCursor",
1417            "spawnProcess",
1418            "spawnProcessWait",
1419            "spawnHostProcess",
1420            "httpFetch",
1421            "setAuthority",
1422            "clearAuthority",
1423            "setRemoteIndicatorState",
1424            "clearRemoteIndicatorState",
1425            "getBufferText",
1426            "delay",
1427            "sendLspRequest",
1428            "spawnBackgroundProcess",
1429            "killBackgroundProcess",
1430            "createTerminal",
1431            "sendTerminalInput",
1432            "closeTerminal",
1433            "signalWindow",
1434            "refreshLines",
1435            "getCurrentLocale",
1436            "loadPlugin",
1437            "unloadPlugin",
1438            "reloadPlugin",
1439            "listPlugins",
1440            "mountFloatingWidget",
1441            "updateFloatingWidget",
1442            "unmountFloatingWidget",
1443            "floatingPanelControl",
1444            "setActiveWindowAnimated",
1445        ];
1446
1447        let mut missing = Vec::new();
1448        for method in &expected_methods {
1449            // Check that the method name appears followed by ( in the API
1450            let pattern = format!("{}(", method);
1451            if !api.contains(&pattern) {
1452                missing.push(*method);
1453            }
1454        }
1455
1456        assert!(
1457            missing.is_empty(),
1458            "Missing methods in EditorAPI interface: {:?}",
1459            missing
1460        );
1461    }
1462}