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 "RemoteAgentSpec" => Some(REMOTE_AGENT_SPEC_DECL.to_string()),
171
172 "RemoteIndicatorStatePayload" => Some(REMOTE_INDICATOR_STATE_DECL.to_string()),
178
179 _ => None,
180 }
181}
182
183const AUTHORITY_PAYLOAD_DECL: &str = r#"type AuthorityFilesystem = { kind: "local" };
190
191type AuthoritySpawner =
192 | { kind: "local" }
193 | {
194 kind: "docker-exec";
195 container_id: string;
196 user?: string | null;
197 workspace?: string | null;
198 env?: [string, string][];
199 };
200
201type AuthorityTerminalWrapper =
202 | { kind: "host-shell" }
203 | {
204 kind: "explicit";
205 command: string;
206 args: string[];
207 manages_cwd?: boolean;
208 };
209
210type AuthorityPayload = {
211 filesystem: AuthorityFilesystem;
212 spawner: AuthoritySpawner;
213 terminal_wrapper: AuthorityTerminalWrapper;
214 display_label?: string;
215 /**
216 * Optional host↔remote workspace path mapping. The dev-container
217 * authority sets both roots (editor.getCwd() on host;
218 * remoteWorkspaceFolder on container) so LSP URIs translate at the
219 * host/container boundary. Local and SSH authorities omit it.
220 */
221 path_translation?: PathTranslationSpec;
222};
223type PathTranslationSpec = {
224 host_root: string;
225 remote_root: string;
226};"#;
227
228const REMOTE_AGENT_SPEC_DECL: &str = r#"type RemoteAgentTransport = {
235 kind: "kubectl-exec";
236 /** kubeconfig context to select (`--context`); omit for the current one. */
237 context?: string | null;
238 namespace: string;
239 pod: string;
240 /** Target container in a multi-container pod (`-c`). */
241 container?: string | null;
242 /** Pod-side workspace root the terminal opens in. */
243 workspace?: string | null;
244} | {
245 kind: "ssh";
246 /** Login user. Optional — omit for `host` / `ssh://host`, letting ssh pick
247 * the user from its own config or the current local user. */
248 user?: string | null;
249 host: string;
250 port?: number | null;
251 identity_file?: string | null;
252 /** Remote directory to root the session at. */
253 remote_path?: string | null;
254 /** Extra `ssh` arguments (e.g. `-J jump`, `-o ProxyCommand=…`) applied to
255 * every ssh invocation for this session. */
256 extra_args?: string[];
257};
258
259type RemoteAgentSpec = {
260 transport: RemoteAgentTransport;
261 /**
262 * Captured in-pod env (PATH/HOME/LANG/…) applied to LSP spawns and
263 * binary-presence probes. Omit when no probe was run.
264 */
265 base_env?: [string, string][];
266 /**
267 * When true, attach as a NEW window (born-attached, coexisting with the
268 * existing windows) instead of the default global restart that replaces the
269 * whole editor's authority. The Orchestrator sets this so a cloud session is
270 * a real session row beside local ones.
271 */
272 window?: boolean;
273 /** Window label (window mode only). Omit to use the transport's display. */
274 label?: string;
275 /** Optional agent argv for the new window's seed terminal (window mode). */
276 command?: string[];
277};"#;
278
279const REMOTE_INDICATOR_STATE_DECL: &str = r#"type RemoteIndicatorStatePayload =
284 | { kind: "local" }
285 | { kind: "connecting"; label?: string | null }
286 | { kind: "connected"; label?: string | null }
287 | { kind: "failed_attach"; error?: string | null }
288 | { kind: "disconnected"; label?: string | null };"#;
289
290const DEPENDENCY_TYPES: &[&str] = &[
294 "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",
346 "SearchHandle",
347 "ReplaceResult",
349];
350
351pub fn collect_ts_types() -> String {
356 use crate::backend::quickjs_backend::JSEDITORAPI_REFERENCED_TYPES;
357
358 let mut types = Vec::new();
359 let mut included_decls = std::collections::HashSet::new();
362
363 for type_name in DEPENDENCY_TYPES {
365 if let Some(decl) = get_type_decl(type_name) {
366 if included_decls.insert(decl.clone()) {
367 types.push(decl);
368 }
369 }
370 }
371
372 for type_name in JSEDITORAPI_REFERENCED_TYPES {
374 if let Some(decl) = get_type_decl(type_name) {
375 if included_decls.insert(decl.clone()) {
376 types.push(decl);
377 }
378 } else {
379 eprintln!(
381 "Warning: Type '{}' is referenced in API but not registered in get_type_decl()",
382 type_name
383 );
384 }
385 }
386
387 types.join("\n\n")
388}
389
390pub fn validate_typescript(source: &str) -> Result<(), String> {
394 let allocator = Allocator::default();
395 let source_type = SourceType::d_ts();
396
397 let parser_ret = Parser::new(&allocator, source, source_type).parse();
398
399 if parser_ret.errors.is_empty() {
400 Ok(())
401 } else {
402 let errors: Vec<String> = parser_ret
403 .errors
404 .iter()
405 .map(|e: &oxc_diagnostics::OxcDiagnostic| e.to_string())
406 .collect();
407 Err(format!("TypeScript parse errors:\n{}", errors.join("\n")))
408 }
409}
410
411pub fn format_typescript(source: &str) -> String {
416 let allocator = Allocator::default();
417 let source_type = SourceType::d_ts();
418
419 let parser_ret = Parser::new(&allocator, source, source_type).parse();
420
421 if !parser_ret.errors.is_empty() {
422 return source.to_string();
424 }
425
426 Codegen::new().build(&parser_ret.program).code
428}
429
430pub fn write_fresh_dts() -> Result<(), String> {
435 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
436
437 let ts_types = collect_ts_types();
438
439 let plugin_api_trailer = r#"
448
449/**
450 * Typed overload of `editor.getPluginApi`. When the caller passes a
451 * key that some loaded plugin declared in `FreshPluginRegistry`, the
452 * return type is narrowed to that plugin's API. Unknown names fall
453 * through to the untyped `unknown | null` signature.
454 */
455interface EditorAPI {
456 getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
457}
458
459/**
460 * Typed overload of `editor.defineConfigEnum`. The macro-generated
461 * signature can't express `<E extends string>` propagating from the
462 * `values` array into the return type, so it's declared here. Use
463 * `as const` on the `values` array to get a literal-union return:
464 *
465 * ```ts
466 * const mode = editor.defineConfigEnum("mode", {
467 * values: ["normal", "insert"] as const,
468 * default: "normal",
469 * });
470 * mode; // typed as "normal" | "insert"
471 * ```
472 *
473 * Typed overload of `editor.getPluginConfig`. Plugins that declared
474 * their fields via `defineConfigX` can pass the shape type explicitly:
475 * `editor.getPluginConfig<{ autoEnable: boolean; ... }>()`. Without
476 * the generic, falls back to `unknown`.
477 */
478interface EditorAPI {
479 defineConfigEnum<E extends string>(
480 name: string,
481 options: { values: readonly E[]; default: NoInfer<E>; description?: string },
482 ): E;
483 getPluginConfig<T = unknown>(): T;
484}
485
486/**
487 * Maps every hook event name to its payload type.
488 *
489 * Payloads match the flat JSON produced by `hook_args_to_json` on the Rust
490 * side (`HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
491 * fields only). The TypeScript types here are derived directly from the Rust
492 * field definitions and must be kept in sync with `fresh-core/src/hooks.rs`.
493 *
494 * `action` in `pre_command`/`post_command` is the serde JSON of the `Action`
495 * enum: unit variants serialize as a plain string (e.g. `"MoveLeft"`),
496 * tuple variants as a single-key object (e.g. `{"InsertChar": "a"}`).
497 */
498interface HookEventMap {
499 // ── lifecycle ────────────────────────────────────────────────────────────
500 editor_initialized: Record<string, never>;
501 plugins_loaded: Record<string, never>;
502 ready: Record<string, never>;
503 focus_gained: Record<string, never>;
504 authority_changed: { label: string };
505
506 // ── buffer lifecycle ─────────────────────────────────────────────────────
507 buffer_activated: { buffer_id: number };
508 buffer_deactivated: { buffer_id: number };
509 buffer_closed: { buffer_id: number };
510
511 // ── file I/O ─────────────────────────────────────────────────────────────
512 before_file_open: { path: string };
513 after_file_open: { path: string; buffer_id: number };
514 before_file_save: { path: string; buffer_id: number };
515 after_file_save: { path: string; buffer_id: number };
516 /**
517 * Fired by the file explorer after a paste/duplicate/etc. mutates
518 * the filesystem without going through a buffer save. Plugins that
519 * surface FS-derived state (git status badges, etc.) should
520 * subscribe in addition to `after_file_save` to refresh on
521 * explorer-driven changes too.
522 */
523 after_file_explorer_change: { path: string };
524
525 // ── text edits ───────────────────────────────────────────────────────────
526 before_insert: { buffer_id: number; position: number; text: string };
527 after_insert: {
528 buffer_id: number;
529 position: number;
530 text: string;
531 affected_start: number;
532 affected_end: number;
533 start_line: number;
534 end_line: number;
535 lines_added: number;
536 };
537 before_delete: { buffer_id: number; start: number; end: number };
538 after_delete: {
539 buffer_id: number;
540 start: number;
541 end: number;
542 deleted_text: string;
543 affected_start: number;
544 deleted_len: number;
545 start_line: number;
546 end_line: number;
547 lines_removed: number;
548 };
549
550 // ── cursor & viewport ────────────────────────────────────────────────────
551 cursor_moved: {
552 buffer_id: number;
553 cursor_id: number;
554 old_position: number;
555 new_position: number;
556 line: number;
557 text_properties: Record<string, unknown>[];
558 };
559 viewport_changed: {
560 split_id: number;
561 buffer_id: number;
562 top_byte: number;
563 top_line: number | null;
564 width: number;
565 height: number;
566 };
567
568 // ── rendering ────────────────────────────────────────────────────────────
569 render_start: { buffer_id: number };
570 render_line: {
571 buffer_id: number;
572 line_number: number;
573 byte_start: number;
574 byte_end: number;
575 content: string;
576 };
577 lines_changed: {
578 buffer_id: number;
579 lines: { line_number: number; byte_start: number; byte_end: number; content: string }[];
580 };
581 view_transform_request: {
582 buffer_id: number;
583 split_id: number;
584 viewport_start: number;
585 viewport_end: number;
586 tokens: ViewTokenWire[];
587 cursor_positions: number[];
588 };
589
590 // ── commands ─────────────────────────────────────────────────────────────
591 pre_command: { action: string | Record<string, unknown> };
592 post_command: { action: string | Record<string, unknown> };
593 idle: { milliseconds: number };
594 resize: { width: number; height: number };
595
596 // ── prompts ──────────────────────────────────────────────────────────────
597 prompt_changed: { prompt_type: string; input: string };
598 prompt_confirmed: { prompt_type: string; input: string; selected_index: number | null };
599 prompt_cancelled: { prompt_type: string; input: string };
600 prompt_selection_changed: { prompt_type: string; selected_index: number };
601
602 // ── mouse ────────────────────────────────────────────────────────────────
603 mouse_click: MouseClickHookArgs;
604 mouse_move: { column: number; row: number; content_x: number; content_y: number };
605 mouse_scroll: { buffer_id: number; delta: number; col: number; row: number };
606
607 // ── LSP ──────────────────────────────────────────────────────────────────
608 diagnostics_updated: { uri: string; count: number };
609 lsp_references: {
610 symbol: string;
611 locations: { file: string; line: number; column: number }[];
612 };
613 lsp_server_request: {
614 language: string;
615 method: string;
616 server_command: string;
617 params: string | null;
618 };
619 lsp_server_error: {
620 language: string;
621 server_command: string;
622 error_type: string;
623 message: string;
624 };
625 lsp_status_clicked: {
626 language: string;
627 has_error: boolean;
628 missing_servers: string[];
629 user_dismissed: boolean;
630 };
631
632 // ── UI events ────────────────────────────────────────────────────────────
633 action_popup_result: { popup_id: string; action_id: string };
634 process_output: { process_id: number; data: string };
635 language_changed: { buffer_id: number; language: string };
636 theme_inspect_key: { theme_name: string; key: string };
637 keyboard_shortcuts: { bindings: { key: string; action: string }[] };
638
639 // ── PTY terminals (see crates/fresh-core/src/hooks.rs) ───────────────────
640 // `window_id` is the editor window owning the terminal (== session id),
641 // so a plugin can attribute output to a session: output from ANY terminal
642 // in the window counts, and it fires on every PTY read (in-place redraws
643 // and carriage-return progress bars register, not just newlines).
644 terminal_output: { terminal_id: number; window_id: number; last_line: string };
645 terminal_exit: { terminal_id: number; window_id: number; exit_code: number | null };
646
647 // ── filesystem watching (watchPath plugin API) ────────────────────────────
648 path_changed: {
649 handle: number;
650 path: string;
651 /** "modify" | "create" | "delete" | "rename" | "other" */
652 kind: string;
653 };
654
655 // ── editor sessions (Orchestrator; see orchestrator-sessions-design.md) ────────
656 window_created: { id: number; label: string; root: string };
657 window_closed: { id: number };
658 active_window_changed: { previous_id: number | null; active_id: number };
659
660 // ── widget runtime ───────────────────────────────────────────────────────
661 /**
662 * A widget mounted via `editor.mountWidgetPanel` emitted a
663 * semantic event. Fired when the host's hit-test routes a mouse
664 * click to a `Toggle` / `Button` widget node within a mounted
665 * widget panel. See `docs/internal/plugin-widget-library-design.md`.
666 *
667 * Routing is by `panel_id` (matches the id the plugin allocated
668 * at mount time) plus `widget_key` (the stable `key` set on the
669 * widget spec node, or empty when the spec did not assign one).
670 *
671 * `event_type` and `payload` shapes:
672 * * Toggle: `event_type = "toggle"`, `payload = { checked: <new> }`.
673 * * Button: `event_type = "activate"`, `payload = {}`.
674 */
675 widget_event: {
676 panel_id: number;
677 widget_key: string;
678 event_type: string;
679 payload: Record<string, unknown>;
680 };
681}
682
683/**
684 * Typed overloads of `editor.on` / `editor.off`.
685 *
686 * When the event name is a key of `HookEventMap` the handler receives a
687 * fully-typed payload — TypeScript will flag misspelled field accesses at
688 * compile time. Unknown event names fall through to the untyped base
689 * signatures in the EditorAPI interface.
690 *
691 * Both function-value and handler-name forms are supported:
692 *
693 * ```ts
694 * editor.on("buffer_activated", (args) => { /* args.buffer_id is number *\/ });
695 * editor.on("buffer_activated", "myHandler"); // registerHandler("myHandler", fn)
696 * ```
697 */
698interface EditorAPI {
699 on<K extends keyof HookEventMap>(
700 eventName: K,
701 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
702 ): void;
703 on<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
704 off<K extends keyof HookEventMap>(
705 eventName: K,
706 handler: (args: HookEventMap[K]) => boolean | void | Promise<boolean | void>,
707 ): void;
708 off<K extends keyof HookEventMap>(eventName: K, handlerName: string): void;
709 /**
710 * Create a buffer group: multiple panels appearing as one tab.
711 * This is an async runtime binding (not a direct #[qjs] method).
712 */
713 createBufferGroup(
714 name: string,
715 mode: string,
716 layout: unknown,
717 ): Promise<BufferGroupResult>;
718}
719"#;
720
721 let content = format!(
722 "{}\n{}\n{}{}",
723 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API, plugin_api_trailer
724 );
725
726 validate_typescript(&content)?;
728
729 let formatted = format_typescript(&content);
731
732 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
734 let output_path = std::path::Path::new(&manifest_dir)
735 .parent() .and_then(|p| p.parent()) .map(|p| p.join("crates/fresh-editor/plugins/lib/fresh.d.ts"))
738 .unwrap_or_else(|| std::path::PathBuf::from("plugins/lib/fresh.d.ts"));
739
740 let should_write = match std::fs::read_to_string(&output_path) {
742 Ok(existing) => existing != formatted,
743 Err(_) => true,
744 };
745
746 if should_write {
747 if let Some(parent) = output_path.parent() {
748 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
749 }
750 std::fs::write(&output_path, &formatted).map_err(|e| e.to_string())?;
751 }
752
753 Ok(())
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 #[test]
763 #[ignore]
764 fn write_fresh_dts_file() {
765 write_fresh_dts().expect("Failed to write fresh.d.ts");
767 println!("Successfully generated, validated, and formatted fresh.d.ts");
768 }
769
770 #[test]
774 #[ignore]
775 fn type_check_plugins() {
776 let tsc_check = std::process::Command::new("tsc").arg("--version").output();
778
779 match tsc_check {
780 Ok(output) if output.status.success() => {
781 println!(
782 "Found tsc: {}",
783 String::from_utf8_lossy(&output.stdout).trim()
784 );
785 }
786 _ => {
787 println!("tsc not found in PATH, skipping type check test");
788 return;
789 }
790 }
791
792 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
794 let script_path = std::path::Path::new(&manifest_dir)
795 .parent()
796 .and_then(|p| p.parent())
797 .map(|p| p.join("crates/fresh-editor/plugins/check-types.sh"))
798 .expect("Failed to find check-types.sh");
799
800 println!("Running type check script: {}", script_path.display());
801
802 let output = std::process::Command::new("bash")
804 .arg(&script_path)
805 .output()
806 .expect("Failed to run check-types.sh");
807
808 let stdout = String::from_utf8_lossy(&output.stdout);
809 let stderr = String::from_utf8_lossy(&output.stderr);
810
811 println!("stdout:\n{}", stdout);
812 if !stderr.is_empty() {
813 println!("stderr:\n{}", stderr);
814 }
815
816 if stdout.contains("had type errors") || !output.status.success() {
818 panic!(
819 "TypeScript type check failed. Run 'crates/fresh-editor/plugins/check-types.sh' to see details."
820 );
821 }
822
823 println!("All plugins type check successfully!");
824 }
825
826 #[test]
831 fn test_get_type_decl_returns_all_expected_types() {
832 let expected_types = vec![
833 "BufferInfo",
834 "WindowInfo",
835 "CursorInfo",
836 "ViewportInfo",
837 "ScreenSize",
838 "KeyEventPayload",
839 "SplitSnapshot",
840 "ActionSpec",
841 "BufferSavedDiff",
842 "LayoutHints",
843 "SpawnResult",
844 "BackgroundProcessResult",
845 "TerminalResult",
846 "CreateTerminalOptions",
847 "CreateWindowWithTerminalOptions",
848 "SessionWithTerminalResult",
849 "TsCompositeLayoutConfig",
850 "TsCompositeSourceConfig",
851 "TsCompositePaneStyle",
852 "TsCompositeHunk",
853 "TsCreateCompositeBufferOptions",
854 "ViewTokenWireKind",
855 "TokenColor",
856 "ViewTokenStyle",
857 "ViewTokenWire",
858 "TsActionPopupAction",
859 "ActionPopupOptions",
860 "TsHighlightSpan",
861 "FileExplorerDecoration",
862 "TextPropertyEntry",
863 "CreateVirtualBufferOptions",
864 "CreateVirtualBufferInSplitOptions",
865 "CreateVirtualBufferInExistingSplitOptions",
866 "TextPropertiesAtCursor",
867 "VirtualBufferResult",
868 "PromptSuggestion",
869 "DirEntry",
870 "JsDiagnostic",
871 "JsRange",
872 "JsPosition",
873 "LanguagePackConfig",
874 "LspServerPackConfig",
875 "ProcessLimitsPackConfig",
876 "FormatterPackConfig",
877 ];
878
879 for type_name in &expected_types {
880 assert!(
881 get_type_decl(type_name).is_some(),
882 "get_type_decl should return a declaration for '{}'",
883 type_name
884 );
885 }
886 }
887
888 #[test]
889 fn test_get_type_decl_aliases_resolve_same() {
890 let alias_pairs = vec![
892 ("CompositeHunk", "TsCompositeHunk"),
893 ("CompositeLayoutConfig", "TsCompositeLayoutConfig"),
894 ("CompositeSourceConfig", "TsCompositeSourceConfig"),
895 ("CompositePaneStyle", "TsCompositePaneStyle"),
896 (
897 "CreateCompositeBufferOptions",
898 "TsCreateCompositeBufferOptions",
899 ),
900 ("ActionPopupAction", "TsActionPopupAction"),
901 ("Suggestion", "PromptSuggestion"),
902 ("JsTextPropertyEntry", "TextPropertyEntry"),
903 ];
904
905 for (rust_name, ts_name) in &alias_pairs {
906 let rust_decl = get_type_decl(rust_name);
907 let ts_decl = get_type_decl(ts_name);
908 assert!(
909 rust_decl.is_some(),
910 "get_type_decl should handle Rust name '{}'",
911 rust_name
912 );
913 assert_eq!(
914 rust_decl, ts_decl,
915 "Alias '{}' and '{}' should produce identical declarations",
916 rust_name, ts_name
917 );
918 }
919 }
920
921 #[test]
922 fn test_terminal_types_exist() {
923 let terminal_result = get_type_decl("TerminalResult");
924 assert!(
925 terminal_result.is_some(),
926 "TerminalResult should be defined"
927 );
928 let decl = terminal_result.unwrap();
929 assert!(
930 decl.contains("bufferId"),
931 "TerminalResult should have bufferId field"
932 );
933 assert!(
934 decl.contains("terminalId"),
935 "TerminalResult should have terminalId field"
936 );
937 assert!(
938 decl.contains("splitId"),
939 "TerminalResult should have splitId field"
940 );
941
942 let terminal_opts = get_type_decl("CreateTerminalOptions");
943 assert!(
944 terminal_opts.is_some(),
945 "CreateTerminalOptions should be defined"
946 );
947 }
948
949 #[test]
950 fn test_cursor_info_type_exists() {
951 let cursor_info = get_type_decl("CursorInfo");
952 assert!(cursor_info.is_some(), "CursorInfo should be defined");
953 let decl = cursor_info.unwrap();
954 assert!(
955 decl.contains("position"),
956 "CursorInfo should have position field"
957 );
958 assert!(
959 decl.contains("selection"),
960 "CursorInfo should have selection field"
961 );
962 }
963
964 #[test]
965 fn test_collect_ts_types_no_duplicates() {
966 let output = collect_ts_types();
967 let lines: Vec<&str> = output.lines().collect();
968
969 let mut declarations = std::collections::HashSet::new();
971 for line in &lines {
972 let trimmed = line.trim();
973 if trimmed.starts_with("type ") && trimmed.contains('=') {
975 let name = trimmed
976 .strip_prefix("type ")
977 .unwrap()
978 .split(|c: char| c == '=' || c.is_whitespace())
979 .next()
980 .unwrap();
981 assert!(
982 declarations.insert(name.to_string()),
983 "Duplicate type declaration found: '{}'",
984 name
985 );
986 }
987 }
988 }
989
990 #[test]
991 fn test_collect_ts_types_includes_dependency_types() {
992 let output = collect_ts_types();
993 let required_types = [
994 "TextPropertyEntry",
995 "TsCompositeLayoutConfig",
996 "TsCompositeSourceConfig",
997 "TsCompositePaneStyle",
998 "TsCompositeHunk",
999 "TsCreateCompositeBufferOptions",
1000 "PromptSuggestion",
1001 "BufferInfo",
1002 "CursorInfo",
1003 "TerminalResult",
1004 "CreateTerminalOptions",
1005 ];
1006
1007 for type_name in &required_types {
1008 assert!(
1009 output.contains(type_name),
1010 "collect_ts_types output should contain type '{}'",
1011 type_name
1012 );
1013 }
1014 }
1015
1016 #[test]
1017 fn test_generated_dts_validates_as_typescript() {
1018 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1019
1020 let ts_types = collect_ts_types();
1021 let content = format!(
1022 "{}\n{}\n{}",
1023 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1024 );
1025
1026 validate_typescript(&content).expect("Generated TypeScript should be syntactically valid");
1027 }
1028
1029 #[test]
1030 fn test_generated_dts_no_undefined_type_references() {
1031 use crate::backend::quickjs_backend::{JSEDITORAPI_TS_EDITOR_API, JSEDITORAPI_TS_PREAMBLE};
1032
1033 let ts_types = collect_ts_types();
1034 let content = format!(
1035 "{}\n{}\n{}",
1036 JSEDITORAPI_TS_PREAMBLE, ts_types, JSEDITORAPI_TS_EDITOR_API
1037 );
1038
1039 let mut defined_types = std::collections::HashSet::new();
1041 for builtin in &[
1043 "number",
1044 "string",
1045 "boolean",
1046 "void",
1047 "unknown",
1048 "null",
1049 "undefined",
1050 "Record",
1051 "Array",
1052 "Promise",
1053 "ProcessHandle",
1054 "PromiseLike",
1055 "BufferId",
1056 "SplitId",
1057 "EditorAPI",
1058 ] {
1059 defined_types.insert(builtin.to_string());
1060 }
1061
1062 for line in content.lines() {
1064 let trimmed = line.trim();
1065 if trimmed.starts_with("type ") && trimmed.contains('=') {
1066 if let Some(name) = trimmed
1067 .strip_prefix("type ")
1068 .unwrap()
1069 .split(|c: char| c == '=' || c.is_whitespace())
1070 .next()
1071 {
1072 defined_types.insert(name.to_string());
1073 }
1074 }
1075 if trimmed.starts_with("interface ") {
1076 if let Some(name) = trimmed
1077 .strip_prefix("interface ")
1078 .unwrap()
1079 .split(|c: char| !c.is_alphanumeric() && c != '_')
1080 .next()
1081 {
1082 defined_types.insert(name.to_string());
1083 }
1084 }
1085 }
1086
1087 let interface_section = JSEDITORAPI_TS_EDITOR_API;
1090 let mut undefined_refs = Vec::new();
1091
1092 for line in interface_section.lines() {
1093 let trimmed = line.trim();
1094
1095 if trimmed.starts_with('*')
1097 || trimmed.starts_with("/*")
1098 || trimmed.starts_with("//")
1099 || trimmed.is_empty()
1100 || trimmed == "{"
1101 || trimmed == "}"
1102 {
1103 continue;
1104 }
1105
1106 for word in trimmed.split(|c: char| !c.is_alphanumeric() && c != '_') {
1108 if word.is_empty() {
1109 continue;
1110 }
1111 if word.chars().next().is_some_and(|c| c.is_uppercase())
1113 && !defined_types.contains(word)
1114 {
1115 undefined_refs.push(word.to_string());
1116 }
1117 }
1118 }
1119
1120 undefined_refs.sort();
1122 undefined_refs.dedup();
1123
1124 assert!(
1125 undefined_refs.is_empty(),
1126 "Found undefined type references in EditorAPI interface: {:?}",
1127 undefined_refs
1128 );
1129 }
1130
1131 #[test]
1132 fn test_editor_api_cursor_methods_have_typed_returns() {
1133 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1134
1135 let api = JSEDITORAPI_TS_EDITOR_API;
1136
1137 assert!(
1139 api.contains("getPrimaryCursor(): CursorInfo | null;"),
1140 "getPrimaryCursor should return CursorInfo | null, got: {}",
1141 api.lines()
1142 .find(|l| l.contains("getPrimaryCursor"))
1143 .unwrap_or("not found")
1144 );
1145
1146 assert!(
1148 api.contains("getAllCursors(): CursorInfo[];"),
1149 "getAllCursors should return CursorInfo[], got: {}",
1150 api.lines()
1151 .find(|l| l.contains("getAllCursors"))
1152 .unwrap_or("not found")
1153 );
1154
1155 assert!(
1157 api.contains("getAllCursorPositions(): number[];"),
1158 "getAllCursorPositions should return number[], got: {}",
1159 api.lines()
1160 .find(|l| l.contains("getAllCursorPositions"))
1161 .unwrap_or("not found")
1162 );
1163 }
1164
1165 #[test]
1166 fn test_editor_api_terminal_methods_use_defined_types() {
1167 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1168
1169 let api = JSEDITORAPI_TS_EDITOR_API;
1170
1171 assert!(
1173 api.contains("CreateTerminalOptions"),
1174 "createTerminal should reference CreateTerminalOptions"
1175 );
1176 assert!(
1177 api.contains("TerminalResult"),
1178 "createTerminal should reference TerminalResult"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_editor_api_composite_methods_use_ts_prefix_types() {
1184 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1185
1186 let api = JSEDITORAPI_TS_EDITOR_API;
1187
1188 assert!(
1190 api.contains("TsCompositeHunk[]"),
1191 "updateCompositeAlignment should use TsCompositeHunk[], not CompositeHunk[]"
1192 );
1193
1194 assert!(
1196 api.contains("TsCreateCompositeBufferOptions"),
1197 "createCompositeBuffer should use TsCreateCompositeBufferOptions"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_editor_api_prompt_suggestions_use_prompt_suggestion() {
1203 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1204
1205 let api = JSEDITORAPI_TS_EDITOR_API;
1206
1207 assert!(
1209 api.contains("PromptSuggestion[]"),
1210 "setPromptSuggestions should use PromptSuggestion[], not Suggestion[]"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_all_editor_api_methods_present() {
1216 use crate::backend::quickjs_backend::JSEDITORAPI_TS_EDITOR_API;
1217
1218 let api = JSEDITORAPI_TS_EDITOR_API;
1219
1220 let expected_methods = vec![
1222 "apiVersion",
1223 "getActiveBufferId",
1224 "getActiveSplitId",
1225 "listBuffers",
1226 "debug",
1227 "info",
1228 "warn",
1229 "error",
1230 "setStatus",
1231 "copyToClipboard",
1232 "setClipboard",
1233 "registerCommand",
1234 "unregisterCommand",
1235 "setContext",
1236 "executeAction",
1237 "cancelPrompt",
1238 "getCursorPosition",
1239 "getBufferPath",
1240 "getBufferLength",
1241 "isBufferModified",
1242 "saveBufferToPath",
1243 "getBufferInfo",
1244 "getPrimaryCursor",
1245 "getAllCursors",
1246 "getAllCursorPositions",
1247 "getViewport",
1248 "getScreenSize",
1249 "getCursorLine",
1250 "getLineStartPosition",
1251 "getLineEndPosition",
1252 "getBufferLineCount",
1253 "scrollToLineCenter",
1254 "findBufferByPath",
1255 "getBufferSavedDiff",
1256 "insertText",
1257 "deleteRange",
1258 "insertAtCursor",
1259 "openFile",
1260 "openFileInSplit",
1261 "showBuffer",
1262 "closeBuffer",
1263 "animateArea",
1264 "animateVirtualBuffer",
1265 "cancelAnimation",
1266 "on",
1267 "off",
1268 "getEnv",
1269 "getCwd",
1270 "pathJoin",
1271 "pathDirname",
1272 "pathBasename",
1273 "pathExtname",
1274 "pathIsAbsolute",
1275 "utf8ByteLength",
1276 "fileExists",
1277 "readFile",
1278 "writeFile",
1279 "readDir",
1280 "createDir",
1281 "removePath",
1282 "renamePath",
1283 "copyPath",
1284 "getTempDir",
1285 "getConfig",
1286 "getUserConfig",
1287 "getPluginConfig",
1288 "defineConfigBoolean",
1289 "defineConfigInteger",
1290 "defineConfigNumber",
1291 "defineConfigString",
1292 "defineConfigStringArray",
1296 "reloadConfig",
1297 "reloadThemes",
1298 "reloadAndApplyTheme",
1299 "registerGrammar",
1300 "registerLanguageConfig",
1301 "registerLspServer",
1302 "reloadGrammars",
1303 "getConfigDir",
1304 "getDataDir",
1305 "getWorkingDataDir",
1306 "getTerminalDir",
1307 "getThemesDir",
1308 "applyTheme",
1309 "getThemeSchema",
1310 "getBuiltinThemes",
1311 "getAllThemes",
1312 "getThemeData",
1313 "saveThemeFile",
1314 "themeFileExists",
1315 "deleteTheme",
1316 "fileStat",
1317 "isProcessRunning",
1318 "killProcess",
1319 "pluginTranslate",
1320 "createCompositeBuffer",
1321 "updateCompositeAlignment",
1322 "closeCompositeBuffer",
1323 "flushLayout",
1324 "compositeNextHunk",
1325 "compositePrevHunk",
1326 "getHighlights",
1327 "addOverlay",
1328 "clearNamespace",
1329 "clearAllOverlays",
1330 "clearOverlaysInRange",
1331 "clearOverlaysInRangeForNamespace",
1332 "removeOverlay",
1333 "addConceal",
1334 "clearConcealNamespace",
1335 "clearConcealsInRange",
1336 "addSoftBreak",
1337 "clearSoftBreakNamespace",
1338 "clearSoftBreaksInRange",
1339 "submitViewTransform",
1340 "clearViewTransform",
1341 "setLayoutHints",
1342 "setFileExplorerDecorations",
1343 "clearFileExplorerDecorations",
1344 "addVirtualText",
1345 "removeVirtualText",
1346 "removeVirtualTextsByPrefix",
1347 "clearVirtualTexts",
1348 "clearVirtualTextNamespace",
1349 "addVirtualLine",
1350 "prompt",
1351 "startPrompt",
1352 "startPromptWithInitial",
1353 "setPromptSuggestions",
1354 "setPromptSelectedIndex",
1355 "setPromptInputSync",
1356 "defineMode",
1357 "setEditorMode",
1358 "getEditorMode",
1359 "closeSplit",
1360 "setSplitBuffer",
1361 "focusSplit",
1362 "setSplitScroll",
1363 "setSplitRatio",
1364 "setSplitLabel",
1365 "clearSplitLabel",
1366 "getSplitByLabel",
1367 "distributeSplitsEvenly",
1368 "setBufferCursor",
1369 "setLineIndicator",
1370 "clearLineIndicators",
1371 "setLineNumbers",
1372 "setViewMode",
1373 "setViewState",
1374 "getViewState",
1375 "setGlobalState",
1376 "getGlobalState",
1377 "setLineWrap",
1378 "createScrollSyncGroup",
1379 "setScrollSyncAnchors",
1380 "removeScrollSyncGroup",
1381 "executeActions",
1382 "showActionPopup",
1383 "setLspMenuContributions",
1384 "disableLspForLanguage",
1385 "setLspRootUri",
1386 "getAllDiagnostics",
1387 "getHandlers",
1388 "createVirtualBuffer",
1389 "createVirtualBufferInSplit",
1390 "createVirtualBufferInExistingSplit",
1391 "setVirtualBufferContent",
1392 "getTextPropertiesAtCursor",
1393 "spawnProcess",
1394 "spawnProcessWait",
1395 "spawnHostProcess",
1396 "httpFetch",
1397 "setAuthority",
1398 "clearAuthority",
1399 "setRemoteIndicatorState",
1400 "clearRemoteIndicatorState",
1401 "getBufferText",
1402 "delay",
1403 "sendLspRequest",
1404 "spawnBackgroundProcess",
1405 "killBackgroundProcess",
1406 "createTerminal",
1407 "sendTerminalInput",
1408 "closeTerminal",
1409 "signalWindow",
1410 "refreshLines",
1411 "getCurrentLocale",
1412 "loadPlugin",
1413 "unloadPlugin",
1414 "reloadPlugin",
1415 "listPlugins",
1416 "mountFloatingWidget",
1417 "updateFloatingWidget",
1418 "unmountFloatingWidget",
1419 "floatingPanelControl",
1420 "setActiveWindowAnimated",
1421 ];
1422
1423 let mut missing = Vec::new();
1424 for method in &expected_methods {
1425 let pattern = format!("{}(", method);
1427 if !api.contains(&pattern) {
1428 missing.push(*method);
1429 }
1430 }
1431
1432 assert!(
1433 missing.is_empty(),
1434 "Missing methods in EditorAPI interface: {:?}",
1435 missing
1436 );
1437 }
1438}