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