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