1use proc_macro::TokenStream;
90use quote::{format_ident, quote};
91use syn::{
92 parse_macro_input, spanned::Spanned, Attribute, FnArg, GenericArgument, ImplItem, ImplItemFn,
93 ItemImpl, Meta, Pat, PathArguments, ReturnType, Type,
94};
95
96fn compile_error(span: proc_macro2::Span, message: &str) -> proc_macro2::TokenStream {
102 syn::Error::new(span, message).to_compile_error()
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum ApiKind {
112 Sync,
114 AsyncPromise,
116 AsyncThenable,
118}
119
120impl ApiKind {
121 fn wrap_return_type(&self, inner: &str) -> String {
123 match self {
124 ApiKind::Sync => inner.to_string(),
125 ApiKind::AsyncPromise => format!("Promise<{}>", inner),
126 ApiKind::AsyncThenable => format!("ProcessHandle<{}>", inner),
127 }
128 }
129}
130
131#[derive(Debug)]
137struct ApiMethod {
138 js_name: String,
140 kind: ApiKind,
142 params: Vec<ParamInfo>,
144 return_type: String,
146 doc: String,
148 ts_raw: Option<String>,
151}
152
153#[derive(Debug)]
155struct ParamInfo {
156 name: String,
158 ts_type: String,
160 optional: bool,
162 variadic: bool,
164}
165
166impl ParamInfo {
167 fn to_typescript(&self) -> String {
169 if self.variadic {
170 format!("...{}: {}[]", self.name, self.ts_type)
171 } else if self.optional {
172 format!("{}?: {}", self.name, self.ts_type)
173 } else {
174 format!("{}: {}", self.name, self.ts_type)
175 }
176 }
177}
178
179fn to_camel_case(s: &str) -> String {
191 let mut result = String::with_capacity(s.len());
192 let mut capitalize_next = false;
193
194 for c in s.chars() {
195 if c == '_' {
196 capitalize_next = true;
197 } else if capitalize_next {
198 result.push(c.to_ascii_uppercase());
199 capitalize_next = false;
200 } else {
201 result.push(c);
202 }
203 }
204 result
205}
206
207fn extract_doc_comment(attrs: &[Attribute]) -> String {
213 attrs
214 .iter()
215 .filter_map(|attr| {
216 if !attr.path().is_ident("doc") {
217 return None;
218 }
219 if let Meta::NameValue(meta) = &attr.meta {
220 if let syn::Expr::Lit(expr_lit) = &meta.value {
221 if let syn::Lit::Str(lit_str) = &expr_lit.lit {
222 return Some(lit_str.value().trim().to_string());
223 }
224 }
225 }
226 None
227 })
228 .collect::<Vec<_>>()
229 .join("\n")
230}
231
232fn parse_attr_string_value(tokens: &str, key: &str) -> Option<String> {
234 let start = tokens.find(key)?;
235 let rest = &tokens[start..];
236 let eq_pos = rest.find('=')?;
237 let after_eq = rest[eq_pos + 1..].trim();
238
239 if !after_eq.starts_with('"') {
240 return None;
241 }
242
243 let end_quote = after_eq[1..].find('"')?;
244 Some(after_eq[1..end_quote + 1].to_string())
245}
246
247fn has_plugin_api_flag(attrs: &[Attribute], flag: &str) -> bool {
249 attrs.iter().any(|attr| {
250 if !attr.path().is_ident("plugin_api") {
251 return false;
252 }
253 if let Meta::List(meta_list) = &attr.meta {
254 meta_list.tokens.to_string().contains(flag)
255 } else {
256 false
257 }
258 })
259}
260
261fn get_plugin_api_value(attrs: &[Attribute], key: &str) -> Option<String> {
263 for attr in attrs {
264 if !attr.path().is_ident("plugin_api") {
265 continue;
266 }
267 if let Meta::List(meta_list) = &attr.meta {
268 if let Some(value) = parse_attr_string_value(&meta_list.tokens.to_string(), key) {
269 return Some(value);
270 }
271 }
272 }
273 None
274}
275
276fn get_js_name(attrs: &[Attribute]) -> Option<String> {
278 if let Some(name) = get_plugin_api_value(attrs, "js_name") {
280 return Some(name);
281 }
282
283 for attr in attrs {
285 if !attr.path().is_ident("qjs") {
286 continue;
287 }
288 if let Meta::List(meta_list) = &attr.meta {
289 if let Some(name) = parse_attr_string_value(&meta_list.tokens.to_string(), "rename") {
290 return Some(name);
291 }
292 }
293 }
294 None
295}
296
297fn extract_inner_type(ty: &Type) -> Option<Type> {
303 if let Type::Path(type_path) = ty {
304 if let Some(segment) = type_path.path.segments.last() {
305 if let PathArguments::AngleBracketed(args) = &segment.arguments {
306 if let Some(GenericArgument::Type(inner)) = args.args.first() {
307 return Some(inner.clone());
308 }
309 }
310 }
311 }
312 None
313}
314
315fn get_type_name(ty: &Type) -> Option<String> {
317 if let Type::Path(type_path) = ty {
318 type_path.path.segments.last().map(|s| s.ident.to_string())
319 } else {
320 None
321 }
322}
323
324fn is_ctx_type(ty: &Type) -> bool {
326 if let Type::Path(type_path) = ty {
327 if let Some(segment) = type_path.path.segments.last() {
329 if segment.ident == "Ctx" {
330 return true;
331 }
332 }
333 let path_str: String = type_path
335 .path
336 .segments
337 .iter()
338 .map(|s| s.ident.to_string())
339 .collect::<Vec<_>>()
340 .join("::");
341 path_str.contains("Ctx")
342 } else {
343 false
344 }
345}
346
347fn is_opt_type(ty: &Type) -> bool {
349 get_type_name(ty).is_some_and(|n| n == "Opt")
350}
351
352fn is_rest_type(ty: &Type) -> bool {
354 get_type_name(ty).is_some_and(|n| n == "Rest")
355}
356
357fn rust_to_typescript(ty: &Type, attrs: &[Attribute]) -> String {
369 if let Some(custom) = get_plugin_api_value(attrs, "ts_type") {
371 return custom;
372 }
373
374 match ty {
375 Type::Path(type_path) => {
376 let type_name = type_path
377 .path
378 .segments
379 .last()
380 .map(|s| s.ident.to_string())
381 .unwrap_or_else(|| "unknown".to_string());
382
383 match type_name.as_str() {
384 "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "usize" | "isize"
386 | "f32" | "f64" => "number".to_string(),
387
388 "bool" => "boolean".to_string(),
390
391 "String" | "str" => "string".to_string(),
393
394 "()" => "void".to_string(),
396
397 "Option" => {
399 let inner = extract_inner_type(ty)
400 .map(|t| rust_to_typescript(&t, &[]))
401 .unwrap_or_else(|| "unknown".to_string());
402 format!("{} | null", inner)
403 }
404
405 "Vec" => {
407 let inner = extract_inner_type(ty)
408 .map(|t| rust_to_typescript(&t, &[]))
409 .unwrap_or_else(|| "unknown".to_string());
410 format!("{}[]", inner)
411 }
412
413 "Opt" => extract_inner_type(ty)
415 .map(|t| rust_to_typescript(&t, &[]))
416 .unwrap_or_else(|| "unknown".to_string()),
417
418 "Rest" => extract_inner_type(ty)
420 .map(|t| rust_to_typescript(&t, &[]))
421 .unwrap_or_else(|| "unknown".to_string()),
422
423 "Result" => extract_inner_type(ty)
425 .map(|t| rust_to_typescript(&t, &[]))
426 .unwrap_or_else(|| "unknown".to_string()),
427
428 "Value" => "unknown".to_string(),
430 "Object" => "Record<string, unknown>".to_string(),
431
432 "HashMap" | "BTreeMap" => "Record<string, unknown>".to_string(),
434
435 "BufferInfo"
437 | "CursorInfo"
438 | "ViewportInfo"
439 | "SpawnResult"
440 | "BackgroundProcessResult"
441 | "DirEntry"
442 | "FileStat"
443 | "CreateVirtualBufferResult"
444 | "PromptSuggestion"
445 | "TextPropertyEntry"
446 | "JsTextPropertyEntry"
447 | "CreateVirtualBufferOptions"
448 | "CreateVirtualBufferInSplitOptions"
449 | "CreateVirtualBufferInExistingSplitOptions"
450 | "VirtualBufferResult"
451 | "ActionSpec"
452 | "ActionPopupAction"
453 | "ActionPopupOptions"
454 | "ViewTokenWire"
455 | "ViewTokenStyle"
456 | "LayoutHints"
457 | "FileExplorerDecoration"
458 | "TsCompositeLayoutConfig"
459 | "TsCompositeSourceConfig"
460 | "TsCompositePaneStyle"
461 | "TsHighlightSpan"
462 | "TsActionPopupAction"
463 | "JsDiagnostic"
464 | "CreateTerminalOptions"
465 | "TerminalResult" => type_name,
466
467 "CompositeHunk" => "TsCompositeHunk".to_string(),
469 "CreateCompositeBufferOptions" => "TsCreateCompositeBufferOptions".to_string(),
470 "Suggestion" => "PromptSuggestion".to_string(),
471
472 _ => type_name,
474 }
475 }
476 Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
477 Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
478 _ => "unknown".to_string(),
479 }
480}
481
482fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
490 if has_plugin_api_flag(&method.attrs, "skip") {
492 return None;
493 }
494
495 let rust_name = method.sig.ident.to_string();
496 let doc = extract_doc_comment(&method.attrs);
497
498 let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
500 ApiKind::AsyncThenable
501 } else if has_plugin_api_flag(&method.attrs, "async_promise") {
502 ApiKind::AsyncPromise
503 } else {
504 ApiKind::Sync
505 };
506
507 let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
509
510 if js_name.starts_with('_') {
512 return None;
513 }
514
515 let params: Vec<ParamInfo> = method
517 .sig
518 .inputs
519 .iter()
520 .filter_map(|arg| {
521 let FnArg::Typed(pat_type) = arg else {
522 return None;
523 };
524 let Pat::Ident(pat_ident) = &*pat_type.pat else {
525 return None;
526 };
527
528 let raw_name = pat_ident.ident.to_string();
529
530 if raw_name == "self" {
532 return None;
533 }
534
535 let param_name = raw_name.strip_prefix('_').unwrap_or(&raw_name);
537
538 let ty = &*pat_type.ty;
539
540 if is_ctx_type(ty) {
542 return None;
543 }
544
545 Some(ParamInfo {
546 name: to_camel_case(param_name),
547 ts_type: rust_to_typescript(ty, &pat_type.attrs),
548 optional: is_opt_type(ty),
549 variadic: is_rest_type(ty),
550 })
551 })
552 .collect();
553
554 let return_type = match &method.sig.output {
556 ReturnType::Default => "void".to_string(),
557 ReturnType::Type(_, ty) => {
558 get_plugin_api_value(&method.attrs, "ts_return")
560 .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
561 }
562 };
563
564 let ts_raw = get_plugin_api_value(&method.attrs, "ts_raw");
566
567 Some(ApiMethod {
568 js_name,
569 kind,
570 params,
571 return_type,
572 doc,
573 ts_raw,
574 })
575}
576
577fn generate_ts_method(method: &ApiMethod) -> String {
583 let mut lines = Vec::new();
584
585 if !method.doc.is_empty() {
587 lines.push(" /**".to_string());
588 for line in method.doc.lines() {
589 lines.push(format!(" * {}", line));
590 }
591 lines.push(" */".to_string());
592 }
593
594 if let Some(raw) = &method.ts_raw {
596 lines.push(format!(" {};", raw));
597 } else {
598 let params: String = method
600 .params
601 .iter()
602 .map(ParamInfo::to_typescript)
603 .collect::<Vec<_>>()
604 .join(", ");
605
606 let return_type = method.kind.wrap_return_type(&method.return_type);
607
608 lines.push(format!(
609 " {}({}): {};",
610 method.js_name, params, return_type
611 ));
612 }
613
614 lines.join("\n")
615}
616
617fn generate_ts_preamble() -> &'static str {
619 r#"/**
620 * Fresh Editor TypeScript Plugin API
621 *
622 * This file provides type definitions for the Fresh editor's TypeScript plugin system.
623 * Plugins have access to the global `editor` object which provides methods to:
624 * - Query editor state (buffers, cursors, viewports)
625 * - Modify buffer content (insert, delete text)
626 * - Add visual decorations (overlays, highlighting)
627 * - Interact with the editor UI (status messages, prompts)
628 *
629 * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
630 * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
631 */
632
633/**
634 * Get the editor API instance.
635 * Plugins must call this at the top of their file to get a scoped editor object.
636 */
637declare function getEditor(): EditorAPI;
638
639/**
640 * Register a function as a named handler on the global scope.
641 *
642 * Handler functions registered this way can be referenced by name in
643 * `editor.registerCommand()`, `editor.on()`, and mode keybindings.
644 *
645 * The `fn` parameter is typed as `Function` because the runtime passes
646 * different argument shapes depending on the caller: command handlers
647 * receive no arguments, event handlers receive an event-specific data
648 * object (e.g. `{ buffer_id: number }`), and prompt handlers receive
649 * `{ prompt_type: string, input: string }`. Type-annotate your handler
650 * parameters to match the event you are handling.
651 *
652 * @param name - Handler name (referenced by registerCommand, on, etc.)
653 * @param fn - The handler function
654 */
655declare function registerHandler(name: string, fn: Function): void;
656
657/** Handle for a cancellable async operation */
658interface ProcessHandle<T> extends PromiseLike<T> {
659 /** Promise that resolves to the result when complete */
660 readonly result: Promise<T>;
661 /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
662 kill(): Promise<boolean>;
663}
664
665/** Buffer identifier */
666type BufferId = number;
667
668/** Split identifier */
669type SplitId = number;
670
671/**
672 * Payload delivered to handlers registered with `editor.on("mouse_click", ...)`.
673 *
674 * All coordinate fields are in cell (terminal character) units. `buffer_*`
675 * fields are `null` when the click did not land in any buffer panel.
676 */
677interface MouseClickHookArgs {
678 /** Screen column (0-indexed). */
679 column: number;
680 /** Screen row (0-indexed). */
681 row: number;
682 /** Mouse button: "left", "right", "middle". */
683 button: string;
684 /** Modifier keys (e.g. "shift"). */
685 modifiers: string;
686 /** X offset of the content area the click landed in. */
687 content_x: number;
688 /** Y offset of the content area the click landed in. */
689 content_y: number;
690 /** Buffer under the click, or `null` when outside any buffer panel. */
691 buffer_id: number | null;
692 /** 0-indexed buffer row (line number) of the click, accounting for scroll. */
693 buffer_row: number | null;
694 /** 0-indexed byte column inside the buffer row. */
695 buffer_col: number | null;
696}
697
698"#
699}
700
701fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
704 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
705
706 format!(
707 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
708 method_sigs.join("\n\n")
709 )
710}
711
712const BUILTIN_TS_TYPES: &[&str] = &[
714 "number",
715 "string",
716 "boolean",
717 "void",
718 "unknown",
719 "null",
720 "undefined",
721 "Record",
722 "Array",
723 "Promise",
724 "ProcessHandle",
725 "PromiseLike",
726 "BufferId",
727 "SplitId", ];
729
730fn extract_type_references(ts_type: &str) -> Vec<String> {
738 let mut types = Vec::new();
739
740 let mut current = ts_type.to_string();
742
743 while let Some(start) = current.find('<') {
745 if let Some(end) = current.rfind('>') {
746 let outer = current[..start].trim().to_string();
747 let inner = current[start + 1..end].trim().to_string();
748
749 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
751 types.push(outer);
752 }
753
754 current = inner;
756 } else {
757 break;
758 }
759 }
760
761 for part in current.split('|') {
763 let part = part.trim();
764
765 if BUILTIN_TS_TYPES.contains(&part) {
767 continue;
768 }
769
770 let part = part.trim_end_matches("[]");
772
773 if part.contains('<') || part.contains('>') {
775 continue;
776 }
777
778 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
780 continue;
781 }
782
783 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
785 types.push(part.to_string());
786 }
787 }
788
789 types
790}
791
792fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
794 let mut types = std::collections::HashSet::new();
795
796 for method in methods {
797 for ty in extract_type_references(&method.return_type) {
799 types.insert(ty);
800 }
801
802 for param in &method.params {
804 for ty in extract_type_references(¶m.ts_type) {
805 types.insert(ty);
806 }
807 }
808 }
809
810 let mut sorted: Vec<String> = types.into_iter().collect();
811 sorted.sort();
812 sorted
813}
814
815#[proc_macro_attribute]
849pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
850 let input = parse_macro_input!(item as ItemImpl);
851
852 let impl_name = match &*input.self_ty {
854 Type::Path(type_path) => type_path
855 .path
856 .segments
857 .last()
858 .map(|s| s.ident.to_string())
859 .unwrap_or_else(|| "Unknown".to_string()),
860 _ => {
861 return compile_error(
862 input.self_ty.span(),
863 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
864 )
865 .into();
866 }
867 };
868
869 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
871 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
872 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
873
874 let methods: Vec<ApiMethod> = input
876 .items
877 .iter()
878 .filter_map(|item| {
879 if let ImplItem::Fn(method) = item {
880 parse_method(method)
881 } else {
882 None
883 }
884 })
885 .collect();
886
887 let preamble = generate_ts_preamble();
889 let editor_api = generate_editor_api_interface(&methods);
890
891 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
893
894 let referenced_types = collect_referenced_types(&methods);
896 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
897
898 let mut cleaned_input = input.clone();
902 for item in &mut cleaned_input.items {
903 if let ImplItem::Fn(method) = item {
904 for arg in &mut method.sig.inputs {
905 if let FnArg::Typed(pat_type) = arg {
906 pat_type
907 .attrs
908 .retain(|attr| !attr.path().is_ident("plugin_api"));
909 }
910 }
911 }
912 }
913
914 let expanded = quote! {
916 #cleaned_input
917
918 pub const #preamble_const: &str = #preamble;
922
923 pub const #editor_api_const: &str = #editor_api;
927
928 pub const #methods_const: &[&str] = &[#(#js_names),*];
932
933 pub const #types_const: &[&str] = &[#(#referenced_types),*];
938 };
939
940 TokenStream::from(expanded)
941}
942
943#[proc_macro_attribute]
973pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
974 item
976}
977
978#[cfg(test)]
983mod tests {
984 use super::*;
985
986 #[test]
987 fn test_to_camel_case() {
988 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
989 assert_eq!(to_camel_case("simple"), "simple");
990 assert_eq!(to_camel_case("a_b_c"), "aBC");
991 assert_eq!(to_camel_case("process_id"), "processId");
994 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
995 assert_eq!(to_camel_case(""), "");
996 assert_eq!(to_camel_case("_leading"), "Leading");
997 assert_eq!(to_camel_case("trailing_"), "trailing");
998 }
999
1000 #[test]
1001 fn test_parse_attr_string_value() {
1002 assert_eq!(
1003 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
1004 Some("myMethod".to_string())
1005 );
1006 assert_eq!(
1007 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
1008 Some("foo".to_string())
1009 );
1010 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
1011 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
1012 }
1013
1014 #[test]
1015 fn test_api_kind_wrap_return_type() {
1016 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
1017 assert_eq!(
1018 ApiKind::AsyncPromise.wrap_return_type("number"),
1019 "Promise<number>"
1020 );
1021 assert_eq!(
1022 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
1023 "ProcessHandle<SpawnResult>"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_param_info_to_typescript() {
1029 let regular = ParamInfo {
1030 name: "bufferId".to_string(),
1031 ts_type: "number".to_string(),
1032 optional: false,
1033 variadic: false,
1034 };
1035 assert_eq!(regular.to_typescript(), "bufferId: number");
1036
1037 let optional = ParamInfo {
1038 name: "line".to_string(),
1039 ts_type: "number".to_string(),
1040 optional: true,
1041 variadic: false,
1042 };
1043 assert_eq!(optional.to_typescript(), "line?: number");
1044
1045 let variadic = ParamInfo {
1046 name: "parts".to_string(),
1047 ts_type: "string".to_string(),
1048 optional: false,
1049 variadic: true,
1050 };
1051 assert_eq!(variadic.to_typescript(), "...parts: string[]");
1052 }
1053
1054 #[test]
1055 fn test_generate_ts_preamble_contains_required_declarations() {
1056 let preamble = generate_ts_preamble();
1057
1058 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
1060 assert!(preamble.contains("interface ProcessHandle<T>"));
1061 assert!(preamble.contains("type BufferId = number"));
1062 assert!(preamble.contains("type SplitId = number"));
1063
1064 assert!(preamble.contains("AUTO-GENERATED FILE"));
1066 }
1067
1068 #[test]
1069 fn test_extract_type_references() {
1070 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
1072
1073 assert!(extract_type_references("number").is_empty());
1075 assert!(extract_type_references("string").is_empty());
1076 assert!(extract_type_references("void").is_empty());
1077
1078 assert_eq!(
1080 extract_type_references("ProcessHandle<SpawnResult>"),
1081 vec!["SpawnResult"]
1082 );
1083 assert_eq!(
1084 extract_type_references("Promise<BufferInfo>"),
1085 vec!["BufferInfo"]
1086 );
1087
1088 assert_eq!(
1090 extract_type_references("CursorInfo | null"),
1091 vec!["CursorInfo"]
1092 );
1093
1094 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1096
1097 assert!(extract_type_references("Record<string, unknown>").is_empty());
1099 assert!(extract_type_references("Promise<void>").is_empty());
1100 }
1101
1102 #[test]
1103 fn test_collect_referenced_types() {
1104 let methods = vec![
1105 ApiMethod {
1106 js_name: "spawnProcess".to_string(),
1107 kind: ApiKind::AsyncThenable,
1108 params: vec![],
1109 return_type: "SpawnResult".to_string(),
1110 doc: "".to_string(),
1111 ts_raw: None,
1112 },
1113 ApiMethod {
1114 js_name: "listBuffers".to_string(),
1115 kind: ApiKind::Sync,
1116 params: vec![],
1117 return_type: "BufferInfo[]".to_string(),
1118 doc: "".to_string(),
1119 ts_raw: None,
1120 },
1121 ];
1122
1123 let types = collect_referenced_types(&methods);
1124 assert!(types.contains(&"SpawnResult".to_string()));
1125 assert!(types.contains(&"BufferInfo".to_string()));
1126 }
1127
1128 #[test]
1129 fn test_generate_ts_method_sync() {
1130 let method = ApiMethod {
1131 js_name: "getActiveBufferId".to_string(),
1132 kind: ApiKind::Sync,
1133 params: vec![],
1134 return_type: "number".to_string(),
1135 doc: "Get the active buffer ID".to_string(),
1136 ts_raw: None,
1137 };
1138
1139 let ts = generate_ts_method(&method);
1140 assert!(ts.contains("getActiveBufferId(): number;"));
1141 assert!(ts.contains("Get the active buffer ID"));
1142 }
1143
1144 #[test]
1145 fn test_generate_ts_method_async_promise() {
1146 let method = ApiMethod {
1147 js_name: "delay".to_string(),
1148 kind: ApiKind::AsyncPromise,
1149 params: vec![ParamInfo {
1150 name: "ms".to_string(),
1151 ts_type: "number".to_string(),
1152 optional: false,
1153 variadic: false,
1154 }],
1155 return_type: "void".to_string(),
1156 doc: "".to_string(),
1157 ts_raw: None,
1158 };
1159
1160 let ts = generate_ts_method(&method);
1161 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1162 }
1163
1164 #[test]
1165 fn test_generate_ts_method_async_thenable() {
1166 let method = ApiMethod {
1167 js_name: "spawnProcess".to_string(),
1168 kind: ApiKind::AsyncThenable,
1169 params: vec![
1170 ParamInfo {
1171 name: "command".to_string(),
1172 ts_type: "string".to_string(),
1173 optional: false,
1174 variadic: false,
1175 },
1176 ParamInfo {
1177 name: "args".to_string(),
1178 ts_type: "string".to_string(),
1179 optional: false,
1180 variadic: false,
1181 },
1182 ],
1183 return_type: "SpawnResult".to_string(),
1184 doc: "Spawn a process".to_string(),
1185 ts_raw: None,
1186 };
1187
1188 let ts = generate_ts_method(&method);
1189 assert!(
1190 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1191 );
1192 }
1193
1194 fn parse_type(s: &str) -> Type {
1200 syn::parse_str::<Type>(s).unwrap()
1201 }
1202
1203 #[test]
1204 fn test_renamed_type_composite_hunk() {
1205 let ty = parse_type("Vec<CompositeHunk>");
1206 let ts = rust_to_typescript(&ty, &[]);
1207 assert_eq!(ts, "TsCompositeHunk[]");
1208 }
1209
1210 #[test]
1211 fn test_renamed_type_create_composite_buffer_options() {
1212 let ty = parse_type("CreateCompositeBufferOptions");
1213 let ts = rust_to_typescript(&ty, &[]);
1214 assert_eq!(ts, "TsCreateCompositeBufferOptions");
1215 }
1216
1217 #[test]
1218 fn test_renamed_type_suggestion() {
1219 let ty = parse_type("Vec<Suggestion>");
1220 let ts = rust_to_typescript(&ty, &[]);
1221 assert_eq!(ts, "PromptSuggestion[]");
1222 }
1223
1224 #[test]
1225 fn test_passthrough_type_terminal_result() {
1226 let ty = parse_type("TerminalResult");
1227 let ts = rust_to_typescript(&ty, &[]);
1228 assert_eq!(ts, "TerminalResult");
1229 }
1230
1231 #[test]
1232 fn test_passthrough_type_create_terminal_options() {
1233 let ty = parse_type("CreateTerminalOptions");
1234 let ts = rust_to_typescript(&ty, &[]);
1235 assert_eq!(ts, "CreateTerminalOptions");
1236 }
1237
1238 #[test]
1239 fn test_passthrough_type_cursor_info() {
1240 let ty = parse_type("CursorInfo");
1241 let ts = rust_to_typescript(&ty, &[]);
1242 assert_eq!(ts, "CursorInfo");
1243 }
1244
1245 #[test]
1246 fn test_option_cursor_info() {
1247 let ty = parse_type("Option<CursorInfo>");
1248 let ts = rust_to_typescript(&ty, &[]);
1249 assert_eq!(ts, "CursorInfo | null");
1250 }
1251
1252 #[test]
1253 fn test_extract_type_references_renamed_types() {
1254 assert_eq!(
1256 extract_type_references("TsCompositeHunk[]"),
1257 vec!["TsCompositeHunk"]
1258 );
1259 assert_eq!(
1260 extract_type_references("TsCreateCompositeBufferOptions"),
1261 vec!["TsCreateCompositeBufferOptions"]
1262 );
1263 assert_eq!(
1264 extract_type_references("PromptSuggestion[]"),
1265 vec!["PromptSuggestion"]
1266 );
1267 }
1268
1269 #[test]
1270 fn test_extract_type_references_terminal_types() {
1271 assert_eq!(
1272 extract_type_references("Promise<TerminalResult>"),
1273 vec!["TerminalResult"]
1274 );
1275 assert_eq!(
1276 extract_type_references("CreateTerminalOptions"),
1277 vec!["CreateTerminalOptions"]
1278 );
1279 }
1280
1281 #[test]
1282 fn test_extract_type_references_cursor_types() {
1283 assert_eq!(
1284 extract_type_references("CursorInfo | null"),
1285 vec!["CursorInfo"]
1286 );
1287 assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
1288 }
1289
1290 #[test]
1291 fn test_generate_ts_method_with_renamed_param_type() {
1292 let method = ApiMethod {
1293 js_name: "updateCompositeAlignment".to_string(),
1294 kind: ApiKind::Sync,
1295 params: vec![
1296 ParamInfo {
1297 name: "bufferId".to_string(),
1298 ts_type: "number".to_string(),
1299 optional: false,
1300 variadic: false,
1301 },
1302 ParamInfo {
1303 name: "hunks".to_string(),
1304 ts_type: "TsCompositeHunk[]".to_string(),
1305 optional: false,
1306 variadic: false,
1307 },
1308 ],
1309 return_type: "boolean".to_string(),
1310 doc: "Update alignment hunks".to_string(),
1311 ts_raw: None,
1312 };
1313
1314 let ts = generate_ts_method(&method);
1315 assert!(ts.contains(
1316 "updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
1317 ));
1318 }
1319
1320 #[test]
1321 fn test_generate_ts_method_cursor_return_types() {
1322 let method = ApiMethod {
1323 js_name: "getPrimaryCursor".to_string(),
1324 kind: ApiKind::Sync,
1325 params: vec![],
1326 return_type: "CursorInfo | null".to_string(),
1327 doc: "Get primary cursor".to_string(),
1328 ts_raw: None,
1329 };
1330 let ts = generate_ts_method(&method);
1331 assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
1332
1333 let method = ApiMethod {
1334 js_name: "getAllCursors".to_string(),
1335 kind: ApiKind::Sync,
1336 params: vec![],
1337 return_type: "CursorInfo[]".to_string(),
1338 doc: "Get all cursors".to_string(),
1339 ts_raw: None,
1340 };
1341 let ts = generate_ts_method(&method);
1342 assert!(ts.contains("getAllCursors(): CursorInfo[];"));
1343
1344 let method = ApiMethod {
1345 js_name: "getAllCursorPositions".to_string(),
1346 kind: ApiKind::Sync,
1347 params: vec![],
1348 return_type: "number[]".to_string(),
1349 doc: "Get all cursor positions".to_string(),
1350 ts_raw: None,
1351 };
1352 let ts = generate_ts_method(&method);
1353 assert!(ts.contains("getAllCursorPositions(): number[];"));
1354 }
1355
1356 #[test]
1357 fn test_generate_ts_method_terminal() {
1358 let method = ApiMethod {
1359 js_name: "createTerminal".to_string(),
1360 kind: ApiKind::AsyncPromise,
1361 params: vec![ParamInfo {
1362 name: "opts".to_string(),
1363 ts_type: "CreateTerminalOptions".to_string(),
1364 optional: true,
1365 variadic: false,
1366 }],
1367 return_type: "TerminalResult".to_string(),
1368 doc: "Create a terminal".to_string(),
1369 ts_raw: None,
1370 };
1371
1372 let ts = generate_ts_method(&method);
1373 assert!(
1374 ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
1375 );
1376 }
1377
1378 #[test]
1379 fn test_collect_referenced_types_includes_renamed() {
1380 let methods = vec![
1381 ApiMethod {
1382 js_name: "updateAlignment".to_string(),
1383 kind: ApiKind::Sync,
1384 params: vec![ParamInfo {
1385 name: "hunks".to_string(),
1386 ts_type: "TsCompositeHunk[]".to_string(),
1387 optional: false,
1388 variadic: false,
1389 }],
1390 return_type: "boolean".to_string(),
1391 doc: "".to_string(),
1392 ts_raw: None,
1393 },
1394 ApiMethod {
1395 js_name: "setSuggestions".to_string(),
1396 kind: ApiKind::Sync,
1397 params: vec![ParamInfo {
1398 name: "suggestions".to_string(),
1399 ts_type: "PromptSuggestion[]".to_string(),
1400 optional: false,
1401 variadic: false,
1402 }],
1403 return_type: "boolean".to_string(),
1404 doc: "".to_string(),
1405 ts_raw: None,
1406 },
1407 ApiMethod {
1408 js_name: "getPrimaryCursor".to_string(),
1409 kind: ApiKind::Sync,
1410 params: vec![],
1411 return_type: "CursorInfo | null".to_string(),
1412 doc: "".to_string(),
1413 ts_raw: None,
1414 },
1415 ApiMethod {
1416 js_name: "createTerminal".to_string(),
1417 kind: ApiKind::AsyncPromise,
1418 params: vec![ParamInfo {
1419 name: "opts".to_string(),
1420 ts_type: "CreateTerminalOptions".to_string(),
1421 optional: true,
1422 variadic: false,
1423 }],
1424 return_type: "TerminalResult".to_string(),
1425 doc: "".to_string(),
1426 ts_raw: None,
1427 },
1428 ];
1429
1430 let types = collect_referenced_types(&methods);
1431 assert!(types.contains(&"TsCompositeHunk".to_string()));
1432 assert!(types.contains(&"PromptSuggestion".to_string()));
1433 assert!(types.contains(&"CursorInfo".to_string()));
1434 assert!(types.contains(&"TerminalResult".to_string()));
1435 assert!(types.contains(&"CreateTerminalOptions".to_string()));
1436 }
1437
1438 #[test]
1439 fn test_all_known_types_are_passthrough_or_renamed() {
1440 let passthrough_types = vec![
1442 "BufferInfo",
1443 "CursorInfo",
1444 "ViewportInfo",
1445 "SpawnResult",
1446 "BackgroundProcessResult",
1447 "DirEntry",
1448 "PromptSuggestion",
1449 "ActionSpec",
1450 "ActionPopupOptions",
1451 "VirtualBufferResult",
1452 "TerminalResult",
1453 "CreateTerminalOptions",
1454 "TsHighlightSpan",
1455 "JsDiagnostic",
1456 ];
1457
1458 for type_name in &passthrough_types {
1459 let ty = parse_type(type_name);
1460 let ts = rust_to_typescript(&ty, &[]);
1461 assert_eq!(
1462 &ts, type_name,
1463 "Type {} should pass through unchanged",
1464 type_name
1465 );
1466 }
1467
1468 let renamed = vec![
1470 ("CompositeHunk", "TsCompositeHunk"),
1471 (
1472 "CreateCompositeBufferOptions",
1473 "TsCreateCompositeBufferOptions",
1474 ),
1475 ("Suggestion", "PromptSuggestion"),
1476 ];
1477
1478 for (rust_name, ts_name) in &renamed {
1479 let ty = parse_type(rust_name);
1480 let ts = rust_to_typescript(&ty, &[]);
1481 assert_eq!(
1482 &ts, ts_name,
1483 "Type {} should be renamed to {}",
1484 rust_name, ts_name
1485 );
1486 }
1487 }
1488}