Skip to main content

lattice_syntax/
conceal.rs

1//! Conceal rules — the per-language declaration of what to hide.
2//!
3//! A rule is a regex over one line plus the 1-based capture groups
4//! whose spans are elided. Org's link rendering is two of them and
5//! nothing else; the mechanism, the coordinate maths and the mode
6//! scoping are the host's.
7//!
8//! Design anchor:
9//! [`docs/dev/architecture/conceal.md`](../../../docs/dev/architecture/conceal.md).
10//!
11//! ## Why `regex` and not `fancy-regex`
12//!
13//! The workspace carries both: `fancy-regex` for `/`, `?` and `:s`,
14//! where users expect lookaround, and `regex` for everything that
15//! must not backtrack. Conceal patterns run against every rebuilt
16//! display line, supplied by a *plugin*, so a catastrophic
17//! backtracking pattern would be a plugin's ability to freeze the
18//! renderer. `regex`'s RE2-style engine has no such input, which
19//! turns "don't ship a pathological pattern" from a plugin-author
20//! obligation into something they cannot do.
21//!
22//! ## Why a bad rule is dropped rather than fatal
23//!
24//! A malformed tree-sitter *query* rejects the whole language
25//! (`LanguageRegistrationError::QueryCompile`), and that asymmetry
26//! is deliberate rather than an oversight. A broken `folds.scm`
27//! means the language cannot fold at all — structural, and silence
28//! there is indistinguishable from the feature not existing. A
29//! broken conceal rule means one pattern does not hide, the other
30//! rules are unaffected, and the language is otherwise entirely
31//! usable. Losing org over a typo in a cosmetic regex would be the
32//! disproportionate answer.
33
34use crate::Style;
35use regex::Regex;
36
37/// The most rules one language may declare.
38///
39/// Not a backtracking guard — the engine has none. It bounds how
40/// much of *someone else's configuration* a display-matrix rebuild
41/// has to walk: every rule is tried against every rebuilt line, so
42/// an unbounded list turns a rebuild into a linear scan of a
43/// plugin's ambition.
44pub const MAX_CONCEAL_RULES: usize = 32;
45
46/// The longest pattern accepted, in bytes. Compilation cost and
47/// per-line match cost both scale with program size, and no
48/// legitimate line-level rule comes close.
49pub const MAX_CONCEAL_PATTERN_LEN: usize = 512;
50
51/// One compiled display-time elision rule.
52#[derive(Debug, Clone)]
53pub struct ConcealRule {
54    pattern: Regex,
55    hide: Vec<u32>,
56    /// OL.1: the style for what stays VISIBLE in a match, or `None` for a
57    /// rule that only elides.
58    ///
59    /// Per-RULE, not per language, and that is the whole point: conceal is a
60    /// general mechanism and most rules simply hide punctuation. Only the
61    /// ones whose remainder means something — an org link's description —
62    /// carry a style, so adding a conceal rule for something else cannot
63    /// accidentally paint it.
64    style: Option<Style>,
65}
66
67impl ConcealRule {
68    /// The compiled pattern, for matching a line.
69    pub fn pattern(&self) -> &Regex {
70        &self.pattern
71    }
72
73    /// 1-based capture-group indices this rule hides.
74    pub fn hide(&self) -> &[u32] {
75        &self.hide
76    }
77
78    /// The pattern source, for diagnostics and equality.
79    pub fn source(&self) -> &str {
80        self.pattern.as_str()
81    }
82
83    /// OL.1: the style for this rule's visible remainder, if it declared one.
84    pub fn style(&self) -> Option<Style> {
85        self.style
86    }
87}
88
89/// Compiled rules compare by their declaration, not by their
90/// compiled program — `Regex` has no `PartialEq`, and two rules are
91/// the same rule exactly when a plugin declared them the same way.
92impl PartialEq for ConcealRule {
93    fn eq(&self, other: &Self) -> bool {
94        self.pattern.as_str() == other.pattern.as_str() && self.hide == other.hide
95    }
96}
97
98impl Eq for ConcealRule {}
99
100/// Why one rule was refused.
101///
102/// Every variant names the rule, because the message reaches a
103/// plugin author who has several and needs to know which.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum ConcealRuleError {
106    EmptyPattern,
107    PatternTooLong {
108        len: usize,
109    },
110    BadRegex {
111        detail: String,
112    },
113    /// No groups listed, so the rule would hide nothing and cost a
114    /// match per line to do it.
115    NothingHidden,
116    /// Group 0 is the whole match. Hiding it is a deletion rather
117    /// than a concealment and is almost always a pattern that
118    /// forgot its capture parentheses.
119    HidesWholeMatch,
120    /// A `hide` index past the pattern's group count. Caught here
121    /// rather than per line, where it would log at rebuild rate.
122    UnknownGroup {
123        group: u32,
124        groups: u32,
125    },
126    /// The language already declared [`MAX_CONCEAL_RULES`].
127    TooManyRules {
128        limit: usize,
129    },
130}
131
132impl std::fmt::Display for ConcealRuleError {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::EmptyPattern => write!(f, "pattern is empty"),
136            Self::PatternTooLong { len } => {
137                write!(
138                    f,
139                    "pattern is {len} bytes, limit is {MAX_CONCEAL_PATTERN_LEN}"
140                )
141            }
142            Self::BadRegex { detail } => write!(f, "pattern does not compile: {detail}"),
143            Self::NothingHidden => write!(f, "hide is empty, so the rule would hide nothing"),
144            Self::HidesWholeMatch => write!(
145                f,
146                "hide names group 0 (the whole match) — that is a deletion, not a \
147                 concealment; the pattern is probably missing its capture parentheses"
148            ),
149            Self::UnknownGroup { group, groups } => {
150                write!(f, "hide names group {group} but the pattern has {groups}")
151            }
152            Self::TooManyRules { limit } => {
153                write!(f, "more than {limit} conceal rules")
154            }
155        }
156    }
157}
158
159impl std::error::Error for ConcealRuleError {}
160
161/// Compile one rule, or say precisely why not.
162pub fn compile_rule(pattern: &str, hide: &[u32]) -> Result<ConcealRule, ConcealRuleError> {
163    compile_rule_styled(pattern, hide, None, None)
164}
165
166/// OL.1: [`compile_rule`], plus the style its visible remainder takes.
167///
168/// `slot` is a capture or theme-element NAME, resolved here — at compile
169/// time, once, the way query sources are — rather than per line. It goes
170/// through `name_to_style_with_theme`, which is the same path a
171/// `highlights.scm` capture takes, so a concealed link is coloured by the
172/// same vocabulary and the same active colourscheme as everything else. An
173/// unresolvable name is `Style::Default`: the rule still conceals, it just
174/// does not paint, which is the right severity for a theme that has not
175/// registered an element yet.
176pub fn compile_rule_styled(
177    pattern: &str,
178    hide: &[u32],
179    slot: Option<&str>,
180    theme: Option<&dyn lattice_theme::ThemeRegistry>,
181) -> Result<ConcealRule, ConcealRuleError> {
182    if pattern.trim().is_empty() {
183        return Err(ConcealRuleError::EmptyPattern);
184    }
185    if pattern.len() > MAX_CONCEAL_PATTERN_LEN {
186        return Err(ConcealRuleError::PatternTooLong { len: pattern.len() });
187    }
188    if hide.is_empty() {
189        return Err(ConcealRuleError::NothingHidden);
190    }
191    if hide.contains(&0) {
192        return Err(ConcealRuleError::HidesWholeMatch);
193    }
194    let compiled = Regex::new(pattern).map_err(|e| ConcealRuleError::BadRegex {
195        // `regex`'s error renders as a multi-line diagram; the log
196        // line wants one line.
197        detail: e.to_string().replace('\n', " ").trim().to_string(),
198    })?;
199    // `captures_len` counts group 0, so the highest addressable
200    // 1-based group is one less.
201    let groups = compiled.captures_len().saturating_sub(1) as u32;
202    if let Some(&bad) = hide.iter().find(|g| **g > groups) {
203        return Err(ConcealRuleError::UnknownGroup { group: bad, groups });
204    }
205    let mut hide = hide.to_vec();
206    // Sorted + deduped so the matcher can trust the order and a rule
207    // listing a group twice does not subtract its width twice.
208    hide.sort_unstable();
209    hide.dedup();
210    Ok(ConcealRule {
211        pattern: compiled,
212        hide,
213        style: slot
214            .filter(|s| !s.trim().is_empty())
215            .map(|s| crate::style::name_to_style_with_theme(s, theme)),
216    })
217}
218
219/// Compile a language's whole declaration, keeping what works.
220///
221/// Returns the accepted rules and one rejection per refused rule.
222/// **A refusal never fails the language** — see the module header
223/// for why this is asymmetric with query compilation. The caller
224/// logs each rejection once, at registration; logging per line
225/// would emit at rebuild rate, which is the `debug!`-not-`info!`
226/// mistake wearing a different hat.
227pub fn compile_rules(
228    // OL.1: `(pattern, hide, slot)`. The slot is a capture/theme name for the
229    // match's visible remainder, resolved HERE — once, at registration, the
230    // way query sources are — rather than per line.
231    declared: &[(String, Vec<u32>, Option<String>)],
232    theme: Option<&dyn lattice_theme::ThemeRegistry>,
233) -> (Vec<ConcealRule>, Vec<(usize, ConcealRuleError)>) {
234    let mut ok = Vec::new();
235    let mut errs = Vec::new();
236    for (i, (pattern, hide, slot)) in declared.iter().enumerate() {
237        if ok.len() >= MAX_CONCEAL_RULES {
238            errs.push((
239                i,
240                ConcealRuleError::TooManyRules {
241                    limit: MAX_CONCEAL_RULES,
242                },
243            ));
244            continue;
245        }
246        match compile_rule_styled(pattern, hide, slot.as_deref(), theme) {
247            Ok(r) => ok.push(r),
248            Err(e) => errs.push((i, e)),
249        }
250    }
251    (ok, errs)
252}
253
254/// A stamp identifying a rule set, for the matrix's `conceal` axis.
255///
256/// **Zero for an empty rule set, and that is the load-bearing case.**
257/// Every buffer whose language declares no rules gets a constant, so
258/// the axis cannot move for it — which is what keeps `i` in a Rust
259/// file from costing a viewport rebuild once H.4 folds the modal state
260/// in here. The check is `is_empty()` before any hashing, so those
261/// buffers pay a branch.
262///
263/// Hashes the declarations rather than the `Arc`'s address: a pointer
264/// would change on any reallocation that did not change a rule, and
265/// would compare equal across two different rule sets that happened to
266/// land at the same address after a free.
267pub fn rules_version(rules: &[ConcealRule]) -> u64 {
268    if rules.is_empty() {
269        return 0;
270    }
271    use std::hash::{Hash, Hasher};
272    let mut h = std::collections::hash_map::DefaultHasher::new();
273    for r in rules {
274        r.source().hash(&mut h);
275        r.hide().hash(&mut h);
276    }
277    // Fold in a non-zero marker so a rule set cannot hash to 0 and
278    // become indistinguishable from "no rules".
279    h.finish() | 1
280}
281
282/// The byte ranges `rules` hide in `line` — sorted and coalesced.
283///
284/// Rules are tried in declaration order and every match of every rule
285/// contributes; the result is the **union** of their hidden groups.
286///
287/// ## Why the union is coalesced before it is returned
288///
289/// Two rules hiding overlapping spans must produce one hidden span.
290/// The consumer subtracts `end - start` per range to find a display
291/// column, so an un-merged overlap subtracts its shared width twice
292/// and every column past it on that line is wrong. Merging here rather
293/// than trusting callers means the invariant holds at the one place it
294/// can be established.
295///
296/// ## Declaration order does not affect the result
297///
298/// Stated because the design originally claimed the opposite, and a
299/// test caught it. Every rule is tried at every position and the
300/// hidden spans are unioned, so no rule can consume text before
301/// another sees it — the output is the same under any permutation of
302/// the rule list.
303///
304/// The worry that motivated the retracted claim was that a described
305/// org link `[[a][b]]` would be matched as a *bare* one, hiding the
306/// outer brackets and leaving `a][b` on screen. It cannot happen, for
307/// a second and independent reason: the bare pattern's `[^]]+` stops
308/// at the first `]`, so it never reaches the closing `]]` of a
309/// described link and does not match it at all. The two patterns are
310/// disjoint by construction, which is a property of how they are
311/// written rather than of the order they are declared in.
312///
313/// What a rule author must therefore get right is the *pattern*, not
314/// its position: a rule that matches more than it means to will hide
315/// more than it means to no matter where it sits.
316///
317/// Returns empty immediately when there are no rules, which is the
318/// path every buffer in the editor but one takes.
319/// OL.1: the styled runs `rules` paint in `line` — each match's VISIBLE
320/// remainder, for rules that declared a style.
321///
322/// "What survives concealment" is the styling unit, and picking it rather
323/// than a named capture group is what makes one declaration serve both of
324/// org's link forms without a second knob:
325///
326/// ```text
327/// [[target][description]]   hides `[[target][` and `]]`  → styles `description`
328/// [[target]]               hides `[[` and `]]`           → styles `target`
329/// ```
330///
331/// Semantically: what is left on screen after concealing IS the link, so the
332/// mechanism that decides where a link is also decides what to paint. Two
333/// mechanisms could disagree about that; one cannot.
334///
335/// Returns empty immediately when no rule carries a style — which is every
336/// language today but org, and every org rule but the two link ones.
337pub fn conceal_style_spans(rules: &[ConcealRule], line: &str) -> Vec<(u32, u32, Style)> {
338    if line.is_empty() || !rules.iter().any(|r| r.style.is_some()) {
339        return Vec::new();
340    }
341    let mut out: Vec<(u32, u32, Style)> = Vec::new();
342    for rule in rules {
343        let Some(style) = rule.style else {
344            continue;
345        };
346        for caps in rule.pattern.captures_iter(line) {
347            let Some(whole) = caps.get(0) else {
348                continue;
349            };
350            // The hidden ranges WITHIN this match, so the remainder is what is
351            // left of it. Computed per match rather than from `conceal_spans`,
352            // which unions across every rule — a different rule's elision
353            // elsewhere on the line must not carve up this one's styling.
354            let mut hidden: Vec<(usize, usize)> = rule
355                .hide()
356                .iter()
357                .filter_map(|g| caps.get(*g as usize))
358                .filter(|m| m.start() < m.end())
359                .map(|m| (m.start(), m.end()))
360                .collect();
361            hidden.sort_unstable();
362            let mut cursor = whole.start();
363            for (hs, he) in hidden {
364                if hs > cursor {
365                    out.push((cursor as u32, hs as u32, style));
366                }
367                cursor = cursor.max(he);
368            }
369            if cursor < whole.end() {
370                out.push((cursor as u32, whole.end() as u32, style));
371            }
372        }
373    }
374    out.sort_unstable_by_key(|(s, e, _)| (*s, *e));
375    out
376}
377
378pub fn conceal_spans(rules: &[ConcealRule], line: &str) -> Vec<(u32, u32)> {
379    if rules.is_empty() || line.is_empty() {
380        return Vec::new();
381    }
382    let mut spans: Vec<(u32, u32)> = Vec::new();
383    for rule in rules {
384        for caps in rule.pattern.captures_iter(line) {
385            for g in rule.hide() {
386                // A group that did not participate in this match is
387                // `None` — legal and common with alternations, and not
388                // an error: it hid nothing here. An empty match is
389                // dropped for the same reason, before it can become a
390                // zero-width range the coalescer has to reason about.
391                if let Some(m) = caps.get(*g as usize)
392                    && m.start() < m.end()
393                {
394                    spans.push((m.start() as u32, m.end() as u32));
395                }
396            }
397        }
398    }
399    if spans.len() > 1 {
400        spans.sort_unstable();
401        let mut merged: Vec<(u32, u32)> = Vec::with_capacity(spans.len());
402        for (s, e) in spans {
403            match merged.last_mut() {
404                // `<=` not `<`: two ranges that merely touch are one
405                // hidden run, and leaving them adjacent-but-separate
406                // would be a correct-but-noisier list the consumer has
407                // to walk twice.
408                Some(last) if s <= last.1 => last.1 = last.1.max(e),
409                _ => merged.push((s, e)),
410            }
411        }
412        return merged;
413    }
414    spans
415}
416
417#[cfg(test)]
418mod tests {
419    /// OL.1: these tests predate the `slot` parameter and none of them is
420    /// about styling, so they go through a shim rather than repeating
421    /// `, None` at every call.
422    fn compile_rules_t(
423        declared: &[(String, Vec<u32>, Option<String>)],
424    ) -> (Vec<ConcealRule>, Vec<(usize, ConcealRuleError)>) {
425        compile_rules(declared, None)
426    }
427
428    use super::*;
429
430    /// Org's own two rules — the acceptance case this exists for.
431    const DESCRIBED: &str = r"(\[\[[^]]+\]\[)[^]]+(\]\])";
432    const BARE: &str = r"(\[\[)([^]]+)(\]\])";
433
434    #[test]
435    fn orgs_two_rules_compile() {
436        let (ok, errs) = compile_rules_t(&[
437            (DESCRIBED.to_string(), vec![1, 2], None),
438            (BARE.to_string(), vec![1, 3], None),
439        ]);
440        assert_eq!(ok.len(), 2);
441        assert!(errs.is_empty(), "{errs:?}");
442        assert_eq!(ok[0].hide(), &[1, 2]);
443        assert_eq!(ok[1].hide(), &[1, 3]);
444    }
445
446    #[test]
447    fn an_uncompilable_pattern_drops_exactly_one_rule() {
448        // The property the whole module exists for: a plugin does
449        // not lose its language over a typo in a cosmetic regex.
450        let (ok, errs) = compile_rules_t(&[
451            (DESCRIBED.to_string(), vec![1, 2], None),
452            ("(unclosed".to_string(), vec![1], None),
453            (BARE.to_string(), vec![1, 3], None),
454        ]);
455        assert_eq!(ok.len(), 2, "the two good rules survive");
456        assert_eq!(errs.len(), 1);
457        assert_eq!(errs[0].0, 1, "the rejection names which rule");
458        assert!(matches!(errs[0].1, ConcealRuleError::BadRegex { .. }));
459    }
460
461    #[test]
462    fn hiding_group_zero_is_refused() {
463        let e = compile_rule(r"\[\[.+\]\]", &[0]).unwrap_err();
464        assert_eq!(e, ConcealRuleError::HidesWholeMatch);
465        // And the message says what to do about it, because the
466        // cause is almost always missing capture parentheses.
467        assert!(e.to_string().contains("capture parentheses"));
468    }
469
470    #[test]
471    fn a_group_the_pattern_does_not_have_is_refused_at_registration() {
472        // Not per line — this is the check that keeps a bad index
473        // from logging at rebuild rate.
474        let e = compile_rule(DESCRIBED, &[1, 2, 5]).unwrap_err();
475        assert_eq!(
476            e,
477            ConcealRuleError::UnknownGroup {
478                group: 5,
479                groups: 2
480            }
481        );
482    }
483
484    #[test]
485    fn an_empty_hide_list_is_refused() {
486        assert_eq!(
487            compile_rule(DESCRIBED, &[]).unwrap_err(),
488            ConcealRuleError::NothingHidden
489        );
490    }
491
492    #[test]
493    fn an_empty_pattern_is_refused() {
494        assert_eq!(
495            compile_rule("   ", &[1]).unwrap_err(),
496            ConcealRuleError::EmptyPattern
497        );
498    }
499
500    #[test]
501    fn an_overlong_pattern_is_refused() {
502        let long = format!("({})", "a".repeat(MAX_CONCEAL_PATTERN_LEN));
503        let e = compile_rule(&long, &[1]).unwrap_err();
504        assert!(matches!(e, ConcealRuleError::PatternTooLong { .. }));
505    }
506
507    #[test]
508    fn the_cap_refuses_the_overflow_and_keeps_the_rest() {
509        let declared: Vec<(String, Vec<u32>, Option<String>)> = (0..MAX_CONCEAL_RULES + 3)
510            .map(|_| (BARE.to_string(), vec![1, 3], None))
511            .collect();
512        let (ok, errs) = compile_rules_t(&declared);
513        assert_eq!(ok.len(), MAX_CONCEAL_RULES);
514        assert_eq!(errs.len(), 3);
515        assert!(
516            errs.iter()
517                .all(|(_, e)| matches!(e, ConcealRuleError::TooManyRules { .. }))
518        );
519    }
520
521    #[test]
522    fn a_duplicated_group_is_normalised_away() {
523        // Listing a group twice would otherwise subtract its width
524        // twice once the matcher runs.
525        let r = compile_rule(DESCRIBED, &[2, 1, 2]).unwrap();
526        assert_eq!(r.hide(), &[1, 2]);
527    }
528
529    #[test]
530    fn rules_compare_by_declaration_not_by_compiled_program() {
531        let a = compile_rule(BARE, &[1, 3]).unwrap();
532        let b = compile_rule(BARE, &[1, 3]).unwrap();
533        let c = compile_rule(BARE, &[1]).unwrap();
534        assert_eq!(a, b);
535        assert_ne!(a, c);
536    }
537
538    // ---- H.3: matching ----
539
540    fn org_rules() -> Vec<ConcealRule> {
541        // Described BEFORE bare — the order org declares them in, and
542        // the reason is asserted below.
543        let (ok, errs) = compile_rules_t(&[
544            (DESCRIBED.to_string(), vec![1, 2], None),
545            (BARE.to_string(), vec![1, 3], None),
546        ]);
547        assert!(errs.is_empty());
548        ok
549    }
550
551    /// Apply the spans to get what the user would actually see.
552    fn rendered(rules: &[ConcealRule], line: &str) -> String {
553        let spans = conceal_spans(rules, line);
554        let mut out = String::new();
555        let mut at = 0usize;
556        for (s, e) in spans {
557            out.push_str(&line[at..s as usize]);
558            at = e as usize;
559        }
560        out.push_str(&line[at..]);
561        out
562    }
563
564    #[test]
565    fn no_rules_is_free_and_hides_nothing() {
566        assert!(conceal_spans(&[], "[[id:X][Title]]").is_empty());
567    }
568
569    #[test]
570    fn a_described_link_collapses_to_its_description() {
571        let r = org_rules();
572        assert_eq!(
573            rendered(&r, "* See [[id:6F39][Project Kickoff]] before Friday."),
574            "* See Project Kickoff before Friday."
575        );
576    }
577
578    #[test]
579    fn a_bare_link_keeps_its_target() {
580        // Emacs draws the same line: a link whose only text IS its
581        // target has nothing left to show once the target is hidden.
582        let r = org_rules();
583        assert_eq!(
584            rendered(&r, "see [[https://example.com]] ok"),
585            "see https://example.com ok"
586        );
587    }
588
589    /// The design claimed org's described rule "must be tried first",
590    /// or a described link would be matched as a bare one and render
591    /// as `id:6F39][Project Kickoff`. This test was written to pin
592    /// that and instead disproved it, twice over. Kept in the shape
593    /// that found the error.
594    #[test]
595    fn declaration_order_cannot_change_what_is_hidden() {
596        let described_first = org_rules();
597        let (bare_first, errs) = compile_rules_t(&[
598            (BARE.to_string(), vec![1, 3], None),
599            (DESCRIBED.to_string(), vec![1, 2], None),
600        ]);
601        assert!(errs.is_empty());
602
603        for line in [
604            "[[id:6F39][Project Kickoff]]",
605            "see [[https://example.com]] ok",
606            "[[id:A][one]] and [[id:B][two]]",
607            "[[id:A][one]] and [[https://x.test]]",
608        ] {
609            assert_eq!(
610                conceal_spans(&described_first, line),
611                conceal_spans(&bare_first, line),
612                "spans must be order-independent: {line}"
613            );
614        }
615    }
616
617    /// The second, independent reason the ordering worry was unfounded:
618    /// the two patterns cannot both match the same link. `[^]]+` stops
619    /// at the first `]`, so the bare pattern never reaches a described
620    /// link's closing `]]`.
621    ///
622    /// This is a property of how the patterns are WRITTEN, so it is
623    /// asserted here — a future edit to either pattern that made them
624    /// overlap would silently reintroduce the failure the retracted
625    /// ordering rule was worried about.
626    #[test]
627    fn orgs_two_patterns_are_disjoint_by_construction() {
628        let described = compile_rule(DESCRIBED, &[1, 2]).unwrap();
629        let bare = compile_rule(BARE, &[1, 3]).unwrap();
630        let link = "[[id:6F39][Project Kickoff]]";
631        assert!(
632            bare.pattern().find(link).is_none(),
633            "the bare pattern must not match a described link"
634        );
635        assert!(described.pattern().find(link).is_some());
636        // And the converse, so neither pattern is silently doing the
637        // other's job.
638        let plain = "[[https://example.com]]";
639        assert!(described.pattern().find(plain).is_none());
640        assert!(bare.pattern().find(plain).is_some());
641    }
642
643    #[test]
644    fn two_links_on_one_line_both_collapse() {
645        let r = org_rules();
646        assert_eq!(
647            rendered(&r, "[[id:A][one]] and [[id:B][two]]"),
648            "one and two"
649        );
650    }
651
652    #[test]
653    fn a_malformed_link_is_left_entirely_alone() {
654        let r = org_rules();
655        let line = "[[id:6F39][unterminated";
656        assert!(conceal_spans(&r, line).is_empty());
657        assert_eq!(rendered(&r, line), line);
658    }
659
660    #[test]
661    fn overlapping_rules_coalesce_into_one_span() {
662        // The invariant the consumer depends on: an un-merged overlap
663        // would have its shared width subtracted twice and every
664        // column past it would be wrong.
665        let (rules, _) = compile_rules_t(&[
666            (r"(abcd)ef".to_string(), vec![1], None),
667            (r"ab(cdef)".to_string(), vec![1], None),
668        ]);
669        assert_eq!(conceal_spans(&rules, "abcdef"), vec![(0, 6)]);
670    }
671
672    #[test]
673    fn touching_spans_merge_rather_than_staying_adjacent() {
674        let (rules, _) = compile_rules_t(&[(r"(ab)(cd)".to_string(), vec![1, 2], None)]);
675        assert_eq!(conceal_spans(&rules, "abcd"), vec![(0, 4)]);
676    }
677
678    #[test]
679    fn spans_come_back_sorted() {
680        let r = org_rules();
681        let spans = conceal_spans(&r, "[[id:A][one]] and [[id:B][two]]");
682        assert!(
683            spans.windows(2).all(|w| w[0].1 <= w[1].0),
684            "sorted and disjoint: {spans:?}"
685        );
686    }
687
688    #[test]
689    fn a_group_that_did_not_participate_is_not_an_error() {
690        // An alternation leaves one branch's group unmatched. That hid
691        // nothing here; it is not a refusal.
692        let (rules, errs) = compile_rules_t(&[(r"(?:(aa)|(bb))cc".to_string(), vec![1, 2], None)]);
693        assert!(errs.is_empty());
694        assert_eq!(conceal_spans(&rules, "aacc"), vec![(0, 2)]);
695        assert_eq!(conceal_spans(&rules, "bbcc"), vec![(0, 2)]);
696    }
697
698    #[test]
699    fn a_link_inside_a_source_block_still_conceals() {
700        // Documented behaviour, not a bug to be surprised by later:
701        // conceal is textual and knows nothing about blocks. The
702        // tree-driven alternative that WOULD know is rejected in
703        // conceal.md for flickering on every reparse.
704        let r = org_rules();
705        assert_eq!(rendered(&r, "  [[id:A][shown]]"), "  shown");
706    }
707
708    #[test]
709    fn a_regex_error_renders_on_one_line() {
710        // `regex` renders errors as a multi-line diagram; a log line
711        // that spans five rows is a log line nobody reads.
712        let e = compile_rule("(unclosed", &[1]).unwrap_err();
713        assert!(!e.to_string().contains('\n'), "{e}");
714    }
715
716    // ---- OL.1: styling the visible remainder ----
717
718    fn link_rules() -> Vec<ConcealRule> {
719        vec![
720            compile_rule_styled(
721                r"(\[\[[^]]+\]\[)[^]]+(\]\])",
722                &[1, 2],
723                Some("text.reference"),
724                None,
725            )
726            .expect("described-link rule"),
727            compile_rule_styled(r"(\[\[)([^]]+)(\]\])", &[1, 3], Some("text.uri"), None)
728                .expect("bare-link rule"),
729        ]
730    }
731
732    /// The two org link shapes, one declaration each, and the styled run is
733    /// what SURVIVES conceal in both — the description in one, the target in
734    /// the other.
735    #[test]
736    fn the_visible_remainder_is_what_gets_styled() {
737        let rules = link_rules();
738
739        let line = "see [[id:ABC][Rust async]] ok";
740        let spans = conceal_style_spans(&rules, line);
741        assert_eq!(spans.len(), 1, "one run, got {spans:?}");
742        let (s, e, style) = spans[0];
743        assert_eq!(&line[s as usize..e as usize], "Rust async");
744        assert_eq!(style, Style::Link);
745
746        let line = "see [[https://example.com]] ok";
747        let spans = conceal_style_spans(&rules, line);
748        assert_eq!(spans.len(), 1, "got {spans:?}");
749        let (s, e, style) = spans[0];
750        assert_eq!(&line[s as usize..e as usize], "https://example.com");
751        assert_eq!(style, Style::Url);
752    }
753
754    /// **The styled run and the concealed run partition the match.** If they
755    /// disagreed, styling would paint bytes that are about to disappear and
756    /// leave the visible ones bare — which is the bug this feature fixes, in
757    /// a subtler form.
758    #[test]
759    fn styled_and_hidden_ranges_partition_the_match() {
760        let rules = link_rules();
761        let line = "[[id:ABC][Rust async]]";
762        let hidden = conceal_spans(&rules, line);
763        let styled = conceal_style_spans(&rules, line);
764        let covered: usize = hidden.iter().map(|(s, e)| (e - s) as usize).sum::<usize>()
765            + styled
766                .iter()
767                .map(|(s, e, _)| (e - s) as usize)
768                .sum::<usize>();
769        assert_eq!(covered, line.len(), "hidden={hidden:?} styled={styled:?}");
770    }
771
772    /// A rule with NO slot conceals and paints nothing. Conceal is a general
773    /// mechanism; only rules whose remainder MEANS something carry a style,
774    /// so adding a rule for something else cannot accidentally paint it.
775    #[test]
776    fn a_rule_without_a_slot_styles_nothing() {
777        let plain = vec![compile_rule(r"(\*)[^*]+(\*)", &[1, 2]).expect("emphasis rule")];
778        let line = "some *bold* text";
779        assert!(!conceal_spans(&plain, line).is_empty(), "it still conceals");
780        assert!(
781            conceal_style_spans(&plain, line).is_empty(),
782            "…and paints nothing"
783        );
784    }
785
786    /// A styled rule beside an unstyled one paints only its own matches —
787    /// the per-rule scoping, asserted rather than assumed.
788    #[test]
789    fn styling_is_scoped_to_the_rule_that_declared_it() {
790        let mut rules = link_rules();
791        rules.push(compile_rule(r"(\*)[^*]+(\*)", &[1, 2]).expect("emphasis rule"));
792        let line = "*bold* and [[id:A][Link]]";
793        let styled = conceal_style_spans(&rules, line);
794        assert_eq!(styled.len(), 1, "only the link is styled: {styled:?}");
795        let (s, e, _) = styled[0];
796        assert_eq!(&line[s as usize..e as usize], "Link");
797    }
798
799    /// An unresolvable slot still conceals — it just does not paint. A theme
800    /// element that is not registered yet is a normal transient state, not a
801    /// reason to lose the elision.
802    #[test]
803    fn an_unknown_slot_conceals_without_painting() {
804        let rules = vec![
805            compile_rule_styled(r"(\[\[)([^]]+)(\]\])", &[1, 3], Some("not.a.capture"), None)
806                .expect("compiles"),
807        ];
808        let line = "[[target]]";
809        assert!(!conceal_spans(&rules, line).is_empty());
810        let styled = conceal_style_spans(&rules, line);
811        assert_eq!(styled.len(), 1);
812        assert_eq!(
813            styled[0].2,
814            Style::Default,
815            "resolved to nothing, painted as nothing"
816        );
817    }
818}