use oxc_allocator::Allocator;
use oxc_codegen::Codegen;
use oxc_parser::Parser;
use oxc_span::SourceType;
use ts_rs::{Config as TsConfig, TS};
use fresh_core::api::{
ActionPopupAction, ActionPopupOptions, ActionSpec, AnimationRect, BackgroundProcessResult,
BufferGroupResult, BufferInfo, BufferSavedDiff, CompositeHunk, CompositeLayoutConfig,
CompositePaneStyle, CompositeSourceConfig, CreateCompositeBufferOptions, CreateTerminalOptions,
CreateVirtualBufferInExistingSplitOptions, CreateVirtualBufferInSplitOptions,
CreateVirtualBufferOptions, CursorInfo, DirEntry, FormatterPackConfig, GrammarInfoSnapshot,
GrepMatch, JsDiagnostic, JsPosition, JsRange, JsTextPropertyEntry, KeyEventPayload,
LanguagePackConfig, LayoutHints, LspServerPackConfig, OverlayColorSpec, OverlayOptions,
PluginAnimationEdge, PluginAnimationKind, ProcessLimitsPackConfig, ReplaceResult, SpawnResult,
SplitSnapshot, TerminalResult, TextPropertiesAtCursor, TsHighlightSpan, ViewTokenStyle,
ViewTokenWire, ViewTokenWireKind, ViewportInfo, VirtualBufferResult,
};
use fresh_core::command::Suggestion;
use fresh_core::file_explorer::FileExplorerDecoration;
use fresh_core::text_property::InlineOverlay;
fn get_type_decl(type_name: &str) -> Option<String> {
let cfg = TsConfig::default();
match type_name {
"AnimationRect" => Some(AnimationRect::decl(&cfg)),
"PluginAnimationEdge" => Some(PluginAnimationEdge::decl(&cfg)),
"PluginAnimationKind" => Some(PluginAnimationKind::decl(&cfg)),
"BufferInfo" => Some(BufferInfo::decl(&cfg)),
"CursorInfo" => Some(CursorInfo::decl(&cfg)),
"ViewportInfo" => Some(ViewportInfo::decl(&cfg)),
"KeyEventPayload" => Some(KeyEventPayload::decl(&cfg)),
"SplitSnapshot" => Some(SplitSnapshot::decl(&cfg)),
"ActionSpec" => Some(ActionSpec::decl(&cfg)),
"BufferSavedDiff" => Some(BufferSavedDiff::decl(&cfg)),
"LayoutHints" => Some(LayoutHints::decl(&cfg)),
"SpawnResult" => Some(SpawnResult::decl(&cfg)),
"BackgroundProcessResult" => Some(BackgroundProcessResult::decl(&cfg)),
"GrepMatch" => Some(GrepMatch::decl(&cfg)),
"ReplaceResult" => Some(ReplaceResult::decl(&cfg)),
"TerminalResult" => Some(TerminalResult::decl(&cfg)),
"CreateTerminalOptions" => Some(CreateTerminalOptions::decl(&cfg)),
"TsCompositeLayoutConfig" | "CompositeLayoutConfig" => {
Some(CompositeLayoutConfig::decl(&cfg))
}
"TsCompositeSourceConfig" | "CompositeSourceConfig" => {
Some(CompositeSourceConfig::decl(&cfg))
}
"TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl(&cfg)),
"TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl(&cfg)),
"TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
Some(CreateCompositeBufferOptions::decl(&cfg))
}
"ViewTokenWireKind" => Some(ViewTokenWireKind::decl(&cfg)),
"ViewTokenStyle" => Some(ViewTokenStyle::decl(&cfg)),
"ViewTokenWire" => Some(ViewTokenWire::decl(&cfg)),
"TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl(&cfg)),
"ActionPopupOptions" => Some(ActionPopupOptions::decl(&cfg)),
"TsHighlightSpan" => Some(TsHighlightSpan::decl(&cfg)),
"FileExplorerDecoration" => Some(FileExplorerDecoration::decl(&cfg)),
"TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl(&cfg)),
"CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl(&cfg)),
"CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl(&cfg)),
"CreateVirtualBufferInExistingSplitOptions" => {
Some(CreateVirtualBufferInExistingSplitOptions::decl(&cfg))
}
"TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl(&cfg)),
"VirtualBufferResult" => Some(VirtualBufferResult::decl(&cfg)),
"BufferGroupResult" => Some(BufferGroupResult::decl(&cfg)),
"PromptSuggestion" | "Suggestion" => Some(Suggestion::decl(&cfg)),
"DirEntry" => Some(DirEntry::decl(&cfg)),
"JsDiagnostic" => Some(JsDiagnostic::decl(&cfg)),
"JsRange" => Some(JsRange::decl(&cfg)),
"JsPosition" => Some(JsPosition::decl(&cfg)),
"GrammarInfoSnapshot" => Some(GrammarInfoSnapshot::decl(&cfg)),
"LanguagePackConfig" => Some(LanguagePackConfig::decl(&cfg)),
"LspServerPackConfig" => Some(LspServerPackConfig::decl(&cfg)),
"ProcessLimitsPackConfig" => Some(ProcessLimitsPackConfig::decl(&cfg)),
"FormatterPackConfig" => Some(FormatterPackConfig::decl(&cfg)),
"OverlayOptions" => Some(OverlayOptions::decl(&cfg)),
"OverlayColorSpec" => Some(OverlayColorSpec::decl(&cfg)),
"InlineOverlay" => Some(InlineOverlay::decl(&cfg)),
"StyledText" => Some(fresh_core::api::StyledText::decl(&cfg)),
"AuthorityPayload" => Some(AUTHORITY_PAYLOAD_DECL.to_string()),
"RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
_ => None,
}
}
const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
type AuthoritySpawner =
| { kind: "local" }
| {
kind: "docker-exec";
container_id: string;
user?: string | null;
workspace?: string | null;
env?: [string, string][];
};
type AuthorityTerminalWrapper =
| { kind: "host-shell" }
| {
kind: "explicit";
command: string;
args: string[];
manages_cwd?: boolean;
};
type AuthorityPayload = {
filesystem: AuthorityFilesystem;
spawner: AuthoritySpawner;
terminal_wrapper: AuthorityTerminalWrapper;
display_label?: string;
/**
* Optional host↔remote workspace path mapping. The dev-container
* authority sets both roots (editor.getCwd() on host;
* remoteWorkspaceFolder on container) so LSP URIs translate at the
* host/container boundary. Local and SSH authorities omit it.
*/
path_translation?: PathTranslationSpec;
};
type PathTranslationSpec = {
host_root: string;
remote_root: string;
};"#;
const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
| { kind: "local" }
| { kind: "connecting"; label?: string | null }
| { kind: "connected"; label?: string | null }
| { kind: "failed_attach"; error?: string | null }
| { kind: "disconnected"; label?: string | null };"#;
const DEPENDENCY_TYPES: &[&str] = &[
"TextPropertyEntry", "TsCompositeLayoutConfig", "TsCompositeSourceConfig", "TsCompositePaneStyle", "TsCompositeHunk", "TsCreateCompositeBufferOptions", "ViewportInfo", "KeyEventPayload", "SplitSnapshot", "LayoutHints", "ViewTokenWire", "ViewTokenWireKind", "ViewTokenStyle", "PromptSuggestion", "DirEntry", "BufferInfo", "JsDiagnostic", "JsRange", "JsPosition", "ActionSpec", "TsActionPopupAction", "ActionPopupOptions", "FileExplorerDecoration", "FormatterPackConfig", "ProcessLimitsPackConfig", "TerminalResult", "CreateTerminalOptions", "CursorInfo", "OverlayOptions", "OverlayColorSpec", "InlineOverlay", "GrammarInfoSnapshot", "AnimationRect", "PluginAnimationEdge", "PluginAnimationKind", ];
pub fn collect_ts_types() -> String {
use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
let mut types = Vec::new();
let mut included_decls = std::collections::HashSet::new();
for type_name in DEPENDENCY_TYPES {
if let Some(decl) = get_type_decl(type_name) {
if included_decls.insert(decl.clone()) {
types.push(decl);
}
}
}
for type_name in JSEDITORAPI_REFERENCED_TYPES {
if let Some(decl) = get_type_decl(type_name) {
if included_decls.insert(decl.clone()) {
types.push(decl);
}
} else {
eprintln!(
"Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
type_name
);
}
}
types.join("\n\n")
}
pub fn validate_typescript(source: &str) -> Result<(), String> {
let allocator = Allocator::default();
let source_type = SourceType::d_ts();
let parser_ret = Parser::new(&allocator, source, source_type).parse();
if parser_ret.errors.is_empty() {
Ok(())
} else {
let errors: Vec<String> = parser_ret
.errors
.iter()
.map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
.collect();
Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
}
}
pub fn format_typescript(source: &str) -> String {
let allocator = Allocator::default();
let source_type = SourceType::d_ts();
let parser_ret = Parser::new(&allocator, source, source_type).parse();
if !parser_ret.errors.is_empty() {
return source.to_string();
}
Codegen::new().build(&parser_ret.program).code
}
pub fn write_fresh_dts() -> Result<(), String> {
use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
let ts_types = collect_ts_types();
let plugin_api_trailer = r#"
/**
* Typed overload of `editor.getPluginApi`. When the caller passes a
* key that some loaded plugin declared in `FreshPluginRegistry`, the
* return type is narrowed to that plugin's API. Unknown names fall
* through to the untyped `unknown | null` signature.
*/
interface EditorAPI {
getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
}
/**
* Maps every hook event name to its payload type.
*
* Payloads match the flat JSON produced by `hook_args_to_json` on the Rust
* side (`HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
* fields only). The TypeScript types here are derived directly from the Rust
* field definitions and must be kept in sync with `fresh-core/src/hooks.rs`.
*
* `action` in `pre_command`/`post_command` is the serde JSON of the `Action`
* enum: unit variants serialize as a plain string (e.g. `"MoveLeft"`),
* tuple variants as a single-key object (e.g. `{"InsertChar": "a"}`).
*/
interface HookEventMap {
// ── lifecycle ────────────────────────────────────────────────────────────
editor_initialized: Record<string, never>;
plugins_loaded: Record<string, never>;
ready: Record<string, never>;
focus_gained: Record<string, never>;
authority_changed: { label: string };
// ── buffer lifecycle ─────────────────────────────────────────────────────
buffer_activated: { buffer_id: number };
buffer_deactivated: { buffer_id: number };
buffer_closed: { buffer_id: number };
// ── file I/O ─────────────────────────────────────────────────────────────
before_file_open: { path: string };
after_file_open: { path: string; buffer_id: number };
before_file_save: { path: string; buffer_id: number };
after_file_save: { path: string; buffer_id: number };
/**
* Fired by the file explorer after a paste/duplicate/etc. mutates
* the filesystem without going through a buffer save. Plugins that
* surface FS-derived state (git status badges, etc.) should
* subscribe in addition to `after_file_save` to refresh on
* explorer-driven changes too.
*/
after_file_explorer_change: { path: string };
// ── text edits ───────────────────────────────────────────────────────────
before_insert: { buffer_id: number; position: number; text: string };
after_insert: {
buffer_id: number;
position: number;
text: string;
affected_start: number;
affected_end: number;
start_line: number;
end_line: number;
lines_added: number;
};
before_delete: { buffer_id: number; start: number; end: number };
after_delete: {
buffer_id: number;
start: number;
end: number;
deleted_text: string;
affected_start: number;
deleted_len: number;
start_line: number;
end_line: number;
lines_removed: number;
};
// ── cursor & viewport ────────────────────────────────────────────────────
cursor_moved: {
buffer_id: number;
cursor_id: number;
old_position: number;
new_position: number;
line: number;
text_properties: Record<string, unknown>[];
};
viewport_changed: {
split_id: number;
buffer_id: number;
top_byte: number;
top_line: number | null;
width: number;
height: number;
};
// ── rendering ────────────────────────────────────────────────────────────
render_start: { buffer_id: number };
render_line: {
buffer_id: number;
line_number: number;
byte_start: number;
byte_end: number;
content: string;
};
lines_changed: {
buffer_id: number;
lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
};
view_transform_request: {
buffer_id: number;
split_id: number;
viewport_start: number;
viewport_end: number;
tokens: ViewTokenWire[];
cursor_positions: number[];
};
// ── commands ─────────────────────────────────────────────────────────────
pre_command: { action: string | Record<string, unknown> };
post_command: { action: string | Record<string, unknown> };
idle: { milliseconds: number };
resize: { width: number; height: number };
// ── prompts ──────────────────────────────────────────────────────────────
prompt_changed: { prompt_type: string; input: string };
prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
prompt_cancelled: { prompt_type: string; input: string };
prompt_selection_changed: { prompt_type: string; selected_index: number };
// ── mouse ────────────────────────────────────────────────────────────────
mouse_click: MouseClickHookArgs;
mouse_move: { column: number; row: number; content_x: number; content_y: number };
mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
// ── LSP ──────────────────────────────────────────────────────────────────
diagnostics_updated: { uri: string; count: number };
lsp_references: {
symbol: string;
locations: { file: string; line: number; column: number }[];
};
lsp_server_request: {
language: string;
method: string;
server_command: string;
params: string | null;
};
lsp_server_error: {
language: string;
server_command: string;
error_type: string;
message: string;
};
lsp_status_clicked: {
language: string;
has_error: boolean;
missing_servers: string[];
user_dismissed: boolean;
};
// ── UI events ────────────────────────────────────────────────────────────
action_popup_result: { popup_id: string; action_id: string };
process_output: { process_id: number; data: string };
language_changed: { buffer_id: number; language: string };
theme_inspect_key: { theme_name: string; key: string };
keyboard_shortcuts: { bindings: { key: string; action: string }[] };
}
/**
* Typed overloads of `editor.on` / `editor.off`.
*
* When the event name is a key of `HookEventMap` the handler receives a
* fully-typed payload — TypeScript will flag misspelled field accesses at
* compile time. Unknown event names fall through to the untyped base
* signatures in the EditorAPI interface.
*
* Both function-value and handler-name forms are supported:
*
* ```ts
* editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
* editor.on("buffer_activated", "myHandler"); // registerHandler("myHandler", fn)
* ```
*/
interface EditorAPI {
on<K extends keyof HookEventMap>(
eventName: K,
handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
): void;
on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
off<K extends keyof HookEventMap>(
eventName: K,
handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
): void;
off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
/**
* Create a buffer group: multiple panels appearing as one tab.
* This is an async runtime binding (not a direct #[qjs] method).
*/
createBufferGroup(
name: string,
mode: string,
layout: unknown,
): Promise<BufferGroupResult>;
}
"#;
let content = format!(
"{}\n{}\n{}{}",
JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
);
validate_typescript(&content)?;
let formatted = format_typescript(&content);
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let output_path = std::path::Path::new(&manifest_dir)
.parent() .and_then(|p| p.parent()) .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
.unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
let should_write = match std::fs::read_to_string(&output_path) {
Ok(existing) => existing != formatted,
Err(_) => true,
};
if should_write {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
fn write_fresh_dts_file() {
write_fresh_dts().expect("Failed to write fresh.d.ts");
println!("Successfully generated, validated, and formatted fresh.d.ts");
}
#[test]
#[ignore]
fn type_check_plugins() {
let tsc_check = std::process::Command::new("tsc").arg("--version").output();
match tsc_check {
Ok(output) if output.status.success() => {
println!(
"Found tsc: {}",
String::from_utf8_lossy(&output.stdout).trim()
);
}
_ => {
println!("tsc not found in PATH, skipping type check test");
return;
}
}
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let script_path = std::path::Path::new(&manifest_dir)
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
.expect("Failed to find check-types.sh");
println!("Running type check script: {}", script_path.display());
let output = std::process::Command::new("bash")
.arg(&script_path)
.output()
.expect("Failed to run check-types.sh");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("stdout:\n{}", stdout);
if !stderr.is_empty() {
println!("stderr:\n{}", stderr);
}
if stdout.contains("had type errors") || !output.status.success() {
panic!(
"TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
);
}
println!("All plugins type check successfully!");
}
#[test]
fn test_get_type_decl_returns_all_expected_types() {
let expected_types = vec![
"BufferInfo",
"CursorInfo",
"ViewportInfo",
"KeyEventPayload",
"SplitSnapshot",
"ActionSpec",
"BufferSavedDiff",
"LayoutHints",
"SpawnResult",
"BackgroundProcessResult",
"TerminalResult",
"CreateTerminalOptions",
"TsCompositeLayoutConfig",
"TsCompositeSourceConfig",
"TsCompositePaneStyle",
"TsCompositeHunk",
"TsCreateCompositeBufferOptions",
"ViewTokenWireKind",
"ViewTokenStyle",
"ViewTokenWire",
"TsActionPopupAction",
"ActionPopupOptions",
"TsHighlightSpan",
"FileExplorerDecoration",
"TextPropertyEntry",
"CreateVirtualBufferOptions",
"CreateVirtualBufferInSplitOptions",
"CreateVirtualBufferInExistingSplitOptions",
"TextPropertiesAtCursor",
"VirtualBufferResult",
"PromptSuggestion",
"DirEntry",
"JsDiagnostic",
"JsRange",
"JsPosition",
"LanguagePackConfig",
"LspServerPackConfig",
"ProcessLimitsPackConfig",
"FormatterPackConfig",
];
for type_name in &expected_types {
assert!(
get_type_decl(type_name).is_some(),
"get_type_decl should return a declaration for '{}'",
type_name
);
}
}
#[test]
fn test_get_type_decl_aliases_resolve_same() {
let alias_pairs = vec![
("CompositeHunk", "TsCompositeHunk"),
("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
("CompositeSourceConfig", "TsCompositeSourceConfig"),
("CompositePaneStyle", "TsCompositePaneStyle"),
(
"CreateCompositeBufferOptions",
"TsCreateCompositeBufferOptions",
),
("ActionPopupAction", "TsActionPopupAction"),
("Suggestion", "PromptSuggestion"),
("JsTextPropertyEntry", "TextPropertyEntry"),
];
for (rust_name, ts_name) in &alias_pairs {
let rust_decl = get_type_decl(rust_name);
let ts_decl = get_type_decl(ts_name);
assert!(
rust_decl.is_some(),
"get_type_decl should handle Rust name '{}'",
rust_name
);
assert_eq!(
rust_decl, ts_decl,
"Alias '{}' and '{}' should produce identical declarations",
rust_name, ts_name
);
}
}
#[test]
fn test_terminal_types_exist() {
let terminal_result = get_type_decl("TerminalResult");
assert!(
terminal_result.is_some(),
"TerminalResult should be defined"
);
let decl = terminal_result.unwrap();
assert!(
decl.contains("bufferId"),
"TerminalResult should have bufferId field"
);
assert!(
decl.contains("terminalId"),
"TerminalResult should have terminalId field"
);
assert!(
decl.contains("splitId"),
"TerminalResult should have splitId field"
);
let terminal_opts = get_type_decl("CreateTerminalOptions");
assert!(
terminal_opts.is_some(),
"CreateTerminalOptions should be defined"
);
}
#[test]
fn test_cursor_info_type_exists() {
let cursor_info = get_type_decl("CursorInfo");
assert!(cursor_info.is_some(), "CursorInfo should be defined");
let decl = cursor_info.unwrap();
assert!(
decl.contains("position"),
"CursorInfo should have position field"
);
assert!(
decl.contains("selection"),
"CursorInfo should have selection field"
);
}
#[test]
fn test_collect_ts_types_no_duplicates() {
let output = collect_ts_types();
let lines: Vec<&str> = output.lines().collect();
let mut declarations = std::collections::HashSet::new();
for line in &lines {
let trimmed = line.trim();
if trimmed.starts_with("type ") && trimmed.contains('=') {
let name = trimmed
.strip_prefix("type ")
.unwrap()
.split(|c: char| c == '=' || c.is_whitespace())
.next()
.unwrap();
assert!(
declarations.insert(name.to_string()),
"Duplicate type declaration found: '{}'",
name
);
}
}
}
#[test]
fn test_collect_ts_types_includes_dependency_types() {
let output = collect_ts_types();
let required_types = [
"TextPropertyEntry",
"TsCompositeLayoutConfig",
"TsCompositeSourceConfig",
"TsCompositePaneStyle",
"TsCompositeHunk",
"TsCreateCompositeBufferOptions",
"PromptSuggestion",
"BufferInfo",
"CursorInfo",
"TerminalResult",
"CreateTerminalOptions",
];
for type_name in &required_types {
assert!(
output.contains(type_name),
"collect_ts_types output should contain type '{}'",
type_name
);
}
}
#[test]
fn test_generated_dts_validates_as_typescript() {
use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
let ts_types = collect_ts_types();
let content = format!(
"{}\n{}\n{}",
JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
);
validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
}
#[test]
fn test_generated_dts_no_undefined_type_references() {
use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
let ts_types = collect_ts_types();
let content = format!(
"{}\n{}\n{}",
JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
);
let mut defined_types = std::collections::HashSet::new();
for builtin in &[
"number",
"string",
"boolean",
"void",
"unknown",
"null",
"undefined",
"Record",
"Array",
"Promise",
"ProcessHandle",
"PromiseLike",
"BufferId",
"SplitId",
"EditorAPI",
] {
defined_types.insert(builtin.to_string());
}
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("type ") && trimmed.contains('=') {
if let Some(name) = trimmed
.strip_prefix("type ")
.unwrap()
.split(|c: char| c == '=' || c.is_whitespace())
.next()
{
defined_types.insert(name.to_string());
}
}
if trimmed.starts_with("interface ") {
if let Some(name) = trimmed
.strip_prefix("interface ")
.unwrap()
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
{
defined_types.insert(name.to_string());
}
}
}
let interface_section = JSEDITORAPI_TS_EDITOR_API;
let mut undefined_refs = Vec::new();
for line in interface_section.lines() {
let trimmed = line.trim();
if trimmed.starts_with('*')
|| trimmed.starts_with("/*")
|| trimmed.starts_with("//")
|| trimmed.is_empty()
|| trimmed == "{"
|| trimmed == "}"
{
continue;
}
for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
if word.is_empty() {
continue;
}
if word.chars().next().is_some_and(|c| c.is_uppercase())
&& !defined_types.contains(word)
{
undefined_refs.push(word.to_string());
}
}
}
undefined_refs.sort();
undefined_refs.dedup();
assert!(
undefined_refs.is_empty(),
"Found undefined type references in EditorAPI interface: {:?}",
undefined_refs
);
}
#[test]
fn test_editor_api_cursor_methods_have_typed_returns() {
use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
let api = JSEDITORAPI_TS_EDITOR_API;
assert!(
api.contains("getPrimaryCursor(): CursorInfo | null;"),
"getPrimaryCursor should return CursorInfo | null, got: {}",
api.lines()
.find(|l| l.contains("getPrimaryCursor"))
.unwrap_or("not found")
);
assert!(
api.contains("getAllCursors(): CursorInfo[];"),
"getAllCursors should return CursorInfo[], got: {}",
api.lines()
.find(|l| l.contains("getAllCursors"))
.unwrap_or("not found")
);
assert!(
api.contains("getAllCursorPositions(): number[];"),
"getAllCursorPositions should return number[], got: {}",
api.lines()
.find(|l| l.contains("getAllCursorPositions"))
.unwrap_or("not found")
);
}
#[test]
fn test_editor_api_terminal_methods_use_defined_types() {
use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
let api = JSEDITORAPI_TS_EDITOR_API;
assert!(
api.contains("CreateTerminalOptions"),
"createTerminal should reference CreateTerminalOptions"
);
assert!(
api.contains("TerminalResult"),
"createTerminal should reference TerminalResult"
);
}
#[test]
fn test_editor_api_composite_methods_use_ts_prefix_types() {
use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
let api = JSEDITORAPI_TS_EDITOR_API;
assert!(
api.contains("TsCompositeHunk[]"),
"updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
);
assert!(
api.contains("TsCreateCompositeBufferOptions"),
"createCompositeBuffer should use TsCreateCompositeBufferOptions"
);
}
#[test]
fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
let api = JSEDITORAPI_TS_EDITOR_API;
assert!(
api.contains("PromptSuggestion[]"),
"setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
);
}
#[test]
fn test_all_editor_api_methods_present() {
use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
let api = JSEDITORAPI_TS_EDITOR_API;
let expected_methods = vec![
"apiVersion",
"getActiveBufferId",
"getActiveSplitId",
"listBuffers",
"debug",
"info",
"warn",
"error",
"setStatus",
"copyToClipboard",
"setClipboard",
"registerCommand",
"unregisterCommand",
"setContext",
"executeAction",
"getCursorPosition",
"getBufferPath",
"getBufferLength",
"isBufferModified",
"saveBufferToPath",
"getBufferInfo",
"getPrimaryCursor",
"getAllCursors",
"getAllCursorPositions",
"getViewport",
"getCursorLine",
"getLineStartPosition",
"getLineEndPosition",
"getBufferLineCount",
"scrollToLineCenter",
"findBufferByPath",
"getBufferSavedDiff",
"insertText",
"deleteRange",
"insertAtCursor",
"openFile",
"openFileInSplit",
"showBuffer",
"closeBuffer",
"animateArea",
"animateVirtualBuffer",
"cancelAnimation",
"on",
"off",
"getEnv",
"getCwd",
"pathJoin",
"pathDirname",
"pathBasename",
"pathExtname",
"pathIsAbsolute",
"utf8ByteLength",
"fileExists",
"readFile",
"writeFile",
"readDir",
"createDir",
"removePath",
"renamePath",
"copyPath",
"getTempDir",
"getConfig",
"getUserConfig",
"reloadConfig",
"reloadThemes",
"reloadAndApplyTheme",
"registerGrammar",
"registerLanguageConfig",
"registerLspServer",
"reloadGrammars",
"getConfigDir",
"getDataDir",
"getThemesDir",
"applyTheme",
"getThemeSchema",
"getBuiltinThemes",
"getAllThemes",
"getThemeData",
"saveThemeFile",
"themeFileExists",
"deleteTheme",
"fileStat",
"isProcessRunning",
"killProcess",
"pluginTranslate",
"createCompositeBuffer",
"updateCompositeAlignment",
"closeCompositeBuffer",
"flushLayout",
"compositeNextHunk",
"compositePrevHunk",
"getHighlights",
"addOverlay",
"clearNamespace",
"clearAllOverlays",
"clearOverlaysInRange",
"removeOverlay",
"addConceal",
"clearConcealNamespace",
"clearConcealsInRange",
"addSoftBreak",
"clearSoftBreakNamespace",
"clearSoftBreaksInRange",
"submitViewTransform",
"clearViewTransform",
"setLayoutHints",
"setFileExplorerDecorations",
"clearFileExplorerDecorations",
"addVirtualText",
"removeVirtualText",
"removeVirtualTextsByPrefix",
"clearVirtualTexts",
"clearVirtualTextNamespace",
"addVirtualLine",
"prompt",
"startPrompt",
"startPromptWithInitial",
"setPromptSuggestions",
"setPromptInputSync",
"defineMode",
"setEditorMode",
"getEditorMode",
"closeSplit",
"setSplitBuffer",
"focusSplit",
"setSplitScroll",
"setSplitRatio",
"setSplitLabel",
"clearSplitLabel",
"getSplitByLabel",
"distributeSplitsEvenly",
"setBufferCursor",
"setLineIndicator",
"clearLineIndicators",
"setLineNumbers",
"setViewMode",
"setViewState",
"getViewState",
"setGlobalState",
"getGlobalState",
"setLineWrap",
"createScrollSyncGroup",
"setScrollSyncAnchors",
"removeScrollSyncGroup",
"executeActions",
"showActionPopup",
"disableLspForLanguage",
"setLspRootUri",
"getAllDiagnostics",
"getHandlers",
"createVirtualBuffer",
"createVirtualBufferInSplit",
"createVirtualBufferInExistingSplit",
"setVirtualBufferContent",
"getTextPropertiesAtCursor",
"spawnProcess",
"spawnProcessWait",
"spawnHostProcess",
"setAuthority",
"clearAuthority",
"setRemoteIndicatorState",
"clearRemoteIndicatorState",
"getBufferText",
"delay",
"sendLspRequest",
"spawnBackgroundProcess",
"killBackgroundProcess",
"createTerminal",
"sendTerminalInput",
"closeTerminal",
"refreshLines",
"getCurrentLocale",
"loadPlugin",
"unloadPlugin",
"reloadPlugin",
"listPlugins",
];
let mut missing = Vec::new();
for method in &expected_methods {
let pattern = format!("{}(", method);
if !api.contains(&pattern) {
missing.push(*method);
}
}
assert!(
missing.is_empty(),
"Missing methods in EditorAPI interface: {:?}",
missing
);
}
}