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::TS;
16
17use fresh_core::api::{
18    ActionPopupAction, ActionPopupOptions, ActionSpec, BackgroundProcessResult, BufferInfo,
19    BufferSavedDiff, CompositeHunk, CompositeLayoutConfig, CompositePaneStyle,
20    CompositeSourceConfig, CreateCompositeBufferOptions, CreateVirtualBufferInExistingSplitOptions,
21    CreateVirtualBufferInSplitOptions, CreateVirtualBufferOptions, CursorInfo, DirEntry,
22    JsDiagnostic, JsPosition, JsRange, JsTextPropertyEntry, LayoutHints, SpawnResult,
23    TextPropertiesAtCursor, TsHighlightSpan, ViewTokenStyle, ViewTokenWire, ViewTokenWireKind,
24    ViewportInfo, VirtualBufferResult,
25};
26use fresh_core::command::Suggestion;
27use fresh_core::file_explorer::FileExplorerDecoration;
28
29/// Get the TypeScript declaration for a type by name
30///
31/// Returns None if the type is not known (not registered in this mapping).
32/// Add new types here when they're added to api.rs with `#[derive(TS)]`.
33fn get_type_decl(type_name: &str) -> Option<String> {
34    // Map TypeScript type names to their ts-rs declarations
35    // The type name should match either the Rust struct name or the ts(rename = "...") value
36    match type_name {
37        // Core types
38        "BufferInfo" => Some(BufferInfo::decl()),
39        "CursorInfo" => Some(CursorInfo::decl()),
40        "ViewportInfo" => Some(ViewportInfo::decl()),
41        "ActionSpec" => Some(ActionSpec::decl()),
42        "BufferSavedDiff" => Some(BufferSavedDiff::decl()),
43        "LayoutHints" => Some(LayoutHints::decl()),
44
45        // Process types
46        "SpawnResult" => Some(SpawnResult::decl()),
47        "BackgroundProcessResult" => Some(BackgroundProcessResult::decl()),
48
49        // Composite buffer types (ts-rs renames these with Ts prefix)
50        "TsCompositeLayoutConfig" | "CompositeLayoutConfig" => Some(CompositeLayoutConfig::decl()),
51        "TsCompositeSourceConfig" | "CompositeSourceConfig" => Some(CompositeSourceConfig::decl()),
52        "TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl()),
53        "TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl()),
54        "TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
55            Some(CreateCompositeBufferOptions::decl())
56        }
57
58        // View transform types
59        "ViewTokenWireKind" => Some(ViewTokenWireKind::decl()),
60        "ViewTokenStyle" => Some(ViewTokenStyle::decl()),
61        "ViewTokenWire" => Some(ViewTokenWire::decl()),
62
63        // UI types (ts-rs renames these with Ts prefix)
64        "TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl()),
65        "ActionPopupOptions" => Some(ActionPopupOptions::decl()),
66        "TsHighlightSpan" => Some(TsHighlightSpan::decl()),
67        "FileExplorerDecoration" => Some(FileExplorerDecoration::decl()),
68
69        // Virtual buffer option types
70        "TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl()),
71        "CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl()),
72        "CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl()),
73        "CreateVirtualBufferInExistingSplitOptions" => {
74            Some(CreateVirtualBufferInExistingSplitOptions::decl())
75        }
76
77        // Return types
78        "TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl()),
79        "VirtualBufferResult" => Some(VirtualBufferResult::decl()),
80
81        // Prompt and directory types
82        "PromptSuggestion" | "Suggestion" => Some(Suggestion::decl()),
83        "DirEntry" => Some(DirEntry::decl()),
84
85        // Diagnostic types
86        "JsDiagnostic" => Some(JsDiagnostic::decl()),
87        "JsRange" => Some(JsRange::decl()),
88        "JsPosition" => Some(JsPosition::decl()),
89
90        _ => None,
91    }
92}
93
94/// Types that are dependencies of other types and must always be included.
95/// These are types referenced inside option structs or other complex types
96/// that aren't directly in method signatures.
97const DEPENDENCY_TYPES: &[&str] = &[
98    "TextPropertyEntry",              // Used in CreateVirtualBuffer*Options.entries
99    "TsCompositeLayoutConfig",        // Used in createCompositeBuffer opts
100    "TsCompositeSourceConfig",        // Used in createCompositeBuffer opts.sources
101    "TsCompositePaneStyle",           // Used in TsCompositeSourceConfig.style
102    "TsCompositeHunk",                // Used in createCompositeBuffer opts.hunks
103    "TsCreateCompositeBufferOptions", // Options for createCompositeBuffer
104    "ViewportInfo",                   // Used by plugins for viewport queries
105    "LayoutHints",                    // Used by plugins for view transforms
106    "ViewTokenWire",                  // Used by plugins for view transforms
107    "ViewTokenWireKind",              // Used by ViewTokenWire
108    "ViewTokenStyle",                 // Used by ViewTokenWire
109    "PromptSuggestion",               // Used by plugins for prompt suggestions
110    "DirEntry",                       // Used by plugins for directory entries
111    "BufferInfo",                     // Used by listBuffers, getBufferInfo
112    "JsDiagnostic",                   // Used by getAllDiagnostics
113    "JsRange",                        // Used by JsDiagnostic
114    "JsPosition",                     // Used by JsRange
115    "ActionSpec",                     // Used by executeActions
116    "TsActionPopupAction",            // Used by ActionPopupOptions.actions
117    "ActionPopupOptions",             // Used by showActionPopup
118    "FileExplorerDecoration",         // Used by setFileExplorerDecorations
119];
120
121/// Collect TypeScript type declarations based on referenced types from proc macro
122///
123/// Uses `JSEDITORAPI_REFERENCED_TYPES` to determine which types to include.
124/// Also includes dependency types that are referenced by other types.
125pub fn collect_ts_types() -> String {
126    use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
127
128    let mut types = Vec::new();
129    let mut included = std::collections::HashSet::new();
130
131    // First, include dependency types (order matters - dependencies first)
132    for type_name in DEPENDENCY_TYPES {
133        if let Some(decl) = get_type_decl(type_name) {
134            types.push(decl);
135            included.insert(*type_name);
136        }
137    }
138
139    // Collect types referenced by the API
140    for type_name in JSEDITORAPI_REFERENCED_TYPES {
141        if included.contains(*type_name) {
142            continue;
143        }
144        if let Some(decl) = get_type_decl(type_name) {
145            types.push(decl);
146            included.insert(*type_name);
147        } else {
148            // Log warning for unknown types (these need to be added to get_type_decl)
149            eprintln!(
150                "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
151                type_name
152            );
153        }
154    }
155
156    types.join("\n\n")
157}
158
159/// Validate TypeScript syntax using oxc parser
160///
161/// Returns Ok(()) if the syntax is valid, or an error with the parse errors.
162pub fn validate_typescript(source: &str) -> Result<(), String> {
163    let allocator = Allocator::default();
164    let source_type = SourceType::d_ts();
165
166    let parser_ret = Parser::new(&allocator, source, source_type).parse();
167
168    if parser_ret.errors.is_empty() {
169        Ok(())
170    } else {
171        let errors: Vec<String> = parser_ret
172            .errors
173            .iter()
174            .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
175            .collect();
176        Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
177    }
178}
179
180/// Format TypeScript source code using oxc codegen
181///
182/// Parses the TypeScript and regenerates it with consistent formatting.
183/// Returns the original source if parsing fails.
184pub fn format_typescript(source: &str) -> String {
185    let allocator = Allocator::default();
186    let source_type = SourceType::d_ts();
187
188    let parser_ret = Parser::new(&allocator, source, source_type).parse();
189
190    if !parser_ret.errors.is_empty() {
191        // Return original source if parsing fails
192        return source.to_string();
193    }
194
195    // Generate formatted code from AST
196    Codegen::new().build(&parser_ret.program).code
197}
198
199/// Generate and write the complete fresh.d.ts file
200///
201/// Combines ts-rs generated types with proc macro output,
202/// validates the syntax, formats the output, and writes to disk.
203pub fn write_fresh_dts() -> Result<(), String> {
204    use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
205
206    let ts_types = collect_ts_types();
207
208    let content = format!(
209        "{}\n{}\n{}",
210        JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
211    );
212
213    // Validate the generated TypeScript syntax
214    validate_typescript(&content)?;
215
216    // Format the TypeScript
217    let formatted = format_typescript(&content);
218
219    // Determine output path - write to fresh-editor/plugins/lib/fresh.d.ts
220    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
221    let output_path = std::path::Path::new(&manifest_dir)
222        .parent() // crates/
223        .and_then(|p| p.parent()) // workspace root
224        .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
225        .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
226
227    // Only write if content changed
228    let should_write = match std::fs::read_to_string(&output_path) {
229        Ok(existing) => existing != formatted,
230        Err(_) => true,
231    };
232
233    if should_write {
234        if let Some(parent) = output_path.parent() {
235            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
236        }
237        std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
238    }
239
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    /// Generate, validate, format, and write fresh.d.ts
248    /// Run with: cargo test -p fresh-plugin-runtime write_fresh_dts_file -- --ignored --nocapture
249    #[test]
250    #[ignore]
251    fn write_fresh_dts_file() {
252        // write_fresh_dts validates syntax and formats before writing
253        write_fresh_dts().expect("Failed to write fresh.d.ts");
254        println!("Successfully generated, validated, and formatted fresh.d.ts");
255    }
256
257    /// Type check all plugins using TypeScript compiler
258    /// Skips if tsc is not available in PATH
259    /// Run with: cargo test -p fresh-plugin-runtime type_check_plugins -- --ignored --nocapture
260    #[test]
261    #[ignore]
262    fn type_check_plugins() {
263        // Check if tsc is available
264        let tsc_check = std::process::Command::new("tsc").arg("--version").output();
265
266        match tsc_check {
267            Ok(output) if output.status.success() => {
268                println!(
269                    "Found tsc: {}",
270                    String::from_utf8_lossy(&output.stdout).trim()
271                );
272            }
273            _ => {
274                println!("tsc not found in PATH, skipping type check test");
275                return;
276            }
277        }
278
279        // Find the check-types.sh script
280        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
281        let script_path = std::path::Path::new(&manifest_dir)
282            .parent()
283            .and_then(|p| p.parent())
284            .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
285            .expect("Failed to find check-types.sh");
286
287        println!("Running type check script: {}", script_path.display());
288
289        // Run the check-types.sh script
290        let output = std::process::Command::new("bash")
291            .arg(&script_path)
292            .output()
293            .expect("Failed to run check-types.sh");
294
295        let stdout = String::from_utf8_lossy(&output.stdout);
296        let stderr = String::from_utf8_lossy(&output.stderr);
297
298        println!("stdout:\n{}", stdout);
299        if !stderr.is_empty() {
300            println!("stderr:\n{}", stderr);
301        }
302
303        // The script outputs "X file(s) had type errors" if there are errors
304        if stdout.contains("had type errors") || !output.status.success() {
305            panic!(
306                "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
307            );
308        }
309
310        println!("All plugins type check successfully!");
311    }
312}