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 | "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 "CompositeHunk" => "TsCompositeHunk".to_string(),
471 "CreateCompositeBufferOptions" => "TsCreateCompositeBufferOptions".to_string(),
472 "Suggestion" => "PromptSuggestion".to_string(),
473 "LspMenuItem" => "TsLspMenuItem".to_string(),
474
475 _ => 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
485fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
493 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 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 let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
512
513 if js_name.starts_with('_') {
515 return None;
516 }
517
518 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 if raw_name == "self" {
535 return None;
536 }
537
538 let param_name = raw_name.strip_prefix('_').unwrap_or(&raw_name);
540
541 let ty = &*pat_type.ty;
542
543 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 let return_type = match &method.sig.output {
559 ReturnType::Default => "void".to_string(),
560 ReturnType::Type(_, ty) => {
561 get_plugin_api_value(&method.attrs, "ts_return")
563 .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
564 }
565 };
566
567 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
580fn generate_ts_method(method: &ApiMethod) -> String {
586 let mut lines = Vec::new();
587
588 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 if let Some(raw) = &method.ts_raw {
599 lines.push(format!(" {};", raw));
600 } else {
601 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
620fn 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
734fn 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
745const 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", ];
762
763fn extract_type_references(ts_type: &str) -> Vec<String> {
771 let mut types = Vec::new();
772
773 let mut current = ts_type.to_string();
775
776 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 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
784 types.push(outer);
785 }
786
787 current = inner;
789 } else {
790 break;
791 }
792 }
793
794 for part in current.split('|') {
796 let part = part.trim();
797
798 if BUILTIN_TS_TYPES.contains(&part) {
800 continue;
801 }
802
803 let part = part.trim_end_matches("[]");
805
806 if part.contains('<') || part.contains('>') {
808 continue;
809 }
810
811 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
813 continue;
814 }
815
816 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
818 types.push(part.to_string());
819 }
820 }
821
822 types
823}
824
825fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
827 let mut types = std::collections::HashSet::new();
828
829 for method in methods {
830 for ty in extract_type_references(&method.return_type) {
832 types.insert(ty);
833 }
834
835 for param in &method.params {
837 for ty in extract_type_references(¶m.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#[proc_macro_attribute]
882pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
883 let input = parse_macro_input!(item as ItemImpl);
884
885 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 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 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 let preamble = generate_ts_preamble();
922 let editor_api = generate_editor_api_interface(&methods);
923
924 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
926
927 let referenced_types = collect_referenced_types(&methods);
929 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
930
931 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 let expanded = quote! {
949 #cleaned_input
950
951 pub const #preamble_const: &str = #preamble;
955
956 pub const #editor_api_const: &str = #editor_api;
960
961 pub const #methods_const: &[&str] = &[#(#js_names),*];
965
966 pub const #types_const: &[&str] = &[#(#referenced_types),*];
971 };
972
973 TokenStream::from(expanded)
974}
975
976#[proc_macro_attribute]
1006pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
1007 item
1009}
1010
1011#[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 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 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 assert!(preamble.contains("AUTO-GENERATED FILE"));
1099 }
1100
1101 #[test]
1102 fn test_extract_type_references() {
1103 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1105
1106 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 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 assert_eq!(
1123 extract_type_references("CursorInfo | null"),
1124 vec!["CursorInfo"]
1125 );
1126
1127 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1129
1130 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 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 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 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 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}