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