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/** Handle for a cancellable async operation */
628interface ProcessHandle<T> extends PromiseLike<T> {
629 /** Promise that resolves to the result when complete */
630 readonly result: Promise<T>;
631 /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
632 kill(): Promise<boolean>;
633}
634
635/** Buffer identifier */
636type BufferId = number;
637
638/** Split identifier */
639type SplitId = number;
640
641"#
642}
643
644fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
647 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
648
649 format!(
650 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
651 method_sigs.join("\n\n")
652 )
653}
654
655const BUILTIN_TS_TYPES: &[&str] = &[
657 "number",
658 "string",
659 "boolean",
660 "void",
661 "unknown",
662 "null",
663 "undefined",
664 "Record",
665 "Array",
666 "Promise",
667 "ProcessHandle",
668 "PromiseLike",
669 "BufferId",
670 "SplitId", ];
672
673fn extract_type_references(ts_type: &str) -> Vec<String> {
681 let mut types = Vec::new();
682
683 let mut current = ts_type.to_string();
685
686 while let Some(start) = current.find('<') {
688 if let Some(end) = current.rfind('>') {
689 let outer = current[..start].trim().to_string();
690 let inner = current[start + 1..end].trim().to_string();
691
692 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
694 types.push(outer);
695 }
696
697 current = inner;
699 } else {
700 break;
701 }
702 }
703
704 for part in current.split('|') {
706 let part = part.trim();
707
708 if BUILTIN_TS_TYPES.contains(&part) {
710 continue;
711 }
712
713 let part = part.trim_end_matches("[]");
715
716 if part.contains('<') || part.contains('>') {
718 continue;
719 }
720
721 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
723 continue;
724 }
725
726 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
728 types.push(part.to_string());
729 }
730 }
731
732 types
733}
734
735fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
737 let mut types = std::collections::HashSet::new();
738
739 for method in methods {
740 for ty in extract_type_references(&method.return_type) {
742 types.insert(ty);
743 }
744
745 for param in &method.params {
747 for ty in extract_type_references(¶m.ts_type) {
748 types.insert(ty);
749 }
750 }
751 }
752
753 let mut sorted: Vec<String> = types.into_iter().collect();
754 sorted.sort();
755 sorted
756}
757
758#[proc_macro_attribute]
792pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
793 let input = parse_macro_input!(item as ItemImpl);
794
795 let impl_name = match &*input.self_ty {
797 Type::Path(type_path) => type_path
798 .path
799 .segments
800 .last()
801 .map(|s| s.ident.to_string())
802 .unwrap_or_else(|| "Unknown".to_string()),
803 _ => {
804 return compile_error(
805 input.self_ty.span(),
806 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
807 )
808 .into();
809 }
810 };
811
812 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
814 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
815 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
816
817 let methods: Vec<ApiMethod> = input
819 .items
820 .iter()
821 .filter_map(|item| {
822 if let ImplItem::Fn(method) = item {
823 parse_method(method)
824 } else {
825 None
826 }
827 })
828 .collect();
829
830 let preamble = generate_ts_preamble();
832 let editor_api = generate_editor_api_interface(&methods);
833
834 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
836
837 let referenced_types = collect_referenced_types(&methods);
839 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
840
841 let mut cleaned_input = input.clone();
845 for item in &mut cleaned_input.items {
846 if let ImplItem::Fn(method) = item {
847 for arg in &mut method.sig.inputs {
848 if let FnArg::Typed(pat_type) = arg {
849 pat_type
850 .attrs
851 .retain(|attr| !attr.path().is_ident("plugin_api"));
852 }
853 }
854 }
855 }
856
857 let expanded = quote! {
859 #cleaned_input
860
861 pub const #preamble_const: &str = #preamble;
865
866 pub const #editor_api_const: &str = #editor_api;
870
871 pub const #methods_const: &[&str] = &[#(#js_names),*];
875
876 pub const #types_const: &[&str] = &[#(#referenced_types),*];
881 };
882
883 TokenStream::from(expanded)
884}
885
886#[proc_macro_attribute]
915pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
916 item
918}
919
920#[cfg(test)]
925mod tests {
926 use super::*;
927
928 #[test]
929 fn test_to_camel_case() {
930 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
931 assert_eq!(to_camel_case("simple"), "simple");
932 assert_eq!(to_camel_case("a_b_c"), "aBC");
933 assert_eq!(to_camel_case("process_id"), "processId");
936 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
937 assert_eq!(to_camel_case(""), "");
938 assert_eq!(to_camel_case("_leading"), "Leading");
939 assert_eq!(to_camel_case("trailing_"), "trailing");
940 }
941
942 #[test]
943 fn test_parse_attr_string_value() {
944 assert_eq!(
945 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
946 Some("myMethod".to_string())
947 );
948 assert_eq!(
949 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
950 Some("foo".to_string())
951 );
952 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
953 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
954 }
955
956 #[test]
957 fn test_api_kind_wrap_return_type() {
958 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
959 assert_eq!(
960 ApiKind::AsyncPromise.wrap_return_type("number"),
961 "Promise<number>"
962 );
963 assert_eq!(
964 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
965 "ProcessHandle<SpawnResult>"
966 );
967 }
968
969 #[test]
970 fn test_param_info_to_typescript() {
971 let regular = ParamInfo {
972 name: "bufferId".to_string(),
973 ts_type: "number".to_string(),
974 optional: false,
975 variadic: false,
976 };
977 assert_eq!(regular.to_typescript(), "bufferId: number");
978
979 let optional = ParamInfo {
980 name: "line".to_string(),
981 ts_type: "number".to_string(),
982 optional: true,
983 variadic: false,
984 };
985 assert_eq!(optional.to_typescript(), "line?: number");
986
987 let variadic = ParamInfo {
988 name: "parts".to_string(),
989 ts_type: "string".to_string(),
990 optional: false,
991 variadic: true,
992 };
993 assert_eq!(variadic.to_typescript(), "...parts: string[]");
994 }
995
996 #[test]
997 fn test_generate_ts_preamble_contains_required_declarations() {
998 let preamble = generate_ts_preamble();
999
1000 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
1002 assert!(preamble.contains("interface ProcessHandle<T>"));
1003 assert!(preamble.contains("type BufferId = number"));
1004 assert!(preamble.contains("type SplitId = number"));
1005
1006 assert!(preamble.contains("AUTO-GENERATED FILE"));
1008 }
1009
1010 #[test]
1011 fn test_extract_type_references() {
1012 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1014
1015 assert!(extract_type_references("number").is_empty());
1017 assert!(extract_type_references("string").is_empty());
1018 assert!(extract_type_references("void").is_empty());
1019
1020 assert_eq!(
1022 extract_type_references("ProcessHandle<SpawnResult>"),
1023 vec!["SpawnResult"]
1024 );
1025 assert_eq!(
1026 extract_type_references("Promise<BufferInfo>"),
1027 vec!["BufferInfo"]
1028 );
1029
1030 assert_eq!(
1032 extract_type_references("CursorInfo | null"),
1033 vec!["CursorInfo"]
1034 );
1035
1036 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1038
1039 assert!(extract_type_references("Record<string, unknown>").is_empty());
1041 assert!(extract_type_references("Promise<void>").is_empty());
1042 }
1043
1044 #[test]
1045 fn test_collect_referenced_types() {
1046 let methods = vec![
1047 ApiMethod {
1048 js_name: "spawnProcess".to_string(),
1049 kind: ApiKind::AsyncThenable,
1050 params: vec![],
1051 return_type: "SpawnResult".to_string(),
1052 doc: "".to_string(),
1053 },
1054 ApiMethod {
1055 js_name: "listBuffers".to_string(),
1056 kind: ApiKind::Sync,
1057 params: vec![],
1058 return_type: "BufferInfo[]".to_string(),
1059 doc: "".to_string(),
1060 },
1061 ];
1062
1063 let types = collect_referenced_types(&methods);
1064 assert!(types.contains(&"SpawnResult".to_string()));
1065 assert!(types.contains(&"BufferInfo".to_string()));
1066 }
1067
1068 #[test]
1069 fn test_generate_ts_method_sync() {
1070 let method = ApiMethod {
1071 js_name: "getActiveBufferId".to_string(),
1072 kind: ApiKind::Sync,
1073 params: vec![],
1074 return_type: "number".to_string(),
1075 doc: "Get the active buffer ID".to_string(),
1076 };
1077
1078 let ts = generate_ts_method(&method);
1079 assert!(ts.contains("getActiveBufferId(): number;"));
1080 assert!(ts.contains("Get the active buffer ID"));
1081 }
1082
1083 #[test]
1084 fn test_generate_ts_method_async_promise() {
1085 let method = ApiMethod {
1086 js_name: "delay".to_string(),
1087 kind: ApiKind::AsyncPromise,
1088 params: vec![ParamInfo {
1089 name: "ms".to_string(),
1090 ts_type: "number".to_string(),
1091 optional: false,
1092 variadic: false,
1093 }],
1094 return_type: "void".to_string(),
1095 doc: "".to_string(),
1096 };
1097
1098 let ts = generate_ts_method(&method);
1099 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1100 }
1101
1102 #[test]
1103 fn test_generate_ts_method_async_thenable() {
1104 let method = ApiMethod {
1105 js_name: "spawnProcess".to_string(),
1106 kind: ApiKind::AsyncThenable,
1107 params: vec![
1108 ParamInfo {
1109 name: "command".to_string(),
1110 ts_type: "string".to_string(),
1111 optional: false,
1112 variadic: false,
1113 },
1114 ParamInfo {
1115 name: "args".to_string(),
1116 ts_type: "string".to_string(),
1117 optional: false,
1118 variadic: false,
1119 },
1120 ],
1121 return_type: "SpawnResult".to_string(),
1122 doc: "Spawn a process".to_string(),
1123 };
1124
1125 let ts = generate_ts_method(&method);
1126 assert!(
1127 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1128 );
1129 }
1130
1131 fn parse_type(s: &str) -> Type {
1137 syn::parse_str::<Type>(s).unwrap()
1138 }
1139
1140 #[test]
1141 fn test_renamed_type_composite_hunk() {
1142 let ty = parse_type("Vec<CompositeHunk>");
1143 let ts = rust_to_typescript(&ty, &[]);
1144 assert_eq!(ts, "TsCompositeHunk[]");
1145 }
1146
1147 #[test]
1148 fn test_renamed_type_create_composite_buffer_options() {
1149 let ty = parse_type("CreateCompositeBufferOptions");
1150 let ts = rust_to_typescript(&ty, &[]);
1151 assert_eq!(ts, "TsCreateCompositeBufferOptions");
1152 }
1153
1154 #[test]
1155 fn test_renamed_type_suggestion() {
1156 let ty = parse_type("Vec<Suggestion>");
1157 let ts = rust_to_typescript(&ty, &[]);
1158 assert_eq!(ts, "PromptSuggestion[]");
1159 }
1160
1161 #[test]
1162 fn test_passthrough_type_terminal_result() {
1163 let ty = parse_type("TerminalResult");
1164 let ts = rust_to_typescript(&ty, &[]);
1165 assert_eq!(ts, "TerminalResult");
1166 }
1167
1168 #[test]
1169 fn test_passthrough_type_create_terminal_options() {
1170 let ty = parse_type("CreateTerminalOptions");
1171 let ts = rust_to_typescript(&ty, &[]);
1172 assert_eq!(ts, "CreateTerminalOptions");
1173 }
1174
1175 #[test]
1176 fn test_passthrough_type_cursor_info() {
1177 let ty = parse_type("CursorInfo");
1178 let ts = rust_to_typescript(&ty, &[]);
1179 assert_eq!(ts, "CursorInfo");
1180 }
1181
1182 #[test]
1183 fn test_option_cursor_info() {
1184 let ty = parse_type("Option<CursorInfo>");
1185 let ts = rust_to_typescript(&ty, &[]);
1186 assert_eq!(ts, "CursorInfo | null");
1187 }
1188
1189 #[test]
1190 fn test_extract_type_references_renamed_types() {
1191 assert_eq!(
1193 extract_type_references("TsCompositeHunk[]"),
1194 vec!["TsCompositeHunk"]
1195 );
1196 assert_eq!(
1197 extract_type_references("TsCreateCompositeBufferOptions"),
1198 vec!["TsCreateCompositeBufferOptions"]
1199 );
1200 assert_eq!(
1201 extract_type_references("PromptSuggestion[]"),
1202 vec!["PromptSuggestion"]
1203 );
1204 }
1205
1206 #[test]
1207 fn test_extract_type_references_terminal_types() {
1208 assert_eq!(
1209 extract_type_references("Promise<TerminalResult>"),
1210 vec!["TerminalResult"]
1211 );
1212 assert_eq!(
1213 extract_type_references("CreateTerminalOptions"),
1214 vec!["CreateTerminalOptions"]
1215 );
1216 }
1217
1218 #[test]
1219 fn test_extract_type_references_cursor_types() {
1220 assert_eq!(
1221 extract_type_references("CursorInfo | null"),
1222 vec!["CursorInfo"]
1223 );
1224 assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
1225 }
1226
1227 #[test]
1228 fn test_generate_ts_method_with_renamed_param_type() {
1229 let method = ApiMethod {
1230 js_name: "updateCompositeAlignment".to_string(),
1231 kind: ApiKind::Sync,
1232 params: vec![
1233 ParamInfo {
1234 name: "bufferId".to_string(),
1235 ts_type: "number".to_string(),
1236 optional: false,
1237 variadic: false,
1238 },
1239 ParamInfo {
1240 name: "hunks".to_string(),
1241 ts_type: "TsCompositeHunk[]".to_string(),
1242 optional: false,
1243 variadic: false,
1244 },
1245 ],
1246 return_type: "boolean".to_string(),
1247 doc: "Update alignment hunks".to_string(),
1248 };
1249
1250 let ts = generate_ts_method(&method);
1251 assert!(ts.contains(
1252 "updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
1253 ));
1254 }
1255
1256 #[test]
1257 fn test_generate_ts_method_cursor_return_types() {
1258 let method = ApiMethod {
1259 js_name: "getPrimaryCursor".to_string(),
1260 kind: ApiKind::Sync,
1261 params: vec![],
1262 return_type: "CursorInfo | null".to_string(),
1263 doc: "Get primary cursor".to_string(),
1264 };
1265 let ts = generate_ts_method(&method);
1266 assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
1267
1268 let method = ApiMethod {
1269 js_name: "getAllCursors".to_string(),
1270 kind: ApiKind::Sync,
1271 params: vec![],
1272 return_type: "CursorInfo[]".to_string(),
1273 doc: "Get all cursors".to_string(),
1274 };
1275 let ts = generate_ts_method(&method);
1276 assert!(ts.contains("getAllCursors(): CursorInfo[];"));
1277
1278 let method = ApiMethod {
1279 js_name: "getAllCursorPositions".to_string(),
1280 kind: ApiKind::Sync,
1281 params: vec![],
1282 return_type: "number[]".to_string(),
1283 doc: "Get all cursor positions".to_string(),
1284 };
1285 let ts = generate_ts_method(&method);
1286 assert!(ts.contains("getAllCursorPositions(): number[];"));
1287 }
1288
1289 #[test]
1290 fn test_generate_ts_method_terminal() {
1291 let method = ApiMethod {
1292 js_name: "createTerminal".to_string(),
1293 kind: ApiKind::AsyncPromise,
1294 params: vec![ParamInfo {
1295 name: "opts".to_string(),
1296 ts_type: "CreateTerminalOptions".to_string(),
1297 optional: true,
1298 variadic: false,
1299 }],
1300 return_type: "TerminalResult".to_string(),
1301 doc: "Create a terminal".to_string(),
1302 };
1303
1304 let ts = generate_ts_method(&method);
1305 assert!(
1306 ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
1307 );
1308 }
1309
1310 #[test]
1311 fn test_collect_referenced_types_includes_renamed() {
1312 let methods = vec![
1313 ApiMethod {
1314 js_name: "updateAlignment".to_string(),
1315 kind: ApiKind::Sync,
1316 params: vec![ParamInfo {
1317 name: "hunks".to_string(),
1318 ts_type: "TsCompositeHunk[]".to_string(),
1319 optional: false,
1320 variadic: false,
1321 }],
1322 return_type: "boolean".to_string(),
1323 doc: "".to_string(),
1324 },
1325 ApiMethod {
1326 js_name: "setSuggestions".to_string(),
1327 kind: ApiKind::Sync,
1328 params: vec![ParamInfo {
1329 name: "suggestions".to_string(),
1330 ts_type: "PromptSuggestion[]".to_string(),
1331 optional: false,
1332 variadic: false,
1333 }],
1334 return_type: "boolean".to_string(),
1335 doc: "".to_string(),
1336 },
1337 ApiMethod {
1338 js_name: "getPrimaryCursor".to_string(),
1339 kind: ApiKind::Sync,
1340 params: vec![],
1341 return_type: "CursorInfo | null".to_string(),
1342 doc: "".to_string(),
1343 },
1344 ApiMethod {
1345 js_name: "createTerminal".to_string(),
1346 kind: ApiKind::AsyncPromise,
1347 params: vec![ParamInfo {
1348 name: "opts".to_string(),
1349 ts_type: "CreateTerminalOptions".to_string(),
1350 optional: true,
1351 variadic: false,
1352 }],
1353 return_type: "TerminalResult".to_string(),
1354 doc: "".to_string(),
1355 },
1356 ];
1357
1358 let types = collect_referenced_types(&methods);
1359 assert!(types.contains(&"TsCompositeHunk".to_string()));
1360 assert!(types.contains(&"PromptSuggestion".to_string()));
1361 assert!(types.contains(&"CursorInfo".to_string()));
1362 assert!(types.contains(&"TerminalResult".to_string()));
1363 assert!(types.contains(&"CreateTerminalOptions".to_string()));
1364 }
1365
1366 #[test]
1367 fn test_all_known_types_are_passthrough_or_renamed() {
1368 let passthrough_types = vec![
1370 "BufferInfo",
1371 "CursorInfo",
1372 "ViewportInfo",
1373 "SpawnResult",
1374 "BackgroundProcessResult",
1375 "DirEntry",
1376 "PromptSuggestion",
1377 "ActionSpec",
1378 "ActionPopupOptions",
1379 "VirtualBufferResult",
1380 "TerminalResult",
1381 "CreateTerminalOptions",
1382 "TsHighlightSpan",
1383 "JsDiagnostic",
1384 ];
1385
1386 for type_name in &passthrough_types {
1387 let ty = parse_type(type_name);
1388 let ts = rust_to_typescript(&ty, &[]);
1389 assert_eq!(
1390 &ts, type_name,
1391 "Type {} should pass through unchanged",
1392 type_name
1393 );
1394 }
1395
1396 let renamed = vec![
1398 ("CompositeHunk", "TsCompositeHunk"),
1399 (
1400 "CreateCompositeBufferOptions",
1401 "TsCreateCompositeBufferOptions",
1402 ),
1403 ("Suggestion", "PromptSuggestion"),
1404 ];
1405
1406 for (rust_name, ts_name) in &renamed {
1407 let ty = parse_type(rust_name);
1408 let ts = rust_to_typescript(&ty, &[]);
1409 assert_eq!(
1410 &ts, ts_name,
1411 "Type {} should be renamed to {}",
1412 rust_name, ts_name
1413 );
1414 }
1415 }
1416}