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