use std::collections::{BTreeMap, BTreeSet};
use brink_ir::hir::{Content, ContentContext, ContentPart, HirFile, HirVisitor, SpanPart};
use brink_ir::{FileId, HostManifest, ManifestSpanKind};
use rowan::TextRange;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HarvestSite {
pub file: FileId,
pub range: TextRange,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CueHarvest {
pub sites: Vec<HarvestSite>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SpanHarvest {
pub sites: Vec<HarvestSite>,
pub attrs: BTreeMap<String, Vec<HarvestSite>>,
pub declared: Option<ManifestSpanKind>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HarvestIndex {
pub cues: BTreeMap<String, CueHarvest>,
pub spans: BTreeMap<String, SpanHarvest>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HarvestNames {
pub cues: BTreeSet<String>,
pub spans: BTreeMap<String, SpanNames>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SpanNames {
pub attrs: BTreeSet<String>,
pub declared: Option<ManifestSpanKind>,
}
impl HarvestIndex {
#[must_use]
pub fn names(&self) -> HarvestNames {
HarvestNames {
cues: self.cues.keys().cloned().collect(),
spans: self
.spans
.iter()
.map(|(name, span)| {
(
name.clone(),
SpanNames {
attrs: span.attrs.keys().cloned().collect(),
declared: span.declared.clone(),
},
)
})
.collect(),
}
}
}
#[must_use]
pub fn harvest(files: &[(FileId, &HirFile)], manifest: Option<&HostManifest>) -> HarvestIndex {
let mut index = HarvestIndex::default();
if let Some(manifest) = manifest {
for kind in &manifest.markup {
let entry = index.spans.entry(kind.name.clone()).or_default();
entry.declared = Some(match entry.declared.take() {
Some(existing) => merge_declared(&existing, kind),
None => kind.clone(),
});
}
}
for &(file, hir) in files {
for cue in &hir.cue_names {
index
.cues
.entry(cue.name.clone())
.or_default()
.sites
.push(HarvestSite {
file,
range: cue.range,
});
}
let mut walker = SpanHarvestWalker {
file,
index: &mut index,
};
brink_ir::hir::visit::visit(hir, &mut walker);
}
index
}
fn merge_declared(existing: &ManifestSpanKind, incoming: &ManifestSpanKind) -> ManifestSpanKind {
let mut attrs = existing.attrs.clone();
for attr in &incoming.attrs {
match attrs.iter_mut().find(|a| a.name == attr.name) {
Some(present) => present.required |= attr.required,
None => attrs.push(attr.clone()),
}
}
ManifestSpanKind {
name: existing.name.clone(),
attrs,
}
}
struct SpanHarvestWalker<'a> {
file: FileId,
index: &'a mut HarvestIndex,
}
impl SpanHarvestWalker<'_> {
fn harvest_span(&mut self, span: &SpanPart) {
let range = span.ptr.text_range();
let entry = self.index.spans.entry(span.name.clone()).or_default();
entry.sites.push(HarvestSite {
file: self.file,
range,
});
for attr in &span.attrs {
entry
.attrs
.entry(attr.name.clone())
.or_default()
.push(HarvestSite {
file: self.file,
range,
});
}
for child in &span.children {
self.harvest_part(child);
}
}
fn harvest_part(&mut self, part: &ContentPart) {
match part {
ContentPart::Span(span) => self.harvest_span(span),
ContentPart::Text(_)
| ContentPart::Glue
| ContentPart::Spring
| ContentPart::Interpolation(_)
| ContentPart::InlineConditional(_)
| ContentPart::InlineSequence(_) => {}
}
}
}
impl HirVisitor for SpanHarvestWalker<'_> {
fn enter_content(&mut self, content: &Content, _ctx: ContentContext) {
for part in &content.parts {
self.harvest_part(part);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::{ExternalKind, ManifestExternal, ManifestSpanAttr, SemanticTypeDef, TypeRef};
fn lower_native(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());
hir
}
fn lower_native_at(file: FileId, 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(file, &parse.tree());
hir
}
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,
}
}
#[test]
fn an_unclaimed_cue_is_still_harvested() {
let hir = lower_native("flow a() {\n @KID\n Says who?\n}\n");
let index = harvest(&[(FileId(0), &hir)], None);
assert!(
index.cues.contains_key("KID"),
"an unclaimed cue must still be harvested: {index:?}"
);
}
#[test]
fn a_claimed_cue_is_harvested_the_same_way() {
let hir = lower_native(
"@[convention(claims = \"^(?<name>[A-Z][A-Z]*)$\", order = 10, block)]\nfn cue(name: string, body: content) >{\n {name}\n {body}\n}\n\nflow a() {\n @KID\n Says who?\n}\n",
);
let index = harvest(&[(FileId(0), &hir)], None);
assert!(
index.cues.contains_key("KID"),
"a claimed cue must still be harvested: {index:?}"
);
}
#[test]
fn a_compact_cue_name_is_harvested_too() {
let hir = lower_native("flow a() {\n @VENDOR: Something for the road?\n}\n");
let index = harvest(&[(FileId(0), &hir)], None);
assert!(
index.cues.contains_key("VENDOR"),
"a compact cue's name must be harvested: {index:?}"
);
}
#[test]
fn the_same_cue_name_in_two_files_completes_from_either() {
let a = lower_native_at(FileId(0), "flow a() {\n @KID\n Hey.\n}\n");
let b = lower_native_at(FileId(1), "flow b() {\n @KID\n You again.\n}\n");
let index = harvest(&[(FileId(0), &a), (FileId(1), &b)], None);
let sites = &index.cues.get("KID").expect("KID harvested").sites;
assert_eq!(sites.len(), 2, "one site per file: {sites:?}");
let files: std::collections::BTreeSet<_> = sites.iter().map(|s| s.file).collect();
assert_eq!(
files,
[FileId(0), FileId(1)].into_iter().collect(),
"both files' occurrences must be present: {sites:?}"
);
}
#[test]
fn cue_names_are_never_harvested_from_the_ink_frontend() {
let parse = brink_syntax::parse("=== knot ===\nHello.\n-> END\n");
let (hir, _manifest, _diags) = brink_ir::hir::lower(FileId(0), &parse.tree());
assert!(hir.cue_names.is_empty(), "ink grammar has no cue channel");
let index = harvest(&[(FileId(0), &hir)], None);
assert!(index.cues.is_empty(), "{index:?}");
}
#[test]
fn an_undeclared_span_still_harvests_its_kind_and_attribute() {
let hir = lower_native("flow a() {\n <wave amount=\"3\">shimmer</wave>\n}\n");
let index = harvest(&[(FileId(0), &hir)], None);
let wave = index.spans.get("wave").expect("wave harvested");
assert_eq!(wave.sites.len(), 1);
assert!(wave.attrs.contains_key("amount"));
assert!(wave.declared.is_none(), "no manifest registered: {wave:?}");
}
#[test]
fn a_declared_but_never_used_span_kind_still_completes() {
let manifest = HostManifest {
markup: vec![ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![attr("name"), required_attr("volume")],
}],
..HostManifest::default()
};
let hir = lower_native("flow a() {\n Nothing here.\n}\n");
let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
let sfx = index.spans.get("sfx").expect("declared kind must appear");
assert!(sfx.sites.is_empty(), "never used: {sfx:?}");
assert_eq!(
sfx.declared.as_ref().expect("declared").attrs,
vec![attr("name"), required_attr("volume")],
"the manifest's attribute records must survive verbatim, \
`required` included — not degraded to bare names"
);
}
#[test]
fn a_harvested_span_kind_the_manifest_also_declares_merges_both_halves() {
let manifest = HostManifest {
markup: vec![ManifestSpanKind {
name: "wave".to_string(),
attrs: vec![attr("amount")],
}],
..HostManifest::default()
};
let hir = lower_native("flow a() {\n <wave amount=\"3\" speed=\"2\">shimmer</wave>\n}\n");
let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
let wave = index.spans.get("wave").expect("wave present");
assert_eq!(wave.sites.len(), 1, "harvested occurrence: {wave:?}");
assert!(wave.attrs.contains_key("amount"));
assert!(wave.attrs.contains_key("speed"));
assert_eq!(
wave.declared.as_ref().expect("declared").attrs,
vec![attr("amount")]
);
}
#[test]
fn duplicate_declared_kinds_merge_attrs_and_never_unrequire() {
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 hir = lower_native("flow a() {\n Nothing here.\n}\n");
let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
let sfx = index.spans.get("sfx").expect("declared kind");
let declared = sfx.declared.as_ref().expect("declared");
let volume = declared
.attrs
.iter()
.find(|a| a.name == "volume")
.expect("volume present");
assert!(volume.required, "must still be required after the merge");
}
#[test]
fn a_manifest_with_no_markup_key_contributes_nothing_beyond_harvest() {
let manifest = HostManifest {
externals: vec![ManifestExternal {
name: "play_sfx".to_string(),
params: Vec::new(),
returns: TypeRef::default(),
kind: 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 hir = lower_native("flow a() {\n <glow>shine</glow>\n}\n");
let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
let glow = index.spans.get("glow").expect("harvested regardless");
assert!(glow.declared.is_none(), "externals-only manifest: {glow:?}");
}
#[test]
fn a_nested_span_is_harvested_not_just_the_outermost() {
let hir = lower_native("flow a() {\n <b><glitch>hi</glitch></b>\n}\n");
let index = harvest(&[(FileId(0), &hir)], None);
assert!(index.spans.contains_key("b"));
assert!(index.spans.contains_key("glitch"));
}
#[test]
fn names_projection_drops_ranges_but_keeps_every_cue_and_span_name() {
let hir = lower_native(
"flow a() {\n @KID\n <wave amount=\"3\">shimmer</wave>\n Says hi.\n}\n",
);
let index = harvest(&[(FileId(0), &hir)], None);
let names = index.names();
assert!(names.cues.contains("KID"));
let wave = names.spans.get("wave").expect("wave harvested");
assert!(wave.attrs.contains("amount"));
}
#[test]
fn names_projection_is_eq_stable_across_a_range_only_change() {
let a = lower_native("flow a() {\n @KID\n Hi.\n}\n");
let b = lower_native("flow a() {\n\n\n @KID\n Hi.\n}\n");
let index_a = harvest(&[(FileId(0), &a)], None);
let index_b = harvest(&[(FileId(0), &b)], None);
assert_ne!(
index_a, index_b,
"sanity: the raw indexes differ by range, so this test is real"
);
assert_eq!(
index_a.names(),
index_b.names(),
"the range-free projection must be Eq-stable across a pure range shift"
);
}
#[test]
fn names_projection_preserves_declared_span_metadata() {
let manifest = HostManifest {
markup: vec![ManifestSpanKind {
name: "sfx".to_string(),
attrs: vec![required_attr("volume")],
}],
..HostManifest::default()
};
let hir = lower_native("flow a() {\n Nothing here.\n}\n");
let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
let names = index.names();
let sfx = names.spans.get("sfx").expect("declared kind must appear");
assert!(sfx.declared.as_ref().expect("declared").attrs[0].required);
}
}