use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse_macro_input, spanned::Spanned, Attribute, FnArg, GenericArgument, ImplItem, ImplItemFn,
ItemImpl, Meta, Pat, PathArguments, ReturnType, Type,
};
fn compile_error(span: proc_macro2::Span, message: &str) -> proc_macro2::TokenStream {
syn::Error::new(span, message).to_compile_error()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiKind {
Sync,
AsyncPromise,
AsyncThenable,
}
impl ApiKind {
fn wrap_return_type(&self, inner: &str) -> String {
match self {
ApiKind::Sync => inner.to_string(),
ApiKind::AsyncPromise => format!("Promise<{}>", inner),
ApiKind::AsyncThenable => format!("ProcessHandle<{}>", inner),
}
}
}
#[derive(Debug)]
struct ApiMethod {
js_name: String,
kind: ApiKind,
params: Vec<ParamInfo>,
return_type: String,
doc: String,
}
#[derive(Debug)]
struct ParamInfo {
name: String,
ts_type: String,
optional: bool,
variadic: bool,
}
impl ParamInfo {
fn to_typescript(&self) -> String {
if self.variadic {
format!("...{}: {}[]", self.name, self.ts_type)
} else if self.optional {
format!("{}?: {}", self.name, self.ts_type)
} else {
format!("{}: {}", self.name, self.ts_type)
}
}
}
fn to_camel_case(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut capitalize_next = false;
for c in s.chars() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
fn extract_doc_comment(attrs: &[Attribute]) -> String {
attrs
.iter()
.filter_map(|attr| {
if !attr.path().is_ident("doc") {
return None;
}
if let Meta::NameValue(meta) = &attr.meta {
if let syn::Expr::Lit(expr_lit) = &meta.value {
if let syn::Lit::Str(lit_str) = &expr_lit.lit {
return Some(lit_str.value().trim().to_string());
}
}
}
None
})
.collect::<Vec<_>>()
.join("\n")
}
fn parse_attr_string_value(tokens: &str, key: &str) -> Option<String> {
let start = tokens.find(key)?;
let rest = &tokens[start..];
let eq_pos = rest.find('=')?;
let after_eq = rest[eq_pos + 1..].trim();
if !after_eq.starts_with('"') {
return None;
}
let end_quote = after_eq[1..].find('"')?;
Some(after_eq[1..end_quote + 1].to_string())
}
fn has_plugin_api_flag(attrs: &[Attribute], flag: &str) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("plugin_api") {
return false;
}
if let Meta::List(meta_list) = &attr.meta {
meta_list.tokens.to_string().contains(flag)
} else {
false
}
})
}
fn get_plugin_api_value(attrs: &[Attribute], key: &str) -> Option<String> {
for attr in attrs {
if !attr.path().is_ident("plugin_api") {
continue;
}
if let Meta::List(meta_list) = &attr.meta {
if let Some(value) = parse_attr_string_value(&meta_list.tokens.to_string(), key) {
return Some(value);
}
}
}
None
}
fn get_js_name(attrs: &[Attribute]) -> Option<String> {
if let Some(name) = get_plugin_api_value(attrs, "js_name") {
return Some(name);
}
for attr in attrs {
if !attr.path().is_ident("qjs") {
continue;
}
if let Meta::List(meta_list) = &attr.meta {
if let Some(name) = parse_attr_string_value(&meta_list.tokens.to_string(), "rename") {
return Some(name);
}
}
}
None
}
fn extract_inner_type(ty: &Type) -> Option<Type> {
if let Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
if let PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(GenericArgument::Type(inner)) = args.args.first() {
return Some(inner.clone());
}
}
}
}
None
}
fn get_type_name(ty: &Type) -> Option<String> {
if let Type::Path(type_path) = ty {
type_path.path.segments.last().map(|s| s.ident.to_string())
} else {
None
}
}
fn is_ctx_type(ty: &Type) -> bool {
if let Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
if segment.ident == "Ctx" {
return true;
}
}
let path_str: String = type_path
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
path_str.contains("Ctx")
} else {
false
}
}
fn is_opt_type(ty: &Type) -> bool {
get_type_name(ty).is_some_and(|n| n == "Opt")
}
fn is_rest_type(ty: &Type) -> bool {
get_type_name(ty).is_some_and(|n| n == "Rest")
}
fn rust_to_typescript(ty: &Type, attrs: &[Attribute]) -> String {
if let Some(custom) = get_plugin_api_value(attrs, "ts_type") {
return custom;
}
match ty {
Type::Path(type_path) => {
let type_name = type_path
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_else(|| "unknown".to_string());
match type_name.as_str() {
"u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "usize" | "isize"
| "f32" | "f64" => "number".to_string(),
"bool" => "boolean".to_string(),
"String" | "str" => "string".to_string(),
"()" => "void".to_string(),
"Option" => {
let inner = extract_inner_type(ty)
.map(|t| rust_to_typescript(&t, &[]))
.unwrap_or_else(|| "unknown".to_string());
format!("{} | null", inner)
}
"Vec" => {
let inner = extract_inner_type(ty)
.map(|t| rust_to_typescript(&t, &[]))
.unwrap_or_else(|| "unknown".to_string());
format!("{}[]", inner)
}
"Opt" => extract_inner_type(ty)
.map(|t| rust_to_typescript(&t, &[]))
.unwrap_or_else(|| "unknown".to_string()),
"Rest" => extract_inner_type(ty)
.map(|t| rust_to_typescript(&t, &[]))
.unwrap_or_else(|| "unknown".to_string()),
"Result" => extract_inner_type(ty)
.map(|t| rust_to_typescript(&t, &[]))
.unwrap_or_else(|| "unknown".to_string()),
"Value" => "unknown".to_string(),
"Object" => "Record<string, unknown>".to_string(),
"HashMap" | "BTreeMap" => "Record<string, unknown>".to_string(),
"BufferInfo"
| "CursorInfo"
| "ViewportInfo"
| "SpawnResult"
| "BackgroundProcessResult"
| "DirEntry"
| "FileStat"
| "CreateVirtualBufferResult"
| "PromptSuggestion"
| "TextPropertyEntry"
| "JsTextPropertyEntry"
| "CreateVirtualBufferOptions"
| "CreateVirtualBufferInSplitOptions"
| "CreateVirtualBufferInExistingSplitOptions"
| "VirtualBufferResult"
| "ActionSpec"
| "ActionPopupAction"
| "ActionPopupOptions"
| "ViewTokenWire"
| "ViewTokenStyle"
| "LayoutHints"
| "FileExplorerDecoration"
| "TsCompositeLayoutConfig"
| "TsCompositeSourceConfig"
| "TsCompositePaneStyle"
| "TsHighlightSpan"
| "TsActionPopupAction"
| "JsDiagnostic"
| "CreateTerminalOptions"
| "TerminalResult" => type_name,
"CompositeHunk" => "TsCompositeHunk".to_string(),
"CreateCompositeBufferOptions" => "TsCreateCompositeBufferOptions".to_string(),
"Suggestion" => "PromptSuggestion".to_string(),
_ => type_name,
}
}
Type::Tuple(tuple) if tuple.elems.is_empty() => "void".to_string(),
Type::Reference(reference) => rust_to_typescript(&reference.elem, attrs),
_ => "unknown".to_string(),
}
}
fn parse_method(method: &ImplItemFn) -> Option<ApiMethod> {
if has_plugin_api_flag(&method.attrs, "skip") {
return None;
}
let rust_name = method.sig.ident.to_string();
let doc = extract_doc_comment(&method.attrs);
let kind = if has_plugin_api_flag(&method.attrs, "async_thenable") {
ApiKind::AsyncThenable
} else if has_plugin_api_flag(&method.attrs, "async_promise") {
ApiKind::AsyncPromise
} else {
ApiKind::Sync
};
let js_name = get_js_name(&method.attrs).unwrap_or_else(|| to_camel_case(&rust_name));
if js_name.starts_with('_') {
return None;
}
let params: Vec<ParamInfo> = method
.sig
.inputs
.iter()
.filter_map(|arg| {
let FnArg::Typed(pat_type) = arg else {
return None;
};
let Pat::Ident(pat_ident) = &*pat_type.pat else {
return None;
};
let raw_name = pat_ident.ident.to_string();
if raw_name == "self" {
return None;
}
let param_name = raw_name.strip_prefix('_').unwrap_or(&raw_name);
let ty = &*pat_type.ty;
if is_ctx_type(ty) {
return None;
}
Some(ParamInfo {
name: to_camel_case(¶m_name),
ts_type: rust_to_typescript(ty, &pat_type.attrs),
optional: is_opt_type(ty),
variadic: is_rest_type(ty),
})
})
.collect();
let return_type = match &method.sig.output {
ReturnType::Default => "void".to_string(),
ReturnType::Type(_, ty) => {
get_plugin_api_value(&method.attrs, "ts_return")
.unwrap_or_else(|| rust_to_typescript(ty, &method.attrs))
}
};
Some(ApiMethod {
js_name,
kind,
params,
return_type,
doc,
})
}
fn generate_ts_method(method: &ApiMethod) -> String {
let mut lines = Vec::new();
if !method.doc.is_empty() {
lines.push(" /**".to_string());
for line in method.doc.lines() {
lines.push(format!(" * {}", line));
}
lines.push(" */".to_string());
}
let params: String = method
.params
.iter()
.map(ParamInfo::to_typescript)
.collect::<Vec<_>>()
.join(", ");
let return_type = method.kind.wrap_return_type(&method.return_type);
lines.push(format!(
" {}({}): {};",
method.js_name, params, return_type
));
lines.join("\n")
}
fn generate_ts_preamble() -> &'static str {
r#"/**
* Fresh Editor TypeScript Plugin API
*
* This file provides type definitions for the Fresh editor's TypeScript plugin system.
* Plugins have access to the global `editor` object which provides methods to:
* - Query editor state (buffers, cursors, viewports)
* - Modify buffer content (insert, delete text)
* - Add visual decorations (overlays, highlighting)
* - Interact with the editor UI (status messages, prompts)
*
* AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
* Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl
*/
/**
* Get the editor API instance.
* Plugins must call this at the top of their file to get a scoped editor object.
*/
declare function getEditor(): EditorAPI;
/** Handle for a cancellable async operation */
interface ProcessHandle<T> extends PromiseLike<T> {
/** Promise that resolves to the result when complete */
readonly result: Promise<T>;
/** Cancel/kill the operation. Returns true if cancelled, false if already completed */
kill(): Promise<boolean>;
}
/** Buffer identifier */
type BufferId = number;
/** Split identifier */
type SplitId = number;
"#
}
fn generate_editor_api_interface(methods: &[ApiMethod]) -> String {
let method_sigs: Vec<String> = methods.iter().map(generate_ts_method).collect();
format!(
"/**\n * Main editor API interface\n */\ninterface EditorAPI {{\n{}\n}}\n",
method_sigs.join("\n\n")
)
}
const BUILTIN_TS_TYPES: &[&str] = &[
"number",
"string",
"boolean",
"void",
"unknown",
"null",
"undefined",
"Record",
"Array",
"Promise",
"ProcessHandle",
"PromiseLike",
"BufferId",
"SplitId", ];
fn extract_type_references(ts_type: &str) -> Vec<String> {
let mut types = Vec::new();
let mut current = ts_type.to_string();
while let Some(start) = current.find('<') {
if let Some(end) = current.rfind('>') {
let outer = current[..start].trim().to_string();
let inner = current[start + 1..end].trim().to_string();
if !BUILTIN_TS_TYPES.contains(&outer.as_str()) && !outer.is_empty() {
types.push(outer);
}
current = inner;
} else {
break;
}
}
for part in current.split('|') {
let part = part.trim();
if BUILTIN_TS_TYPES.contains(&part) {
continue;
}
let part = part.trim_end_matches("[]");
if part.contains('<') || part.contains('>') {
continue;
}
if part.is_empty() || BUILTIN_TS_TYPES.contains(&part) {
continue;
}
if part.chars().next().is_some_and(|c| c.is_uppercase()) {
types.push(part.to_string());
}
}
types
}
fn collect_referenced_types(methods: &[ApiMethod]) -> Vec<String> {
let mut types = std::collections::HashSet::new();
for method in methods {
for ty in extract_type_references(&method.return_type) {
types.insert(ty);
}
for param in &method.params {
for ty in extract_type_references(¶m.ts_type) {
types.insert(ty);
}
}
}
let mut sorted: Vec<String> = types.into_iter().collect();
sorted.sort();
sorted
}
#[proc_macro_attribute]
pub fn plugin_api_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemImpl);
let impl_name = match &*input.self_ty {
Type::Path(type_path) => type_path
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_else(|| "Unknown".to_string()),
_ => {
return compile_error(
input.self_ty.span(),
"plugin_api_impl requires a named type (e.g., `impl JsEditorApi`)",
)
.into();
}
};
let preamble_const = format_ident!("{}_TS_PREAMBLE", impl_name.to_uppercase());
let editor_api_const = format_ident!("{}_TS_EDITOR_API", impl_name.to_uppercase());
let methods_const = format_ident!("{}_JS_METHODS", impl_name.to_uppercase());
let methods: Vec<ApiMethod> = input
.items
.iter()
.filter_map(|item| {
if let ImplItem::Fn(method) = item {
parse_method(method)
} else {
None
}
})
.collect();
let preamble = generate_ts_preamble();
let editor_api = generate_editor_api_interface(&methods);
let js_names: Vec<&str> = methods.iter().map(|m| m.js_name.as_str()).collect();
let referenced_types = collect_referenced_types(&methods);
let types_const = format_ident!("{}_REFERENCED_TYPES", impl_name.to_uppercase());
let mut cleaned_input = input.clone();
for item in &mut cleaned_input.items {
if let ImplItem::Fn(method) = item {
for arg in &mut method.sig.inputs {
if let FnArg::Typed(pat_type) = arg {
pat_type
.attrs
.retain(|attr| !attr.path().is_ident("plugin_api"));
}
}
}
}
let expanded = quote! {
#cleaned_input
pub const #preamble_const: &str = #preamble;
pub const #editor_api_const: &str = #editor_api;
pub const #methods_const: &[&str] = &[#(#js_names),*];
pub const #types_const: &[&str] = &[#(#referenced_types),*];
};
TokenStream::from(expanded)
}
#[proc_macro_attribute]
pub fn plugin_api(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("get_active_buffer"), "getActiveBuffer");
assert_eq!(to_camel_case("simple"), "simple");
assert_eq!(to_camel_case("a_b_c"), "aBC");
assert_eq!(to_camel_case("process_id"), "processId");
assert_eq!(to_camel_case("already_camel"), "alreadyCamel");
assert_eq!(to_camel_case(""), "");
assert_eq!(to_camel_case("_leading"), "Leading");
assert_eq!(to_camel_case("trailing_"), "trailing");
}
#[test]
fn test_parse_attr_string_value() {
assert_eq!(
parse_attr_string_value(r#"js_name = "myMethod""#, "js_name"),
Some("myMethod".to_string())
);
assert_eq!(
parse_attr_string_value(r#"skip, js_name = "foo""#, "js_name"),
Some("foo".to_string())
);
assert_eq!(parse_attr_string_value(r#"skip"#, "js_name"), None);
assert_eq!(parse_attr_string_value(r#"js_name = 123"#, "js_name"), None);
}
#[test]
fn test_api_kind_wrap_return_type() {
assert_eq!(ApiKind::Sync.wrap_return_type("number"), "number");
assert_eq!(
ApiKind::AsyncPromise.wrap_return_type("number"),
"Promise<number>"
);
assert_eq!(
ApiKind::AsyncThenable.wrap_return_type("SpawnResult"),
"ProcessHandle<SpawnResult>"
);
}
#[test]
fn test_param_info_to_typescript() {
let regular = ParamInfo {
name: "bufferId".to_string(),
ts_type: "number".to_string(),
optional: false,
variadic: false,
};
assert_eq!(regular.to_typescript(), "bufferId: number");
let optional = ParamInfo {
name: "line".to_string(),
ts_type: "number".to_string(),
optional: true,
variadic: false,
};
assert_eq!(optional.to_typescript(), "line?: number");
let variadic = ParamInfo {
name: "parts".to_string(),
ts_type: "string".to_string(),
optional: false,
variadic: true,
};
assert_eq!(variadic.to_typescript(), "...parts: string[]");
}
#[test]
fn test_generate_ts_preamble_contains_required_declarations() {
let preamble = generate_ts_preamble();
assert!(preamble.contains("declare function getEditor(): EditorAPI"));
assert!(preamble.contains("interface ProcessHandle<T>"));
assert!(preamble.contains("type BufferId = number"));
assert!(preamble.contains("type SplitId = number"));
assert!(preamble.contains("AUTO-GENERATED FILE"));
}
#[test]
fn test_extract_type_references() {
assert_eq!(extract_type_references("SpawnResult"), vec!["SpawnResult"]);
assert!(extract_type_references("number").is_empty());
assert!(extract_type_references("string").is_empty());
assert!(extract_type_references("void").is_empty());
assert_eq!(
extract_type_references("ProcessHandle<SpawnResult>"),
vec!["SpawnResult"]
);
assert_eq!(
extract_type_references("Promise<BufferInfo>"),
vec!["BufferInfo"]
);
assert_eq!(
extract_type_references("CursorInfo | null"),
vec!["CursorInfo"]
);
assert_eq!(extract_type_references("BufferInfo[]"), vec!["BufferInfo"]);
assert!(extract_type_references("Record<string, unknown>").is_empty());
assert!(extract_type_references("Promise<void>").is_empty());
}
#[test]
fn test_collect_referenced_types() {
let methods = vec![
ApiMethod {
js_name: "spawnProcess".to_string(),
kind: ApiKind::AsyncThenable,
params: vec![],
return_type: "SpawnResult".to_string(),
doc: "".to_string(),
},
ApiMethod {
js_name: "listBuffers".to_string(),
kind: ApiKind::Sync,
params: vec![],
return_type: "BufferInfo[]".to_string(),
doc: "".to_string(),
},
];
let types = collect_referenced_types(&methods);
assert!(types.contains(&"SpawnResult".to_string()));
assert!(types.contains(&"BufferInfo".to_string()));
}
#[test]
fn test_generate_ts_method_sync() {
let method = ApiMethod {
js_name: "getActiveBufferId".to_string(),
kind: ApiKind::Sync,
params: vec![],
return_type: "number".to_string(),
doc: "Get the active buffer ID".to_string(),
};
let ts = generate_ts_method(&method);
assert!(ts.contains("getActiveBufferId(): number;"));
assert!(ts.contains("Get the active buffer ID"));
}
#[test]
fn test_generate_ts_method_async_promise() {
let method = ApiMethod {
js_name: "delay".to_string(),
kind: ApiKind::AsyncPromise,
params: vec![ParamInfo {
name: "ms".to_string(),
ts_type: "number".to_string(),
optional: false,
variadic: false,
}],
return_type: "void".to_string(),
doc: "".to_string(),
};
let ts = generate_ts_method(&method);
assert!(ts.contains("delay(ms: number): Promise<void>;"));
}
#[test]
fn test_generate_ts_method_async_thenable() {
let method = ApiMethod {
js_name: "spawnProcess".to_string(),
kind: ApiKind::AsyncThenable,
params: vec![
ParamInfo {
name: "command".to_string(),
ts_type: "string".to_string(),
optional: false,
variadic: false,
},
ParamInfo {
name: "args".to_string(),
ts_type: "string".to_string(),
optional: false,
variadic: false,
},
],
return_type: "SpawnResult".to_string(),
doc: "Spawn a process".to_string(),
};
let ts = generate_ts_method(&method);
assert!(
ts.contains("spawnProcess(command: string, args: string): ProcessHandle<SpawnResult>;")
);
}
fn parse_type(s: &str) -> Type {
syn::parse_str::<Type>(s).unwrap()
}
#[test]
fn test_renamed_type_composite_hunk() {
let ty = parse_type("Vec<CompositeHunk>");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "TsCompositeHunk[]");
}
#[test]
fn test_renamed_type_create_composite_buffer_options() {
let ty = parse_type("CreateCompositeBufferOptions");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "TsCreateCompositeBufferOptions");
}
#[test]
fn test_renamed_type_suggestion() {
let ty = parse_type("Vec<Suggestion>");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "PromptSuggestion[]");
}
#[test]
fn test_passthrough_type_terminal_result() {
let ty = parse_type("TerminalResult");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "TerminalResult");
}
#[test]
fn test_passthrough_type_create_terminal_options() {
let ty = parse_type("CreateTerminalOptions");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "CreateTerminalOptions");
}
#[test]
fn test_passthrough_type_cursor_info() {
let ty = parse_type("CursorInfo");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "CursorInfo");
}
#[test]
fn test_option_cursor_info() {
let ty = parse_type("Option<CursorInfo>");
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(ts, "CursorInfo | null");
}
#[test]
fn test_extract_type_references_renamed_types() {
assert_eq!(
extract_type_references("TsCompositeHunk[]"),
vec!["TsCompositeHunk"]
);
assert_eq!(
extract_type_references("TsCreateCompositeBufferOptions"),
vec!["TsCreateCompositeBufferOptions"]
);
assert_eq!(
extract_type_references("PromptSuggestion[]"),
vec!["PromptSuggestion"]
);
}
#[test]
fn test_extract_type_references_terminal_types() {
assert_eq!(
extract_type_references("Promise<TerminalResult>"),
vec!["TerminalResult"]
);
assert_eq!(
extract_type_references("CreateTerminalOptions"),
vec!["CreateTerminalOptions"]
);
}
#[test]
fn test_extract_type_references_cursor_types() {
assert_eq!(
extract_type_references("CursorInfo | null"),
vec!["CursorInfo"]
);
assert_eq!(extract_type_references("CursorInfo[]"), vec!["CursorInfo"]);
}
#[test]
fn test_generate_ts_method_with_renamed_param_type() {
let method = ApiMethod {
js_name: "updateCompositeAlignment".to_string(),
kind: ApiKind::Sync,
params: vec![
ParamInfo {
name: "bufferId".to_string(),
ts_type: "number".to_string(),
optional: false,
variadic: false,
},
ParamInfo {
name: "hunks".to_string(),
ts_type: "TsCompositeHunk[]".to_string(),
optional: false,
variadic: false,
},
],
return_type: "boolean".to_string(),
doc: "Update alignment hunks".to_string(),
};
let ts = generate_ts_method(&method);
assert!(ts.contains(
"updateCompositeAlignment(bufferId: number, hunks: TsCompositeHunk[]): boolean;"
));
}
#[test]
fn test_generate_ts_method_cursor_return_types() {
let method = ApiMethod {
js_name: "getPrimaryCursor".to_string(),
kind: ApiKind::Sync,
params: vec![],
return_type: "CursorInfo | null".to_string(),
doc: "Get primary cursor".to_string(),
};
let ts = generate_ts_method(&method);
assert!(ts.contains("getPrimaryCursor(): CursorInfo | null;"));
let method = ApiMethod {
js_name: "getAllCursors".to_string(),
kind: ApiKind::Sync,
params: vec![],
return_type: "CursorInfo[]".to_string(),
doc: "Get all cursors".to_string(),
};
let ts = generate_ts_method(&method);
assert!(ts.contains("getAllCursors(): CursorInfo[];"));
let method = ApiMethod {
js_name: "getAllCursorPositions".to_string(),
kind: ApiKind::Sync,
params: vec![],
return_type: "number[]".to_string(),
doc: "Get all cursor positions".to_string(),
};
let ts = generate_ts_method(&method);
assert!(ts.contains("getAllCursorPositions(): number[];"));
}
#[test]
fn test_generate_ts_method_terminal() {
let method = ApiMethod {
js_name: "createTerminal".to_string(),
kind: ApiKind::AsyncPromise,
params: vec![ParamInfo {
name: "opts".to_string(),
ts_type: "CreateTerminalOptions".to_string(),
optional: true,
variadic: false,
}],
return_type: "TerminalResult".to_string(),
doc: "Create a terminal".to_string(),
};
let ts = generate_ts_method(&method);
assert!(
ts.contains("createTerminal(opts?: CreateTerminalOptions): Promise<TerminalResult>;")
);
}
#[test]
fn test_collect_referenced_types_includes_renamed() {
let methods = vec![
ApiMethod {
js_name: "updateAlignment".to_string(),
kind: ApiKind::Sync,
params: vec![ParamInfo {
name: "hunks".to_string(),
ts_type: "TsCompositeHunk[]".to_string(),
optional: false,
variadic: false,
}],
return_type: "boolean".to_string(),
doc: "".to_string(),
},
ApiMethod {
js_name: "setSuggestions".to_string(),
kind: ApiKind::Sync,
params: vec![ParamInfo {
name: "suggestions".to_string(),
ts_type: "PromptSuggestion[]".to_string(),
optional: false,
variadic: false,
}],
return_type: "boolean".to_string(),
doc: "".to_string(),
},
ApiMethod {
js_name: "getPrimaryCursor".to_string(),
kind: ApiKind::Sync,
params: vec![],
return_type: "CursorInfo | null".to_string(),
doc: "".to_string(),
},
ApiMethod {
js_name: "createTerminal".to_string(),
kind: ApiKind::AsyncPromise,
params: vec![ParamInfo {
name: "opts".to_string(),
ts_type: "CreateTerminalOptions".to_string(),
optional: true,
variadic: false,
}],
return_type: "TerminalResult".to_string(),
doc: "".to_string(),
},
];
let types = collect_referenced_types(&methods);
assert!(types.contains(&"TsCompositeHunk".to_string()));
assert!(types.contains(&"PromptSuggestion".to_string()));
assert!(types.contains(&"CursorInfo".to_string()));
assert!(types.contains(&"TerminalResult".to_string()));
assert!(types.contains(&"CreateTerminalOptions".to_string()));
}
#[test]
fn test_all_known_types_are_passthrough_or_renamed() {
let passthrough_types = vec![
"BufferInfo",
"CursorInfo",
"ViewportInfo",
"SpawnResult",
"BackgroundProcessResult",
"DirEntry",
"PromptSuggestion",
"ActionSpec",
"ActionPopupOptions",
"VirtualBufferResult",
"TerminalResult",
"CreateTerminalOptions",
"TsHighlightSpan",
"JsDiagnostic",
];
for type_name in &passthrough_types {
let ty = parse_type(type_name);
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(
&ts, type_name,
"Type {} should pass through unchanged",
type_name
);
}
let renamed = vec![
("CompositeHunk", "TsCompositeHunk"),
(
"CreateCompositeBufferOptions",
"TsCreateCompositeBufferOptions",
),
("Suggestion", "PromptSuggestion"),
];
for (rust_name, ts_name) in &renamed {
let ty = parse_type(rust_name);
let ts = rust_to_typescript(&ty, &[]);
assert_eq!(
&ts, ts_name,
"Type {} should be renamed to {}",
rust_name, ts_name
);
}
}
}