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}