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}
673
674fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
677 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
678
679 format!(
680 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
681 method_sigs.join("\n\n")
682 )
683}
684
685const BUILTIN_TS_TYPES: &[&str] = &[
687 "number",
688 "string",
689 "boolean",
690 "void",
691 "unknown",
692 "null",
693 "undefined",
694 "Record",
695 "Array",
696 "Promise",
697 "ProcessHandle",
698 "PromiseLike",
699 "BufferId",
700 "SplitId", ];
702
703fn extract_type_references(ts_type: &str) -> Vec<String> {
711 let mut types = Vec::new();
712
713 let mut current = ts_type.to_string();
715
716 while let Some(start) = current.find('<') {
718 if let Some(end) = current.rfind('>') {
719 let outer = current[..start].trim().to_string();
720 let inner = current[start + 1..end].trim().to_string();
721
722 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
724 types.push(outer);
725 }
726
727 current = inner;
729 } else {
730 break;
731 }
732 }
733
734 for part in current.split('|') {
736 let part = part.trim();
737
738 if BUILTIN_TS_TYPES.contains(&part) {
740 continue;
741 }
742
743 let part = part.trim_end_matches("[]");
745
746 if part.contains('<') || part.contains('>') {
748 continue;
749 }
750
751 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
753 continue;
754 }
755
756 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
758 types.push(part.to_string());
759 }
760 }
761
762 types
763}
764
765fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
767 let mut types = std::collections::HashSet::new();
768
769 for method in methods {
770 for ty in extract_type_references(&method.return_type) {
772 types.insert(ty);
773 }
774
775 for param in &method.params {
777 for ty in extract_type_references(¶m.ts_type) {
778 types.insert(ty);
779 }
780 }
781 }
782
783 let mut sorted: Vec<String> = types.into_iter().collect();
784 sorted.sort();
785 sorted
786}
787
788#[proc_macro_attribute]
822pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
823 let input = parse_macro_input!(item as ItemImpl);
824
825 let impl_name = match &*input.self_ty {
827 Type::Path(type_path) => type_path
828 .path
829 .segments
830 .last()
831 .map(|s| s.ident.to_string())
832 .unwrap_or_else(|| "Unknown".to_string()),
833 _ => {
834 return compile_error(
835 input.self_ty.span(),
836 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
837 )
838 .into();
839 }
840 };
841
842 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
844 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
845 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
846
847 let methods: Vec<ApiMethod> = input
849 .items
850 .iter()
851 .filter_map(|item| {
852 if let ImplItem::Fn(method) = item {
853 parse_method(method)
854 } else {
855 None
856 }
857 })
858 .collect();
859
860 let preamble = generate_ts_preamble();
862 let editor_api = generate_editor_api_interface(&methods);
863
864 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
866
867 let referenced_types = collect_referenced_types(&methods);
869 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
870
871 let mut cleaned_input = input.clone();
875 for item in &mut cleaned_input.items {
876 if let ImplItem::Fn(method) = item {
877 for arg in &mut method.sig.inputs {
878 if let FnArg::Typed(pat_type) = arg {
879 pat_type
880 .attrs
881 .retain(|attr| !attr.path().is_ident("plugin_api"));
882 }
883 }
884 }
885 }
886
887 let expanded = quote! {
889 #cleaned_input
890
891 pub const #preamble_const: &str = #preamble;
895
896 pub const #editor_api_const: &str = #editor_api;
900
901 pub const #methods_const: &[&str] = &[#(#js_names),*];
905
906 pub const #types_const: &[&str] = &[#(#referenced_types),*];
911 };
912
913 TokenStream::from(expanded)
914}
915
916#[proc_macro_attribute]
946pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
947 item
949}
950
951#[cfg(test)]
956mod tests {
957 use super::*;
958
959 #[test]
960 fn test_to_camel_case() {
961 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
962 assert_eq!(to_camel_case("simple"), "simple");
963 assert_eq!(to_camel_case("a_b_c"), "aBC");
964 assert_eq!(to_camel_case("process_id"), "processId");
967 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
968 assert_eq!(to_camel_case(""), "");
969 assert_eq!(to_camel_case("_leading"), "Leading");
970 assert_eq!(to_camel_case("trailing_"), "trailing");
971 }
972
973 #[test]
974 fn test_parse_attr_string_value() {
975 assert_eq!(
976 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
977 Some("myMethod".to_string())
978 );
979 assert_eq!(
980 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
981 Some("foo".to_string())
982 );
983 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
984 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
985 }
986
987 #[test]
988 fn test_api_kind_wrap_return_type() {
989 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
990 assert_eq!(
991 ApiKind::AsyncPromise.wrap_return_type("number"),
992 "Promise<number>"
993 );
994 assert_eq!(
995 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
996 "ProcessHandle<SpawnResult>"
997 );
998 }
999
1000 #[test]
1001 fn test_param_info_to_typescript() {
1002 let regular = ParamInfo {
1003 name: "bufferId".to_string(),
1004 ts_type: "number".to_string(),
1005 optional: false,
1006 variadic: false,
1007 };
1008 assert_eq!(regular.to_typescript(), "bufferId: number");
1009
1010 let optional = ParamInfo {
1011 name: "line".to_string(),
1012 ts_type: "number".to_string(),
1013 optional: true,
1014 variadic: false,
1015 };
1016 assert_eq!(optional.to_typescript(), "line?: number");
1017
1018 let variadic = ParamInfo {
1019 name: "parts".to_string(),
1020 ts_type: "string".to_string(),
1021 optional: false,
1022 variadic: true,
1023 };
1024 assert_eq!(variadic.to_typescript(), "...parts: string[]");
1025 }
1026
1027 #[test]
1028 fn test_generate_ts_preamble_contains_required_declarations() {
1029 let preamble = generate_ts_preamble();
1030
1031 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
1033 assert!(preamble.contains("interface ProcessHandle<T>"));
1034 assert!(preamble.contains("type BufferId = number"));
1035 assert!(preamble.contains("type SplitId = number"));
1036
1037 assert!(preamble.contains("AUTO-GENERATED FILE"));
1039 }
1040
1041 #[test]
1042 fn test_extract_type_references() {
1043 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1045
1046 assert!(extract_type_references("number").is_empty());
1048 assert!(extract_type_references("string").is_empty());
1049 assert!(extract_type_references("void").is_empty());
1050
1051 assert_eq!(
1053 extract_type_references("ProcessHandle<SpawnResult>"),
1054 vec!["SpawnResult"]
1055 );
1056 assert_eq!(
1057 extract_type_references("Promise<BufferInfo>"),
1058 vec!["BufferInfo"]
1059 );
1060
1061 assert_eq!(
1063 extract_type_references("CursorInfo | null"),
1064 vec!["CursorInfo"]
1065 );
1066
1067 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1069
1070 assert!(extract_type_references("Record<string, unknown>").is_empty());
1072 assert!(extract_type_references("Promise<void>").is_empty());
1073 }
1074
1075 #[test]
1076 fn test_collect_referenced_types() {
1077 let methods = vec![
1078 ApiMethod {
1079 js_name: "spawnProcess".to_string(),
1080 kind: ApiKind::AsyncThenable,
1081 params: vec![],
1082 return_type: "SpawnResult".to_string(),
1083 doc: "".to_string(),
1084 ts_raw: None,
1085 },
1086 ApiMethod {
1087 js_name: "listBuffers".to_string(),
1088 kind: ApiKind::Sync,
1089 params: vec![],
1090 return_type: "BufferInfo[]".to_string(),
1091 doc: "".to_string(),
1092 ts_raw: None,
1093 },
1094 ];
1095
1096 let types = collect_referenced_types(&methods);
1097 assert!(types.contains(&"SpawnResult".to_string()));
1098 assert!(types.contains(&"BufferInfo".to_string()));
1099 }
1100
1101 #[test]
1102 fn test_generate_ts_method_sync() {
1103 let method = ApiMethod {
1104 js_name: "getActiveBufferId".to_string(),
1105 kind: ApiKind::Sync,
1106 params: vec![],
1107 return_type: "number".to_string(),
1108 doc: "Get the active buffer ID".to_string(),
1109 ts_raw: None,
1110 };
1111
1112 let ts = generate_ts_method(&method);
1113 assert!(ts.contains("getActiveBufferId(): number;"));
1114 assert!(ts.contains("Get the active buffer ID"));
1115 }
1116
1117 #[test]
1118 fn test_generate_ts_method_async_promise() {
1119 let method = ApiMethod {
1120 js_name: "delay".to_string(),
1121 kind: ApiKind::AsyncPromise,
1122 params: vec![ParamInfo {
1123 name: "ms".to_string(),
1124 ts_type: "number".to_string(),
1125 optional: false,
1126 variadic: false,
1127 }],
1128 return_type: "void".to_string(),
1129 doc: "".to_string(),
1130 ts_raw: None,
1131 };
1132
1133 let ts = generate_ts_method(&method);
1134 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1135 }
1136
1137 #[test]
1138 fn test_generate_ts_method_async_thenable() {
1139 let method = ApiMethod {
1140 js_name: "spawnProcess".to_string(),
1141 kind: ApiKind::AsyncThenable,
1142 params: vec![
1143 ParamInfo {
1144 name: "command".to_string(),
1145 ts_type: "string".to_string(),
1146 optional: false,
1147 variadic: false,
1148 },
1149 ParamInfo {
1150 name: "args".to_string(),
1151 ts_type: "string".to_string(),
1152 optional: false,
1153 variadic: false,
1154 },
1155 ],
1156 return_type: "SpawnResult".to_string(),
1157 doc: "Spawn a process".to_string(),
1158 ts_raw: None,
1159 };
1160
1161 let ts = generate_ts_method(&method);
1162 assert!(
1163 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1164 );
1165 }
1166
1167 fn parse_type(s: &str) -> Type {
1173 syn::parse_str::<Type>(s).unwrap()
1174 }
1175
1176 #[test]
1177 fn test_renamed_type_composite_hunk() {
1178 let ty = parse_type("Vec<CompositeHunk>");
1179 let ts = rust_to_typescript(&ty, &[]);
1180 assert_eq!(ts, "TsCompositeHunk[]");
1181 }
1182
1183 #[test]
1184 fn test_renamed_type_create_composite_buffer_options() {
1185 let ty = parse_type("CreateCompositeBufferOptions");
1186 let ts = rust_to_typescript(&ty, &[]);
1187 assert_eq!(ts, "TsCreateCompositeBufferOptions");
1188 }
1189
1190 #[test]
1191 fn test_renamed_type_suggestion() {
1192 let ty = parse_type("Vec<Suggestion>");
1193 let ts = rust_to_typescript(&ty, &[]);
1194 assert_eq!(ts, "PromptSuggestion[]");
1195 }
1196
1197 #[test]
1198 fn test_passthrough_type_terminal_result() {
1199 let ty = parse_type("TerminalResult");
1200 let ts = rust_to_typescript(&ty, &[]);
1201 assert_eq!(ts, "TerminalResult");
1202 }
1203
1204 #[test]
1205 fn test_passthrough_type_create_terminal_options() {
1206 let ty = parse_type("CreateTerminalOptions");
1207 let ts = rust_to_typescript(&ty, &[]);
1208 assert_eq!(ts, "CreateTerminalOptions");
1209 }
1210
1211 #[test]
1212 fn test_passthrough_type_cursor_info() {
1213 let ty = parse_type("CursorInfo");
1214 let ts = rust_to_typescript(&ty, &[]);
1215 assert_eq!(ts, "CursorInfo");
1216 }
1217
1218 #[test]
1219 fn test_option_cursor_info() {
1220 let ty = parse_type("Option<CursorInfo>");
1221 let ts = rust_to_typescript(&ty, &[]);
1222 assert_eq!(ts, "CursorInfo | null");
1223 }
1224
1225 #[test]
1226 fn test_extract_type_references_renamed_types() {
1227 assert_eq!(
1229 extract_type_references("TsCompositeHunk[]"),
1230 vec!["TsCompositeHunk"]
1231 );
1232 assert_eq!(
1233 extract_type_references("TsCreateCompositeBufferOptions"),
1234 vec!["TsCreateCompositeBufferOptions"]
1235 );
1236 assert_eq!(
1237 extract_type_references("PromptSuggestion[]"),
1238 vec!["PromptSuggestion"]
1239 );
1240 }
1241
1242 #[test]
1243 fn test_extract_type_references_terminal_types() {
1244 assert_eq!(
1245 extract_type_references("Promise<TerminalResult>"),
1246 vec!["TerminalResult"]
1247 );
1248 assert_eq!(
1249 extract_type_references("CreateTerminalOptions"),
1250 vec!["CreateTerminalOptions"]
1251 );
1252 }
1253
1254 #[test]
1255 fn test_extract_type_references_cursor_types() {
1256 assert_eq!(
1257 extract_type_references("CursorInfo | null"),
1258 vec!["CursorInfo"]
1259 );
1260 assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
1261 }
1262
1263 #[test]
1264 fn test_generate_ts_method_with_renamed_param_type() {
1265 let method = ApiMethod {
1266 js_name: "updateCompositeAlignment".to_string(),
1267 kind: ApiKind::Sync,
1268 params: vec![
1269 ParamInfo {
1270 name: "bufferId".to_string(),
1271 ts_type: "number".to_string(),
1272 optional: false,
1273 variadic: false,
1274 },
1275 ParamInfo {
1276 name: "hunks".to_string(),
1277 ts_type: "TsCompositeHunk[]".to_string(),
1278 optional: false,
1279 variadic: false,
1280 },
1281 ],
1282 return_type: "boolean".to_string(),
1283 doc: "Update alignment hunks".to_string(),
1284 ts_raw: None,
1285 };
1286
1287 let ts = generate_ts_method(&method);
1288 assert!(ts.contains(
1289 "updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
1290 ));
1291 }
1292
1293 #[test]
1294 fn test_generate_ts_method_cursor_return_types() {
1295 let method = ApiMethod {
1296 js_name: "getPrimaryCursor".to_string(),
1297 kind: ApiKind::Sync,
1298 params: vec![],
1299 return_type: "CursorInfo | null".to_string(),
1300 doc: "Get primary cursor".to_string(),
1301 ts_raw: None,
1302 };
1303 let ts = generate_ts_method(&method);
1304 assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
1305
1306 let method = ApiMethod {
1307 js_name: "getAllCursors".to_string(),
1308 kind: ApiKind::Sync,
1309 params: vec![],
1310 return_type: "CursorInfo[]".to_string(),
1311 doc: "Get all cursors".to_string(),
1312 ts_raw: None,
1313 };
1314 let ts = generate_ts_method(&method);
1315 assert!(ts.contains("getAllCursors(): CursorInfo[];"));
1316
1317 let method = ApiMethod {
1318 js_name: "getAllCursorPositions".to_string(),
1319 kind: ApiKind::Sync,
1320 params: vec![],
1321 return_type: "number[]".to_string(),
1322 doc: "Get all cursor positions".to_string(),
1323 ts_raw: None,
1324 };
1325 let ts = generate_ts_method(&method);
1326 assert!(ts.contains("getAllCursorPositions(): number[];"));
1327 }
1328
1329 #[test]
1330 fn test_generate_ts_method_terminal() {
1331 let method = ApiMethod {
1332 js_name: "createTerminal".to_string(),
1333 kind: ApiKind::AsyncPromise,
1334 params: vec![ParamInfo {
1335 name: "opts".to_string(),
1336 ts_type: "CreateTerminalOptions".to_string(),
1337 optional: true,
1338 variadic: false,
1339 }],
1340 return_type: "TerminalResult".to_string(),
1341 doc: "Create a terminal".to_string(),
1342 ts_raw: None,
1343 };
1344
1345 let ts = generate_ts_method(&method);
1346 assert!(
1347 ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
1348 );
1349 }
1350
1351 #[test]
1352 fn test_collect_referenced_types_includes_renamed() {
1353 let methods = vec![
1354 ApiMethod {
1355 js_name: "updateAlignment".to_string(),
1356 kind: ApiKind::Sync,
1357 params: vec![ParamInfo {
1358 name: "hunks".to_string(),
1359 ts_type: "TsCompositeHunk[]".to_string(),
1360 optional: false,
1361 variadic: false,
1362 }],
1363 return_type: "boolean".to_string(),
1364 doc: "".to_string(),
1365 ts_raw: None,
1366 },
1367 ApiMethod {
1368 js_name: "setSuggestions".to_string(),
1369 kind: ApiKind::Sync,
1370 params: vec![ParamInfo {
1371 name: "suggestions".to_string(),
1372 ts_type: "PromptSuggestion[]".to_string(),
1373 optional: false,
1374 variadic: false,
1375 }],
1376 return_type: "boolean".to_string(),
1377 doc: "".to_string(),
1378 ts_raw: None,
1379 },
1380 ApiMethod {
1381 js_name: "getPrimaryCursor".to_string(),
1382 kind: ApiKind::Sync,
1383 params: vec![],
1384 return_type: "CursorInfo | null".to_string(),
1385 doc: "".to_string(),
1386 ts_raw: None,
1387 },
1388 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: "".to_string(),
1399 ts_raw: None,
1400 },
1401 ];
1402
1403 let types = collect_referenced_types(&methods);
1404 assert!(types.contains(&"TsCompositeHunk".to_string()));
1405 assert!(types.contains(&"PromptSuggestion".to_string()));
1406 assert!(types.contains(&"CursorInfo".to_string()));
1407 assert!(types.contains(&"TerminalResult".to_string()));
1408 assert!(types.contains(&"CreateTerminalOptions".to_string()));
1409 }
1410
1411 #[test]
1412 fn test_all_known_types_are_passthrough_or_renamed() {
1413 let passthrough_types = vec![
1415 "BufferInfo",
1416 "CursorInfo",
1417 "ViewportInfo",
1418 "SpawnResult",
1419 "BackgroundProcessResult",
1420 "DirEntry",
1421 "PromptSuggestion",
1422 "ActionSpec",
1423 "ActionPopupOptions",
1424 "VirtualBufferResult",
1425 "TerminalResult",
1426 "CreateTerminalOptions",
1427 "TsHighlightSpan",
1428 "JsDiagnostic",
1429 ];
1430
1431 for type_name in &passthrough_types {
1432 let ty = parse_type(type_name);
1433 let ts = rust_to_typescript(&ty, &[]);
1434 assert_eq!(
1435 &ts, type_name,
1436 "Type {} should pass through unchanged",
1437 type_name
1438 );
1439 }
1440
1441 let renamed = vec![
1443 ("CompositeHunk", "TsCompositeHunk"),
1444 (
1445 "CreateCompositeBufferOptions",
1446 "TsCreateCompositeBufferOptions",
1447 ),
1448 ("Suggestion", "PromptSuggestion"),
1449 ];
1450
1451 for (rust_name, ts_name) in &renamed {
1452 let ty = parse_type(rust_name);
1453 let ts = rust_to_typescript(&ty, &[]);
1454 assert_eq!(
1455 &ts, ts_name,
1456 "Type {} should be renamed to {}",
1457 rust_name, ts_name
1458 );
1459 }
1460 }
1461}