1use 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
96fn compile_error(span: proc_macro2::Span, message: &str) -> proc_macro2::TokenStream {
102 syn::Error::new(span, message).to_compile_error()
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum ApiKind {
112 Sync,
114 AsyncPromise,
116 AsyncThenable,
118}
119
120impl ApiKind {
121 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#[derive(Debug)]
137struct ApiMethod {
138 js_name: String,
140 kind: ApiKind,
142 params: Vec<ParamInfo>,
144 return_type: String,
146 doc: String,
148 ts_raw: Option<String>,
151}
152
153#[derive(Debug)]
155struct ParamInfo {
156 name: String,
158 ts_type: String,
160 optional: bool,
162 variadic: bool,
164}
165
166impl ParamInfo {
167 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
179fn 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
207fn 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
232fn 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
247fn 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
261fn 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
276fn get_js_name(attrs: &[Attribute]) -> Option<String> {
278 if let Some(name) = get_plugin_api_value(attrs, "js_name") {
280 return Some(name);
281 }
282
283 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
297fn 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
315fn 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
324fn is_ctx_type(ty: &Type) -> bool {
326 if let Type::Path(type_path) = ty {
327 if let Some(segment) = type_path.path.segments.last() {
329 if segment.ident == "Ctx" {
330 return true;
331 }
332 }
333 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
347fn is_opt_type(ty: &Type) -> bool {
349 get_type_name(ty).is_some_and(|n| n == "Opt")
350}
351
352fn is_rest_type(ty: &Type) -> bool {
354 get_type_name(ty).is_some_and(|n| n == "Rest")
355}
356
357fn rust_to_typescript(ty: &Type, attrs: &[Attribute]) -> String {
369 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 "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "usize" | "isize"
386 | "f32" | "f64" => "number".to_string(),
387
388 "bool" => "boolean".to_string(),
390
391 "String" | "str" => "string".to_string(),
393
394 "()" => "void".to_string(),
396
397 "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" => {
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" => extract_inner_type(ty)
415 .map(|t| rust_to_typescript(&t, &[]))
416 .unwrap_or_else(|| "unknown".to_string()),
417
418 "Rest" => extract_inner_type(ty)
420 .map(|t| rust_to_typescript(&t, &[]))
421 .unwrap_or_else(|| "unknown".to_string()),
422
423 "Result" => extract_inner_type(ty)
425 .map(|t| rust_to_typescript(&t, &[]))
426 .unwrap_or_else(|| "unknown".to_string()),
427
428 "Value" => "unknown".to_string(),
430 "Object" => "Record<string, unknown>".to_string(),
431
432 "HashMap" | "BTreeMap" => "Record<string, unknown>".to_string(),
434
435 "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 | "LayoutHints"
457 | "FileExplorerDecoration"
458 | "TsCompositeLayoutConfig"
459 | "TsCompositeSourceConfig"
460 | "TsCompositePaneStyle"
461 | "TsHighlightSpan"
462 | "TsActionPopupAction"
463 | "JsDiagnostic"
464 | "CreateTerminalOptions"
465 | "TerminalResult" => type_name,
466
467 "CompositeHunk" => "TsCompositeHunk".to_string(),
469 "CreateCompositeBufferOptions" => "TsCreateCompositeBufferOptions".to_string(),
470 "Suggestion" => "PromptSuggestion".to_string(),
471
472 _ => type_name,
474 }
475 }
476 Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
477 Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
478 _ => "unknown".to_string(),
479 }
480}
481
482fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
490 if has_plugin_api_flag(&method.attrs, "skip") {
492 return None;
493 }
494
495 let rust_name = method.sig.ident.to_string();
496 let doc = extract_doc_comment(&method.attrs);
497
498 let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
500 ApiKind::AsyncThenable
501 } else if has_plugin_api_flag(&method.attrs, "async_promise") {
502 ApiKind::AsyncPromise
503 } else {
504 ApiKind::Sync
505 };
506
507 let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
509
510 if js_name.starts_with('_') {
512 return None;
513 }
514
515 let params: Vec<ParamInfo> = method
517 .sig
518 .inputs
519 .iter()
520 .filter_map(|arg| {
521 let FnArg::Typed(pat_type) = arg else {
522 return None;
523 };
524 let Pat::Ident(pat_ident) = &*pat_type.pat else {
525 return None;
526 };
527
528 let raw_name = pat_ident.ident.to_string();
529
530 if raw_name == "self" {
532 return None;
533 }
534
535 let param_name = raw_name.strip_prefix('_').unwrap_or(&raw_name);
537
538 let ty = &*pat_type.ty;
539
540 if is_ctx_type(ty) {
542 return None;
543 }
544
545 Some(ParamInfo {
546 name: to_camel_case(param_name),
547 ts_type: rust_to_typescript(ty, &pat_type.attrs),
548 optional: is_opt_type(ty),
549 variadic: is_rest_type(ty),
550 })
551 })
552 .collect();
553
554 let return_type = match &method.sig.output {
556 ReturnType::Default => "void".to_string(),
557 ReturnType::Type(_, ty) => {
558 get_plugin_api_value(&method.attrs, "ts_return")
560 .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
561 }
562 };
563
564 let ts_raw = get_plugin_api_value(&method.attrs, "ts_raw");
566
567 Some(ApiMethod {
568 js_name,
569 kind,
570 params,
571 return_type,
572 doc,
573 ts_raw,
574 })
575}
576
577fn generate_ts_method(method: &ApiMethod) -> String {
583 let mut lines = Vec::new();
584
585 if !method.doc.is_empty() {
587 lines.push(" /**".to_string());
588 for line in method.doc.lines() {
589 lines.push(format!(" * {}", line));
590 }
591 lines.push(" */".to_string());
592 }
593
594 if let Some(raw) = &method.ts_raw {
596 lines.push(format!(" {};", raw));
597 } else {
598 let params: String = method
600 .params
601 .iter()
602 .map(ParamInfo::to_typescript)
603 .collect::<Vec<_>>()
604 .join(", ");
605
606 let return_type = method.kind.wrap_return_type(&method.return_type);
607
608 lines.push(format!(
609 " {}({}): {};",
610 method.js_name, params, return_type
611 ));
612 }
613
614 lines.join("\n")
615}
616
617fn generate_ts_preamble() -> &'static str {
619 r#"/**
620 * Fresh Editor TypeScript Plugin API
621 *
622 * This file provides type definitions for the Fresh editor's TypeScript plugin system.
623 * Plugins have access to the global `editor` object which provides methods to:
624 * - Query editor state (buffers, cursors, viewports)
625 * - Modify buffer content (insert, delete text)
626 * - Add visual decorations (overlays, highlighting)
627 * - Interact with the editor UI (status messages, prompts)
628 *
629 * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
630 * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
631 */
632
633/**
634 * Get the editor API instance.
635 * Plugins must call this at the top of their file to get a scoped editor object.
636 */
637declare function getEditor(): EditorAPI;
638
639/**
640 * Register a function as a named handler on the global scope.
641 *
642 * Handler functions registered this way can be referenced by name in
643 * `editor.registerCommand()`, `editor.on()`, and mode keybindings.
644 *
645 * The `fn` parameter is typed as `Function` because the runtime passes
646 * different argument shapes depending on the caller: command handlers
647 * receive no arguments, event handlers receive an event-specific data
648 * object (e.g. `{ buffer_id: number }`), and prompt handlers receive
649 * `{ prompt_type: string, input: string }`. Type-annotate your handler
650 * parameters to match the event you are handling.
651 *
652 * @param name - Handler name (referenced by registerCommand, on, etc.)
653 * @param fn - The handler function
654 */
655declare function registerHandler(name: string, fn: Function): void;
656
657/** Handle for a cancellable async operation */
658interface ProcessHandle<T> extends PromiseLike<T> {
659 /** Promise that resolves to the result when complete */
660 readonly result: Promise<T>;
661 /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
662 kill(): Promise<boolean>;
663}
664
665/** Buffer identifier */
666type BufferId = number;
667
668/** Split identifier */
669type SplitId = number;
670
671/**
672 * Payload delivered to handlers registered with `editor.on("mouse_click", ...)`.
673 *
674 * All coordinate fields are in cell (terminal character) units. `buffer_*`
675 * fields are `null` when the click did not land in any buffer panel.
676 */
677interface MouseClickHookArgs {
678 /** Screen column (0-indexed). */
679 column: number;
680 /** Screen row (0-indexed). */
681 row: number;
682 /** Mouse button: "left", "right", "middle". */
683 button: string;
684 /** Modifier keys (e.g. "shift"). */
685 modifiers: string;
686 /** X offset of the content area the click landed in. */
687 content_x: number;
688 /** Y offset of the content area the click landed in. */
689 content_y: number;
690 /** Buffer under the click, or `null` when outside any buffer panel. */
691 buffer_id: number | null;
692 /** 0-indexed buffer row (line number) of the click, accounting for scroll. */
693 buffer_row: number | null;
694 /** 0-indexed byte column inside the buffer row. */
695 buffer_col: number | null;
696}
697
698/**
699 * Registry of typed plugin APIs surfaced through
700 * `editor.exportPluginApi` / `editor.getPluginApi`.
701 *
702 * Plugins that want their surface to be typed for downstream
703 * consumers augment this interface in their own source:
704 *
705 * ```ts
706 * // in my_plugin.ts
707 * export type MyPluginApi = { doThing(): void };
708 * declare global {
709 * interface FreshPluginRegistry {
710 * "my-plugin": MyPluginApi;
711 * }
712 * }
713 * ```
714 *
715 * `editor.getPluginApi("my-plugin")` then returns
716 * `MyPluginApi | null` without any `as`-cast on the consumer side.
717 * Plugins that skip the augmentation still work — the untyped
718 * `getPluginApi<T = unknown>(name: string): T | null` overload
719 * takes over.
720 *
721 * Each plugin's augmentation is emitted to
722 * `<config_dir>/types/plugins.d.ts` at load time (via oxc's
723 * isolated-declarations), so init.ts sees every loaded plugin's
724 * registry entry automatically.
725 */
726interface FreshPluginRegistry {}
727
728"#
729}
730
731fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
734 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
735
736 format!(
737 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
738 method_sigs.join("\n\n")
739 )
740}
741
742const BUILTIN_TS_TYPES: &[&str] = &[
744 "number",
745 "string",
746 "boolean",
747 "void",
748 "unknown",
749 "null",
750 "undefined",
751 "Record",
752 "Array",
753 "Promise",
754 "ProcessHandle",
755 "PromiseLike",
756 "BufferId",
757 "SplitId", ];
759
760fn extract_type_references(ts_type: &str) -> Vec<String> {
768 let mut types = Vec::new();
769
770 let mut current = ts_type.to_string();
772
773 while let Some(start) = current.find('<') {
775 if let Some(end) = current.rfind('>') {
776 let outer = current[..start].trim().to_string();
777 let inner = current[start + 1..end].trim().to_string();
778
779 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
781 types.push(outer);
782 }
783
784 current = inner;
786 } else {
787 break;
788 }
789 }
790
791 for part in current.split('|') {
793 let part = part.trim();
794
795 if BUILTIN_TS_TYPES.contains(&part) {
797 continue;
798 }
799
800 let part = part.trim_end_matches("[]");
802
803 if part.contains('<') || part.contains('>') {
805 continue;
806 }
807
808 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
810 continue;
811 }
812
813 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
815 types.push(part.to_string());
816 }
817 }
818
819 types
820}
821
822fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
824 let mut types = std::collections::HashSet::new();
825
826 for method in methods {
827 for ty in extract_type_references(&method.return_type) {
829 types.insert(ty);
830 }
831
832 for param in &method.params {
834 for ty in extract_type_references(¶m.ts_type) {
835 types.insert(ty);
836 }
837 }
838 }
839
840 let mut sorted: Vec<String> = types.into_iter().collect();
841 sorted.sort();
842 sorted
843}
844
845#[proc_macro_attribute]
879pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
880 let input = parse_macro_input!(item as ItemImpl);
881
882 let impl_name = match &*input.self_ty {
884 Type::Path(type_path) => type_path
885 .path
886 .segments
887 .last()
888 .map(|s| s.ident.to_string())
889 .unwrap_or_else(|| "Unknown".to_string()),
890 _ => {
891 return compile_error(
892 input.self_ty.span(),
893 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
894 )
895 .into();
896 }
897 };
898
899 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
901 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
902 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
903
904 let methods: Vec<ApiMethod> = input
906 .items
907 .iter()
908 .filter_map(|item| {
909 if let ImplItem::Fn(method) = item {
910 parse_method(method)
911 } else {
912 None
913 }
914 })
915 .collect();
916
917 let preamble = generate_ts_preamble();
919 let editor_api = generate_editor_api_interface(&methods);
920
921 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
923
924 let referenced_types = collect_referenced_types(&methods);
926 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
927
928 let mut cleaned_input = input.clone();
932 for item in &mut cleaned_input.items {
933 if let ImplItem::Fn(method) = item {
934 for arg in &mut method.sig.inputs {
935 if let FnArg::Typed(pat_type) = arg {
936 pat_type
937 .attrs
938 .retain(|attr| !attr.path().is_ident("plugin_api"));
939 }
940 }
941 }
942 }
943
944 let expanded = quote! {
946 #cleaned_input
947
948 pub const #preamble_const: &str = #preamble;
952
953 pub const #editor_api_const: &str = #editor_api;
957
958 pub const #methods_const: &[&str] = &[#(#js_names),*];
962
963 pub const #types_const: &[&str] = &[#(#referenced_types),*];
968 };
969
970 TokenStream::from(expanded)
971}
972
973#[proc_macro_attribute]
1003pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
1004 item
1006}
1007
1008#[cfg(test)]
1013mod tests {
1014 use super::*;
1015
1016 #[test]
1017 fn test_to_camel_case() {
1018 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
1019 assert_eq!(to_camel_case("simple"), "simple");
1020 assert_eq!(to_camel_case("a_b_c"), "aBC");
1021 assert_eq!(to_camel_case("process_id"), "processId");
1024 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
1025 assert_eq!(to_camel_case(""), "");
1026 assert_eq!(to_camel_case("_leading"), "Leading");
1027 assert_eq!(to_camel_case("trailing_"), "trailing");
1028 }
1029
1030 #[test]
1031 fn test_parse_attr_string_value() {
1032 assert_eq!(
1033 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
1034 Some("myMethod".to_string())
1035 );
1036 assert_eq!(
1037 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
1038 Some("foo".to_string())
1039 );
1040 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
1041 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
1042 }
1043
1044 #[test]
1045 fn test_api_kind_wrap_return_type() {
1046 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
1047 assert_eq!(
1048 ApiKind::AsyncPromise.wrap_return_type("number"),
1049 "Promise<number>"
1050 );
1051 assert_eq!(
1052 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
1053 "ProcessHandle<SpawnResult>"
1054 );
1055 }
1056
1057 #[test]
1058 fn test_param_info_to_typescript() {
1059 let regular = ParamInfo {
1060 name: "bufferId".to_string(),
1061 ts_type: "number".to_string(),
1062 optional: false,
1063 variadic: false,
1064 };
1065 assert_eq!(regular.to_typescript(), "bufferId: number");
1066
1067 let optional = ParamInfo {
1068 name: "line".to_string(),
1069 ts_type: "number".to_string(),
1070 optional: true,
1071 variadic: false,
1072 };
1073 assert_eq!(optional.to_typescript(), "line?: number");
1074
1075 let variadic = ParamInfo {
1076 name: "parts".to_string(),
1077 ts_type: "string".to_string(),
1078 optional: false,
1079 variadic: true,
1080 };
1081 assert_eq!(variadic.to_typescript(), "...parts: string[]");
1082 }
1083
1084 #[test]
1085 fn test_generate_ts_preamble_contains_required_declarations() {
1086 let preamble = generate_ts_preamble();
1087
1088 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
1090 assert!(preamble.contains("interface ProcessHandle<T>"));
1091 assert!(preamble.contains("type BufferId = number"));
1092 assert!(preamble.contains("type SplitId = number"));
1093
1094 assert!(preamble.contains("AUTO-GENERATED FILE"));
1096 }
1097
1098 #[test]
1099 fn test_extract_type_references() {
1100 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1102
1103 assert!(extract_type_references("number").is_empty());
1105 assert!(extract_type_references("string").is_empty());
1106 assert!(extract_type_references("void").is_empty());
1107
1108 assert_eq!(
1110 extract_type_references("ProcessHandle<SpawnResult>"),
1111 vec!["SpawnResult"]
1112 );
1113 assert_eq!(
1114 extract_type_references("Promise<BufferInfo>"),
1115 vec!["BufferInfo"]
1116 );
1117
1118 assert_eq!(
1120 extract_type_references("CursorInfo | null"),
1121 vec!["CursorInfo"]
1122 );
1123
1124 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1126
1127 assert!(extract_type_references("Record<string, unknown>").is_empty());
1129 assert!(extract_type_references("Promise<void>").is_empty());
1130 }
1131
1132 #[test]
1133 fn test_collect_referenced_types() {
1134 let methods = vec![
1135 ApiMethod {
1136 js_name: "spawnProcess".to_string(),
1137 kind: ApiKind::AsyncThenable,
1138 params: vec![],
1139 return_type: "SpawnResult".to_string(),
1140 doc: "".to_string(),
1141 ts_raw: None,
1142 },
1143 ApiMethod {
1144 js_name: "listBuffers".to_string(),
1145 kind: ApiKind::Sync,
1146 params: vec![],
1147 return_type: "BufferInfo[]".to_string(),
1148 doc: "".to_string(),
1149 ts_raw: None,
1150 },
1151 ];
1152
1153 let types = collect_referenced_types(&methods);
1154 assert!(types.contains(&"SpawnResult".to_string()));
1155 assert!(types.contains(&"BufferInfo".to_string()));
1156 }
1157
1158 #[test]
1159 fn test_generate_ts_method_sync() {
1160 let method = ApiMethod {
1161 js_name: "getActiveBufferId".to_string(),
1162 kind: ApiKind::Sync,
1163 params: vec![],
1164 return_type: "number".to_string(),
1165 doc: "Get the active buffer ID".to_string(),
1166 ts_raw: None,
1167 };
1168
1169 let ts = generate_ts_method(&method);
1170 assert!(ts.contains("getActiveBufferId(): number;"));
1171 assert!(ts.contains("Get the active buffer ID"));
1172 }
1173
1174 #[test]
1175 fn test_generate_ts_method_async_promise() {
1176 let method = ApiMethod {
1177 js_name: "delay".to_string(),
1178 kind: ApiKind::AsyncPromise,
1179 params: vec![ParamInfo {
1180 name: "ms".to_string(),
1181 ts_type: "number".to_string(),
1182 optional: false,
1183 variadic: false,
1184 }],
1185 return_type: "void".to_string(),
1186 doc: "".to_string(),
1187 ts_raw: None,
1188 };
1189
1190 let ts = generate_ts_method(&method);
1191 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1192 }
1193
1194 #[test]
1195 fn test_generate_ts_method_async_thenable() {
1196 let method = ApiMethod {
1197 js_name: "spawnProcess".to_string(),
1198 kind: ApiKind::AsyncThenable,
1199 params: vec![
1200 ParamInfo {
1201 name: "command".to_string(),
1202 ts_type: "string".to_string(),
1203 optional: false,
1204 variadic: false,
1205 },
1206 ParamInfo {
1207 name: "args".to_string(),
1208 ts_type: "string".to_string(),
1209 optional: false,
1210 variadic: false,
1211 },
1212 ],
1213 return_type: "SpawnResult".to_string(),
1214 doc: "Spawn a process".to_string(),
1215 ts_raw: None,
1216 };
1217
1218 let ts = generate_ts_method(&method);
1219 assert!(
1220 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1221 );
1222 }
1223
1224 fn parse_type(s: &str) -> Type {
1230 syn::parse_str::<Type>(s).unwrap()
1231 }
1232
1233 #[test]
1234 fn test_renamed_type_composite_hunk() {
1235 let ty = parse_type("Vec<CompositeHunk>");
1236 let ts = rust_to_typescript(&ty, &[]);
1237 assert_eq!(ts, "TsCompositeHunk[]");
1238 }
1239
1240 #[test]
1241 fn test_renamed_type_create_composite_buffer_options() {
1242 let ty = parse_type("CreateCompositeBufferOptions");
1243 let ts = rust_to_typescript(&ty, &[]);
1244 assert_eq!(ts, "TsCreateCompositeBufferOptions");
1245 }
1246
1247 #[test]
1248 fn test_renamed_type_suggestion() {
1249 let ty = parse_type("Vec<Suggestion>");
1250 let ts = rust_to_typescript(&ty, &[]);
1251 assert_eq!(ts, "PromptSuggestion[]");
1252 }
1253
1254 #[test]
1255 fn test_passthrough_type_terminal_result() {
1256 let ty = parse_type("TerminalResult");
1257 let ts = rust_to_typescript(&ty, &[]);
1258 assert_eq!(ts, "TerminalResult");
1259 }
1260
1261 #[test]
1262 fn test_passthrough_type_create_terminal_options() {
1263 let ty = parse_type("CreateTerminalOptions");
1264 let ts = rust_to_typescript(&ty, &[]);
1265 assert_eq!(ts, "CreateTerminalOptions");
1266 }
1267
1268 #[test]
1269 fn test_passthrough_type_cursor_info() {
1270 let ty = parse_type("CursorInfo");
1271 let ts = rust_to_typescript(&ty, &[]);
1272 assert_eq!(ts, "CursorInfo");
1273 }
1274
1275 #[test]
1276 fn test_option_cursor_info() {
1277 let ty = parse_type("Option<CursorInfo>");
1278 let ts = rust_to_typescript(&ty, &[]);
1279 assert_eq!(ts, "CursorInfo | null");
1280 }
1281
1282 #[test]
1283 fn test_extract_type_references_renamed_types() {
1284 assert_eq!(
1286 extract_type_references("TsCompositeHunk[]"),
1287 vec!["TsCompositeHunk"]
1288 );
1289 assert_eq!(
1290 extract_type_references("TsCreateCompositeBufferOptions"),
1291 vec!["TsCreateCompositeBufferOptions"]
1292 );
1293 assert_eq!(
1294 extract_type_references("PromptSuggestion[]"),
1295 vec!["PromptSuggestion"]
1296 );
1297 }
1298
1299 #[test]
1300 fn test_extract_type_references_terminal_types() {
1301 assert_eq!(
1302 extract_type_references("Promise<TerminalResult>"),
1303 vec!["TerminalResult"]
1304 );
1305 assert_eq!(
1306 extract_type_references("CreateTerminalOptions"),
1307 vec!["CreateTerminalOptions"]
1308 );
1309 }
1310
1311 #[test]
1312 fn test_extract_type_references_cursor_types() {
1313 assert_eq!(
1314 extract_type_references("CursorInfo | null"),
1315 vec!["CursorInfo"]
1316 );
1317 assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
1318 }
1319
1320 #[test]
1321 fn test_generate_ts_method_with_renamed_param_type() {
1322 let method = ApiMethod {
1323 js_name: "updateCompositeAlignment".to_string(),
1324 kind: ApiKind::Sync,
1325 params: vec![
1326 ParamInfo {
1327 name: "bufferId".to_string(),
1328 ts_type: "number".to_string(),
1329 optional: false,
1330 variadic: false,
1331 },
1332 ParamInfo {
1333 name: "hunks".to_string(),
1334 ts_type: "TsCompositeHunk[]".to_string(),
1335 optional: false,
1336 variadic: false,
1337 },
1338 ],
1339 return_type: "boolean".to_string(),
1340 doc: "Update alignment hunks".to_string(),
1341 ts_raw: None,
1342 };
1343
1344 let ts = generate_ts_method(&method);
1345 assert!(ts.contains(
1346 "updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
1347 ));
1348 }
1349
1350 #[test]
1351 fn test_generate_ts_method_cursor_return_types() {
1352 let method = ApiMethod {
1353 js_name: "getPrimaryCursor".to_string(),
1354 kind: ApiKind::Sync,
1355 params: vec![],
1356 return_type: "CursorInfo | null".to_string(),
1357 doc: "Get primary cursor".to_string(),
1358 ts_raw: None,
1359 };
1360 let ts = generate_ts_method(&method);
1361 assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
1362
1363 let method = ApiMethod {
1364 js_name: "getAllCursors".to_string(),
1365 kind: ApiKind::Sync,
1366 params: vec![],
1367 return_type: "CursorInfo[]".to_string(),
1368 doc: "Get all cursors".to_string(),
1369 ts_raw: None,
1370 };
1371 let ts = generate_ts_method(&method);
1372 assert!(ts.contains("getAllCursors(): CursorInfo[];"));
1373
1374 let method = ApiMethod {
1375 js_name: "getAllCursorPositions".to_string(),
1376 kind: ApiKind::Sync,
1377 params: vec![],
1378 return_type: "number[]".to_string(),
1379 doc: "Get all cursor positions".to_string(),
1380 ts_raw: None,
1381 };
1382 let ts = generate_ts_method(&method);
1383 assert!(ts.contains("getAllCursorPositions(): number[];"));
1384 }
1385
1386 #[test]
1387 fn test_generate_ts_method_terminal() {
1388 let method = ApiMethod {
1389 js_name: "createTerminal".to_string(),
1390 kind: ApiKind::AsyncPromise,
1391 params: vec![ParamInfo {
1392 name: "opts".to_string(),
1393 ts_type: "CreateTerminalOptions".to_string(),
1394 optional: true,
1395 variadic: false,
1396 }],
1397 return_type: "TerminalResult".to_string(),
1398 doc: "Create a terminal".to_string(),
1399 ts_raw: None,
1400 };
1401
1402 let ts = generate_ts_method(&method);
1403 assert!(
1404 ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
1405 );
1406 }
1407
1408 #[test]
1409 fn test_collect_referenced_types_includes_renamed() {
1410 let methods = vec![
1411 ApiMethod {
1412 js_name: "updateAlignment".to_string(),
1413 kind: ApiKind::Sync,
1414 params: vec![ParamInfo {
1415 name: "hunks".to_string(),
1416 ts_type: "TsCompositeHunk[]".to_string(),
1417 optional: false,
1418 variadic: false,
1419 }],
1420 return_type: "boolean".to_string(),
1421 doc: "".to_string(),
1422 ts_raw: None,
1423 },
1424 ApiMethod {
1425 js_name: "setSuggestions".to_string(),
1426 kind: ApiKind::Sync,
1427 params: vec![ParamInfo {
1428 name: "suggestions".to_string(),
1429 ts_type: "PromptSuggestion[]".to_string(),
1430 optional: false,
1431 variadic: false,
1432 }],
1433 return_type: "boolean".to_string(),
1434 doc: "".to_string(),
1435 ts_raw: None,
1436 },
1437 ApiMethod {
1438 js_name: "getPrimaryCursor".to_string(),
1439 kind: ApiKind::Sync,
1440 params: vec![],
1441 return_type: "CursorInfo | null".to_string(),
1442 doc: "".to_string(),
1443 ts_raw: None,
1444 },
1445 ApiMethod {
1446 js_name: "createTerminal".to_string(),
1447 kind: ApiKind::AsyncPromise,
1448 params: vec![ParamInfo {
1449 name: "opts".to_string(),
1450 ts_type: "CreateTerminalOptions".to_string(),
1451 optional: true,
1452 variadic: false,
1453 }],
1454 return_type: "TerminalResult".to_string(),
1455 doc: "".to_string(),
1456 ts_raw: None,
1457 },
1458 ];
1459
1460 let types = collect_referenced_types(&methods);
1461 assert!(types.contains(&"TsCompositeHunk".to_string()));
1462 assert!(types.contains(&"PromptSuggestion".to_string()));
1463 assert!(types.contains(&"CursorInfo".to_string()));
1464 assert!(types.contains(&"TerminalResult".to_string()));
1465 assert!(types.contains(&"CreateTerminalOptions".to_string()));
1466 }
1467
1468 #[test]
1469 fn test_all_known_types_are_passthrough_or_renamed() {
1470 let passthrough_types = vec![
1472 "BufferInfo",
1473 "CursorInfo",
1474 "ViewportInfo",
1475 "SpawnResult",
1476 "BackgroundProcessResult",
1477 "DirEntry",
1478 "PromptSuggestion",
1479 "ActionSpec",
1480 "ActionPopupOptions",
1481 "VirtualBufferResult",
1482 "TerminalResult",
1483 "CreateTerminalOptions",
1484 "TsHighlightSpan",
1485 "JsDiagnostic",
1486 ];
1487
1488 for type_name in &passthrough_types {
1489 let ty = parse_type(type_name);
1490 let ts = rust_to_typescript(&ty, &[]);
1491 assert_eq!(
1492 &ts, type_name,
1493 "Type {} should pass through unchanged",
1494 type_name
1495 );
1496 }
1497
1498 let renamed = vec![
1500 ("CompositeHunk", "TsCompositeHunk"),
1501 (
1502 "CreateCompositeBufferOptions",
1503 "TsCreateCompositeBufferOptions",
1504 ),
1505 ("Suggestion", "PromptSuggestion"),
1506 ];
1507
1508 for (rust_name, ts_name) in &renamed {
1509 let ty = parse_type(rust_name);
1510 let ts = rust_to_typescript(&ty, &[]);
1511 assert_eq!(
1512 &ts, ts_name,
1513 "Type {} should be renamed to {}",
1514 rust_name, ts_name
1515 );
1516 }
1517 }
1518}