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}
439
440/// Every frontmatter value that reads as a link destination, in document order.
441///
442/// Lines owned by a key in `ignored_fields` are skipped, along with the whole
443/// subtree under that key. Field names are matched lowercased, so callers pass
444/// a lowercased set.
445pub fn link_destinations(ctx: &LintContext, ignored_fields: &HashSet<String>) -> Vec<FrontMatterLink> {
446    let mut links = Vec::new();
447    if ctx.front_matter_end_line() == 0 {
448        return links;
449    }
450
451    // Attribution is only needed when fields are actually excluded.
452    let fields = if ignored_fields.is_empty() {
453        Vec::new()
454    } else {
455        field_map(ctx)
456    };
457
458    for (idx, info) in ctx.lines.iter().enumerate() {
459        if !info.in_front_matter {
460            continue;
461        }
462        if let Some(Some(field)) = fields.get(idx)
463            && ignored_fields.contains(field)
464        {
465            continue;
466        }
467
468        let line = info.content(ctx.content);
469        let Some((value_start, value_end)) = value_span(line) else {
470            continue;
471        };
472        let (start, end) = trim_token_bounds(line, value_start, value_end);
473        if start >= end || !is_link_destination(&line[start..end]) {
474            continue;
475        }
476        links.push(FrontMatterLink {
477            line: idx + 1,
478            range: start..end,
479        });
480    }
481
482    links
483}
484
485/// Whether a frontmatter value reads as a link destination rather than prose.
486///
487/// Frontmatter has no syntax marking a value as a link, so the answer comes
488/// from the shape of the value alone. It is deliberately strict, because a rule
489/// acting on a `true` here reports a finding: a value that is merely
490/// path-shaped, such as a tag pair `ci/cd`, a date `2026/07/31` or a version
491/// `1.2.3`, must not qualify.
492///
493/// A value qualifies when it holds no whitespace and one of:
494///
495/// - it starts with `#`, so it is a fragment;
496/// - it names a markdown file, so a bare `myapp.md` qualifies while a dotted
497///   proper name such as `Node.js` does not;
498/// - it holds a `/` and either starts with a path prefix (`./`, `../`, `~/`,
499///   `/`) or ends in a file extension.
500pub fn is_link_destination(value: &str) -> bool {
501    if value.is_empty() || value.chars().any(char::is_whitespace) {
502        return false;
503    }
504
505    let path = match value.find('#') {
506        Some(0) => return true,
507        Some(i) => &value[..i],
508        None => value,
509    };
510    // A query string belongs to the destination, not to the path it names, so
511    // `page.md?raw=true` names `page.md`. Body links are resolved the same way.
512    let path = path.split('?').next().unwrap_or(path);
513
514    let last_segment = path.rsplit('/').next().unwrap_or(path);
515    if has_markdown_extension(last_segment) {
516        return true;
517    }
518
519    path.contains('/')
520        && (path.starts_with('/')
521            || path.starts_with("./")
522            || path.starts_with("../")
523            || path.starts_with("~/")
524            || has_file_extension(last_segment))
525}
526
527/// Whether `segment` ends in one of the extensions rumdl treats as markdown.
528fn has_markdown_extension(segment: &str) -> bool {
529    segment.rsplit_once('.').is_some_and(|(stem, ext)| {
530        !stem.is_empty() && MARKDOWN_EXTENSIONS.iter().any(|known| ext.eq_ignore_ascii_case(known))
531    })
532}
533
534/// Whether `segment` ends in something that reads as a file extension: a short
535/// run of alphanumerics carrying at least one letter. The letter requirement is
536/// what keeps a version number such as `1.2.3` from reading as a file.
537fn has_file_extension(segment: &str) -> bool {
538    segment.rsplit_once('.').is_some_and(|(stem, ext)| {
539        !stem.is_empty()
540            && (1..=8).contains(&ext.len())
541            && ext.chars().all(|c| c.is_ascii_alphanumeric())
542            && ext.chars().any(|c| c.is_ascii_alphabetic())
543    })
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::config::MarkdownFlavor;
550
551    fn destinations(content: &str) -> Vec<String> {
552        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
553        link_destinations(&ctx, &HashSet::new())
554            .into_iter()
555            .map(|link| {
556                let line = ctx.lines[link.line - 1].content(ctx.content);
557                line[link.range].to_string()
558            })
559            .collect()
560    }
561
562    #[test]
563    fn a_relative_path_reads_as_a_destination() {
564        assert!(is_link_destination("this/is/a/link/to/myapp.md"));
565        assert!(is_link_destination("./other.md"));
566        assert!(is_link_destination("../parent/other"));
567        assert!(is_link_destination("~/notes/other.md"));
568        assert!(is_link_destination("/absolute/other.md"));
569        assert!(is_link_destination("assets/logo.png"));
570    }
571
572    #[test]
573    fn a_bare_markdown_filename_reads_as_a_destination() {
574        assert!(is_link_destination("myapp.md"));
575        assert!(is_link_destination("report.QMD"));
576    }
577
578    #[test]
579    fn a_fragment_reads_as_a_destination() {
580        assert!(is_link_destination("#installation"));
581        assert!(is_link_destination("other.md#installation"));
582        assert!(is_link_destination("docs/other.md#installation"));
583    }
584
585    #[test]
586    fn a_query_string_is_not_part_of_the_path() {
587        assert!(is_link_destination("docs/other.md?raw=true"));
588        assert!(is_link_destination("other.md?raw=true"));
589        assert!(is_link_destination("docs/other.md?raw=true#installation"));
590        // The query is what makes this path-shaped, so it stays prose.
591        assert!(!is_link_destination("what?about/this"));
592    }
593
594    #[test]
595    fn prose_and_path_shaped_values_do_not() {
596        // A dotted proper name is not a file: only markdown extensions lift the
597        // slash requirement.
598        assert!(!is_link_destination("Node.js"));
599        // Tag pairs, dates and versions are all path-shaped and none is a file.
600        assert!(!is_link_destination("ci/cd"));
601        assert!(!is_link_destination("2026/07/31"));
602        assert!(!is_link_destination("1.2.3"));
603        // An extensionless path with no prefix stays prose.
604        assert!(!is_link_destination("docs/guides/intro"));
605        // A destination never holds whitespace.
606        assert!(!is_link_destination("a description of docs/a.md"));
607        assert!(!is_link_destination(""));
608    }
609
610    #[test]
611    fn a_destination_is_read_out_of_its_quotes() {
612        assert_eq!(
613            destinations("---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\n# Title\n"),
614            vec!["this/is/a/link/to/myapp.md"]
615        );
616        assert_eq!(
617            destinations("---\nlink: \"docs/a.md\"\n---\n\n# Title\n"),
618            vec!["docs/a.md"]
619        );
620    }
621
622    #[test]
623    fn a_trailing_comment_is_not_part_of_a_destination() {
624        assert_eq!(
625            destinations("---\nlink: docs/a.md # the guide\n---\n\n# Title\n"),
626            vec!["docs/a.md"]
627        );
628    }
629
630    #[test]
631    fn only_frontmatter_is_read() {
632        assert_eq!(
633            destinations("---\nlink: docs/a.md\n---\n\nSee docs/b.md for more.\n"),
634            vec!["docs/a.md"]
635        );
636    }
637
638    #[test]
639    fn a_sequence_item_carries_a_destination() {
640        assert_eq!(
641            destinations("---\nlinks:\n  - docs/a.md\n  - docs/b.md\n---\n\n# Title\n"),
642            vec!["docs/a.md", "docs/b.md"]
643        );
644    }
645
646    #[test]
647    fn a_toml_value_carries_a_destination() {
648        assert_eq!(
649            destinations("+++\nlink = \"docs/a.md\"\n+++\n\n# Title\n"),
650            vec!["docs/a.md"]
651        );
652    }
653
654    #[test]
655    fn an_ignored_field_hides_its_whole_subtree() {
656        let content = "---\nlink: docs/a.md\nseo:\n  canonical: docs/b.md\n---\n\n# Title\n";
657        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
658        let ignored: HashSet<String> = ["seo".to_string()].into_iter().collect();
659        let found: Vec<String> = link_destinations(&ctx, &ignored)
660            .into_iter()
661            .map(|link| ctx.lines[link.line - 1].content(ctx.content)[link.range].to_string())
662            .collect();
663        assert_eq!(found, vec!["docs/a.md"]);
664    }
665
666    #[test]
667    fn a_document_without_frontmatter_has_no_destinations() {
668        assert!(destinations("# Title\n\nSee docs/a.md.\n").is_empty());
669    }
670}