Skip to main content

rumdl_lib/utils/
frontmatter_values.rs

1//! Reading values out of a document's frontmatter.
2//!
3//! Frontmatter is YAML, TOML or JSON rather than Markdown, so the rules that
4//! look at it need answers Markdown parsing cannot give: where the checkable
5//! value on a line starts and ends, which top-level key owns a line, and
6//! whether a value reads as a link destination. This module is the single
7//! place those answers are computed, so every rule reading frontmatter agrees
8//! about what it contains.
9//!
10//! Parsing is deliberately line-based and heuristic rather than a full YAML or
11//! TOML parse: rules need byte spans inside the original line to report and fix
12//! at, and a real parser hands back reconstructed values with no position.
13
14use crate::discovery::MARKDOWN_EXTENSIONS;
15use crate::lint_context::LintContext;
16use crate::rules::front_matter_utils::FrontMatterUtils;
17use std::collections::HashSet;
18use std::ops::Range;
19
20/// Delimiters that wrap a token from the outside (quotes, brackets, parens,
21/// angle brackets) rather than appearing inside a path. Used only by the
22/// edge-trimming pass: these characters legitimately occur inside real paths
23/// (Next.js route groups `(marketing)/`, dynamic segments `[slug]`,
24/// disambiguated filenames `myapp(1).md`), so they must not act as mid-token
25/// boundaries, only as leading/trailing punctuation to peel off prose wrapping
26/// such as `See (docs/a.md) here.`.
27pub const PATH_TOKEN_WRAPPERS: &[char] = &['\'', '"', '`', '(', ')', '[', ']', '<', '>'];
28
29/// Whether the frontmatter value starting at `value_start` is a quoted scalar:
30/// the character immediately preceding `value_start` is a quote. Call this with
31/// the span start returned by `value_span`, which always lands just past the
32/// opening quote for quoted values. The raw `value_offset` does not carry that
33/// guarantee: its helper `kv_value_offset` only skips the opening quote when the
34/// whole trimmed remainder of the line starts and ends with the same quote
35/// character, so a trailing comment or an unterminated quote leaves the offset
36/// pointing AT the quote instead of past it.
37pub fn value_is_quoted(line: &str, value_start: usize) -> bool {
38    matches!(line[..value_start].chars().next_back(), Some('\'') | Some('"'))
39}
40
41/// Byte span of the semantic value on a frontmatter line: the checkable content
42/// with a trailing comment excluded. For a quoted scalar the span ends at the
43/// closing quote (or the trimmed end of line if the quote is unterminated), so
44/// `#` and spaces inside it are literal, and the quote characters themselves
45/// are never part of the span. `None` when the line carries no checkable value,
46/// including an empty quoted value (`''`).
47pub fn value_span(line: &str) -> Option<(usize, usize)> {
48    let start = value_offset(line);
49    if start == usize::MAX || start >= line.len() {
50        return None;
51    }
52
53    // `value_offset` sometimes points past the opening quote already, and
54    // sometimes points AT it (see `value_is_quoted` docs). Detect the quote from
55    // either position so both cases converge on a `content_start` that is always
56    // just past the opening quote.
57    let before = line[..start].chars().next_back();
58    let at = line[start..].chars().next();
59    let (content_start, quote) = match (before, at) {
60        (Some(q @ ('\'' | '"')), _) => (start, Some(q)),
61        (_, Some(q @ ('\'' | '"'))) => (start + q.len_utf8(), Some(q)),
62        _ => (start, None),
63    };
64
65    let end = if let Some(quote) = quote {
66        let rest = &line[content_start..];
67        match rest.find(quote) {
68            Some(i) => content_start + i,
69            None => content_start + rest.trim_end().len(),
70        }
71    } else {
72        let rest = &line[content_start..];
73        let raw_end = match rest.find(" #") {
74            Some(i) => content_start + i,
75            None => line.len(),
76        };
77        line[..raw_end].trim_end().len()
78    };
79
80    if end <= content_start {
81        None
82    } else {
83        Some((content_start, end))
84    }
85}
86
87/// For a frontmatter line, the byte offset where the checkable value portion
88/// starts. Returns `usize::MAX` if the entire line should be skipped
89/// (frontmatter delimiters, key-only lines, YAML comments, flow constructs).
90pub fn value_offset(line: &str) -> usize {
91    let trimmed = line.trim();
92
93    // Skip frontmatter delimiters and empty lines
94    if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
95        return usize::MAX;
96    }
97
98    // Skip YAML comments
99    if trimmed.starts_with('#') {
100        return usize::MAX;
101    }
102
103    // YAML list item: "  - item" or "  - key: value"
104    let stripped = line.trim_start();
105    if let Some(after_dash) = stripped.strip_prefix("- ") {
106        let leading = line.len() - stripped.len();
107        // Check if the list item contains a mapping (e.g., "- key: value")
108        if let Some(result) = kv_value_offset(line, after_dash, leading + 2) {
109            return result;
110        }
111        // Bare list item value (no colon) - check content after "- "
112        return leading + 2;
113    }
114    if stripped == "-" {
115        return usize::MAX;
116    }
117
118    // Key-value pair with colon separator (YAML): "key: value"
119    if let Some(result) = kv_value_offset(line, stripped, line.len() - stripped.len()) {
120        return result;
121    }
122
123    // Key-value pair with equals separator (TOML): "key = value"
124    if let Some(eq_pos) = line.find('=') {
125        let after_eq = eq_pos + 1;
126        if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
127            let value_start = after_eq + 1;
128            let value_slice = &line[value_start..];
129            let value_trimmed = value_slice.trim();
130            if value_trimmed.is_empty() {
131                return usize::MAX;
132            }
133            // For quoted values, skip the opening quote character
134            if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
135                || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
136            {
137                let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
138                return value_start + quote_offset + 1;
139            }
140            return value_start;
141        }
142        // Equals with no space after or at end of line -> no value to check
143        return usize::MAX;
144    }
145
146    // No separator found - continuation line or bare value, check the whole line
147    0
148}
149
150/// Parse a key-value pair using colon separator within `content` that starts at
151/// `base_offset` in the original line. Returns `Some(offset)` if a colon
152/// separator is found, `None` if no colon is present.
153fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
154    let colon_pos = content.find(':')?;
155    let abs_colon = base_offset + colon_pos;
156    let after_colon = abs_colon + 1;
157    if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
158        let value_start = after_colon + 1;
159        let value_slice = &line[value_start..];
160        let value_trimmed = value_slice.trim();
161        if value_trimmed.is_empty() {
162            return Some(usize::MAX);
163        }
164        // Skip flow mappings and flow sequences - too complex for heuristic parsing
165        if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
166            return Some(usize::MAX);
167        }
168        // For quoted values, skip the opening quote character
169        if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
170            || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
171        {
172            let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
173            return Some(value_start + quote_offset + 1);
174        }
175        return Some(value_start);
176    }
177    // Colon with no space after or at end of line -> no value to check
178    Some(usize::MAX)
179}
180
181/// Bounds of the whitespace-delimited token containing `pos`, clamped to
182/// `[value_start, value_end)`. The clamp is what keeps this search inside a
183/// single frontmatter value: it can never walk past the value's own boundaries,
184/// so it can never wander into Markdown link syntax on the same line
185/// (frontmatter has none) or onto a neighboring line.
186pub fn token_bounds(line: &str, pos: usize, value_start: usize, value_end: usize) -> (usize, usize) {
187    let before = &line[value_start..pos];
188    let start = before.rfind(char::is_whitespace).map_or(value_start, |i| {
189        value_start + i + before[i..].chars().next().unwrap().len_utf8()
190    });
191
192    let after = &line[pos..value_end];
193    let end = after.find(char::is_whitespace).map_or(value_end, |i| pos + i);
194
195    (start, end)
196}
197
198/// Strip wrapping delimiters, then trailing sentence punctuation, repeating both
199/// passes until a full pass leaves the bounds unchanged. Punctuation removal can
200/// expose a wrapper underneath it (`"docs/myapp.md",` sheds the comma to reveal
201/// a trailing quote), so a single sequential pass is not enough to reach a
202/// stable result.
203pub fn trim_token_bounds(line: &str, mut start: usize, mut end: usize) -> (usize, usize) {
204    const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?'];
205    while start < end && line[start..end].starts_with(PATH_TOKEN_WRAPPERS) {
206        start += line[start..].chars().next().unwrap().len_utf8();
207    }
208    loop {
209        let before = (start, end);
210        while end > start && line[start..end].ends_with(PATH_TOKEN_WRAPPERS) {
211            end -= line[..end].chars().next_back().unwrap().len_utf8();
212        }
213        while end > start && line[start..end].ends_with(TRAILING) {
214            end -= line[..end].chars().next_back().unwrap().len_utf8();
215        }
216        if (start, end) == before {
217            break;
218        }
219    }
220    (start, end)
221}
222
223/// Byte offset of the first occurrence of `target` in `s` that is outside a
224/// single- or double-quoted span, skipping escaped characters inside double
225/// quotes. `None` if `target` never occurs unquoted.
226fn find_unquoted(s: &str, target: char) -> Option<usize> {
227    let mut in_double = false;
228    let mut in_single = false;
229    let mut chars = s.char_indices();
230    while let Some((i, c)) = chars.next() {
231        if in_double {
232            if c == '\\' {
233                chars.next();
234            } else if c == '"' {
235                in_double = false;
236            }
237        } else if in_single {
238            if c == '\'' {
239                in_single = false;
240            }
241        } else if c == target {
242            return Some(i);
243        } else if c == '"' {
244            in_double = true;
245        } else if c == '\'' {
246            in_single = true;
247        }
248    }
249    None
250}
251
252/// Inner key path of a real TOML table header, `[seo]` or `[[authors]]`.
253///
254/// A header, after stripping an optional trailing `#comment` that is outside
255/// quotes and trimming, must START with `[` and END with the matching `]` or
256/// `]]` and nothing else, and its inner key path must contain no unquoted
257/// comma: a comma never appears in a real header key (a bare or dotted path
258/// like `params.seo`), only in an array literal like `1, 2`. This rejects a
259/// column-0 array element such as `[1, 2],`: TOML does not require array
260/// elements to be indented, so without this check the line is misread as a
261/// header named `1, 2`.
262///
263/// A quoted key that itself contains a comma (`["a,b"]`) is valid TOML and is
264/// still recognized here, since the comma is inside the quotes and
265/// `find_unquoted` skips it.
266fn toml_table_header(trimmed: &str) -> Option<&str> {
267    let head = match find_unquoted(trimmed, '#') {
268        Some(i) => trimmed[..i].trim_end(),
269        None => trimmed,
270    };
271
272    let inner = if let Some(rest) = head.strip_prefix("[[") {
273        rest.strip_suffix("]]")?
274    } else {
275        head.strip_prefix('[')?.strip_suffix(']')?
276    };
277
278    if find_unquoted(inner, ',').is_some() {
279        return None;
280    }
281
282    let inner = inner.trim();
283    if inner.is_empty() { None } else { Some(inner) }
284}
285
286/// Signed count of `[` minus `]` on a TOML line, ignoring bracket characters
287/// inside quoted strings. Used to track how deep the parser is inside an
288/// unclosed `key = [ ... ]` array so a nested element like `[1, 2],` is never
289/// misread as a table header.
290fn toml_bracket_delta(trimmed: &str) -> i32 {
291    let mut delta = 0i32;
292    let mut chars = trimmed.chars();
293    let mut in_double = false;
294    let mut in_single = false;
295    while let Some(c) = chars.next() {
296        if in_double {
297            if c == '\\' {
298                chars.next();
299            } else if c == '"' {
300                in_double = false;
301            }
302        } else if in_single {
303            if c == '\'' {
304                in_single = false;
305            }
306        } else {
307            match c {
308                '"' => in_double = true,
309                '\'' => in_single = true,
310                '[' => delta += 1,
311                ']' => delta -= 1,
312                _ => {}
313            }
314        }
315    }
316    delta
317}
318
319fn strip_key_quotes(raw: &str) -> &str {
320    raw.strip_prefix('"')
321        .and_then(|k| k.strip_suffix('"'))
322        .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
323        .unwrap_or(raw)
324}
325
326/// The lowercased top-level frontmatter key owning each line, indexed by line
327/// number. `None` where no owner is determinable, which leaves the line
328/// checked.
329///
330/// Attribution is a heuristic. It is deliberately biased so an uncertain line
331/// falls back to being checked: an indent-0 YAML key line always starts a new
332/// key, so a bracket inside a block scalar can never cause a later real key to
333/// be suppressed. The cost is that an indent-0 flow continuation is attributed
334/// to its own text rather than to its parent.
335pub fn field_map(ctx: &LintContext) -> Vec<Option<String>> {
336    let mut map = vec![None; ctx.lines.len()];
337    let mut current: Option<String> = None;
338    let mut toml = false;
339    let mut in_toml_table = false;
340    // Depth of unclosed `[` inside the current TOML `key = [ ... ]` array,
341    // across lines. TOML only: see the comment in the YAML branch below for why
342    // this tracking does not extend there.
343    let mut toml_array_depth: i32 = 0;
344
345    for (idx, info) in ctx.lines.iter().enumerate() {
346        if !info.in_front_matter {
347            continue;
348        }
349        let line = info.content(ctx.content);
350        let trimmed = line.trim();
351
352        if trimmed == "---" || trimmed == "+++" {
353            toml = trimmed == "+++";
354            current = None;
355            in_toml_table = false;
356            toml_array_depth = 0;
357            continue;
358        }
359        if trimmed.is_empty() || trimmed.starts_with('#') {
360            map[idx].clone_from(&current);
361            continue;
362        }
363
364        if toml {
365            // A table header can only ever be found while `toml_array_depth` is
366            // zero: valid TOML never lets a `[table]`/`[[array-of-tables]]`
367            // header appear inside an unclosed array value, so a bracket-only
368            // line seen while depth is above zero, such as a column-0 array
369            // element `[1, 2]` or `[2]`, is always a continuation, never a
370            // header, regardless of whether it happens to satisfy
371            // `toml_table_header`'s shape check on its own.
372            //
373            // An indent-0 assignment, by contrast, always resyncs `current` and
374            // clears the stuck depth, even while `toml_array_depth` is stuck
375            // above zero from an unclosed array (a forgotten closing bracket).
376            // Without this, a malformed array would misattribute every following
377            // key to the array's key for the rest of the frontmatter.
378            let indent = line.len() - line.trim_start().len();
379            let header = if indent == 0 && toml_array_depth == 0 {
380                toml_table_header(trimmed)
381            } else {
382                None
383            };
384            let assignment_eq = if indent == 0 {
385                FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, '=')
386            } else {
387                None
388            };
389            let resync = header.is_some() || assignment_eq.is_some();
390
391            if resync {
392                if let Some(name) = header {
393                    current = Some(FrontMatterUtils::toml_root_key(name).to_lowercase());
394                    in_toml_table = true;
395                } else if !in_toml_table && let Some(eq) = assignment_eq {
396                    let root = FrontMatterUtils::toml_root_key(trimmed[..eq].trim());
397                    current = Some(root.to_lowercase());
398                }
399                // Inside a table, assignments belong to the table. Array
400                // continuations match neither branch and inherit.
401                toml_array_depth = 0;
402            }
403            toml_array_depth = (toml_array_depth + toml_bracket_delta(trimmed)).max(0);
404        } else {
405            // YAML deliberately does not track bracket/flow depth the way the
406            // TOML branch does. YAML has block scalars (`description: |`) whose
407            // content is arbitrary text that could contain an unmatched `[`, and
408            // a running depth counter would misread that as an open array and
409            // wrongly swallow a later, real top-level key. TOML has no block
410            // scalars, so depth tracking is safe there. YAML instead stays with
411            // the simpler, safer rule: every indent-0 key line always starts a
412            // new key.
413            let indent = line.len() - line.trim_start().len();
414            if indent == 0 {
415                if trimmed.starts_with("- ") || trimmed == "-" {
416                    current = None;
417                } else if let Some(colon) = FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, ':') {
418                    let raw = trimmed[..colon].trim();
419                    current = Some(strip_key_quotes(raw).to_lowercase());
420                }
421            }
422            // Indented lines inherit, which covers nested maps, sequence items
423            // and block-scalar continuations.
424        }
425        map[idx].clone_from(&current);
426    }
427    map
428}
429
430/// A frontmatter value that reads as a link destination.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct FrontMatterLink {
433    /// 1-indexed line the destination sits on.
434    pub line: usize,
435    /// Byte range of the destination within its own line, with quotes and other
436    /// wrapping punctuation excluded.
437    pub range: Range<usize>,
438    /// The lowercased top-level key owning the value, or `None` where no owner
439    /// is determinable. See [`field_map`] for how attribution is biased.
440    pub field: Option<String>,
441}
442
443impl FrontMatterLink {
444    /// Whether this link's owning field is in a set of lowercased field names.
445    ///
446    /// A link with no determinable owner belongs to no field, so it is never
447    /// excluded by name.
448    pub fn field_is_in(&self, fields: &HashSet<String>) -> bool {
449        self.field.as_ref().is_some_and(|field| fields.contains(field))
450    }
451}
452
453/// Every frontmatter value that reads as a link destination, in document order,
454/// each attributed to the top-level key that owns it.
455///
456/// No configuration is applied: callers decide which links their own settings
457/// exclude, which is what lets one caller index every link while another
458/// reports only some of them.
459pub fn link_destinations(ctx: &LintContext) -> Vec<FrontMatterLink> {
460    let mut links = Vec::new();
461    if ctx.front_matter_end_line() == 0 {
462        return links;
463    }
464
465    for (idx, info) in ctx.lines.iter().enumerate() {
466        if !info.in_front_matter {
467            continue;
468        }
469
470        let line = info.content(ctx.content);
471        let Some((value_start, value_end)) = value_span(line) else {
472            continue;
473        };
474        let (start, end) = trim_token_bounds(line, value_start, value_end);
475        if start >= end || !is_link_destination(&line[start..end]) {
476            continue;
477        }
478        links.push(FrontMatterLink {
479            line: idx + 1,
480            range: start..end,
481            field: None,
482        });
483    }
484
485    // Attribution walks the document, so it is worth doing only once there is
486    // something to attribute. Frontmatter holding no link destination at all is
487    // the overwhelmingly common case.
488    if !links.is_empty() {
489        let fields = field_map(ctx);
490        for link in &mut links {
491            link.field = fields.get(link.line - 1).cloned().flatten();
492        }
493    }
494
495    links
496}
497
498/// Whether a frontmatter value reads as a link destination rather than prose.
499///
500/// Frontmatter has no syntax marking a value as a link, so the answer comes
501/// from the shape of the value alone. It is deliberately strict, because a rule
502/// acting on a `true` here reports a finding: a value that is merely
503/// path-shaped, such as a tag pair `ci/cd`, a date `2026/07/31` or a version
504/// `1.2.3`, must not qualify.
505///
506/// A value qualifies when it holds no whitespace and one of:
507///
508/// - it starts with `#`, so it is a fragment;
509/// - it names a markdown file, so a bare `myapp.md` qualifies while a dotted
510///   proper name such as `Node.js` does not;
511/// - it holds a `/` and either starts with a path prefix (`./`, `../`, `~/`,
512///   `/`) or ends in a file extension.
513pub fn is_link_destination(value: &str) -> bool {
514    if value.is_empty() || value.chars().any(char::is_whitespace) {
515        return false;
516    }
517
518    let path = match value.find('#') {
519        Some(0) => return true,
520        Some(i) => &value[..i],
521        None => value,
522    };
523    // A query string belongs to the destination, not to the path it names, so
524    // `page.md?raw=true` names `page.md`. Body links are resolved the same way.
525    let path = path.split('?').next().unwrap_or(path);
526
527    let last_segment = path.rsplit('/').next().unwrap_or(path);
528    if has_markdown_extension(last_segment) {
529        return true;
530    }
531
532    path.contains('/')
533        && (path.starts_with('/')
534            || path.starts_with("./")
535            || path.starts_with("../")
536            || path.starts_with("~/")
537            || has_file_extension(last_segment))
538}
539
540/// Whether `segment` ends in one of the extensions rumdl treats as markdown.
541fn has_markdown_extension(segment: &str) -> bool {
542    segment.rsplit_once('.').is_some_and(|(stem, ext)| {
543        !stem.is_empty() && MARKDOWN_EXTENSIONS.iter().any(|known| ext.eq_ignore_ascii_case(known))
544    })
545}
546
547/// Whether `segment` ends in something that reads as a file extension: a short
548/// run of alphanumerics carrying at least one letter. The letter requirement is
549/// what keeps a version number such as `1.2.3` from reading as a file.
550fn has_file_extension(segment: &str) -> bool {
551    segment.rsplit_once('.').is_some_and(|(stem, ext)| {
552        !stem.is_empty()
553            && (1..=8).contains(&ext.len())
554            && ext.chars().all(|c| c.is_ascii_alphanumeric())
555            && ext.chars().any(|c| c.is_ascii_alphabetic())
556    })
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::config::MarkdownFlavor;
563
564    fn destinations(content: &str) -> Vec<String> {
565        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
566        link_destinations(&ctx)
567            .into_iter()
568            .map(|link| {
569                let line = ctx.lines[link.line - 1].content(ctx.content);
570                line[link.range].to_string()
571            })
572            .collect()
573    }
574
575    #[test]
576    fn a_relative_path_reads_as_a_destination() {
577        assert!(is_link_destination("this/is/a/link/to/myapp.md"));
578        assert!(is_link_destination("./other.md"));
579        assert!(is_link_destination("../parent/other"));
580        assert!(is_link_destination("~/notes/other.md"));
581        assert!(is_link_destination("/absolute/other.md"));
582        assert!(is_link_destination("assets/logo.png"));
583    }
584
585    #[test]
586    fn a_bare_markdown_filename_reads_as_a_destination() {
587        assert!(is_link_destination("myapp.md"));
588        assert!(is_link_destination("report.QMD"));
589    }
590
591    #[test]
592    fn a_fragment_reads_as_a_destination() {
593        assert!(is_link_destination("#installation"));
594        assert!(is_link_destination("other.md#installation"));
595        assert!(is_link_destination("docs/other.md#installation"));
596    }
597
598    #[test]
599    fn a_query_string_is_not_part_of_the_path() {
600        assert!(is_link_destination("docs/other.md?raw=true"));
601        assert!(is_link_destination("other.md?raw=true"));
602        assert!(is_link_destination("docs/other.md?raw=true#installation"));
603        // The query is what makes this path-shaped, so it stays prose.
604        assert!(!is_link_destination("what?about/this"));
605    }
606
607    #[test]
608    fn prose_and_path_shaped_values_do_not() {
609        // A dotted proper name is not a file: only markdown extensions lift the
610        // slash requirement.
611        assert!(!is_link_destination("Node.js"));
612        // Tag pairs, dates and versions are all path-shaped and none is a file.
613        assert!(!is_link_destination("ci/cd"));
614        assert!(!is_link_destination("2026/07/31"));
615        assert!(!is_link_destination("1.2.3"));
616        // An extensionless path with no prefix stays prose.
617        assert!(!is_link_destination("docs/guides/intro"));
618        // A destination never holds whitespace.
619        assert!(!is_link_destination("a description of docs/a.md"));
620        assert!(!is_link_destination(""));
621    }
622
623    #[test]
624    fn a_destination_is_read_out_of_its_quotes() {
625        assert_eq!(
626            destinations("---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\n# Title\n"),
627            vec!["this/is/a/link/to/myapp.md"]
628        );
629        assert_eq!(
630            destinations("---\nlink: \"docs/a.md\"\n---\n\n# Title\n"),
631            vec!["docs/a.md"]
632        );
633    }
634
635    #[test]
636    fn a_trailing_comment_is_not_part_of_a_destination() {
637        assert_eq!(
638            destinations("---\nlink: docs/a.md # the guide\n---\n\n# Title\n"),
639            vec!["docs/a.md"]
640        );
641    }
642
643    #[test]
644    fn only_frontmatter_is_read() {
645        assert_eq!(
646            destinations("---\nlink: docs/a.md\n---\n\nSee docs/b.md for more.\n"),
647            vec!["docs/a.md"]
648        );
649    }
650
651    #[test]
652    fn a_sequence_item_carries_a_destination() {
653        assert_eq!(
654            destinations("---\nlinks:\n  - docs/a.md\n  - docs/b.md\n---\n\n# Title\n"),
655            vec!["docs/a.md", "docs/b.md"]
656        );
657    }
658
659    #[test]
660    fn a_toml_value_carries_a_destination() {
661        assert_eq!(
662            destinations("+++\nlink = \"docs/a.md\"\n+++\n\n# Title\n"),
663            vec!["docs/a.md"]
664        );
665    }
666
667    #[test]
668    fn a_destination_carries_the_field_owning_it_through_a_whole_subtree() {
669        let content = "---\nlink: docs/a.md\nseo:\n  canonical: docs/b.md\n---\n\n# Title\n";
670        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
671        let links = link_destinations(&ctx);
672
673        let owners: Vec<Option<&str>> = links.iter().map(|link| link.field.as_deref()).collect();
674        assert_eq!(owners, vec![Some("link"), Some("seo")]);
675
676        // The nested value is attributed to the top-level key, so excluding
677        // `seo` by name hides its whole subtree.
678        let ignored: HashSet<String> = ["seo".to_string()].into_iter().collect();
679        let kept: Vec<String> = links
680            .iter()
681            .filter(|link| !link.field_is_in(&ignored))
682            .map(|link| ctx.lines[link.line - 1].content(ctx.content)[link.range.clone()].to_string())
683            .collect();
684        assert_eq!(kept, vec!["docs/a.md"]);
685    }
686
687    #[test]
688    fn a_destination_with_no_determinable_owner_belongs_to_no_field() {
689        // A top-level sequence item has no owning key, so no field name can
690        // exclude it. The distinction matters to callers that must still treat
691        // it as frontmatter.
692        let ctx = LintContext::new("---\n- docs/a.md\n---\n\n# Title\n", MarkdownFlavor::Standard, None);
693        let links = link_destinations(&ctx);
694        assert_eq!(links.len(), 1);
695        assert_eq!(links[0].field, None);
696        assert!(!links[0].field_is_in(&["docs".to_string()].into_iter().collect()));
697    }
698
699    #[test]
700    fn a_document_without_frontmatter_has_no_destinations() {
701        assert!(destinations("# Title\n\nSee docs/a.md.\n").is_empty());
702    }
703}