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 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, ®, 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, ®, 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, ®, 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, ®, 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}