Skip to main content

fresh_plugin_api_macros/
lib.rs

1//! # Fresh Plugin API Macros
2//!
3//! Proc macros for generating TypeScript definitions from Rust QuickJS API implementations.
4//!
5//! ## Overview
6//!
7//! This crate provides the `#[plugin_api_impl]` attribute macro that:
8//! 1. Parses method signatures from a `JsEditorApi` impl block
9//! 2. Generates TypeScript type definitions (`.d.ts`)
10//! 3. Automatically writes to `plugins/lib/fresh.d.ts` during compilation
11//!
12//! ## Usage
13//!
14//! ```rust,ignore
15//! use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
16//!
17//! #[plugin_api_impl]
18//! #[rquickjs::methods(rename_all = "camelCase")]
19//! impl JsEditorApi {
20//!     /// Get the active buffer ID (0 if none)
21//!     pub fn get_active_buffer_id(&self) -> u32 { ... }
22//!
23//!     /// Create a virtual buffer (async)
24//!     #[plugin_api(async_promise, js_name = "createVirtualBuffer", ts_return = "number")]
25//!     #[qjs(rename = "_createVirtualBufferStart")]
26//!     pub fn create_virtual_buffer_start(&self, opts: Object) -> u64 { ... }
27//! }
28//! ```
29//!
30//! ## Attributes
31//!
32//! ### `#[plugin_api_impl]`
33//!
34//! Apply to the impl block to enable TypeScript generation. Generates:
35//! - `{IMPL_NAME}_TYPESCRIPT_DEFINITIONS: &str` - Full `.d.ts` content
36//! - `{IMPL_NAME}_JS_METHODS: &[&str]` - List of all JS method names
37//!
38//! ### `#[plugin_api(...)]`
39//!
40//! Apply to individual methods for customization:
41//!
42//! | Attribute | Description | Example |
43//! |-----------|-------------|---------|
44//! | `skip` | Exclude from TypeScript | `#[plugin_api(skip)]` |
45//! | `js_name = "..."` | Custom JS method name | `#[plugin_api(js_name = "myMethod")]` |
46//! | `async_promise` | Returns `Promise<T>` | `#[plugin_api(async_promise)]` |
47//! | `async_thenable` | Returns `ProcessHandle<T>` (cancellable) | `#[plugin_api(async_thenable)]` |
48//! | `ts_type = "..."` | Custom TypeScript type for parameter | `#[plugin_api(ts_type = "BufferInfo")]` |
49//! | `ts_return = "..."` | Custom TypeScript return type | `#[plugin_api(ts_return = "string")]` |
50//!
51//! ## Type Mapping
52//!
53//! | Rust Type | TypeScript Type | Notes |
54//! |-----------|-----------------|-------|
55//! | `u8`, `u16`, `u32`, `i32`, etc. | `number` | All numeric types |
56//! | `bool` | `boolean` | |
57//! | `String`, `&str` | `string` | |
58//! | `()` | `void` | |
59//! | `Option<T>` | `T \| null` | |
60//! | `Vec<T>` | `T[]` | |
61//! | `rquickjs::Ctx<'js>` | *(skipped)* | Runtime context |
62//! | `rquickjs::function::Opt<T>` | `T?` | Optional parameter |
63//! | `rquickjs::function::Rest<T>` | `...T[]` | Variadic parameter |
64//! | `rquickjs::Result<T>` | `T` | Unwrapped |
65//! | `rquickjs::Object<'js>` | `Record<string, unknown>` | Use `ts_type` for specifics |
66//!
67//! ## Async Methods
68//!
69//! Async methods must be explicitly marked with `#[plugin_api(async_promise)]` or
70//! `#[plugin_api(async_thenable)]`. There is no heuristic-based detection.
71//!
72//! - `async_promise`: For operations that complete with a result
73//! - `async_thenable`: For cancellable operations (e.g., process spawning)
74//!
75//! ## File Output
76//!
77//! The macro automatically writes `plugins/lib/fresh.d.ts` when:
78//! 1. Building the main crate (not the macro crate)
79//! 2. The content has changed (avoids unnecessary rebuilds)
80//!
81//! ## Design Principles
82//!
83//! 1. **Single Source of Truth**: API defined once in Rust, TypeScript generated
84//! 2. **Explicit Over Implicit**: No magic naming conventions, use attributes
85//! 3. **Deterministic Output**: Same input always produces same output
86//! 4. **Preserve Original Code**: Macro passes through impl block unchanged
87//! 5. **Clear Errors**: Compile-time errors with helpful messages
88
89use proc_macro::TokenStream;
90use quote::{format_ident, quote};
91use syn::{
92    parse_macro_input, spanned::Spanned, Attribute, FnArg, GenericArgument, ImplItem, ImplItemFn,
93    ItemImpl, Meta, Pat, PathArguments, ReturnType, Type,
94};
95
96// ============================================================================
97// Error Handling
98// ============================================================================
99
100/// Create a compile error with a helpful message and source span
101fn compile_error(span: proc_macro2::Span, message: &str) -> proc_macro2::TokenStream {
102    syn::Error::new(span, message).to_compile_error()
103}
104
105// ============================================================================
106// API Method Classification
107// ============================================================================
108
109/// Classification of API method return behavior
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum ApiKind {
112    /// Synchronous method - returns value directly
113    Sync,
114    /// Async method returning `Promise<T>`
115    AsyncPromise,
116    /// Async method returning `ProcessHandle<T>` (cancellable)
117    AsyncThenable,
118}
119
120impl ApiKind {
121    /// Wrap a TypeScript type in the appropriate async wrapper
122    fn wrap_return_type(&self, inner: &str) -> String {
123        match self {
124            ApiKind::Sync => inner.to_string(),
125            ApiKind::AsyncPromise => format!("Promise<{}>", inner),
126            ApiKind::AsyncThenable => format!("ProcessHandle<{}>", inner),
127        }
128    }
129}
130
131// ============================================================================
132// Parsed Structures (Intermediate Representation)
133// ============================================================================
134
135/// Parsed API method - intermediate representation for code generation
136#[derive(Debug)]
137struct ApiMethod {
138    /// JavaScript method name (camelCase)
139    js_name: String,
140    /// Method classification (sync/async)
141    kind: ApiKind,
142    /// Parsed parameters
143    params: Vec<ParamInfo>,
144    /// TypeScript return type
145    return_type: String,
146    /// Documentation from doc comments
147    doc: String,
148    /// Raw TypeScript signature override (from `ts_raw = "..."`)
149    /// When set, replaces the entire auto-generated signature line.
150    ts_raw: Option<String>,
151}
152
153/// Parsed parameter information
154#[derive(Debug)]
155struct ParamInfo {
156    /// Parameter name (camelCase)
157    name: String,
158    /// TypeScript type
159    ts_type: String,
160    /// Whether parameter is optional (from `Opt<T>`)
161    optional: bool,
162    /// Whether parameter is variadic (from `Rest<T>`)
163    variadic: bool,
164}
165
166impl ParamInfo {
167    /// Format as TypeScript parameter
168    fn to_typescript(&self) -> String {
169        if self.variadic {
170            format!("...{}: {}[]", self.name, self.ts_type)
171        } else if self.optional {
172            format!("{}?: {}", self.name, self.ts_type)
173        } else {
174            format!("{}: {}", self.name, self.ts_type)
175        }
176    }
177}
178
179// ============================================================================
180// String Utilities
181// ============================================================================
182
183/// Convert snake_case identifier to camelCase
184///
185/// # Examples
186/// ```ignore
187/// assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
188/// assert_eq!(to_camel_case("simple"), "simple");
189/// ```
190fn to_camel_case(s: &str) -> String {
191    let mut result = String::with_capacity(s.len());
192    let mut capitalize_next = false;
193
194    for c in s.chars() {
195        if c == '_' {
196            capitalize_next = true;
197        } else if capitalize_next {
198            result.push(c.to_ascii_uppercase());
199            capitalize_next = false;
200        } else {
201            result.push(c);
202        }
203    }
204    result
205}
206
207// ============================================================================
208// Attribute Parsing
209// ============================================================================
210
211/// Extract documentation from `#[doc = "..."]` attributes
212fn extract_doc_comment(attrs: &[Attribute]) -> String {
213    attrs
214        .iter()
215        .filter_map(|attr| {
216            if !attr.path().is_ident("doc") {
217                return None;
218            }
219            if let Meta::NameValue(meta) = &attr.meta {
220                if let syn::Expr::Lit(expr_lit) = &meta.value {
221                    if let syn::Lit::Str(lit_str) = &expr_lit.lit {
222                        return Some(lit_str.value().trim().to_string());
223                    }
224                }
225            }
226            None
227        })
228        .collect::<Vec<_>>()
229        .join("\n")
230}
231
232/// Parse a string value from attribute tokens like `key = "value"`
233fn parse_attr_string_value(tokens: &str, key: &str) -> Option<String> {
234    let start = tokens.find(key)?;
235    let rest = &tokens[start..];
236    let eq_pos = rest.find('=')?;
237    let after_eq = rest[eq_pos + 1..].trim();
238
239    if !after_eq.starts_with('"') {
240        return None;
241    }
242
243    let end_quote = after_eq[1..].find('"')?;
244    Some(after_eq[1..end_quote + 1].to_string())
245}
246
247/// Check if `#[plugin_api(...)]` contains a specific flag
248fn has_plugin_api_flag(attrs: &[Attribute], flag: &str) -> bool {
249    attrs.iter().any(|attr| {
250        if !attr.path().is_ident("plugin_api") {
251            return false;
252        }
253        if let Meta::List(meta_list) = &attr.meta {
254            meta_list.tokens.to_string().contains(flag)
255        } else {
256            false
257        }
258    })
259}
260
261/// Get a string value from `#[plugin_api(key = "value")]`
262fn get_plugin_api_value(attrs: &[Attribute], key: &str) -> Option<String> {
263    for attr in attrs {
264        if !attr.path().is_ident("plugin_api") {
265            continue;
266        }
267        if let Meta::List(meta_list) = &attr.meta {
268            if let Some(value) = parse_attr_string_value(&meta_list.tokens.to_string(), key) {
269                return Some(value);
270            }
271        }
272    }
273    None
274}
275
276/// Get custom JS name from `#[qjs(rename = "...")]` or `#[plugin_api(js_name = "...")]`
277fn get_js_name(attrs: &[Attribute]) -> Option<String> {
278    // First check plugin_api attribute (takes precedence)
279    if let Some(name) = get_plugin_api_value(attrs, "js_name") {
280        return Some(name);
281    }
282
283    // Then check qjs attribute
284    for attr in attrs {
285        if !attr.path().is_ident("qjs") {
286            continue;
287        }
288        if let Meta::List(meta_list) = &attr.meta {
289            if let Some(name) = parse_attr_string_value(&meta_list.tokens.to_string(), "rename") {
290                return Some(name);
291            }
292        }
293    }
294    None
295}
296
297// ============================================================================
298// Type Analysis
299// ============================================================================
300
301/// Extract inner type from generic wrapper like `Option<T>`, `Vec<T>`
302fn extract_inner_type(ty: &Type) -> Option<Type> {
303    if let Type::Path(type_path) = ty {
304        if let Some(segment) = type_path.path.segments.last() {
305            if let PathArguments::AngleBracketed(args) = &segment.arguments {
306                if let Some(GenericArgument::Type(inner)) = args.args.first() {
307                    return Some(inner.clone());
308                }
309            }
310        }
311    }
312    None
313}
314
315/// Get the final segment name from a type path (e.g., "Opt" from "rquickjs::function::Opt")
316fn get_type_name(ty: &Type) -> Option<String> {
317    if let Type::Path(type_path) = ty {
318        type_path.path.segments.last().map(|s| s.ident.to_string())
319    } else {
320        None
321    }
322}
323
324/// Check if type is QuickJS context (`Ctx<'js>`) - should be skipped from parameters
325fn is_ctx_type(ty: &Type) -> bool {
326    if let Type::Path(type_path) = ty {
327        // Check final segment
328        if let Some(segment) = type_path.path.segments.last() {
329            if segment.ident == "Ctx" {
330                return true;
331            }
332        }
333        // Check full path for "Ctx" anywhere
334        let path_str: String = type_path
335            .path
336            .segments
337            .iter()
338            .map(|s| s.ident.to_string())
339            .collect::<Vec<_>>()
340            .join("::");
341        path_str.contains("Ctx")
342    } else {
343        false
344    }
345}
346
347/// Check if type is `Opt<T>` (optional parameter)
348fn is_opt_type(ty: &Type) -> bool {
349    get_type_name(ty).is_some_and(|n| n == "Opt")
350}
351
352/// Check if type is `Rest<T>` (variadic parameter)
353fn is_rest_type(ty: &Type) -> bool {
354    get_type_name(ty).is_some_and(|n| n == "Rest")
355}
356
357// ============================================================================
358// Rust to TypeScript Type Conversion
359// ============================================================================
360
361/// Convert a Rust type to its TypeScript equivalent
362///
363/// Handles:
364/// - Primitive types (numbers, bool, string)
365/// - Generic wrappers (Option, Vec, Result)
366/// - QuickJS types (Opt, Rest, Object, Value)
367/// - Known API types (BufferInfo, etc.)
368fn rust_to_typescript(ty: &Type, attrs: &[Attribute]) -> String {
369    // Check for explicit ts_type override
370    if let Some(custom) = get_plugin_api_value(attrs, "ts_type") {
371        return custom;
372    }
373
374    match ty {
375        Type::Path(type_path) => {
376            let type_name = type_path
377                .path
378                .segments
379                .last()
380                .map(|s| s.ident.to_string())
381                .unwrap_or_else(|| "unknown".to_string());
382
383            match type_name.as_str() {
384                // Numeric types -> number
385                "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "usize" | "isize"
386                | "f32" | "f64" => "number".to_string(),
387
388                // Boolean
389                "bool" => "boolean".to_string(),
390
391                // String types
392                "String" | "str" => "string".to_string(),
393
394                // Unit type
395                "()" => "void".to_string(),
396
397                // Option<T> -> T | null
398                "Option" => {
399                    let inner = extract_inner_type(ty)
400                        .map(|t| rust_to_typescript(&t, &[]))
401                        .unwrap_or_else(|| "unknown".to_string());
402                    format!("{} | null", inner)
403                }
404
405                // Vec<T> -> T[]
406                "Vec" => {
407                    let inner = extract_inner_type(ty)
408                        .map(|t| rust_to_typescript(&t, &[]))
409                        .unwrap_or_else(|| "unknown".to_string());
410                    format!("{}[]", inner)
411                }
412
413                // Opt<T> -> extract inner (optionality handled at param level)
414                "Opt" => extract_inner_type(ty)
415                    .map(|t| rust_to_typescript(&t, &[]))
416                    .unwrap_or_else(|| "unknown".to_string()),
417
418                // Rest<T> -> extract inner (variadic handled at param level)
419                "Rest" => extract_inner_type(ty)
420                    .map(|t| rust_to_typescript(&t, &[]))
421                    .unwrap_or_else(|| "unknown".to_string()),
422
423                // Result<T, E> -> extract T
424                "Result" => extract_inner_type(ty)
425                    .map(|t| rust_to_typescript(&t, &[]))
426                    .unwrap_or_else(|| "unknown".to_string()),
427
428                // QuickJS types
429                "Value" => "unknown".to_string(),
430                "Object" => "Record<string, unknown>".to_string(),
431
432                // Rust collections
433                "HashMap" | "BTreeMap" => "Record<string, unknown>".to_string(),
434
435                // Known API types - pass through unchanged
436                "BufferInfo"
437                | "CursorInfo"
438                | "ViewportInfo"
439                | "SpawnResult"
440                | "BackgroundProcessResult"
441                | "DirEntry"
442                | "FileStat"
443                | "CreateVirtualBufferResult"
444                | "PromptSuggestion"
445                | "TextPropertyEntry"
446                | "JsTextPropertyEntry"
447                | "CreateVirtualBufferOptions"
448                | "CreateVirtualBufferInSplitOptions"
449                | "CreateVirtualBufferInExistingSplitOptions"
450                | "VirtualBufferResult"
451                | "ActionSpec"
452                | "ActionPopupAction"
453                | "ActionPopupOptions"
454                | "ViewTokenWire"
455                | "ViewTokenStyle"
456                | "TokenColor"
457                | "LayoutHints"
458                | "FileExplorerDecoration"
459                | "TsCompositeLayoutConfig"
460                | "TsCompositeSourceConfig"
461                | "TsCompositePaneStyle"
462                | "TsHighlightSpan"
463                | "TsActionPopupAction"
464                | "TsLspMenuItem"
465                | "JsDiagnostic"
466                | "CreateTerminalOptions"
467                | "TerminalResult" => type_name,
468
469                // Types renamed by ts-rs — map Rust name to TypeScript name
470                "CompositeHunk" => "TsCompositeHunk".to_string(),
471                "CreateCompositeBufferOptions" => "TsCreateCompositeBufferOptions".to_string(),
472                "Suggestion" => "PromptSuggestion".to_string(),
473                "LspMenuItem" => "TsLspMenuItem".to_string(),
474
475                // Default: use type name as-is
476                _ => type_name,
477            }
478        }
479        Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
480        Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
481        _ => "unknown".to_string(),
482    }
483}
484
485// ============================================================================
486// Method Parsing
487// ============================================================================
488
489/// Parse a method from the impl block into an ApiMethod
490///
491/// Returns `None` if the method should be skipped (marked with `skip` or internal)
492fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
493    // Skip methods marked with #[plugin_api(skip)]
494    if has_plugin_api_flag(&method.attrs, "skip") {
495        return None;
496    }
497
498    let rust_name = method.sig.ident.to_string();
499    let doc = extract_doc_comment(&method.attrs);
500
501    // Determine method kind from explicit attributes only (no heuristics)
502    let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
503        ApiKind::AsyncThenable
504    } else if has_plugin_api_flag(&method.attrs, "async_promise") {
505        ApiKind::AsyncPromise
506    } else {
507        ApiKind::Sync
508    };
509
510    // Get JS name: explicit > snake_to_camel conversion
511    let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
512
513    // Skip internal methods (names starting with underscore)
514    if js_name.starts_with('_') {
515        return None;
516    }
517
518    // Parse parameters
519    let params: Vec<ParamInfo> = method
520        .sig
521        .inputs
522        .iter()
523        .filter_map(|arg| {
524            let FnArg::Typed(pat_type) = arg else {
525                return None;
526            };
527            let Pat::Ident(pat_ident) = &*pat_type.pat else {
528                return None;
529            };
530
531            let raw_name = pat_ident.ident.to_string();
532
533            // Skip self parameter
534            if raw_name == "self" {
535                return None;
536            }
537
538            // Strip leading underscore (Rust convention for "unused" params)
539            let param_name = raw_name.strip_prefix('_').unwrap_or(&raw_name);
540
541            let ty = &*pat_type.ty;
542
543            // Skip QuickJS context parameter
544            if is_ctx_type(ty) {
545                return None;
546            }
547
548            Some(ParamInfo {
549                name: to_camel_case(param_name),
550                ts_type: rust_to_typescript(ty, &pat_type.attrs),
551                optional: is_opt_type(ty),
552                variadic: is_rest_type(ty),
553            })
554        })
555        .collect();
556
557    // Parse return type
558    let return_type = match &method.sig.output {
559        ReturnType::Default => "void".to_string(),
560        ReturnType::Type(_, ty) => {
561            // Check for explicit ts_return override
562            get_plugin_api_value(&method.attrs, "ts_return")
563                .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
564        }
565    };
566
567    // Check for raw TS signature override
568    let ts_raw = get_plugin_api_value(&method.attrs, "ts_raw");
569
570    Some(ApiMethod {
571        js_name,
572        kind,
573        params,
574        return_type,
575        doc,
576        ts_raw,
577    })
578}
579
580// ============================================================================
581// TypeScript Code Generation
582// ============================================================================
583
584/// Generate TypeScript method signature with JSDoc
585fn generate_ts_method(method: &ApiMethod) -> String {
586    let mut lines = Vec::new();
587
588    // JSDoc comment
589    if !method.doc.is_empty() {
590        lines.push("  /**".to_string());
591        for line in method.doc.lines() {
592            lines.push(format!("   * {}", line));
593        }
594        lines.push("   */".to_string());
595    }
596
597    // Use raw TS override if provided, otherwise auto-generate
598    if let Some(raw) = &method.ts_raw {
599        lines.push(format!("  {};", raw));
600    } else {
601        // Method signature
602        let params: String = method
603            .params
604            .iter()
605            .map(ParamInfo::to_typescript)
606            .collect::<Vec<_>>()
607            .join(", ");
608
609        let return_type = method.kind.wrap_return_type(&method.return_type);
610
611        lines.push(format!(
612            "  {}({}): {};",
613            method.js_name, params, return_type
614        ));
615    }
616
617    lines.join("\n")
618}
619
620/// Generate the TypeScript preamble (header comment and getEditor declaration)
621fn generate_ts_preamble() -> &'static str {
622    r#"/**
623 * Fresh Editor TypeScript Plugin API
624 *
625 * This file provides type definitions for the Fresh editor's TypeScript plugin system.
626 * Plugins have access to the global `editor` object which provides methods to:
627 * - Query editor state (buffers, cursors, viewports)
628 * - Modify buffer content (insert, delete text)
629 * - Add visual decorations (overlays, highlighting)
630 * - Interact with the editor UI (status messages, prompts)
631 *
632 * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
633 * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
634 */
635
636/**
637 * Get the editor API instance.
638 * Plugins must call this at the top of their file to get a scoped editor object.
639 */
640declare function getEditor(): EditorAPI;
641
642/**
643 * Register a function as a named handler on the global scope.
644 *
645 * Handler functions registered this way can be referenced by name in
646 * `editor.registerCommand()`, `editor.on()`, and mode keybindings.
647 *
648 * The `fn` parameter is typed as `Function` because the runtime passes
649 * different argument shapes depending on the caller: command handlers
650 * receive no arguments, event handlers receive an event-specific data
651 * object (e.g. `{ buffer_id: number }`), and prompt handlers receive
652 * `{ prompt_type: string, input: string }`. Type-annotate your handler
653 * parameters to match the event you are handling.
654 *
655 * @param name - Handler name (referenced by registerCommand, on, etc.)
656 * @param fn - The handler function
657 */
658declare function registerHandler(name: string, fn: Function): void;
659
660/** Handle for a cancellable async operation */
661interface ProcessHandle<T> extends PromiseLike<T> {
662  /** Promise that resolves to the result when complete */
663  readonly result: Promise<T>;
664  /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
665  kill(): Promise<boolean>;
666}
667
668/** Buffer identifier */
669type BufferId = number;
670
671/** Split identifier */
672type SplitId = number;
673
674/**
675 * Payload delivered to handlers registered with `editor.on("mouse_click", ...)`.
676 *
677 * All coordinate fields are in cell (terminal character) units. `buffer_*`
678 * fields are `null` when the click did not land in any buffer panel.
679 */
680interface MouseClickHookArgs {
681  /** Screen column (0-indexed). */
682  column: number;
683  /** Screen row (0-indexed). */
684  row: number;
685  /** Mouse button: "left", "right", "middle". */
686  button: string;
687  /** Modifier keys (e.g. "shift"). */
688  modifiers: string;
689  /** X offset of the content area the click landed in. */
690  content_x: number;
691  /** Y offset of the content area the click landed in. */
692  content_y: number;
693  /** Buffer under the click, or `null` when outside any buffer panel. */
694  buffer_id: number | null;
695  /** 0-indexed buffer row (line number) of the click, accounting for scroll. */
696  buffer_row: number | null;
697  /** 0-indexed byte column inside the buffer row. */
698  buffer_col: number | null;
699}
700
701/**
702 * Registry of typed plugin APIs surfaced through
703 * `editor.exportPluginApi` / `editor.getPluginApi`.
704 *
705 * Plugins that want their surface to be typed for downstream
706 * consumers augment this interface in their own source:
707 *
708 * ```ts
709 * // in my_plugin.ts
710 * export type MyPluginApi = { doThing(): void };
711 * declare global {
712 *   interface FreshPluginRegistry {
713 *     "my-plugin": MyPluginApi;
714 *   }
715 * }
716 * ```
717 *
718 * `editor.getPluginApi("my-plugin")` then returns
719 * `MyPluginApi | null` without any `as`-cast on the consumer side.
720 * Plugins that skip the augmentation still work — the untyped
721 * `getPluginApi<T = unknown>(name: string): T | null` overload
722 * takes over.
723 *
724 * Each plugin's augmentation is emitted to
725 * `<config_dir>/types/plugins.d.ts` at load time (via oxc's
726 * isolated-declarations), so init.ts sees every loaded plugin's
727 * registry entry automatically.
728 */
729interface FreshPluginRegistry {}
730
731"#
732}
733
734/// Generate the EditorAPI interface (methods only)
735/// Types are provided separately via ts-rs
736fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
737    let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
738
739    format!(
740        "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
741        method_sigs.join("\n\n")
742    )
743}
744
745/// Built-in TypeScript types that don't need to be collected
746const BUILTIN_TS_TYPES: &[&str] = &[
747    "number",
748    "string",
749    "boolean",
750    "void",
751    "unknown",
752    "null",
753    "undefined",
754    "Record",
755    "Array",
756    "Promise",
757    "ProcessHandle",
758    "PromiseLike",
759    "BufferId",
760    "SplitId", // Defined in preamble
761];
762
763/// Extract type names from a TypeScript type string
764///
765/// Handles:
766/// - Simple types: "SpawnResult" -> ["SpawnResult"]
767/// - Generics: "ProcessHandle<SpawnResult>" -> ["SpawnResult"]
768/// - Union: "string | null" -> []
769/// - Arrays: "BufferInfo[]" -> ["BufferInfo"]
770fn extract_type_references(ts_type: &str) -> Vec<String> {
771    let mut types = Vec::new();
772
773    // Remove generic wrappers like ProcessHandle<...>, Promise<...>, Array<...>
774    let mut current = ts_type.to_string();
775
776    // Strip outer generics repeatedly
777    while let Some(start) = current.find('<') {
778        if let Some(end) = current.rfind('>') {
779            let outer = current[..start].trim().to_string();
780            let inner = current[start + 1..end].trim().to_string();
781
782            // But check if outer is a custom type we need
783            if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
784                types.push(outer);
785            }
786
787            // Process the inner type
788            current = inner;
789        } else {
790            break;
791        }
792    }
793
794    // Handle union types (split by |)
795    for part in current.split('|') {
796        let part = part.trim();
797
798        // Skip built-in types
799        if BUILTIN_TS_TYPES.contains(&part) {
800            continue;
801        }
802
803        // Handle array types like "BufferInfo[]"
804        let part = part.trim_end_matches("[]");
805
806        // Skip Record<...> and other generics
807        if part.contains('<') || part.contains('>') {
808            continue;
809        }
810
811        // Skip empty or built-in
812        if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
813            continue;
814        }
815
816        // This looks like a custom type reference
817        if part.chars().next().is_some_and(|c| c.is_uppercase()) {
818            types.push(part.to_string());
819        }
820    }
821
822    types
823}
824
825/// Collect all type references from methods
826fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
827    let mut types = std::collections::HashSet::new();
828
829    for method in methods {
830        // Collect from return type
831        for ty in extract_type_references(&method.return_type) {
832            types.insert(ty);
833        }
834
835        // Collect from parameters
836        for param in &method.params {
837            for ty in extract_type_references(&param.ts_type) {
838                types.insert(ty);
839            }
840        }
841    }
842
843    let mut sorted: Vec<String> = types.into_iter().collect();
844    sorted.sort();
845    sorted
846}
847
848// ============================================================================
849// Proc Macros
850// ============================================================================
851
852/// Generate TypeScript definitions from a QuickJS impl block
853///
854/// # Generated Constants
855///
856/// - `{IMPL_NAME}_TS_PREAMBLE: &str` - Header comment + getEditor + ProcessHandle + BufferId/SplitId
857/// - `{IMPL_NAME}_TS_EDITOR_API: &str` - Just the EditorAPI interface with methods
858/// - `{IMPL_NAME}_JS_METHODS: &[&str]` - List of all JS method names
859///
860/// The main crate should combine these with ts-rs generated types to create fresh.d.ts.
861///
862/// # Example
863///
864/// ```rust,ignore
865/// #[plugin_api_impl]
866/// #[rquickjs::methods(rename_all = "camelCase")]
867/// impl JsEditorApi {
868///     /// Get the active buffer ID
869///     pub fn get_active_buffer_id(&self) -> u32 { ... }
870///
871///     /// Spawn a process (cancellable)
872///     #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
873///     #[qjs(rename = "_spawnProcessStart")]
874///     pub fn spawn_process_start(&self, cmd: String) -> u64 { ... }
875/// }
876/// ```
877///
878/// # Errors
879///
880/// Compile-time error if applied to non-impl items.
881#[proc_macro_attribute]
882pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
883    let input = parse_macro_input!(item as ItemImpl);
884
885    // Extract impl target name
886    let impl_name = match &*input.self_ty {
887        Type::Path(type_path) => type_path
888            .path
889            .segments
890            .last()
891            .map(|s| s.ident.to_string())
892            .unwrap_or_else(|| "Unknown".to_string()),
893        _ => {
894            return compile_error(
895                input.self_ty.span(),
896                "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
897            )
898            .into();
899        }
900    };
901
902    // Generate constant names
903    let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
904    let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
905    let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
906
907    // Parse methods into intermediate representation
908    let methods: Vec<ApiMethod> = input
909        .items
910        .iter()
911        .filter_map(|item| {
912            if let ImplItem::Fn(method) = item {
913                parse_method(method)
914            } else {
915                None
916            }
917        })
918        .collect();
919
920    // Generate TypeScript parts
921    let preamble = generate_ts_preamble();
922    let editor_api = generate_editor_api_interface(&methods);
923
924    // Collect JS method names
925    let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
926
927    // Collect referenced types (for ts-rs export)
928    let referenced_types = collect_referenced_types(&methods);
929    let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
930
931    // Strip #[plugin_api(...)] attributes from method parameters before emitting,
932    // since plugin_api is a proc_macro_attribute and can't appear on parameters.
933    // The attribute was already read during parse_method for ts_type overrides.
934    let mut cleaned_input = input.clone();
935    for item in &mut cleaned_input.items {
936        if let ImplItem::Fn(method) = item {
937            for arg in &mut method.sig.inputs {
938                if let FnArg::Typed(pat_type) = arg {
939                    pat_type
940                        .attrs
941                        .retain(|attr| !attr.path().is_ident("plugin_api"));
942                }
943            }
944        }
945    }
946
947    // Generate output: original impl + constants
948    let expanded = quote! {
949        #cleaned_input
950
951        /// TypeScript preamble (header, getEditor, ProcessHandle, BufferId, SplitId)
952        ///
953        /// Combine with ts-rs types and EDITOR_API to create fresh.d.ts
954        pub const #preamble_const: &str = #preamble;
955
956        /// TypeScript EditorAPI interface (methods only)
957        ///
958        /// Combine with preamble and ts-rs types to create fresh.d.ts
959        pub const #editor_api_const: &str = #editor_api;
960
961        /// List of all JavaScript method names exposed in the API
962        ///
963        /// Useful for verification and debugging.
964        pub const #methods_const: &[&str] = &[#(#js_names),*];
965
966        /// List of TypeScript types referenced in method signatures
967        ///
968        /// These types need to be defined (via ts-rs or manually) in fresh.d.ts.
969        /// Use this to automatically collect type definitions.
970        pub const #types_const: &[&str] = &[#(#referenced_types),*];
971    };
972
973    TokenStream::from(expanded)
974}
975
976/// Marker attribute for customizing individual API methods
977///
978/// This attribute is parsed by `#[plugin_api_impl]` but doesn't generate any code itself.
979///
980/// # Options
981///
982/// - `skip` - Exclude method from TypeScript generation
983/// - `js_name = "..."` - Custom JavaScript method name
984/// - `async_promise` - Method returns `Promise<T>`
985/// - `async_thenable` - Method returns `ProcessHandle<T>` (cancellable)
986/// - `ts_type = "..."` - Custom TypeScript type for a parameter
987/// - `ts_return = "..."` - Custom TypeScript return type
988/// - `ts_raw = "..."` - Raw TypeScript signature (replaces auto-generated signature)
989///
990/// # Examples
991///
992/// ```rust,ignore
993/// // Skip internal helper
994/// #[plugin_api(skip)]
995/// fn internal_helper(&self) { ... }
996///
997/// // Async method with custom return type
998/// #[plugin_api(async_promise, js_name = "fetchData", ts_return = "DataResult")]
999/// fn fetch_data_start(&self) -> u64 { ... }
1000///
1001/// // Cancellable operation
1002/// #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
1003/// fn spawn_process_start(&self, cmd: String) -> u64 { ... }
1004/// ```
1005#[proc_macro_attribute]
1006pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
1007    // Marker attribute - passes through unchanged
1008    item
1009}
1010
1011// ============================================================================
1012// Unit Tests
1013// ============================================================================
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018
1019    #[test]
1020    fn test_to_camel_case() {
1021        assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
1022        assert_eq!(to_camel_case("simple"), "simple");
1023        assert_eq!(to_camel_case("a_b_c"), "aBC");
1024        // Note: leading underscores in parameter names are stripped by parse_method
1025        // before to_camel_case is called, so "process_id" is the input, not "_process_id"
1026        assert_eq!(to_camel_case("process_id"), "processId");
1027        assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
1028        assert_eq!(to_camel_case(""), "");
1029        assert_eq!(to_camel_case("_leading"), "Leading");
1030        assert_eq!(to_camel_case("trailing_"), "trailing");
1031    }
1032
1033    #[test]
1034    fn test_parse_attr_string_value() {
1035        assert_eq!(
1036            parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
1037            Some("myMethod".to_string())
1038        );
1039        assert_eq!(
1040            parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
1041            Some("foo".to_string())
1042        );
1043        assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
1044        assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
1045    }
1046
1047    #[test]
1048    fn test_api_kind_wrap_return_type() {
1049        assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
1050        assert_eq!(
1051            ApiKind::AsyncPromise.wrap_return_type("number"),
1052            "Promise<number>"
1053        );
1054        assert_eq!(
1055            ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
1056            "ProcessHandle<SpawnResult>"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_param_info_to_typescript() {
1062        let regular = ParamInfo {
1063            name: "bufferId".to_string(),
1064            ts_type: "number".to_string(),
1065            optional: false,
1066            variadic: false,
1067        };
1068        assert_eq!(regular.to_typescript(), "bufferId: number");
1069
1070        let optional = ParamInfo {
1071            name: "line".to_string(),
1072            ts_type: "number".to_string(),
1073            optional: true,
1074            variadic: false,
1075        };
1076        assert_eq!(optional.to_typescript(), "line?: number");
1077
1078        let variadic = ParamInfo {
1079            name: "parts".to_string(),
1080            ts_type: "string".to_string(),
1081            optional: false,
1082            variadic: true,
1083        };
1084        assert_eq!(variadic.to_typescript(), "...parts: string[]");
1085    }
1086
1087    #[test]
1088    fn test_generate_ts_preamble_contains_required_declarations() {
1089        let preamble = generate_ts_preamble();
1090
1091        // Check essential declarations
1092        assert!(preamble.contains("declare function getEditor(): EditorAPI"));
1093        assert!(preamble.contains("interface ProcessHandle<T>"));
1094        assert!(preamble.contains("type BufferId = number"));
1095        assert!(preamble.contains("type SplitId = number"));
1096
1097        // Check it's marked as auto-generated
1098        assert!(preamble.contains("AUTO-GENERATED FILE"));
1099    }
1100
1101    #[test]
1102    fn test_extract_type_references() {
1103        // Simple type
1104        assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1105
1106        // Built-in types return empty
1107        assert!(extract_type_references("number").is_empty());
1108        assert!(extract_type_references("string").is_empty());
1109        assert!(extract_type_references("void").is_empty());
1110
1111        // Generic wrapper - extracts inner type
1112        assert_eq!(
1113            extract_type_references("ProcessHandle<SpawnResult>"),
1114            vec!["SpawnResult"]
1115        );
1116        assert_eq!(
1117            extract_type_references("Promise<BufferInfo>"),
1118            vec!["BufferInfo"]
1119        );
1120
1121        // Union with null
1122        assert_eq!(
1123            extract_type_references("CursorInfo | null"),
1124            vec!["CursorInfo"]
1125        );
1126
1127        // Array type
1128        assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1129
1130        // Built-in generics return empty
1131        assert!(extract_type_references("Record<string, unknown>").is_empty());
1132        assert!(extract_type_references("Promise<void>").is_empty());
1133    }
1134
1135    #[test]
1136    fn test_collect_referenced_types() {
1137        let methods = vec![
1138            ApiMethod {
1139                js_name: "spawnProcess".to_string(),
1140                kind: ApiKind::AsyncThenable,
1141                params: vec![],
1142                return_type: "SpawnResult".to_string(),
1143                doc: "".to_string(),
1144                ts_raw: None,
1145            },
1146            ApiMethod {
1147                js_name: "listBuffers".to_string(),
1148                kind: ApiKind::Sync,
1149                params: vec![],
1150                return_type: "BufferInfo[]".to_string(),
1151                doc: "".to_string(),
1152                ts_raw: None,
1153            },
1154        ];
1155
1156        let types = collect_referenced_types(&methods);
1157        assert!(types.contains(&"SpawnResult".to_string()));
1158        assert!(types.contains(&"BufferInfo".to_string()));
1159    }
1160
1161    #[test]
1162    fn test_generate_ts_method_sync() {
1163        let method = ApiMethod {
1164            js_name: "getActiveBufferId".to_string(),
1165            kind: ApiKind::Sync,
1166            params: vec![],
1167            return_type: "number".to_string(),
1168            doc: "Get the active buffer ID".to_string(),
1169            ts_raw: None,
1170        };
1171
1172        let ts = generate_ts_method(&method);
1173        assert!(ts.contains("getActiveBufferId(): number;"));
1174        assert!(ts.contains("Get the active buffer ID"));
1175    }
1176
1177    #[test]
1178    fn test_generate_ts_method_async_promise() {
1179        let method = ApiMethod {
1180            js_name: "delay".to_string(),
1181            kind: ApiKind::AsyncPromise,
1182            params: vec![ParamInfo {
1183                name: "ms".to_string(),
1184                ts_type: "number".to_string(),
1185                optional: false,
1186                variadic: false,
1187            }],
1188            return_type: "void".to_string(),
1189            doc: "".to_string(),
1190            ts_raw: None,
1191        };
1192
1193        let ts = generate_ts_method(&method);
1194        assert!(ts.contains("delay(ms: number): Promise<void>;"));
1195    }
1196
1197    #[test]
1198    fn test_generate_ts_method_async_thenable() {
1199        let method = ApiMethod {
1200            js_name: "spawnProcess".to_string(),
1201            kind: ApiKind::AsyncThenable,
1202            params: vec![
1203                ParamInfo {
1204                    name: "command".to_string(),
1205                    ts_type: "string".to_string(),
1206                    optional: false,
1207                    variadic: false,
1208                },
1209                ParamInfo {
1210                    name: "args".to_string(),
1211                    ts_type: "string".to_string(),
1212                    optional: false,
1213                    variadic: false,
1214                },
1215            ],
1216            return_type: "SpawnResult".to_string(),
1217            doc: "Spawn a process".to_string(),
1218            ts_raw: None,
1219        };
1220
1221        let ts = generate_ts_method(&method);
1222        assert!(
1223            ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1224        );
1225    }
1226
1227    // ========================================================================
1228    // Tests for ts-rs renamed type mappings
1229    // ========================================================================
1230
1231    /// Helper to parse a Rust type string into a syn::Type
1232    fn parse_type(s: &str) -> Type {
1233        syn::parse_str::<Type>(s).unwrap()
1234    }
1235
1236    #[test]
1237    fn test_renamed_type_composite_hunk() {
1238        let ty = parse_type("Vec<CompositeHunk>");
1239        let ts = rust_to_typescript(&ty, &[]);
1240        assert_eq!(ts, "TsCompositeHunk[]");
1241    }
1242
1243    #[test]
1244    fn test_renamed_type_create_composite_buffer_options() {
1245        let ty = parse_type("CreateCompositeBufferOptions");
1246        let ts = rust_to_typescript(&ty, &[]);
1247        assert_eq!(ts, "TsCreateCompositeBufferOptions");
1248    }
1249
1250    #[test]
1251    fn test_renamed_type_suggestion() {
1252        let ty = parse_type("Vec<Suggestion>");
1253        let ts = rust_to_typescript(&ty, &[]);
1254        assert_eq!(ts, "PromptSuggestion[]");
1255    }
1256
1257    #[test]
1258    fn test_passthrough_type_terminal_result() {
1259        let ty = parse_type("TerminalResult");
1260        let ts = rust_to_typescript(&ty, &[]);
1261        assert_eq!(ts, "TerminalResult");
1262    }
1263
1264    #[test]
1265    fn test_passthrough_type_create_terminal_options() {
1266        let ty = parse_type("CreateTerminalOptions");
1267        let ts = rust_to_typescript(&ty, &[]);
1268        assert_eq!(ts, "CreateTerminalOptions");
1269    }
1270
1271    #[test]
1272    fn test_passthrough_type_cursor_info() {
1273        let ty = parse_type("CursorInfo");
1274        let ts = rust_to_typescript(&ty, &[]);
1275        assert_eq!(ts, "CursorInfo");
1276    }
1277
1278    #[test]
1279    fn test_option_cursor_info() {
1280        let ty = parse_type("Option<CursorInfo>");
1281        let ts = rust_to_typescript(&ty, &[]);
1282        assert_eq!(ts, "CursorInfo | null");
1283    }
1284
1285    #[test]
1286    fn test_extract_type_references_renamed_types() {
1287        // Renamed types should be extracted by their TS name
1288        assert_eq!(
1289            extract_type_references("TsCompositeHunk[]"),
1290            vec!["TsCompositeHunk"]
1291        );
1292        assert_eq!(
1293            extract_type_references("TsCreateCompositeBufferOptions"),
1294            vec!["TsCreateCompositeBufferOptions"]
1295        );
1296        assert_eq!(
1297            extract_type_references("PromptSuggestion[]"),
1298            vec!["PromptSuggestion"]
1299        );
1300    }
1301
1302    #[test]
1303    fn test_extract_type_references_terminal_types() {
1304        assert_eq!(
1305            extract_type_references("Promise<TerminalResult>"),
1306            vec!["TerminalResult"]
1307        );
1308        assert_eq!(
1309            extract_type_references("CreateTerminalOptions"),
1310            vec!["CreateTerminalOptions"]
1311        );
1312    }
1313
1314    #[test]
1315    fn test_extract_type_references_cursor_types() {
1316        assert_eq!(
1317            extract_type_references("CursorInfo | null"),
1318            vec!["CursorInfo"]
1319        );
1320        assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
1321    }
1322
1323    #[test]
1324    fn test_generate_ts_method_with_renamed_param_type() {
1325        let method = ApiMethod {
1326            js_name: "updateCompositeAlignment".to_string(),
1327            kind: ApiKind::Sync,
1328            params: vec![
1329                ParamInfo {
1330                    name: "bufferId".to_string(),
1331                    ts_type: "number".to_string(),
1332                    optional: false,
1333                    variadic: false,
1334                },
1335                ParamInfo {
1336                    name: "hunks".to_string(),
1337                    ts_type: "TsCompositeHunk[]".to_string(),
1338                    optional: false,
1339                    variadic: false,
1340                },
1341            ],
1342            return_type: "boolean".to_string(),
1343            doc: "Update alignment hunks".to_string(),
1344            ts_raw: None,
1345        };
1346
1347        let ts = generate_ts_method(&method);
1348        assert!(ts.contains(
1349            "updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
1350        ));
1351    }
1352
1353    #[test]
1354    fn test_generate_ts_method_cursor_return_types() {
1355        let method = ApiMethod {
1356            js_name: "getPrimaryCursor".to_string(),
1357            kind: ApiKind::Sync,
1358            params: vec![],
1359            return_type: "CursorInfo | null".to_string(),
1360            doc: "Get primary cursor".to_string(),
1361            ts_raw: None,
1362        };
1363        let ts = generate_ts_method(&method);
1364        assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
1365
1366        let method = ApiMethod {
1367            js_name: "getAllCursors".to_string(),
1368            kind: ApiKind::Sync,
1369            params: vec![],
1370            return_type: "CursorInfo[]".to_string(),
1371            doc: "Get all cursors".to_string(),
1372            ts_raw: None,
1373        };
1374        let ts = generate_ts_method(&method);
1375        assert!(ts.contains("getAllCursors(): CursorInfo[];"));
1376
1377        let method = ApiMethod {
1378            js_name: "getAllCursorPositions".to_string(),
1379            kind: ApiKind::Sync,
1380            params: vec![],
1381            return_type: "number[]".to_string(),
1382            doc: "Get all cursor positions".to_string(),
1383            ts_raw: None,
1384        };
1385        let ts = generate_ts_method(&method);
1386        assert!(ts.contains("getAllCursorPositions(): number[];"));
1387    }
1388
1389    #[test]
1390    fn test_generate_ts_method_terminal() {
1391        let method = ApiMethod {
1392            js_name: "createTerminal".to_string(),
1393            kind: ApiKind::AsyncPromise,
1394            params: vec![ParamInfo {
1395                name: "opts".to_string(),
1396                ts_type: "CreateTerminalOptions".to_string(),
1397                optional: true,
1398                variadic: false,
1399            }],
1400            return_type: "TerminalResult".to_string(),
1401            doc: "Create a terminal".to_string(),
1402            ts_raw: None,
1403        };
1404
1405        let ts = generate_ts_method(&method);
1406        assert!(
1407            ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
1408        );
1409    }
1410
1411    #[test]
1412    fn test_collect_referenced_types_includes_renamed() {
1413        let methods = vec![
1414            ApiMethod {
1415                js_name: "updateAlignment".to_string(),
1416                kind: ApiKind::Sync,
1417                params: vec![ParamInfo {
1418                    name: "hunks".to_string(),
1419                    ts_type: "TsCompositeHunk[]".to_string(),
1420                    optional: false,
1421                    variadic: false,
1422                }],
1423                return_type: "boolean".to_string(),
1424                doc: "".to_string(),
1425                ts_raw: None,
1426            },
1427            ApiMethod {
1428                js_name: "setSuggestions".to_string(),
1429                kind: ApiKind::Sync,
1430                params: vec![ParamInfo {
1431                    name: "suggestions".to_string(),
1432                    ts_type: "PromptSuggestion[]".to_string(),
1433                    optional: false,
1434                    variadic: false,
1435                }],
1436                return_type: "boolean".to_string(),
1437                doc: "".to_string(),
1438                ts_raw: None,
1439            },
1440            ApiMethod {
1441                js_name: "getPrimaryCursor".to_string(),
1442                kind: ApiKind::Sync,
1443                params: vec![],
1444                return_type: "CursorInfo | null".to_string(),
1445                doc: "".to_string(),
1446                ts_raw: None,
1447            },
1448            ApiMethod {
1449                js_name: "createTerminal".to_string(),
1450                kind: ApiKind::AsyncPromise,
1451                params: vec![ParamInfo {
1452                    name: "opts".to_string(),
1453                    ts_type: "CreateTerminalOptions".to_string(),
1454                    optional: true,
1455                    variadic: false,
1456                }],
1457                return_type: "TerminalResult".to_string(),
1458                doc: "".to_string(),
1459                ts_raw: None,
1460            },
1461        ];
1462
1463        let types = collect_referenced_types(&methods);
1464        assert!(types.contains(&"TsCompositeHunk".to_string()));
1465        assert!(types.contains(&"PromptSuggestion".to_string()));
1466        assert!(types.contains(&"CursorInfo".to_string()));
1467        assert!(types.contains(&"TerminalResult".to_string()));
1468        assert!(types.contains(&"CreateTerminalOptions".to_string()));
1469    }
1470
1471    #[test]
1472    fn test_all_known_types_are_passthrough_or_renamed() {
1473        // Verify that all known types produce expected output
1474        let passthrough_types = vec![
1475            "BufferInfo",
1476            "CursorInfo",
1477            "ViewportInfo",
1478            "SpawnResult",
1479            "BackgroundProcessResult",
1480            "DirEntry",
1481            "PromptSuggestion",
1482            "ActionSpec",
1483            "ActionPopupOptions",
1484            "VirtualBufferResult",
1485            "TerminalResult",
1486            "CreateTerminalOptions",
1487            "TsHighlightSpan",
1488            "JsDiagnostic",
1489        ];
1490
1491        for type_name in &passthrough_types {
1492            let ty = parse_type(type_name);
1493            let ts = rust_to_typescript(&ty, &[]);
1494            assert_eq!(
1495                &ts, type_name,
1496                "Type {} should pass through unchanged",
1497                type_name
1498            );
1499        }
1500
1501        // Renamed types
1502        let renamed = vec![
1503            ("CompositeHunk", "TsCompositeHunk"),
1504            (
1505                "CreateCompositeBufferOptions",
1506                "TsCreateCompositeBufferOptions",
1507            ),
1508            ("Suggestion", "PromptSuggestion"),
1509        ];
1510
1511        for (rust_name, ts_name) in &renamed {
1512            let ty = parse_type(rust_name);
1513            let ts = rust_to_typescript(&ty, &[]);
1514            assert_eq!(
1515                &ts, ts_name,
1516                "Type {} should be renamed to {}",
1517                rust_name, ts_name
1518            );
1519        }
1520    }
1521}