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}
149
150#[derive(Debug)]
152struct ParamInfo {
153 name: String,
155 ts_type: String,
157 optional: bool,
159 variadic: bool,
161}
162
163impl ParamInfo {
164 fn to_typescript(&self) -> String {
166 if self.variadic {
167 format!("...{}: {}[]", self.name, self.ts_type)
168 } else if self.optional {
169 format!("{}?: {}", self.name, self.ts_type)
170 } else {
171 format!("{}: {}", self.name, self.ts_type)
172 }
173 }
174}
175
176fn to_camel_case(s: &str) -> String {
188 let mut result = String::with_capacity(s.len());
189 let mut capitalize_next = false;
190
191 for c in s.chars() {
192 if c == '_' {
193 capitalize_next = true;
194 } else if capitalize_next {
195 result.push(c.to_ascii_uppercase());
196 capitalize_next = false;
197 } else {
198 result.push(c);
199 }
200 }
201 result
202}
203
204fn extract_doc_comment(attrs: &[Attribute]) -> String {
210 attrs
211 .iter()
212 .filter_map(|attr| {
213 if !attr.path().is_ident("doc") {
214 return None;
215 }
216 if let Meta::NameValue(meta) = &attr.meta {
217 if let syn::Expr::Lit(expr_lit) = &meta.value {
218 if let syn::Lit::Str(lit_str) = &expr_lit.lit {
219 return Some(lit_str.value().trim().to_string());
220 }
221 }
222 }
223 None
224 })
225 .collect::<Vec<_>>()
226 .join("\n")
227}
228
229fn parse_attr_string_value(tokens: &str, key: &str) -> Option<String> {
231 let start = tokens.find(key)?;
232 let rest = &tokens[start..];
233 let eq_pos = rest.find('=')?;
234 let after_eq = rest[eq_pos + 1..].trim();
235
236 if !after_eq.starts_with('"') {
237 return None;
238 }
239
240 let end_quote = after_eq[1..].find('"')?;
241 Some(after_eq[1..end_quote + 1].to_string())
242}
243
244fn has_plugin_api_flag(attrs: &[Attribute], flag: &str) -> bool {
246 attrs.iter().any(|attr| {
247 if !attr.path().is_ident("plugin_api") {
248 return false;
249 }
250 if let Meta::List(meta_list) = &attr.meta {
251 meta_list.tokens.to_string().contains(flag)
252 } else {
253 false
254 }
255 })
256}
257
258fn get_plugin_api_value(attrs: &[Attribute], key: &str) -> Option<String> {
260 for attr in attrs {
261 if !attr.path().is_ident("plugin_api") {
262 continue;
263 }
264 if let Meta::List(meta_list) = &attr.meta {
265 if let Some(value) = parse_attr_string_value(&meta_list.tokens.to_string(), key) {
266 return Some(value);
267 }
268 }
269 }
270 None
271}
272
273fn get_js_name(attrs: &[Attribute]) -> Option<String> {
275 if let Some(name) = get_plugin_api_value(attrs, "js_name") {
277 return Some(name);
278 }
279
280 for attr in attrs {
282 if !attr.path().is_ident("qjs") {
283 continue;
284 }
285 if let Meta::List(meta_list) = &attr.meta {
286 if let Some(name) = parse_attr_string_value(&meta_list.tokens.to_string(), "rename") {
287 return Some(name);
288 }
289 }
290 }
291 None
292}
293
294fn extract_inner_type(ty: &Type) -> Option<Type> {
300 if let Type::Path(type_path) = ty {
301 if let Some(segment) = type_path.path.segments.last() {
302 if let PathArguments::AngleBracketed(args) = &segment.arguments {
303 if let Some(GenericArgument::Type(inner)) = args.args.first() {
304 return Some(inner.clone());
305 }
306 }
307 }
308 }
309 None
310}
311
312fn get_type_name(ty: &Type) -> Option<String> {
314 if let Type::Path(type_path) = ty {
315 type_path.path.segments.last().map(|s| s.ident.to_string())
316 } else {
317 None
318 }
319}
320
321fn is_ctx_type(ty: &Type) -> bool {
323 if let Type::Path(type_path) = ty {
324 if let Some(segment) = type_path.path.segments.last() {
326 if segment.ident == "Ctx" {
327 return true;
328 }
329 }
330 let path_str: String = type_path
332 .path
333 .segments
334 .iter()
335 .map(|s| s.ident.to_string())
336 .collect::<Vec<_>>()
337 .join("::");
338 path_str.contains("Ctx")
339 } else {
340 false
341 }
342}
343
344fn is_opt_type(ty: &Type) -> bool {
346 get_type_name(ty).is_some_and(|n| n == "Opt")
347}
348
349fn is_rest_type(ty: &Type) -> bool {
351 get_type_name(ty).is_some_and(|n| n == "Rest")
352}
353
354fn rust_to_typescript(ty: &Type, attrs: &[Attribute]) -> String {
366 if let Some(custom) = get_plugin_api_value(attrs, "ts_type") {
368 return custom;
369 }
370
371 match ty {
372 Type::Path(type_path) => {
373 let type_name = type_path
374 .path
375 .segments
376 .last()
377 .map(|s| s.ident.to_string())
378 .unwrap_or_else(|| "unknown".to_string());
379
380 match type_name.as_str() {
381 "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "usize" | "isize"
383 | "f32" | "f64" => "number".to_string(),
384
385 "bool" => "boolean".to_string(),
387
388 "String" | "str" => "string".to_string(),
390
391 "()" => "void".to_string(),
393
394 "Option" => {
396 let inner = extract_inner_type(ty)
397 .map(|t| rust_to_typescript(&t, &[]))
398 .unwrap_or_else(|| "unknown".to_string());
399 format!("{} | null", inner)
400 }
401
402 "Vec" => {
404 let inner = extract_inner_type(ty)
405 .map(|t| rust_to_typescript(&t, &[]))
406 .unwrap_or_else(|| "unknown".to_string());
407 format!("{}[]", inner)
408 }
409
410 "Opt" => extract_inner_type(ty)
412 .map(|t| rust_to_typescript(&t, &[]))
413 .unwrap_or_else(|| "unknown".to_string()),
414
415 "Rest" => extract_inner_type(ty)
417 .map(|t| rust_to_typescript(&t, &[]))
418 .unwrap_or_else(|| "unknown".to_string()),
419
420 "Result" => extract_inner_type(ty)
422 .map(|t| rust_to_typescript(&t, &[]))
423 .unwrap_or_else(|| "unknown".to_string()),
424
425 "Value" => "unknown".to_string(),
427 "Object" => "Record<string, unknown>".to_string(),
428
429 "HashMap" | "BTreeMap" => "Record<string, unknown>".to_string(),
431
432 "BufferInfo"
434 | "CursorInfo"
435 | "ViewportInfo"
436 | "SpawnResult"
437 | "BackgroundProcessResult"
438 | "DirEntry"
439 | "FileStat"
440 | "CreateVirtualBufferResult"
441 | "PromptSuggestion"
442 | "TextPropertyEntry"
443 | "JsTextPropertyEntry"
444 | "CreateVirtualBufferOptions"
445 | "CreateVirtualBufferInSplitOptions"
446 | "CreateVirtualBufferInExistingSplitOptions"
447 | "VirtualBufferResult"
448 | "ActionSpec"
449 | "ActionPopupAction"
450 | "ActionPopupOptions"
451 | "ViewTokenWire"
452 | "ViewTokenStyle"
453 | "LayoutHints"
454 | "FileExplorerDecoration"
455 | "TsCompositeLayoutConfig"
456 | "TsCompositeSourceConfig"
457 | "TsCompositePaneStyle"
458 | "TsHighlightSpan"
459 | "TsActionPopupAction"
460 | "JsDiagnostic"
461 | "CreateTerminalOptions"
462 | "TerminalResult" => type_name,
463
464 "CompositeHunk" => "TsCompositeHunk".to_string(),
466 "CreateCompositeBufferOptions" => "TsCreateCompositeBufferOptions".to_string(),
467 "Suggestion" => "PromptSuggestion".to_string(),
468
469 _ => type_name,
471 }
472 }
473 Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
474 Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
475 _ => "unknown".to_string(),
476 }
477}
478
479fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
487 if has_plugin_api_flag(&method.attrs, "skip") {
489 return None;
490 }
491
492 let rust_name = method.sig.ident.to_string();
493 let doc = extract_doc_comment(&method.attrs);
494
495 let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
497 ApiKind::AsyncThenable
498 } else if has_plugin_api_flag(&method.attrs, "async_promise") {
499 ApiKind::AsyncPromise
500 } else {
501 ApiKind::Sync
502 };
503
504 let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
506
507 if js_name.starts_with('_') {
509 return None;
510 }
511
512 let params: Vec<ParamInfo> = method
514 .sig
515 .inputs
516 .iter()
517 .filter_map(|arg| {
518 let FnArg::Typed(pat_type) = arg else {
519 return None;
520 };
521 let Pat::Ident(pat_ident) = &*pat_type.pat else {
522 return None;
523 };
524
525 let raw_name = pat_ident.ident.to_string();
526
527 if raw_name == "self" {
529 return None;
530 }
531
532 let param_name = raw_name.strip_prefix('_').unwrap_or(&raw_name);
534
535 let ty = &*pat_type.ty;
536
537 if is_ctx_type(ty) {
539 return None;
540 }
541
542 Some(ParamInfo {
543 name: to_camel_case(param_name),
544 ts_type: rust_to_typescript(ty, &pat_type.attrs),
545 optional: is_opt_type(ty),
546 variadic: is_rest_type(ty),
547 })
548 })
549 .collect();
550
551 let return_type = match &method.sig.output {
553 ReturnType::Default => "void".to_string(),
554 ReturnType::Type(_, ty) => {
555 get_plugin_api_value(&method.attrs, "ts_return")
557 .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
558 }
559 };
560
561 Some(ApiMethod {
562 js_name,
563 kind,
564 params,
565 return_type,
566 doc,
567 })
568}
569
570fn generate_ts_method(method: &ApiMethod) -> String {
576 let mut lines = Vec::new();
577
578 if !method.doc.is_empty() {
580 lines.push(" /**".to_string());
581 for line in method.doc.lines() {
582 lines.push(format!(" * {}", line));
583 }
584 lines.push(" */".to_string());
585 }
586
587 let params: String = method
589 .params
590 .iter()
591 .map(ParamInfo::to_typescript)
592 .collect::<Vec<_>>()
593 .join(", ");
594
595 let return_type = method.kind.wrap_return_type(&method.return_type);
596
597 lines.push(format!(
598 " {}({}): {};",
599 method.js_name, params, return_type
600 ));
601
602 lines.join("\n")
603}
604
605fn generate_ts_preamble() -> &'static str {
607 r#"/**
608 * Fresh Editor TypeScript Plugin API
609 *
610 * This file provides type definitions for the Fresh editor's TypeScript plugin system.
611 * Plugins have access to the global `editor` object which provides methods to:
612 * - Query editor state (buffers, cursors, viewports)
613 * - Modify buffer content (insert, delete text)
614 * - Add visual decorations (overlays, highlighting)
615 * - Interact with the editor UI (status messages, prompts)
616 *
617 * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
618 * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
619 */
620
621/**
622 * Get the editor API instance.
623 * Plugins must call this at the top of their file to get a scoped editor object.
624 */
625declare function getEditor(): EditorAPI;
626
627/**
628 * Register a function as a named handler on the global scope.
629 *
630 * Handler functions registered this way can be referenced by name in
631 * `editor.registerCommand()`, `editor.on()`, and mode keybindings.
632 *
633 * The `fn` parameter is typed as `Function` because the runtime passes
634 * different argument shapes depending on the caller: command handlers
635 * receive no arguments, event handlers receive an event-specific data
636 * object (e.g. `{ buffer_id: number }`), and prompt handlers receive
637 * `{ prompt_type: string, input: string }`. Type-annotate your handler
638 * parameters to match the event you are handling.
639 *
640 * @param name - Handler name (referenced by registerCommand, on, etc.)
641 * @param fn - The handler function
642 */
643declare function registerHandler(name: string, fn: Function): void;
644
645/** Handle for a cancellable async operation */
646interface ProcessHandle<T> extends PromiseLike<T> {
647 /** Promise that resolves to the result when complete */
648 readonly result: Promise<T>;
649 /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
650 kill(): Promise<boolean>;
651}
652
653/** Buffer identifier */
654type BufferId = number;
655
656/** Split identifier */
657type SplitId = number;
658
659"#
660}
661
662fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
665 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
666
667 format!(
668 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
669 method_sigs.join("\n\n")
670 )
671}
672
673const BUILTIN_TS_TYPES: &[&str] = &[
675 "number",
676 "string",
677 "boolean",
678 "void",
679 "unknown",
680 "null",
681 "undefined",
682 "Record",
683 "Array",
684 "Promise",
685 "ProcessHandle",
686 "PromiseLike",
687 "BufferId",
688 "SplitId", ];
690
691fn extract_type_references(ts_type: &str) -> Vec<String> {
699 let mut types = Vec::new();
700
701 let mut current = ts_type.to_string();
703
704 while let Some(start) = current.find('<') {
706 if let Some(end) = current.rfind('>') {
707 let outer = current[..start].trim().to_string();
708 let inner = current[start + 1..end].trim().to_string();
709
710 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
712 types.push(outer);
713 }
714
715 current = inner;
717 } else {
718 break;
719 }
720 }
721
722 for part in current.split('|') {
724 let part = part.trim();
725
726 if BUILTIN_TS_TYPES.contains(&part) {
728 continue;
729 }
730
731 let part = part.trim_end_matches("[]");
733
734 if part.contains('<') || part.contains('>') {
736 continue;
737 }
738
739 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
741 continue;
742 }
743
744 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
746 types.push(part.to_string());
747 }
748 }
749
750 types
751}
752
753fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
755 let mut types = std::collections::HashSet::new();
756
757 for method in methods {
758 for ty in extract_type_references(&method.return_type) {
760 types.insert(ty);
761 }
762
763 for param in &method.params {
765 for ty in extract_type_references(¶m.ts_type) {
766 types.insert(ty);
767 }
768 }
769 }
770
771 let mut sorted: Vec<String> = types.into_iter().collect();
772 sorted.sort();
773 sorted
774}
775
776#[proc_macro_attribute]
810pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
811 let input = parse_macro_input!(item as ItemImpl);
812
813 let impl_name = match &*input.self_ty {
815 Type::Path(type_path) => type_path
816 .path
817 .segments
818 .last()
819 .map(|s| s.ident.to_string())
820 .unwrap_or_else(|| "Unknown".to_string()),
821 _ => {
822 return compile_error(
823 input.self_ty.span(),
824 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
825 )
826 .into();
827 }
828 };
829
830 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
832 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
833 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
834
835 let methods: Vec<ApiMethod> = input
837 .items
838 .iter()
839 .filter_map(|item| {
840 if let ImplItem::Fn(method) = item {
841 parse_method(method)
842 } else {
843 None
844 }
845 })
846 .collect();
847
848 let preamble = generate_ts_preamble();
850 let editor_api = generate_editor_api_interface(&methods);
851
852 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
854
855 let referenced_types = collect_referenced_types(&methods);
857 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
858
859 let mut cleaned_input = input.clone();
863 for item in &mut cleaned_input.items {
864 if let ImplItem::Fn(method) = item {
865 for arg in &mut method.sig.inputs {
866 if let FnArg::Typed(pat_type) = arg {
867 pat_type
868 .attrs
869 .retain(|attr| !attr.path().is_ident("plugin_api"));
870 }
871 }
872 }
873 }
874
875 let expanded = quote! {
877 #cleaned_input
878
879 pub const #preamble_const: &str = #preamble;
883
884 pub const #editor_api_const: &str = #editor_api;
888
889 pub const #methods_const: &[&str] = &[#(#js_names),*];
893
894 pub const #types_const: &[&str] = &[#(#referenced_types),*];
899 };
900
901 TokenStream::from(expanded)
902}
903
904#[proc_macro_attribute]
933pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
934 item
936}
937
938#[cfg(test)]
943mod tests {
944 use super::*;
945
946 #[test]
947 fn test_to_camel_case() {
948 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
949 assert_eq!(to_camel_case("simple"), "simple");
950 assert_eq!(to_camel_case("a_b_c"), "aBC");
951 assert_eq!(to_camel_case("process_id"), "processId");
954 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
955 assert_eq!(to_camel_case(""), "");
956 assert_eq!(to_camel_case("_leading"), "Leading");
957 assert_eq!(to_camel_case("trailing_"), "trailing");
958 }
959
960 #[test]
961 fn test_parse_attr_string_value() {
962 assert_eq!(
963 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
964 Some("myMethod".to_string())
965 );
966 assert_eq!(
967 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
968 Some("foo".to_string())
969 );
970 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
971 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
972 }
973
974 #[test]
975 fn test_api_kind_wrap_return_type() {
976 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
977 assert_eq!(
978 ApiKind::AsyncPromise.wrap_return_type("number"),
979 "Promise<number>"
980 );
981 assert_eq!(
982 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
983 "ProcessHandle<SpawnResult>"
984 );
985 }
986
987 #[test]
988 fn test_param_info_to_typescript() {
989 let regular = ParamInfo {
990 name: "bufferId".to_string(),
991 ts_type: "number".to_string(),
992 optional: false,
993 variadic: false,
994 };
995 assert_eq!(regular.to_typescript(), "bufferId: number");
996
997 let optional = ParamInfo {
998 name: "line".to_string(),
999 ts_type: "number".to_string(),
1000 optional: true,
1001 variadic: false,
1002 };
1003 assert_eq!(optional.to_typescript(), "line?: number");
1004
1005 let variadic = ParamInfo {
1006 name: "parts".to_string(),
1007 ts_type: "string".to_string(),
1008 optional: false,
1009 variadic: true,
1010 };
1011 assert_eq!(variadic.to_typescript(), "...parts: string[]");
1012 }
1013
1014 #[test]
1015 fn test_generate_ts_preamble_contains_required_declarations() {
1016 let preamble = generate_ts_preamble();
1017
1018 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
1020 assert!(preamble.contains("interface ProcessHandle<T>"));
1021 assert!(preamble.contains("type BufferId = number"));
1022 assert!(preamble.contains("type SplitId = number"));
1023
1024 assert!(preamble.contains("AUTO-GENERATED FILE"));
1026 }
1027
1028 #[test]
1029 fn test_extract_type_references() {
1030 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1032
1033 assert!(extract_type_references("number").is_empty());
1035 assert!(extract_type_references("string").is_empty());
1036 assert!(extract_type_references("void").is_empty());
1037
1038 assert_eq!(
1040 extract_type_references("ProcessHandle<SpawnResult>"),
1041 vec!["SpawnResult"]
1042 );
1043 assert_eq!(
1044 extract_type_references("Promise<BufferInfo>"),
1045 vec!["BufferInfo"]
1046 );
1047
1048 assert_eq!(
1050 extract_type_references("CursorInfo | null"),
1051 vec!["CursorInfo"]
1052 );
1053
1054 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1056
1057 assert!(extract_type_references("Record<string, unknown>").is_empty());
1059 assert!(extract_type_references("Promise<void>").is_empty());
1060 }
1061
1062 #[test]
1063 fn test_collect_referenced_types() {
1064 let methods = vec![
1065 ApiMethod {
1066 js_name: "spawnProcess".to_string(),
1067 kind: ApiKind::AsyncThenable,
1068 params: vec![],
1069 return_type: "SpawnResult".to_string(),
1070 doc: "".to_string(),
1071 },
1072 ApiMethod {
1073 js_name: "listBuffers".to_string(),
1074 kind: ApiKind::Sync,
1075 params: vec![],
1076 return_type: "BufferInfo[]".to_string(),
1077 doc: "".to_string(),
1078 },
1079 ];
1080
1081 let types = collect_referenced_types(&methods);
1082 assert!(types.contains(&"SpawnResult".to_string()));
1083 assert!(types.contains(&"BufferInfo".to_string()));
1084 }
1085
1086 #[test]
1087 fn test_generate_ts_method_sync() {
1088 let method = ApiMethod {
1089 js_name: "getActiveBufferId".to_string(),
1090 kind: ApiKind::Sync,
1091 params: vec![],
1092 return_type: "number".to_string(),
1093 doc: "Get the active buffer ID".to_string(),
1094 };
1095
1096 let ts = generate_ts_method(&method);
1097 assert!(ts.contains("getActiveBufferId(): number;"));
1098 assert!(ts.contains("Get the active buffer ID"));
1099 }
1100
1101 #[test]
1102 fn test_generate_ts_method_async_promise() {
1103 let method = ApiMethod {
1104 js_name: "delay".to_string(),
1105 kind: ApiKind::AsyncPromise,
1106 params: vec![ParamInfo {
1107 name: "ms".to_string(),
1108 ts_type: "number".to_string(),
1109 optional: false,
1110 variadic: false,
1111 }],
1112 return_type: "void".to_string(),
1113 doc: "".to_string(),
1114 };
1115
1116 let ts = generate_ts_method(&method);
1117 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1118 }
1119
1120 #[test]
1121 fn test_generate_ts_method_async_thenable() {
1122 let method = ApiMethod {
1123 js_name: "spawnProcess".to_string(),
1124 kind: ApiKind::AsyncThenable,
1125 params: vec![
1126 ParamInfo {
1127 name: "command".to_string(),
1128 ts_type: "string".to_string(),
1129 optional: false,
1130 variadic: false,
1131 },
1132 ParamInfo {
1133 name: "args".to_string(),
1134 ts_type: "string".to_string(),
1135 optional: false,
1136 variadic: false,
1137 },
1138 ],
1139 return_type: "SpawnResult".to_string(),
1140 doc: "Spawn a process".to_string(),
1141 };
1142
1143 let ts = generate_ts_method(&method);
1144 assert!(
1145 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1146 );
1147 }
1148
1149 fn parse_type(s: &str) -> Type {
1155 syn::parse_str::<Type>(s).unwrap()
1156 }
1157
1158 #[test]
1159 fn test_renamed_type_composite_hunk() {
1160 let ty = parse_type("Vec<CompositeHunk>");
1161 let ts = rust_to_typescript(&ty, &[]);
1162 assert_eq!(ts, "TsCompositeHunk[]");
1163 }
1164
1165 #[test]
1166 fn test_renamed_type_create_composite_buffer_options() {
1167 let ty = parse_type("CreateCompositeBufferOptions");
1168 let ts = rust_to_typescript(&ty, &[]);
1169 assert_eq!(ts, "TsCreateCompositeBufferOptions");
1170 }
1171
1172 #[test]
1173 fn test_renamed_type_suggestion() {
1174 let ty = parse_type("Vec<Suggestion>");
1175 let ts = rust_to_typescript(&ty, &[]);
1176 assert_eq!(ts, "PromptSuggestion[]");
1177 }
1178
1179 #[test]
1180 fn test_passthrough_type_terminal_result() {
1181 let ty = parse_type("TerminalResult");
1182 let ts = rust_to_typescript(&ty, &[]);
1183 assert_eq!(ts, "TerminalResult");
1184 }
1185
1186 #[test]
1187 fn test_passthrough_type_create_terminal_options() {
1188 let ty = parse_type("CreateTerminalOptions");
1189 let ts = rust_to_typescript(&ty, &[]);
1190 assert_eq!(ts, "CreateTerminalOptions");
1191 }
1192
1193 #[test]
1194 fn test_passthrough_type_cursor_info() {
1195 let ty = parse_type("CursorInfo");
1196 let ts = rust_to_typescript(&ty, &[]);
1197 assert_eq!(ts, "CursorInfo");
1198 }
1199
1200 #[test]
1201 fn test_option_cursor_info() {
1202 let ty = parse_type("Option<CursorInfo>");
1203 let ts = rust_to_typescript(&ty, &[]);
1204 assert_eq!(ts, "CursorInfo | null");
1205 }
1206
1207 #[test]
1208 fn test_extract_type_references_renamed_types() {
1209 assert_eq!(
1211 extract_type_references("TsCompositeHunk[]"),
1212 vec!["TsCompositeHunk"]
1213 );
1214 assert_eq!(
1215 extract_type_references("TsCreateCompositeBufferOptions"),
1216 vec!["TsCreateCompositeBufferOptions"]
1217 );
1218 assert_eq!(
1219 extract_type_references("PromptSuggestion[]"),
1220 vec!["PromptSuggestion"]
1221 );
1222 }
1223
1224 #[test]
1225 fn test_extract_type_references_terminal_types() {
1226 assert_eq!(
1227 extract_type_references("Promise<TerminalResult>"),
1228 vec!["TerminalResult"]
1229 );
1230 assert_eq!(
1231 extract_type_references("CreateTerminalOptions"),
1232 vec!["CreateTerminalOptions"]
1233 );
1234 }
1235
1236 #[test]
1237 fn test_extract_type_references_cursor_types() {
1238 assert_eq!(
1239 extract_type_references("CursorInfo | null"),
1240 vec!["CursorInfo"]
1241 );
1242 assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
1243 }
1244
1245 #[test]
1246 fn test_generate_ts_method_with_renamed_param_type() {
1247 let method = ApiMethod {
1248 js_name: "updateCompositeAlignment".to_string(),
1249 kind: ApiKind::Sync,
1250 params: vec![
1251 ParamInfo {
1252 name: "bufferId".to_string(),
1253 ts_type: "number".to_string(),
1254 optional: false,
1255 variadic: false,
1256 },
1257 ParamInfo {
1258 name: "hunks".to_string(),
1259 ts_type: "TsCompositeHunk[]".to_string(),
1260 optional: false,
1261 variadic: false,
1262 },
1263 ],
1264 return_type: "boolean".to_string(),
1265 doc: "Update alignment hunks".to_string(),
1266 };
1267
1268 let ts = generate_ts_method(&method);
1269 assert!(ts.contains(
1270 "updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
1271 ));
1272 }
1273
1274 #[test]
1275 fn test_generate_ts_method_cursor_return_types() {
1276 let method = ApiMethod {
1277 js_name: "getPrimaryCursor".to_string(),
1278 kind: ApiKind::Sync,
1279 params: vec![],
1280 return_type: "CursorInfo | null".to_string(),
1281 doc: "Get primary cursor".to_string(),
1282 };
1283 let ts = generate_ts_method(&method);
1284 assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
1285
1286 let method = ApiMethod {
1287 js_name: "getAllCursors".to_string(),
1288 kind: ApiKind::Sync,
1289 params: vec![],
1290 return_type: "CursorInfo[]".to_string(),
1291 doc: "Get all cursors".to_string(),
1292 };
1293 let ts = generate_ts_method(&method);
1294 assert!(ts.contains("getAllCursors(): CursorInfo[];"));
1295
1296 let method = ApiMethod {
1297 js_name: "getAllCursorPositions".to_string(),
1298 kind: ApiKind::Sync,
1299 params: vec![],
1300 return_type: "number[]".to_string(),
1301 doc: "Get all cursor positions".to_string(),
1302 };
1303 let ts = generate_ts_method(&method);
1304 assert!(ts.contains("getAllCursorPositions(): number[];"));
1305 }
1306
1307 #[test]
1308 fn test_generate_ts_method_terminal() {
1309 let method = ApiMethod {
1310 js_name: "createTerminal".to_string(),
1311 kind: ApiKind::AsyncPromise,
1312 params: vec![ParamInfo {
1313 name: "opts".to_string(),
1314 ts_type: "CreateTerminalOptions".to_string(),
1315 optional: true,
1316 variadic: false,
1317 }],
1318 return_type: "TerminalResult".to_string(),
1319 doc: "Create a terminal".to_string(),
1320 };
1321
1322 let ts = generate_ts_method(&method);
1323 assert!(
1324 ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
1325 );
1326 }
1327
1328 #[test]
1329 fn test_collect_referenced_types_includes_renamed() {
1330 let methods = vec![
1331 ApiMethod {
1332 js_name: "updateAlignment".to_string(),
1333 kind: ApiKind::Sync,
1334 params: vec![ParamInfo {
1335 name: "hunks".to_string(),
1336 ts_type: "TsCompositeHunk[]".to_string(),
1337 optional: false,
1338 variadic: false,
1339 }],
1340 return_type: "boolean".to_string(),
1341 doc: "".to_string(),
1342 },
1343 ApiMethod {
1344 js_name: "setSuggestions".to_string(),
1345 kind: ApiKind::Sync,
1346 params: vec![ParamInfo {
1347 name: "suggestions".to_string(),
1348 ts_type: "PromptSuggestion[]".to_string(),
1349 optional: false,
1350 variadic: false,
1351 }],
1352 return_type: "boolean".to_string(),
1353 doc: "".to_string(),
1354 },
1355 ApiMethod {
1356 js_name: "getPrimaryCursor".to_string(),
1357 kind: ApiKind::Sync,
1358 params: vec![],
1359 return_type: "CursorInfo | null".to_string(),
1360 doc: "".to_string(),
1361 },
1362 ApiMethod {
1363 js_name: "createTerminal".to_string(),
1364 kind: ApiKind::AsyncPromise,
1365 params: vec![ParamInfo {
1366 name: "opts".to_string(),
1367 ts_type: "CreateTerminalOptions".to_string(),
1368 optional: true,
1369 variadic: false,
1370 }],
1371 return_type: "TerminalResult".to_string(),
1372 doc: "".to_string(),
1373 },
1374 ];
1375
1376 let types = collect_referenced_types(&methods);
1377 assert!(types.contains(&"TsCompositeHunk".to_string()));
1378 assert!(types.contains(&"PromptSuggestion".to_string()));
1379 assert!(types.contains(&"CursorInfo".to_string()));
1380 assert!(types.contains(&"TerminalResult".to_string()));
1381 assert!(types.contains(&"CreateTerminalOptions".to_string()));
1382 }
1383
1384 #[test]
1385 fn test_all_known_types_are_passthrough_or_renamed() {
1386 let passthrough_types = vec![
1388 "BufferInfo",
1389 "CursorInfo",
1390 "ViewportInfo",
1391 "SpawnResult",
1392 "BackgroundProcessResult",
1393 "DirEntry",
1394 "PromptSuggestion",
1395 "ActionSpec",
1396 "ActionPopupOptions",
1397 "VirtualBufferResult",
1398 "TerminalResult",
1399 "CreateTerminalOptions",
1400 "TsHighlightSpan",
1401 "JsDiagnostic",
1402 ];
1403
1404 for type_name in &passthrough_types {
1405 let ty = parse_type(type_name);
1406 let ts = rust_to_typescript(&ty, &[]);
1407 assert_eq!(
1408 &ts, type_name,
1409 "Type {} should pass through unchanged",
1410 type_name
1411 );
1412 }
1413
1414 let renamed = vec![
1416 ("CompositeHunk", "TsCompositeHunk"),
1417 (
1418 "CreateCompositeBufferOptions",
1419 "TsCreateCompositeBufferOptions",
1420 ),
1421 ("Suggestion", "PromptSuggestion"),
1422 ];
1423
1424 for (rust_name, ts_name) in &renamed {
1425 let ty = parse_type(rust_name);
1426 let ts = rust_to_typescript(&ty, &[]);
1427 assert_eq!(
1428 &ts, ts_name,
1429 "Type {} should be renamed to {}",
1430 rust_name, ts_name
1431 );
1432 }
1433 }
1434}