1use std::path::{Path, PathBuf};
15
16const CRASH_FUSE_THRESHOLD: u32 = 3;
18const CRASH_FUSE_WINDOW_SECS: u64 = 300;
20pub const INIT_PLUGIN_NAME: &str = "init.ts";
22
23pub const STARTER_TEMPLATE: &str = r#"/// <reference path="./types/fresh.d.ts" />
31/// <reference path="./types/plugins.d.ts" />
32const editor = getEditor();
33
34// Fresh init.ts — decisions that depend on the environment at startup.
35//
36// init.ts is NOT for:
37// - Static preferences (tab size, line numbers, ...) -> Settings UI
38// - Key bindings -> Keybindings editor
39// - Themes you always want -> Theme selector
40// - Reusable features -> A plugin package
41//
42// init.ts IS for things that:
43// - Register code handlers, commands, etc.
44// - Depend on where/how Fresh is starting (host, SSH, $TERM, project, ...)
45// - Would differ across machines or launches
46// - Can't live in a shared config.json without lying to teammates
47//
48// API reference: ~/.config/fresh/types/fresh.d.ts (same as plugins)
49// Commands: Ctrl+P -> "init: Reload", "init: Check"
50// CLI: fresh --cmd init check | fresh --safe | fresh --no-init
51
52// Example: enable vi mode at startup (otherwise off until toggled).
53//
54// editor.on("plugins_loaded", () => {
55// editor.getPluginApi("vi-mode")?.enable();
56// });
57
58// Example: Add a command to select (mark) from current cursor to target line.
59//
60// registerHandler("select_to_line_handler", async function start_review_range() {
61// editor.executeActions([
62// { action: "set_mark", count: 1 },
63// { action: "goto_line", count: 1 },
64// ]);
65// });
66//
67// editor.registerCommand(
68// "select_to_line",
69// "Select from current position to target line",
70// "select_to_line_handler",
71// );
72//
73
74// Example: a saved macro. Record one with "Record macro", then run
75// "Macro: Save to init.ts" to generate a block like this — it re-seeds the
76// register at startup so the macro plays back in a fresh session. Edit the
77// steps freely; each is an ActionSpec the same shape `executeActions` takes.
78//
79// editor.defineMacro("q", [
80// { action: "move_line_start" },
81// { action: "insert_char", args: { char: "-" } },
82// { action: "insert_char", args: { char: " " } },
83// ]);
84//
85// To go further, run "Macro: Promote to command" instead — it generates a
86// registerHandler/registerCommand stub seeded with the same steps that you
87// can extend with loops, conditionals, and the full plugin API.
88
89// Example: fade the editor in from black to the target theme. Uses
90// `overrideThemeColors` (in-memory, no disk I/O) for each frame, then
91// calls `applyTheme` at the end to drop the overrides and land cleanly
92// on the saved theme. `editor.delay(ms)` returns a Promise, so an async
93// for-loop is all the timing machinery we need — no setInterval.
94// (async () => {
95// const target = "one-dark";
96// const data = editor.getThemeData(target) as
97// | { editor?: Record<string, [number, number, number]> }
98// | null;
99// const bg = data?.editor?.bg ?? [30, 30, 30];
100// const fg = data?.editor?.fg ?? [220, 220, 220];
101// const frames = 18;
102// const stepMs = 16;
103// const lerp = (a: number, b: number, t: number) =>
104// Math.round(a + (b - a) * t);
105// for (let i = 1; i <= frames; i++) {
106// const t = i / frames;
107// editor.overrideThemeColors({
108// "editor.bg": [lerp(0, bg[0], t), lerp(0, bg[1], t), lerp(0, bg[2], t)],
109// "editor.fg": [lerp(0, fg[0], t), lerp(0, fg[1], t), lerp(0, fg[2], t)],
110// });
111// await editor.delay(stepMs);
112// }
113// editor.applyTheme(target); // drop overrides, settle on the real theme
114// })();
115
116// Example: calmer UI over SSH. setSetting writes to the runtime layer —
117// nothing is persisted to disk, and removing this file is a complete undo.
118// if (editor.getEnv("SSH_TTY")) {
119// editor.setSetting("editor.diagnostics_inline_text", false);
120// editor.setSetting("terminal.mouse", false);
121// }
122
123// Example: host-specific rust-analyzer path.
124// if (editor.getEnv("HOSTNAME") === "my-mac") {
125// editor.registerLspServer("rust", {
126// command: "/opt/homebrew/bin/rust-analyzer",
127// args: [],
128// autoStart: true,
129// initializationOptions: null,
130// processLimits: null,
131// });
132// }
133
134// Example: env-driven profile (fresh invoked as FRESH_PROFILE=writing fresh).
135// if (editor.getEnv("FRESH_PROFILE") === "writing") {
136// editor.setSetting("editor.line_wrap", true);
137// editor.setSetting("editor.wrap_column", 80);
138// }
139
140// Example: configure a plugin once it loads. `plugins_loaded` fires after
141// every registry plugin and init.ts top-level code has run.
142// editor.on("plugins_loaded", () => {
143// const api = editor.getPluginApi("my-plugin");
144// if (api) api.configure({ option: "value" });
145// });
146
147// Example: enable the opt-in Dashboard widgets (weather, GitHub).
148// Both hit the network on every refresh, so the plugin ships with
149// only `git` and `disk` registered by default. The handlers live
150// on the exported plugin API as `builtinHandlers` — pass them to
151// `registerSection` with whatever name you like.
152//
153// editor.on("plugins_loaded", () => {
154// const dash = editor.getPluginApi("dashboard");
155// if (!dash) return;
156// dash.registerSection("weather", dash.builtinHandlers.weather);
157// dash.registerSection("github", dash.builtinHandlers.github);
158// });
159
160// Example: disable the Dashboard's auto-open behaviour on this
161// machine (it will still be available via the "Show Dashboard"
162// command). The same toggle can also be set persistently in
163// config.json at `plugins.dashboard.auto-open`.
164//
165// editor.on("plugins_loaded", () => {
166// const dash = editor.getPluginApi("dashboard");
167// if (dash) dash.setAutoOpen(false);
168// });
169
170// Example: add a custom section to the Dashboard plugin.
171//
172// `editor.getPluginApi("dashboard")` is typed automatically via
173// `types/plugins.d.ts` — no `as` cast needed. Hover over `dash` or
174// `ctx` in your editor to see the full API.
175//
176// editor.on("plugins_loaded", () => {
177// const dash = editor.getPluginApi("dashboard");
178// if (!dash) return;
179// dash.registerSection("todo", async (ctx) => {
180// // Pretend we read a TODO count from somewhere async.
181// const count = 3;
182// if (count === 0) {
183// ctx.kv("status", "inbox zero", "ok");
184// return;
185// }
186// ctx.kv("open", String(count), count > 5 ? "warn" : "value");
187// ctx.text(" " + "see all".padEnd(10), { color: "muted" });
188// ctx.text("open inbox", {
189// color: "accent",
190// bold: true,
191// onClick: () => editor.executeAction("open_inbox"),
192// });
193// ctx.newline();
194// });
195// });
196
197// Example: register a custom Live Grep search backend.
198//
199// The bundled providers (ripgrep → git grep → grep) are picked by
200// priority on each invocation. Higher-priority registrations win;
201// register from init.ts to use a custom indexer or wrapper script.
202//
203// editor.on("plugins_loaded", () => {
204// const liveGrep = editor.getPluginApi("live-grep");
205// if (!liveGrep) return;
206// liveGrep.registerProvider({
207// name: "fff",
208// priority: 100,
209// isAvailable: async () => {
210// try {
211// const r = await editor.spawnProcess("fff", ["--version"], editor.getCwd());
212// return r.exit_code === 0;
213// } catch {
214// return false;
215// }
216// },
217// search: async (query, { cwd, maxResults }) => {
218// const r = await editor.spawnProcess("fff", [query], cwd);
219// // Return GrepMatch[]: { file, line, column, content }
220// return r.stdout.split("\n").filter(Boolean).map((line) => {
221// const [file, lineStr, ...rest] = line.split(":");
222// return {
223// file,
224// line: parseInt(lineStr, 10) || 1,
225// column: 1,
226// content: rest.join(":"),
227// };
228// }).slice(0, maxResults);
229// },
230// });
231// });
232"#;
233
234const INIT_TSCONFIG: &str = r#"{
238 "compilerOptions": {
239 "target": "ES2020",
240 "module": "ES2020",
241 "moduleResolution": "node",
242 "strict": true,
243 "noEmit": true,
244 "skipLibCheck": true,
245 "lib": ["ES2020"],
246 "types": []
247 },
248 "files": ["init.ts", "types/fresh.d.ts", "types/plugins.d.ts"]
249}
250"#;
251
252#[cfg(feature = "embed-plugins")]
257fn embedded_fresh_dts_path() -> Option<PathBuf> {
258 let embedded = crate::services::plugins::embedded::get_embedded_plugins_dir()?;
259 let p = embedded.join("lib").join("fresh.d.ts");
260 p.exists().then_some(p)
261}
262
263#[cfg(not(feature = "embed-plugins"))]
264fn embedded_fresh_dts_path() -> Option<PathBuf> {
265 None
266}
267
268pub fn refresh_types_scaffolding(config_dir: &Path) {
281 let Some(source) = embedded_fresh_dts_path() else {
282 tracing::warn!(
283 "init.ts: embedded fresh.d.ts unavailable; \
284 LSP completions in init.ts will be unavailable"
285 );
286 return;
287 };
288
289 let types_dir = config_dir.join("types");
290 if let Err(e) = std::fs::create_dir_all(&types_dir) {
291 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
292 return;
293 }
294 let dest_dts = types_dir.join("fresh.d.ts");
295 if let Err(e) = std::fs::copy(&source, &dest_dts) {
296 tracing::warn!(
297 "init.ts: failed to copy fresh.d.ts from {} to {}: {e}",
298 source.display(),
299 dest_dts.display()
300 );
301 }
302
303 let tsconfig = config_dir.join("tsconfig.json");
304 if !tsconfig.exists() {
305 if let Err(e) = std::fs::write(&tsconfig, INIT_TSCONFIG) {
306 tracing::warn!("init.ts: failed to write {}: {e}", tsconfig.display());
307 }
308 }
309}
310
311pub fn write_plugin_declarations(config_dir: &Path, declarations: &[(String, String)]) {
320 let types_dir = config_dir.join("types");
321 if let Err(e) = std::fs::create_dir_all(&types_dir) {
322 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
323 return;
324 }
325 let dest = types_dir.join("plugins.d.ts");
326
327 let mut sorted: Vec<&(String, String)> = declarations.iter().collect();
331 sorted.sort_by(|a, b| a.0.cmp(&b.0));
332
333 let mut body = String::new();
334 body.push_str(
335 "// AUTO-GENERATED by fresh — do not edit.\n\
336 //\n\
337 // Aggregate of every loaded plugin's isolated-declarations\n\
338 // emit (oxc). This is what makes `editor.getPluginApi(\"foo\")`\n\
339 // return a typed result in init.ts / downstream plugins —\n\
340 // each plugin that declares `FreshPluginRegistry` here\n\
341 // contributes its augmentation.\n\n",
342 );
343 for (name, dts) in sorted {
344 let trimmed = dts.trim();
345 if trimmed.is_empty() || trimmed == "export {};" {
350 continue;
351 }
352 body.push_str(&format!("// ── {name} ─────────────────────\n"));
353 body.push_str(dts.trim_end());
354 body.push_str("\n\n");
355 }
356
357 if let Err(e) = std::fs::write(&dest, &body) {
358 tracing::warn!("init.ts: failed to write {}: {e}", dest.display());
359 }
360}
361
362pub fn ensure_starter(config_dir: &Path) -> std::io::Result<PathBuf> {
368 let path = init_ts_path(config_dir);
369 if !path.exists() {
370 if let Some(parent) = path.parent() {
371 std::fs::create_dir_all(parent)?;
372 }
373 std::fs::write(&path, STARTER_TEMPLATE)?;
374 }
375 refresh_types_scaffolding(config_dir);
376 Ok(path)
377}
378
379#[derive(Debug)]
381pub enum InitOutcome {
382 NotFound,
384 Disabled,
386 CrashFused { failures: u32 },
388 Loaded,
390 Failed { message: String },
392}
393
394pub fn init_ts_path(config_dir: &Path) -> PathBuf {
396 config_dir.join("init.ts")
397}
398
399fn crashes_path(config_dir: &Path) -> PathBuf {
401 config_dir.join("logs").join("init.crashes")
402}
403
404#[derive(Debug, Default)]
405struct CrashState {
406 count: u32,
407 last_increment_epoch: u64,
408}
409
410impl CrashState {
411 fn load(config_dir: &Path) -> Self {
412 let path = crashes_path(config_dir);
413 let Ok(text) = std::fs::read_to_string(&path) else {
414 return Self::default();
415 };
416 let mut count = 0u32;
417 let mut last = 0u64;
418 for (i, line) in text.lines().enumerate() {
419 let trimmed = line.trim();
420 if trimmed.is_empty() {
421 continue;
422 }
423 match i {
424 0 => count = trimmed.parse().unwrap_or(0),
425 1 => last = trimmed.parse().unwrap_or(0),
426 _ => break,
427 }
428 }
429 Self {
430 count,
431 last_increment_epoch: last,
432 }
433 }
434
435 fn save(&self, config_dir: &Path) -> std::io::Result<()> {
436 let path = crashes_path(config_dir);
437 if let Some(parent) = path.parent() {
438 std::fs::create_dir_all(parent)?;
439 }
440 std::fs::write(
441 &path,
442 format!("{}\n{}\n", self.count, self.last_increment_epoch),
443 )
444 }
445
446 fn clear(config_dir: &Path) {
447 let path = crashes_path(config_dir);
448 if let Err(e) = std::fs::remove_file(&path) {
449 if e.kind() != std::io::ErrorKind::NotFound {
450 tracing::debug!(
451 "init.ts crash-fuse: failed to clear {}: {e}",
452 path.display()
453 );
454 }
455 }
456 }
457}
458
459fn now_epoch_secs() -> u64 {
460 std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .map(|d| d.as_secs())
463 .unwrap_or(0)
464}
465
466fn check_and_increment_fuse(config_dir: &Path) -> Option<u32> {
472 let now = now_epoch_secs();
473 let mut state = CrashState::load(config_dir);
474
475 if state.last_increment_epoch == 0
477 || now.saturating_sub(state.last_increment_epoch) > CRASH_FUSE_WINDOW_SECS
478 {
479 state.count = 0;
480 }
481
482 if state.count >= CRASH_FUSE_THRESHOLD {
483 return Some(state.count);
484 }
485
486 state.count += 1;
487 state.last_increment_epoch = now;
488 if let Err(e) = state.save(config_dir) {
489 tracing::debug!("init.ts crash-fuse: failed to persist counter: {e}");
490 }
491
492 None
493}
494
495pub fn record_success(config_dir: &Path) {
498 CrashState::clear(config_dir);
499}
500
501pub fn read_init_script(config_dir: &Path) -> std::io::Result<Option<String>> {
504 let path = init_ts_path(config_dir);
505 match std::fs::read_to_string(&path) {
506 Ok(s) => Ok(Some(s)),
507 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
508 Err(e) => Err(e),
509 }
510}
511
512pub fn should_skip(enabled: bool) -> bool {
515 !enabled
516}
517
518pub fn describe(outcome: &InitOutcome) -> String {
520 match outcome {
521 InitOutcome::NotFound => String::from("init.ts: not present"),
522 InitOutcome::Disabled => String::from("init.ts: skipped (--no-init / --safe)"),
523 InitOutcome::CrashFused { failures } => format!(
524 "init.ts: skipped after {failures} consecutive failures — fix ~/.config/fresh/init.ts or remove it"
525 ),
526 InitOutcome::Loaded => String::from("init.ts: loaded"),
527 InitOutcome::Failed { message } => format!("init.ts: {message}"),
528 }
529}
530
531pub enum LoadDecision {
534 Skip(InitOutcome),
535 Load { source: String },
536}
537
538pub fn decide_load(config_dir: &Path, enabled: bool) -> LoadDecision {
539 if should_skip(enabled) {
540 return LoadDecision::Skip(InitOutcome::Disabled);
541 }
542 match read_init_script(config_dir) {
543 Ok(None) => LoadDecision::Skip(InitOutcome::NotFound),
544 Err(e) => LoadDecision::Skip(InitOutcome::Failed {
545 message: format!("read failed: {e}"),
546 }),
547 Ok(Some(source)) => {
548 if let Some(failures) = check_and_increment_fuse(config_dir) {
549 LoadDecision::Skip(InitOutcome::CrashFused { failures })
550 } else {
551 LoadDecision::Load { source }
552 }
553 }
554 }
555}
556
557#[derive(Debug)]
559pub struct CheckReport {
560 pub ok: bool,
561 pub diagnostics: Vec<CheckDiagnostic>,
562 pub path: PathBuf,
563}
564
565#[derive(Debug)]
566pub struct CheckDiagnostic {
567 pub severity: CheckSeverity,
568 pub message: String,
569 pub line: u32,
571 pub column: u32,
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum CheckSeverity {
576 Error,
577 Warning,
578}
579
580#[cfg(not(feature = "plugins"))]
589pub fn check(config_dir: &Path) -> CheckReport {
590 CheckReport {
594 ok: true,
595 diagnostics: Vec::new(),
596 path: init_ts_path(config_dir),
597 }
598}
599
600#[cfg(feature = "plugins")]
601pub fn check(config_dir: &Path) -> CheckReport {
602 use oxc_allocator::Allocator;
603 use oxc_parser::Parser;
604 use oxc_span::SourceType;
605
606 let path = init_ts_path(config_dir);
607
608 let source = match std::fs::read_to_string(&path) {
609 Ok(s) => s,
610 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
611 return CheckReport {
612 ok: true,
613 diagnostics: Vec::new(),
614 path,
615 };
616 }
617 Err(e) => {
618 return CheckReport {
619 ok: false,
620 diagnostics: vec![CheckDiagnostic {
621 severity: CheckSeverity::Error,
622 message: format!("read failed: {e}"),
623 line: 0,
624 column: 0,
625 }],
626 path,
627 };
628 }
629 };
630
631 let allocator = Allocator::default();
632 let source_type = SourceType::from_path(&path).unwrap_or_default();
633 let parser_ret = Parser::new(&allocator, &source, source_type).parse();
634
635 let mut diagnostics = Vec::new();
636 for err in &parser_ret.errors {
637 let (line, column) = err
641 .labels
642 .as_ref()
643 .and_then(|v| v.first())
644 .map(|l| line_col(&source, l.offset()))
645 .unwrap_or((0, 0));
646 diagnostics.push(CheckDiagnostic {
647 severity: CheckSeverity::Error,
648 message: err.message.to_string(),
649 line,
650 column,
651 });
652 }
653
654 CheckReport {
655 ok: parser_ret.errors.is_empty(),
656 diagnostics,
657 path,
658 }
659}
660
661#[cfg(feature = "plugins")]
663fn line_col(source: &str, offset: usize) -> (u32, u32) {
664 let clipped = source.get(..offset).unwrap_or(source);
665 let line = 1 + clipped.bytes().filter(|&b| b == b'\n').count();
666 let col = 1 + clipped
667 .rsplit('\n')
668 .next()
669 .map(|s| s.chars().count())
670 .unwrap_or(0);
671 (line as u32, col as u32)
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use tempfile::TempDir;
678
679 #[test]
680 fn init_ts_path_is_under_config_dir() {
681 let p = init_ts_path(Path::new("/tmp/fresh"));
682 assert_eq!(p, PathBuf::from("/tmp/fresh/init.ts"));
683 }
684
685 #[test]
686 fn crash_fuse_trips_after_threshold_consecutive_failures() {
687 let tmp = TempDir::new().unwrap();
688 let dir = tmp.path();
689
690 for _ in 0..CRASH_FUSE_THRESHOLD {
693 assert!(check_and_increment_fuse(dir).is_none());
694 }
695
696 let tripped = check_and_increment_fuse(dir);
698 assert!(tripped.is_some());
699 assert_eq!(tripped.unwrap(), CRASH_FUSE_THRESHOLD);
700 }
701
702 #[test]
703 fn record_success_resets_the_fuse() {
704 let tmp = TempDir::new().unwrap();
705 let dir = tmp.path();
706
707 for _ in 0..CRASH_FUSE_THRESHOLD {
708 check_and_increment_fuse(dir);
709 }
710 record_success(dir);
711
712 assert!(check_and_increment_fuse(dir).is_none());
714 }
715
716 #[test]
717 fn stale_failures_outside_window_are_ignored() {
718 let tmp = TempDir::new().unwrap();
719 let dir = tmp.path();
720
721 let state = CrashState {
723 count: CRASH_FUSE_THRESHOLD + 5,
724 last_increment_epoch: now_epoch_secs().saturating_sub(CRASH_FUSE_WINDOW_SECS + 1),
725 };
726 state.save(dir).unwrap();
727
728 assert!(check_and_increment_fuse(dir).is_none());
730 }
731
732 #[test]
733 fn decide_load_reports_not_found_when_missing() {
734 let tmp = TempDir::new().unwrap();
735 match decide_load(tmp.path(), true) {
736 LoadDecision::Skip(InitOutcome::NotFound) => {}
737 other => panic!("expected NotFound, got {other:?}"),
738 }
739 }
740
741 #[test]
742 fn decide_load_reports_disabled_when_flag_says_so() {
743 let tmp = TempDir::new().unwrap();
744 std::fs::write(init_ts_path(tmp.path()), "// hi").unwrap();
745 match decide_load(tmp.path(), false) {
746 LoadDecision::Skip(InitOutcome::Disabled) => {}
747 other => panic!("expected Disabled, got {other:?}"),
748 }
749 }
750
751 #[test]
752 fn decide_load_returns_source_when_file_present_and_enabled() {
753 let tmp = TempDir::new().unwrap();
754 std::fs::write(init_ts_path(tmp.path()), "const x = 1;").unwrap();
755 match decide_load(tmp.path(), true) {
756 LoadDecision::Load { source } => assert_eq!(source, "const x = 1;"),
757 other => panic!("expected Load, got {other:?}"),
758 }
759 }
760
761 impl std::fmt::Debug for LoadDecision {
763 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
764 match self {
765 LoadDecision::Skip(o) => write!(f, "Skip({o:?})"),
766 LoadDecision::Load { source } => write!(f, "Load({} chars)", source.len()),
767 }
768 }
769 }
770
771 #[test]
772 fn check_no_file_is_ok() {
773 let tmp = TempDir::new().unwrap();
774 let report = check(tmp.path());
775 assert!(report.ok);
776 assert!(report.diagnostics.is_empty());
777 }
778
779 #[test]
780 fn check_clean_source_is_ok() {
781 let tmp = TempDir::new().unwrap();
782 std::fs::write(
783 init_ts_path(tmp.path()),
784 "const editor = getEditor();\neditor.setStatus('hi');\n",
785 )
786 .unwrap();
787 let report = check(tmp.path());
788 assert!(report.ok, "diagnostics: {:?}", report.diagnostics);
789 }
790
791 #[test]
792 fn check_syntax_error_reports_a_diagnostic() {
793 let tmp = TempDir::new().unwrap();
794 std::fs::write(init_ts_path(tmp.path()), "function broken(\n").unwrap();
796 let report = check(tmp.path());
797 assert!(!report.ok);
798 assert!(!report.diagnostics.is_empty());
799 assert_eq!(report.diagnostics[0].severity, CheckSeverity::Error);
800 }
801
802 #[test]
803 fn starter_template_references_both_dts_files() {
804 assert!(
805 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/fresh.d.ts" />"#),
806 "starter template must reference fresh.d.ts"
807 );
808 assert!(
809 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/plugins.d.ts" />"#),
810 "starter template must reference plugins.d.ts so plugin APIs are typed"
811 );
812 }
813
814 #[test]
815 fn write_plugin_declarations_skips_empty_export_plugins() {
816 let tmp = TempDir::new().unwrap();
817 let decls = vec![
818 ("noop".to_string(), "export {};\n".to_string()),
819 ("blank".to_string(), "".to_string()),
820 (
821 "dashboard".to_string(),
822 "export type DashboardApi = { foo(): void; };\n\
823 declare global { interface FreshPluginRegistry { dashboard: DashboardApi; } }\n\
824 export {};\n"
825 .to_string(),
826 ),
827 ];
828 write_plugin_declarations(tmp.path(), &decls);
829 let body = std::fs::read_to_string(tmp.path().join("types/plugins.d.ts")).unwrap();
830 assert!(
831 body.contains("// ── dashboard ─"),
832 "dashboard section missing: {body}"
833 );
834 assert!(
835 body.contains("DashboardApi"),
836 "dashboard API missing: {body}"
837 );
838 assert!(
839 !body.contains("// ── noop ─"),
840 "empty-export plugin should not get a section header: {body}"
841 );
842 assert!(
843 !body.contains("// ── blank ─"),
844 "blank-emit plugin should not get a section header: {body}"
845 );
846 }
847}