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 | "TsCreateCompositeBufferOptions"
456 | "TsCompositeLayoutConfig"
457 | "TsCompositeSourceConfig"
458 | "TsCompositePaneStyle"
459 | "TsCompositeHunk"
460 | "TsHighlightSpan"
461 | "TsActionPopupAction"
462 | "JsDiagnostic" => type_name,
463
464 _ => type_name,
466 }
467 }
468 Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
469 Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
470 _ => "unknown".to_string(),
471 }
472}
473
474fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
482 if has_plugin_api_flag(&method.attrs, "skip") {
484 return None;
485 }
486
487 let rust_name = method.sig.ident.to_string();
488 let doc = extract_doc_comment(&method.attrs);
489
490 let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
492 ApiKind::AsyncThenable
493 } else if has_plugin_api_flag(&method.attrs, "async_promise") {
494 ApiKind::AsyncPromise
495 } else {
496 ApiKind::Sync
497 };
498
499 let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
501
502 if js_name.starts_with('_') {
504 return None;
505 }
506
507 let params: Vec<ParamInfo> = method
509 .sig
510 .inputs
511 .iter()
512 .filter_map(|arg| {
513 let FnArg::Typed(pat_type) = arg else {
514 return None;
515 };
516 let Pat::Ident(pat_ident) = &*pat_type.pat else {
517 return None;
518 };
519
520 let param_name = pat_ident.ident.to_string();
521
522 if param_name == "self" {
524 return None;
525 }
526
527 let ty = &*pat_type.ty;
528
529 if is_ctx_type(ty) {
531 return None;
532 }
533
534 Some(ParamInfo {
535 name: to_camel_case(¶m_name),
536 ts_type: rust_to_typescript(ty, &pat_type.attrs),
537 optional: is_opt_type(ty),
538 variadic: is_rest_type(ty),
539 })
540 })
541 .collect();
542
543 let return_type = match &method.sig.output {
545 ReturnType::Default => "void".to_string(),
546 ReturnType::Type(_, ty) => {
547 get_plugin_api_value(&method.attrs, "ts_return")
549 .unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
550 }
551 };
552
553 Some(ApiMethod {
554 js_name,
555 kind,
556 params,
557 return_type,
558 doc,
559 })
560}
561
562fn generate_ts_method(method: &ApiMethod) -> String {
568 let mut lines = Vec::new();
569
570 if !method.doc.is_empty() {
572 lines.push(" /**".to_string());
573 for line in method.doc.lines() {
574 lines.push(format!(" * {}", line));
575 }
576 lines.push(" */".to_string());
577 }
578
579 let params: String = method
581 .params
582 .iter()
583 .map(ParamInfo::to_typescript)
584 .collect::<Vec<_>>()
585 .join(", ");
586
587 let return_type = method.kind.wrap_return_type(&method.return_type);
588
589 lines.push(format!(
590 " {}({}): {};",
591 method.js_name, params, return_type
592 ));
593
594 lines.join("\n")
595}
596
597fn generate_ts_preamble() -> &'static str {
599 r#"/**
600 * Fresh Editor TypeScript Plugin API
601 *
602 * This file provides type definitions for the Fresh editor's TypeScript plugin system.
603 * Plugins have access to the global `editor` object which provides methods to:
604 * - Query editor state (buffers, cursors, viewports)
605 * - Modify buffer content (insert, delete text)
606 * - Add visual decorations (overlays, highlighting)
607 * - Interact with the editor UI (status messages, prompts)
608 *
609 * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
610 * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
611 */
612
613/**
614 * Get the editor API instance.
615 * Plugins must call this at the top of their file to get a scoped editor object.
616 */
617declare function getEditor(): EditorAPI;
618
619/** Handle for a cancellable async operation */
620interface ProcessHandle<T> extends PromiseLike<T> {
621 /** Promise that resolves to the result when complete */
622 readonly result: Promise<T>;
623 /** Cancel/kill the operation. Returns true if cancelled, false if already completed */
624 kill(): Promise<boolean>;
625}
626
627/** Buffer identifier */
628type BufferId = number;
629
630/** Split identifier */
631type SplitId = number;
632
633"#
634}
635
636fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
639 let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
640
641 format!(
642 "/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
643 method_sigs.join("\n\n")
644 )
645}
646
647const BUILTIN_TS_TYPES: &[&str] = &[
649 "number",
650 "string",
651 "boolean",
652 "void",
653 "unknown",
654 "null",
655 "undefined",
656 "Record",
657 "Array",
658 "Promise",
659 "ProcessHandle",
660 "PromiseLike",
661 "BufferId",
662 "SplitId", ];
664
665fn extract_type_references(ts_type: &str) -> Vec<String> {
673 let mut types = Vec::new();
674
675 let mut current = ts_type.to_string();
677
678 while let Some(start) = current.find('<') {
680 if let Some(end) = current.rfind('>') {
681 let outer = current[..start].trim().to_string();
682 let inner = current[start + 1..end].trim().to_string();
683
684 if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
686 types.push(outer);
687 }
688
689 current = inner;
691 } else {
692 break;
693 }
694 }
695
696 for part in current.split('|') {
698 let part = part.trim();
699
700 if BUILTIN_TS_TYPES.contains(&part) {
702 continue;
703 }
704
705 let part = part.trim_end_matches("[]");
707
708 if part.contains('<') || part.contains('>') {
710 continue;
711 }
712
713 if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
715 continue;
716 }
717
718 if part.chars().next().is_some_and(|c| c.is_uppercase()) {
720 types.push(part.to_string());
721 }
722 }
723
724 types
725}
726
727fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
729 let mut types = std::collections::HashSet::new();
730
731 for method in methods {
732 for ty in extract_type_references(&method.return_type) {
734 types.insert(ty);
735 }
736
737 for param in &method.params {
739 for ty in extract_type_references(¶m.ts_type) {
740 types.insert(ty);
741 }
742 }
743 }
744
745 let mut sorted: Vec<String> = types.into_iter().collect();
746 sorted.sort();
747 sorted
748}
749
750#[proc_macro_attribute]
784pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
785 let input = parse_macro_input!(item as ItemImpl);
786
787 let impl_name = match &*input.self_ty {
789 Type::Path(type_path) => type_path
790 .path
791 .segments
792 .last()
793 .map(|s| s.ident.to_string())
794 .unwrap_or_else(|| "Unknown".to_string()),
795 _ => {
796 return compile_error(
797 input.self_ty.span(),
798 "plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
799 )
800 .into();
801 }
802 };
803
804 let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
806 let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
807 let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
808
809 let methods: Vec<ApiMethod> = input
811 .items
812 .iter()
813 .filter_map(|item| {
814 if let ImplItem::Fn(method) = item {
815 parse_method(method)
816 } else {
817 None
818 }
819 })
820 .collect();
821
822 let preamble = generate_ts_preamble();
824 let editor_api = generate_editor_api_interface(&methods);
825
826 let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
828
829 let referenced_types = collect_referenced_types(&methods);
831 let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
832
833 let expanded = quote! {
835 #input
836
837 pub const #preamble_const: &str = #preamble;
841
842 pub const #editor_api_const: &str = #editor_api;
846
847 pub const #methods_const: &[&str] = &[#(#js_names),*];
851
852 pub const #types_const: &[&str] = &[#(#referenced_types),*];
857 };
858
859 TokenStream::from(expanded)
860}
861
862#[proc_macro_attribute]
891pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
892 item
894}
895
896#[cfg(test)]
901mod tests {
902 use super::*;
903
904 #[test]
905 fn test_to_camel_case() {
906 assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
907 assert_eq!(to_camel_case("simple"), "simple");
908 assert_eq!(to_camel_case("a_b_c"), "aBC");
909 assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
910 assert_eq!(to_camel_case(""), "");
911 assert_eq!(to_camel_case("_leading"), "Leading");
912 assert_eq!(to_camel_case("trailing_"), "trailing");
913 }
914
915 #[test]
916 fn test_parse_attr_string_value() {
917 assert_eq!(
918 parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
919 Some("myMethod".to_string())
920 );
921 assert_eq!(
922 parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
923 Some("foo".to_string())
924 );
925 assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
926 assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
927 }
928
929 #[test]
930 fn test_api_kind_wrap_return_type() {
931 assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
932 assert_eq!(
933 ApiKind::AsyncPromise.wrap_return_type("number"),
934 "Promise<number>"
935 );
936 assert_eq!(
937 ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
938 "ProcessHandle<SpawnResult>"
939 );
940 }
941
942 #[test]
943 fn test_param_info_to_typescript() {
944 let regular = ParamInfo {
945 name: "bufferId".to_string(),
946 ts_type: "number".to_string(),
947 optional: false,
948 variadic: false,
949 };
950 assert_eq!(regular.to_typescript(), "bufferId: number");
951
952 let optional = ParamInfo {
953 name: "line".to_string(),
954 ts_type: "number".to_string(),
955 optional: true,
956 variadic: false,
957 };
958 assert_eq!(optional.to_typescript(), "line?: number");
959
960 let variadic = ParamInfo {
961 name: "parts".to_string(),
962 ts_type: "string".to_string(),
963 optional: false,
964 variadic: true,
965 };
966 assert_eq!(variadic.to_typescript(), "...parts: string[]");
967 }
968
969 #[test]
970 fn test_generate_ts_preamble_contains_required_declarations() {
971 let preamble = generate_ts_preamble();
972
973 assert!(preamble.contains("declare function getEditor(): EditorAPI"));
975 assert!(preamble.contains("interface ProcessHandle<T>"));
976 assert!(preamble.contains("type BufferId = number"));
977 assert!(preamble.contains("type SplitId = number"));
978
979 assert!(preamble.contains("AUTO-GENERATED FILE"));
981 }
982
983 #[test]
984 fn test_extract_type_references() {
985 assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
987
988 assert!(extract_type_references("number").is_empty());
990 assert!(extract_type_references("string").is_empty());
991 assert!(extract_type_references("void").is_empty());
992
993 assert_eq!(
995 extract_type_references("ProcessHandle<SpawnResult>"),
996 vec!["SpawnResult"]
997 );
998 assert_eq!(
999 extract_type_references("Promise<BufferInfo>"),
1000 vec!["BufferInfo"]
1001 );
1002
1003 assert_eq!(
1005 extract_type_references("CursorInfo | null"),
1006 vec!["CursorInfo"]
1007 );
1008
1009 assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
1011
1012 assert!(extract_type_references("Record<string, unknown>").is_empty());
1014 assert!(extract_type_references("Promise<void>").is_empty());
1015 }
1016
1017 #[test]
1018 fn test_collect_referenced_types() {
1019 let methods = vec![
1020 ApiMethod {
1021 js_name: "spawnProcess".to_string(),
1022 kind: ApiKind::AsyncThenable,
1023 params: vec![],
1024 return_type: "SpawnResult".to_string(),
1025 doc: "".to_string(),
1026 },
1027 ApiMethod {
1028 js_name: "listBuffers".to_string(),
1029 kind: ApiKind::Sync,
1030 params: vec![],
1031 return_type: "BufferInfo[]".to_string(),
1032 doc: "".to_string(),
1033 },
1034 ];
1035
1036 let types = collect_referenced_types(&methods);
1037 assert!(types.contains(&"SpawnResult".to_string()));
1038 assert!(types.contains(&"BufferInfo".to_string()));
1039 }
1040
1041 #[test]
1042 fn test_generate_ts_method_sync() {
1043 let method = ApiMethod {
1044 js_name: "getActiveBufferId".to_string(),
1045 kind: ApiKind::Sync,
1046 params: vec![],
1047 return_type: "number".to_string(),
1048 doc: "Get the active buffer ID".to_string(),
1049 };
1050
1051 let ts = generate_ts_method(&method);
1052 assert!(ts.contains("getActiveBufferId(): number;"));
1053 assert!(ts.contains("Get the active buffer ID"));
1054 }
1055
1056 #[test]
1057 fn test_generate_ts_method_async_promise() {
1058 let method = ApiMethod {
1059 js_name: "delay".to_string(),
1060 kind: ApiKind::AsyncPromise,
1061 params: vec![ParamInfo {
1062 name: "ms".to_string(),
1063 ts_type: "number".to_string(),
1064 optional: false,
1065 variadic: false,
1066 }],
1067 return_type: "void".to_string(),
1068 doc: "".to_string(),
1069 };
1070
1071 let ts = generate_ts_method(&method);
1072 assert!(ts.contains("delay(ms: number): Promise<void>;"));
1073 }
1074
1075 #[test]
1076 fn test_generate_ts_method_async_thenable() {
1077 let method = ApiMethod {
1078 js_name: "spawnProcess".to_string(),
1079 kind: ApiKind::AsyncThenable,
1080 params: vec![
1081 ParamInfo {
1082 name: "command".to_string(),
1083 ts_type: "string".to_string(),
1084 optional: false,
1085 variadic: false,
1086 },
1087 ParamInfo {
1088 name: "args".to_string(),
1089 ts_type: "string".to_string(),
1090 optional: false,
1091 variadic: false,
1092 },
1093 ],
1094 return_type: "SpawnResult".to_string(),
1095 doc: "Spawn a process".to_string(),
1096 };
1097
1098 let ts = generate_ts_method(&method);
1099 assert!(
1100 ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
1101 );
1102 }
1103}