Skip to main content

aozora_spec/
slugs.rs

1//! Canonical slug catalogue for Aozora annotation bodies (Phase 1.2 of
2//! the editor-integration sprint).
3//!
4//! `phase3_classify::BODY_PATTERNS` is the *parser-side* aho-corasick
5//! table — its goal is exhaustive matching, including every digit a
6//! `{N}字下げ` form can start with. This module is the *editor-side*
7//! mirror: the public, stable list of slugs an LSP completion menu
8//! offers and the LSP `canonicalize` code action snaps user input to.
9//!
10//! The two tables stay in sync via the
11//! `every_slug_dispatches_in_phase3_body_dispatcher` integration test
12//! living in `aozora-lex`.
13//!
14//! ## Why a separate table
15//!
16//! - **Granularity**: the editor wants `{N}字下げ` (one entry, accepts
17//!   a parameter), not ten distinct rows for each digit prefix.
18//! - **Documentation**: each entry carries a Japanese `doc` string that
19//!   becomes the LSP completion item's `documentation` field.
20//! - **Stability**: `BODY_PATTERNS`'s exact shape is tied to phase-3
21//!   internals (`LeftmostLongest` dispatch order, `BodyFamily` variants);
22//!   downstream editor consumers should not depend on it.
23//!
24//! ## Canonicalisation
25//!
26//! [`canonicalise_slug`] maps a known orthographic *variant* (typically
27//! a hiragana-only spelling — `ぼうてん`, `にぼうてん`) to the canonical
28//! form (`傍点`). The variant table is intentionally small: it covers
29//! the highest-frequency author-side abbreviations the editor surface
30//! treats as a one-keystroke-quick-fix. Any input that is already
31//! canonical short-circuits with `Some(canonical)` so callers can
32//! always trust the return value.
33
34use crate::PairKind;
35
36/// Family / coarse category a slug belongs to. Used by the LSP
37/// completion UI to group entries (`CompletionItem::sort_text`) and
38/// pick an appropriate `CompletionItemKind` icon.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[non_exhaustive]
42pub enum SlugFamily {
43    /// `[#改ページ]` and other section-level page breaks.
44    PageBreak,
45    /// `[#改丁]`, `[#改段]`, `[#改見開き]`.
46    Section,
47    /// Block container open marker (`[#ここから...]`). Pairs with
48    /// the corresponding [`SlugFamily::BlockContainerClose`] slug.
49    BlockContainerOpen,
50    /// Block container close marker (`[#ここで...終わり]`).
51    BlockContainerClose,
52    /// Leaf-line layout slug applied to the immediately preceding
53    /// paragraph (`地付き`, `{N}字下げ`, `地から{N}字上げ`).
54    LeafAlign,
55    /// Forward-reference bouten / underline (`[#「target」に傍点]`).
56    Bouten,
57    /// Inline figure (`[#挿絵(path)入る]`).
58    Sashie,
59    /// Keigakomi rule frame (open / close).
60    Keigakomi,
61    /// Warichu inline-break (open / close).
62    Warichu,
63    /// Forward-reference 縦中横 (`[#「target」は縦中横]`).
64    TateChuYoko,
65    /// Kaeriten single mark (一, 二, 三, 上, 中, 下, 甲, 乙, 丙, 丁,
66    /// 四, レ).
67    KaeritenSingle,
68    /// Kaeriten compound mark (一レ, 二レ, …).
69    KaeritenCompound,
70}
71
72/// One row of the slug catalogue.
73#[derive(Debug, Clone, Copy)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct SlugEntry {
76    /// Canonical body text (without the surrounding `[#` / `]`).
77    pub canonical: &'static str,
78    /// Coarse category / family.
79    pub family: SlugFamily,
80    /// Whether the slug expects a numeric parameter (or, for `Sashie`,
81    /// a path) following the canonical text.
82    pub accepts_param: bool,
83    /// Short Japanese description shown in LSP `CompletionItem.detail`
84    /// / `documentation` fields. Single sentence, no surrounding
85    /// punctuation, terminating period.
86    pub doc: &'static str,
87    /// For `BlockContainerOpen` / `BlockContainerClose` slugs, the
88    /// canonical text of the partner slug — so the editor can link
89    /// them together (insert close on accept, jump to partner, …).
90    /// `None` for non-paired families.
91    pub partner: Option<&'static str>,
92    /// Always [`PairKind::Bracket`] for the slugs in this table — the
93    /// surrounding `[# … ]` is a bracket pair. Carried so editor
94    /// snippets can render the wrapper bracket pair without having to
95    /// re-derive it.
96    pub wrapper: PairKind,
97}
98
99/// Canonical slug catalogue. See module docs.
100///
101/// Order is irrelevant for behavior; entries are grouped by family for
102/// readability. The `every_canonical_resolves_through_canonicalise_slug`
103/// test pins identity round-trip for every entry.
104pub const SLUGS: &[SlugEntry] = &[
105    // --- Section / page break ----------------------------------------------
106    SlugEntry {
107        canonical: "改ページ",
108        family: SlugFamily::PageBreak,
109        accepts_param: false,
110        doc: "ページを改める",
111        partner: None,
112        wrapper: PairKind::Bracket,
113    },
114    SlugEntry {
115        canonical: "改丁",
116        family: SlugFamily::Section,
117        accepts_param: false,
118        doc: "改丁(次の奇数ページから)",
119        partner: None,
120        wrapper: PairKind::Bracket,
121    },
122    SlugEntry {
123        canonical: "改段",
124        family: SlugFamily::Section,
125        accepts_param: false,
126        doc: "改段(段組を改める)",
127        partner: None,
128        wrapper: PairKind::Bracket,
129    },
130    SlugEntry {
131        canonical: "改見開き",
132        family: SlugFamily::Section,
133        accepts_param: false,
134        doc: "改見開き(次の見開きへ)",
135        partner: None,
136        wrapper: PairKind::Bracket,
137    },
138    // --- Block containers (open / close pairs) -----------------------------
139    SlugEntry {
140        canonical: "ここから字下げ",
141        family: SlugFamily::BlockContainerOpen,
142        accepts_param: false,
143        doc: "1字下げを開始(終わりまで)",
144        partner: Some("ここで字下げ終わり"),
145        wrapper: PairKind::Bracket,
146    },
147    SlugEntry {
148        canonical: "ここから{N}字下げ",
149        family: SlugFamily::BlockContainerOpen,
150        accepts_param: true,
151        doc: "N字下げを開始(終わりまで)",
152        partner: Some("ここで字下げ終わり"),
153        wrapper: PairKind::Bracket,
154    },
155    SlugEntry {
156        canonical: "ここで字下げ終わり",
157        family: SlugFamily::BlockContainerClose,
158        accepts_param: false,
159        doc: "字下げブロックを閉じる",
160        partner: Some("ここから字下げ"),
161        wrapper: PairKind::Bracket,
162    },
163    SlugEntry {
164        canonical: "ここから地付き",
165        family: SlugFamily::BlockContainerOpen,
166        accepts_param: false,
167        doc: "地付きを開始",
168        partner: Some("ここで地付き終わり"),
169        wrapper: PairKind::Bracket,
170    },
171    SlugEntry {
172        canonical: "ここから地から{N}字上げ",
173        family: SlugFamily::BlockContainerOpen,
174        accepts_param: true,
175        doc: "地からN字上げを開始",
176        partner: Some("ここで地付き終わり"),
177        wrapper: PairKind::Bracket,
178    },
179    SlugEntry {
180        canonical: "ここで地付き終わり",
181        family: SlugFamily::BlockContainerClose,
182        accepts_param: false,
183        doc: "地付きブロックを閉じる",
184        partner: Some("ここから地付き"),
185        wrapper: PairKind::Bracket,
186    },
187    SlugEntry {
188        canonical: "罫囲み",
189        family: SlugFamily::Keigakomi,
190        accepts_param: false,
191        doc: "罫線で囲む(終わりまで)",
192        partner: Some("罫囲み終わり"),
193        wrapper: PairKind::Bracket,
194    },
195    SlugEntry {
196        canonical: "罫囲み終わり",
197        family: SlugFamily::Keigakomi,
198        accepts_param: false,
199        doc: "罫囲みを閉じる",
200        partner: Some("罫囲み"),
201        wrapper: PairKind::Bracket,
202    },
203    SlugEntry {
204        canonical: "割り注",
205        family: SlugFamily::Warichu,
206        accepts_param: false,
207        doc: "割り注を開始(終わりまで)",
208        partner: Some("割り注終わり"),
209        wrapper: PairKind::Bracket,
210    },
211    SlugEntry {
212        canonical: "割り注終わり",
213        family: SlugFamily::Warichu,
214        accepts_param: false,
215        doc: "割り注を閉じる",
216        partner: Some("割り注"),
217        wrapper: PairKind::Bracket,
218    },
219    // --- Leaf alignment (single-paragraph) ---------------------------------
220    SlugEntry {
221        canonical: "地付き",
222        family: SlugFamily::LeafAlign,
223        accepts_param: false,
224        doc: "前の段落を地付きに揃える",
225        partner: None,
226        wrapper: PairKind::Bracket,
227    },
228    SlugEntry {
229        canonical: "地から{N}字上げ",
230        family: SlugFamily::LeafAlign,
231        accepts_param: true,
232        doc: "前の段落を地からN字上げて揃える",
233        partner: None,
234        wrapper: PairKind::Bracket,
235    },
236    SlugEntry {
237        canonical: "{N}字下げ",
238        family: SlugFamily::LeafAlign,
239        accepts_param: true,
240        doc: "前の段落をN字下げる(単発)",
241        partner: None,
242        wrapper: PairKind::Bracket,
243    },
244    // --- Bouten / underline (forward-ref via 「target」に...) --------------
245    SlugEntry {
246        canonical: "傍点",
247        family: SlugFamily::Bouten,
248        accepts_param: false,
249        doc: "ゴマ傍点([#「対象」に傍点])",
250        partner: None,
251        wrapper: PairKind::Bracket,
252    },
253    SlugEntry {
254        canonical: "白ゴマ傍点",
255        family: SlugFamily::Bouten,
256        accepts_param: false,
257        doc: "白ゴマ傍点",
258        partner: None,
259        wrapper: PairKind::Bracket,
260    },
261    SlugEntry {
262        canonical: "丸傍点",
263        family: SlugFamily::Bouten,
264        accepts_param: false,
265        doc: "丸傍点",
266        partner: None,
267        wrapper: PairKind::Bracket,
268    },
269    SlugEntry {
270        canonical: "白丸傍点",
271        family: SlugFamily::Bouten,
272        accepts_param: false,
273        doc: "白丸傍点",
274        partner: None,
275        wrapper: PairKind::Bracket,
276    },
277    SlugEntry {
278        canonical: "二重丸傍点",
279        family: SlugFamily::Bouten,
280        accepts_param: false,
281        doc: "二重丸傍点",
282        partner: None,
283        wrapper: PairKind::Bracket,
284    },
285    SlugEntry {
286        canonical: "蛇の目傍点",
287        family: SlugFamily::Bouten,
288        accepts_param: false,
289        doc: "蛇の目傍点",
290        partner: None,
291        wrapper: PairKind::Bracket,
292    },
293    SlugEntry {
294        canonical: "ばつ傍点",
295        family: SlugFamily::Bouten,
296        accepts_param: false,
297        doc: "ばつ傍点",
298        partner: None,
299        wrapper: PairKind::Bracket,
300    },
301    SlugEntry {
302        canonical: "白三角傍点",
303        family: SlugFamily::Bouten,
304        accepts_param: false,
305        doc: "白三角傍点",
306        partner: None,
307        wrapper: PairKind::Bracket,
308    },
309    SlugEntry {
310        canonical: "波線",
311        family: SlugFamily::Bouten,
312        accepts_param: false,
313        doc: "波線(傍線の波形)",
314        partner: None,
315        wrapper: PairKind::Bracket,
316    },
317    SlugEntry {
318        canonical: "傍線",
319        family: SlugFamily::Bouten,
320        accepts_param: false,
321        doc: "傍線(下線)",
322        partner: None,
323        wrapper: PairKind::Bracket,
324    },
325    SlugEntry {
326        canonical: "二重傍線",
327        family: SlugFamily::Bouten,
328        accepts_param: false,
329        doc: "二重傍線(二重下線)",
330        partner: None,
331        wrapper: PairKind::Bracket,
332    },
333    // --- Other inline -----------------------------------------------------
334    SlugEntry {
335        canonical: "挿絵({path})入る",
336        family: SlugFamily::Sashie,
337        accepts_param: true,
338        doc: "挿絵を埋め込む",
339        partner: None,
340        wrapper: PairKind::Bracket,
341    },
342    SlugEntry {
343        canonical: "縦中横",
344        family: SlugFamily::TateChuYoko,
345        accepts_param: false,
346        doc: "縦中横([#「対象」は縦中横])",
347        partner: None,
348        wrapper: PairKind::Bracket,
349    },
350    // --- Kaeriten single (12) ---------------------------------------------
351    SlugEntry {
352        canonical: "一",
353        family: SlugFamily::KaeritenSingle,
354        accepts_param: false,
355        doc: "返り点 一",
356        partner: None,
357        wrapper: PairKind::Bracket,
358    },
359    SlugEntry {
360        canonical: "二",
361        family: SlugFamily::KaeritenSingle,
362        accepts_param: false,
363        doc: "返り点 二",
364        partner: None,
365        wrapper: PairKind::Bracket,
366    },
367    SlugEntry {
368        canonical: "三",
369        family: SlugFamily::KaeritenSingle,
370        accepts_param: false,
371        doc: "返り点 三",
372        partner: None,
373        wrapper: PairKind::Bracket,
374    },
375    SlugEntry {
376        canonical: "四",
377        family: SlugFamily::KaeritenSingle,
378        accepts_param: false,
379        doc: "返り点 四",
380        partner: None,
381        wrapper: PairKind::Bracket,
382    },
383    SlugEntry {
384        canonical: "上",
385        family: SlugFamily::KaeritenSingle,
386        accepts_param: false,
387        doc: "返り点 上",
388        partner: None,
389        wrapper: PairKind::Bracket,
390    },
391    SlugEntry {
392        canonical: "中",
393        family: SlugFamily::KaeritenSingle,
394        accepts_param: false,
395        doc: "返り点 中",
396        partner: None,
397        wrapper: PairKind::Bracket,
398    },
399    SlugEntry {
400        canonical: "下",
401        family: SlugFamily::KaeritenSingle,
402        accepts_param: false,
403        doc: "返り点 下",
404        partner: None,
405        wrapper: PairKind::Bracket,
406    },
407    SlugEntry {
408        canonical: "甲",
409        family: SlugFamily::KaeritenSingle,
410        accepts_param: false,
411        doc: "返り点 甲",
412        partner: None,
413        wrapper: PairKind::Bracket,
414    },
415    SlugEntry {
416        canonical: "乙",
417        family: SlugFamily::KaeritenSingle,
418        accepts_param: false,
419        doc: "返り点 乙",
420        partner: None,
421        wrapper: PairKind::Bracket,
422    },
423    SlugEntry {
424        canonical: "丙",
425        family: SlugFamily::KaeritenSingle,
426        accepts_param: false,
427        doc: "返り点 丙",
428        partner: None,
429        wrapper: PairKind::Bracket,
430    },
431    SlugEntry {
432        canonical: "丁",
433        family: SlugFamily::KaeritenSingle,
434        accepts_param: false,
435        doc: "返り点 丁",
436        partner: None,
437        wrapper: PairKind::Bracket,
438    },
439    SlugEntry {
440        canonical: "レ",
441        family: SlugFamily::KaeritenSingle,
442        accepts_param: false,
443        doc: "返り点 レ",
444        partner: None,
445        wrapper: PairKind::Bracket,
446    },
447    // --- Kaeriten compound (6) --------------------------------------------
448    SlugEntry {
449        canonical: "一レ",
450        family: SlugFamily::KaeritenCompound,
451        accepts_param: false,
452        doc: "返り点 一レ",
453        partner: None,
454        wrapper: PairKind::Bracket,
455    },
456    SlugEntry {
457        canonical: "二レ",
458        family: SlugFamily::KaeritenCompound,
459        accepts_param: false,
460        doc: "返り点 二レ",
461        partner: None,
462        wrapper: PairKind::Bracket,
463    },
464    SlugEntry {
465        canonical: "三レ",
466        family: SlugFamily::KaeritenCompound,
467        accepts_param: false,
468        doc: "返り点 三レ",
469        partner: None,
470        wrapper: PairKind::Bracket,
471    },
472    SlugEntry {
473        canonical: "上レ",
474        family: SlugFamily::KaeritenCompound,
475        accepts_param: false,
476        doc: "返り点 上レ",
477        partner: None,
478        wrapper: PairKind::Bracket,
479    },
480    SlugEntry {
481        canonical: "中レ",
482        family: SlugFamily::KaeritenCompound,
483        accepts_param: false,
484        doc: "返り点 中レ",
485        partner: None,
486        wrapper: PairKind::Bracket,
487    },
488    SlugEntry {
489        canonical: "下レ",
490        family: SlugFamily::KaeritenCompound,
491        accepts_param: false,
492        doc: "返り点 下レ",
493        partner: None,
494        wrapper: PairKind::Bracket,
495    },
496];
497
498/// Variant → canonical mapping for [`canonicalise_slug`]. Each row
499/// covers one common abbreviation or hiragana spelling that the LSP
500/// snaps to the canonical form. Identity rows (`canonical → canonical`)
501/// are inserted automatically by [`canonicalise_slug`] so this table
502/// only needs the *non-trivial* variants.
503const VARIANTS: &[(&str, &str)] = &[
504    // Bouten — hiragana variants commonly typed in drafts.
505    ("ぼうてん", "傍点"),
506    ("にぼうてん", "傍点"),
507    ("しろぼうてん", "白ゴマ傍点"),
508    ("しろごまぼうてん", "白ゴマ傍点"),
509    ("まるぼうてん", "丸傍点"),
510    ("にまるぼうてん", "丸傍点"),
511    ("しろまるぼうてん", "白丸傍点"),
512    ("にしろまるぼうてん", "白丸傍点"),
513    ("にじゅうまるぼうてん", "二重丸傍点"),
514    ("じゃのめぼうてん", "蛇の目傍点"),
515    ("ばつぼうてん", "ばつ傍点"),
516    ("しろさんかくぼうてん", "白三角傍点"),
517    ("はせん", "波線"),
518    ("ぼうせん", "傍線"),
519    ("にじゅうぼうせん", "二重傍線"),
520    // Page break.
521    ("かいぺーじ", "改ページ"),
522    ("ページかえ", "改ページ"),
523    ("かいちょう", "改丁"),
524    ("かいだん", "改段"),
525    ("かいみひらき", "改見開き"),
526    // Block container open / close.
527    ("ここからじさげ", "ここから字下げ"),
528    ("ここでじさげおわり", "ここで字下げ終わり"),
529    ("ここからじつき", "ここから地付き"),
530    ("ここでじつきおわり", "ここで地付き終わり"),
531    // Leaf align.
532    ("じつき", "地付き"),
533    // Other inline.
534    ("たてちゅうよこ", "縦中横"),
535    ("たて中横", "縦中横"),
536    ("そうにゅうえ", "挿絵({path})入る"),
537    // Keigakomi / warichu.
538    ("けいがこみ", "罫囲み"),
539    ("けいがこみおわり", "罫囲み終わり"),
540    ("わりちゅう", "割り注"),
541    ("わりちゅうおわり", "割り注終わり"),
542];
543
544/// Snap an input slug body (with the surrounding `[# … ]` already
545/// stripped) to the canonical form, if one is recognised.
546///
547/// Returns:
548/// - `Some(s)` — `s` is the canonical text. `s` is `&'static str`
549///   pointing into [`SLUGS`]'s `canonical` field, so callers can use
550///   it as a stable key.
551/// - `None` — no recognised slug. Callers may still parse `input` as a
552///   `{N}字下げ` parametric form (which intentionally has no fixed
553///   variant).
554///
555/// Identity rows are accepted: passing a canonical string back returns
556/// the same pointer. This lets the LSP's `canonicalize` code action
557/// short-circuit safely.
558#[must_use]
559pub fn canonicalise_slug(input: &str) -> Option<&'static str> {
560    // Identity short-circuit. SLUGS is small (~40 entries) and the
561    // strings are short, so a linear scan beats hashing on cache cost.
562    for entry in SLUGS {
563        if entry.canonical == input {
564            return Some(entry.canonical);
565        }
566    }
567    for &(variant, canonical) in VARIANTS {
568        if variant == input {
569            return Some(canonical);
570        }
571    }
572    None
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn slugs_table_is_non_empty() {
581        assert!(!SLUGS.is_empty());
582    }
583
584    #[test]
585    fn slugs_have_unique_canonical_strings() {
586        let mut seen: Vec<&'static str> = Vec::with_capacity(SLUGS.len());
587        for entry in SLUGS {
588            assert!(
589                !seen.contains(&entry.canonical),
590                "duplicate canonical: {}",
591                entry.canonical
592            );
593            seen.push(entry.canonical);
594        }
595    }
596
597    #[test]
598    fn every_canonical_is_self_canonical() {
599        for entry in SLUGS {
600            let resolved = canonicalise_slug(entry.canonical)
601                .unwrap_or_else(|| panic!("canonical {} did not resolve", entry.canonical));
602            assert_eq!(resolved, entry.canonical);
603        }
604    }
605
606    #[test]
607    fn known_hiragana_variants_resolve_to_canonical() {
608        assert_eq!(canonicalise_slug("ぼうてん"), Some("傍点"));
609        assert_eq!(canonicalise_slug("にぼうてん"), Some("傍点"));
610        assert_eq!(canonicalise_slug("しろまるぼうてん"), Some("白丸傍点"));
611        assert_eq!(canonicalise_slug("ここからじさげ"), Some("ここから字下げ"));
612    }
613
614    #[test]
615    fn unknown_input_returns_none() {
616        assert_eq!(canonicalise_slug("nonsense"), None);
617        assert_eq!(canonicalise_slug(""), None);
618    }
619
620    #[test]
621    fn paired_slugs_reference_existing_partner() {
622        for entry in SLUGS {
623            if let Some(partner) = entry.partner {
624                let found = SLUGS.iter().any(|e| e.canonical == partner);
625                assert!(
626                    found,
627                    "partner {partner} not in SLUGS for {}",
628                    entry.canonical
629                );
630            }
631        }
632    }
633
634    #[test]
635    fn block_container_open_pairs_with_close() {
636        // Every BlockContainerOpen entry must point at a partner whose
637        // family is BlockContainerClose, and vice versa.
638        for entry in SLUGS {
639            match entry.family {
640                SlugFamily::BlockContainerOpen => {
641                    let partner_canonical = entry
642                        .partner
643                        .unwrap_or_else(|| panic!("open {} has no partner", entry.canonical));
644                    let partner = SLUGS
645                        .iter()
646                        .find(|e| e.canonical == partner_canonical)
647                        .expect("partner exists");
648                    assert!(matches!(
649                        partner.family,
650                        SlugFamily::BlockContainerClose
651                            | SlugFamily::Keigakomi
652                            | SlugFamily::Warichu
653                    ));
654                }
655                SlugFamily::BlockContainerClose => {
656                    let partner_canonical = entry
657                        .partner
658                        .unwrap_or_else(|| panic!("close {} has no partner", entry.canonical));
659                    let partner = SLUGS
660                        .iter()
661                        .find(|e| e.canonical == partner_canonical)
662                        .expect("partner exists");
663                    assert!(matches!(
664                        partner.family,
665                        SlugFamily::BlockContainerOpen
666                            | SlugFamily::Keigakomi
667                            | SlugFamily::Warichu
668                    ));
669                }
670                _ => {}
671            }
672        }
673    }
674
675    #[test]
676    fn accepts_param_aligns_with_brace_in_canonical() {
677        // Every entry whose canonical contains `{` must have
678        // accepts_param == true, and vice versa.
679        for entry in SLUGS {
680            let has_brace = entry.canonical.contains('{');
681            assert_eq!(
682                entry.accepts_param, has_brace,
683                "accepts_param/brace mismatch on {}",
684                entry.canonical
685            );
686        }
687    }
688
689    #[test]
690    fn variant_table_resolves_to_strings_in_slugs() {
691        for &(variant, canonical) in VARIANTS {
692            assert!(
693                SLUGS.iter().any(|e| e.canonical == canonical),
694                "variant {variant} maps to unknown canonical {canonical}"
695            );
696        }
697    }
698}