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