From e75ad6ca8975bdb875678c0783e0739a3e796810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Mon, 11 May 2026 21:04:55 +0000 Subject: [PATCH] phase 16: diary path conventions + nav commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled date primitive (no chrono/time dep) plus the diary command surface that closes the `:VimwikiMakeDiaryNote` ergonomics gap. nuwiki-core/src/date.rs: - `DiaryDate { year, month, day }` with strict `YYYY-MM-DD` parsing (rejects missing zero-padding, alternate separators, whitespace, impossible calendar dates including the leap-year exceptions). - `next_day` / `prev_day` via Howard Hinnant's days-from-civil algorithm — proleptic Gregorian, walks across month/year/leap-day boundaries. - `today_utc()` from `SystemTime::now()`. UTC-only by design: a local-tz implementation would need a tz database we don't ship. - `Ord` by epoch days, `Display`/`format` round-trip with `parse`. WikiConfig (P15 resolution): - `diary_rel_path: String` (default `"diary"`) — mirrors vimwiki's `g:vimwiki_diary_rel_path`. - `diary_index: String` (default `"diary"`) — the stem inside the diary subdir. - `diary_dir()`, `diary_index_path()`, `diary_path_for(date)` — centralise the path math so commands stay declarative. - `RawWiki` (init-options wire format) accepts both fields, so users with `g:vimwiki_diary_rel_path = "journal"` migrate cleanly. WorkspaceIndex: - New `diary_rel_path: Option` field set by `Wiki::new`. - `IndexedPage.diary_date: Option` — populated in `upsert` when the URI is under `//` and the stem parses. `diary_date_for_uri` is the standalone classifier. - The classifier *requires* the URI to be inside the diary subdir when a root is known; with no root it accepts any file whose stem is a date (kept for the ad-hoc test/scratch case). nuwiki-lsp/src/diary.rs: - `uri_for_date` / `index_uri` — bridge from `DiaryDate` and config to `file://` URIs. - `list_entries(index) → Vec` — ascending, plus `list_entries_filtered(year, month)` for calendar hooks. - `next_entry` / `prev_entry` — strict less-than/greater-than comparison so navigating from a non-diary page (uses today as pivot) still moves to the right entry. - `build_index_body` — newest-first, grouped under `== YYYY ==` / `=== Month ===` subheadings, emits `- [[diary/YYYY-MM-DD]]`. Matches `:VimwikiDiaryGenerateLinks`. - `DiaryEntry` JSON shape: `{ date: "YYYY-MM-DD", uri }`. Manual serde impl so DiaryDate stays serde-free in nuwiki-core. Commands (9 new): - `nuwiki.diary.openToday` / `openYesterday` / `openTomorrow` → `{ uri, date }`. - `nuwiki.diary.openIndex` → `{ uri }`. - `nuwiki.diary.generateIndex` → WorkspaceEdit. Three paths: live document in `backend.documents` → full-doc replace; on-disk index → read + full-doc replace; missing → CreateFile + insert. - `nuwiki.diary.next` / `prev` → pivots on the current page's `diary_date`, falls back to today. - `nuwiki.diary.listEntries` → optional `year` / `month` filter. - `nuwiki.diary.openForDate` → strict `YYYY-MM-DD` arg. All commands accept an optional `uri` to scope to a particular wiki when multi-wiki lands; the dispatcher falls back to `default_wiki()`. Tests: 35 new in `phase16_diary.rs` (date parser exhaustively, calendar arithmetic across boundaries + leaps, path conventions, upsert classification including the "date filename outside diary dir" no-op case, list/filter/nav, index body grouping, serde shape, command list completeness). Total 309 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- crates/nuwiki-core/src/date.rs | 166 ++++++++++ crates/nuwiki-core/src/lib.rs | 1 + crates/nuwiki-lsp/src/commands.rs | 282 +++++++++++++++++ crates/nuwiki-lsp/src/config.rs | 46 +++ crates/nuwiki-lsp/src/diary.rs | 169 ++++++++++ crates/nuwiki-lsp/src/index.rs | 42 +++ crates/nuwiki-lsp/src/lib.rs | 1 + crates/nuwiki-lsp/src/wiki.rs | 4 +- crates/nuwiki-lsp/tests/phase16_diary.rs | 378 +++++++++++++++++++++++ 10 files changed, 1089 insertions(+), 2 deletions(-) create mode 100644 crates/nuwiki-core/src/date.rs create mode 100644 crates/nuwiki-lsp/src/diary.rs create mode 100644 crates/nuwiki-lsp/tests/phase16_diary.rs diff --git a/README.md b/README.md index cb12bad..9df04e2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 13 | Workspace edits + executeCommand | ✅ done | | 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) | | 15 | Link health + TOC/index generation | ✅ done | -| 16 | Diary | ⏳ | +| 16 | Diary | ✅ done | | 17 | HTML export commands | ⏳ | | 18 | Multi-wiki | ⏳ | | 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ | diff --git a/crates/nuwiki-core/src/date.rs b/crates/nuwiki-core/src/date.rs new file mode 100644 index 0000000..64c8d40 --- /dev/null +++ b/crates/nuwiki-core/src/date.rs @@ -0,0 +1,166 @@ +//! Date primitives for the diary subsystem (Phase 16). +//! +//! v1.1 deliberately doesn't pull in `chrono` or `time`. The diary feature +//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which +//! is small enough that the dependency cost outweighs the convenience. +//! +//! Date math uses Howard Hinnant's days-from-civil algorithm +//! () — proleptic +//! Gregorian, valid for any year representable in `i32`. + +use std::cmp::Ordering; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// A calendar day, decoupled from any time zone or wall-clock context. +/// +/// Validation: `parse` and `from_ymd` reject impossible dates (month 0, +/// day 0, day > days-in-month, etc.). The struct fields are public so +/// callers can read them, but constructing one with bogus values bypasses +/// validation — use the constructors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DiaryDate { + pub year: i32, + pub month: u8, + pub day: u8, +} + +impl DiaryDate { + /// Build a date from its components, validating the calendar. + pub fn from_ymd(year: i32, month: u8, day: u8) -> Option { + if !(1..=12).contains(&month) { + return None; + } + if day < 1 || day > days_in_month(year, month) { + return None; + } + Some(Self { year, month, day }) + } + + /// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra + /// whitespace, missing zero-padding, alternate separators, etc. + pub fn parse(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { + return None; + } + let y = parse_digits(&b[0..4])?; + let m = parse_digits(&b[5..7])? as u8; + let d = parse_digits(&b[8..10])? as u8; + Self::from_ymd(y as i32, m, d) + } + + /// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render + /// numerically; the diary use case never produces those. + pub fn format(&self) -> String { + format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) + } + + /// UTC "today" computed from `SystemTime::now()`. Phase 16's diary + /// commands intentionally use UTC — local-time semantics depend on a + /// timezone DB we don't ship and would surprise users crossing DST + /// boundaries. + pub fn today_utc() -> Self { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = (secs / 86_400) as i64; + from_days(days) + } + + pub fn next_day(&self) -> Self { + from_days(to_days(self.year, self.month, self.day) + 1) + } + + pub fn prev_day(&self) -> Self { + from_days(to_days(self.year, self.month, self.day) - 1) + } + + /// Internal — exposed for tests that want to verify ordering against + /// the days-from-epoch representation. + pub fn to_days_epoch(&self) -> i64 { + to_days(self.year, self.month, self.day) + } +} + +impl PartialOrd for DiaryDate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DiaryDate { + fn cmp(&self, other: &Self) -> Ordering { + self.to_days_epoch().cmp(&other.to_days_epoch()) + } +} + +impl fmt::Display for DiaryDate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.format()) + } +} + +fn parse_digits(b: &[u8]) -> Option { + if b.is_empty() { + return None; + } + let mut n: u32 = 0; + for ch in b { + if !ch.is_ascii_digit() { + return None; + } + n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?; + } + Some(n) +} + +fn is_leap(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +fn days_in_month(year: i32, month: u8) -> u8 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap(year) { + 29 + } else { + 28 + } + } + _ => 0, + } +} + +/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0. +fn to_days(year: i32, month: u8, day: u8) -> i64 { + let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let m = month as i64; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Inverse of [`to_days`]. +fn from_days(days: i64) -> DiaryDate { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u8; + let m_int = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = y + if m_int <= 2 { 1 } else { 0 }; + DiaryDate { + year: year as i32, + month: m_int as u8, + day: d, + } +} diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs index 90ea884..8401d3a 100644 --- a/crates/nuwiki-core/src/lib.rs +++ b/crates/nuwiki-core/src/lib.rs @@ -4,5 +4,6 @@ //! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules. pub mod ast; +pub mod date; pub mod render; pub mod syntax; diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 041eeff..342c074 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -43,6 +43,15 @@ pub const COMMANDS: &[&str] = &[ "nuwiki.links.generate", "nuwiki.workspace.checkLinks", "nuwiki.workspace.findOrphans", + "nuwiki.diary.openToday", + "nuwiki.diary.openYesterday", + "nuwiki.diary.openTomorrow", + "nuwiki.diary.openIndex", + "nuwiki.diary.generateIndex", + "nuwiki.diary.next", + "nuwiki.diary.prev", + "nuwiki.diary.listEntries", + "nuwiki.diary.openForDate", ]; pub(crate) async fn execute( @@ -80,6 +89,30 @@ pub(crate) async fn execute( "nuwiki.workspace.findOrphans" => { workspace_find_orphans(backend, args).map(|o| o.map(CommandOutcome::Value)) } + "nuwiki.diary.openToday" => diary_open_relative(backend, args, RelativeDay::Today) + .map(|o| o.map(CommandOutcome::Value)), + "nuwiki.diary.openYesterday" => diary_open_relative(backend, args, RelativeDay::Yesterday) + .map(|o| o.map(CommandOutcome::Value)), + "nuwiki.diary.openTomorrow" => diary_open_relative(backend, args, RelativeDay::Tomorrow) + .map(|o| o.map(CommandOutcome::Value)), + "nuwiki.diary.openIndex" => { + diary_open_index(backend, args).map(|o| o.map(CommandOutcome::Value)) + } + "nuwiki.diary.generateIndex" => { + diary_generate_index(backend, args).map(|o| o.map(CommandOutcome::Edit)) + } + "nuwiki.diary.next" => { + diary_step(backend, args, true).map(|o| o.map(CommandOutcome::Value)) + } + "nuwiki.diary.prev" => { + diary_step(backend, args, false).map(|o| o.map(CommandOutcome::Value)) + } + "nuwiki.diary.listEntries" => { + diary_list(backend, args).map(|o| o.map(CommandOutcome::Value)) + } + "nuwiki.diary.openForDate" => { + diary_open_for_date(backend, args).map(|o| o.map(CommandOutcome::Value)) + } other => Err(format!("unknown nuwiki command: {other}")), } } @@ -249,6 +282,176 @@ fn workspace_check_links(backend: &Backend, args: Vec) -> Result) -> Option { + if let Some(u) = uri { + if let Some(w) = backend.wiki_for_uri(u) { + return Some(w); + } + } + backend.default_wiki() +} + +fn diary_open_relative( + backend: &Backend, + args: Vec, + rel: RelativeDay, +) -> Result, String> { + let p = parse_optional_uri_arg(args)?; + let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { + return Ok(None); + }; + let today = nuwiki_core::date::DiaryDate::today_utc(); + let date = match rel { + RelativeDay::Today => today, + RelativeDay::Yesterday => today.prev_day(), + RelativeDay::Tomorrow => today.next_day(), + }; + let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else { + return Ok(None); + }; + Ok(Some(serde_json::json!({ + "uri": uri, + "date": date.format(), + }))) +} + +fn diary_open_index(backend: &Backend, args: Vec) -> Result, String> { + let p = parse_optional_uri_arg(args)?; + let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { + return Ok(None); + }; + let Some(uri) = crate::diary::index_uri(&wiki.config) else { + return Ok(None); + }; + Ok(Some(serde_json::json!({ "uri": uri }))) +} + +fn diary_generate_index( + backend: &Backend, + args: Vec, +) -> Result, String> { + let p = parse_optional_uri_arg(args)?; + let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { + return Ok(None); + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let Some(index_uri) = crate::diary::index_uri(&wiki.config) else { + return Ok(None); + }; + let body = { + let idx = wiki + .index + .read() + .map_err(|_| "index lock poisoned".to_string())?; + crate::diary::render_index_body(&wiki.config, &idx) + }; + Ok(Some(ops::diary_generate_index_edit( + backend, &index_uri, &body, utf8, + ))) +} + +fn diary_step(backend: &Backend, args: Vec, forward: bool) -> Result, String> { + let p = parse_uri_arg(args)?; + let Some(wiki) = backend.wiki_for_uri(&p.uri) else { + return Ok(None); + }; + // Pivot date: the current page's `diary_date` when it's an entry, + // otherwise today — matches vimwiki's "navigate from whatever date + // I'm currently viewing". + let pivot = { + let idx = wiki + .index + .read() + .map_err(|_| "index lock poisoned".to_string())?; + idx.page(&p.uri) + .and_then(|page| page.diary_date) + .unwrap_or_else(nuwiki_core::date::DiaryDate::today_utc) + }; + let next = { + let idx = wiki + .index + .read() + .map_err(|_| "index lock poisoned".to_string())?; + if forward { + crate::diary::next_entry(&idx, &pivot) + } else { + crate::diary::prev_entry(&idx, &pivot) + } + }; + match next { + None => Ok(None), + Some(entry) => Ok(Some(serde_json::to_value(&entry).map_err(|e| { + format!("nuwiki.diary.next/prev: serialization failed — {e}") + })?)), + } +} + +fn diary_list(backend: &Backend, args: Vec) -> Result, String> { + #[derive(Deserialize, Default)] + struct Args { + #[serde(default)] + uri: Option, + #[serde(default)] + year: Option, + #[serde(default)] + month: Option, + } + let parsed: Args = match args.into_iter().next() { + Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?, + None => Args::default(), + }; + let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else { + return Ok(Some(serde_json::json!([]))); + }; + let entries = { + let idx = wiki + .index + .read() + .map_err(|_| "index lock poisoned".to_string())?; + crate::diary::list_entries_filtered(&idx, parsed.year, parsed.month) + }; + Ok(Some(serde_json::to_value(&entries).map_err(|e| { + format!("nuwiki.diary.listEntries: serialization failed — {e}") + })?)) +} + +fn diary_open_for_date(backend: &Backend, args: Vec) -> Result, String> { + #[derive(Deserialize)] + struct Args { + date: String, + #[serde(default)] + uri: Option, + } + let raw = args + .into_iter() + .next() + .ok_or_else(|| "nuwiki.diary.openForDate: missing { date } argument".to_string())?; + let parsed: Args = serde_json::from_value(raw) + .map_err(|e| format!("nuwiki.diary.openForDate: invalid args — {e}"))?; + let Some(date) = nuwiki_core::date::DiaryDate::parse(&parsed.date) else { + return Err(format!( + "nuwiki.diary.openForDate: '{}' is not a YYYY-MM-DD date", + parsed.date + )); + }; + let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else { + return Ok(None); + }; + let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else { + return Ok(None); + }; + Ok(Some( + serde_json::json!({ "uri": uri, "date": date.format() }), + )) +} + fn workspace_find_orphans(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args)?; let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) { @@ -824,4 +1027,83 @@ pub mod ops { /// Re-exported AST link walker so tests can drive it without depending /// on the diagnostics module's internals. pub use crate::diagnostics::collect_wiki_links; + + // ===== Phase 16: diary helpers ===== + + use crate::edits::op_create; + + /// Build the `WorkspaceEdit` that materialises the diary index page. + /// + /// Decision tree: + /// - If the index URI is open in `backend.documents`, the live text is + /// the source of truth: emit a single full-document `TextEdit` + /// replacing it with `body`. + /// - If the index file exists on disk but isn't open: same full-doc + /// replace, span computed from the on-disk bytes. + /// - If neither: a `CreateFile` op + an insert at `0:0`. + pub(crate) fn diary_generate_index_edit( + backend: &Backend, + index_uri: &Url, + body: &str, + utf8: bool, + ) -> WorkspaceEdit { + let mut b = WorkspaceEditBuilder::new(); + if let Some(doc) = backend.documents.get(index_uri) { + let span = whole_doc_span(&doc.text); + b.edit( + index_uri.clone(), + text_edit_replace(span, body.to_string(), &doc.text, utf8), + ); + return b.build(); + } + if let Ok(path) = index_uri.to_file_path() { + if let Ok(existing) = std::fs::read_to_string(&path) { + let span = whole_doc_span(&existing); + b.edit( + index_uri.clone(), + text_edit_replace(span, body.to_string(), &existing, utf8), + ); + return b.build(); + } + } + // File does not exist — create it and insert the body. The text + // edit applies *after* CreateFile via the builder's ordering. + b.file_op(op_create(index_uri.clone())); + b.edit( + index_uri.clone(), + crate::edits::text_edit_insert( + LspPosition { + line: 0, + character: 0, + }, + body.to_string(), + ), + ); + b.build() + } + + fn whole_doc_span(text: &str) -> Span { + let bytes = text.as_bytes(); + let mut line = 0u32; + let mut last_nl = 0usize; + for (i, b) in bytes.iter().enumerate() { + if *b == b'\n' { + line += 1; + last_nl = i + 1; + } + } + let column = (bytes.len() - last_nl) as u32; + Span::new( + AstPosition { + line: 0, + column: 0, + offset: 0, + }, + AstPosition { + line, + column, + offset: bytes.len(), + }, + ) + } } diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 2789bcd..4853709 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -27,6 +27,13 @@ pub struct WikiConfig { pub root: PathBuf, pub file_extension: String, pub syntax: String, + /// Subdirectory under `root` where diary entries live. Matches + /// vimwiki's `g:vimwiki_diary_rel_path` (default `"diary"`). + pub diary_rel_path: String, + /// Stem of the diary index page within the diary subdirectory. + /// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`). + /// The full path is `//.wiki`. + pub diary_index: String, } #[derive(Debug, Clone, Default)] @@ -53,6 +60,8 @@ impl WikiConfig { root: PathBuf::new(), file_extension: ".wiki".into(), syntax: "vimwiki".into(), + diary_rel_path: default_diary_rel_path(), + diary_index: default_diary_index(), } } @@ -65,8 +74,37 @@ impl WikiConfig { root, file_extension: ".wiki".into(), syntax: "vimwiki".into(), + diary_rel_path: default_diary_rel_path(), + diary_index: default_diary_index(), } } + + /// Absolute path to the diary subdirectory under this wiki's root. + pub fn diary_dir(&self) -> PathBuf { + self.root.join(&self.diary_rel_path) + } + + /// Absolute path to this wiki's diary index page. + pub fn diary_index_path(&self) -> PathBuf { + let mut p = self.diary_dir(); + p.push(format!("{}{}", self.diary_index, self.file_extension)); + p + } + + /// Absolute path for a diary entry on `date`. + pub fn diary_path_for(&self, date: &nuwiki_core::date::DiaryDate) -> PathBuf { + let mut p = self.diary_dir(); + p.push(format!("{}{}", date.format(), self.file_extension)); + p + } +} + +fn default_diary_rel_path() -> String { + "diary".to_string() +} + +fn default_diary_index() -> String { + "diary".to_string() } impl Config { @@ -97,6 +135,8 @@ impl Config { root, file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".into()), syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()), + diary_rel_path: default_diary_rel_path(), + diary_index: default_diary_index(), }] } else if let Some(folder) = first_workspace_folder(params) { vec![WikiConfig::from_root(folder)] @@ -155,6 +195,10 @@ struct RawWiki { file_extension: Option, #[serde(default)] syntax: Option, + #[serde(default)] + diary_rel_path: Option, + #[serde(default)] + diary_index: Option, } impl From for WikiConfig { @@ -169,6 +213,8 @@ impl From for WikiConfig { root, file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()), syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()), + diary_rel_path: r.diary_rel_path.unwrap_or_else(default_diary_rel_path), + diary_index: r.diary_index.unwrap_or_else(default_diary_index), } } } diff --git a/crates/nuwiki-lsp/src/diary.rs b/crates/nuwiki-lsp/src/diary.rs new file mode 100644 index 0000000..2b2b7ba --- /dev/null +++ b/crates/nuwiki-lsp/src/diary.rs @@ -0,0 +1,169 @@ +//! Diary subsystem (Phase 16) — pure helpers used by the +//! `nuwiki.diary.*` `executeCommand` handlers. +//! +//! All path/URI math lives here so the command dispatcher stays a thin +//! wrapper. The diary navigation model is intentionally lightweight: +//! entries are identified by file stem (`YYYY-MM-DD`) living under +//! `//`. The `WorkspaceIndex` tags each +//! `IndexedPage` with a `diary_date` so prev/next/list operations stay +//! O(n) over the indexed pages. + +use std::path::Path; + +use tower_lsp::lsp_types::Url; + +use nuwiki_core::date::DiaryDate; + +use crate::config::WikiConfig; +use crate::index::WorkspaceIndex; + +/// Resolve a `DiaryDate` to its `file://` URI under the given wiki. +pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option { + Url::from_file_path(cfg.diary_path_for(date)).ok() +} + +/// `file://` URI of the diary index page. +pub fn index_uri(cfg: &WikiConfig) -> Option { + Url::from_file_path(cfg.diary_index_path()).ok() +} + +/// All diary entries currently indexed for `wiki`, sorted ascending by +/// date. The wiki's index must already have its `diary_rel_path` set +/// (handled by `Wiki::new`). +pub fn list_entries(index: &WorkspaceIndex) -> Vec { + let mut out: Vec = index + .pages_by_uri + .values() + .filter_map(|p| { + p.diary_date.map(|d| DiaryEntry { + date: d, + uri: p.uri.clone(), + }) + }) + .collect(); + out.sort_by(|a, b| a.date.cmp(&b.date)); + out +} + +/// Entries for a given (year, month). `month = None` returns the whole +/// year; `year = None` matches every year. +pub fn list_entries_filtered( + index: &WorkspaceIndex, + year: Option, + month: Option, +) -> Vec { + list_entries(index) + .into_iter() + .filter(|e| year.is_none_or(|y| e.date.year == y)) + .filter(|e| month.is_none_or(|m| e.date.month == m)) + .collect() +} + +/// The diary entry chronologically after `from`. `from` doesn't need to +/// be indexed itself — we look for the smallest indexed date strictly +/// greater than `from`. +pub fn next_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option { + list_entries(index).into_iter().find(|e| e.date > *from) +} + +/// The diary entry chronologically before `from`. +pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option { + list_entries(index) + .into_iter() + .rev() + .find(|e| e.date < *from) +} + +/// Generate the body of the diary index page — a flat newest-first list +/// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month +/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`. +pub fn build_index_body( + entries: &[DiaryEntry], + diary_rel_path: &str, + index_heading: &str, +) -> String { + let mut out = String::new(); + out.push_str("= "); + out.push_str(index_heading); + out.push_str(" =\n"); + + if entries.is_empty() { + return out; + } + + // Sort descending by date — newest first matches vimwiki. + let mut sorted: Vec<&DiaryEntry> = entries.iter().collect(); + sorted.sort_by(|a, b| b.date.cmp(&a.date)); + + let mut current_year: Option = None; + let mut current_month: Option = None; + for e in sorted { + if current_year != Some(e.date.year) { + out.push_str(&format!("\n== {} ==\n", e.date.year)); + current_year = Some(e.date.year); + current_month = None; + } + if current_month != Some(e.date.month) { + out.push_str(&format!("=== {} ===\n", month_name(e.date.month))); + current_month = Some(e.date.month); + } + out.push_str(&format!( + "- [[{}/{}]]\n", + diary_rel_path.trim_end_matches('/'), + e.date.format() + )); + } + out +} + +/// Render the diary-index body for the given wiki's currently-indexed +/// entries. Convenience wrapper around [`build_index_body`]. +pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String { + build_index_body(&list_entries(index), &cfg.diary_rel_path, "Diary") +} + +/// Cheap "is this path under the diary subdir of this wiki" check — +/// surface-level, no parse needed. +pub fn is_in_diary(cfg: &WikiConfig, path: &Path) -> bool { + path.starts_with(cfg.diary_dir()) +} + +/// One diary entry, returned by listing/navigation queries. The custom +/// `Serialize` emits `{ "date": "YYYY-MM-DD", "uri": "file://..." }` so +/// the client doesn't have to know about [`DiaryDate`]'s internal layout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiaryEntry { + pub date: DiaryDate, + pub uri: Url, +} + +impl serde::Serialize for DiaryEntry { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut s = serializer.serialize_struct("DiaryEntry", 2)?; + s.serialize_field("date", &self.date.format())?; + s.serialize_field("uri", &self.uri)?; + s.end() + } +} + +fn month_name(m: u8) -> &'static str { + match m { + 1 => "January", + 2 => "February", + 3 => "March", + 4 => "April", + 5 => "May", + 6 => "June", + 7 => "July", + 8 => "August", + 9 => "September", + 10 => "October", + 11 => "November", + 12 => "December", + _ => "Unknown", + } +} diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index e2d36e0..a14a9a4 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -17,6 +17,7 @@ use nuwiki_core::ast::{ BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span, TableNode, TagScope, }; +use nuwiki_core::date::DiaryDate; use tower_lsp::lsp_types::Url; #[derive(Debug, Clone)] @@ -31,6 +32,10 @@ pub struct IndexedPage { /// Phase 12: every `TagNode` on the page. `tags_by_name` on the /// containing `WorkspaceIndex` is the reverse map. pub tags: Vec, + /// Phase 16: `Some(date)` when this page is a diary entry — i.e. its + /// file lives under `//` and its stem parses as + /// `YYYY-MM-DD`. Used by the diary navigation commands. + pub diary_date: Option, } /// One tag occurrence on a page. `name` is the bare tag string (no @@ -81,6 +86,10 @@ pub struct TagOccurrence { #[derive(Debug, Default)] pub struct WorkspaceIndex { pub root: Option, + /// Phase 16: subdir relative to `root` that holds diary entries. + /// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can + /// classify each indexed URI without a back-reference to the config. + pub diary_rel_path: Option, pub pages_by_uri: HashMap, pub pages_by_name: HashMap, pub backlinks: HashMap>, @@ -98,12 +107,19 @@ impl WorkspaceIndex { } } + pub fn with_diary_rel_path(mut self, rel: Option) -> Self { + self.diary_rel_path = rel; + self + } + /// Insert or update an indexed page. Removes any prior indexing for /// the same URI first (handles renames and re-parses without leaking /// stale backlinks). pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) { self.remove(&uri); let name = page_name_from_uri(&uri, self.root.as_deref()); + let diary_date = + diary_date_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref()); let mut page = IndexedPage { uri: uri.clone(), name: name.clone(), @@ -111,6 +127,7 @@ impl WorkspaceIndex { headings: Vec::new(), outgoing: Vec::new(), tags: Vec::new(), + diary_date, }; index_blocks(&ast.children, &mut page); @@ -207,6 +224,31 @@ impl WorkspaceIndex { } } +/// Return a parsed [`DiaryDate`] when `uri` is a diary entry — its file +/// path is under `//` and its stem parses as +/// `YYYY-MM-DD`. Anything else returns `None`. +pub fn diary_date_for_uri( + uri: &Url, + root: Option<&Path>, + diary_rel_path: Option<&str>, +) -> Option { + let path = uri.to_file_path().ok()?; + let stem = path.file_stem()?.to_str()?; + let parsed = DiaryDate::parse(stem)?; + // Without a root we can't enforce the subdir requirement — accept any + // file whose stem looks like a date so that ad-hoc test setups work. + let Some(root) = root else { + return Some(parsed); + }; + let parent = path.parent()?; + let expected = root.join(diary_rel_path.unwrap_or("diary")); + if parent.starts_with(&expected) { + Some(parsed) + } else { + None + } +} + /// Derive a page name from a `file://` URL. With a workspace root the name /// is the URL's path relative to root, sans `.wiki`. Without a root it's /// just the filename stem. diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 8546fd8..6238db0 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -24,6 +24,7 @@ pub mod commands; pub mod config; pub mod diagnostics; +pub mod diary; pub mod edits; pub mod index; pub mod nav; diff --git a/crates/nuwiki-lsp/src/wiki.rs b/crates/nuwiki-lsp/src/wiki.rs index c136b15..5c7225f 100644 --- a/crates/nuwiki-lsp/src/wiki.rs +++ b/crates/nuwiki-lsp/src/wiki.rs @@ -25,7 +25,9 @@ pub struct Wiki { impl Wiki { pub fn new(id: WikiId, config: WikiConfig) -> Self { - let index = Arc::new(RwLock::new(WorkspaceIndex::new(Some(config.root.clone())))); + let index = WorkspaceIndex::new(Some(config.root.clone())) + .with_diary_rel_path(Some(config.diary_rel_path.clone())); + let index = Arc::new(RwLock::new(index)); Self { id, config, index } } diff --git a/crates/nuwiki-lsp/tests/phase16_diary.rs b/crates/nuwiki-lsp/tests/phase16_diary.rs new file mode 100644 index 0000000..31c1b71 --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase16_diary.rs @@ -0,0 +1,378 @@ +//! Phase 16: diary date primitives + path conventions + nav helpers + +//! IndexedPage diary classification. + +use std::path::PathBuf; + +use nuwiki_core::date::DiaryDate; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::config::WikiConfig; +use nuwiki_lsp::diary; +use nuwiki_lsp::index::WorkspaceIndex; +use tower_lsp::lsp_types::Url; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn wiki_cfg(root: &str) -> WikiConfig { + let mut cfg = WikiConfig::from_root(PathBuf::from(root)); + cfg.diary_rel_path = "diary".into(); + cfg.diary_index = "diary".into(); + cfg +} + +fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex { + let mut idx = + WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into())); + for d in dates { + let path = format!("{root}/diary/{d}.wiki"); + let uri = Url::from_file_path(&path).unwrap(); + idx.upsert(uri, &parse("= entry =\n")); + } + idx +} + +// ===== DiaryDate parser ===== + +#[test] +fn parse_accepts_canonical_iso8601() { + let d = DiaryDate::parse("2026-05-11").unwrap(); + assert_eq!(d.year, 2026); + assert_eq!(d.month, 5); + assert_eq!(d.day, 11); +} + +#[test] +fn parse_rejects_missing_zero_padding() { + assert!(DiaryDate::parse("2026-5-11").is_none()); + assert!(DiaryDate::parse("2026-05-1").is_none()); +} + +#[test] +fn parse_rejects_alternate_separators() { + assert!(DiaryDate::parse("2026/05/11").is_none()); + assert!(DiaryDate::parse("2026.05.11").is_none()); +} + +#[test] +fn parse_rejects_extra_whitespace() { + assert!(DiaryDate::parse(" 2026-05-11").is_none()); + assert!(DiaryDate::parse("2026-05-11 ").is_none()); +} + +#[test] +fn parse_rejects_invalid_calendar_dates() { + assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13"); + assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30"); + assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31"); + assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0"); + assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0"); +} + +#[test] +fn parse_accepts_leap_day_in_leap_year() { + assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap"); + assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap"); + assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400"); + assert!( + DiaryDate::parse("1900-02-29").is_none(), + "div by 100 not 400" + ); +} + +#[test] +fn format_round_trip() { + let d = DiaryDate::from_ymd(2026, 5, 11).unwrap(); + assert_eq!(d.format(), "2026-05-11"); + assert_eq!(DiaryDate::parse(&d.format()), Some(d)); +} + +// ===== Date arithmetic ===== + +#[test] +fn next_day_wraps_month_boundary() { + let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap(); + assert_eq!( + last_of_jan.next_day(), + DiaryDate::from_ymd(2026, 2, 1).unwrap() + ); +} + +#[test] +fn next_day_wraps_year_boundary() { + let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap(); + assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap()); +} + +#[test] +fn prev_day_walks_back_across_month() { + let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap(); + assert_eq!( + first_of_mar.prev_day(), + DiaryDate::from_ymd(2026, 2, 28).unwrap() + ); +} + +#[test] +fn prev_day_walks_back_across_leap() { + let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap(); + assert_eq!( + first_of_mar.prev_day(), + DiaryDate::from_ymd(2024, 2, 29).unwrap() + ); +} + +#[test] +fn ordering_is_chronological() { + let a = DiaryDate::from_ymd(2026, 1, 1).unwrap(); + let b = DiaryDate::from_ymd(2026, 1, 2).unwrap(); + let c = DiaryDate::from_ymd(2026, 2, 1).unwrap(); + let d = DiaryDate::from_ymd(2027, 1, 1).unwrap(); + assert!(a < b); + assert!(b < c); + assert!(c < d); +} + +#[test] +fn today_utc_is_a_real_calendar_date() { + let t = DiaryDate::today_utc(); + assert!(t.year >= 2020 && t.year <= 2100); + assert!((1..=12).contains(&t.month)); + assert!((1..=31).contains(&t.day)); +} + +// ===== WikiConfig diary paths ===== + +#[test] +fn diary_dir_joins_root_and_rel_path() { + let cfg = wiki_cfg("/tmp/wiki"); + assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary")); +} + +#[test] +fn diary_index_path_uses_diary_index_filename_and_extension() { + let cfg = wiki_cfg("/tmp/wiki"); + assert_eq!( + cfg.diary_index_path(), + PathBuf::from("/tmp/wiki/diary/diary.wiki") + ); +} + +#[test] +fn diary_path_for_uses_iso_date_as_stem() { + let cfg = wiki_cfg("/tmp/wiki"); + let date = DiaryDate::from_ymd(2026, 5, 11).unwrap(); + assert_eq!( + cfg.diary_path_for(&date), + PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki") + ); +} + +#[test] +fn diary_uri_for_date_is_file_url() { + let cfg = wiki_cfg("/tmp/wiki"); + let date = DiaryDate::from_ymd(2026, 5, 11).unwrap(); + let uri = diary::uri_for_date(&cfg, &date).unwrap(); + assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki")); +} + +#[test] +fn diary_index_uri_resolves() { + let cfg = wiki_cfg("/tmp/wiki"); + let uri = diary::index_uri(&cfg).unwrap(); + assert!(uri.as_str().ends_with("/diary/diary.wiki")); +} + +// ===== Index integration: diary_date on IndexedPage ===== + +#[test] +fn upsert_marks_diary_entries() { + let root = "/tmp/diary1"; + let idx = build_index_with_entries(root, &["2026-05-11"]); + let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap(); + let page = idx.page(&uri).expect("page"); + let date = page.diary_date.expect("diary_date"); + assert_eq!(date.format(), "2026-05-11"); +} + +#[test] +fn upsert_does_not_mark_non_diary_files() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2"))) + .with_diary_rel_path(Some("diary".into())); + let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap(); + idx.upsert(uri.clone(), &parse("= Home =\n")); + assert!(idx.page(&uri).unwrap().diary_date.is_none()); +} + +#[test] +fn upsert_does_not_mark_dates_outside_diary_subdir() { + // A file at root named like a date but not under diary/ → not a + // diary entry. + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3"))) + .with_diary_rel_path(Some("diary".into())); + let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap(); + idx.upsert(uri.clone(), &parse("= note =\n")); + assert!(idx.page(&uri).unwrap().diary_date.is_none()); +} + +// ===== Diary listing + navigation ===== + +#[test] +fn list_entries_returns_sorted_ascending() { + let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]); + let entries = diary::list_entries(&idx); + let dates: Vec = entries.iter().map(|e| e.date.format()).collect(); + assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]); +} + +#[test] +fn list_entries_filtered_by_year_and_month() { + let idx = build_index_with_entries( + "/tmp/diary5", + &["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"], + ); + let may = diary::list_entries_filtered(&idx, Some(2026), Some(5)); + assert_eq!(may.len(), 2); + let y_only = diary::list_entries_filtered(&idx, Some(2026), None); + assert_eq!(y_only.len(), 3); +} + +#[test] +fn next_entry_finds_strictly_greater() { + let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]); + let from = DiaryDate::from_ymd(2026, 5, 11).unwrap(); + let next = diary::next_entry(&idx, &from).unwrap(); + assert_eq!(next.date.format(), "2026-05-12"); +} + +#[test] +fn next_entry_none_when_no_future_entries() { + let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]); + let from = DiaryDate::from_ymd(2026, 5, 11).unwrap(); + assert!(diary::next_entry(&idx, &from).is_none()); +} + +#[test] +fn prev_entry_finds_strictly_lesser() { + let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]); + let from = DiaryDate::from_ymd(2026, 5, 13).unwrap(); + let prev = diary::prev_entry(&idx, &from).unwrap(); + assert_eq!(prev.date.format(), "2026-05-12"); +} + +// ===== Index body generation ===== + +#[test] +fn build_index_body_is_newest_first_grouped_by_year_month() { + let idx = build_index_with_entries( + "/tmp/diary9", + &["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"], + ); + let entries = diary::list_entries(&idx); + let body = diary::build_index_body(&entries, "diary", "Diary"); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines[0], "= Diary ="); + // First date in output should be the latest: 2026-05-12 + let earliest_2026_pos = body.find("2026-05-12").unwrap(); + let latest_2025_pos = body.find("2025-12-31").unwrap(); + assert!(earliest_2026_pos < latest_2025_pos, "newer entries first"); + // Year headings appear + assert!(body.contains("== 2026 ==")); + assert!(body.contains("== 2025 ==")); + // Month headings appear + assert!(body.contains("=== May ===")); + assert!(body.contains("=== December ===")); + assert!(body.contains("=== April ===")); +} + +#[test] +fn build_index_body_empty_when_no_entries() { + let body = diary::build_index_body(&[], "diary", "Diary"); + assert_eq!(body, "= Diary =\n"); +} + +#[test] +fn render_index_body_round_trip() { + let cfg = wiki_cfg("/tmp/diaryA"); + let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]); + let body = diary::render_index_body(&cfg, &idx); + assert!(body.contains("= Diary =")); + assert!(body.contains("[[diary/2026-05-11]]")); +} + +// ===== is_in_diary ===== + +#[test] +fn is_in_diary_recognises_subdir_paths() { + let cfg = wiki_cfg("/tmp/wiki"); + assert!(diary::is_in_diary( + &cfg, + &PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki") + )); + assert!(!diary::is_in_diary( + &cfg, + &PathBuf::from("/tmp/wiki/Home.wiki") + )); +} + +// ===== Serde ===== + +#[test] +fn diary_entry_serializes_to_date_string_and_uri() { + let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap(); + let entry = diary::DiaryEntry { + date: DiaryDate::from_ymd(2026, 5, 11).unwrap(), + uri, + }; + let v = serde_json::to_value(&entry).unwrap(); + assert_eq!(v["date"], "2026-05-11"); + assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki")); +} + +// ===== COMMANDS list completeness ===== + +#[test] +fn commands_list_includes_phase16_entries() { + let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); + for name in [ + "nuwiki.diary.openToday", + "nuwiki.diary.openYesterday", + "nuwiki.diary.openTomorrow", + "nuwiki.diary.openIndex", + "nuwiki.diary.generateIndex", + "nuwiki.diary.next", + "nuwiki.diary.prev", + "nuwiki.diary.listEntries", + "nuwiki.diary.openForDate", + ] { + assert!(names.contains(&name), "missing: {name}"); + } +} + +// ===== Config wire format ===== + +#[test] +fn wiki_config_defaults_match_vimwiki() { + let cfg = WikiConfig::empty(); + assert_eq!(cfg.diary_rel_path, "diary"); + assert_eq!(cfg.diary_index, "diary"); +} + +#[test] +fn wiki_config_from_root_uses_default_diary_paths() { + let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x")); + assert_eq!(cfg.diary_rel_path, "diary"); +} + +#[test] +fn config_from_json_accepts_custom_diary_paths() { + use nuwiki_lsp::config::config_from_json; + let cfg = config_from_json(serde_json::json!({ + "wikis": [ + { "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" }, + ], + })); + assert_eq!(cfg.wikis[0].diary_rel_path, "journal"); + assert_eq!(cfg.wikis[0].diary_index, "index"); +}