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: fade the editor in from black to the target theme. Uses
75// `overrideThemeColors` (in-memory, no disk I/O) for each frame, then
76// calls `applyTheme` at the end to drop the overrides and land cleanly
77// on the saved theme. `editor.delay(ms)` returns a Promise, so an async
78// for-loop is all the timing machinery we need — no setInterval.
79// (async () => {
80// const target = "one-dark";
81// const data = editor.getThemeData(target) as
82// | { editor?: Record<string, [number, number, number]> }
83// | null;
84// const bg = data?.editor?.bg ?? [30, 30, 30];
85// const fg = data?.editor?.fg ?? [220, 220, 220];
86// const frames = 18;
87// const stepMs = 16;
88// const lerp = (a: number, b: number, t: number) =>
89// Math.round(a + (b - a) * t);
90// for (let i = 1; i <= frames; i++) {
91// const t = i / frames;
92// editor.overrideThemeColors({
93// "editor.bg": [lerp(0, bg[0], t), lerp(0, bg[1], t), lerp(0, bg[2], t)],
94// "editor.fg": [lerp(0, fg[0], t), lerp(0, fg[1], t), lerp(0, fg[2], t)],
95// });
96// await editor.delay(stepMs);
97// }
98// editor.applyTheme(target); // drop overrides, settle on the real theme
99// })();
100
101// Example: calmer UI over SSH. setSetting writes to the runtime layer —
102// nothing is persisted to disk, and removing this file is a complete undo.
103// if (editor.getEnv("SSH_TTY")) {
104// editor.setSetting("editor.diagnostics_inline_text", false);
105// editor.setSetting("terminal.mouse", false);
106// }
107
108// Example: host-specific rust-analyzer path.
109// if (editor.getEnv("HOSTNAME") === "my-mac") {
110// editor.registerLspServer("rust", {
111// command: "/opt/homebrew/bin/rust-analyzer",
112// args: [],
113// autoStart: true,
114// initializationOptions: null,
115// processLimits: null,
116// });
117// }
118
119// Example: env-driven profile (fresh invoked as FRESH_PROFILE=writing fresh).
120// if (editor.getEnv("FRESH_PROFILE") === "writing") {
121// editor.setSetting("editor.line_wrap", true);
122// editor.setSetting("editor.wrap_column", 80);
123// }
124
125// Example: configure a plugin once it loads. `plugins_loaded` fires after
126// every registry plugin and init.ts top-level code has run.
127// editor.on("plugins_loaded", () => {
128// const api = editor.getPluginApi("my-plugin");
129// if (api) api.configure({ option: "value" });
130// });
131
132// Example: enable the opt-in Dashboard widgets (weather, GitHub).
133// Both hit the network on every refresh, so the plugin ships with
134// only `git` and `disk` registered by default. The handlers live
135// on the exported plugin API as `builtinHandlers` — pass them to
136// `registerSection` with whatever name you like.
137//
138// editor.on("plugins_loaded", () => {
139// const dash = editor.getPluginApi("dashboard");
140// if (!dash) return;
141// dash.registerSection("weather", dash.builtinHandlers.weather);
142// dash.registerSection("github", dash.builtinHandlers.github);
143// });
144
145// Example: disable the Dashboard's auto-open behaviour on this
146// machine (it will still be available via the "Show Dashboard"
147// command). The same toggle can also be set persistently in
148// config.json at `plugins.dashboard.auto-open`.
149//
150// editor.on("plugins_loaded", () => {
151// const dash = editor.getPluginApi("dashboard");
152// if (dash) dash.setAutoOpen(false);
153// });
154
155// Example: add a custom section to the Dashboard plugin.
156//
157// `editor.getPluginApi("dashboard")` is typed automatically via
158// `types/plugins.d.ts` — no `as` cast needed. Hover over `dash` or
159// `ctx` in your editor to see the full API.
160//
161// editor.on("plugins_loaded", () => {
162// const dash = editor.getPluginApi("dashboard");
163// if (!dash) return;
164// dash.registerSection("todo", async (ctx) => {
165// // Pretend we read a TODO count from somewhere async.
166// const count = 3;
167// if (count === 0) {
168// ctx.kv("status", "inbox zero", "ok");
169// return;
170// }
171// ctx.kv("open", String(count), count > 5 ? "warn" : "value");
172// ctx.text(" " + "see all".padEnd(10), { color: "muted" });
173// ctx.text("open inbox", {
174// color: "accent",
175// bold: true,
176// onClick: () => editor.executeAction("open_inbox"),
177// });
178// ctx.newline();
179// });
180// });
181
182// Example: register a custom Live Grep search backend.
183//
184// The bundled providers (ripgrep → git grep → grep) are picked by
185// priority on each invocation. Higher-priority registrations win;
186// register from init.ts to use a custom indexer or wrapper script.
187//
188// editor.on("plugins_loaded", () => {
189// const liveGrep = editor.getPluginApi("live-grep");
190// if (!liveGrep) return;
191// liveGrep.registerProvider({
192// name: "fff",
193// priority: 100,
194// isAvailable: async () => {
195// try {
196// const r = await editor.spawnProcess("fff", ["--version"], editor.getCwd());
197// return r.exit_code === 0;
198// } catch {
199// return false;
200// }
201// },
202// search: async (query, { cwd, maxResults }) => {
203// const r = await editor.spawnProcess("fff", [query], cwd);
204// // Return GrepMatch[]: { file, line, column, content }
205// return r.stdout.split("\n").filter(Boolean).map((line) => {
206// const [file, lineStr, ...rest] = line.split(":");
207// return {
208// file,
209// line: parseInt(lineStr, 10) || 1,
210// column: 1,
211// content: rest.join(":"),
212// };
213// }).slice(0, maxResults);
214// },
215// });
216// });
217"#;
218
219const INIT_TSCONFIG: &str = r#"{
223 "compilerOptions": {
224 "target": "ES2020",
225 "module": "ES2020",
226 "moduleResolution": "node",
227 "strict": true,
228 "noEmit": true,
229 "skipLibCheck": true,
230 "lib": ["ES2020"],
231 "types": []
232 },
233 "files": ["init.ts", "types/fresh.d.ts", "types/plugins.d.ts"]
234}
235"#;
236
237#[cfg(feature = "embed-plugins")]
242fn embedded_fresh_dts_path() -> Option<PathBuf> {
243 let embedded = crate::services::plugins::embedded::get_embedded_plugins_dir()?;
244 let p = embedded.join("lib").join("fresh.d.ts");
245 p.exists().then_some(p)
246}
247
248#[cfg(not(feature = "embed-plugins"))]
249fn embedded_fresh_dts_path() -> Option<PathBuf> {
250 None
251}
252
253pub fn refresh_types_scaffolding(config_dir: &Path) {
266 let Some(source) = embedded_fresh_dts_path() else {
267 tracing::warn!(
268 "init.ts: embedded fresh.d.ts unavailable; \
269 LSP completions in init.ts will be unavailable"
270 );
271 return;
272 };
273
274 let types_dir = config_dir.join("types");
275 if let Err(e) = std::fs::create_dir_all(&types_dir) {
276 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
277 return;
278 }
279 let dest_dts = types_dir.join("fresh.d.ts");
280 if let Err(e) = std::fs::copy(&source, &dest_dts) {
281 tracing::warn!(
282 "init.ts: failed to copy fresh.d.ts from {} to {}: {e}",
283 source.display(),
284 dest_dts.display()
285 );
286 }
287
288 let tsconfig = config_dir.join("tsconfig.json");
289 if !tsconfig.exists() {
290 if let Err(e) = std::fs::write(&tsconfig, INIT_TSCONFIG) {
291 tracing::warn!("init.ts: failed to write {}: {e}", tsconfig.display());
292 }
293 }
294}
295
296pub fn write_plugin_declarations(config_dir: &Path, declarations: &[(String, String)]) {
305 let types_dir = config_dir.join("types");
306 if let Err(e) = std::fs::create_dir_all(&types_dir) {
307 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
308 return;
309 }
310 let dest = types_dir.join("plugins.d.ts");
311
312 let mut sorted: Vec<&(String, String)> = declarations.iter().collect();
316 sorted.sort_by(|a, b| a.0.cmp(&b.0));
317
318 let mut body = String::new();
319 body.push_str(
320 "// AUTO-GENERATED by fresh — do not edit.\n\
321 //\n\
322 // Aggregate of every loaded plugin's isolated-declarations\n\
323 // emit (oxc). This is what makes `editor.getPluginApi(\"foo\")`\n\
324 // return a typed result in init.ts / downstream plugins —\n\
325 // each plugin that declares `FreshPluginRegistry` here\n\
326 // contributes its augmentation.\n\n",
327 );
328 for (name, dts) in sorted {
329 let trimmed = dts.trim();
330 if trimmed.is_empty() || trimmed == "export {};" {
335 continue;
336 }
337 body.push_str(&format!("// ── {name} ─────────────────────\n"));
338 body.push_str(dts.trim_end());
339 body.push_str("\n\n");
340 }
341
342 if let Err(e) = std::fs::write(&dest, &body) {
343 tracing::warn!("init.ts: failed to write {}: {e}", dest.display());
344 }
345}
346
347pub fn ensure_starter(config_dir: &Path) -> std::io::Result<PathBuf> {
353 let path = init_ts_path(config_dir);
354 if !path.exists() {
355 if let Some(parent) = path.parent() {
356 std::fs::create_dir_all(parent)?;
357 }
358 std::fs::write(&path, STARTER_TEMPLATE)?;
359 }
360 refresh_types_scaffolding(config_dir);
361 Ok(path)
362}
363
364#[derive(Debug)]
366pub enum InitOutcome {
367 NotFound,
369 Disabled,
371 CrashFused { failures: u32 },
373 Loaded,
375 Failed { message: String },
377}
378
379pub fn init_ts_path(config_dir: &Path) -> PathBuf {
381 config_dir.join("init.ts")
382}
383
384fn crashes_path(config_dir: &Path) -> PathBuf {
386 config_dir.join("logs").join("init.crashes")
387}
388
389#[derive(Debug, Default)]
390struct CrashState {
391 count: u32,
392 last_increment_epoch: u64,
393}
394
395impl CrashState {
396 fn load(config_dir: &Path) -> Self {
397 let path = crashes_path(config_dir);
398 let Ok(text) = std::fs::read_to_string(&path) else {
399 return Self::default();
400 };
401 let mut count = 0u32;
402 let mut last = 0u64;
403 for (i, line) in text.lines().enumerate() {
404 let trimmed = line.trim();
405 if trimmed.is_empty() {
406 continue;
407 }
408 match i {
409 0 => count = trimmed.parse().unwrap_or(0),
410 1 => last = trimmed.parse().unwrap_or(0),
411 _ => break,
412 }
413 }
414 Self {
415 count,
416 last_increment_epoch: last,
417 }
418 }
419
420 fn save(&self, config_dir: &Path) -> std::io::Result<()> {
421 let path = crashes_path(config_dir);
422 if let Some(parent) = path.parent() {
423 std::fs::create_dir_all(parent)?;
424 }
425 std::fs::write(
426 &path,
427 format!("{}\n{}\n", self.count, self.last_increment_epoch),
428 )
429 }
430
431 fn clear(config_dir: &Path) {
432 let path = crashes_path(config_dir);
433 if let Err(e) = std::fs::remove_file(&path) {
434 if e.kind() != std::io::ErrorKind::NotFound {
435 tracing::debug!(
436 "init.ts crash-fuse: failed to clear {}: {e}",
437 path.display()
438 );
439 }
440 }
441 }
442}
443
444fn now_epoch_secs() -> u64 {
445 std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .map(|d| d.as_secs())
448 .unwrap_or(0)
449}
450
451fn check_and_increment_fuse(config_dir: &Path) -> Option<u32> {
457 let now = now_epoch_secs();
458 let mut state = CrashState::load(config_dir);
459
460 if state.last_increment_epoch == 0
462 || now.saturating_sub(state.last_increment_epoch) > CRASH_FUSE_WINDOW_SECS
463 {
464 state.count = 0;
465 }
466
467 if state.count >= CRASH_FUSE_THRESHOLD {
468 return Some(state.count);
469 }
470
471 state.count += 1;
472 state.last_increment_epoch = now;
473 if let Err(e) = state.save(config_dir) {
474 tracing::debug!("init.ts crash-fuse: failed to persist counter: {e}");
475 }
476
477 None
478}
479
480pub fn record_success(config_dir: &Path) {
483 CrashState::clear(config_dir);
484}
485
486pub fn read_init_script(config_dir: &Path) -> std::io::Result<Option<String>> {
489 let path = init_ts_path(config_dir);
490 match std::fs::read_to_string(&path) {
491 Ok(s) => Ok(Some(s)),
492 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
493 Err(e) => Err(e),
494 }
495}
496
497pub fn should_skip(enabled: bool) -> bool {
500 !enabled
501}
502
503pub fn describe(outcome: &InitOutcome) -> String {
505 match outcome {
506 InitOutcome::NotFound => String::from("init.ts: not present"),
507 InitOutcome::Disabled => String::from("init.ts: skipped (--no-init / --safe)"),
508 InitOutcome::CrashFused { failures } => format!(
509 "init.ts: skipped after {failures} consecutive failures — fix ~/.config/fresh/init.ts or remove it"
510 ),
511 InitOutcome::Loaded => String::from("init.ts: loaded"),
512 InitOutcome::Failed { message } => format!("init.ts: {message}"),
513 }
514}
515
516pub enum LoadDecision {
519 Skip(InitOutcome),
520 Load { source: String },
521}
522
523pub fn decide_load(config_dir: &Path, enabled: bool) -> LoadDecision {
524 if should_skip(enabled) {
525 return LoadDecision::Skip(InitOutcome::Disabled);
526 }
527 match read_init_script(config_dir) {
528 Ok(None) => LoadDecision::Skip(InitOutcome::NotFound),
529 Err(e) => LoadDecision::Skip(InitOutcome::Failed {
530 message: format!("read failed: {e}"),
531 }),
532 Ok(Some(source)) => {
533 if let Some(failures) = check_and_increment_fuse(config_dir) {
534 LoadDecision::Skip(InitOutcome::CrashFused { failures })
535 } else {
536 LoadDecision::Load { source }
537 }
538 }
539 }
540}
541
542#[derive(Debug)]
544pub struct CheckReport {
545 pub ok: bool,
546 pub diagnostics: Vec<CheckDiagnostic>,
547 pub path: PathBuf,
548}
549
550#[derive(Debug)]
551pub struct CheckDiagnostic {
552 pub severity: CheckSeverity,
553 pub message: String,
554 pub line: u32,
556 pub column: u32,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub enum CheckSeverity {
561 Error,
562 Warning,
563}
564
565#[cfg(not(feature = "plugins"))]
574pub fn check(config_dir: &Path) -> CheckReport {
575 CheckReport {
579 ok: true,
580 diagnostics: Vec::new(),
581 path: init_ts_path(config_dir),
582 }
583}
584
585#[cfg(feature = "plugins")]
586pub fn check(config_dir: &Path) -> CheckReport {
587 use oxc_allocator::Allocator;
588 use oxc_parser::Parser;
589 use oxc_span::SourceType;
590
591 let path = init_ts_path(config_dir);
592
593 let source = match std::fs::read_to_string(&path) {
594 Ok(s) => s,
595 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
596 return CheckReport {
597 ok: true,
598 diagnostics: Vec::new(),
599 path,
600 };
601 }
602 Err(e) => {
603 return CheckReport {
604 ok: false,
605 diagnostics: vec![CheckDiagnostic {
606 severity: CheckSeverity::Error,
607 message: format!("read failed: {e}"),
608 line: 0,
609 column: 0,
610 }],
611 path,
612 };
613 }
614 };
615
616 let allocator = Allocator::default();
617 let source_type = SourceType::from_path(&path).unwrap_or_default();
618 let parser_ret = Parser::new(&allocator, &source, source_type).parse();
619
620 let mut diagnostics = Vec::new();
621 for err in &parser_ret.errors {
622 let (line, column) = err
626 .labels
627 .as_ref()
628 .and_then(|v| v.first())
629 .map(|l| line_col(&source, l.offset()))
630 .unwrap_or((0, 0));
631 diagnostics.push(CheckDiagnostic {
632 severity: CheckSeverity::Error,
633 message: err.message.to_string(),
634 line,
635 column,
636 });
637 }
638
639 CheckReport {
640 ok: parser_ret.errors.is_empty(),
641 diagnostics,
642 path,
643 }
644}
645
646#[cfg(feature = "plugins")]
648fn line_col(source: &str, offset: usize) -> (u32, u32) {
649 let clipped = source.get(..offset).unwrap_or(source);
650 let line = 1 + clipped.bytes().filter(|&b| b == b'\n').count();
651 let col = 1 + clipped
652 .rsplit('\n')
653 .next()
654 .map(|s| s.chars().count())
655 .unwrap_or(0);
656 (line as u32, col as u32)
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use tempfile::TempDir;
663
664 #[test]
665 fn init_ts_path_is_under_config_dir() {
666 let p = init_ts_path(Path::new("/tmp/fresh"));
667 assert_eq!(p, PathBuf::from("/tmp/fresh/init.ts"));
668 }
669
670 #[test]
671 fn crash_fuse_trips_after_threshold_consecutive_failures() {
672 let tmp = TempDir::new().unwrap();
673 let dir = tmp.path();
674
675 for _ in 0..CRASH_FUSE_THRESHOLD {
678 assert!(check_and_increment_fuse(dir).is_none());
679 }
680
681 let tripped = check_and_increment_fuse(dir);
683 assert!(tripped.is_some());
684 assert_eq!(tripped.unwrap(), CRASH_FUSE_THRESHOLD);
685 }
686
687 #[test]
688 fn record_success_resets_the_fuse() {
689 let tmp = TempDir::new().unwrap();
690 let dir = tmp.path();
691
692 for _ in 0..CRASH_FUSE_THRESHOLD {
693 check_and_increment_fuse(dir);
694 }
695 record_success(dir);
696
697 assert!(check_and_increment_fuse(dir).is_none());
699 }
700
701 #[test]
702 fn stale_failures_outside_window_are_ignored() {
703 let tmp = TempDir::new().unwrap();
704 let dir = tmp.path();
705
706 let state = CrashState {
708 count: CRASH_FUSE_THRESHOLD + 5,
709 last_increment_epoch: now_epoch_secs().saturating_sub(CRASH_FUSE_WINDOW_SECS + 1),
710 };
711 state.save(dir).unwrap();
712
713 assert!(check_and_increment_fuse(dir).is_none());
715 }
716
717 #[test]
718 fn decide_load_reports_not_found_when_missing() {
719 let tmp = TempDir::new().unwrap();
720 match decide_load(tmp.path(), true) {
721 LoadDecision::Skip(InitOutcome::NotFound) => {}
722 other => panic!("expected NotFound, got {other:?}"),
723 }
724 }
725
726 #[test]
727 fn decide_load_reports_disabled_when_flag_says_so() {
728 let tmp = TempDir::new().unwrap();
729 std::fs::write(init_ts_path(tmp.path()), "// hi").unwrap();
730 match decide_load(tmp.path(), false) {
731 LoadDecision::Skip(InitOutcome::Disabled) => {}
732 other => panic!("expected Disabled, got {other:?}"),
733 }
734 }
735
736 #[test]
737 fn decide_load_returns_source_when_file_present_and_enabled() {
738 let tmp = TempDir::new().unwrap();
739 std::fs::write(init_ts_path(tmp.path()), "const x = 1;").unwrap();
740 match decide_load(tmp.path(), true) {
741 LoadDecision::Load { source } => assert_eq!(source, "const x = 1;"),
742 other => panic!("expected Load, got {other:?}"),
743 }
744 }
745
746 impl std::fmt::Debug for LoadDecision {
748 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749 match self {
750 LoadDecision::Skip(o) => write!(f, "Skip({o:?})"),
751 LoadDecision::Load { source } => write!(f, "Load({} chars)", source.len()),
752 }
753 }
754 }
755
756 #[test]
757 fn check_no_file_is_ok() {
758 let tmp = TempDir::new().unwrap();
759 let report = check(tmp.path());
760 assert!(report.ok);
761 assert!(report.diagnostics.is_empty());
762 }
763
764 #[test]
765 fn check_clean_source_is_ok() {
766 let tmp = TempDir::new().unwrap();
767 std::fs::write(
768 init_ts_path(tmp.path()),
769 "const editor = getEditor();\neditor.setStatus('hi');\n",
770 )
771 .unwrap();
772 let report = check(tmp.path());
773 assert!(report.ok, "diagnostics: {:?}", report.diagnostics);
774 }
775
776 #[test]
777 fn check_syntax_error_reports_a_diagnostic() {
778 let tmp = TempDir::new().unwrap();
779 std::fs::write(init_ts_path(tmp.path()), "function broken(\n").unwrap();
781 let report = check(tmp.path());
782 assert!(!report.ok);
783 assert!(!report.diagnostics.is_empty());
784 assert_eq!(report.diagnostics[0].severity, CheckSeverity::Error);
785 }
786
787 #[test]
788 fn starter_template_references_both_dts_files() {
789 assert!(
790 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/fresh.d.ts" />"#),
791 "starter template must reference fresh.d.ts"
792 );
793 assert!(
794 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/plugins.d.ts" />"#),
795 "starter template must reference plugins.d.ts so plugin APIs are typed"
796 );
797 }
798
799 #[test]
800 fn write_plugin_declarations_skips_empty_export_plugins() {
801 let tmp = TempDir::new().unwrap();
802 let decls = vec![
803 ("noop".to_string(), "export {};\n".to_string()),
804 ("blank".to_string(), "".to_string()),
805 (
806 "dashboard".to_string(),
807 "export type DashboardApi = { foo(): void; };\n\
808 declare global { interface FreshPluginRegistry { dashboard: DashboardApi; } }\n\
809 export {};\n"
810 .to_string(),
811 ),
812 ];
813 write_plugin_declarations(tmp.path(), &decls);
814 let body = std::fs::read_to_string(tmp.path().join("types/plugins.d.ts")).unwrap();
815 assert!(
816 body.contains("// ── dashboard ─"),
817 "dashboard section missing: {body}"
818 );
819 assert!(
820 body.contains("DashboardApi"),
821 "dashboard API missing: {body}"
822 );
823 assert!(
824 !body.contains("// ── noop ─"),
825 "empty-export plugin should not get a section header: {body}"
826 );
827 assert!(
828 !body.contains("// ── blank ─"),
829 "blank-emit plugin should not get a section header: {body}"
830 );
831 }
832}