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