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// - Depend on where/how Fresh is starting (host, SSH, $TERM, project, ...)
44// - Would differ across machines or launches
45// - Can't live in a shared config.json without lying to teammates
46//
47// API reference: ~/.config/fresh/types/fresh.d.ts (same as plugins)
48// Commands: Ctrl+P -> "init: Reload", "init: Check"
49// CLI: fresh --cmd init check | fresh --safe | fresh --no-init
50
51// Example: fade the editor in from black to the target theme. Uses
52// `overrideThemeColors` (in-memory, no disk I/O) for each frame, then
53// calls `applyTheme` at the end to drop the overrides and land cleanly
54// on the saved theme. `editor.delay(ms)` returns a Promise, so an async
55// for-loop is all the timing machinery we need — no setInterval.
56// (async () => {
57// const target = "one-dark";
58// const data = editor.getThemeData(target) as
59// | { editor?: Record<string, [number, number, number]> }
60// | null;
61// const bg = data?.editor?.bg ?? [30, 30, 30];
62// const fg = data?.editor?.fg ?? [220, 220, 220];
63// const frames = 18;
64// const stepMs = 16;
65// const lerp = (a: number, b: number, t: number) =>
66// Math.round(a + (b - a) * t);
67// for (let i = 1; i <= frames; i++) {
68// const t = i / frames;
69// editor.overrideThemeColors({
70// "editor.bg": [lerp(0, bg[0], t), lerp(0, bg[1], t), lerp(0, bg[2], t)],
71// "editor.fg": [lerp(0, fg[0], t), lerp(0, fg[1], t), lerp(0, fg[2], t)],
72// });
73// await editor.delay(stepMs);
74// }
75// editor.applyTheme(target); // drop overrides, settle on the real theme
76// })();
77
78// Example: calmer UI over SSH. setSetting writes to the runtime layer —
79// nothing is persisted to disk, and removing this file is a complete undo.
80// if (editor.getEnv("SSH_TTY")) {
81// editor.setSetting("editor.diagnostics_inline_text", false);
82// editor.setSetting("terminal.mouse", false);
83// }
84
85// Example: host-specific rust-analyzer path.
86// if (editor.getEnv("HOSTNAME") === "my-mac") {
87// editor.registerLspServer("rust", {
88// command: "/opt/homebrew/bin/rust-analyzer",
89// args: [],
90// autoStart: true,
91// initializationOptions: null,
92// processLimits: null,
93// });
94// }
95
96// Example: env-driven profile (fresh invoked as FRESH_PROFILE=writing fresh).
97// if (editor.getEnv("FRESH_PROFILE") === "writing") {
98// editor.setSetting("editor.line_wrap", true);
99// editor.setSetting("editor.wrap_column", 80);
100// }
101
102// Example: configure a plugin once it loads. `plugins_loaded` fires after
103// every registry plugin and init.ts top-level code has run.
104// editor.on("plugins_loaded", () => {
105// const api = editor.getPluginApi("my-plugin");
106// if (api) api.configure({ option: "value" });
107// });
108
109// Example: enable the opt-in Dashboard widgets (weather, GitHub).
110// Both hit the network on every refresh, so the plugin ships with
111// only `git` and `disk` registered by default. The handlers live
112// on the exported plugin API as `builtinHandlers` — pass them to
113// `registerSection` with whatever name you like.
114//
115// editor.on("plugins_loaded", () => {
116// const dash = editor.getPluginApi("dashboard");
117// if (!dash) return;
118// dash.registerSection("weather", dash.builtinHandlers.weather);
119// dash.registerSection("github", dash.builtinHandlers.github);
120// });
121
122// Example: disable the Dashboard's auto-open behaviour on this
123// machine (it will still be available via the "Show Dashboard"
124// command). The same toggle can also be set persistently in
125// config.json at `plugins.dashboard.auto-open`.
126//
127// editor.on("plugins_loaded", () => {
128// const dash = editor.getPluginApi("dashboard");
129// if (dash) dash.setAutoOpen(false);
130// });
131
132// Example: add a custom section to the Dashboard plugin.
133//
134// `editor.getPluginApi("dashboard")` is typed automatically via
135// `types/plugins.d.ts` — no `as` cast needed. Hover over `dash` or
136// `ctx` in your editor to see the full API.
137//
138// editor.on("plugins_loaded", () => {
139// const dash = editor.getPluginApi("dashboard");
140// if (!dash) return;
141// dash.registerSection("todo", async (ctx) => {
142// // Pretend we read a TODO count from somewhere async.
143// const count = 3;
144// if (count === 0) {
145// ctx.kv("status", "inbox zero", "ok");
146// return;
147// }
148// ctx.kv("open", String(count), count > 5 ? "warn" : "value");
149// ctx.text(" " + "see all".padEnd(10), { color: "muted" });
150// ctx.text("open inbox", {
151// color: "accent",
152// bold: true,
153// onClick: () => editor.executeAction("open_inbox"),
154// });
155// ctx.newline();
156// });
157// });
158"#;
159
160const INIT_TSCONFIG: &str = r#"{
164 "compilerOptions": {
165 "target": "ES2020",
166 "module": "ES2020",
167 "moduleResolution": "node",
168 "strict": true,
169 "noEmit": true,
170 "skipLibCheck": true,
171 "lib": ["ES2020"],
172 "types": []
173 },
174 "files": ["init.ts", "types/fresh.d.ts", "types/plugins.d.ts"]
175}
176"#;
177
178#[cfg(feature = "embed-plugins")]
183fn embedded_fresh_dts_path() -> Option<PathBuf> {
184 let embedded = crate::services::plugins::embedded::get_embedded_plugins_dir()?;
185 let p = embedded.join("lib").join("fresh.d.ts");
186 p.exists().then_some(p)
187}
188
189#[cfg(not(feature = "embed-plugins"))]
190fn embedded_fresh_dts_path() -> Option<PathBuf> {
191 None
192}
193
194pub fn refresh_types_scaffolding(config_dir: &Path) {
207 let Some(source) = embedded_fresh_dts_path() else {
208 tracing::warn!(
209 "init.ts: embedded fresh.d.ts unavailable; \
210 LSP completions in init.ts will be unavailable"
211 );
212 return;
213 };
214
215 let types_dir = config_dir.join("types");
216 if let Err(e) = std::fs::create_dir_all(&types_dir) {
217 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
218 return;
219 }
220 let dest_dts = types_dir.join("fresh.d.ts");
221 if let Err(e) = std::fs::copy(&source, &dest_dts) {
222 tracing::warn!(
223 "init.ts: failed to copy fresh.d.ts from {} to {}: {e}",
224 source.display(),
225 dest_dts.display()
226 );
227 }
228
229 let tsconfig = config_dir.join("tsconfig.json");
230 if !tsconfig.exists() {
231 if let Err(e) = std::fs::write(&tsconfig, INIT_TSCONFIG) {
232 tracing::warn!("init.ts: failed to write {}: {e}", tsconfig.display());
233 }
234 }
235}
236
237pub fn write_plugin_declarations(config_dir: &Path, declarations: &[(String, String)]) {
246 let types_dir = config_dir.join("types");
247 if let Err(e) = std::fs::create_dir_all(&types_dir) {
248 tracing::warn!("init.ts: failed to create {}: {e}", types_dir.display());
249 return;
250 }
251 let dest = types_dir.join("plugins.d.ts");
252
253 let mut sorted: Vec<&(String, String)> = declarations.iter().collect();
257 sorted.sort_by(|a, b| a.0.cmp(&b.0));
258
259 let mut body = String::new();
260 body.push_str(
261 "// AUTO-GENERATED by fresh — do not edit.\n\
262 //\n\
263 // Aggregate of every loaded plugin's isolated-declarations\n\
264 // emit (oxc). This is what makes `editor.getPluginApi(\"foo\")`\n\
265 // return a typed result in init.ts / downstream plugins —\n\
266 // each plugin that declares `FreshPluginRegistry` here\n\
267 // contributes its augmentation.\n\n",
268 );
269 for (name, dts) in sorted {
270 let trimmed = dts.trim();
271 if trimmed.is_empty() || trimmed == "export {};" {
276 continue;
277 }
278 body.push_str(&format!("// ── {name} ─────────────────────\n"));
279 body.push_str(dts.trim_end());
280 body.push_str("\n\n");
281 }
282
283 if let Err(e) = std::fs::write(&dest, &body) {
284 tracing::warn!("init.ts: failed to write {}: {e}", dest.display());
285 }
286}
287
288pub fn ensure_starter(config_dir: &Path) -> std::io::Result<PathBuf> {
294 let path = init_ts_path(config_dir);
295 if !path.exists() {
296 if let Some(parent) = path.parent() {
297 std::fs::create_dir_all(parent)?;
298 }
299 std::fs::write(&path, STARTER_TEMPLATE)?;
300 }
301 refresh_types_scaffolding(config_dir);
302 Ok(path)
303}
304
305#[derive(Debug)]
307pub enum InitOutcome {
308 NotFound,
310 Disabled,
312 CrashFused { failures: u32 },
314 Loaded,
316 Failed { message: String },
318}
319
320pub fn init_ts_path(config_dir: &Path) -> PathBuf {
322 config_dir.join("init.ts")
323}
324
325fn crashes_path(config_dir: &Path) -> PathBuf {
327 config_dir.join("logs").join("init.crashes")
328}
329
330#[derive(Debug, Default)]
331struct CrashState {
332 count: u32,
333 last_increment_epoch: u64,
334}
335
336impl CrashState {
337 fn load(config_dir: &Path) -> Self {
338 let path = crashes_path(config_dir);
339 let Ok(text) = std::fs::read_to_string(&path) else {
340 return Self::default();
341 };
342 let mut count = 0u32;
343 let mut last = 0u64;
344 for (i, line) in text.lines().enumerate() {
345 let trimmed = line.trim();
346 if trimmed.is_empty() {
347 continue;
348 }
349 match i {
350 0 => count = trimmed.parse().unwrap_or(0),
351 1 => last = trimmed.parse().unwrap_or(0),
352 _ => break,
353 }
354 }
355 Self {
356 count,
357 last_increment_epoch: last,
358 }
359 }
360
361 fn save(&self, config_dir: &Path) -> std::io::Result<()> {
362 let path = crashes_path(config_dir);
363 if let Some(parent) = path.parent() {
364 std::fs::create_dir_all(parent)?;
365 }
366 std::fs::write(
367 &path,
368 format!("{}\n{}\n", self.count, self.last_increment_epoch),
369 )
370 }
371
372 fn clear(config_dir: &Path) {
373 let path = crashes_path(config_dir);
374 if let Err(e) = std::fs::remove_file(&path) {
375 if e.kind() != std::io::ErrorKind::NotFound {
376 tracing::debug!(
377 "init.ts crash-fuse: failed to clear {}: {e}",
378 path.display()
379 );
380 }
381 }
382 }
383}
384
385fn now_epoch_secs() -> u64 {
386 std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .map(|d| d.as_secs())
389 .unwrap_or(0)
390}
391
392fn check_and_increment_fuse(config_dir: &Path) -> Option<u32> {
398 let now = now_epoch_secs();
399 let mut state = CrashState::load(config_dir);
400
401 if state.last_increment_epoch == 0
403 || now.saturating_sub(state.last_increment_epoch) > CRASH_FUSE_WINDOW_SECS
404 {
405 state.count = 0;
406 }
407
408 if state.count >= CRASH_FUSE_THRESHOLD {
409 return Some(state.count);
410 }
411
412 state.count += 1;
413 state.last_increment_epoch = now;
414 if let Err(e) = state.save(config_dir) {
415 tracing::debug!("init.ts crash-fuse: failed to persist counter: {e}");
416 }
417
418 None
419}
420
421pub fn record_success(config_dir: &Path) {
424 CrashState::clear(config_dir);
425}
426
427pub fn read_init_script(config_dir: &Path) -> std::io::Result<Option<String>> {
430 let path = init_ts_path(config_dir);
431 match std::fs::read_to_string(&path) {
432 Ok(s) => Ok(Some(s)),
433 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
434 Err(e) => Err(e),
435 }
436}
437
438pub fn should_skip(enabled: bool) -> bool {
441 !enabled
442}
443
444pub fn describe(outcome: &InitOutcome) -> String {
446 match outcome {
447 InitOutcome::NotFound => String::from("init.ts: not present"),
448 InitOutcome::Disabled => String::from("init.ts: skipped (--no-init / --safe)"),
449 InitOutcome::CrashFused { failures } => format!(
450 "init.ts: skipped after {failures} consecutive failures — fix ~/.config/fresh/init.ts or remove it"
451 ),
452 InitOutcome::Loaded => String::from("init.ts: loaded"),
453 InitOutcome::Failed { message } => format!("init.ts: {message}"),
454 }
455}
456
457pub enum LoadDecision {
460 Skip(InitOutcome),
461 Load { source: String },
462}
463
464pub fn decide_load(config_dir: &Path, enabled: bool) -> LoadDecision {
465 if should_skip(enabled) {
466 return LoadDecision::Skip(InitOutcome::Disabled);
467 }
468 match read_init_script(config_dir) {
469 Ok(None) => LoadDecision::Skip(InitOutcome::NotFound),
470 Err(e) => LoadDecision::Skip(InitOutcome::Failed {
471 message: format!("read failed: {e}"),
472 }),
473 Ok(Some(source)) => {
474 if let Some(failures) = check_and_increment_fuse(config_dir) {
475 LoadDecision::Skip(InitOutcome::CrashFused { failures })
476 } else {
477 LoadDecision::Load { source }
478 }
479 }
480 }
481}
482
483#[derive(Debug)]
485pub struct CheckReport {
486 pub ok: bool,
487 pub diagnostics: Vec<CheckDiagnostic>,
488 pub path: PathBuf,
489}
490
491#[derive(Debug)]
492pub struct CheckDiagnostic {
493 pub severity: CheckSeverity,
494 pub message: String,
495 pub line: u32,
497 pub column: u32,
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub enum CheckSeverity {
502 Error,
503 Warning,
504}
505
506pub fn check(config_dir: &Path) -> CheckReport {
515 use oxc_allocator::Allocator;
516 use oxc_parser::Parser;
517 use oxc_span::SourceType;
518
519 let path = init_ts_path(config_dir);
520
521 let source = match std::fs::read_to_string(&path) {
522 Ok(s) => s,
523 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
524 return CheckReport {
525 ok: true,
526 diagnostics: Vec::new(),
527 path,
528 };
529 }
530 Err(e) => {
531 return CheckReport {
532 ok: false,
533 diagnostics: vec![CheckDiagnostic {
534 severity: CheckSeverity::Error,
535 message: format!("read failed: {e}"),
536 line: 0,
537 column: 0,
538 }],
539 path,
540 };
541 }
542 };
543
544 let allocator = Allocator::default();
545 let source_type = SourceType::from_path(&path).unwrap_or_default();
546 let parser_ret = Parser::new(&allocator, &source, source_type).parse();
547
548 let mut diagnostics = Vec::new();
549 for err in &parser_ret.errors {
550 let (line, column) = err
554 .labels
555 .as_ref()
556 .and_then(|v| v.first())
557 .map(|l| line_col(&source, l.offset()))
558 .unwrap_or((0, 0));
559 diagnostics.push(CheckDiagnostic {
560 severity: CheckSeverity::Error,
561 message: err.message.to_string(),
562 line,
563 column,
564 });
565 }
566
567 CheckReport {
568 ok: parser_ret.errors.is_empty(),
569 diagnostics,
570 path,
571 }
572}
573
574fn line_col(source: &str, offset: usize) -> (u32, u32) {
576 let clipped = source.get(..offset).unwrap_or(source);
577 let line = 1 + clipped.bytes().filter(|&b| b == b'\n').count();
578 let col = 1 + clipped
579 .rsplit('\n')
580 .next()
581 .map(|s| s.chars().count())
582 .unwrap_or(0);
583 (line as u32, col as u32)
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use tempfile::TempDir;
590
591 #[test]
592 fn init_ts_path_is_under_config_dir() {
593 let p = init_ts_path(Path::new("/tmp/fresh"));
594 assert_eq!(p, PathBuf::from("/tmp/fresh/init.ts"));
595 }
596
597 #[test]
598 fn crash_fuse_trips_after_threshold_consecutive_failures() {
599 let tmp = TempDir::new().unwrap();
600 let dir = tmp.path();
601
602 for _ in 0..CRASH_FUSE_THRESHOLD {
605 assert!(check_and_increment_fuse(dir).is_none());
606 }
607
608 let tripped = check_and_increment_fuse(dir);
610 assert!(tripped.is_some());
611 assert_eq!(tripped.unwrap(), CRASH_FUSE_THRESHOLD);
612 }
613
614 #[test]
615 fn record_success_resets_the_fuse() {
616 let tmp = TempDir::new().unwrap();
617 let dir = tmp.path();
618
619 for _ in 0..CRASH_FUSE_THRESHOLD {
620 check_and_increment_fuse(dir);
621 }
622 record_success(dir);
623
624 assert!(check_and_increment_fuse(dir).is_none());
626 }
627
628 #[test]
629 fn stale_failures_outside_window_are_ignored() {
630 let tmp = TempDir::new().unwrap();
631 let dir = tmp.path();
632
633 let state = CrashState {
635 count: CRASH_FUSE_THRESHOLD + 5,
636 last_increment_epoch: now_epoch_secs().saturating_sub(CRASH_FUSE_WINDOW_SECS + 1),
637 };
638 state.save(dir).unwrap();
639
640 assert!(check_and_increment_fuse(dir).is_none());
642 }
643
644 #[test]
645 fn decide_load_reports_not_found_when_missing() {
646 let tmp = TempDir::new().unwrap();
647 match decide_load(tmp.path(), true) {
648 LoadDecision::Skip(InitOutcome::NotFound) => {}
649 other => panic!("expected NotFound, got {other:?}"),
650 }
651 }
652
653 #[test]
654 fn decide_load_reports_disabled_when_flag_says_so() {
655 let tmp = TempDir::new().unwrap();
656 std::fs::write(init_ts_path(tmp.path()), "// hi").unwrap();
657 match decide_load(tmp.path(), false) {
658 LoadDecision::Skip(InitOutcome::Disabled) => {}
659 other => panic!("expected Disabled, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn decide_load_returns_source_when_file_present_and_enabled() {
665 let tmp = TempDir::new().unwrap();
666 std::fs::write(init_ts_path(tmp.path()), "const x = 1;").unwrap();
667 match decide_load(tmp.path(), true) {
668 LoadDecision::Load { source } => assert_eq!(source, "const x = 1;"),
669 other => panic!("expected Load, got {other:?}"),
670 }
671 }
672
673 impl std::fmt::Debug for LoadDecision {
675 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676 match self {
677 LoadDecision::Skip(o) => write!(f, "Skip({o:?})"),
678 LoadDecision::Load { source } => write!(f, "Load({} chars)", source.len()),
679 }
680 }
681 }
682
683 #[test]
684 fn check_no_file_is_ok() {
685 let tmp = TempDir::new().unwrap();
686 let report = check(tmp.path());
687 assert!(report.ok);
688 assert!(report.diagnostics.is_empty());
689 }
690
691 #[test]
692 fn check_clean_source_is_ok() {
693 let tmp = TempDir::new().unwrap();
694 std::fs::write(
695 init_ts_path(tmp.path()),
696 "const editor = getEditor();\neditor.setStatus('hi');\n",
697 )
698 .unwrap();
699 let report = check(tmp.path());
700 assert!(report.ok, "diagnostics: {:?}", report.diagnostics);
701 }
702
703 #[test]
704 fn check_syntax_error_reports_a_diagnostic() {
705 let tmp = TempDir::new().unwrap();
706 std::fs::write(init_ts_path(tmp.path()), "function broken(\n").unwrap();
708 let report = check(tmp.path());
709 assert!(!report.ok);
710 assert!(!report.diagnostics.is_empty());
711 assert_eq!(report.diagnostics[0].severity, CheckSeverity::Error);
712 }
713
714 #[test]
715 fn starter_template_references_both_dts_files() {
716 assert!(
717 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/fresh.d.ts" />"#),
718 "starter template must reference fresh.d.ts"
719 );
720 assert!(
721 STARTER_TEMPLATE.contains(r#"/// <reference path="./types/plugins.d.ts" />"#),
722 "starter template must reference plugins.d.ts so plugin APIs are typed"
723 );
724 }
725
726 #[test]
727 fn write_plugin_declarations_skips_empty_export_plugins() {
728 let tmp = TempDir::new().unwrap();
729 let decls = vec![
730 ("noop".to_string(), "export {};\n".to_string()),
731 ("blank".to_string(), "".to_string()),
732 (
733 "dashboard".to_string(),
734 "export type DashboardApi = { foo(): void; };\n\
735 declare global { interface FreshPluginRegistry { dashboard: DashboardApi; } }\n\
736 export {};\n"
737 .to_string(),
738 ),
739 ];
740 write_plugin_declarations(tmp.path(), &decls);
741 let body = std::fs::read_to_string(tmp.path().join("types/plugins.d.ts")).unwrap();
742 assert!(
743 body.contains("// ── dashboard ─"),
744 "dashboard section missing: {body}"
745 );
746 assert!(
747 body.contains("DashboardApi"),
748 "dashboard API missing: {body}"
749 );
750 assert!(
751 !body.contains("// ── noop ─"),
752 "empty-export plugin should not get a section header: {body}"
753 );
754 assert!(
755 !body.contains("// ── blank ─"),
756 "blank-emit plugin should not get a section header: {body}"
757 );
758 }
759}