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).map_or(false, |n| n == "Opt")
347}
348
349fn is_rest_type(ty: &Type) -> bool {
351 get_type_name(ty).map_or(false, |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" => type_name,
447
448 _ => type_name,
450 }
451 }
452 Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
453 Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
454 _ => "unknown".to_string(),
455 }
456}
457
458fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
466 if has_plugin_api_flag(&method.attrs, "skip") {
468 return None;
469 }
470
471 let rust_name = method.sig.ident.to_string();
472 let doc = extract_doc_comment(&method.attrs);
473
474 let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
476 ApiKind::AsyncThenable
477 } else if has_plugin_api_flag(&method.attrs, "async_promise") {
478 ApiKind::AsyncPromise
479 } else {
480 ApiKind::Sync
481 };
482
483 let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
485
486 if js_name.starts_with('_') {
488 return None;
489 }
490
491 let params: Vec<ParamInfo> = method
493 .sig
494 .inputs
495 .iter()
496 .filter_map(|arg| {
497 let FnArg::Typed(pat_type) = arg else {
498 return None;
499 };
500 let Pat::Ident(pat_ident) = &*pat_type.pat else {
501 return None;
502 };
503
504 let param_name = pat_ident.ident.to_string();
505
506 if param_name == "self" {
508 return None;
509 }
510
511 let ty = &*pat_type.ty;
512
513 if is_ctx_type(ty) {
515 return None;
516 }
517
518 Some(ParamInfo {
519 name: to_camel_case(¶m_name),
520 ts_type: rust_to_typescript(ty, &pat_type.attrs),
521 optional: is_opt_type(ty),
522 variadic: is_rest_type(ty),
523 })
524 })
525 .collect();
526
527 let return_type = match &method.sig.output {
529 ReturnType::Default => "void".to_string(),
530 ReturnType::Type(_, ty) => {
531 get_plugin_api_value(&method.attrs, "ts_return")
533 .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
534 }
535 };
536
537 Some(ApiMethod {
538 js_name,
539 kind,
540 params,
541 return_type,
542 doc,
543 })
544}
545
546fn generate_ts_method(method: &ApiMethod) -> String {
552 let mut lines = Vec::new();
553
554 if !method.doc.is_empty() {
556 lines.push(" /**".to_string());
557 for line in method.doc.lines() {
558 lines.push(format!(" * {}", line));
559 }
560 lines.push(" */".to_string());
561 }
562
563 let params: String = method
565 .params
566 .iter()
567 .map(ParamInfo::to_typescript)
568 .collect::<Vec<_>>()
569 .join(", ");
570
571 let return_type = method.kind.wrap_return_type(&method.return_type);
572
573 lines.push(format!(
574 " {}({}): {};",
575 method.js_name, params, return_type
576 ));
577
578 lines.join("\n")
579}
580
581fn generate_ts_preamble() -> &'static str {
583 r#"/**
584 * Fresh Editor TypeScript Plugin API
585 *
586 * This file provides type definitions for the Fresh editor's TypeScript plugin system.
587 * Plugins have access to the global `editor` object which provides methods to:
588 * - Query editor state (buffers, cursors, viewports)
589 * - Modify buffer content (insert, delete text)
590 * - Add visual decorations (overlays, highlighting)
591 * - Interact with the editor UI (status messages, prompts)
592 *
593 * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
594 * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
595 */
596
597/**
598 * Get the editor API instance.
599 * Plugins must call this at the top of their file to get a scoped editor object.
600 */
601declare function getEditor(): EditorAPI;
602
603/** Handle for a cancellable async operation */
604interface ProcessHandle<T> extends PromiseLike<T> {
605 /** Promise that resolves to the result when complete */
606 readonly result: Promise<T>;
607 /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
608 kill(): Promise<boolean>;
609}
610
611/** Buffer identifier */
612type BufferId = number;
613
614/** Split identifier */
615type SplitId = number;
616
617"#
618}
619
620fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
623 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
624
625 format!(
626 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
627 method_sigs.join("\n\n")
628 )
629}
630
631const BUILTIN_TS_TYPES: &[&str] = &[
633 "number",
634 "string",
635 "boolean",
636 "void",
637 "unknown",
638 "null",
639 "undefined",
640 "Record",
641 "Array",
642 "Promise",
643 "ProcessHandle",
644 "PromiseLike",
645 "BufferId",
646 "SplitId", ];
648
649fn extract_type_references(ts_type: &str) -> Vec<String> {
657 let mut types = Vec::new();
658
659 let mut current = ts_type.to_string();
661
662 while let Some(start) = current.find('<') {
664 if let Some(end) = current.rfind('>') {
665 let outer = current[..start].trim().to_string();
666 let inner = current[start + 1..end].trim().to_string();
667
668 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
670 types.push(outer);
671 }
672
673 current = inner;
675 } else {
676 break;
677 }
678 }
679
680 for part in current.split('|') {
682 let part = part.trim();
683
684 if BUILTIN_TS_TYPES.contains(&part) {
686 continue;
687 }
688
689 let part = part.trim_end_matches("[]");
691
692 if part.contains('<') || part.contains('>') {
694 continue;
695 }
696
697 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
699 continue;
700 }
701
702 if part.chars().next().map_or(false, |c| c.is_uppercase()) {
704 types.push(part.to_string());
705 }
706 }
707
708 types
709}
710
711fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
713 let mut types = std::collections::HashSet::new();
714
715 for method in methods {
716 for ty in extract_type_references(&method.return_type) {
718 types.insert(ty);
719 }
720
721 for param in &method.params {
723 for ty in extract_type_references(¶m.ts_type) {
724 types.insert(ty);
725 }
726 }
727 }
728
729 let mut sorted: Vec<String> = types.into_iter().collect();
730 sorted.sort();
731 sorted
732}
733
734#[proc_macro_attribute]
768pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
769 let input = parse_macro_input!(item as ItemImpl);
770
771 let impl_name = match &*input.self_ty {
773 Type::Path(type_path) => type_path
774 .path
775 .segments
776 .last()
777 .map(|s| s.ident.to_string())
778 .unwrap_or_else(|| "Unknown".to_string()),
779 _ => {
780 return compile_error(
781 input.self_ty.span(),
782 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
783 )
784 .into();
785 }
786 };
787
788 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
790 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
791 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
792
793 let methods: Vec<ApiMethod> = input
795 .items
796 .iter()
797 .filter_map(|item| {
798 if let ImplItem::Fn(method) = item {
799 parse_method(method)
800 } else {
801 None
802 }
803 })
804 .collect();
805
806 let preamble = generate_ts_preamble();
808 let editor_api = generate_editor_api_interface(&methods);
809
810 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
812
813 let referenced_types = collect_referenced_types(&methods);
815 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
816
817 let expanded = quote! {
819 #input
820
821 pub const #preamble_const: &str = #preamble;
825
826 pub const #editor_api_const: &str = #editor_api;
830
831 pub const #methods_const: &[&str] = &[#(#js_names),*];
835
836 pub const #types_const: &[&str] = &[#(#referenced_types),*];
841 };
842
843 TokenStream::from(expanded)
844}
845
846#[proc_macro_attribute]
875pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
876 item
878}
879
880#[cfg(test)]
885mod tests {
886 use super::*;
887
888 #[test]
889 fn test_to_camel_case() {
890 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
891 assert_eq!(to_camel_case("simple"), "simple");
892 assert_eq!(to_camel_case("a_b_c"), "aBC");
893 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
894 assert_eq!(to_camel_case(""), "");
895 assert_eq!(to_camel_case("_leading"), "Leading");
896 assert_eq!(to_camel_case("trailing_"), "trailing");
897 }
898
899 #[test]
900 fn test_parse_attr_string_value() {
901 assert_eq!(
902 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
903 Some("myMethod".to_string())
904 );
905 assert_eq!(
906 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
907 Some("foo".to_string())
908 );
909 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
910 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
911 }
912
913 #[test]
914 fn test_api_kind_wrap_return_type() {
915 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
916 assert_eq!(
917 ApiKind::AsyncPromise.wrap_return_type("number"),
918 "Promise<number>"
919 );
920 assert_eq!(
921 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
922 "ProcessHandle<SpawnResult>"
923 );
924 }
925
926 #[test]
927 fn test_param_info_to_typescript() {
928 let regular = ParamInfo {
929 name: "bufferId".to_string(),
930 ts_type: "number".to_string(),
931 optional: false,
932 variadic: false,
933 };
934 assert_eq!(regular.to_typescript(), "bufferId: number");
935
936 let optional = ParamInfo {
937 name: "line".to_string(),
938 ts_type: "number".to_string(),
939 optional: true,
940 variadic: false,
941 };
942 assert_eq!(optional.to_typescript(), "line?: number");
943
944 let variadic = ParamInfo {
945 name: "parts".to_string(),
946 ts_type: "string".to_string(),
947 optional: false,
948 variadic: true,
949 };
950 assert_eq!(variadic.to_typescript(), "...parts: string[]");
951 }
952
953 #[test]
954 fn test_generate_ts_preamble_contains_required_declarations() {
955 let preamble = generate_ts_preamble();
956
957 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
959 assert!(preamble.contains("interface ProcessHandle<T>"));
960 assert!(preamble.contains("type BufferId = number"));
961 assert!(preamble.contains("type SplitId = number"));
962
963 assert!(preamble.contains("AUTO-GENERATED FILE"));
965 }
966
967 #[test]
968 fn test_extract_type_references() {
969 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
971
972 assert!(extract_type_references("number").is_empty());
974 assert!(extract_type_references("string").is_empty());
975 assert!(extract_type_references("void").is_empty());
976
977 assert_eq!(
979 extract_type_references("ProcessHandle<SpawnResult>"),
980 vec!["SpawnResult"]
981 );
982 assert_eq!(
983 extract_type_references("Promise<BufferInfo>"),
984 vec!["BufferInfo"]
985 );
986
987 assert_eq!(
989 extract_type_references("CursorInfo | null"),
990 vec!["CursorInfo"]
991 );
992
993 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
995
996 assert!(extract_type_references("Record<string, unknown>").is_empty());
998 assert!(extract_type_references("Promise<void>").is_empty());
999 }
1000
1001 #[test]
1002 fn test_collect_referenced_types() {
1003 let methods = vec![
1004 ApiMethod {
1005 js_name: "spawnProcess".to_string(),
1006 kind: ApiKind::AsyncThenable,
1007 params: vec![],
1008 return_type: "SpawnResult".to_string(),
1009 doc: "".to_string(),
1010 },
1011 ApiMethod {
1012 js_name: "listBuffers".to_string(),
1013 kind: ApiKind::Sync,
1014 params: vec![],
1015 return_type: "BufferInfo[]".to_string(),
1016 doc: "".to_string(),
1017 },
1018 ];
1019
1020 let types = collect_referenced_types(&methods);
1021 assert!(types.contains(&"SpawnResult".to_string()));
1022 assert!(types.contains(&"BufferInfo".to_string()));
1023 }
1024
1025 #[test]
1026 fn test_generate_ts_method_sync() {
1027 let method = ApiMethod {
1028 js_name: "getActiveBufferId".to_string(),
1029 kind: ApiKind::Sync,
1030 params: vec![],
1031 return_type: "number".to_string(),
1032 doc: "Get the active buffer ID".to_string(),
1033 };
1034
1035 let ts = generate_ts_method(&method);
1036 assert!(ts.contains("getActiveBufferId(): number;"));
1037 assert!(ts.contains("Get the active buffer ID"));
1038 }
1039
1040 #[test]
1041 fn test_generate_ts_method_async_promise() {
1042 let method = ApiMethod {
1043 js_name: "delay".to_string(),
1044 kind: ApiKind::AsyncPromise,
1045 params: vec![ParamInfo {
1046 name: "ms".to_string(),
1047 ts_type: "number".to_string(),
1048 optional: false,
1049 variadic: false,
1050 }],
1051 return_type: "void".to_string(),
1052 doc: "".to_string(),
1053 };
1054
1055 let ts = generate_ts_method(&method);
1056 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1057 }
1058
1059 #[test]
1060 fn test_generate_ts_method_async_thenable() {
1061 let method = ApiMethod {
1062 js_name: "spawnProcess".to_string(),
1063 kind: ApiKind::AsyncThenable,
1064 params: vec![
1065 ParamInfo {
1066 name: "command".to_string(),
1067 ts_type: "string".to_string(),
1068 optional: false,
1069 variadic: false,
1070 },
1071 ParamInfo {
1072 name: "args".to_string(),
1073 ts_type: "string".to_string(),
1074 optional: false,
1075 variadic: false,
1076 },
1077 ],
1078 return_type: "SpawnResult".to_string(),
1079 doc: "Spawn a process".to_string(),
1080 };
1081
1082 let ts = generate_ts_method(&method);
1083 assert!(
1084 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1085 );
1086 }
1087}