Skip to main content

brief/
resolve.rs

1use crate::ast::{Block, Document, Inline};
2use crate::diag::{Code, Diagnostic};
3use crate::project::ProjectIndex;
4use crate::shortcode::{ArgType, ArgValue, Registry, ShortKindOpt, Shortcode};
5use crate::span::Span;
6
7pub struct ResolveProject<'a> {
8    pub index: &'a ProjectIndex,
9    pub current: &'a std::path::Path,
10}
11
12/// Resolve without project context. Equivalent to
13/// `resolve_with_project(.., None)`. `@ref` invocations error with
14/// `B0604` when no project root is available.
15pub fn resolve(doc: &mut Document, registry: &Registry) -> Vec<Diagnostic> {
16    resolve_with_project(doc, registry, None)
17}
18
19pub fn resolve_with_project(
20    doc: &mut Document,
21    registry: &Registry,
22    project: Option<&ResolveProject<'_>>,
23) -> Vec<Diagnostic> {
24    let mut diags = Vec::new();
25    for block in &mut doc.blocks {
26        resolve_block(block, registry, &mut diags);
27    }
28    let mut resolved = std::collections::BTreeMap::new();
29    for block in &doc.blocks {
30        scan_refs_block(block, project, &mut diags, &mut resolved);
31    }
32    doc.resolved_refs = resolved;
33    diags
34}
35
36fn scan_refs_block(
37    block: &Block,
38    project: Option<&ResolveProject<'_>>,
39    diags: &mut Vec<Diagnostic>,
40    out: &mut std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
41) {
42    match block {
43        Block::Heading { content, .. } | Block::Paragraph { content, .. } => {
44            for n in content {
45                scan_refs_inline(n, project, diags, out);
46            }
47        }
48        Block::List { items, .. } => {
49            for it in items {
50                for n in &it.content {
51                    scan_refs_inline(n, project, diags, out);
52                }
53                for c in &it.children {
54                    scan_refs_block(c, project, diags, out);
55                }
56            }
57        }
58        Block::Blockquote { children, .. } | Block::BlockShortcode { children, .. } => {
59            for c in children {
60                scan_refs_block(c, project, diags, out);
61            }
62        }
63        Block::Table { header, rows, .. } => {
64            for cell in &header.cells {
65                for n in cell {
66                    scan_refs_inline(n, project, diags, out);
67                }
68            }
69            for row in rows {
70                for cell in &row.cells {
71                    for n in cell {
72                        scan_refs_inline(n, project, diags, out);
73                    }
74                }
75            }
76        }
77        Block::DefinitionList { items, .. } => {
78            for it in items {
79                for n in &it.term {
80                    scan_refs_inline(n, project, diags, out);
81                }
82                for n in &it.definition {
83                    scan_refs_inline(n, project, diags, out);
84                }
85            }
86        }
87        Block::CodeBlock { .. } | Block::HorizontalRule { .. } => {}
88    }
89}
90
91fn scan_refs_inline(
92    node: &crate::ast::Inline,
93    project: Option<&ResolveProject<'_>>,
94    diags: &mut Vec<Diagnostic>,
95    out: &mut std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
96) {
97    use crate::ast::Inline;
98    match node {
99        Inline::Bold { content, .. }
100        | Inline::Italic { content, .. }
101        | Inline::Underline { content, .. }
102        | Inline::Strike { content, .. } => {
103            for n in content {
104                scan_refs_inline(n, project, diags, out);
105            }
106        }
107        Inline::Shortcode {
108            name,
109            args,
110            content,
111            span,
112        } if name == "ref" => {
113            // Project context must exist before any other validation; outside
114            // a project, B0604 is the actionable diagnostic regardless of
115            // whether the body itself is malformed.
116            let project = match project {
117                Some(p) => p,
118                None => {
119                    diags.push(
120                        Diagnostic::new(crate::diag::Code::RefNoProject, *span)
121                            .label("`@ref` requires a `brief.toml`-rooted project")
122                            .help("create a `brief.toml` at the project root to enable cross-document references"),
123                    );
124                    return;
125                }
126            };
127            // Body must be a single Text node — no nested emphasis or shortcodes.
128            let body_text = match content.as_deref() {
129                Some([Inline::Text { value, .. }]) => Some(value.clone()),
130                Some([]) | None => None,
131                _ => {
132                    diags.push(
133                        Diagnostic::new(crate::diag::Code::RefBadTarget, *span)
134                            .label("@ref body must be a plain path; emphasis and nested shortcodes are not allowed"),
135                    );
136                    return;
137                }
138            };
139            let Some(body_text) = body_text else {
140                diags.push(
141                    Diagnostic::new(crate::diag::Code::RefBadTarget, *span)
142                        .label("@ref body cannot be empty"),
143                );
144                return;
145            };
146            let display = args
147                .keyword
148                .get("title")
149                .and_then(|v| v.as_str())
150                .unwrap_or("")
151                .to_string();
152            // (`title` is required at the registry level, so a missing one
153            //  has already produced B0403; we still emit a reasonable display
154            //  string so downstream emitters don't blow up.)
155            match parse_target(&body_text) {
156                Err(reason) => {
157                    diags.push(
158                        Diagnostic::new(crate::diag::Code::RefBadTarget, *span).label(reason),
159                    );
160                }
161                Ok((path, anchor)) => match project.index.anchors.get(&path) {
162                    None => {
163                        let mut help_paths: Vec<&String> = project.index.anchors.keys().collect();
164                        help_paths.sort();
165                        let suggestion = help_paths
166                            .iter()
167                            .take(5)
168                            .map(|p| p.as_str())
169                            .collect::<Vec<_>>()
170                            .join(", ");
171                        diags.push(
172                            Diagnostic::new(crate::diag::Code::RefMissingFile, *span)
173                                .label(format!("file `{}` not found in project", path))
174                                .help(if suggestion.is_empty() {
175                                    "no `.brf` files were indexed under this project root"
176                                        .to_string()
177                                } else {
178                                    format!("known files: {}", suggestion)
179                                }),
180                        );
181                    }
182                    Some(anchors) => {
183                        if let Some(a) = &anchor
184                            && !anchors.contains(a)
185                        {
186                            let mut all: Vec<&String> = anchors.iter().collect();
187                            all.sort();
188                            let listed = all
189                                .iter()
190                                .take(10)
191                                .map(|s| s.as_str())
192                                .collect::<Vec<_>>()
193                                .join(", ");
194                            diags.push(
195                                Diagnostic::new(crate::diag::Code::RefMissingAnchor, *span)
196                                    .label(format!("anchor `{}` not found in `{}`", a, path))
197                                    .help(if listed.is_empty() {
198                                        format!("`{}` defines no anchors", path)
199                                    } else {
200                                        format!("anchors in `{}`: {}", path, listed)
201                                    }),
202                            );
203                            return;
204                        }
205                        out.insert(
206                            *span,
207                            crate::ast::ResolvedRef {
208                                target_path: path.clone(),
209                                target_anchor: anchor,
210                                display,
211                            },
212                        );
213                    }
214                },
215            }
216        }
217        Inline::Shortcode {
218            content: Some(c), ..
219        } => {
220            for n in c {
221                scan_refs_inline(n, project, diags, out);
222            }
223        }
224        _ => {}
225    }
226}
227
228fn parse_target(s: &str) -> Result<(String, Option<String>), String> {
229    let s = s.trim();
230    if s.is_empty() {
231        return Err("@ref target cannot be empty".to_string());
232    }
233    if s.starts_with('/') {
234        return Err("@ref target must be relative; leading `/` is not allowed".to_string());
235    }
236    if s.contains('\\') {
237        return Err("@ref target must use `/` separators".to_string());
238    }
239    let (path_part, anchor_part) = match s.split_once('#') {
240        Some((p, a)) => (p, Some(a)),
241        None => (s, None),
242    };
243    if path_part.is_empty() {
244        return Err("@ref target path cannot be empty".to_string());
245    }
246    for seg in path_part.split('/') {
247        if seg.is_empty() || seg == "." || seg == ".." {
248            return Err(format!("invalid path segment `{}`", seg));
249        }
250    }
251    if !path_part.ends_with(".brf") {
252        return Err("@ref target path must end in `.brf`".to_string());
253    }
254    let anchor = match anchor_part {
255        None => None,
256        Some(a) => {
257            if a.is_empty() {
258                return Err("@ref anchor after `#` cannot be empty".to_string());
259            }
260            if !a
261                .chars()
262                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
263            {
264                return Err(format!("invalid anchor `{}`; must match [a-z0-9-]+", a));
265            }
266            Some(a.to_string())
267        }
268    };
269    Ok((path_part.to_string(), anchor))
270}
271
272fn resolve_block(block: &mut Block, reg: &Registry, diags: &mut Vec<Diagnostic>) {
273    match block {
274        Block::Heading { content, .. } | Block::Paragraph { content, .. } => {
275            for n in content {
276                resolve_inline(n, reg, diags);
277            }
278        }
279        Block::List { items, .. } => {
280            for it in items {
281                for n in &mut it.content {
282                    resolve_inline(n, reg, diags);
283                }
284                for c in &mut it.children {
285                    resolve_block(c, reg, diags);
286                }
287            }
288        }
289        Block::Blockquote { children, .. } => {
290            for c in children {
291                resolve_block(c, reg, diags);
292            }
293        }
294        Block::CodeBlock { .. } | Block::HorizontalRule { .. } => {}
295        Block::Table {
296            args,
297            header,
298            rows,
299            span,
300        } => {
301            check_shortcode("t", args, reg, true, *span, diags);
302            for cell in &mut header.cells {
303                for n in cell {
304                    resolve_inline(n, reg, diags);
305                }
306            }
307            for row in rows {
308                for cell in &mut row.cells {
309                    for n in cell {
310                        resolve_inline(n, reg, diags);
311                    }
312                }
313            }
314        }
315        Block::DefinitionList { items, .. } => {
316            for it in items {
317                for n in &mut it.term {
318                    resolve_inline(n, reg, diags);
319                }
320                for n in &mut it.definition {
321                    resolve_inline(n, reg, diags);
322                }
323            }
324        }
325        Block::BlockShortcode {
326            name,
327            args,
328            children,
329            span,
330        } => {
331            check_shortcode(name, args, reg, true, *span, diags);
332            for c in children {
333                resolve_block(c, reg, diags);
334            }
335        }
336    }
337}
338
339fn resolve_inline(node: &mut Inline, reg: &Registry, diags: &mut Vec<Diagnostic>) {
340    match node {
341        Inline::Bold { content, .. }
342        | Inline::Italic { content, .. }
343        | Inline::Underline { content, .. }
344        | Inline::Strike { content, .. } => {
345            for n in content {
346                resolve_inline(n, reg, diags);
347            }
348        }
349        Inline::Shortcode {
350            name,
351            args,
352            content,
353            span,
354        } => {
355            check_shortcode(name, args, reg, false, *span, diags);
356            if let Some(c) = content {
357                for n in c {
358                    resolve_inline(n, reg, diags);
359                }
360            }
361        }
362        _ => {}
363    }
364}
365
366fn check_shortcode(
367    name: &str,
368    args: &mut crate::ast::ShortArgs,
369    reg: &Registry,
370    is_block: bool,
371    span: Span,
372    diags: &mut Vec<Diagnostic>,
373) {
374    // `@br` is deliberately not registered: Brief already has `\` as the
375    // canonical hard-break sigil. Catch it explicitly so the user gets a
376    // useful pointer instead of the generic "register it" help.
377    if name == "br" {
378        diags.push(
379            Diagnostic::new(Code::UnknownShortcode, span)
380                .label("`@br` is not a Brief shortcode".to_string())
381                .help(
382                    "use `\\` at end of line for a hard break (see §12); `@br` will not be registered as a built-in",
383                ),
384        );
385        return;
386    }
387    let Some(sc) = reg.get(name) else {
388        diags.push(
389            Diagnostic::new(Code::UnknownShortcode, span)
390                .label(format!("shortcode `{}` is not registered", name))
391                .help("register it in `brief.toml` under `[shortcodes.<name>]`"),
392        );
393        return;
394    };
395    let form_ok = matches!(
396        (&sc.kind, is_block),
397        (ShortKindOpt::Block, true) | (ShortKindOpt::Inline, false) | (ShortKindOpt::Both, _)
398    );
399    if !form_ok {
400        diags.push(Diagnostic::new(Code::FormMismatch, span).label(format!(
401            "`{}` was used as {} but is registered as {:?}",
402            name,
403            if is_block { "block" } else { "inline" },
404            sc.kind
405        )));
406    }
407
408    // Deprecation alias rewrite for @callout(kind:).
409    // Must run before the oneof check so the canonical value passes validation.
410    if name == "callout" {
411        if let Some(v) = args.keyword.get("kind") {
412            if let Some(s) = v.as_str() {
413                let (canonical, label_msg): (Option<&str>, Option<&str>) = match s {
414                    "info" => (
415                        Some("note"),
416                        Some("`kind: info` is deprecated; use `kind: note`"),
417                    ),
418                    "danger" => (
419                        Some("caution"),
420                        Some("`kind: danger` is deprecated; use `kind: caution`"),
421                    ),
422                    _ => (None, None),
423                };
424                if let (Some(canonical), Some(msg)) = (canonical, label_msg) {
425                    diags.push(Diagnostic::warning(Code::DeprecatedCalloutKind, span).label(msg));
426                    args.keyword
427                        .insert("kind".into(), ArgValue::Str(canonical.into()));
428                }
429            }
430        }
431    }
432
433    bind_positional(sc, args, span, diags);
434    typecheck_args(sc, args, span, diags);
435    for (kw, spec) in &sc.arguments {
436        if spec.required && !args.keyword.contains_key(kw) {
437            diags.push(
438                Diagnostic::new(Code::MissingArg, span)
439                    .label(format!("missing required argument `{}` for `{}`", kw, name)),
440            );
441        }
442        if let (Some(allowed), Some(v)) = (&spec.oneof, args.keyword.get(kw)) {
443            if let Some(s) = v.as_str() {
444                if !allowed.iter().any(|a| a == s) {
445                    diags.push(Diagnostic::new(Code::BadEnumValue, span).label(format!(
446                        "`{}` is not in {{{}}}",
447                        s,
448                        allowed.join(", ")
449                    )));
450                }
451            }
452        }
453    }
454}
455
456fn bind_positional(
457    sc: &Shortcode,
458    args: &mut crate::ast::ShortArgs,
459    span: Span,
460    diags: &mut Vec<Diagnostic>,
461) {
462    let positional = std::mem::take(&mut args.positional);
463    for (i, v) in positional.into_iter().enumerate() {
464        let pos = i + 1;
465        let bound = sc.arguments.iter().find(|(_, s)| s.position == Some(pos));
466        if let Some((kw, _)) = bound {
467            args.keyword.insert(kw.clone(), v);
468        } else {
469            diags.push(Diagnostic::new(Code::BadArgSyntax, span).label(format!(
470                "positional argument #{} has no `position = {}` mapping",
471                pos, pos
472            )));
473        }
474    }
475}
476
477fn typecheck_args(
478    sc: &Shortcode,
479    args: &crate::ast::ShortArgs,
480    span: Span,
481    diags: &mut Vec<Diagnostic>,
482) {
483    for (kw, v) in &args.keyword {
484        if let Some(spec) = sc.arguments.get(kw)
485            && !type_matches(&spec.ty, v)
486        {
487            diags.push(Diagnostic::new(Code::ArgTypeMismatch, span).label(format!(
488                "argument `{}` has type {} but expected {:?}",
489                kw,
490                v.type_name(),
491                spec.ty
492            )));
493        }
494    }
495}
496
497fn type_matches(t: &ArgType, v: &ArgValue) -> bool {
498    matches!(
499        (t, v),
500        (ArgType::String, ArgValue::Str(_))
501            | (ArgType::String, ArgValue::Ident(_))
502            | (ArgType::Int, ArgValue::Int(_))
503            | (ArgType::Ident, ArgValue::Ident(_))
504            | (ArgType::Array, ArgValue::Array(_))
505    )
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::project::ProjectIndex;
512    use std::collections::BTreeSet;
513    use std::path::PathBuf;
514
515    fn parse_only(src: &str) -> crate::ast::Document {
516        use crate::{lexer, parser, span::SourceMap};
517        let src = SourceMap::new("t.brf", src);
518        let tokens = lexer::lex(&src).expect("lex");
519        let (doc, _) = parser::parse(tokens, &src);
520        doc
521    }
522
523    #[test]
524    fn resolved_refs_starts_empty() {
525        let doc = parse_only("hello\n");
526        assert!(doc.resolved_refs.is_empty());
527    }
528
529    #[test]
530    fn resolve_project_records_valid_ref() {
531        let mut doc = parse_only("See @ref[other.brf#x](Other).\n");
532        let mut idx = ProjectIndex {
533            root: PathBuf::from("/tmp/proj"),
534            ..Default::default()
535        };
536        idx.anchors
537            .insert("other.brf".to_string(), BTreeSet::from(["x".into()]));
538        let project = ResolveProject {
539            index: &idx,
540            current: &PathBuf::from("here.brf"),
541        };
542        let reg = crate::shortcode::Registry::with_builtins();
543        let diags = resolve_with_project(&mut doc, &reg, Some(&project));
544        assert!(
545            diags
546                .iter()
547                .all(|d| d.severity != crate::diag::Severity::Error),
548            "diags: {:?}",
549            diags,
550        );
551        assert_eq!(doc.resolved_refs.len(), 1);
552    }
553
554    use crate::diag::Code;
555
556    fn run_resolve(
557        brief: &str,
558        current: &str,
559        files: &[(&str, &[&str])],
560    ) -> (crate::ast::Document, Vec<crate::diag::Diagnostic>) {
561        let mut doc = parse_only(brief);
562        let mut idx = ProjectIndex {
563            root: PathBuf::from("/tmp/p"),
564            ..Default::default()
565        };
566        for (path, anchors) in files {
567            idx.anchors.insert(
568                path.to_string(),
569                anchors.iter().map(|s| s.to_string()).collect(),
570            );
571        }
572        let project = ResolveProject {
573            index: &idx,
574            current: &PathBuf::from(current),
575        };
576        let reg = crate::shortcode::Registry::with_builtins();
577        let diags = resolve_with_project(&mut doc, &reg, Some(&project));
578        (doc, diags)
579    }
580
581    fn has(diags: &[crate::diag::Diagnostic], c: Code) -> bool {
582        diags.iter().any(|d| d.code == c)
583    }
584
585    #[test]
586    fn ref_to_unknown_file_is_b0601() {
587        let (_, diags) = run_resolve(
588            "See @ref[missing.brf#x](Foo).\n",
589            "here.brf",
590            &[("here.brf", &[])],
591        );
592        assert!(has(&diags, Code::RefMissingFile), "{:?}", diags);
593    }
594
595    #[test]
596    fn ref_to_unknown_anchor_is_b0602() {
597        let (_, diags) = run_resolve(
598            "See @ref[other.brf#missing](Foo).\n",
599            "here.brf",
600            &[("other.brf", &["present"])],
601        );
602        assert!(has(&diags, Code::RefMissingAnchor), "{:?}", diags);
603    }
604
605    #[test]
606    fn ref_with_dot_dot_is_b0603() {
607        let (_, diags) = run_resolve(
608            "See @ref[../escape.brf](Foo).\n",
609            "here.brf",
610            &[("here.brf", &[])],
611        );
612        assert!(has(&diags, Code::RefBadTarget), "{:?}", diags);
613    }
614
615    #[test]
616    fn ref_without_brf_extension_is_b0603() {
617        let (_, diags) = run_resolve(
618            "See @ref[no-extension](Foo).\n",
619            "here.brf",
620            &[("here.brf", &[])],
621        );
622        assert!(has(&diags, Code::RefBadTarget), "{:?}", diags);
623    }
624
625    #[test]
626    fn ref_outside_project_is_b0604() {
627        let mut doc = parse_only("@ref[a.brf](X)\n");
628        let reg = crate::shortcode::Registry::with_builtins();
629        let diags = resolve_with_project(&mut doc, &reg, None);
630        assert!(has(&diags, Code::RefNoProject), "{:?}", diags);
631    }
632
633    #[test]
634    fn valid_ref_records_in_resolved_refs() {
635        let (doc, diags) = run_resolve(
636            "See @ref[other.brf#x](Other).\n",
637            "here.brf",
638            &[("other.brf", &["x"])],
639        );
640        assert!(diags.iter().all(|d| d.code != Code::RefMissingFile
641            && d.code != Code::RefMissingAnchor
642            && d.code != Code::RefBadTarget
643            && d.code != Code::RefNoProject));
644        assert_eq!(doc.resolved_refs.len(), 1);
645        let (_span, rr) = doc.resolved_refs.iter().next().unwrap();
646        assert_eq!(rr.target_path, "other.brf");
647        assert_eq!(rr.target_anchor.as_deref(), Some("x"));
648        assert_eq!(rr.display, "Other");
649    }
650
651    #[test]
652    fn ref_to_self_is_allowed_when_anchor_exists() {
653        let (doc, diags) = run_resolve(
654            "## Top {#top}\n\nSee @ref[here.brf#top](Top).\n",
655            "here.brf",
656            &[("here.brf", &["top"])],
657        );
658        assert!(
659            diags
660                .iter()
661                .all(|d| d.severity != crate::diag::Severity::Error),
662            "diags: {:?}",
663            diags,
664        );
665        assert_eq!(doc.resolved_refs.len(), 1);
666    }
667
668    #[test]
669    fn ref_with_no_anchor_resolves_against_file_only() {
670        let (doc, diags) = run_resolve(
671            "See @ref[other.brf](Other).\n",
672            "here.brf",
673            &[("other.brf", &[])],
674        );
675        assert!(
676            diags
677                .iter()
678                .all(|d| d.severity != crate::diag::Severity::Error),
679            "{:?}",
680            diags
681        );
682        assert_eq!(doc.resolved_refs.len(), 1);
683        let (_span, rr) = doc.resolved_refs.iter().next().unwrap();
684        assert!(rr.target_anchor.is_none());
685    }
686
687    #[test]
688    fn empty_ref_body_outside_project_is_b0604_not_b0603() {
689        let mut doc = parse_only("@ref[](X)\n");
690        let reg = crate::shortcode::Registry::with_builtins();
691        let diags = resolve_with_project(&mut doc, &reg, None);
692        assert!(
693            has(&diags, Code::RefNoProject),
694            "expected B0604 first; got {:?}",
695            diags
696        );
697        assert!(
698            !has(&diags, Code::RefBadTarget),
699            "B0603 should not fire when there is no project: {:?}",
700            diags
701        );
702    }
703}