1use oxc_allocator::Allocator;
12use oxc_codegen::Codegen;
13use oxc_parser::Parser;
14use oxc_span::SourceType;
15use ts_rs::{Config as TsConfig, TS};
16
17use fresh_core::api::{
18 ActionPopupAction, ActionPopupOptions, ActionSpec, AnimationRect, BackgroundProcessResult,
19 BufferGroupResult, BufferInfo, BufferSavedDiff, CompositeHunk, CompositeLayoutConfig,
20 CompositePaneStyle, CompositeSourceConfig, CreateCompositeBufferOptions, CreateTerminalOptions,
21 CreateVirtualBufferInExistingSplitOptions, CreateVirtualBufferInSplitOptions,
22 CreateVirtualBufferOptions, CursorInfo, DirEntry, FormatterPackConfig, GrammarInfoSnapshot,
23 GrepMatch, JsDiagnostic, JsPosition, JsRange, JsTextPropertyEntry, KeyEventPayload,
24 LanguagePackConfig, LayoutHints, LspServerPackConfig, OverlayColorSpec, OverlayOptions,
25 PluginAnimationEdge, PluginAnimationKind, ProcessLimitsPackConfig, ReplaceResult, SpawnResult,
26 SplitSnapshot, TerminalResult, TextPropertiesAtCursor, TsHighlightSpan, ViewTokenStyle,
27 ViewTokenWire, ViewTokenWireKind, ViewportInfo, VirtualBufferResult,
28};
29use fresh_core::command::Suggestion;
30use fresh_core::file_explorer::FileExplorerDecoration;
31use fresh_core::text_property::InlineOverlay;
32
33fn get_type_decl(type_name: &str) -> Option<String> {
38 let cfg = TsConfig::default();
39 match type_name {
42 "AnimationRect" => Some(AnimationRect::decl(&cfg)),
44 "PluginAnimationEdge" => Some(PluginAnimationEdge::decl(&cfg)),
45 "PluginAnimationKind" => Some(PluginAnimationKind::decl(&cfg)),
46
47 "BufferInfo" => Some(BufferInfo::decl(&cfg)),
49 "CursorInfo" => Some(CursorInfo::decl(&cfg)),
50 "ViewportInfo" => Some(ViewportInfo::decl(&cfg)),
51 "KeyEventPayload" => Some(KeyEventPayload::decl(&cfg)),
52 "SplitSnapshot" => Some(SplitSnapshot::decl(&cfg)),
53 "ActionSpec" => Some(ActionSpec::decl(&cfg)),
54 "BufferSavedDiff" => Some(BufferSavedDiff::decl(&cfg)),
55 "LayoutHints" => Some(LayoutHints::decl(&cfg)),
56
57 "SpawnResult" => Some(SpawnResult::decl(&cfg)),
59 "BackgroundProcessResult" => Some(BackgroundProcessResult::decl(&cfg)),
60
61 "GrepMatch" => Some(GrepMatch::decl(&cfg)),
63 "ReplaceResult" => Some(ReplaceResult::decl(&cfg)),
64
65 "TerminalResult" => Some(TerminalResult::decl(&cfg)),
67 "CreateTerminalOptions" => Some(CreateTerminalOptions::decl(&cfg)),
68
69 "TsCompositeLayoutConfig" | "CompositeLayoutConfig" => {
71 Some(CompositeLayoutConfig::decl(&cfg))
72 }
73 "TsCompositeSourceConfig" | "CompositeSourceConfig" => {
74 Some(CompositeSourceConfig::decl(&cfg))
75 }
76 "TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl(&cfg)),
77 "TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl(&cfg)),
78 "TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
79 Some(CreateCompositeBufferOptions::decl(&cfg))
80 }
81
82 "ViewTokenWireKind" => Some(ViewTokenWireKind::decl(&cfg)),
84 "ViewTokenStyle" => Some(ViewTokenStyle::decl(&cfg)),
85 "ViewTokenWire" => Some(ViewTokenWire::decl(&cfg)),
86
87 "TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl(&cfg)),
89 "ActionPopupOptions" => Some(ActionPopupOptions::decl(&cfg)),
90 "TsHighlightSpan" => Some(TsHighlightSpan::decl(&cfg)),
91 "FileExplorerDecoration" => Some(FileExplorerDecoration::decl(&cfg)),
92
93 "TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl(&cfg)),
95 "CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl(&cfg)),
96 "CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl(&cfg)),
97 "CreateVirtualBufferInExistingSplitOptions" => {
98 Some(CreateVirtualBufferInExistingSplitOptions::decl(&cfg))
99 }
100
101 "TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl(&cfg)),
103 "VirtualBufferResult" => Some(VirtualBufferResult::decl(&cfg)),
104 "BufferGroupResult" => Some(BufferGroupResult::decl(&cfg)),
105
106 "PromptSuggestion" | "Suggestion" => Some(Suggestion::decl(&cfg)),
108 "DirEntry" => Some(DirEntry::decl(&cfg)),
109
110 "JsDiagnostic" => Some(JsDiagnostic::decl(&cfg)),
112 "JsRange" => Some(JsRange::decl(&cfg)),
113 "JsPosition" => Some(JsPosition::decl(&cfg)),
114
115 "GrammarInfoSnapshot" => Some(GrammarInfoSnapshot::decl(&cfg)),
117
118 "LanguagePackConfig" => Some(LanguagePackConfig::decl(&cfg)),
120 "LspServerPackConfig" => Some(LspServerPackConfig::decl(&cfg)),
121 "ProcessLimitsPackConfig" => Some(ProcessLimitsPackConfig::decl(&cfg)),
122 "FormatterPackConfig" => Some(FormatterPackConfig::decl(&cfg)),
123
124 "OverlayOptions" => Some(OverlayOptions::decl(&cfg)),
126 "OverlayColorSpec" => Some(OverlayColorSpec::decl(&cfg)),
127 "InlineOverlay" => Some(InlineOverlay::decl(&cfg)),
128
129 "AuthorityPayload" => Some(AUTHORITY_PAYLOAD_DECL.to_string()),
135
136 "RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
142
143 _ => None,
144 }
145}
146
147const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
154
155type AuthoritySpawner =
156 | { kind: "local" }
157 | {
158 kind: "docker-exec";
159 container_id: string;
160 user?: string | null;
161 workspace?: string | null;
162 env?: [string, string][];
163 };
164
165type AuthorityTerminalWrapper =
166 | { kind: "host-shell" }
167 | {
168 kind: "explicit";
169 command: string;
170 args: string[];
171 manages_cwd?: boolean;
172 };
173
174type AuthorityPayload = {
175 filesystem: AuthorityFilesystem;
176 spawner: AuthoritySpawner;
177 terminal_wrapper: AuthorityTerminalWrapper;
178 display_label?: string;
179};"#;
180
181const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
186 | { kind: "local" }
187 | { kind: "connecting"; label?: string | null }
188 | { kind: "connected"; label?: string | null }
189 | { kind: "failed_attach"; error?: string | null }
190 | { kind: "disconnected"; label?: string | null };"#;
191
192const DEPENDENCY_TYPES: &[&str] = &[
196 "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", ];
232
233pub fn collect_ts_types() -> String {
238 use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
239
240 let mut types = Vec::new();
241 let mut included_decls = std::collections::HashSet::new();
244
245 for type_name in DEPENDENCY_TYPES {
247 if let Some(decl) = get_type_decl(type_name) {
248 if included_decls.insert(decl.clone()) {
249 types.push(decl);
250 }
251 }
252 }
253
254 for type_name in JSEDITORAPI_REFERENCED_TYPES {
256 if let Some(decl) = get_type_decl(type_name) {
257 if included_decls.insert(decl.clone()) {
258 types.push(decl);
259 }
260 } else {
261 eprintln!(
263 "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
264 type_name
265 );
266 }
267 }
268
269 types.join("\n\n")
270}
271
272pub fn validate_typescript(source: &str) -> Result<(), String> {
276 let allocator = Allocator::default();
277 let source_type = SourceType::d_ts();
278
279 let parser_ret = Parser::new(&allocator, source, source_type).parse();
280
281 if parser_ret.errors.is_empty() {
282 Ok(())
283 } else {
284 let errors: Vec<String> = parser_ret
285 .errors
286 .iter()
287 .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
288 .collect();
289 Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
290 }
291}
292
293pub fn format_typescript(source: &str) -> String {
298 let allocator = Allocator::default();
299 let source_type = SourceType::d_ts();
300
301 let parser_ret = Parser::new(&allocator, source, source_type).parse();
302
303 if !parser_ret.errors.is_empty() {
304 return source.to_string();
306 }
307
308 Codegen::new().build(&parser_ret.program).code
310}
311
312pub fn write_fresh_dts() -> Result<(), String> {
317 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
318
319 let ts_types = collect_ts_types();
320
321 let plugin_api_trailer = r#"
330
331/**
332 * Typed overload of `editor.getPluginApi`. When the caller passes a
333 * key that some loaded plugin declared in `FreshPluginRegistry`, the
334 * return type is narrowed to that plugin's API. Unknown names fall
335 * through to the untyped `unknown | null` signature.
336 */
337interface EditorAPI {
338 getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
339}
340
341/**
342 * Maps every hook event name to its payload type.
343 *
344 * Payloads match the flat JSON produced by `hook_args_to_json` on the Rust
345 * side (`HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
346 * fields only). The TypeScript types here are derived directly from the Rust
347 * field definitions and must be kept in sync with `fresh-core/src/hooks.rs`.
348 *
349 * `action` in `pre_command`/`post_command` is the serde JSON of the `Action`
350 * enum: unit variants serialize as a plain string (e.g. `"MoveLeft"`),
351 * tuple variants as a single-key object (e.g. `{"InsertChar": "a"}`).
352 */
353interface HookEventMap {
354 // ── lifecycle ────────────────────────────────────────────────────────────
355 editor_initialized: Record<string, never>;
356 plugins_loaded: Record<string, never>;
357 ready: Record<string, never>;
358 focus_gained: Record<string, never>;
359 authority_changed: { label: string };
360
361 // ── buffer lifecycle ─────────────────────────────────────────────────────
362 buffer_activated: { buffer_id: number };
363 buffer_deactivated: { buffer_id: number };
364 buffer_closed: { buffer_id: number };
365
366 // ── file I/O ─────────────────────────────────────────────────────────────
367 before_file_open: { path: string };
368 after_file_open: { path: string; buffer_id: number };
369 before_file_save: { path: string; buffer_id: number };
370 after_file_save: { path: string; buffer_id: number };
371
372 // ── text edits ───────────────────────────────────────────────────────────
373 before_insert: { buffer_id: number; position: number; text: string };
374 after_insert: {
375 buffer_id: number;
376 position: number;
377 text: string;
378 affected_start: number;
379 affected_end: number;
380 start_line: number;
381 end_line: number;
382 lines_added: number;
383 };
384 before_delete: { buffer_id: number; start: number; end: number };
385 after_delete: {
386 buffer_id: number;
387 start: number;
388 end: number;
389 deleted_text: string;
390 affected_start: number;
391 deleted_len: number;
392 start_line: number;
393 end_line: number;
394 lines_removed: number;
395 };
396
397 // ── cursor & viewport ────────────────────────────────────────────────────
398 cursor_moved: {
399 buffer_id: number;
400 cursor_id: number;
401 old_position: number;
402 new_position: number;
403 line: number;
404 text_properties: Record<string, unknown>[];
405 };
406 viewport_changed: {
407 split_id: number;
408 buffer_id: number;
409 top_byte: number;
410 top_line: number | null;
411 width: number;
412 height: number;
413 };
414
415 // ── rendering ────────────────────────────────────────────────────────────
416 render_start: { buffer_id: number };
417 render_line: {
418 buffer_id: number;
419 line_number: number;
420 byte_start: number;
421 byte_end: number;
422 content: string;
423 };
424 lines_changed: {
425 buffer_id: number;
426 lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
427 };
428 view_transform_request: {
429 buffer_id: number;
430 split_id: number;
431 viewport_start: number;
432 viewport_end: number;
433 tokens: ViewTokenWire[];
434 cursor_positions: number[];
435 };
436
437 // ── commands ─────────────────────────────────────────────────────────────
438 pre_command: { action: string | Record<string, unknown> };
439 post_command: { action: string | Record<string, unknown> };
440 idle: { milliseconds: number };
441 resize: { width: number; height: number };
442
443 // ── prompts ──────────────────────────────────────────────────────────────
444 prompt_changed: { prompt_type: string; input: string };
445 prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
446 prompt_cancelled: { prompt_type: string; input: string };
447 prompt_selection_changed: { prompt_type: string; selected_index: number };
448
449 // ── mouse ────────────────────────────────────────────────────────────────
450 mouse_click: MouseClickHookArgs;
451 mouse_move: { column: number; row: number; content_x: number; content_y: number };
452 mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
453
454 // ── LSP ──────────────────────────────────────────────────────────────────
455 diagnostics_updated: { uri: string; count: number };
456 lsp_references: {
457 symbol: string;
458 locations: { file: string; line: number; column: number }[];
459 };
460 lsp_server_request: {
461 language: string;
462 method: string;
463 server_command: string;
464 params: string | null;
465 };
466 lsp_server_error: {
467 language: string;
468 server_command: string;
469 error_type: string;
470 message: string;
471 };
472 lsp_status_clicked: {
473 language: string;
474 has_error: boolean;
475 missing_servers: string[];
476 user_dismissed: boolean;
477 };
478
479 // ── UI events ────────────────────────────────────────────────────────────
480 action_popup_result: { popup_id: string; action_id: string };
481 process_output: { process_id: number; data: string };
482 language_changed: { buffer_id: number; language: string };
483 theme_inspect_key: { theme_name: string; key: string };
484 keyboard_shortcuts: { bindings: { key: string; action: string }[] };
485}
486
487/**
488 * Typed overloads of `editor.on` / `editor.off`.
489 *
490 * When the event name is a key of `HookEventMap` the handler receives a
491 * fully-typed payload — TypeScript will flag misspelled field accesses at
492 * compile time. Unknown event names fall through to the untyped base
493 * signatures in the EditorAPI interface.
494 *
495 * Both function-value and handler-name forms are supported:
496 *
497 * ```ts
498 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
499 * editor.on("buffer_activated", "myHandler"); // registerHandler("myHandler", fn)
500 * ```
501 */
502interface EditorAPI {
503 on<K extends keyof HookEventMap>(
504 eventName: K,
505 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
506 ): void;
507 on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
508 off<K extends keyof HookEventMap>(
509 eventName: K,
510 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
511 ): void;
512 off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
513 /**
514 * Create a buffer group: multiple panels appearing as one tab.
515 * This is an async runtime binding (not a direct #[qjs] method).
516 */
517 createBufferGroup(
518 name: string,
519 mode: string,
520 layout: unknown,
521 ): Promise<BufferGroupResult>;
522}
523"#;
524
525 let content = format!(
526 "{}\n{}\n{}{}",
527 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
528 );
529
530 validate_typescript(&content)?;
532
533 let formatted = format_typescript(&content);
535
536 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
538 let output_path = std::path::Path::new(&manifest_dir)
539 .parent() .and_then(|p| p.parent()) .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
542 .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
543
544 let should_write = match std::fs::read_to_string(&output_path) {
546 Ok(existing) => existing != formatted,
547 Err(_) => true,
548 };
549
550 if should_write {
551 if let Some(parent) = output_path.parent() {
552 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
553 }
554 std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
555 }
556
557 Ok(())
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
567 #[ignore]
568 fn write_fresh_dts_file() {
569 write_fresh_dts().expect("Failed to write fresh.d.ts");
571 println!("Successfully generated, validated, and formatted fresh.d.ts");
572 }
573
574 #[test]
578 #[ignore]
579 fn type_check_plugins() {
580 let tsc_check = std::process::Command::new("tsc").arg("--version").output();
582
583 match tsc_check {
584 Ok(output) if output.status.success() => {
585 println!(
586 "Found tsc: {}",
587 String::from_utf8_lossy(&output.stdout).trim()
588 );
589 }
590 _ => {
591 println!("tsc not found in PATH, skipping type check test");
592 return;
593 }
594 }
595
596 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
598 let script_path = std::path::Path::new(&manifest_dir)
599 .parent()
600 .and_then(|p| p.parent())
601 .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
602 .expect("Failed to find check-types.sh");
603
604 println!("Running type check script: {}", script_path.display());
605
606 let output = std::process::Command::new("bash")
608 .arg(&script_path)
609 .output()
610 .expect("Failed to run check-types.sh");
611
612 let stdout = String::from_utf8_lossy(&output.stdout);
613 let stderr = String::from_utf8_lossy(&output.stderr);
614
615 println!("stdout:\n{}", stdout);
616 if !stderr.is_empty() {
617 println!("stderr:\n{}", stderr);
618 }
619
620 if stdout.contains("had type errors") || !output.status.success() {
622 panic!(
623 "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
624 );
625 }
626
627 println!("All plugins type check successfully!");
628 }
629
630 #[test]
635 fn test_get_type_decl_returns_all_expected_types() {
636 let expected_types = vec![
637 "BufferInfo",
638 "CursorInfo",
639 "ViewportInfo",
640 "KeyEventPayload",
641 "SplitSnapshot",
642 "ActionSpec",
643 "BufferSavedDiff",
644 "LayoutHints",
645 "SpawnResult",
646 "BackgroundProcessResult",
647 "TerminalResult",
648 "CreateTerminalOptions",
649 "TsCompositeLayoutConfig",
650 "TsCompositeSourceConfig",
651 "TsCompositePaneStyle",
652 "TsCompositeHunk",
653 "TsCreateCompositeBufferOptions",
654 "ViewTokenWireKind",
655 "ViewTokenStyle",
656 "ViewTokenWire",
657 "TsActionPopupAction",
658 "ActionPopupOptions",
659 "TsHighlightSpan",
660 "FileExplorerDecoration",
661 "TextPropertyEntry",
662 "CreateVirtualBufferOptions",
663 "CreateVirtualBufferInSplitOptions",
664 "CreateVirtualBufferInExistingSplitOptions",
665 "TextPropertiesAtCursor",
666 "VirtualBufferResult",
667 "PromptSuggestion",
668 "DirEntry",
669 "JsDiagnostic",
670 "JsRange",
671 "JsPosition",
672 "LanguagePackConfig",
673 "LspServerPackConfig",
674 "ProcessLimitsPackConfig",
675 "FormatterPackConfig",
676 ];
677
678 for type_name in &expected_types {
679 assert!(
680 get_type_decl(type_name).is_some(),
681 "get_type_decl should return a declaration for '{}'",
682 type_name
683 );
684 }
685 }
686
687 #[test]
688 fn test_get_type_decl_aliases_resolve_same() {
689 let alias_pairs = vec![
691 ("CompositeHunk", "TsCompositeHunk"),
692 ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
693 ("CompositeSourceConfig", "TsCompositeSourceConfig"),
694 ("CompositePaneStyle", "TsCompositePaneStyle"),
695 (
696 "CreateCompositeBufferOptions",
697 "TsCreateCompositeBufferOptions",
698 ),
699 ("ActionPopupAction", "TsActionPopupAction"),
700 ("Suggestion", "PromptSuggestion"),
701 ("JsTextPropertyEntry", "TextPropertyEntry"),
702 ];
703
704 for (rust_name, ts_name) in &alias_pairs {
705 let rust_decl = get_type_decl(rust_name);
706 let ts_decl = get_type_decl(ts_name);
707 assert!(
708 rust_decl.is_some(),
709 "get_type_decl should handle Rust name '{}'",
710 rust_name
711 );
712 assert_eq!(
713 rust_decl, ts_decl,
714 "Alias '{}' and '{}' should produce identical declarations",
715 rust_name, ts_name
716 );
717 }
718 }
719
720 #[test]
721 fn test_terminal_types_exist() {
722 let terminal_result = get_type_decl("TerminalResult");
723 assert!(
724 terminal_result.is_some(),
725 "TerminalResult should be defined"
726 );
727 let decl = terminal_result.unwrap();
728 assert!(
729 decl.contains("bufferId"),
730 "TerminalResult should have bufferId field"
731 );
732 assert!(
733 decl.contains("terminalId"),
734 "TerminalResult should have terminalId field"
735 );
736 assert!(
737 decl.contains("splitId"),
738 "TerminalResult should have splitId field"
739 );
740
741 let terminal_opts = get_type_decl("CreateTerminalOptions");
742 assert!(
743 terminal_opts.is_some(),
744 "CreateTerminalOptions should be defined"
745 );
746 }
747
748 #[test]
749 fn test_cursor_info_type_exists() {
750 let cursor_info = get_type_decl("CursorInfo");
751 assert!(cursor_info.is_some(), "CursorInfo should be defined");
752 let decl = cursor_info.unwrap();
753 assert!(
754 decl.contains("position"),
755 "CursorInfo should have position field"
756 );
757 assert!(
758 decl.contains("selection"),
759 "CursorInfo should have selection field"
760 );
761 }
762
763 #[test]
764 fn test_collect_ts_types_no_duplicates() {
765 let output = collect_ts_types();
766 let lines: Vec<&str> = output.lines().collect();
767
768 let mut declarations = std::collections::HashSet::new();
770 for line in &lines {
771 let trimmed = line.trim();
772 if trimmed.starts_with("type ") && trimmed.contains('=') {
774 let name = trimmed
775 .strip_prefix("type ")
776 .unwrap()
777 .split(|c: char| c == '=' || c.is_whitespace())
778 .next()
779 .unwrap();
780 assert!(
781 declarations.insert(name.to_string()),
782 "Duplicate type declaration found: '{}'",
783 name
784 );
785 }
786 }
787 }
788
789 #[test]
790 fn test_collect_ts_types_includes_dependency_types() {
791 let output = collect_ts_types();
792 let required_types = [
793 "TextPropertyEntry",
794 "TsCompositeLayoutConfig",
795 "TsCompositeSourceConfig",
796 "TsCompositePaneStyle",
797 "TsCompositeHunk",
798 "TsCreateCompositeBufferOptions",
799 "PromptSuggestion",
800 "BufferInfo",
801 "CursorInfo",
802 "TerminalResult",
803 "CreateTerminalOptions",
804 ];
805
806 for type_name in &required_types {
807 assert!(
808 output.contains(type_name),
809 "collect_ts_types output should contain type '{}'",
810 type_name
811 );
812 }
813 }
814
815 #[test]
816 fn test_generated_dts_validates_as_typescript() {
817 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
818
819 let ts_types = collect_ts_types();
820 let content = format!(
821 "{}\n{}\n{}",
822 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
823 );
824
825 validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
826 }
827
828 #[test]
829 fn test_generated_dts_no_undefined_type_references() {
830 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
831
832 let ts_types = collect_ts_types();
833 let content = format!(
834 "{}\n{}\n{}",
835 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
836 );
837
838 let mut defined_types = std::collections::HashSet::new();
840 for builtin in &[
842 "number",
843 "string",
844 "boolean",
845 "void",
846 "unknown",
847 "null",
848 "undefined",
849 "Record",
850 "Array",
851 "Promise",
852 "ProcessHandle",
853 "PromiseLike",
854 "BufferId",
855 "SplitId",
856 "EditorAPI",
857 ] {
858 defined_types.insert(builtin.to_string());
859 }
860
861 for line in content.lines() {
863 let trimmed = line.trim();
864 if trimmed.starts_with("type ") && trimmed.contains('=') {
865 if let Some(name) = trimmed
866 .strip_prefix("type ")
867 .unwrap()
868 .split(|c: char| c == '=' || c.is_whitespace())
869 .next()
870 {
871 defined_types.insert(name.to_string());
872 }
873 }
874 if trimmed.starts_with("interface ") {
875 if let Some(name) = trimmed
876 .strip_prefix("interface ")
877 .unwrap()
878 .split(|c: char| !c.is_alphanumeric() && c != '_')
879 .next()
880 {
881 defined_types.insert(name.to_string());
882 }
883 }
884 }
885
886 let interface_section = JSEDITORAPI_TS_EDITOR_API;
889 let mut undefined_refs = Vec::new();
890
891 for line in interface_section.lines() {
892 let trimmed = line.trim();
893
894 if trimmed.starts_with('*')
896 || trimmed.starts_with("/*")
897 || trimmed.starts_with("//")
898 || trimmed.is_empty()
899 || trimmed == "{"
900 || trimmed == "}"
901 {
902 continue;
903 }
904
905 for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
907 if word.is_empty() {
908 continue;
909 }
910 if word.chars().next().is_some_and(|c| c.is_uppercase())
912 && !defined_types.contains(word)
913 {
914 undefined_refs.push(word.to_string());
915 }
916 }
917 }
918
919 undefined_refs.sort();
921 undefined_refs.dedup();
922
923 assert!(
924 undefined_refs.is_empty(),
925 "Found undefined type references in EditorAPI interface: {:?}",
926 undefined_refs
927 );
928 }
929
930 #[test]
931 fn test_editor_api_cursor_methods_have_typed_returns() {
932 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
933
934 let api = JSEDITORAPI_TS_EDITOR_API;
935
936 assert!(
938 api.contains("getPrimaryCursor(): CursorInfo | null;"),
939 "getPrimaryCursor should return CursorInfo | null, got: {}",
940 api.lines()
941 .find(|l| l.contains("getPrimaryCursor"))
942 .unwrap_or("not found")
943 );
944
945 assert!(
947 api.contains("getAllCursors(): CursorInfo[];"),
948 "getAllCursors should return CursorInfo[], got: {}",
949 api.lines()
950 .find(|l| l.contains("getAllCursors"))
951 .unwrap_or("not found")
952 );
953
954 assert!(
956 api.contains("getAllCursorPositions(): number[];"),
957 "getAllCursorPositions should return number[], got: {}",
958 api.lines()
959 .find(|l| l.contains("getAllCursorPositions"))
960 .unwrap_or("not found")
961 );
962 }
963
964 #[test]
965 fn test_editor_api_terminal_methods_use_defined_types() {
966 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
967
968 let api = JSEDITORAPI_TS_EDITOR_API;
969
970 assert!(
972 api.contains("CreateTerminalOptions"),
973 "createTerminal should reference CreateTerminalOptions"
974 );
975 assert!(
976 api.contains("TerminalResult"),
977 "createTerminal should reference TerminalResult"
978 );
979 }
980
981 #[test]
982 fn test_editor_api_composite_methods_use_ts_prefix_types() {
983 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
984
985 let api = JSEDITORAPI_TS_EDITOR_API;
986
987 assert!(
989 api.contains("TsCompositeHunk[]"),
990 "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
991 );
992
993 assert!(
995 api.contains("TsCreateCompositeBufferOptions"),
996 "createCompositeBuffer should use TsCreateCompositeBufferOptions"
997 );
998 }
999
1000 #[test]
1001 fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1002 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1003
1004 let api = JSEDITORAPI_TS_EDITOR_API;
1005
1006 assert!(
1008 api.contains("PromptSuggestion[]"),
1009 "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_all_editor_api_methods_present() {
1015 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1016
1017 let api = JSEDITORAPI_TS_EDITOR_API;
1018
1019 let expected_methods = vec![
1021 "apiVersion",
1022 "getActiveBufferId",
1023 "getActiveSplitId",
1024 "listBuffers",
1025 "debug",
1026 "info",
1027 "warn",
1028 "error",
1029 "setStatus",
1030 "copyToClipboard",
1031 "setClipboard",
1032 "registerCommand",
1033 "unregisterCommand",
1034 "setContext",
1035 "executeAction",
1036 "getCursorPosition",
1037 "getBufferPath",
1038 "getBufferLength",
1039 "isBufferModified",
1040 "saveBufferToPath",
1041 "getBufferInfo",
1042 "getPrimaryCursor",
1043 "getAllCursors",
1044 "getAllCursorPositions",
1045 "getViewport",
1046 "getCursorLine",
1047 "getLineStartPosition",
1048 "getLineEndPosition",
1049 "getBufferLineCount",
1050 "scrollToLineCenter",
1051 "findBufferByPath",
1052 "getBufferSavedDiff",
1053 "insertText",
1054 "deleteRange",
1055 "insertAtCursor",
1056 "openFile",
1057 "openFileInSplit",
1058 "showBuffer",
1059 "closeBuffer",
1060 "animateArea",
1061 "animateVirtualBuffer",
1062 "cancelAnimation",
1063 "on",
1064 "off",
1065 "getEnv",
1066 "getCwd",
1067 "pathJoin",
1068 "pathDirname",
1069 "pathBasename",
1070 "pathExtname",
1071 "pathIsAbsolute",
1072 "utf8ByteLength",
1073 "fileExists",
1074 "readFile",
1075 "writeFile",
1076 "readDir",
1077 "createDir",
1078 "removePath",
1079 "renamePath",
1080 "copyPath",
1081 "getTempDir",
1082 "getConfig",
1083 "getUserConfig",
1084 "reloadConfig",
1085 "reloadThemes",
1086 "reloadAndApplyTheme",
1087 "registerGrammar",
1088 "registerLanguageConfig",
1089 "registerLspServer",
1090 "reloadGrammars",
1091 "getConfigDir",
1092 "getDataDir",
1093 "getThemesDir",
1094 "applyTheme",
1095 "getThemeSchema",
1096 "getBuiltinThemes",
1097 "getThemeData",
1098 "saveThemeFile",
1099 "themeFileExists",
1100 "deleteTheme",
1101 "fileStat",
1102 "isProcessRunning",
1103 "killProcess",
1104 "pluginTranslate",
1105 "createCompositeBuffer",
1106 "updateCompositeAlignment",
1107 "closeCompositeBuffer",
1108 "flushLayout",
1109 "compositeNextHunk",
1110 "compositePrevHunk",
1111 "getHighlights",
1112 "addOverlay",
1113 "clearNamespace",
1114 "clearAllOverlays",
1115 "clearOverlaysInRange",
1116 "removeOverlay",
1117 "addConceal",
1118 "clearConcealNamespace",
1119 "clearConcealsInRange",
1120 "addSoftBreak",
1121 "clearSoftBreakNamespace",
1122 "clearSoftBreaksInRange",
1123 "submitViewTransform",
1124 "clearViewTransform",
1125 "setLayoutHints",
1126 "setFileExplorerDecorations",
1127 "clearFileExplorerDecorations",
1128 "addVirtualText",
1129 "removeVirtualText",
1130 "removeVirtualTextsByPrefix",
1131 "clearVirtualTexts",
1132 "clearVirtualTextNamespace",
1133 "addVirtualLine",
1134 "prompt",
1135 "startPrompt",
1136 "startPromptWithInitial",
1137 "setPromptSuggestions",
1138 "setPromptInputSync",
1139 "defineMode",
1140 "setEditorMode",
1141 "getEditorMode",
1142 "closeSplit",
1143 "setSplitBuffer",
1144 "focusSplit",
1145 "setSplitScroll",
1146 "setSplitRatio",
1147 "setSplitLabel",
1148 "clearSplitLabel",
1149 "getSplitByLabel",
1150 "distributeSplitsEvenly",
1151 "setBufferCursor",
1152 "setLineIndicator",
1153 "clearLineIndicators",
1154 "setLineNumbers",
1155 "setViewMode",
1156 "setViewState",
1157 "getViewState",
1158 "setGlobalState",
1159 "getGlobalState",
1160 "setLineWrap",
1161 "createScrollSyncGroup",
1162 "setScrollSyncAnchors",
1163 "removeScrollSyncGroup",
1164 "executeActions",
1165 "showActionPopup",
1166 "disableLspForLanguage",
1167 "setLspRootUri",
1168 "getAllDiagnostics",
1169 "getHandlers",
1170 "createVirtualBuffer",
1171 "createVirtualBufferInSplit",
1172 "createVirtualBufferInExistingSplit",
1173 "setVirtualBufferContent",
1174 "getTextPropertiesAtCursor",
1175 "spawnProcess",
1176 "spawnProcessWait",
1177 "spawnHostProcess",
1178 "setAuthority",
1179 "clearAuthority",
1180 "setRemoteIndicatorState",
1181 "clearRemoteIndicatorState",
1182 "getBufferText",
1183 "delay",
1184 "sendLspRequest",
1185 "spawnBackgroundProcess",
1186 "killBackgroundProcess",
1187 "createTerminal",
1188 "sendTerminalInput",
1189 "closeTerminal",
1190 "refreshLines",
1191 "getCurrentLocale",
1192 "loadPlugin",
1193 "unloadPlugin",
1194 "reloadPlugin",
1195 "listPlugins",
1196 ];
1197
1198 let mut missing = Vec::new();
1199 for method in &expected_methods {
1200 let pattern = format!("{}(", method);
1202 if !api.contains(&pattern) {
1203 missing.push(*method);
1204 }
1205 }
1206
1207 assert!(
1208 missing.is_empty(),
1209 "Missing methods in EditorAPI interface: {:?}",
1210 missing
1211 );
1212 }
1213}