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