From 85094d6e3b3b98d94a084054755f4b1c670fda29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Sat, 30 May 2026 23:32:07 -0300 Subject: [PATCH] feat(core): make checkbox lexing honor a configurable listsyms palette Add a ListSyms type (vimwiki's g:vimwiki_listsyms) and thread it through the lexer so checkbox glyphs are recognized from the configured palette rather than the hardcoded ' .oOX'. Glyphs map to the existing five-bucket CheckboxState, so the HTML renderer and AST consumers are unchanged. VimwikiSyntax::parse_with_listsyms opts into a custom palette; the trait parse() keeps the default. Co-Authored-By: Claude Opus 4.7 --- crates/nuwiki-core/src/lib.rs | 1 + crates/nuwiki-core/src/listsyms.rs | 170 ++++++++++++++++++ .../nuwiki-core/src/syntax/vimwiki/lexer.rs | 47 +++-- crates/nuwiki-core/src/syntax/vimwiki/mod.rs | 10 ++ crates/nuwiki-core/tests/lexer.rs | 39 ++++ 5 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 crates/nuwiki-core/src/listsyms.rs diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs index 0c57b44..a032210 100644 --- a/crates/nuwiki-core/src/lib.rs +++ b/crates/nuwiki-core/src/lib.rs @@ -5,5 +5,6 @@ pub mod ast; pub mod date; +pub mod listsyms; pub mod render; pub mod syntax; diff --git a/crates/nuwiki-core/src/listsyms.rs b/crates/nuwiki-core/src/listsyms.rs new file mode 100644 index 0000000..51e4218 --- /dev/null +++ b/crates/nuwiki-core/src/listsyms.rs @@ -0,0 +1,170 @@ +//! Configurable checkbox progress palette (vimwiki's `g:vimwiki_listsyms`). +//! +//! A palette is a progression of glyphs from "empty" (index 0) to "done" +//! (the last index). The default `" .oOX"` is the stock vimwiki palette. +//! A separate `rejected` glyph (`-`) is fixed by convention. +//! +//! The palette maps glyphs to a normalised five-bucket [`CheckboxState`] for +//! the AST (so the HTML renderer keeps its fixed `done0..done4` classes) while +//! still letting the LSP cycle/toggle commands operate on the real glyphs. + +use crate::ast::CheckboxState; + +const REJECTED: char = '-'; + +/// An ordered checkbox progress palette. Always holds at least two glyphs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListSyms { + progression: Vec, +} + +impl Default for ListSyms { + fn default() -> Self { + Self { + progression: " .oOX".chars().collect(), + } + } +} + +impl ListSyms { + /// Build a palette from a glyph string. Falls back to the default when + /// fewer than two glyphs are supplied (a one-symbol palette can't express + /// both "empty" and "done"). + pub fn new(s: &str) -> Self { + let progression: Vec = s.chars().collect(); + if progression.len() < 2 { + Self::default() + } else { + Self { progression } + } + } + + pub fn len(&self) -> usize { + self.progression.len() + } + + pub fn is_empty(&self) -> bool { + self.progression.is_empty() + } + + pub fn empty_glyph(&self) -> char { + self.progression[0] + } + + pub fn done_glyph(&self) -> char { + *self.progression.last().unwrap() + } + + pub fn rejected_glyph(&self) -> char { + REJECTED + } + + pub fn glyph_at(&self, index: usize) -> char { + self.progression[index.min(self.progression.len() - 1)] + } + + pub fn index_of(&self, c: char) -> Option { + self.progression.iter().position(|&g| g == c) + } + + /// Progress rate (0..=100) for a glyph index. + pub fn rate(&self, index: usize) -> i32 { + let last = (self.progression.len() - 1) as i32; + (index as i32) * 100 / last + } + + /// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the + /// glyph is not part of this palette (and is not the rejected marker). + pub fn state_of(&self, c: char) -> Option { + if c == REJECTED { + return Some(CheckboxState::Rejected); + } + let index = self.index_of(c)?; + let rate = self.rate(index); + // Round the rate to the nearest 25% bucket. + Some(match (rate + 12) / 25 { + 0 => CheckboxState::Empty, + 1 => CheckboxState::Quarter, + 2 => CheckboxState::Half, + 3 => CheckboxState::ThreeQuarters, + _ => CheckboxState::Done, + }) + } + + /// Snap a rate (`-1` = rejected, else 0..=100) to a glyph. Intermediate + /// rates distribute over the `n - 2` non-terminal glyphs using vimwiki's + /// `ceil` rule, matching the stock five-symbol behaviour. + pub fn glyph_for_rate(&self, rate: i32) -> char { + if rate < 0 { + return REJECTED; + } + if rate <= 0 { + return self.empty_glyph(); + } + if rate >= 100 { + return self.done_glyph(); + } + let mids = (self.progression.len().saturating_sub(2)).max(1) as f64; + let index = ((rate as f64) / 100.0 * mids).ceil() as usize; + self.glyph_at(index) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_palette_matches_stock_vimwiki() { + let s = ListSyms::default(); + assert_eq!(s.len(), 5); + assert_eq!(s.empty_glyph(), ' '); + assert_eq!(s.done_glyph(), 'X'); + assert_eq!(s.rejected_glyph(), '-'); + assert_eq!(s.rate(0), 0); + assert_eq!(s.rate(1), 25); + assert_eq!(s.rate(2), 50); + assert_eq!(s.rate(3), 75); + assert_eq!(s.rate(4), 100); + } + + #[test] + fn default_state_of_round_trips_each_glyph() { + let s = ListSyms::default(); + assert_eq!(s.state_of(' '), Some(CheckboxState::Empty)); + assert_eq!(s.state_of('.'), Some(CheckboxState::Quarter)); + assert_eq!(s.state_of('o'), Some(CheckboxState::Half)); + assert_eq!(s.state_of('O'), Some(CheckboxState::ThreeQuarters)); + assert_eq!(s.state_of('X'), Some(CheckboxState::Done)); + assert_eq!(s.state_of('-'), Some(CheckboxState::Rejected)); + assert_eq!(s.state_of('?'), None); + } + + #[test] + fn default_glyph_for_rate_matches_legacy_buckets() { + let s = ListSyms::default(); + assert_eq!(s.glyph_for_rate(-1), '-'); + assert_eq!(s.glyph_for_rate(0), ' '); + assert_eq!(s.glyph_for_rate(25), '.'); + assert_eq!(s.glyph_for_rate(50), 'o'); + assert_eq!(s.glyph_for_rate(75), 'O'); + assert_eq!(s.glyph_for_rate(100), 'X'); + } + + #[test] + fn short_input_falls_back_to_default() { + assert_eq!(ListSyms::new(""), ListSyms::default()); + assert_eq!(ListSyms::new("x"), ListSyms::default()); + } + + #[test] + fn custom_palette_buckets_to_nearest_state() { + // 3 glyphs → rates 0 / 50 / 100. + let s = ListSyms::new(" x✓"); + assert_eq!(s.state_of(' '), Some(CheckboxState::Empty)); + assert_eq!(s.state_of('x'), Some(CheckboxState::Half)); + assert_eq!(s.state_of('✓'), Some(CheckboxState::Done)); + assert_eq!(s.glyph_for_rate(50), 'x'); + assert_eq!(s.glyph_for_rate(100), '✓'); + } +} diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs index 21948bb..c8b120d 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs @@ -21,6 +21,7 @@ use std::collections::HashMap; use crate::ast::{CheckboxState, Keyword, ListSymbol, Position, Span}; +use crate::listsyms::ListSyms; use crate::syntax::{Lexer, TokenStream}; /// A single vimwiki token. The `kind` carries the variant, `span` carries @@ -134,11 +135,20 @@ pub enum VimwikiTokenKind { } #[derive(Debug, Default, Clone)] -pub struct VimwikiLexer; +pub struct VimwikiLexer { + listsyms: ListSyms, +} impl VimwikiLexer { pub fn new() -> Self { - Self + Self::default() + } + + /// Recognise checkbox glyphs from a custom palette + /// (vimwiki's `g:vimwiki_listsyms`) instead of the default `" .oOX"`. + pub fn with_listsyms(mut self, listsyms: ListSyms) -> Self { + self.listsyms = listsyms; + self } } @@ -146,7 +156,7 @@ impl Lexer for VimwikiLexer { type Token = VimwikiToken; fn lex(&self, text: &str) -> TokenStream { - let mut state = LexState::new(text); + let mut state = LexState::new(text, &self.listsyms); state.run(); TokenStream::from_vec(state.tokens) } @@ -170,16 +180,18 @@ struct LexState<'src> { line: u32, tokens: Vec, mode: BlockMode, + listsyms: &'src ListSyms, } impl<'src> LexState<'src> { - fn new(src: &'src str) -> Self { + fn new(src: &'src str, listsyms: &'src ListSyms) -> Self { Self { src, line_start_offset: 0, line: 0, tokens: Vec::new(), mode: BlockMode::Normal, + listsyms, } } @@ -704,7 +716,7 @@ impl<'src> LexState<'src> { // Optional checkbox `[ ]` / `[.]` / `[o]` / `[O]` / `[X]` / `[-]`, // followed by a space. - if let Some((cb, cb_len)) = checkbox_at(after) { + if let Some((cb, cb_len)) = checkbox_at(after, self.listsyms) { let cb_start = indent + cursor as u32; let cb_end = cb_start + cb_len as u32; self.emit(VimwikiTokenKind::Checkbox(cb), cb_start, cb_end); @@ -1230,19 +1242,18 @@ fn parse_sep_alignment(cell: &str) -> crate::ast::TableAlign { } } -fn checkbox_at(s: &str) -> Option<(CheckboxState, usize)> { - let b = s.as_bytes(); - if b.len() < 3 || b[0] != b'[' || b[2] != b']' { +/// Recognise a `[g]` checkbox at the start of `s`, where `g` is a single +/// glyph drawn from `listsyms` (or the rejected marker). Returns the bucketed +/// state and the byte length consumed (including the brackets). +fn checkbox_at(s: &str, listsyms: &ListSyms) -> Option<(CheckboxState, usize)> { + let rest = s.strip_prefix('[')?; + let mut chars = rest.char_indices(); + let (_, glyph) = chars.next()?; + let (close_off, ']') = chars.next()? else { return None; - } - let cb = match b[1] { - b' ' => CheckboxState::Empty, - b'.' => CheckboxState::Quarter, - b'o' => CheckboxState::Half, - b'O' => CheckboxState::ThreeQuarters, - b'X' => CheckboxState::Done, - b'-' => CheckboxState::Rejected, - _ => return None, }; - Some((cb, 3)) + let state = listsyms.state_of(glyph)?; + // `[` + glyph + `]`: 1 + glyph.len_utf8() + 1, i.e. the close bracket + // offset within `rest` plus one for the opening bracket and one for `]`. + Some((state, 1 + close_off + 1)) } diff --git a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs index 6b2d6d0..9f358b3 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs @@ -7,6 +7,7 @@ pub use lexer::{VimwikiLexer, VimwikiToken, VimwikiTokenKind}; pub use parser::VimwikiParser; use crate::ast::DocumentNode; +use crate::listsyms::ListSyms; use crate::syntax::{Lexer, Parser, SyntaxPlugin}; /// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the @@ -18,6 +19,15 @@ impl VimwikiSyntax { pub fn new() -> Self { Self } + + /// Parse with a custom checkbox palette (vimwiki's `g:vimwiki_listsyms`). + /// The trait-level [`SyntaxPlugin::parse`] uses [`ListSyms::default`]. + pub fn parse_with_listsyms(&self, text: &str, listsyms: &ListSyms) -> DocumentNode { + let tokens = VimwikiLexer::new() + .with_listsyms(listsyms.clone()) + .lex(text); + VimwikiParser::new().parse(tokens) + } } impl SyntaxPlugin for VimwikiSyntax { diff --git a/crates/nuwiki-core/tests/lexer.rs b/crates/nuwiki-core/tests/lexer.rs index e966fe6..bd0587c 100644 --- a/crates/nuwiki-core/tests/lexer.rs +++ b/crates/nuwiki-core/tests/lexer.rs @@ -309,6 +309,45 @@ fn checkbox_states() { } } +#[test] +fn checkbox_states_honor_custom_palette() { + use nuwiki_core::listsyms::ListSyms; + + let lex_with = |text: &str, syms: ListSyms| -> Vec { + VimwikiLexer::new() + .with_listsyms(syms) + .lex(text) + .into_iter() + .map(|t| t.kind) + .collect() + }; + + // 3-glyph palette ` x✓`: empty / half / done, plus the fixed rejected `-`. + let syms = ListSyms::new(" x✓"); + let cases: &[(&str, CheckboxState)] = &[ + ("- [ ] a\n", CheckboxState::Empty), + ("- [x] a\n", CheckboxState::Half), + ("- [✓] a\n", CheckboxState::Done), + ("- [-] a\n", CheckboxState::Rejected), + ]; + for (line, expected) in cases { + let lexed = lex_with(line, syms.clone()); + assert!( + matches!(lexed[1], Checkbox(s) if s == *expected), + "input {line:?} expected {expected:?}, got {:?}", + lexed[1] + ); + } + + // Default-palette glyphs are not checkboxes under this palette: `[o]` + // is plain inline text, so no Checkbox token is emitted. + let lexed = lex_with("- [o] a\n", syms); + assert!( + !lexed.iter().any(|k| matches!(k, Checkbox(_))), + "expected no checkbox token, got {lexed:?}" + ); +} + // ===== Blockquotes ===== #[test]