use brink_syntax_native::SyntaxNode;
use regex::Regex;
use rowan::{TextRange, TextSize};
use super::types::{
ConventionAttachSchema, ConventionMode, ConventionsProjection, ElementDisposition, Name,
};
#[derive(Debug, Clone)]
pub(crate) struct CompiledEntry {
name: Name,
order: i64,
mode: ConventionMode,
disposition: ElementDisposition,
attach: Option<ConventionAttachSchema>,
pattern: Regex,
}
#[must_use]
pub(crate) fn compile_entries(projection: &ConventionsProjection) -> Vec<CompiledEntry> {
projection
.entries
.iter()
.filter_map(|entry| {
Some(CompiledEntry {
name: entry.name.clone(),
order: entry.order,
mode: entry.mode,
disposition: entry.disposition,
attach: entry.attach.clone(),
pattern: Regex::new(&entry.pattern).ok()?,
})
})
.collect()
}
fn trim_for_classification(base: TextSize, text: &str) -> Option<(TextSize, &str)> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let lead = u32::try_from(text.len() - text.trim_start().len()).ok()?;
Some((base + TextSize::from(lead), trimmed))
}
fn classify_trimmed(
compiled: &[CompiledEntry],
base: TextSize,
trimmed: &str,
) -> LineClassification {
let mut hits = Vec::with_capacity(compiled.len());
for entry in compiled {
let Some(caps) = entry.pattern.captures(trimmed) else {
continue;
};
let Some(captures) = bind_captures(&entry.pattern, &caps, base) else {
continue;
};
hits.push(ClassifiedMatch {
handler: entry.name.clone(),
order: entry.order,
mode: entry.mode,
disposition: entry.disposition,
attach: entry.attach.clone(),
captures,
});
}
let mut hits = hits.into_iter();
let matched = hits.next();
LineClassification {
matched,
shadowed: hits.collect(),
}
}
#[must_use]
pub(crate) fn classify_line_compiled(
compiled: &[CompiledEntry],
base: TextSize,
text: &str,
) -> LineClassification {
let Some((base, trimmed)) = trim_for_classification(base, text) else {
return LineClassification::default();
};
classify_trimmed(compiled, base, trimmed)
}
#[must_use]
pub(crate) fn classify_node_compiled(
compiled: &[CompiledEntry],
node: &SyntaxNode,
) -> Option<LineClassification> {
let node_start = node.text_range().start();
let (_kind, text_node) = super::lower_native::candidate(node)?;
let local_base = text_node.text_range().start() - node_start;
let text = text_node.text().to_string();
let classification = classify_line_compiled(compiled, local_base, &text);
if let Some(winner) = &classification.matched
&& winner.attach.is_some()
&& super::lower_native::has_trailing_tags(node)
{
return Some(LineClassification::default());
}
Some(classification)
}
#[must_use]
pub fn nearest_element_candidate(node: &SyntaxNode) -> Option<SyntaxNode> {
std::iter::successors(Some(node.clone()), SyntaxNode::parent)
.find(|n| super::lower_native::candidate(n).is_some())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifiedCapture {
pub name: String,
pub text: String,
pub range: TextRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifiedMatch {
pub handler: Name,
pub order: i64,
pub mode: ConventionMode,
pub disposition: ElementDisposition,
pub attach: Option<ConventionAttachSchema>,
pub captures: Vec<ClassifiedCapture>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LineClassification {
pub matched: Option<ClassifiedMatch>,
pub shadowed: Vec<ClassifiedMatch>,
}
impl LineClassification {
#[must_use]
pub fn is_empty(&self) -> bool {
self.matched.is_none() && self.shadowed.is_empty()
}
}
#[must_use]
pub fn classify_line(
projection: &ConventionsProjection,
base: TextSize,
text: &str,
) -> LineClassification {
let Some((base, trimmed)) = trim_for_classification(base, text) else {
return LineClassification::default();
};
let compiled = compile_entries(projection);
classify_trimmed(&compiled, base, trimmed)
}
fn bind_captures(
pattern: &Regex,
caps: ®ex::Captures<'_>,
base: TextSize,
) -> Option<Vec<ClassifiedCapture>> {
pattern
.capture_names()
.flatten()
.map(|name| {
let m = caps.name(name)?;
let start = u32::try_from(m.start()).ok()?;
let end = u32::try_from(m.end()).ok()?;
Some(ClassifiedCapture {
name: name.to_string(),
text: m.as_str().to_string(),
range: TextRange::new(base + TextSize::from(start), base + TextSize::from(end)),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::{ClaimHandlerDecl, ConventionAttachField};
fn name(text: &str) -> Name {
Name {
text: text.to_string(),
range: TextRange::default(),
}
}
fn decl(name_text: &str, order: i64, pattern: &str) -> ClaimHandlerDecl {
ClaimHandlerDecl {
name: name(name_text),
annotation: TextRange::default(),
params: Vec::new(),
pattern: pattern.to_string(),
block: false,
order,
attach: None,
}
}
fn decl_attach(
name_text: &str,
order: i64,
pattern: &str,
attach_struct: &str,
) -> ClaimHandlerDecl {
ClaimHandlerDecl {
attach: Some(attach_struct.to_string()),
..decl(name_text, order, pattern)
}
}
fn no_structs() -> BTreeMap<String, Vec<ConventionAttachField>> {
BTreeMap::new()
}
fn projection(decls: &[ClaimHandlerDecl]) -> ConventionsProjection {
ConventionsProjection::from_decls(decls, &[], &no_structs())
}
#[test]
fn no_entry_matching_yields_empty_classification() {
let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let result = classify_line(&p, TextSize::from(0), "EXT. THE DOCK");
assert!(result.is_empty());
assert_eq!(result.matched, None);
assert!(result.shadowed.is_empty());
}
#[test]
fn a_single_matching_entry_becomes_the_winner_with_no_shadows() {
let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let result = classify_line(&p, TextSize::from(0), "INT. MARKET SQUARE");
let matched = result.matched.expect("expected a match");
assert_eq!(matched.handler.text, "interior");
assert_eq!(matched.order, 10);
assert!(result.shadowed.is_empty());
}
#[test]
fn every_other_matching_entry_is_recorded_as_shadowed_in_order() {
let p = projection(&[
decl("any_line", 10, "^.*$"),
decl("also_any_line", 20, "^.*$"),
decl("still_any_line", 30, "^.*$"),
]);
let result = classify_line(&p, TextSize::from(0), "INT. MARKET SQUARE");
let matched = result.matched.expect("expected a match");
assert_eq!(matched.handler.text, "any_line", "lowest order wins");
let shadowed_names: Vec<&str> = result
.shadowed
.iter()
.map(|m| m.handler.text.as_str())
.collect();
assert_eq!(
shadowed_names,
vec!["also_any_line", "still_any_line"],
"every other match is recorded, in ascending order — not just \
one, and not dropped"
);
}
#[test]
fn a_non_matching_entry_between_two_hits_is_not_shadowed() {
let p = projection(&[
decl("interior", 10, "^INT\\. (?<place>.+)$"),
decl("exterior_only", 20, "^EXT\\. .+$"),
decl("any_line", 30, "^.*$"),
]);
let result = classify_line(&p, TextSize::from(0), "INT. MARKET SQUARE");
let matched = result.matched.expect("expected a match");
assert_eq!(matched.handler.text, "interior");
let shadowed_names: Vec<&str> = result
.shadowed
.iter()
.map(|m| m.handler.text.as_str())
.collect();
assert_eq!(
shadowed_names,
vec!["any_line"],
"exterior_only never matched this line and must not appear"
);
}
#[test]
fn captures_are_bound_as_real_spans_at_the_given_base() {
let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let result = classify_line(&p, TextSize::from(100), "INT. MARKET SQUARE");
let matched = result.matched.expect("expected a match");
assert_eq!(matched.captures.len(), 1);
let capture = &matched.captures[0];
assert_eq!(capture.name, "place");
assert_eq!(capture.text, "MARKET SQUARE");
assert_eq!(capture.range, TextRange::new(105.into(), 118.into()));
}
#[test]
fn mode_and_disposition_are_carried_through_from_the_projection_entry() {
let mut decl_wrap = decl("cue", 10, "^@(?<who>.+)$");
decl_wrap.block = true;
let p = projection(&[decl_wrap]);
let result = classify_line(&p, TextSize::from(0), "@VENDOR");
let matched = result.matched.expect("expected a match");
assert_eq!(matched.mode, ConventionMode::Wrap);
assert_eq!(matched.disposition, ElementDisposition::Call);
}
#[test]
fn a_hit_carries_the_resolved_attach_schema_through_from_the_projection_entry() {
let mut decl_with_attach = decl("cue", 10, "^(?<who>[A-Z]+)$");
decl_with_attach.attach = Some("Cue".to_string());
let mut structs = BTreeMap::new();
structs.insert(
"Cue".to_string(),
vec![ConventionAttachField {
name: "who".to_string(),
ty: crate::SchemaTypeShape::Named("string".to_string()),
}],
);
let p = ConventionsProjection::from_decls(&[decl_with_attach], &[], &structs);
let result = classify_line(&p, TextSize::from(0), "VENDOR");
let matched = result.matched.expect("expected a match");
assert_eq!(
matched.attach,
Some(ConventionAttachSchema::Resolved {
name: "Cue".to_string(),
fields: vec![ConventionAttachField {
name: "who".to_string(),
ty: crate::SchemaTypeShape::Named("string".to_string()),
}],
})
);
}
#[test]
fn a_hit_carries_an_unresolved_attach_schema_through_too() {
let mut decl_with_attach = decl("cue", 10, "^(?<who>[A-Z]+)$");
decl_with_attach.attach = Some("NoSuchStruct".to_string());
let p = ConventionsProjection::from_decls(&[decl_with_attach], &[], &no_structs());
let result = classify_line(&p, TextSize::from(0), "VENDOR");
let matched = result.matched.expect("expected a match");
assert_eq!(
matched.attach,
Some(ConventionAttachSchema::Unresolved(
"NoSuchStruct".to_string()
))
);
}
#[test]
fn an_empty_projection_never_matches_anything() {
let p = projection(&[]);
let result = classify_line(&p, TextSize::from(0), "anything at all");
assert!(result.is_empty());
}
#[test]
fn an_entry_with_a_non_participating_named_group_is_declined_entirely() {
let p = projection(&[decl(
"interior",
10,
"^(?:INT\\. (?<place>.+)|EXT\\. (?<outside>.+))$",
)]);
let result = classify_line(&p, TextSize::from(0), "INT. MARKET");
assert!(
result.is_empty(),
"the `outside` group never participated on this branch, so the \
whole entry must be declined, not reported as a partial match"
);
}
#[test]
fn a_non_participating_entry_is_not_recorded_as_shadowed_either() {
let p = projection(&[
decl("any_line", 10, "^.*$"),
decl(
"interior_or_exterior",
20,
"^(?:INT\\. (?<place>.+)|EXT\\. (?<outside>.+))$",
),
]);
let result = classify_line(&p, TextSize::from(0), "INT. MARKET");
let matched = result.matched.expect("expected a match");
assert_eq!(matched.handler.text, "any_line");
assert!(
result.shadowed.is_empty(),
"interior_or_exterior's `outside` group never participated, so \
it must not appear as a shadow"
);
}
#[test]
fn an_indented_line_is_trimmed_before_matching_and_captures_land_on_real_source() {
let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let result = classify_line(&p, TextSize::from(100), " INT. MARKET SQUARE");
let matched = result.matched.expect("expected a match after trimming");
assert_eq!(matched.handler.text, "interior");
let capture = &matched.captures[0];
assert_eq!(capture.text, "MARKET SQUARE");
assert_eq!(capture.range, TextRange::new(109.into(), 122.into()));
}
#[test]
fn a_whitespace_only_line_never_matches_anything() {
let p = projection(&[decl("any_line", 10, "^.*$")]);
let result = classify_line(&p, TextSize::from(0), " \t ");
assert!(result.is_empty());
}
fn node_agreement_fixture_src() -> &'static str {
"\
var count = 3
@[convention(claims = \"^(?<kind>INT|EXT)\\\\. (?<title>.+)$\", order = 10)]
fn heading(kind: string, title: string) {
return title;
}
@[convention(claims = \"^(?<name>[A-Z][A-Z '-]*)$\", order = 20)]
fn cue(name: string) {
return name;
}
@[convention(claims = \"^(?<delivery>[a-z][a-z' -]*)$\", order = 30)]
fn parenthetical(delivery: string) {
return delivery;
}
flow main() {
INT. MARKET SQUARE - NIGHT [market] #act1
@VENDOR
(hushed)
@KID: I have {count} coins.
-> END
}
"
}
fn lower_src(src: &str) -> (crate::HirFile, SyntaxNode) {
use brink_syntax_native::ast::AstNode as _;
let parse = brink_syntax_native::parse(src);
let tree = parse.tree();
let (hir, _manifest, diags) = crate::hir::lower_native::lower(crate::FileId(0), &tree);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
(hir, tree.syntax().clone())
}
fn projection_from(hir: &crate::HirFile) -> ConventionsProjection {
ConventionsProjection::from_decls(
&hir.claim_handlers,
&hir.dispatch_handlers,
&BTreeMap::new(),
)
}
fn assert_agrees_with_compiled(
root: &SyntaxNode,
compiled: &[CompiledEntry],
elm: &crate::ElementMatch,
) {
let token = root
.token_at_offset(elm.line.start())
.right_biased()
.expect("a real token must start at the claimed line's own start");
let start_node = token
.parent()
.expect("every token in a well-formed tree has a parent node");
let not_a_candidate_msg = format!(
"the compiler claimed this line ({:?}), so some ancestor must be one of \
candidate's five recognized shapes: {elm:?}",
elm.kind
);
let node = nearest_element_candidate(&start_node).expect(¬_a_candidate_msg);
assert_eq!(
node.text_range(),
elm.line,
"the located node must be the EXACT node try_claim saw for this claim"
);
let node_start = node.text_range().start();
let classification = classify_node_compiled(compiled, &node)
.expect("candidate() must recognize a node this same call just found via it");
let missed_msg = format!(
"the live node-aware walk must agree the compiler DID claim this line (issue \
#2351's own bug: it used to miss every sigil-bearing line here) — attempted \
nothing for {elm:?}"
);
let winner = classification.matched.expect(&missed_msg);
assert_eq!(
winner.handler.text, elm.handler.text,
"must agree with the compiler on which handler claimed the line"
);
assert_eq!(
winner.captures.len(),
elm.captures.len(),
"must bind exactly the same number of captures as the compiler did"
);
for (live, from_compiler) in winner.captures.iter().zip(&elm.captures) {
assert_eq!(live.name, from_compiler.name);
assert_eq!(
live.text, from_compiler.text,
"capture text must match the compiler's own recorded capture exactly"
);
let live_range = TextRange::new(
live.range.start() + node_start,
live.range.end() + node_start,
);
assert_eq!(
live_range, from_compiler.range,
"capture spans, rebased onto the node's real position, must land on the \
exact same source bytes the compiler recorded"
);
}
}
#[test]
fn classify_node_agrees_with_the_compiler_for_every_claim_candidate_shape() {
let src = node_agreement_fixture_src();
let (hir, root) = lower_src(src);
assert_eq!(
hir.element_matches.len(),
4,
"expected one match each for the heading/cue/parenthetical/compact-cue \
lines: {:?}",
hir.element_matches
);
let kinds: Vec<_> = hir.element_matches.iter().map(|m| m.kind).collect();
assert_eq!(
kinds,
vec![
crate::ElementKind::SceneHeading,
crate::ElementKind::Cue,
crate::ElementKind::Parenthetical,
crate::ElementKind::Cue,
],
"the fixture must exercise all four named shapes, in this order — a smaller \
set here would silently weaken the agreement check below"
);
let compiled = compile_entries(&projection_from(&hir));
for elm in &hir.element_matches {
assert_agrees_with_compiled(&root, &compiled, elm);
}
}
#[test]
fn classify_line_against_the_raw_text_still_diverges_from_the_compiler() {
let src = node_agreement_fixture_src();
let (hir, _root) = lower_src(src);
let compiled = compile_entries(&projection_from(&hir));
for elm in &hir.element_matches {
let start = usize::from(elm.line.start());
let end = usize::from(elm.line.end());
let raw = classify_line_compiled(&compiled, elm.line.start(), &src[start..end]);
match elm.kind {
crate::ElementKind::Cue | crate::ElementKind::Parenthetical => {
assert!(
raw.matched.is_none(),
"the raw whole-line walk must still miss a real {:?} line \
entirely — got {:?}",
elm.kind,
raw.matched
);
}
crate::ElementKind::SceneHeading => {
let raw_winner = raw
.matched
.as_ref()
.expect("the raw walk still matches a heading line at all");
let raw_title = &raw_winner
.captures
.iter()
.find(|c| c.name == "title")
.expect("heading pattern declares a `title` capture")
.text;
let compiled_title = &elm
.captures
.iter()
.find(|c| c.name == "title")
.expect("heading pattern declares a `title` capture")
.text;
assert_ne!(
raw_title, compiled_title,
"the raw walk's `title` capture must diverge from the compiler's \
own stripped-slug/tag capture — raw: {raw_title:?}, compiler: \
{compiled_title:?}"
);
}
other => unreachable!("fixture should not produce a {other:?} match"),
}
}
}
fn candidate_node_at(src: &str, probe_needle: &str) -> SyntaxNode {
use brink_syntax_native::ast::AstNode as _;
let parse = brink_syntax_native::parse(src);
let tree = parse.tree();
let root = tree.syntax().clone();
assert!(
src.contains(probe_needle),
"fixture must contain {probe_needle:?}"
);
let offset = u32::try_from(src.find(probe_needle).expect("just asserted above"))
.expect("fixture source fits a u32 offset");
let token = root
.token_at_offset(TextSize::from(offset))
.right_biased()
.expect("a real token must start at the probe needle");
let start_node = token
.parent()
.expect("every token in a well-formed tree has a parent node");
let candidate = nearest_element_candidate(&start_node);
assert!(
candidate.is_some(),
"{probe_needle:?} must sit on a claim-candidate shape"
);
candidate.expect("just asserted above")
}
#[test]
fn a_tag_bearing_cue_under_an_attach_handler_explains_as_unmatched() {
let src = "flow main() {\n @VENDOR #(v.o.)\n}\n";
let decls = vec![decl_attach("cue", 10, "^(?<name>[A-Z][A-Z ]*)$", "Cue")];
let compiled = compile_entries(&projection(&decls));
let node = candidate_node_at(src, "@VENDOR");
let classification = classify_node_compiled(&compiled, &node)
.expect("a CUE node is always one of candidate's recognized shapes");
assert!(
classification.matched.is_none(),
"an attach-mode handler with nowhere to carry a claim's tags must decline \
the whole line, mirroring try_claim's own gate — got {:?}",
classification.matched
);
}
#[test]
fn a_tag_bearing_cue_under_a_claims_only_handler_still_explains_as_matched() {
let src = "flow main() {\n @VENDOR #(v.o.)\n}\n";
let decls = vec![decl("cue", 10, "^(?<name>[A-Z][A-Z ]*)$")];
let compiled = compile_entries(&projection(&decls));
let node = candidate_node_at(src, "@VENDOR");
let classification = classify_node_compiled(&compiled, &node)
.expect("a CUE node is always one of candidate's recognized shapes");
let winner = classification
.matched
.expect("a claims-only handler must still claim a tag-bearing line");
assert_eq!(winner.handler.text, "cue");
}
}