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