Skip to main content

fresh/
init_script.rs

1//! User init.ts support.
2//!
3//! At startup Fresh reads `~/.config/fresh/init.ts` (if present) and feeds it
4//! through the existing plugin pipeline as a plugin named `init.ts`. This is
5//! the same code path as "Load Plugin from Buffer", so reload, unload, and
6//! per-plugin registration tagging are free.
7//!
8//! Recovery: a lightweight crash fuse at
9//! `~/.config/fresh/logs/init.crashes` counts consecutive init.ts failures
10//! within a rolling window. After N failures the next launch auto-skips
11//! init.ts until the user fixes or removes it. A successful evaluation
12//! clears the counter.
13
14use std::path::{Path, PathBuf};
15
16/// How many consecutive failed attempts trigger auto-skip.
17const CRASH_FUSE_THRESHOLD: u32 = 3;
18/// Rolling window (seconds) beyond which stale failures are ignored.
19const CRASH_FUSE_WINDOW_SECS: u64 = 300;
20/// Plugin name Fresh uses when loading init.ts — stable so hot-reload works.
21pub const INIT_PLUGIN_NAME: &str = "init.ts";
22
23/// Starter content written by `init: Edit init.ts` when the file doesn't
24/// exist yet. Every example is commented out — an empty init() body is
25/// valid and users un-comment what they want.
26///
27/// The comments establish what init.ts is *not* for (static preferences,
28/// keybindings, themes, reusable features) so users don't reach for this
29/// file when another surface is the right tool.
30pub 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
234/// `tsconfig.json` for the user's init.ts. Matches the plugin-dev
235/// workspace (no DOM, no ambient types) so LSP behaviour is consistent
236/// with plugins.
237const 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/// Resolve the path to `fresh.d.ts` inside the embedded-plugins cache.
253/// Only embedded content is used — never an on-disk copy that isn't
254/// guaranteed to match this binary — so the types always track the
255/// running build.
256#[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
268/// Refresh `~/.config/fresh/types/fresh.d.ts` from the embedded copy and
269/// write `tsconfig.json` if it isn't already present.
270///
271/// `fresh.d.ts` is **always overwritten** — it's an auto-generated API
272/// mirror that must track the running binary. Keeping a stale copy in
273/// `~/.config/fresh/types/` would silently hide drift between the API
274/// the user's `init.ts` was written against and the one the running
275/// binary actually exposes. `tsconfig.json` is treated as user-editable
276/// and only written on first run.
277///
278/// Errors are logged but not returned: type scaffolding is best-effort
279/// and must not block opening or loading init.ts.
280pub 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
311/// Write `<config_dir>/types/plugins.d.ts` from the `.d.ts` emit of
312/// each loaded plugin. The editor calls this after scanning every
313/// plugin directory, so by the time `init.ts` is evaluated the
314/// ambient `FreshPluginRegistry` is fully populated and
315/// `editor.getPluginApi("dashboard")` resolves to the typed overload.
316///
317/// Errors are logged but not returned: an empty or stale
318/// `plugins.d.ts` must not block startup.
319pub 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    // Stable order so a re-scan doesn't produce a needlessly
328    // different file (and a noisy diff for users who version-control
329    // their config dir).
330    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        // Script-style plugins with no exports get `export {};` appended
346        // in the parser to force module mode. After isolated-declarations
347        // strips internals, that's all that remains — a per-plugin
348        // section with just `export {};` is pure noise in the aggregate.
349        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
362/// Ensure `~/.config/fresh/init.ts` exists. If absent, writes the starter
363/// template. Also refreshes `types/fresh.d.ts` + `tsconfig.json` so the
364/// template's `/// <reference path=...` directive resolves and
365/// `getEditor()` type-checks in any TS-aware editor.
366/// Returns the (possibly newly-created) `init.ts` path.
367pub 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/// Outcome of [`autoload`].
380#[derive(Debug)]
381pub enum InitOutcome {
382    /// init.ts did not exist; nothing to do.
383    NotFound,
384    /// Skipped because `--no-init` / `--safe` was passed.
385    Disabled,
386    /// Skipped because the crash fuse engaged.
387    CrashFused { failures: u32 },
388    /// Loaded and evaluated successfully.
389    Loaded,
390    /// Evaluation produced an error; the status message has been set.
391    Failed { message: String },
392}
393
394/// Resolve `~/.config/fresh/init.ts`.
395pub fn init_ts_path(config_dir: &Path) -> PathBuf {
396    config_dir.join("init.ts")
397}
398
399/// Resolve the crash-fuse counter file path.
400fn 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
466/// Called before loading init.ts. Returns `Some(failures)` if the fuse has
467/// tripped and init.ts should be skipped; `None` if loading may proceed.
468///
469/// Also increments the counter — if init.ts evaluation succeeds, the caller
470/// must invoke [`record_success`] to reset it.
471fn 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    // Stale entries outside the rolling window: treat as a clean slate.
476    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
495/// Called after init.ts finishes cleanly. Resets the crash-fuse counter so
496/// the next launch starts from zero.
497pub fn record_success(config_dir: &Path) {
498    CrashState::clear(config_dir);
499}
500
501/// Read init.ts from disk. Returns `Ok(None)` when the file simply doesn't
502/// exist.
503pub 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
512/// Decide, without touching disk for the source, whether init.ts loading
513/// should run at all.
514pub fn should_skip(enabled: bool) -> bool {
515    !enabled
516}
517
518/// Human-readable summary for the status bar / logs.
519pub 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
531/// Pre-flight for the caller: check fuse, return either the source to load
532/// or an outcome explaining why we're not loading.
533pub 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/// Result of `fresh --cmd init check`.
558#[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    /// Best-effort: 1-based line number. `0` if the parser didn't surface one.
570    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/// Parse `~/.config/fresh/init.ts` via oxc and report syntax errors.
581///
582/// This is the "parse mode" from the design (§5.1): always-on, low-latency,
583/// catches the mistakes that would otherwise blow up at startup. The
584/// deeper type-check (`tsc --noEmit`) and the scope-discipline lints
585/// (`init/unconditional-preference`, `init/unconditional-plugin-load`)
586/// are deliberately not implemented here — they're strict-mode concerns
587/// that can grow on top of this foundation.
588#[cfg(not(feature = "plugins"))]
589pub fn check(config_dir: &Path) -> CheckReport {
590    // Without `plugins` there is no QuickJS runtime to execute `init.ts`, so
591    // there is nothing to syntax-check and `oxc` is not compiled in. Report a
592    // clean result so callers behave as if the file is absent/valid.
593    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        // oxc errors carry labels/spans but the formatting is embedded in
638        // the miette-style Display impl. Pull the primary message + try to
639        // recover line/column from the start of the first label.
640        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/// Convert a byte offset into a (line, column) pair, 1-based, for display.
662#[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        // Three attempts that never record success — each returns None
691        // (proceed) and bumps the counter.
692        for _ in 0..CRASH_FUSE_THRESHOLD {
693            assert!(check_and_increment_fuse(dir).is_none());
694        }
695
696        // Fourth attempt should be short-circuited.
697        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        // After success, we should have room for another full cycle.
713        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        // Manually plant an old, tripped counter.
722        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        // Next attempt should treat it as fresh: proceed, counter back to 1.
729        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    // Minor: LoadDecision/InitOutcome must be Debug to use in assertions.
762    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        // Missing closing paren — unambiguous parse error.
795        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}