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,
26 SearchTakeResult, SpawnResult, SplitSnapshot, TerminalResult, TextPropertiesAtCursor,
27 TokenColor, TsHighlightSpan, ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewportInfo,
28 VirtualBufferResult, WindowInfo,
29};
30use fresh_core::command::Suggestion;
31use fresh_core::file_explorer::FileExplorerDecoration;
32use fresh_core::text_property::InlineOverlay;
33
34fn get_type_decl(type_name: &str) -> Option<String> {
39 let cfg = TsConfig::default();
40 match type_name {
43 "AnimationRect" => Some(AnimationRect::decl(&cfg)),
45 "PluginAnimationEdge" => Some(PluginAnimationEdge::decl(&cfg)),
46 "PluginAnimationKind" => Some(PluginAnimationKind::decl(&cfg)),
47
48 "BufferInfo" => Some(BufferInfo::decl(&cfg)),
50 "WindowInfo" => Some(WindowInfo::decl(&cfg)),
51 "CursorInfo" => Some(CursorInfo::decl(&cfg)),
52 "ViewportInfo" => Some(ViewportInfo::decl(&cfg)),
53 "KeyEventPayload" => Some(KeyEventPayload::decl(&cfg)),
54 "SplitSnapshot" => Some(SplitSnapshot::decl(&cfg)),
55 "ActionSpec" => Some(ActionSpec::decl(&cfg)),
56 "BufferSavedDiff" => Some(BufferSavedDiff::decl(&cfg)),
57 "LayoutHints" => Some(LayoutHints::decl(&cfg)),
58
59 "SpawnResult" => Some(SpawnResult::decl(&cfg)),
61 "BackgroundProcessResult" => Some(BackgroundProcessResult::decl(&cfg)),
62
63 "GrepMatch" => Some(GrepMatch::decl(&cfg)),
65 "ReplaceResult" => Some(ReplaceResult::decl(&cfg)),
66 "SearchTakeResult" => Some(SearchTakeResult::decl(&cfg)),
67 "SearchHandle" => Some(
70 "interface SearchHandle { searchId: number; take(): SearchTakeResult; cancel(): void; }".to_string(),
71 ),
72
73 "TerminalResult" => Some(TerminalResult::decl(&cfg)),
75 "CreateTerminalOptions" => Some(CreateTerminalOptions::decl(&cfg)),
76
77 "TsCompositeLayoutConfig" | "CompositeLayoutConfig" => {
79 Some(CompositeLayoutConfig::decl(&cfg))
80 }
81 "TsCompositeSourceConfig" | "CompositeSourceConfig" => {
82 Some(CompositeSourceConfig::decl(&cfg))
83 }
84 "TsCompositePaneStyle" | "CompositePaneStyle" => Some(CompositePaneStyle::decl(&cfg)),
85 "TsCompositeHunk" | "CompositeHunk" => Some(CompositeHunk::decl(&cfg)),
86 "TsCreateCompositeBufferOptions" | "CreateCompositeBufferOptions" => {
87 Some(CreateCompositeBufferOptions::decl(&cfg))
88 }
89
90 "ViewTokenWireKind" => Some(ViewTokenWireKind::decl(&cfg)),
92 "TokenColor" => Some(TokenColor::decl(&cfg)),
93 "ViewTokenStyle" => Some(ViewTokenStyle::decl(&cfg)),
94 "ViewTokenWire" => Some(ViewTokenWire::decl(&cfg)),
95
96 "TsActionPopupAction" | "ActionPopupAction" => Some(ActionPopupAction::decl(&cfg)),
98 "ActionPopupOptions" => Some(ActionPopupOptions::decl(&cfg)),
99 "TsLspMenuItem" | "LspMenuItem" => Some(fresh_core::api::LspMenuItem::decl(&cfg)),
100 "TsHighlightSpan" => Some(TsHighlightSpan::decl(&cfg)),
101 "FileExplorerDecoration" => Some(FileExplorerDecoration::decl(&cfg)),
102
103 "TextPropertyEntry" | "JsTextPropertyEntry" => Some(JsTextPropertyEntry::decl(&cfg)),
105 "CreateVirtualBufferOptions" => Some(CreateVirtualBufferOptions::decl(&cfg)),
106 "CreateVirtualBufferInSplitOptions" => Some(CreateVirtualBufferInSplitOptions::decl(&cfg)),
107 "CreateVirtualBufferInExistingSplitOptions" => {
108 Some(CreateVirtualBufferInExistingSplitOptions::decl(&cfg))
109 }
110
111 "TextPropertiesAtCursor" => Some(TextPropertiesAtCursor::decl(&cfg)),
113 "VirtualBufferResult" => Some(VirtualBufferResult::decl(&cfg)),
114 "BufferGroupResult" => Some(BufferGroupResult::decl(&cfg)),
115
116 "PromptSuggestion" | "Suggestion" => Some(Suggestion::decl(&cfg)),
118 "DirEntry" => Some(DirEntry::decl(&cfg)),
119
120 "JsDiagnostic" => Some(JsDiagnostic::decl(&cfg)),
122 "JsRange" => Some(JsRange::decl(&cfg)),
123 "JsPosition" => Some(JsPosition::decl(&cfg)),
124
125 "GrammarInfoSnapshot" => Some(GrammarInfoSnapshot::decl(&cfg)),
127
128 "LanguagePackConfig" => Some(LanguagePackConfig::decl(&cfg)),
130 "LspServerPackConfig" => Some(LspServerPackConfig::decl(&cfg)),
131 "ProcessLimitsPackConfig" => Some(ProcessLimitsPackConfig::decl(&cfg)),
132 "FormatterPackConfig" => Some(FormatterPackConfig::decl(&cfg)),
133
134 "OverlayOptions" => Some(OverlayOptions::decl(&cfg)),
136 "OverlayColorSpec" => Some(OverlayColorSpec::decl(&cfg)),
137 "InlineOverlay" => Some(InlineOverlay::decl(&cfg)),
138 "OffsetUnit" => Some(fresh_core::text_property::OffsetUnit::decl(&cfg)),
139 "StyledSegment" => Some(fresh_core::text_property::StyledSegment::decl(&cfg)),
140 "StyledText" => Some(fresh_core::api::StyledText::decl(&cfg)),
141
142 "WidgetSpec" => Some(fresh_core::api::WidgetSpec::decl(&cfg)),
145 "HintEntry" => Some(fresh_core::api::HintEntry::decl(&cfg)),
146 "ButtonKind" => Some(fresh_core::api::ButtonKind::decl(&cfg)),
147 "WidgetAction" => Some(fresh_core::api::WidgetAction::decl(&cfg)),
148 "WidgetMutation" => Some(fresh_core::api::WidgetMutation::decl(&cfg)),
149 "TreeNode" => Some(fresh_core::api::TreeNode::decl(&cfg)),
150
151 "AuthorityPayload" => Some(AUTHORITY_PAYLOAD_DECL.to_string()),
157
158 "RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
164
165 _ => None,
166 }
167}
168
169const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
176
177type AuthoritySpawner =
178 | { kind: "local" }
179 | {
180 kind: "docker-exec";
181 container_id: string;
182 user?: string | null;
183 workspace?: string | null;
184 env?: [string, string][];
185 };
186
187type AuthorityTerminalWrapper =
188 | { kind: "host-shell" }
189 | {
190 kind: "explicit";
191 command: string;
192 args: string[];
193 manages_cwd?: boolean;
194 };
195
196type AuthorityPayload = {
197 filesystem: AuthorityFilesystem;
198 spawner: AuthoritySpawner;
199 terminal_wrapper: AuthorityTerminalWrapper;
200 display_label?: string;
201 /**
202 * Optional host↔remote workspace path mapping. The dev-container
203 * authority sets both roots (editor.getCwd() on host;
204 * remoteWorkspaceFolder on container) so LSP URIs translate at the
205 * host/container boundary. Local and SSH authorities omit it.
206 */
207 path_translation?: PathTranslationSpec;
208};
209type PathTranslationSpec = {
210 host_root: string;
211 remote_root: string;
212};"#;
213
214const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
219 | { kind: "local" }
220 | { kind: "connecting"; label?: string | null }
221 | { kind: "connected"; label?: string | null }
222 | { kind: "failed_attach"; error?: string | null }
223 | { kind: "disconnected"; label?: string | null };"#;
224
225const DEPENDENCY_TYPES: &[&str] = &[
229 "TextPropertyEntry", "TsCompositeLayoutConfig", "TsCompositeSourceConfig", "TsCompositePaneStyle", "TsCompositeHunk", "TsCreateCompositeBufferOptions", "ViewportInfo", "KeyEventPayload", "SplitSnapshot", "LayoutHints", "ViewTokenWire", "ViewTokenWireKind", "TokenColor", "ViewTokenStyle", "PromptSuggestion", "DirEntry", "BufferInfo", "WindowInfo", "JsDiagnostic", "JsRange", "JsPosition", "ActionSpec", "TsActionPopupAction", "ActionPopupOptions", "TsLspMenuItem", "FileExplorerDecoration", "FormatterPackConfig", "ProcessLimitsPackConfig", "TerminalResult", "CreateTerminalOptions", "CursorInfo", "OverlayOptions", "OverlayColorSpec", "InlineOverlay", "OffsetUnit", "StyledSegment", "GrammarInfoSnapshot", "AnimationRect", "PluginAnimationEdge", "PluginAnimationKind", "HintEntry", "ButtonKind", "TreeNode", "WidgetSpec", "WidgetAction", "WidgetMutation", "SearchTakeResult",
278 "SearchHandle",
279];
280
281pub fn collect_ts_types() -> String {
286 use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
287
288 let mut types = Vec::new();
289 let mut included_decls = std::collections::HashSet::new();
292
293 for type_name in DEPENDENCY_TYPES {
295 if let Some(decl) = get_type_decl(type_name) {
296 if included_decls.insert(decl.clone()) {
297 types.push(decl);
298 }
299 }
300 }
301
302 for type_name in JSEDITORAPI_REFERENCED_TYPES {
304 if let Some(decl) = get_type_decl(type_name) {
305 if included_decls.insert(decl.clone()) {
306 types.push(decl);
307 }
308 } else {
309 eprintln!(
311 "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
312 type_name
313 );
314 }
315 }
316
317 types.join("\n\n")
318}
319
320pub fn validate_typescript(source: &str) -> Result<(), String> {
324 let allocator = Allocator::default();
325 let source_type = SourceType::d_ts();
326
327 let parser_ret = Parser::new(&allocator, source, source_type).parse();
328
329 if parser_ret.errors.is_empty() {
330 Ok(())
331 } else {
332 let errors: Vec<String> = parser_ret
333 .errors
334 .iter()
335 .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
336 .collect();
337 Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
338 }
339}
340
341pub fn format_typescript(source: &str) -> String {
346 let allocator = Allocator::default();
347 let source_type = SourceType::d_ts();
348
349 let parser_ret = Parser::new(&allocator, source, source_type).parse();
350
351 if !parser_ret.errors.is_empty() {
352 return source.to_string();
354 }
355
356 Codegen::new().build(&parser_ret.program).code
358}
359
360pub fn write_fresh_dts() -> Result<(), String> {
365 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
366
367 let ts_types = collect_ts_types();
368
369 let plugin_api_trailer = r#"
378
379/**
380 * Typed overload of `editor.getPluginApi`. When the caller passes a
381 * key that some loaded plugin declared in `FreshPluginRegistry`, the
382 * return type is narrowed to that plugin's API. Unknown names fall
383 * through to the untyped `unknown | null` signature.
384 */
385interface EditorAPI {
386 getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
387}
388
389/**
390 * Maps every hook event name to its payload type.
391 *
392 * Payloads match the flat JSON produced by `hook_args_to_json` on the Rust
393 * side (`HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
394 * fields only). The TypeScript types here are derived directly from the Rust
395 * field definitions and must be kept in sync with `fresh-core/src/hooks.rs`.
396 *
397 * `action` in `pre_command`/`post_command` is the serde JSON of the `Action`
398 * enum: unit variants serialize as a plain string (e.g. `"MoveLeft"`),
399 * tuple variants as a single-key object (e.g. `{"InsertChar": "a"}`).
400 */
401interface HookEventMap {
402 // ── lifecycle ────────────────────────────────────────────────────────────
403 editor_initialized: Record<string, never>;
404 plugins_loaded: Record<string, never>;
405 ready: Record<string, never>;
406 focus_gained: Record<string, never>;
407 authority_changed: { label: string };
408
409 // ── buffer lifecycle ─────────────────────────────────────────────────────
410 buffer_activated: { buffer_id: number };
411 buffer_deactivated: { buffer_id: number };
412 buffer_closed: { buffer_id: number };
413
414 // ── file I/O ─────────────────────────────────────────────────────────────
415 before_file_open: { path: string };
416 after_file_open: { path: string; buffer_id: number };
417 before_file_save: { path: string; buffer_id: number };
418 after_file_save: { path: string; buffer_id: number };
419 /**
420 * Fired by the file explorer after a paste/duplicate/etc. mutates
421 * the filesystem without going through a buffer save. Plugins that
422 * surface FS-derived state (git status badges, etc.) should
423 * subscribe in addition to `after_file_save` to refresh on
424 * explorer-driven changes too.
425 */
426 after_file_explorer_change: { path: string };
427
428 // ── text edits ───────────────────────────────────────────────────────────
429 before_insert: { buffer_id: number; position: number; text: string };
430 after_insert: {
431 buffer_id: number;
432 position: number;
433 text: string;
434 affected_start: number;
435 affected_end: number;
436 start_line: number;
437 end_line: number;
438 lines_added: number;
439 };
440 before_delete: { buffer_id: number; start: number; end: number };
441 after_delete: {
442 buffer_id: number;
443 start: number;
444 end: number;
445 deleted_text: string;
446 affected_start: number;
447 deleted_len: number;
448 start_line: number;
449 end_line: number;
450 lines_removed: number;
451 };
452
453 // ── cursor & viewport ────────────────────────────────────────────────────
454 cursor_moved: {
455 buffer_id: number;
456 cursor_id: number;
457 old_position: number;
458 new_position: number;
459 line: number;
460 text_properties: Record<string, unknown>[];
461 };
462 viewport_changed: {
463 split_id: number;
464 buffer_id: number;
465 top_byte: number;
466 top_line: number | null;
467 width: number;
468 height: number;
469 };
470
471 // ── rendering ────────────────────────────────────────────────────────────
472 render_start: { buffer_id: number };
473 render_line: {
474 buffer_id: number;
475 line_number: number;
476 byte_start: number;
477 byte_end: number;
478 content: string;
479 };
480 lines_changed: {
481 buffer_id: number;
482 lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
483 };
484 view_transform_request: {
485 buffer_id: number;
486 split_id: number;
487 viewport_start: number;
488 viewport_end: number;
489 tokens: ViewTokenWire[];
490 cursor_positions: number[];
491 };
492
493 // ── commands ─────────────────────────────────────────────────────────────
494 pre_command: { action: string | Record<string, unknown> };
495 post_command: { action: string | Record<string, unknown> };
496 idle: { milliseconds: number };
497 resize: { width: number; height: number };
498
499 // ── prompts ──────────────────────────────────────────────────────────────
500 prompt_changed: { prompt_type: string; input: string };
501 prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
502 prompt_cancelled: { prompt_type: string; input: string };
503 prompt_selection_changed: { prompt_type: string; selected_index: number };
504
505 // ── mouse ────────────────────────────────────────────────────────────────
506 mouse_click: MouseClickHookArgs;
507 mouse_move: { column: number; row: number; content_x: number; content_y: number };
508 mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
509
510 // ── LSP ──────────────────────────────────────────────────────────────────
511 diagnostics_updated: { uri: string; count: number };
512 lsp_references: {
513 symbol: string;
514 locations: { file: string; line: number; column: number }[];
515 };
516 lsp_server_request: {
517 language: string;
518 method: string;
519 server_command: string;
520 params: string | null;
521 };
522 lsp_server_error: {
523 language: string;
524 server_command: string;
525 error_type: string;
526 message: string;
527 };
528 lsp_status_clicked: {
529 language: string;
530 has_error: boolean;
531 missing_servers: string[];
532 user_dismissed: boolean;
533 };
534
535 // ── UI events ────────────────────────────────────────────────────────────
536 action_popup_result: { popup_id: string; action_id: string };
537 process_output: { process_id: number; data: string };
538 language_changed: { buffer_id: number; language: string };
539 theme_inspect_key: { theme_name: string; key: string };
540 keyboard_shortcuts: { bindings: { key: string; action: string }[] };
541
542 // ── PTY terminals (see crates/fresh-core/src/hooks.rs) ───────────────────
543 terminal_output: { terminal_id: number; last_line: string };
544 terminal_exit: { terminal_id: number; exit_code: number | null };
545
546 // ── filesystem watching (watchPath plugin API) ────────────────────────────
547 path_changed: {
548 handle: number;
549 path: string;
550 /** "modify" | "create" | "delete" | "rename" | "other" */
551 kind: string;
552 };
553
554 // ── editor sessions (Orchestrator; see orchestrator-sessions-design.md) ────────
555 window_created: { id: number; label: string; root: string };
556 window_closed: { id: number };
557 active_window_changed: { previous_id: number | null; active_id: number };
558
559 // ── widget runtime ───────────────────────────────────────────────────────
560 /**
561 * A widget mounted via `editor.mountWidgetPanel` emitted a
562 * semantic event. Fired when the host's hit-test routes a mouse
563 * click to a `Toggle` / `Button` widget node within a mounted
564 * widget panel. See `docs/internal/plugin-widget-library-design.md`.
565 *
566 * Routing is by `panel_id` (matches the id the plugin allocated
567 * at mount time) plus `widget_key` (the stable `key` set on the
568 * widget spec node, or empty when the spec did not assign one).
569 *
570 * `event_type` and `payload` shapes:
571 * * Toggle: `event_type = "toggle"`, `payload = { checked: <new> }`.
572 * * Button: `event_type = "activate"`, `payload = {}`.
573 */
574 widget_event: {
575 panel_id: number;
576 widget_key: string;
577 event_type: string;
578 payload: Record<string, unknown>;
579 };
580}
581
582/**
583 * Typed overloads of `editor.on` / `editor.off`.
584 *
585 * When the event name is a key of `HookEventMap` the handler receives a
586 * fully-typed payload — TypeScript will flag misspelled field accesses at
587 * compile time. Unknown event names fall through to the untyped base
588 * signatures in the EditorAPI interface.
589 *
590 * Both function-value and handler-name forms are supported:
591 *
592 * ```ts
593 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
594 * editor.on("buffer_activated", "myHandler"); // registerHandler("myHandler", fn)
595 * ```
596 */
597interface EditorAPI {
598 on<K extends keyof HookEventMap>(
599 eventName: K,
600 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
601 ): void;
602 on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
603 off<K extends keyof HookEventMap>(
604 eventName: K,
605 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
606 ): void;
607 off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
608 /**
609 * Create a buffer group: multiple panels appearing as one tab.
610 * This is an async runtime binding (not a direct #[qjs] method).
611 */
612 createBufferGroup(
613 name: string,
614 mode: string,
615 layout: unknown,
616 ): Promise<BufferGroupResult>;
617}
618"#;
619
620 let content = format!(
621 "{}\n{}\n{}{}",
622 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
623 );
624
625 validate_typescript(&content)?;
627
628 let formatted = format_typescript(&content);
630
631 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
633 let output_path = std::path::Path::new(&manifest_dir)
634 .parent() .and_then(|p| p.parent()) .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
637 .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
638
639 let should_write = match std::fs::read_to_string(&output_path) {
641 Ok(existing) => existing != formatted,
642 Err(_) => true,
643 };
644
645 if should_write {
646 if let Some(parent) = output_path.parent() {
647 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
648 }
649 std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
650 }
651
652 Ok(())
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
662 #[ignore]
663 fn write_fresh_dts_file() {
664 write_fresh_dts().expect("Failed to write fresh.d.ts");
666 println!("Successfully generated, validated, and formatted fresh.d.ts");
667 }
668
669 #[test]
673 #[ignore]
674 fn type_check_plugins() {
675 let tsc_check = std::process::Command::new("tsc").arg("--version").output();
677
678 match tsc_check {
679 Ok(output) if output.status.success() => {
680 println!(
681 "Found tsc: {}",
682 String::from_utf8_lossy(&output.stdout).trim()
683 );
684 }
685 _ => {
686 println!("tsc not found in PATH, skipping type check test");
687 return;
688 }
689 }
690
691 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
693 let script_path = std::path::Path::new(&manifest_dir)
694 .parent()
695 .and_then(|p| p.parent())
696 .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
697 .expect("Failed to find check-types.sh");
698
699 println!("Running type check script: {}", script_path.display());
700
701 let output = std::process::Command::new("bash")
703 .arg(&script_path)
704 .output()
705 .expect("Failed to run check-types.sh");
706
707 let stdout = String::from_utf8_lossy(&output.stdout);
708 let stderr = String::from_utf8_lossy(&output.stderr);
709
710 println!("stdout:\n{}", stdout);
711 if !stderr.is_empty() {
712 println!("stderr:\n{}", stderr);
713 }
714
715 if stdout.contains("had type errors") || !output.status.success() {
717 panic!(
718 "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
719 );
720 }
721
722 println!("All plugins type check successfully!");
723 }
724
725 #[test]
730 fn test_get_type_decl_returns_all_expected_types() {
731 let expected_types = vec![
732 "BufferInfo",
733 "WindowInfo",
734 "CursorInfo",
735 "ViewportInfo",
736 "KeyEventPayload",
737 "SplitSnapshot",
738 "ActionSpec",
739 "BufferSavedDiff",
740 "LayoutHints",
741 "SpawnResult",
742 "BackgroundProcessResult",
743 "TerminalResult",
744 "CreateTerminalOptions",
745 "TsCompositeLayoutConfig",
746 "TsCompositeSourceConfig",
747 "TsCompositePaneStyle",
748 "TsCompositeHunk",
749 "TsCreateCompositeBufferOptions",
750 "ViewTokenWireKind",
751 "TokenColor",
752 "ViewTokenStyle",
753 "ViewTokenWire",
754 "TsActionPopupAction",
755 "ActionPopupOptions",
756 "TsHighlightSpan",
757 "FileExplorerDecoration",
758 "TextPropertyEntry",
759 "CreateVirtualBufferOptions",
760 "CreateVirtualBufferInSplitOptions",
761 "CreateVirtualBufferInExistingSplitOptions",
762 "TextPropertiesAtCursor",
763 "VirtualBufferResult",
764 "PromptSuggestion",
765 "DirEntry",
766 "JsDiagnostic",
767 "JsRange",
768 "JsPosition",
769 "LanguagePackConfig",
770 "LspServerPackConfig",
771 "ProcessLimitsPackConfig",
772 "FormatterPackConfig",
773 ];
774
775 for type_name in &expected_types {
776 assert!(
777 get_type_decl(type_name).is_some(),
778 "get_type_decl should return a declaration for '{}'",
779 type_name
780 );
781 }
782 }
783
784 #[test]
785 fn test_get_type_decl_aliases_resolve_same() {
786 let alias_pairs = vec![
788 ("CompositeHunk", "TsCompositeHunk"),
789 ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
790 ("CompositeSourceConfig", "TsCompositeSourceConfig"),
791 ("CompositePaneStyle", "TsCompositePaneStyle"),
792 (
793 "CreateCompositeBufferOptions",
794 "TsCreateCompositeBufferOptions",
795 ),
796 ("ActionPopupAction", "TsActionPopupAction"),
797 ("Suggestion", "PromptSuggestion"),
798 ("JsTextPropertyEntry", "TextPropertyEntry"),
799 ];
800
801 for (rust_name, ts_name) in &alias_pairs {
802 let rust_decl = get_type_decl(rust_name);
803 let ts_decl = get_type_decl(ts_name);
804 assert!(
805 rust_decl.is_some(),
806 "get_type_decl should handle Rust name '{}'",
807 rust_name
808 );
809 assert_eq!(
810 rust_decl, ts_decl,
811 "Alias '{}' and '{}' should produce identical declarations",
812 rust_name, ts_name
813 );
814 }
815 }
816
817 #[test]
818 fn test_terminal_types_exist() {
819 let terminal_result = get_type_decl("TerminalResult");
820 assert!(
821 terminal_result.is_some(),
822 "TerminalResult should be defined"
823 );
824 let decl = terminal_result.unwrap();
825 assert!(
826 decl.contains("bufferId"),
827 "TerminalResult should have bufferId field"
828 );
829 assert!(
830 decl.contains("terminalId"),
831 "TerminalResult should have terminalId field"
832 );
833 assert!(
834 decl.contains("splitId"),
835 "TerminalResult should have splitId field"
836 );
837
838 let terminal_opts = get_type_decl("CreateTerminalOptions");
839 assert!(
840 terminal_opts.is_some(),
841 "CreateTerminalOptions should be defined"
842 );
843 }
844
845 #[test]
846 fn test_cursor_info_type_exists() {
847 let cursor_info = get_type_decl("CursorInfo");
848 assert!(cursor_info.is_some(), "CursorInfo should be defined");
849 let decl = cursor_info.unwrap();
850 assert!(
851 decl.contains("position"),
852 "CursorInfo should have position field"
853 );
854 assert!(
855 decl.contains("selection"),
856 "CursorInfo should have selection field"
857 );
858 }
859
860 #[test]
861 fn test_collect_ts_types_no_duplicates() {
862 let output = collect_ts_types();
863 let lines: Vec<&str> = output.lines().collect();
864
865 let mut declarations = std::collections::HashSet::new();
867 for line in &lines {
868 let trimmed = line.trim();
869 if trimmed.starts_with("type ") && trimmed.contains('=') {
871 let name = trimmed
872 .strip_prefix("type ")
873 .unwrap()
874 .split(|c: char| c == '=' || c.is_whitespace())
875 .next()
876 .unwrap();
877 assert!(
878 declarations.insert(name.to_string()),
879 "Duplicate type declaration found: '{}'",
880 name
881 );
882 }
883 }
884 }
885
886 #[test]
887 fn test_collect_ts_types_includes_dependency_types() {
888 let output = collect_ts_types();
889 let required_types = [
890 "TextPropertyEntry",
891 "TsCompositeLayoutConfig",
892 "TsCompositeSourceConfig",
893 "TsCompositePaneStyle",
894 "TsCompositeHunk",
895 "TsCreateCompositeBufferOptions",
896 "PromptSuggestion",
897 "BufferInfo",
898 "CursorInfo",
899 "TerminalResult",
900 "CreateTerminalOptions",
901 ];
902
903 for type_name in &required_types {
904 assert!(
905 output.contains(type_name),
906 "collect_ts_types output should contain type '{}'",
907 type_name
908 );
909 }
910 }
911
912 #[test]
913 fn test_generated_dts_validates_as_typescript() {
914 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
915
916 let ts_types = collect_ts_types();
917 let content = format!(
918 "{}\n{}\n{}",
919 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
920 );
921
922 validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
923 }
924
925 #[test]
926 fn test_generated_dts_no_undefined_type_references() {
927 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
928
929 let ts_types = collect_ts_types();
930 let content = format!(
931 "{}\n{}\n{}",
932 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
933 );
934
935 let mut defined_types = std::collections::HashSet::new();
937 for builtin in &[
939 "number",
940 "string",
941 "boolean",
942 "void",
943 "unknown",
944 "null",
945 "undefined",
946 "Record",
947 "Array",
948 "Promise",
949 "ProcessHandle",
950 "PromiseLike",
951 "BufferId",
952 "SplitId",
953 "EditorAPI",
954 ] {
955 defined_types.insert(builtin.to_string());
956 }
957
958 for line in content.lines() {
960 let trimmed = line.trim();
961 if trimmed.starts_with("type ") && trimmed.contains('=') {
962 if let Some(name) = trimmed
963 .strip_prefix("type ")
964 .unwrap()
965 .split(|c: char| c == '=' || c.is_whitespace())
966 .next()
967 {
968 defined_types.insert(name.to_string());
969 }
970 }
971 if trimmed.starts_with("interface ") {
972 if let Some(name) = trimmed
973 .strip_prefix("interface ")
974 .unwrap()
975 .split(|c: char| !c.is_alphanumeric() && c != '_')
976 .next()
977 {
978 defined_types.insert(name.to_string());
979 }
980 }
981 }
982
983 let interface_section = JSEDITORAPI_TS_EDITOR_API;
986 let mut undefined_refs = Vec::new();
987
988 for line in interface_section.lines() {
989 let trimmed = line.trim();
990
991 if trimmed.starts_with('*')
993 || trimmed.starts_with("/*")
994 || trimmed.starts_with("//")
995 || trimmed.is_empty()
996 || trimmed == "{"
997 || trimmed == "}"
998 {
999 continue;
1000 }
1001
1002 for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
1004 if word.is_empty() {
1005 continue;
1006 }
1007 if word.chars().next().is_some_and(|c| c.is_uppercase())
1009 && !defined_types.contains(word)
1010 {
1011 undefined_refs.push(word.to_string());
1012 }
1013 }
1014 }
1015
1016 undefined_refs.sort();
1018 undefined_refs.dedup();
1019
1020 assert!(
1021 undefined_refs.is_empty(),
1022 "Found undefined type references in EditorAPI interface: {:?}",
1023 undefined_refs
1024 );
1025 }
1026
1027 #[test]
1028 fn test_editor_api_cursor_methods_have_typed_returns() {
1029 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1030
1031 let api = JSEDITORAPI_TS_EDITOR_API;
1032
1033 assert!(
1035 api.contains("getPrimaryCursor(): CursorInfo | null;"),
1036 "getPrimaryCursor should return CursorInfo | null, got: {}",
1037 api.lines()
1038 .find(|l| l.contains("getPrimaryCursor"))
1039 .unwrap_or("not found")
1040 );
1041
1042 assert!(
1044 api.contains("getAllCursors(): CursorInfo[];"),
1045 "getAllCursors should return CursorInfo[], got: {}",
1046 api.lines()
1047 .find(|l| l.contains("getAllCursors"))
1048 .unwrap_or("not found")
1049 );
1050
1051 assert!(
1053 api.contains("getAllCursorPositions(): number[];"),
1054 "getAllCursorPositions should return number[], got: {}",
1055 api.lines()
1056 .find(|l| l.contains("getAllCursorPositions"))
1057 .unwrap_or("not found")
1058 );
1059 }
1060
1061 #[test]
1062 fn test_editor_api_terminal_methods_use_defined_types() {
1063 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1064
1065 let api = JSEDITORAPI_TS_EDITOR_API;
1066
1067 assert!(
1069 api.contains("CreateTerminalOptions"),
1070 "createTerminal should reference CreateTerminalOptions"
1071 );
1072 assert!(
1073 api.contains("TerminalResult"),
1074 "createTerminal should reference TerminalResult"
1075 );
1076 }
1077
1078 #[test]
1079 fn test_editor_api_composite_methods_use_ts_prefix_types() {
1080 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1081
1082 let api = JSEDITORAPI_TS_EDITOR_API;
1083
1084 assert!(
1086 api.contains("TsCompositeHunk[]"),
1087 "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
1088 );
1089
1090 assert!(
1092 api.contains("TsCreateCompositeBufferOptions"),
1093 "createCompositeBuffer should use TsCreateCompositeBufferOptions"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1099 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1100
1101 let api = JSEDITORAPI_TS_EDITOR_API;
1102
1103 assert!(
1105 api.contains("PromptSuggestion[]"),
1106 "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_all_editor_api_methods_present() {
1112 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1113
1114 let api = JSEDITORAPI_TS_EDITOR_API;
1115
1116 let expected_methods = vec![
1118 "apiVersion",
1119 "getActiveBufferId",
1120 "getActiveSplitId",
1121 "listBuffers",
1122 "debug",
1123 "info",
1124 "warn",
1125 "error",
1126 "setStatus",
1127 "copyToClipboard",
1128 "setClipboard",
1129 "registerCommand",
1130 "unregisterCommand",
1131 "setContext",
1132 "executeAction",
1133 "getCursorPosition",
1134 "getBufferPath",
1135 "getBufferLength",
1136 "isBufferModified",
1137 "saveBufferToPath",
1138 "getBufferInfo",
1139 "getPrimaryCursor",
1140 "getAllCursors",
1141 "getAllCursorPositions",
1142 "getViewport",
1143 "getCursorLine",
1144 "getLineStartPosition",
1145 "getLineEndPosition",
1146 "getBufferLineCount",
1147 "scrollToLineCenter",
1148 "findBufferByPath",
1149 "getBufferSavedDiff",
1150 "insertText",
1151 "deleteRange",
1152 "insertAtCursor",
1153 "openFile",
1154 "openFileInSplit",
1155 "showBuffer",
1156 "closeBuffer",
1157 "animateArea",
1158 "animateVirtualBuffer",
1159 "cancelAnimation",
1160 "on",
1161 "off",
1162 "getEnv",
1163 "getCwd",
1164 "pathJoin",
1165 "pathDirname",
1166 "pathBasename",
1167 "pathExtname",
1168 "pathIsAbsolute",
1169 "utf8ByteLength",
1170 "fileExists",
1171 "readFile",
1172 "writeFile",
1173 "readDir",
1174 "createDir",
1175 "removePath",
1176 "renamePath",
1177 "copyPath",
1178 "getTempDir",
1179 "getConfig",
1180 "getUserConfig",
1181 "reloadConfig",
1182 "reloadThemes",
1183 "reloadAndApplyTheme",
1184 "registerGrammar",
1185 "registerLanguageConfig",
1186 "registerLspServer",
1187 "reloadGrammars",
1188 "getConfigDir",
1189 "getDataDir",
1190 "getThemesDir",
1191 "applyTheme",
1192 "getThemeSchema",
1193 "getBuiltinThemes",
1194 "getAllThemes",
1195 "getThemeData",
1196 "saveThemeFile",
1197 "themeFileExists",
1198 "deleteTheme",
1199 "fileStat",
1200 "isProcessRunning",
1201 "killProcess",
1202 "pluginTranslate",
1203 "createCompositeBuffer",
1204 "updateCompositeAlignment",
1205 "closeCompositeBuffer",
1206 "flushLayout",
1207 "compositeNextHunk",
1208 "compositePrevHunk",
1209 "getHighlights",
1210 "addOverlay",
1211 "clearNamespace",
1212 "clearAllOverlays",
1213 "clearOverlaysInRange",
1214 "removeOverlay",
1215 "addConceal",
1216 "clearConcealNamespace",
1217 "clearConcealsInRange",
1218 "addSoftBreak",
1219 "clearSoftBreakNamespace",
1220 "clearSoftBreaksInRange",
1221 "submitViewTransform",
1222 "clearViewTransform",
1223 "setLayoutHints",
1224 "setFileExplorerDecorations",
1225 "clearFileExplorerDecorations",
1226 "addVirtualText",
1227 "removeVirtualText",
1228 "removeVirtualTextsByPrefix",
1229 "clearVirtualTexts",
1230 "clearVirtualTextNamespace",
1231 "addVirtualLine",
1232 "prompt",
1233 "startPrompt",
1234 "startPromptWithInitial",
1235 "setPromptSuggestions",
1236 "setPromptSelectedIndex",
1237 "setPromptInputSync",
1238 "defineMode",
1239 "setEditorMode",
1240 "getEditorMode",
1241 "closeSplit",
1242 "setSplitBuffer",
1243 "focusSplit",
1244 "setSplitScroll",
1245 "setSplitRatio",
1246 "setSplitLabel",
1247 "clearSplitLabel",
1248 "getSplitByLabel",
1249 "distributeSplitsEvenly",
1250 "setBufferCursor",
1251 "setLineIndicator",
1252 "clearLineIndicators",
1253 "setLineNumbers",
1254 "setViewMode",
1255 "setViewState",
1256 "getViewState",
1257 "setGlobalState",
1258 "getGlobalState",
1259 "setLineWrap",
1260 "createScrollSyncGroup",
1261 "setScrollSyncAnchors",
1262 "removeScrollSyncGroup",
1263 "executeActions",
1264 "showActionPopup",
1265 "setLspMenuContributions",
1266 "disableLspForLanguage",
1267 "setLspRootUri",
1268 "getAllDiagnostics",
1269 "getHandlers",
1270 "createVirtualBuffer",
1271 "createVirtualBufferInSplit",
1272 "createVirtualBufferInExistingSplit",
1273 "setVirtualBufferContent",
1274 "getTextPropertiesAtCursor",
1275 "spawnProcess",
1276 "spawnProcessWait",
1277 "spawnHostProcess",
1278 "setAuthority",
1279 "clearAuthority",
1280 "setRemoteIndicatorState",
1281 "clearRemoteIndicatorState",
1282 "getBufferText",
1283 "delay",
1284 "sendLspRequest",
1285 "spawnBackgroundProcess",
1286 "killBackgroundProcess",
1287 "createTerminal",
1288 "sendTerminalInput",
1289 "closeTerminal",
1290 "signalWindow",
1291 "refreshLines",
1292 "getCurrentLocale",
1293 "loadPlugin",
1294 "unloadPlugin",
1295 "reloadPlugin",
1296 "listPlugins",
1297 "mountFloatingWidget",
1298 "updateFloatingWidget",
1299 "unmountFloatingWidget",
1300 ];
1301
1302 let mut missing = Vec::new();
1303 for method in &expected_methods {
1304 let pattern = format!("{}(", method);
1306 if !api.contains(&pattern) {
1307 missing.push(*method);
1308 }
1309 }
1310
1311 assert!(
1312 missing.is_empty(),
1313 "Missing methods in EditorAPI interface: {:?}",
1314 missing
1315 );
1316 }
1317}