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, BackgroundProcessResult, BufferInfo,
19    BufferSavedDiff, CompositeHunk, CompositeLayoutConfig, CompositePaneStyle,
20    CompositeSourceConfig, CreateCompositeBufferOptions, CreateTerminalOptions,
21    CreateVirtualBufferInExistingSplitOptions, CreateVirtualBufferInSplitOptions,
22    CreateVirtualBufferOptions, CursorInfo, DirEntry, FormatterPackConfig, GrammarInfoSnapshot,
23    GrepMatch, JsDiagnostic, JsPosition, JsRange, JsTextPropertyEntry, LanguagePackConfig,
24    LayoutHints, LspServerPackConfig, OverlayColorSpec, OverlayOptions, ProcessLimitsPackConfig,
25    ReplaceResult, SpawnResult, TerminalResult, TextPropertiesAtCursor, TsHighlightSpan,
26    ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewportInfo, VirtualBufferResult,
27};
28use fresh_core::command::Suggestion;
29use fresh_core::file_explorer::FileExplorerDecoration;
30use fresh_core::text_property::InlineOverlay;
31
32/// Get the TypeScript declaration for a type by name
33///
34/// Returns None if the type is not known (not registered in this mapping).
35/// Add new types here when they're added to api.rs with `#[derive(TS)]`.
36fn get_type_decl(type_name: &str) -> Option<String> {
37    let cfg = TsConfig::default();
38    // Map TypeScript type names to their ts-rs declarations
39    // The type name should match either the Rust struct name or the ts(rename = "...") value
40    match type_name {
41        // Core types
42        "BufferInfo" => Some(BufferInfo::decl(&cfg)),
43        "CursorInfo" => Some(CursorInfo::decl(&cfg)),
44        "ViewportInfo" => Some(ViewportInfo::decl(&cfg)),
45        "ActionSpec" => Some(ActionSpec::decl(&cfg)),
46        "BufferSavedDiff" => Some(BufferSavedDiff::decl(&cfg)),
47        "LayoutHints" => Some(LayoutHints::decl(&cfg)),
48
49        // Process types
50        "SpawnResult" => Some(SpawnResult::decl(&cfg)),
51        "BackgroundProcessResult" => Some(BackgroundProcessResult::decl(&cfg)),
52
53        // Grep/Replace types
54        "GrepMatch" => Some(GrepMatch::decl(&cfg)),
55        "ReplaceResult" => Some(ReplaceResult::decl(&cfg)),
56
57        // Terminal types
58        "TerminalResult" => Some(TerminalResult::decl(&cfg)),
59        "CreateTerminalOptions" => Some(CreateTerminalOptions::decl(&cfg)),
60
61        // Composite buffer types (ts-rs renames these with Ts prefix)
62        "TsCompositeLayoutConfig" | "CompositeLayoutConfig" => {
63            Some(CompositeLayoutConfig::decl(&cfg))
64        }
65        "TsCompositeSourceConfig" | "CompositeSourceConfig" => {
66            Some(CompositeSourceConfig::decl(&cfg))
67        }
68        "TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl(&cfg)),
69        "TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl(&cfg)),
70        "TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
71            Some(CreateCompositeBufferOptions::decl(&cfg))
72        }
73
74        // View transform types
75        "ViewTokenWireKind" => Some(ViewTokenWireKind::decl(&cfg)),
76        "ViewTokenStyle" => Some(ViewTokenStyle::decl(&cfg)),
77        "ViewTokenWire" => Some(ViewTokenWire::decl(&cfg)),
78
79        // UI types (ts-rs renames these with Ts prefix)
80        "TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl(&cfg)),
81        "ActionPopupOptions" => Some(ActionPopupOptions::decl(&cfg)),
82        "TsHighlightSpan" => Some(TsHighlightSpan::decl(&cfg)),
83        "FileExplorerDecoration" => Some(FileExplorerDecoration::decl(&cfg)),
84
85        // Virtual buffer option types
86        "TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl(&cfg)),
87        "CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl(&cfg)),
88        "CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl(&cfg)),
89        "CreateVirtualBufferInExistingSplitOptions" => {
90            Some(CreateVirtualBufferInExistingSplitOptions::decl(&cfg))
91        }
92
93        // Return types
94        "TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl(&cfg)),
95        "VirtualBufferResult" => Some(VirtualBufferResult::decl(&cfg)),
96
97        // Prompt and directory types
98        "PromptSuggestion" | "Suggestion" => Some(Suggestion::decl(&cfg)),
99        "DirEntry" => Some(DirEntry::decl(&cfg)),
100
101        // Diagnostic types
102        "JsDiagnostic" => Some(JsDiagnostic::decl(&cfg)),
103        "JsRange" => Some(JsRange::decl(&cfg)),
104        "JsPosition" => Some(JsPosition::decl(&cfg)),
105
106        // Grammar info types
107        "GrammarInfoSnapshot" => Some(GrammarInfoSnapshot::decl(&cfg)),
108
109        // Language pack types
110        "LanguagePackConfig" => Some(LanguagePackConfig::decl(&cfg)),
111        "LspServerPackConfig" => Some(LspServerPackConfig::decl(&cfg)),
112        "ProcessLimitsPackConfig" => Some(ProcessLimitsPackConfig::decl(&cfg)),
113        "FormatterPackConfig" => Some(FormatterPackConfig::decl(&cfg)),
114
115        // Overlay/inline styling types
116        "OverlayOptions" => Some(OverlayOptions::decl(&cfg)),
117        "OverlayColorSpec" => Some(OverlayColorSpec::decl(&cfg)),
118        "InlineOverlay" => Some(InlineOverlay::decl(&cfg)),
119
120        // Authority — payload schema for `editor.setAuthority(...)`.
121        // Hand-written because the authoritative struct lives in
122        // `fresh-editor` and this crate must not depend on it
123        // (principle 3: core is opaque to backend kinds). Keep this in
124        // sync with `crates/fresh-editor/src/services/authority/mod.rs`.
125        "AuthorityPayload" => Some(AUTHORITY_PAYLOAD_DECL.to_string()),
126
127        // Remote Indicator override — payload for
128        // `editor.setRemoteIndicatorState(...)`. Same hand-written
129        // rationale: the authoritative enum lives in
130        // `fresh-editor::view::ui::status_bar::RemoteIndicatorOverride`
131        // and this crate must not depend on it. Keep in sync.
132        "RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
133
134        _ => None,
135    }
136}
137
138/// Hand-written declaration for `AuthorityPayload` and its helpers.
139/// See the doc comment on the match arm for why this isn't ts-rs.
140///
141/// Emitted as plain `type …` (not `export type …`) to match the rest of
142/// the file — the generated d.ts lives in global scope and plugins
143/// reference types by bare name without importing them.
144const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
145
146type AuthoritySpawner =
147  | { kind: "local" }
148  | {
149      kind: "docker-exec";
150      container_id: string;
151      user?: string | null;
152      workspace?: string | null;
153    };
154
155type AuthorityTerminalWrapper =
156  | { kind: "host-shell" }
157  | {
158      kind: "explicit";
159      command: string;
160      args: string[];
161      manages_cwd?: boolean;
162    };
163
164type AuthorityPayload = {
165  filesystem: AuthorityFilesystem;
166  spawner: AuthoritySpawner;
167  terminal_wrapper: AuthorityTerminalWrapper;
168  display_label?: string;
169};"#;
170
171/// Hand-written declaration for `RemoteIndicatorStatePayload`. Keep in
172/// sync with
173/// `crates/fresh-editor/src/view/ui/status_bar.rs::RemoteIndicatorOverride`
174/// (the struct this crate must not depend on).
175const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
176  | { kind: "local" }
177  | { kind: "connecting"; label?: string | null }
178  | { kind: "connected"; label?: string | null }
179  | { kind: "failed_attach"; error?: string | null }
180  | { kind: "disconnected"; label?: string | null };"#;
181
182/// Types that are dependencies of other types and must always be included.
183/// These are types referenced inside option structs or other complex types
184/// that aren't directly in method signatures.
185const DEPENDENCY_TYPES: &[&str] = &[
186    "TextPropertyEntry",              // Used in CreateVirtualBuffer*Options.entries
187    "TsCompositeLayoutConfig",        // Used in createCompositeBuffer opts
188    "TsCompositeSourceConfig",        // Used in createCompositeBuffer opts.sources
189    "TsCompositePaneStyle",           // Used in TsCompositeSourceConfig.style
190    "TsCompositeHunk",                // Used in createCompositeBuffer opts.hunks
191    "TsCreateCompositeBufferOptions", // Options for createCompositeBuffer
192    "ViewportInfo",                   // Used by plugins for viewport queries
193    "LayoutHints",                    // Used by plugins for view transforms
194    "ViewTokenWire",                  // Used by plugins for view transforms
195    "ViewTokenWireKind",              // Used by ViewTokenWire
196    "ViewTokenStyle",                 // Used by ViewTokenWire
197    "PromptSuggestion",               // Used by plugins for prompt suggestions
198    "DirEntry",                       // Used by plugins for directory entries
199    "BufferInfo",                     // Used by listBuffers, getBufferInfo
200    "JsDiagnostic",                   // Used by getAllDiagnostics
201    "JsRange",                        // Used by JsDiagnostic
202    "JsPosition",                     // Used by JsRange
203    "ActionSpec",                     // Used by executeActions
204    "TsActionPopupAction",            // Used by ActionPopupOptions.actions
205    "ActionPopupOptions",             // Used by showActionPopup
206    "FileExplorerDecoration",         // Used by setFileExplorerDecorations
207    "FormatterPackConfig",            // Used by LanguagePackConfig.formatter
208    "ProcessLimitsPackConfig",        // Used by LspServerPackConfig.process_limits
209    "TerminalResult",                 // Used by createTerminal return type
210    "CreateTerminalOptions",          // Used by createTerminal opts parameter
211    "CursorInfo",                     // Used by getPrimaryCursor, getAllCursors
212    "OverlayOptions",                 // Used by TextPropertyEntry.style and InlineOverlay
213    "OverlayColorSpec",               // Used by OverlayOptions.fg/bg
214    "InlineOverlay",                  // Used by TextPropertyEntry.inlineOverlays
215    "GrammarInfoSnapshot",            // Used by listGrammars
216];
217
218/// Collect TypeScript type declarations based on referenced types from proc macro
219///
220/// Uses `JSEDITORAPI_REFERENCED_TYPES` to determine which types to include.
221/// Also includes dependency types that are referenced by other types.
222pub fn collect_ts_types() -> String {
223    use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
224
225    let mut types = Vec::new();
226    // Track by declaration content to prevent duplicates from aliases
227    // (e.g., "CompositeHunk" and "TsCompositeHunk" both resolve to the same decl)
228    let mut included_decls = std::collections::HashSet::new();
229
230    // First, include dependency types (order matters - dependencies first)
231    for type_name in DEPENDENCY_TYPES {
232        if let Some(decl) = get_type_decl(type_name) {
233            if included_decls.insert(decl.clone()) {
234                types.push(decl);
235            }
236        }
237    }
238
239    // Collect types referenced by the API
240    for type_name in JSEDITORAPI_REFERENCED_TYPES {
241        if let Some(decl) = get_type_decl(type_name) {
242            if included_decls.insert(decl.clone()) {
243                types.push(decl);
244            }
245        } else {
246            // Log warning for unknown types (these need to be added to get_type_decl)
247            eprintln!(
248                "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
249                type_name
250            );
251        }
252    }
253
254    types.join("\n\n")
255}
256
257/// Validate TypeScript syntax using oxc parser
258///
259/// Returns Ok(()) if the syntax is valid, or an error with the parse errors.
260pub fn validate_typescript(source: &str) -> Result<(), String> {
261    let allocator = Allocator::default();
262    let source_type = SourceType::d_ts();
263
264    let parser_ret = Parser::new(&allocator, source, source_type).parse();
265
266    if parser_ret.errors.is_empty() {
267        Ok(())
268    } else {
269        let errors: Vec<String> = parser_ret
270            .errors
271            .iter()
272            .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
273            .collect();
274        Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
275    }
276}
277
278/// Format TypeScript source code using oxc codegen
279///
280/// Parses the TypeScript and regenerates it with consistent formatting.
281/// Returns the original source if parsing fails.
282pub fn format_typescript(source: &str) -> String {
283    let allocator = Allocator::default();
284    let source_type = SourceType::d_ts();
285
286    let parser_ret = Parser::new(&allocator, source, source_type).parse();
287
288    if !parser_ret.errors.is_empty() {
289        // Return original source if parsing fails
290        return source.to_string();
291    }
292
293    // Generate formatted code from AST
294    Codegen::new().build(&parser_ret.program).code
295}
296
297/// Generate and write the complete fresh.d.ts file
298///
299/// Combines ts-rs generated types with proc macro output,
300/// validates the syntax, formats the output, and writes to disk.
301pub fn write_fresh_dts() -> Result<(), String> {
302    use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
303
304    let ts_types = collect_ts_types();
305
306    // After the macro-generated EditorAPI interface, merge in a
307    // typed overload of `getPluginApi` that looks through the
308    // `FreshPluginRegistry` interface (declared in the preamble,
309    // augmented by each loaded plugin's `plugins.d.ts`). Declared
310    // AFTER the base interface so TypeScript's overload resolution
311    // prefers the typed form when the name is a known key; the
312    // untyped `getPluginApi(name: string): unknown | null` from the
313    // macro output is the fallback.
314    let plugin_api_trailer = r#"
315
316/**
317 * Typed overload of `editor.getPluginApi`. When the caller passes a
318 * key that some loaded plugin declared in `FreshPluginRegistry`, the
319 * return type is narrowed to that plugin's API. Unknown names fall
320 * through to the untyped `unknown | null` signature.
321 */
322interface EditorAPI {
323  getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
324}
325"#;
326
327    let content = format!(
328        "{}\n{}\n{}{}",
329        JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
330    );
331
332    // Validate the generated TypeScript syntax
333    validate_typescript(&content)?;
334
335    // Format the TypeScript
336    let formatted = format_typescript(&content);
337
338    // Determine output path - write to fresh-editor/plugins/lib/fresh.d.ts
339    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
340    let output_path = std::path::Path::new(&manifest_dir)
341        .parent() // crates/
342        .and_then(|p| p.parent()) // workspace root
343        .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
344        .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
345
346    // Only write if content changed
347    let should_write = match std::fs::read_to_string(&output_path) {
348        Ok(existing) => existing != formatted,
349        Err(_) => true,
350    };
351
352    if should_write {
353        if let Some(parent) = output_path.parent() {
354            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
355        }
356        std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
357    }
358
359    Ok(())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    /// Generate, validate, format, and write fresh.d.ts
367    /// Run with: cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored --nocapture
368    #[test]
369    #[ignore]
370    fn write_fresh_dts_file() {
371        // write_fresh_dts validates syntax and formats before writing
372        write_fresh_dts().expect("Failed to write fresh.d.ts");
373        println!("Successfully generated, validated, and formatted fresh.d.ts");
374    }
375
376    /// Type check all plugins using TypeScript compiler
377    /// Skips if tsc is not available in PATH
378    /// Run with: cargo test -p fresh-plugin-runtime type_check_plugins -- --ignored --nocapture
379    #[test]
380    #[ignore]
381    fn type_check_plugins() {
382        // Check if tsc is available
383        let tsc_check = std::process::Command::new("tsc").arg("--version").output();
384
385        match tsc_check {
386            Ok(output) if output.status.success() => {
387                println!(
388                    "Found tsc: {}",
389                    String::from_utf8_lossy(&output.stdout).trim()
390                );
391            }
392            _ => {
393                println!("tsc not found in PATH, skipping type check test");
394                return;
395            }
396        }
397
398        // Find the check-types.sh script
399        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
400        let script_path = std::path::Path::new(&manifest_dir)
401            .parent()
402            .and_then(|p| p.parent())
403            .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
404            .expect("Failed to find check-types.sh");
405
406        println!("Running type check script: {}", script_path.display());
407
408        // Run the check-types.sh script
409        let output = std::process::Command::new("bash")
410            .arg(&script_path)
411            .output()
412            .expect("Failed to run check-types.sh");
413
414        let stdout = String::from_utf8_lossy(&output.stdout);
415        let stderr = String::from_utf8_lossy(&output.stderr);
416
417        println!("stdout:\n{}", stdout);
418        if !stderr.is_empty() {
419            println!("stderr:\n{}", stderr);
420        }
421
422        // The script outputs "X file(s) had type errors" if there are errors
423        if stdout.contains("had type errors") || !output.status.success() {
424            panic!(
425                "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
426            );
427        }
428
429        println!("All plugins type check successfully!");
430    }
431
432    // ========================================================================
433    // Type declaration tests
434    // ========================================================================
435
436    #[test]
437    fn test_get_type_decl_returns_all_expected_types() {
438        let expected_types = vec![
439            "BufferInfo",
440            "CursorInfo",
441            "ViewportInfo",
442            "ActionSpec",
443            "BufferSavedDiff",
444            "LayoutHints",
445            "SpawnResult",
446            "BackgroundProcessResult",
447            "TerminalResult",
448            "CreateTerminalOptions",
449            "TsCompositeLayoutConfig",
450            "TsCompositeSourceConfig",
451            "TsCompositePaneStyle",
452            "TsCompositeHunk",
453            "TsCreateCompositeBufferOptions",
454            "ViewTokenWireKind",
455            "ViewTokenStyle",
456            "ViewTokenWire",
457            "TsActionPopupAction",
458            "ActionPopupOptions",
459            "TsHighlightSpan",
460            "FileExplorerDecoration",
461            "TextPropertyEntry",
462            "CreateVirtualBufferOptions",
463            "CreateVirtualBufferInSplitOptions",
464            "CreateVirtualBufferInExistingSplitOptions",
465            "TextPropertiesAtCursor",
466            "VirtualBufferResult",
467            "PromptSuggestion",
468            "DirEntry",
469            "JsDiagnostic",
470            "JsRange",
471            "JsPosition",
472            "LanguagePackConfig",
473            "LspServerPackConfig",
474            "ProcessLimitsPackConfig",
475            "FormatterPackConfig",
476        ];
477
478        for type_name in &expected_types {
479            assert!(
480                get_type_decl(type_name).is_some(),
481                "get_type_decl should return a declaration for '{}'",
482                type_name
483            );
484        }
485    }
486
487    #[test]
488    fn test_get_type_decl_aliases_resolve_same() {
489        // Rust name aliases should produce the same declaration as ts-rs name
490        let alias_pairs = vec![
491            ("CompositeHunk", "TsCompositeHunk"),
492            ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
493            ("CompositeSourceConfig", "TsCompositeSourceConfig"),
494            ("CompositePaneStyle", "TsCompositePaneStyle"),
495            (
496                "CreateCompositeBufferOptions",
497                "TsCreateCompositeBufferOptions",
498            ),
499            ("ActionPopupAction", "TsActionPopupAction"),
500            ("Suggestion", "PromptSuggestion"),
501            ("JsTextPropertyEntry", "TextPropertyEntry"),
502        ];
503
504        for (rust_name, ts_name) in &alias_pairs {
505            let rust_decl = get_type_decl(rust_name);
506            let ts_decl = get_type_decl(ts_name);
507            assert!(
508                rust_decl.is_some(),
509                "get_type_decl should handle Rust name '{}'",
510                rust_name
511            );
512            assert_eq!(
513                rust_decl, ts_decl,
514                "Alias '{}' and '{}' should produce identical declarations",
515                rust_name, ts_name
516            );
517        }
518    }
519
520    #[test]
521    fn test_terminal_types_exist() {
522        let terminal_result = get_type_decl("TerminalResult");
523        assert!(
524            terminal_result.is_some(),
525            "TerminalResult should be defined"
526        );
527        let decl = terminal_result.unwrap();
528        assert!(
529            decl.contains("bufferId"),
530            "TerminalResult should have bufferId field"
531        );
532        assert!(
533            decl.contains("terminalId"),
534            "TerminalResult should have terminalId field"
535        );
536        assert!(
537            decl.contains("splitId"),
538            "TerminalResult should have splitId field"
539        );
540
541        let terminal_opts = get_type_decl("CreateTerminalOptions");
542        assert!(
543            terminal_opts.is_some(),
544            "CreateTerminalOptions should be defined"
545        );
546    }
547
548    #[test]
549    fn test_cursor_info_type_exists() {
550        let cursor_info = get_type_decl("CursorInfo");
551        assert!(cursor_info.is_some(), "CursorInfo should be defined");
552        let decl = cursor_info.unwrap();
553        assert!(
554            decl.contains("position"),
555            "CursorInfo should have position field"
556        );
557        assert!(
558            decl.contains("selection"),
559            "CursorInfo should have selection field"
560        );
561    }
562
563    #[test]
564    fn test_collect_ts_types_no_duplicates() {
565        let output = collect_ts_types();
566        let lines: Vec<&str> = output.lines().collect();
567
568        // Check for duplicate type/interface declarations
569        let mut declarations = std::collections::HashSet::new();
570        for line in &lines {
571            let trimmed = line.trim();
572            // Match type declarations: "type Foo = {" or "type Foo ="
573            if trimmed.starts_with("type ") && trimmed.contains('=') {
574                let name = trimmed
575                    .strip_prefix("type ")
576                    .unwrap()
577                    .split(|c: char| c == '=' || c.is_whitespace())
578                    .next()
579                    .unwrap();
580                assert!(
581                    declarations.insert(name.to_string()),
582                    "Duplicate type declaration found: '{}'",
583                    name
584                );
585            }
586        }
587    }
588
589    #[test]
590    fn test_collect_ts_types_includes_dependency_types() {
591        let output = collect_ts_types();
592        let required_types = [
593            "TextPropertyEntry",
594            "TsCompositeLayoutConfig",
595            "TsCompositeSourceConfig",
596            "TsCompositePaneStyle",
597            "TsCompositeHunk",
598            "TsCreateCompositeBufferOptions",
599            "PromptSuggestion",
600            "BufferInfo",
601            "CursorInfo",
602            "TerminalResult",
603            "CreateTerminalOptions",
604        ];
605
606        for type_name in &required_types {
607            assert!(
608                output.contains(type_name),
609                "collect_ts_types output should contain type '{}'",
610                type_name
611            );
612        }
613    }
614
615    #[test]
616    fn test_generated_dts_validates_as_typescript() {
617        use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
618
619        let ts_types = collect_ts_types();
620        let content = format!(
621            "{}\n{}\n{}",
622            JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
623        );
624
625        validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
626    }
627
628    #[test]
629    fn test_generated_dts_no_undefined_type_references() {
630        use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
631
632        let ts_types = collect_ts_types();
633        let content = format!(
634            "{}\n{}\n{}",
635            JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
636        );
637
638        // Collect all defined type names
639        let mut defined_types = std::collections::HashSet::new();
640        // Built-in types
641        for builtin in &[
642            "number",
643            "string",
644            "boolean",
645            "void",
646            "unknown",
647            "null",
648            "undefined",
649            "Record",
650            "Array",
651            "Promise",
652            "ProcessHandle",
653            "PromiseLike",
654            "BufferId",
655            "SplitId",
656            "EditorAPI",
657        ] {
658            defined_types.insert(builtin.to_string());
659        }
660
661        // Extract defined types from declarations
662        for line in content.lines() {
663            let trimmed = line.trim();
664            if trimmed.starts_with("type ") && trimmed.contains('=') {
665                if let Some(name) = trimmed
666                    .strip_prefix("type ")
667                    .unwrap()
668                    .split(|c: char| c == '=' || c.is_whitespace())
669                    .next()
670                {
671                    defined_types.insert(name.to_string());
672                }
673            }
674            if trimmed.starts_with("interface ") {
675                if let Some(name) = trimmed
676                    .strip_prefix("interface ")
677                    .unwrap()
678                    .split(|c: char| !c.is_alphanumeric() && c != '_')
679                    .next()
680                {
681                    defined_types.insert(name.to_string());
682                }
683            }
684        }
685
686        // Extract capitalized identifiers from EditorAPI method signature lines only
687        // (skip JSDoc comment lines which contain prose with capitalized words)
688        let interface_section = JSEDITORAPI_TS_EDITOR_API;
689        let mut undefined_refs = Vec::new();
690
691        for line in interface_section.lines() {
692            let trimmed = line.trim();
693
694            // Skip JSDoc comments and blank lines
695            if trimmed.starts_with('*')
696                || trimmed.starts_with("/*")
697                || trimmed.starts_with("//")
698                || trimmed.is_empty()
699                || trimmed == "{"
700                || trimmed == "}"
701            {
702                continue;
703            }
704
705            // This should be a method signature line
706            for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
707                if word.is_empty() {
708                    continue;
709                }
710                // Type references start with uppercase letter
711                if word.chars().next().is_some_and(|c| c.is_uppercase())
712                    && !defined_types.contains(word)
713                {
714                    undefined_refs.push(word.to_string());
715                }
716            }
717        }
718
719        // Remove duplicates for clearer error message
720        undefined_refs.sort();
721        undefined_refs.dedup();
722
723        assert!(
724            undefined_refs.is_empty(),
725            "Found undefined type references in EditorAPI interface: {:?}",
726            undefined_refs
727        );
728    }
729
730    #[test]
731    fn test_editor_api_cursor_methods_have_typed_returns() {
732        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
733
734        let api = JSEDITORAPI_TS_EDITOR_API;
735
736        // getPrimaryCursor should return CursorInfo | null, not unknown
737        assert!(
738            api.contains("getPrimaryCursor(): CursorInfo | null;"),
739            "getPrimaryCursor should return CursorInfo | null, got: {}",
740            api.lines()
741                .find(|l| l.contains("getPrimaryCursor"))
742                .unwrap_or("not found")
743        );
744
745        // getAllCursors should return CursorInfo[], not unknown
746        assert!(
747            api.contains("getAllCursors(): CursorInfo[];"),
748            "getAllCursors should return CursorInfo[], got: {}",
749            api.lines()
750                .find(|l| l.contains("getAllCursors"))
751                .unwrap_or("not found")
752        );
753
754        // getAllCursorPositions should return number[], not unknown
755        assert!(
756            api.contains("getAllCursorPositions(): number[];"),
757            "getAllCursorPositions should return number[], got: {}",
758            api.lines()
759                .find(|l| l.contains("getAllCursorPositions"))
760                .unwrap_or("not found")
761        );
762    }
763
764    #[test]
765    fn test_editor_api_terminal_methods_use_defined_types() {
766        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
767
768        let api = JSEDITORAPI_TS_EDITOR_API;
769
770        // createTerminal should use CreateTerminalOptions and TerminalResult
771        assert!(
772            api.contains("CreateTerminalOptions"),
773            "createTerminal should reference CreateTerminalOptions"
774        );
775        assert!(
776            api.contains("TerminalResult"),
777            "createTerminal should reference TerminalResult"
778        );
779    }
780
781    #[test]
782    fn test_editor_api_composite_methods_use_ts_prefix_types() {
783        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
784
785        let api = JSEDITORAPI_TS_EDITOR_API;
786
787        // updateCompositeAlignment should use TsCompositeHunk (not CompositeHunk)
788        assert!(
789            api.contains("TsCompositeHunk[]"),
790            "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
791        );
792
793        // createCompositeBuffer should use TsCreateCompositeBufferOptions
794        assert!(
795            api.contains("TsCreateCompositeBufferOptions"),
796            "createCompositeBuffer should use TsCreateCompositeBufferOptions"
797        );
798    }
799
800    #[test]
801    fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
802        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
803
804        let api = JSEDITORAPI_TS_EDITOR_API;
805
806        // setPromptSuggestions should use PromptSuggestion (not Suggestion)
807        assert!(
808            api.contains("PromptSuggestion[]"),
809            "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
810        );
811    }
812
813    #[test]
814    fn test_all_editor_api_methods_present() {
815        use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
816
817        let api = JSEDITORAPI_TS_EDITOR_API;
818
819        // Comprehensive list of all expected methods
820        let expected_methods = vec![
821            "apiVersion",
822            "getActiveBufferId",
823            "getActiveSplitId",
824            "listBuffers",
825            "debug",
826            "info",
827            "warn",
828            "error",
829            "setStatus",
830            "copyToClipboard",
831            "setClipboard",
832            "registerCommand",
833            "unregisterCommand",
834            "setContext",
835            "executeAction",
836            "getCursorPosition",
837            "getBufferPath",
838            "getBufferLength",
839            "isBufferModified",
840            "saveBufferToPath",
841            "getBufferInfo",
842            "getPrimaryCursor",
843            "getAllCursors",
844            "getAllCursorPositions",
845            "getViewport",
846            "getCursorLine",
847            "getLineStartPosition",
848            "getLineEndPosition",
849            "getBufferLineCount",
850            "scrollToLineCenter",
851            "findBufferByPath",
852            "getBufferSavedDiff",
853            "insertText",
854            "deleteRange",
855            "insertAtCursor",
856            "openFile",
857            "openFileInSplit",
858            "showBuffer",
859            "closeBuffer",
860            "on",
861            "off",
862            "getEnv",
863            "getCwd",
864            "pathJoin",
865            "pathDirname",
866            "pathBasename",
867            "pathExtname",
868            "pathIsAbsolute",
869            "utf8ByteLength",
870            "fileExists",
871            "readFile",
872            "writeFile",
873            "readDir",
874            "createDir",
875            "removePath",
876            "renamePath",
877            "copyPath",
878            "getTempDir",
879            "getConfig",
880            "getUserConfig",
881            "reloadConfig",
882            "reloadThemes",
883            "reloadAndApplyTheme",
884            "registerGrammar",
885            "registerLanguageConfig",
886            "registerLspServer",
887            "reloadGrammars",
888            "getConfigDir",
889            "getDataDir",
890            "getThemesDir",
891            "applyTheme",
892            "getThemeSchema",
893            "getBuiltinThemes",
894            "getThemeData",
895            "saveThemeFile",
896            "themeFileExists",
897            "deleteTheme",
898            "fileStat",
899            "isProcessRunning",
900            "killProcess",
901            "pluginTranslate",
902            "createCompositeBuffer",
903            "updateCompositeAlignment",
904            "closeCompositeBuffer",
905            "flushLayout",
906            "compositeNextHunk",
907            "compositePrevHunk",
908            "getHighlights",
909            "addOverlay",
910            "clearNamespace",
911            "clearAllOverlays",
912            "clearOverlaysInRange",
913            "removeOverlay",
914            "addConceal",
915            "clearConcealNamespace",
916            "clearConcealsInRange",
917            "addSoftBreak",
918            "clearSoftBreakNamespace",
919            "clearSoftBreaksInRange",
920            "submitViewTransform",
921            "clearViewTransform",
922            "setLayoutHints",
923            "setFileExplorerDecorations",
924            "clearFileExplorerDecorations",
925            "addVirtualText",
926            "removeVirtualText",
927            "removeVirtualTextsByPrefix",
928            "clearVirtualTexts",
929            "clearVirtualTextNamespace",
930            "addVirtualLine",
931            "prompt",
932            "startPrompt",
933            "startPromptWithInitial",
934            "setPromptSuggestions",
935            "setPromptInputSync",
936            "defineMode",
937            "setEditorMode",
938            "getEditorMode",
939            "closeSplit",
940            "setSplitBuffer",
941            "focusSplit",
942            "setSplitScroll",
943            "setSplitRatio",
944            "setSplitLabel",
945            "clearSplitLabel",
946            "getSplitByLabel",
947            "distributeSplitsEvenly",
948            "setBufferCursor",
949            "setLineIndicator",
950            "clearLineIndicators",
951            "setLineNumbers",
952            "setViewMode",
953            "setViewState",
954            "getViewState",
955            "setGlobalState",
956            "getGlobalState",
957            "setLineWrap",
958            "createScrollSyncGroup",
959            "setScrollSyncAnchors",
960            "removeScrollSyncGroup",
961            "executeActions",
962            "showActionPopup",
963            "disableLspForLanguage",
964            "setLspRootUri",
965            "getAllDiagnostics",
966            "getHandlers",
967            "createVirtualBuffer",
968            "createVirtualBufferInSplit",
969            "createVirtualBufferInExistingSplit",
970            "setVirtualBufferContent",
971            "getTextPropertiesAtCursor",
972            "spawnProcess",
973            "spawnProcessWait",
974            "spawnHostProcess",
975            "setAuthority",
976            "clearAuthority",
977            "setRemoteIndicatorState",
978            "clearRemoteIndicatorState",
979            "getBufferText",
980            "delay",
981            "sendLspRequest",
982            "spawnBackgroundProcess",
983            "killBackgroundProcess",
984            "createTerminal",
985            "sendTerminalInput",
986            "closeTerminal",
987            "refreshLines",
988            "getCurrentLocale",
989            "loadPlugin",
990            "unloadPlugin",
991            "reloadPlugin",
992            "listPlugins",
993        ];
994
995        let mut missing = Vec::new();
996        for method in &expected_methods {
997            // Check that the method name appears followed by ( in the API
998            let pattern = format!("{}(", method);
999            if !api.contains(&pattern) {
1000                missing.push(*method);
1001            }
1002        }
1003
1004        assert!(
1005            missing.is_empty(),
1006            "Missing methods in EditorAPI interface: {:?}",
1007            missing
1008        );
1009    }
1010}