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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,5 +5,6 @@
|
|||||||
|
|
||||||
pub mod ast;
|
pub mod ast;
|
||||||
pub mod date;
|
pub mod date;
|
||||||
|
pub mod listsyms;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod syntax;
|
pub mod syntax;
|
||||||
|
|||||||
@@ -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<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<char> = 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<usize> {
|
||||||
|
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<CheckboxState> {
|
||||||
|
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), '✓');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::ast::{CheckboxState, Keyword, ListSymbol, Position, Span};
|
use crate::ast::{CheckboxState, Keyword, ListSymbol, Position, Span};
|
||||||
|
use crate::listsyms::ListSyms;
|
||||||
use crate::syntax::{Lexer, TokenStream};
|
use crate::syntax::{Lexer, TokenStream};
|
||||||
|
|
||||||
/// A single vimwiki token. The `kind` carries the variant, `span` carries
|
/// A single vimwiki token. The `kind` carries the variant, `span` carries
|
||||||
@@ -134,11 +135,20 @@ pub enum VimwikiTokenKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct VimwikiLexer;
|
pub struct VimwikiLexer {
|
||||||
|
listsyms: ListSyms,
|
||||||
|
}
|
||||||
|
|
||||||
impl VimwikiLexer {
|
impl VimwikiLexer {
|
||||||
pub fn new() -> Self {
|
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;
|
type Token = VimwikiToken;
|
||||||
|
|
||||||
fn lex(&self, text: &str) -> TokenStream<VimwikiToken> {
|
fn lex(&self, text: &str) -> TokenStream<VimwikiToken> {
|
||||||
let mut state = LexState::new(text);
|
let mut state = LexState::new(text, &self.listsyms);
|
||||||
state.run();
|
state.run();
|
||||||
TokenStream::from_vec(state.tokens)
|
TokenStream::from_vec(state.tokens)
|
||||||
}
|
}
|
||||||
@@ -170,16 +180,18 @@ struct LexState<'src> {
|
|||||||
line: u32,
|
line: u32,
|
||||||
tokens: Vec<VimwikiToken>,
|
tokens: Vec<VimwikiToken>,
|
||||||
mode: BlockMode,
|
mode: BlockMode,
|
||||||
|
listsyms: &'src ListSyms,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'src> LexState<'src> {
|
impl<'src> LexState<'src> {
|
||||||
fn new(src: &'src str) -> Self {
|
fn new(src: &'src str, listsyms: &'src ListSyms) -> Self {
|
||||||
Self {
|
Self {
|
||||||
src,
|
src,
|
||||||
line_start_offset: 0,
|
line_start_offset: 0,
|
||||||
line: 0,
|
line: 0,
|
||||||
tokens: Vec::new(),
|
tokens: Vec::new(),
|
||||||
mode: BlockMode::Normal,
|
mode: BlockMode::Normal,
|
||||||
|
listsyms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +716,7 @@ impl<'src> LexState<'src> {
|
|||||||
|
|
||||||
// Optional checkbox `[ ]` / `[.]` / `[o]` / `[O]` / `[X]` / `[-]`,
|
// Optional checkbox `[ ]` / `[.]` / `[o]` / `[O]` / `[X]` / `[-]`,
|
||||||
// followed by a space.
|
// 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_start = indent + cursor as u32;
|
||||||
let cb_end = cb_start + cb_len as u32;
|
let cb_end = cb_start + cb_len as u32;
|
||||||
self.emit(VimwikiTokenKind::Checkbox(cb), cb_start, cb_end);
|
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)> {
|
/// Recognise a `[g]` checkbox at the start of `s`, where `g` is a single
|
||||||
let b = s.as_bytes();
|
/// glyph drawn from `listsyms` (or the rejected marker). Returns the bucketed
|
||||||
if b.len() < 3 || b[0] != b'[' || b[2] != b']' {
|
/// 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;
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub use lexer::{VimwikiLexer, VimwikiToken, VimwikiTokenKind};
|
|||||||
pub use parser::VimwikiParser;
|
pub use parser::VimwikiParser;
|
||||||
|
|
||||||
use crate::ast::DocumentNode;
|
use crate::ast::DocumentNode;
|
||||||
|
use crate::listsyms::ListSyms;
|
||||||
use crate::syntax::{Lexer, Parser, SyntaxPlugin};
|
use crate::syntax::{Lexer, Parser, SyntaxPlugin};
|
||||||
|
|
||||||
/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the
|
/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the
|
||||||
@@ -18,6 +19,15 @@ impl VimwikiSyntax {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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 {
|
impl SyntaxPlugin for VimwikiSyntax {
|
||||||
|
|||||||
@@ -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<VimwikiTokenKind> {
|
||||||
|
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 =====
|
// ===== Blockquotes =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user