use std::collections::{BTreeMap, BTreeSet};
use rowan::TextRange;
use brink_ir::hir::{Content, ContentContext, ContentPart, HirFile, HirVisitor, SpanPart};
use brink_ir::{Diagnostic, DiagnosticCode, FileId, HostManifest, Provenance};
#[must_use]
pub fn check(files: &[(FileId, &HirFile)], manifest: Option<&HostManifest>) -> Vec<Diagnostic> {
let Some(manifest) = manifest else {
return Vec::new();
};
if manifest.markup.is_empty() {
return Vec::new();
}
let mut vocab: BTreeMap<&str, KindVocab<'_>> = BTreeMap::new();
for kind in &manifest.markup {
let entry = vocab.entry(kind.name.as_str()).or_default();
for attr in &kind.attrs {
entry.allowed.insert(attr.name.as_str());
if attr.required {
entry.required.insert(attr.name.as_str());
}
}
}
let mut out = Vec::new();
for &(file, hir) in files {
let mut walker = SpanWalker {
file,
vocab: &vocab,
out: &mut out,
};
brink_ir::hir::visit::visit(hir, &mut walker);
}
out
}
#[derive(Default)]
struct KindVocab<'a> {
allowed: BTreeSet<&'a str>,
required: BTreeSet<&'a str>,
}
struct SpanWalker<'a> {
file: FileId,
vocab: &'a BTreeMap<&'a str, KindVocab<'a>>,
out: &'a mut Vec<Diagnostic>,
}
impl SpanWalker<'_> {
fn report(&mut self, range: TextRange, code: DiagnosticCode, message: String) {
self.out.push(Diagnostic {
file: self.file,
range,
message,
code,
});
}
fn check_span(&mut self, span: &SpanPart) {
match self.vocab.get(span.name.as_str()) {
None => self.report(
Provenance::text_range(&span.ptr),
DiagnosticCode::E164,
format!(
"unknown markup tag `<{}>`: the host manifest's markup vocabulary does not declare it",
span.name
),
),
Some(kind_vocab) => {
for attr in &span.attrs {
if !kind_vocab.allowed.contains(attr.name.as_str()) {
self.report(
Provenance::text_range(&attr.ptr),
DiagnosticCode::E165,
format!(
"unknown attribute `{}` on markup tag `<{}>`: the host manifest does not declare it for this span kind",
attr.name, span.name
),
);
}
}
let present: BTreeSet<&str> =
span.attrs.iter().map(|attr| attr.name.as_str()).collect();
for &required_attr in &kind_vocab.required {
if !present.contains(required_attr) {
self.report(
Provenance::text_range(&span.ptr),
DiagnosticCode::E173,
format!(
"markup tag `<{}>` is missing required attribute `{required_attr}`: the host manifest declares it required for this span kind",
span.name
),
);
}
}
}
}
for child in &span.children {
self.check_part(child);
}
}
fn check_part(&mut self, part: &ContentPart) {
match part {
ContentPart::Span(span) => self.check_span(span),
ContentPart::Text(_)
| ContentPart::Glue
| ContentPart::Spring
| ContentPart::Interpolation(_)
| ContentPart::InlineConditional(_)
| ContentPart::InlineSequence(_) => {}
}
}
}
impl HirVisitor for SpanWalker<'_> {
fn enter_content(&mut self, content: &Content, _ctx: ContentContext) {
for part in &content.parts {
self.check_part(part);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::{ManifestExternal, ManifestSpanAttr, ManifestSpanKind, SemanticTypeDef};
fn attr(name: &str) -> ManifestSpanAttr {
ManifestSpanAttr {
name: name.to_string(),
required: false,
ty: None,
}
}
fn required_attr(name: &str) -> ManifestSpanAttr {
ManifestSpanAttr {
name: name.to_string(),
required: true,
ty: None,
}
}
fn lower(src: &str) -> HirFile {
let parse = brink_syntax_native::parse(src);
assert!(
parse.errors().is_empty(),
"parse errors: {:?}",
parse.errors()
);
let (hir, _manifest, diags) = brink_ir::hir::lower_native::lower(FileId(0), &parse.tree());
assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
hir
}
fn run(src: &str, manifest: Option<&HostManifest>) -> Vec<Diagnostic> {
let hir = lower(src);
check(&[(FileId(0), &hir)], manifest)
}
fn codes(diags: &[Diagnostic]) -> Vec<&'static str> {
diags.iter().map(|d| d.code.as_str()).collect()
}
fn wave_manifest() -> HostManifest {
HostManifest {
markup: vec![
ManifestSpanKind {
name: "wave".to_string(),
attrs: vec![attr("amount")],
},
ManifestSpanKind {
name: "b".to_string(),
attrs: Vec::new(),
},
],
..HostManifest::default()
}
}
#[test]
fn no_manifest_at_all_never_diagnoses_a_span() {
let diags = run(
"flow a() {\n He hands you <item id=\"lantern\">the lantern</item>.\n}\n",
None,
);
assert!(
diags.is_empty(),
"freeform default must stay silent: {diags:?}"
);
}
#[test]
fn a_manifest_with_externals_but_no_markup_key_never_diagnoses_a_span() {
let manifest = HostManifest {
externals: vec![ManifestExternal {
name: "play_sfx".to_string(),
params: Vec::new(),
returns: brink_ir::TypeRef::default(),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
}],
types: vec![SemanticTypeDef {
name: "actor_id".to_string(),
base: brink_ir::BaseType::Int,
constraint: None,
values: None,
widget: None,
}],
markup: Vec::new(),
};
let diags = run(
"flow a() {\n <totally undeclared=\"yes\">hi</totally>\n}\n",
Some(&manifest),
);
assert!(
diags.is_empty(),
"an externals-only manifest must not enable markup checking: {diags:?}"
);
}
#[test]
fn a_declared_vocabulary_still_accepts_every_declared_tag() {
let diags = run(
"flow a() {\n <wave amount=\"3\">shimmer</wave> and <b>bold</b>\n}\n",
Some(&wave_manifest()),
);
assert!(diags.is_empty(), "declared tags must pass: {diags:?}");
}
#[test]
fn an_undeclared_tag_reports_e164_once() {
let diags = run(
"flow a() {\n <shake power=\"9\">whoa</shake>\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164"], "{diags:?}");
assert!(
diags[0].message.contains("<shake>"),
"message must name the tag: {}",
diags[0].message
);
}
#[test]
fn an_undeclared_attribute_on_a_declared_tag_reports_e165() {
let diags = run(
"flow a() {\n <wave speed=\"2\">shimmer</wave>\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E165"], "{diags:?}");
assert!(
diags[0].message.contains("speed") && diags[0].message.contains("<wave>"),
"message must name attribute and tag: {}",
diags[0].message
);
}
#[test]
fn an_undeclared_tag_does_not_cascade_one_e165_per_attribute() {
let diags = run(
"flow a() {\n <shake power=\"9\" decay=\"1\">whoa</shake>\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164"], "{diags:?}");
}
#[test]
fn a_self_closing_undeclared_tag_reports_e164() {
let diags = run(
"flow a() {\n Bell tolls. <pause/> Door slams.\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164"], "{diags:?}");
}
#[test]
fn a_nested_span_is_checked_not_just_the_outermost() {
let diags = run(
"flow a() {\n <b><glitch>hi</glitch></b>\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164"], "{diags:?}");
assert!(
diags[0].message.contains("<glitch>"),
"{}",
diags[0].message
);
}
#[test]
fn a_span_inside_a_conditional_branch_is_checked() {
let diags = run(
"flow a(hp) {\n {if hp > 0: <glitch>yawn</glitch> else: Ready.}\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164"], "{diags:?}");
}
#[test]
fn a_span_in_a_real_choice_point_is_checked_in_every_display_region() {
let diags = run(
"flow a() {\n {?\n * <glitch>start</glitch>[<shake>bracket</shake>]<wobble>inner</wobble>\n }\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164", "E164", "E164"], "{diags:?}");
assert!(
diags[0].message.contains("<glitch>"),
"{}",
diags[0].message
);
assert!(diags[1].message.contains("<shake>"), "{}", diags[1].message);
assert!(
diags[2].message.contains("<wobble>"),
"{}",
diags[2].message
);
}
#[test]
fn markup_written_inside_a_tag_is_tag_text_not_a_span() {
let hir = lower("flow a() {\n Hello. # <glitch>loud</glitch>\n}\n");
let tags: Vec<_> = hir
.knots
.iter()
.flat_map(|k| &k.body.stmts)
.filter_map(|s| match s {
brink_ir::hir::Stmt::Content(c) => Some(&c.tags),
_ => None,
})
.flatten()
.collect();
assert!(!tags.is_empty(), "fixture must produce a tag: {hir:?}");
for tag in tags {
assert_eq!(
tag.parts,
vec![ContentPart::Text("<glitch>loud</glitch>".to_string())]
);
}
}
#[test]
fn every_undeclared_tag_on_one_line_is_reported() {
let diags = run(
"flow a() {\n <glitch>a</glitch> and <shake>b</shake>\n}\n",
Some(&wave_manifest()),
);
assert_eq!(codes(&diags), ["E164", "E164"], "{diags:?}");
}
#[test]
fn two_different_undeclared_tags_on_one_line_get_distinct_squiggle_ranges() {
let src = "flow a() {\n <glitch>a</glitch> and <shake>b</shake>\n}\n";
let diags = run(src, Some(&wave_manifest()));
assert_eq!(codes(&diags), ["E164", "E164"], "{diags:?}");
assert_ne!(
diags[0].range, diags[1].range,
"two spans on one line must not share a range: {diags:?}"
);
assert_eq!(
&src[usize::from(diags[0].range.start())..usize::from(diags[0].range.end())],
"<glitch>a</glitch>",
"range must cover only the offending span, not the whole line"
);
assert_eq!(
&src[usize::from(diags[1].range.start())..usize::from(diags[1].range.end())],
"<shake>b</shake>",
"range must cover only the offending span, not the whole line"
);
}
#[test]
fn repeated_undeclared_tag_on_one_line_still_gets_per_occurrence_ranges() {
let src = "flow a() {\n <pulse>a</pulse> <pulse>b</pulse>\n}\n";
let diags = run(src, Some(&wave_manifest()));
assert_eq!(codes(&diags), ["E164", "E164"], "{diags:?}");
assert_eq!(
diags[0].message, diags[1].message,
"same undeclared tag name -> same message text: {diags:?}"
);
assert_ne!(
diags[0].range, diags[1].range,
"repeated undeclared tag must still get per-occurrence ranges: {diags:?}"
);
assert_eq!(
&src[usize::from(diags[0].range.start())..usize::from(diags[0].range.end())],
"<pulse>a</pulse>"
);
assert_eq!(
&src[usize::from(diags[1].range.start())..usize::from(diags[1].range.end())],
"<pulse>b</pulse>"
);
}
#[test]
fn two_undeclared_attributes_on_one_span_get_distinct_squiggle_ranges() {
let src = "flow a() {\n <wave amount=\"1\" speed=\"2\" decay=\"3\">shimmer</wave>\n}\n";
let diags = run(src, Some(&wave_manifest()));
assert_eq!(codes(&diags), ["E165", "E165"], "{diags:?}");
assert_ne!(
diags[0].range, diags[1].range,
"two undeclared attributes on one span must not share a range: {diags:?}"
);
assert_eq!(
&src[usize::from(diags[0].range.start())..usize::from(diags[0].range.end())],
"speed=\"2\"",
"range must cover only the offending attribute, not the whole span"
);
assert_eq!(
&src[usize::from(diags[1].range.start())..usize::from(diags[1].range.end())],
"decay=\"3\"",
"range must cover only the offending attribute, not the whole span"
);
}
#[test]
fn repeated_undeclared_attribute_name_on_one_span_still_gets_per_occurrence_ranges() {
let src = "flow a() {\n <wave speed=\"1\" speed=\"2\">shimmer</wave>\n}\n";
let diags = run(src, Some(&wave_manifest()));
assert_eq!(codes(&diags), ["E165", "E165"], "{diags:?}");
assert_eq!(
diags[0].message, diags[1].message,
"same undeclared attribute name -> same message text: {diags:?}"
);
assert_ne!(
diags[0].range, diags[1].range,
"repeated undeclared attribute name must still get per-occurrence ranges: {diags:?}"
);
assert_eq!(
&src[usize::from(diags[0].range.start())..usize::from(diags[0].range.end())],
"speed=\"1\""
);
assert_eq!(
&src[usize::from(diags[1].range.start())..usize::from(diags[1].range.end())],
"speed=\"2\""
);
}
#[test]
fn a_single_undeclared_attribute_still_reports_e165_narrowed_to_that_attribute() {
let src = "flow a() {\n <wave speed=\"2\">shimmer</wave>\n}\n";
let diags = run(src, Some(&wave_manifest()));
assert_eq!(codes(&diags), ["E165"], "{diags:?}");
assert_eq!(
&src[usize::from(diags[0].range.start())..usize::from(diags[0].range.end())],
"speed=\"2\""
);
}
#[test]
fn a_missing_required_attribute_stays_span_ranged_not_attribute_ranged() {
let manifest = HostManifest {
markup: vec![ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![required_attr("volume")],
}],
..HostManifest::default()
};
let src = "flow a() {\n <sfx>clank</sfx>\n}\n";
let diags = run(src, Some(&manifest));
assert_eq!(codes(&diags), ["E173"], "{diags:?}");
assert_eq!(
&src[usize::from(diags[0].range.start())..usize::from(diags[0].range.end())],
"<sfx>clank</sfx>",
"a missing attribute has no node of its own; E173 must stay span-ranged"
);
}
#[test]
fn both_codes_default_to_warning_so_they_stay_overridable_and_suppressible() {
assert_eq!(DiagnosticCode::E164.severity(), brink_ir::Severity::Warning);
assert_eq!(DiagnosticCode::E165.severity(), brink_ir::Severity::Warning);
assert_eq!(DiagnosticCode::E173.severity(), brink_ir::Severity::Warning);
}
#[test]
fn duplicate_kind_declarations_merge_their_attribute_sets() {
let manifest = HostManifest {
markup: vec![
ManifestSpanKind {
name: "wave".to_string(),
attrs: vec![attr("amount")],
},
ManifestSpanKind {
name: "wave".to_string(),
attrs: vec![attr("speed")],
},
],
..HostManifest::default()
};
let diags = run(
"flow a() {\n <wave amount=\"3\">x</wave> <wave speed=\"2\">y</wave>\n}\n",
Some(&manifest),
);
assert!(diags.is_empty(), "both attrs must be accepted: {diags:?}");
}
fn sfx_manifest() -> HostManifest {
HostManifest {
markup: vec![ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![attr("name"), required_attr("volume")],
}],
..HostManifest::default()
}
}
#[test]
fn a_span_carrying_its_required_attribute_is_accepted() {
let diags = run(
"flow a() {\n <sfx name=\"door\" volume=\"3\">clank</sfx>\n}\n",
Some(&sfx_manifest()),
);
assert!(diags.is_empty(), "required attribute present: {diags:?}");
}
#[test]
fn a_span_missing_its_required_attribute_reports_e173() {
let diags = run(
"flow a() {\n <sfx name=\"door\">clank</sfx>\n}\n",
Some(&sfx_manifest()),
);
assert_eq!(codes(&diags), ["E173"], "{diags:?}");
assert!(
diags[0].message.contains("volume") && diags[0].message.contains("<sfx>"),
"message must name the missing attribute and the tag: {}",
diags[0].message
);
}
#[test]
fn a_span_missing_every_optional_attribute_but_none_required_is_not_diagnosed() {
let diags = run(
"flow a() {\n <wave>shimmer</wave>\n}\n",
Some(&wave_manifest()),
);
assert!(diags.is_empty(), "no required attrs declared: {diags:?}");
}
#[test]
fn a_span_missing_several_required_attributes_reports_one_e173_per_attribute() {
let manifest = HostManifest {
markup: vec![ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![required_attr("name"), required_attr("volume")],
}],
..HostManifest::default()
};
let diags = run("flow a() {\n <sfx>clank</sfx>\n}\n", Some(&manifest));
assert_eq!(codes(&diags), ["E173", "E173"], "{diags:?}");
assert!(diags[0].message.contains("name"), "{}", diags[0].message);
assert!(diags[1].message.contains("volume"), "{}", diags[1].message);
}
#[test]
fn an_undeclared_tag_does_not_also_report_e173() {
let diags = run(
"flow a() {\n <shake power=\"9\">whoa</shake>\n}\n",
Some(&sfx_manifest()),
);
assert_eq!(codes(&diags), ["E164"], "{diags:?}");
}
#[test]
fn duplicate_kind_declarations_union_required_rather_than_overwrite() {
let manifest = HostManifest {
markup: vec![
ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![required_attr("volume")],
},
ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![attr("name")],
},
],
..HostManifest::default()
};
let diags = run(
"flow a() {\n <sfx name=\"door\">clank</sfx>\n}\n",
Some(&manifest),
);
assert_eq!(
codes(&diags),
["E173"],
"volume must still be required after the merge: {diags:?}"
);
}
}