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        let Some(spec) = sc.arguments.get(kw) else {
485            let mut d = Diagnostic::new(Code::UnknownArg, span)
486                .label(format!("no argument named `{}` on this shortcode", kw));
487            if !sc.arguments.is_empty() {
488                let known: Vec<&str> = sc.arguments.keys().map(|s| s.as_str()).collect();
489                d = d.help(format!("declared arguments: {}", known.join(", ")));
490            }
491            diags.push(d);
492            continue;
493        };
494        if !type_matches(&spec.ty, v) {
495            diags.push(Diagnostic::new(Code::ArgTypeMismatch, span).label(format!(
496                "argument `{}` has type {} but expected {:?}",
497                kw,
498                v.type_name(),
499                spec.ty
500            )));
501        }
502    }
503}
504
505fn type_matches(t: &ArgType, v: &ArgValue) -> bool {
506    matches!(
507        (t, v),
508        (ArgType::String, ArgValue::Str(_))
509            | (ArgType::String, ArgValue::Ident(_))
510            | (ArgType::Int, ArgValue::Int(_))
511            | (ArgType::Ident, ArgValue::Ident(_))
512            | (ArgType::Array, ArgValue::Array(_))
513    )
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::project::ProjectIndex;
520    use std::collections::BTreeSet;
521    use std::path::PathBuf;
522
523    fn parse_only(src: &str) -> crate::ast::Document {
524        use crate::{lexer, parser, span::SourceMap};
525        let src = SourceMap::new("t.brf", src);
526        let tokens = lexer::lex(&src).expect("lex");
527        let (doc, _) = parser::parse(tokens, &src);
528        doc
529    }
530
531    #[test]
532    fn resolved_refs_starts_empty() {
533        let doc = parse_only("hello\n");
534        assert!(doc.resolved_refs.is_empty());
535    }
536
537    #[test]
538    fn unknown_keyword_arg_is_b0409() {
539        let mut doc = parse_only("@details(summary: \"s\", bogus: 1)\nx\n@end\n");
540        let reg = crate::shortcode::Registry::with_builtins();
541        let diags = resolve(&mut doc, &reg);
542        assert!(
543            diags.iter().any(|d| d.code == Code::UnknownArg),
544            "diags: {:?}",
545            diags
546        );
547    }
548
549    #[test]
550    fn declared_keyword_arg_passes() {
551        let mut doc = parse_only("@details(summary: \"s\")\nx\n@end\n");
552        let reg = crate::shortcode::Registry::with_builtins();
553        let diags = resolve(&mut doc, &reg);
554        assert!(
555            diags.iter().all(|d| d.code != Code::UnknownArg),
556            "diags: {:?}",
557            diags
558        );
559    }
560
561    #[test]
562    fn resolve_project_records_valid_ref() {
563        let mut doc = parse_only("See @ref[other.brf#x](Other).\n");
564        let mut idx = ProjectIndex {
565            root: PathBuf::from("/tmp/proj"),
566            ..Default::default()
567        };
568        idx.anchors
569            .insert("other.brf".to_string(), BTreeSet::from(["x".into()]));
570        let project = ResolveProject {
571            index: &idx,
572            current: &PathBuf::from("here.brf"),
573        };
574        let reg = crate::shortcode::Registry::with_builtins();
575        let diags = resolve_with_project(&mut doc, &reg, Some(&project));
576        assert!(
577            diags
578                .iter()
579                .all(|d| d.severity != crate::diag::Severity::Error),
580            "diags: {:?}",
581            diags,
582        );
583        assert_eq!(doc.resolved_refs.len(), 1);
584    }
585
586    use crate::diag::Code;
587
588    fn run_resolve(
589        brief: &str,
590        current: &str,
591        files: &[(&str, &[&str])],
592    ) -> (crate::ast::Document, Vec<crate::diag::Diagnostic>) {
593        let mut doc = parse_only(brief);
594        let mut idx = ProjectIndex {
595            root: PathBuf::from("/tmp/p"),
596            ..Default::default()
597        };
598        for (path, anchors) in files {
599            idx.anchors.insert(
600                path.to_string(),
601                anchors.iter().map(|s| s.to_string()).collect(),
602            );
603        }
604        let project = ResolveProject {
605            index: &idx,
606            current: &PathBuf::from(current),
607        };
608        let reg = crate::shortcode::Registry::with_builtins();
609        let diags = resolve_with_project(&mut doc, &reg, Some(&project));
610        (doc, diags)
611    }
612
613    fn has(diags: &[crate::diag::Diagnostic], c: Code) -> bool {
614        diags.iter().any(|d| d.code == c)
615    }
616
617    #[test]
618    fn ref_to_unknown_file_is_b0601() {
619        let (_, diags) = run_resolve(
620            "See @ref[missing.brf#x](Foo).\n",
621            "here.brf",
622            &[("here.brf", &[])],
623        );
624        assert!(has(&diags, Code::RefMissingFile), "{:?}", diags);
625    }
626
627    #[test]
628    fn ref_to_unknown_anchor_is_b0602() {
629        let (_, diags) = run_resolve(
630            "See @ref[other.brf#missing](Foo).\n",
631            "here.brf",
632            &[("other.brf", &["present"])],
633        );
634        assert!(has(&diags, Code::RefMissingAnchor), "{:?}", diags);
635    }
636
637    #[test]
638    fn ref_with_dot_dot_is_b0603() {
639        let (_, diags) = run_resolve(
640            "See @ref[../escape.brf](Foo).\n",
641            "here.brf",
642            &[("here.brf", &[])],
643        );
644        assert!(has(&diags, Code::RefBadTarget), "{:?}", diags);
645    }
646
647    #[test]
648    fn ref_without_brf_extension_is_b0603() {
649        let (_, diags) = run_resolve(
650            "See @ref[no-extension](Foo).\n",
651            "here.brf",
652            &[("here.brf", &[])],
653        );
654        assert!(has(&diags, Code::RefBadTarget), "{:?}", diags);
655    }
656
657    #[test]
658    fn ref_outside_project_is_b0604() {
659        let mut doc = parse_only("@ref[a.brf](X)\n");
660        let reg = crate::shortcode::Registry::with_builtins();
661        let diags = resolve_with_project(&mut doc, &reg, None);
662        assert!(has(&diags, Code::RefNoProject), "{:?}", diags);
663    }
664
665    #[test]
666    fn valid_ref_records_in_resolved_refs() {
667        let (doc, diags) = run_resolve(
668            "See @ref[other.brf#x](Other).\n",
669            "here.brf",
670            &[("other.brf", &["x"])],
671        );
672        assert!(diags.iter().all(|d| d.code != Code::RefMissingFile
673            && d.code != Code::RefMissingAnchor
674            && d.code != Code::RefBadTarget
675            && d.code != Code::RefNoProject));
676        assert_eq!(doc.resolved_refs.len(), 1);
677        let (_span, rr) = doc.resolved_refs.iter().next().unwrap();
678        assert_eq!(rr.target_path, "other.brf");
679        assert_eq!(rr.target_anchor.as_deref(), Some("x"));
680        assert_eq!(rr.display, "Other");
681    }
682
683    #[test]
684    fn ref_to_self_is_allowed_when_anchor_exists() {
685        let (doc, diags) = run_resolve(
686            "## Top {#top}\n\nSee @ref[here.brf#top](Top).\n",
687            "here.brf",
688            &[("here.brf", &["top"])],
689        );
690        assert!(
691            diags
692                .iter()
693                .all(|d| d.severity != crate::diag::Severity::Error),
694            "diags: {:?}",
695            diags,
696        );
697        assert_eq!(doc.resolved_refs.len(), 1);
698    }
699
700    #[test]
701    fn ref_with_no_anchor_resolves_against_file_only() {
702        let (doc, diags) = run_resolve(
703            "See @ref[other.brf](Other).\n",
704            "here.brf",
705            &[("other.brf", &[])],
706        );
707        assert!(
708            diags
709                .iter()
710                .all(|d| d.severity != crate::diag::Severity::Error),
711            "{:?}",
712            diags
713        );
714        assert_eq!(doc.resolved_refs.len(), 1);
715        let (_span, rr) = doc.resolved_refs.iter().next().unwrap();
716        assert!(rr.target_anchor.is_none());
717    }
718
719    #[test]
720    fn empty_ref_body_outside_project_is_b0604_not_b0603() {
721        let mut doc = parse_only("@ref[](X)\n");
722        let reg = crate::shortcode::Registry::with_builtins();
723        let diags = resolve_with_project(&mut doc, &reg, None);
724        assert!(
725            has(&diags, Code::RefNoProject),
726            "expected B0604 first; got {:?}",
727            diags
728        );
729        assert!(
730            !has(&diags, Code::RefBadTarget),
731            "B0603 should not fire when there is no project: {:?}",
732            diags
733        );
734    }
735}