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