use std::collections::BTreeMap;
use brink_syntax_native::SyntaxNode;
use rowan::{TextRange, TextSize};
use super::classify::{
ClassifiedCapture, ClassifiedMatch, CompiledEntry, classify_line_compiled,
classify_node_compiled, compile_entries,
};
use super::types::ConventionProjectionEntry;
use crate::ConventionsProjection;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineExplanation {
Matched {
winner: ClassifiedMatch,
shadowed: Vec<ClassifiedMatch>,
},
Unmatched {
attempted: Vec<ConventionProjectionEntry>,
},
}
impl LineExplanation {
#[must_use]
pub fn is_matched(&self) -> bool {
matches!(self, Self::Matched { .. })
}
#[must_use]
pub fn into_matched(self) -> Option<(ClassifiedMatch, Vec<ClassifiedMatch>)> {
match self {
Self::Matched { winner, shadowed } => Some((winner, shadowed)),
Self::Unmatched { .. } => None,
}
}
#[must_use]
pub fn into_attempted(self) -> Option<Vec<ConventionProjectionEntry>> {
match self {
Self::Unmatched { attempted } => Some(attempted),
Self::Matched { .. } => None,
}
}
}
#[must_use]
pub fn explain_match(
projection: &ConventionsProjection,
base: TextSize,
text: &str,
) -> LineExplanation {
let compiled = compile_entries(projection);
explain_match_compiled(&compiled, &projection.entries, base, text)
}
fn explain_match_compiled(
compiled: &[CompiledEntry],
entries: &[ConventionProjectionEntry],
base: TextSize,
text: &str,
) -> LineExplanation {
if text.trim().is_empty() {
return LineExplanation::Unmatched {
attempted: Vec::new(),
};
}
from_classification(classify_line_compiled(compiled, base, text), entries)
}
#[must_use]
pub fn explain_match_node(
projection: &ConventionsProjection,
node: &SyntaxNode,
) -> Option<LineExplanation> {
let compiled = compile_entries(projection);
let classification = classify_node_compiled(&compiled, node)?;
Some(from_classification(classification, &projection.entries))
}
fn from_classification(
classification: crate::LineClassification,
entries: &[ConventionProjectionEntry],
) -> LineExplanation {
match classification.matched {
Some(winner) => LineExplanation::Matched {
winner,
shadowed: classification.shadowed,
},
None => LineExplanation::Unmatched {
attempted: entries.to_vec(),
},
}
}
fn rebase(explanation: LineExplanation, delta: TextSize) -> LineExplanation {
fn rebase_capture(capture: ClassifiedCapture, delta: TextSize) -> ClassifiedCapture {
ClassifiedCapture {
range: TextRange::new(capture.range.start() + delta, capture.range.end() + delta),
..capture
}
}
fn rebase_match(m: ClassifiedMatch, delta: TextSize) -> ClassifiedMatch {
ClassifiedMatch {
captures: m
.captures
.into_iter()
.map(|c| rebase_capture(c, delta))
.collect(),
..m
}
}
match explanation {
LineExplanation::Matched { winner, shadowed } => LineExplanation::Matched {
winner: rebase_match(winner, delta),
shadowed: shadowed
.into_iter()
.map(|m| rebase_match(m, delta))
.collect(),
},
unmatched @ LineExplanation::Unmatched { .. } => unmatched,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CachedLine {
Matched {
winner: ClassifiedMatch,
shadowed: Vec<ClassifiedMatch>,
},
Miss,
}
fn to_cached(explanation: LineExplanation) -> CachedLine {
match explanation {
LineExplanation::Matched { winner, shadowed } => CachedLine::Matched { winner, shadowed },
LineExplanation::Unmatched { .. } => CachedLine::Miss,
}
}
fn from_cached(cached: CachedLine, entries: &[ConventionProjectionEntry]) -> LineExplanation {
match cached {
CachedLine::Matched { winner, shadowed } => LineExplanation::Matched { winner, shadowed },
CachedLine::Miss => LineExplanation::Unmatched {
attempted: entries.to_vec(),
},
}
}
const MAX_CACHED_LINES: usize = 4096;
#[derive(Debug, Default)]
pub struct ExplainMatchCache {
projection: ConventionsProjection,
compiled: Vec<CompiledEntry>,
lines: BTreeMap<String, CachedLine>,
}
impl ExplainMatchCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn explain(
&mut self,
projection: &ConventionsProjection,
base: TextSize,
text: &str,
node: Option<&SyntaxNode>,
) -> LineExplanation {
if &self.projection != projection {
self.projection = projection.clone();
self.compiled = compile_entries(projection);
self.lines.clear();
}
if text.trim().is_empty() {
return LineExplanation::Unmatched {
attempted: Vec::new(),
};
}
if let Some(node) = node
&& let Some(classification) = classify_node_compiled(&self.compiled, node)
{
return rebase(
from_classification(classification, &self.projection.entries),
base,
);
}
if let Some(cached) = self.lines.get(text) {
return rebase(from_cached(cached.clone(), &self.projection.entries), base);
}
if self.lines.len() >= MAX_CACHED_LINES {
self.lines.clear();
}
let explanation = explain_match_compiled(
&self.compiled,
&self.projection.entries,
TextSize::from(0),
text,
);
self.lines
.insert(text.to_owned(), to_cached(explanation.clone()));
rebase(explanation, base)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::{ClaimHandlerDecl, ConventionAttachField, Name};
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 no_structs() -> BTreeMap<String, Vec<ConventionAttachField>> {
BTreeMap::new()
}
fn projection(decls: &[ClaimHandlerDecl]) -> ConventionsProjection {
ConventionsProjection::from_decls(decls, &[], &no_structs())
}
#[test]
fn a_hit_reports_the_winner_and_every_shadowed_entry_in_order() {
let p = projection(&[
decl("cue", 10, "^(?<name>[A-Z]+)$"),
decl("any_line", 20, "^.*$"),
decl("also_any_line", 30, "^.*$"),
]);
let explanation = explain_match(&p, TextSize::from(0), "VENDOR");
let (winner, shadowed) = explanation.into_matched().expect("expected a match");
assert_eq!(winner.handler.text, "cue");
let shadowed_names: Vec<&str> = shadowed.iter().map(|m| m.handler.text.as_str()).collect();
assert_eq!(shadowed_names, vec!["any_line", "also_any_line"]);
}
#[test]
fn a_miss_reports_every_entry_attempted_in_registration_order() {
let p = projection(&[
decl("interior", 10, "^INT\\. (?<place>.+)$"),
decl("exterior", 20, "^EXT\\. (?<place>.+)$"),
decl("cue", 30, "^(?<name>[A-Z]+)$"),
]);
let explanation = explain_match(&p, TextSize::from(0), "plain content, matches nothing");
let attempted = explanation.into_attempted().expect("expected a miss");
let names: Vec<&str> = attempted.iter().map(|e| e.name.text.as_str()).collect();
assert_eq!(
names,
vec!["interior", "exterior", "cue"],
"registration order is resolution order — report attempted patterns in it"
);
}
#[test]
fn a_blank_line_reports_no_attempted_patterns_at_all() {
let p = projection(&[decl("any_line", 10, "^.*$")]);
let explanation = explain_match(&p, TextSize::from(0), " \t ");
let attempted = explanation.into_attempted().expect("expected a miss");
assert!(attempted.is_empty());
}
#[test]
fn an_empty_projection_attempts_nothing() {
let p = projection(&[]);
let explanation = explain_match(&p, TextSize::from(0), "anything at all");
let attempted = explanation.into_attempted().expect("expected a miss");
assert!(attempted.is_empty());
}
#[test]
fn a_declined_entirely_entry_still_appears_as_attempted_on_a_miss() {
let p = projection(&[decl(
"interior_or_exterior",
10,
"^(?:INT\\. (?<place>.+)|EXT\\. (?<outside>.+))$",
)]);
let explanation = explain_match(&p, TextSize::from(0), "no match here");
let attempted = explanation.into_attempted().expect("expected a miss");
assert_eq!(attempted.len(), 1);
assert_eq!(attempted[0].name.text, "interior_or_exterior");
}
#[test]
fn captures_on_a_hit_are_real_spans_at_the_given_base() {
let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let explanation = explain_match(&p, TextSize::from(100), "INT. MARKET SQUARE");
let (winner, _shadowed) = explanation.into_matched().expect("expected a match");
assert_eq!(winner.captures.len(), 1);
assert_eq!(winner.captures[0].text, "MARKET SQUARE");
assert_eq!(
winner.captures[0].range,
TextRange::new(105.into(), 118.into())
);
}
#[test]
fn the_cache_gives_the_same_answer_as_the_uncached_call() {
let p = projection(&[
decl("interior", 10, "^INT\\. (?<place>.+)$"),
decl("any_line", 20, "^.*$"),
]);
let direct = explain_match(&p, TextSize::from(50), "INT. MARKET SQUARE");
let mut cache = ExplainMatchCache::new();
let cached = cache.explain(&p, TextSize::from(50), "INT. MARKET SQUARE", None);
assert_eq!(direct, cached);
}
#[test]
fn identical_line_text_at_two_different_bases_gets_correctly_rebased_captures() {
let p = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let mut cache = ExplainMatchCache::new();
let first = cache.explain(&p, TextSize::from(0), "INT. MARKET SQUARE", None);
let second = cache.explain(&p, TextSize::from(1000), "INT. MARKET SQUARE", None);
let (w1, _) = first.into_matched().expect("expected a match");
let (w2, _) = second.into_matched().expect("expected a match");
assert_eq!(w1.captures[0].range, TextRange::new(5.into(), 18.into()));
assert_eq!(
w2.captures[0].range,
TextRange::new(1005.into(), 1018.into()),
"the second occurrence must be rebased onto its own base, not \
reuse the first occurrence's cached range"
);
}
#[test]
fn a_changed_projection_invalidates_every_cached_result() {
let before = projection(&[decl("any_line", 10, "^.*$")]);
let after = projection(&[decl("cue", 10, "^(?<name>[A-Z]+)$")]);
let mut cache = ExplainMatchCache::new();
let first = cache.explain(&before, TextSize::from(0), "VENDOR", None);
let (winner, _) = first.into_matched().expect("expected a match");
assert_eq!(winner.handler.text, "any_line");
let second = cache.explain(&after, TextSize::from(0), "VENDOR", None);
let (winner, _) = second.into_matched().expect("expected a match");
assert_eq!(
winner.handler.text, "cue",
"the stale `any_line` entry from the old projection must not \
still win after the projection changed"
);
}
#[test]
fn the_cache_never_grows_the_line_map_past_its_cap() {
let p = projection(&[decl("any_line", 10, "^.*$")]);
let mut cache = ExplainMatchCache::new();
for i in 0..(MAX_CACHED_LINES + 10) {
let _ = cache.explain(&p, TextSize::from(0), &format!("line number {i}"), None);
}
assert!(
cache.lines.len() <= MAX_CACHED_LINES,
"cache grew to {} entries, past its {MAX_CACHED_LINES} cap",
cache.lines.len()
);
}
#[test]
fn a_blank_line_never_occupies_a_cache_slot() {
let p = projection(&[decl("any_line", 10, "^.*$")]);
let mut cache = ExplainMatchCache::new();
let _ = cache.explain(&p, TextSize::from(0), " \t ", None);
assert!(cache.lines.is_empty());
}
#[test]
fn a_changed_projection_updates_attempted_patterns_on_a_repeat_miss() {
let before = projection(&[decl("interior", 10, "^INT\\. (?<place>.+)$")]);
let after = projection(&[
decl("interior", 10, "^INT\\. (?<place>.+)$"),
decl("exterior", 20, "^EXT\\. (?<place>.+)$"),
]);
let mut cache = ExplainMatchCache::new();
let first = cache.explain(&before, TextSize::from(0), "plain content", None);
let attempted = first.into_attempted().expect("expected a miss");
assert_eq!(attempted.len(), 1);
let second = cache.explain(&after, TextSize::from(0), "plain content", None);
let attempted = second.into_attempted().expect("expected a miss");
assert_eq!(
attempted.len(),
2,
"the new entry must appear after the projection changed"
);
}
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,
&no_structs(),
)
}
fn node_for(root: &SyntaxNode, elm: &crate::ElementMatch) -> SyntaxNode {
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 has a parent node");
crate::nearest_element_candidate(&start_node)
.expect("the compiler claimed this line, so some ancestor must be a candidate")
}
const CUE_FIXTURE: &str = "\
@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 10)]
fn cue(name: string) {
return name;
}
@[convention(claims = \"^(?<delivery>[a-z][a-z' -]*)$\", order = 20)]
fn parenthetical(delivery: string) {
return delivery;
}
flow main() {
@VENDOR
(hushed)
}
";
#[test]
fn explain_match_node_reports_a_real_hit_for_a_cue_line() {
let (hir, root) = lower_src(CUE_FIXTURE);
let projection = projection_from(&hir);
let cue_match = hir
.element_matches
.iter()
.find(|m| m.kind == crate::ElementKind::Cue)
.expect("the @VENDOR line must be claimed");
let node = node_for(&root, cue_match);
let explanation = explain_match_node(&projection, &node).expect("a recognized shape");
let (winner, _shadowed) = explanation
.into_matched()
.expect("the node-aware walk must agree the compiler claimed this line");
assert_eq!(winner.handler.text, "cue");
assert_eq!(winner.captures.len(), 1);
assert_eq!(winner.captures[0].name, "name");
assert_eq!(winner.captures[0].text, "VENDOR");
}
#[test]
fn explain_match_node_declines_for_a_non_candidate_node() {
let (hir, root) = lower_src(CUE_FIXTURE);
let projection = projection_from(&hir);
let flow_kw = root
.descendants_with_tokens()
.find_map(rowan::NodeOrToken::into_token)
.expect("the file must start with a real token");
let start_node = flow_kw.parent().expect("every token has a parent");
assert!(
crate::nearest_element_candidate(&start_node).is_none(),
"the file's very first token must not resolve to a claim candidate"
);
assert!(explain_match_node(&projection, &start_node).is_none());
}
#[test]
fn explain_cache_reports_a_real_hit_through_the_node_path() {
let (hir, root) = lower_src(CUE_FIXTURE);
let projection = projection_from(&hir);
let cue_match = hir
.element_matches
.iter()
.find(|m| m.kind == crate::ElementKind::Cue)
.expect("the @VENDOR line must be claimed");
let node = node_for(&root, cue_match);
let node_start = node.text_range().start();
let mut cache = ExplainMatchCache::new();
let explanation = cache.explain(&projection, node_start, "@VENDOR", Some(&node));
let (winner, _) = explanation
.into_matched()
.expect("the cache must report the same real hit explain_match_node does");
assert_eq!(winner.handler.text, "cue");
assert_eq!(winner.captures[0].text, "VENDOR");
}
#[test]
fn explain_cache_never_caches_a_node_derived_result_by_raw_text() {
let (hir, root) = lower_src(CUE_FIXTURE);
let projection = projection_from(&hir);
let cue_match = hir
.element_matches
.iter()
.find(|m| m.kind == crate::ElementKind::Cue)
.expect("the @VENDOR line must be claimed");
let node = node_for(&root, cue_match);
let node_start = node.text_range().start();
let mut cache = ExplainMatchCache::new();
let _ = cache.explain(&projection, node_start, "@VENDOR", Some(&node));
assert!(
cache.lines.is_empty(),
"a node-derived hit must never be inserted into the raw-text cache: {:?}",
cache.lines
);
}
}