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