use brink_format::{LinePart, SlotInfo, SourceLocation};
use crate::hir;
use crate::hir::display_expr;
use super::content::lower_content_parts_pub;
use super::context::LowerCtx;
use super::expr::lower_expr;
use super::lir;
pub fn compose_hir_content(a: &hir::Content, b: &hir::Content) -> hir::Content {
let mut parts = a.parts.clone();
if let (Some(hir::ContentPart::Text(last)), Some(hir::ContentPart::Text(first))) =
(parts.last(), b.parts.first())
{
let merged =
if last.ends_with(char::is_whitespace) && first.starts_with(char::is_whitespace) {
format!("{last}{}", first.trim_start())
} else {
format!("{last}{first}")
};
let len = parts.len();
parts[len - 1] = hir::ContentPart::Text(merged);
parts.extend(b.parts.iter().skip(1).cloned());
} else {
parts.extend(b.parts.iter().cloned());
}
let mut tags = a.tags.clone();
tags.extend(b.tags.iter().cloned());
hir::Content {
ptr: a.ptr,
parts,
tags,
}
}
pub fn compose_hir_content_opt(
a: Option<&hir::Content>,
b: Option<&hir::Content>,
) -> Option<hir::Content> {
match (a, b) {
(None, None) => None,
(Some(c), None) | (None, Some(c)) => Some(c.clone()),
(Some(a_content), Some(b_content)) => Some(compose_hir_content(a_content, b_content)),
}
}
pub fn starts_with_whitespace_only_text(content: &hir::Content) -> bool {
matches!(content.parts.first(), Some(hir::ContentPart::Text(s)) if !s.is_empty() && s.trim().is_empty())
}
pub fn try_recognize(
content: &hir::Content,
ctx: &mut LowerCtx<'_>,
) -> Option<lir::ContentEmission> {
if content.parts.len() == 1
&& let hir::ContentPart::Text(s) = &content.parts[0]
{
let source_hash = brink_format::content_hash(s);
let source_location = build_source_location(content, ctx);
let tags = content
.tags
.iter()
.map(|t| lower_content_parts_pub(&t.parts, ctx))
.collect();
return Some(lir::ContentEmission {
line: lir::RecognizedLine::Plain(s.clone()),
metadata: lir::LineMetadata {
source_hash,
slot_info: Vec::new(),
source_location,
},
tags,
});
}
if try_recognize_template(content, ctx) {
let mut template_parts = Vec::new();
let mut slot_exprs = Vec::new();
let mut slot_info = Vec::new();
let mut hash_source = String::new();
let mut slot_idx: u8 = 0;
build_recognized_parts(
&content.parts,
ctx,
&mut template_parts,
&mut hash_source,
&mut slot_exprs,
&mut slot_info,
&mut slot_idx,
);
let source_hash = brink_format::content_hash(&hash_source);
let source_location = build_source_location(content, ctx);
let tags = content
.tags
.iter()
.map(|t| lower_content_parts_pub(&t.parts, ctx))
.collect();
return Some(lir::ContentEmission {
line: lir::RecognizedLine::Template {
parts: template_parts,
slot_exprs,
},
metadata: lir::LineMetadata {
source_hash,
slot_info,
source_location,
},
tags,
});
}
None
}
pub fn strip_boundary_glue(content: &hir::Content) -> (bool, hir::Content, bool) {
let parts = &content.parts;
let mut start = 0;
let mut has_leading = false;
while start < parts.len() && parts[start] == hir::ContentPart::Glue {
has_leading = true;
start += 1;
}
let mut end = parts.len();
let mut has_trailing = false;
while end > start && parts[end - 1] == hir::ContentPart::Glue {
has_trailing = true;
end -= 1;
}
let interior = &parts[start..end];
let mut merged_parts: Vec<hir::ContentPart> = Vec::with_capacity(interior.len());
for part in interior {
match part {
hir::ContentPart::Glue => {
if matches!(merged_parts.last(), Some(hir::ContentPart::Text(_))) {
merged_parts.push(hir::ContentPart::Glue);
} else {
merged_parts.push(hir::ContentPart::Glue);
}
}
hir::ContentPart::Text(s) => {
if matches!(merged_parts.last(), Some(hir::ContentPart::Glue)) {
merged_parts.pop(); if let Some(hir::ContentPart::Text(prev)) = merged_parts.last_mut() {
prev.push_str(s);
} else {
merged_parts.push(hir::ContentPart::Text(s.clone()));
}
} else {
merged_parts.push(part.clone());
}
}
_ => {
merged_parts.push(part.clone());
}
}
}
let stripped = hir::Content {
ptr: content.ptr,
parts: merged_parts,
tags: content.tags.clone(),
};
(has_leading, stripped, has_trailing)
}
pub fn try_recognize_with_glue(
content: &hir::Content,
ctx: &mut LowerCtx<'_>,
) -> Option<(bool, lir::ContentEmission, bool)> {
let (has_leading, stripped, has_trailing) = strip_boundary_glue(content);
if !has_leading && !has_trailing && stripped.parts.len() == content.parts.len() {
return None;
}
if stripped.parts.is_empty() {
return None;
}
let emission = try_recognize(&stripped, ctx)?;
Some((has_leading, emission, has_trailing))
}
pub(super) fn build_source_location(
content: &hir::Content,
ctx: &LowerCtx<'_>,
) -> Option<SourceLocation> {
let ptr = content.ptr.as_ref()?;
let range = ptr.text_range();
let file = ctx.file_paths.get(&ctx.file)?;
Some(SourceLocation {
file: file.clone(),
range_start: range.start().into(),
range_end: range.end().into(),
})
}
fn try_recognize_template(content: &hir::Content, _ctx: &LowerCtx<'_>) -> bool {
is_template_admissible(&content.parts)
&& content_has_span_or_interpolation(&content.parts)
&& (content_has_nonempty_text(&content.parts) || content_has_span(&content.parts))
}
fn is_template_admissible(parts: &[hir::ContentPart]) -> bool {
parts.iter().all(|p| match p {
hir::ContentPart::Text(_) | hir::ContentPart::Interpolation(_) => true,
hir::ContentPart::Span(span) => is_template_admissible(&span.children),
hir::ContentPart::Glue
| hir::ContentPart::Spring
| hir::ContentPart::InlineConditional(_)
| hir::ContentPart::InlineSequence(_) => false,
})
}
fn content_has_span_or_interpolation(parts: &[hir::ContentPart]) -> bool {
parts.iter().any(|p| {
matches!(
p,
hir::ContentPart::Interpolation(_) | hir::ContentPart::Span(_)
)
})
}
fn content_has_span(parts: &[hir::ContentPart]) -> bool {
parts.iter().any(|p| matches!(p, hir::ContentPart::Span(_)))
}
fn content_has_nonempty_text(parts: &[hir::ContentPart]) -> bool {
parts.iter().any(|p| match p {
hir::ContentPart::Text(s) => !s.trim().is_empty(),
hir::ContentPart::Span(span) => content_has_nonempty_text(&span.children),
_ => false,
})
}
fn build_recognized_parts(
parts: &[hir::ContentPart],
ctx: &mut LowerCtx<'_>,
out: &mut Vec<LinePart>,
hash_source: &mut String,
slot_exprs: &mut Vec<lir::Expr>,
slot_info: &mut Vec<SlotInfo>,
slot_idx: &mut u8,
) {
for part in parts {
match part {
hir::ContentPart::Text(s) => {
out.push(LinePart::Literal(s.clone()));
hash_source.push_str(s);
}
hir::ContentPart::Interpolation(expr) => {
out.push(LinePart::Slot(*slot_idx));
slot_exprs.push(lower_expr(expr, ctx));
slot_info.push(SlotInfo {
index: *slot_idx,
name: display_expr(expr),
});
hash_source.push_str("{…}");
*slot_idx = slot_idx.saturating_add(1);
}
hir::ContentPart::Span(span) => {
let mut children = Vec::with_capacity(span.children.len());
build_recognized_parts(
&span.children,
ctx,
&mut children,
hash_source,
slot_exprs,
slot_info,
slot_idx,
);
out.push(LinePart::Span {
name: span.name.clone(),
attrs: span
.attrs
.iter()
.map(|attr| (attr.name.clone(), attr.value.clone()))
.collect(),
children,
});
}
hir::ContentPart::Glue
| hir::ContentPart::Spring
| hir::ContentPart::InlineConditional(_)
| hir::ContentPart::InlineSequence(_) => {
unreachable!("try_recognize_template already validated")
}
}
}
}
pub const VARIANT_CAP: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VariantAlt {
pub part_idx: usize,
pub kind: hir::SequenceType,
pub branch_count: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VariantEnumeration {
pub alts: Vec<VariantAlt>,
pub dims: Vec<u16>,
pub variants: Vec<hir::Content>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VariantCapExceeded {
pub product: usize,
pub cap: usize,
}
pub fn enumerate_variant_contents(
content: &hir::Content,
) -> Result<Option<VariantEnumeration>, VariantCapExceeded> {
let mut alts = Vec::new();
for (idx, part) in content.parts.iter().enumerate() {
match part {
hir::ContentPart::InlineSequence(seq) => {
let kind = seq.kind;
let plain = [
hir::SequenceType::CYCLE,
hir::SequenceType::STOPPING,
hir::SequenceType::ONCE,
hir::SequenceType::SHUFFLE,
];
if !plain.contains(&kind) {
return Ok(None);
}
if seq.branches.is_empty() || seq.branches.len() > usize::from(u16::MAX) {
return Ok(None);
}
for branch in &seq.branches {
if branch_textual_parts(&branch.body).is_none() {
return Ok(None);
}
}
let branch_count = u16::try_from(seq.branches.len()).unwrap_or(u16::MAX);
alts.push(VariantAlt {
part_idx: idx,
kind,
branch_count,
});
}
hir::ContentPart::InlineConditional(_)
| hir::ContentPart::Glue
| hir::ContentPart::Spring => return Ok(None),
hir::ContentPart::Text(_)
| hir::ContentPart::Interpolation(_)
| hir::ContentPart::Span(_) => {}
}
}
if alts.is_empty() {
return Ok(None);
}
let dims: Vec<u16> = alts
.iter()
.map(|alt| {
if alt.kind == hir::SequenceType::ONCE {
alt.branch_count.saturating_add(1)
} else {
alt.branch_count
}
})
.collect();
let product = dims.iter().try_fold(1usize, |acc, &d| {
acc.checked_mul(usize::from(d))
.filter(|p| *p <= VARIANT_CAP)
});
let Some(product) = product else {
return Err(VariantCapExceeded {
product: dims.iter().map(|&d| usize::from(d)).product(),
cap: VARIANT_CAP,
});
};
let mut variants = Vec::with_capacity(product);
let mut combo = vec![0u16; alts.len()];
loop {
variants.push(substitute_combo(content, &alts, &combo));
let mut k = alts.len();
loop {
if k == 0 {
break;
}
k -= 1;
combo[k] += 1;
if combo[k] < dims[k] {
break;
}
combo[k] = 0;
if k == 0 {
debug_assert_eq!(
variants.len(),
product,
"mixed-radix walk covers the product"
);
return Ok(Some(VariantEnumeration {
alts,
dims,
variants,
}));
}
}
}
}
pub fn claims_variant_line(content: &hir::Content) -> bool {
match enumerate_variant_contents(content) {
Err(_) => true,
Ok(Some(en)) => en.variants.iter().all(statically_recognizable),
Ok(None) => false,
}
}
fn statically_recognizable(c: &hir::Content) -> bool {
if let [hir::ContentPart::Text(_)] = c.parts.as_slice() {
return true;
}
is_template_admissible(&c.parts)
&& content_has_span_or_interpolation(&c.parts)
&& (content_has_nonempty_text(&c.parts) || content_has_span(&c.parts))
}
fn branch_textual_parts(body: &hir::Block) -> Option<Vec<hir::ContentPart>> {
match body.stmts.as_slice() {
[] => Some(Vec::new()),
[hir::Stmt::Content(c)] if c.tags.is_empty() => {
let ok = c.parts.iter().all(|p| {
matches!(
p,
hir::ContentPart::Text(_)
| hir::ContentPart::Interpolation(_)
| hir::ContentPart::Span(_)
)
});
ok.then(|| c.parts.clone())
}
_ => None,
}
}
fn substitute_combo(content: &hir::Content, alts: &[VariantAlt], combo: &[u16]) -> hir::Content {
let mut parts: Vec<hir::ContentPart> = Vec::with_capacity(content.parts.len());
let push_merged = |parts: &mut Vec<hir::ContentPart>, part: &hir::ContentPart| {
if let (Some(hir::ContentPart::Text(last)), hir::ContentPart::Text(next)) =
(parts.last_mut(), part)
{
if last.ends_with(char::is_whitespace) && next.starts_with(char::is_whitespace) {
last.push_str(next.trim_start());
} else {
last.push_str(next);
}
} else {
parts.push(part.clone());
}
};
for (idx, part) in content.parts.iter().enumerate() {
if let Some(alt_pos) = alts.iter().position(|a| a.part_idx == idx) {
let hir::ContentPart::InlineSequence(seq) = part else {
unreachable!("alts only index InlineSequence parts");
};
let chosen = usize::from(combo[alt_pos]);
if chosen < seq.branches.len() {
let branch_parts =
branch_textual_parts(&seq.branches[chosen].body).unwrap_or_default();
for bp in &branch_parts {
push_merged(&mut parts, bp);
}
}
} else {
push_merged(&mut parts, part);
}
}
hir::Content {
ptr: content.ptr,
parts,
tags: content.tags.clone(),
}
}