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
12pub 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 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 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 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 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 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, ®);
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, ®);
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, ®, 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, ®, 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, ®, 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, ®, 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}