use alloc::{
borrow::Cow,
string::{String, ToString},
vec::Vec,
};
use super::{CompiledInclude, Condition, Segment};
use crate::{
compat::{HashMap, HashSet},
scope::{CompiledExpr, CompiledPath, ConditionOperand},
types::{VarDecl, VarType, VariantDecl},
};
#[must_use]
pub fn validate_field_accesses(segments: &[Segment], declarations: &[VarDecl]) -> Vec<String> {
validate_field_accesses_with_opaque(segments, declarations, &HashSet::new())
}
#[must_use]
pub fn validate_field_accesses_with_opaque(
segments: &[Segment],
declarations: &[VarDecl],
opaque_roots: &HashSet<&str>,
) -> Vec<String> {
let mut type_env = TypeEnv::from_declarations(declarations);
type_env.opaque_roots.clone_from(opaque_roots);
let mut errors = Vec::new();
let mut visited = HashSet::new();
walk_segments(segments, &mut type_env, &mut errors, &mut visited);
errors
}
#[derive(Clone)]
struct TypeEnv<'a> {
vars: HashMap<&'a str, &'a VarType>,
narrowed: HashMap<String, VarType>,
opaque_roots: HashSet<&'a str>,
}
impl<'a> TypeEnv<'a> {
fn from_declarations(declarations: &'a [VarDecl]) -> Self {
let mut vars = HashMap::with_capacity(declarations.len());
for decl in declarations {
vars.insert(decl.name.as_str(), &decl.var_type);
}
Self {
vars,
narrowed: HashMap::new(),
opaque_roots: HashSet::new(),
}
}
fn lookup(&self, name: &str) -> Option<&VarType> {
self.narrowed
.get(name)
.or_else(|| self.vars.get(name).copied())
}
fn is_opaque(&self, name: &str) -> bool {
self.opaque_roots.contains(name)
}
fn narrow(&mut self, name: &str, ty: VarType) -> Option<VarType> {
self.narrowed.insert(name.to_string(), ty)
}
fn unnarrow(&mut self, name: &str) {
self.narrowed.remove(name);
}
}
fn walk_segments(
segments: &[Segment],
env: &mut TypeEnv<'_>,
errors: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
for seg in segments {
walk_segment(seg, env, errors, visited);
}
}
fn walk_segment(
seg: &Segment,
env: &mut TypeEnv<'_>,
errors: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
match seg {
Segment::Static(_) | Segment::Raw(_) | Segment::Comment(_) => {}
Segment::Expr { expr, .. } => match expr {
CompiledExpr::Path(path) => {
validate_compiled_path(path, env, errors);
if let Some(resolved) = resolve_compiled_path_type(path, env) {
if !resolved.is_displayable() {
let hint = match resolved {
VarType::List(_) => "use {% for %} to iterate, or | join()",
VarType::Struct(_) => {
"access fields with dot notation, e.g. {{ x.field }}"
}
VarType::Enum(_) => "use kind(x) for the variant name, or {% match %}",
VarType::Tmpl(_) => "use {% include %} to render a template",
VarType::Option(_) => "use {% if has(x) %} to unwrap, or {% match %}",
_ => "only str, int, float, bool can be displayed",
};
errors.push(format!(
"'{}': cannot display value of type {resolved} — {hint}",
path.as_str()
));
}
}
}
CompiledExpr::Len(path) | CompiledExpr::Kind(path) | CompiledExpr::Has(path) => {
validate_compiled_path(path, env, errors);
}
CompiledExpr::Idx(_) => {}
},
Segment::ForLoop {
binding,
list_path,
body,
else_body,
} => {
validate_compiled_path(list_path, env, errors);
let resolved = resolve_compiled_path_type(list_path, env).cloned();
match resolved {
Some(VarType::List(ref fields)) => {
let elem_ty = VarType::Struct(fields.clone());
let prev = env.narrow(binding, elem_ty);
walk_segments(body, env, errors, visited);
match prev {
Some(t) => {
env.narrow(binding, t);
}
None => {
env.unnarrow(binding);
}
}
}
Some(other) => {
errors.push(format!(
"for loop over '{}': expected list, got {other}",
list_path.as_str()
));
}
None => {
walk_segments(body, env, errors, visited);
}
}
walk_segments(else_body, env, errors, visited);
}
Segment::If {
branches,
else_body,
} => {
for (condition, branch_body) in branches {
validate_condition(condition, env, errors);
let narrowing = extract_has_narrowing(condition, env);
if let Some((ref path_str, ref narrowed_type)) = narrowing {
let prev = env.narrow(path_str, narrowed_type.clone());
walk_segments(branch_body, env, errors, visited);
match prev {
Some(t) => {
env.narrow(path_str, t);
}
None => {
env.unnarrow(path_str);
}
}
} else {
walk_segments(branch_body, env, errors, visited);
}
}
walk_segments(else_body, env, errors, visited);
}
Segment::Match { expr, arms, .. } => {
validate_match(expr, arms, env, errors, visited);
}
Segment::Include(inc) => {
validate_include(inc, env, errors, visited);
}
}
}
fn validate_match(
expr: &CompiledPath,
arms: &[(Vec<Cow<'static, str>>, Vec<Segment>)],
env: &mut TypeEnv<'_>,
errors: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
if arms.is_empty() {
errors.push(format!(
"match on '{}': no case arms — add at least one {{% case %}}",
expr.as_str()
));
return;
}
let expr_type = resolve_compiled_path_type(expr, env).cloned();
match expr_type {
Some(VarType::Enum(ref declared)) => {
validate_match_arms_with_narrowing(expr, declared, arms, env, errors, visited);
}
Some(VarType::Option(_)) => {
for (variants, arm_body) in arms {
for v in variants {
let name = v.as_ref();
if name != "Some" && name != "None" && name != "_" {
errors.push(format!(
"match on '{}': invalid option variant '{name}' — \
expected 'Some', 'None', or '_'",
expr.as_str()
));
}
}
walk_segments(arm_body, env, errors, visited);
}
}
Some(other_type) => {
errors.push(format!(
"match on '{}': expected enum, got {other_type} — \
use {{% if %}} with == for non-enum dispatch",
expr.as_str()
));
for (_, arm_body) in arms {
walk_segments(arm_body, env, errors, visited);
}
}
None => {
let root = &expr.parts()[0];
if !env.is_opaque(root) {
errors.push(format!(
"match on '{}': undeclared variable '{root}'",
expr.as_str()
));
}
}
}
}
fn validate_match_arms_with_narrowing(
expr: &CompiledPath,
declared: &[VariantDecl],
arms: &[(Vec<Cow<'static, str>>, Vec<Segment>)],
env: &mut TypeEnv<'_>,
errors: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
let mut covered_variants: Vec<&str> = Vec::new();
let mut has_default = false;
for (case_variants, arm_body) in arms {
let is_default_arm = case_variants.iter().any(|v| v.as_ref() == "_");
if is_default_arm {
has_default = true;
let remaining_variants: Vec<VariantDecl> = declared
.iter()
.filter(|v| !covered_variants.contains(&v.name.as_str()))
.cloned()
.collect();
if remaining_variants.is_empty() {
walk_segments(arm_body, env, errors, visited);
} else {
let narrowed_type = VarType::Enum(remaining_variants);
let prev = env.narrow(expr.as_str(), narrowed_type);
walk_segments(arm_body, env, errors, visited);
match prev {
Some(t) => {
env.narrow(expr.as_str(), t);
}
None => {
env.unnarrow(expr.as_str());
}
}
}
continue;
}
for case_name in case_variants {
if declared.iter().any(|v| v.name == case_name.as_ref()) {
covered_variants.push(case_name.as_ref());
} else {
let valid: Vec<&str> = declared.iter().map(|v| v.name.as_str()).collect();
errors.push(format!(
"match on '{}': unknown variant '{case_name}' \
(declared variants: {})",
expr.as_str(),
valid.join(", ")
));
}
}
let narrowed_variants: Vec<VariantDecl> = declared
.iter()
.filter(|v| case_variants.iter().any(|c| c.as_ref() == v.name))
.cloned()
.collect();
if narrowed_variants.is_empty() {
walk_segments(arm_body, env, errors, visited);
} else {
let narrowed_type = VarType::Enum(narrowed_variants);
let prev = env.narrow(expr.as_str(), narrowed_type);
walk_segments(arm_body, env, errors, visited);
match prev {
Some(t) => {
env.narrow(expr.as_str(), t);
}
None => {
env.unnarrow(expr.as_str());
}
}
}
}
if arms.len() > 1 && !has_default {
let missing: Vec<&str> = declared
.iter()
.filter(|v| !covered_variants.contains(&v.name.as_str()))
.map(|v| v.name.as_str())
.collect();
if !missing.is_empty() {
errors.push(format!(
"match on '{}': non-exhaustive — missing variant(s): {}",
expr.as_str(),
missing.join(", ")
));
}
}
}
fn validate_path(path: &str, env: &TypeEnv<'_>, errors: &mut Vec<String>) {
if path.contains(crate::consts::PAREN_OPEN) {
return;
}
if crate::consts::strip_string_literal(path).is_some() {
return;
}
if path.bytes().next().is_some_and(|b| b.is_ascii_digit()) {
return;
}
let compiled = CompiledPath::compile(path);
validate_compiled_path(&compiled, env, errors);
}
fn validate_compiled_path(path: &CompiledPath, env: &TypeEnv<'_>, errors: &mut Vec<String>) {
let root = &path.parts()[0];
let Some(root_type) = env.lookup(root) else {
if env.is_opaque(root) {
return;
}
errors.push(format!("'{root}': undeclared variable"));
return;
};
let mut current_type = root_type;
let mut traversed = root.clone();
for field in &path.parts()[1..] {
traversed.push(crate::consts::PATH_SEP);
traversed.push_str(field);
if let Some(narrowed) = env.narrowed.get(&traversed) {
current_type = narrowed;
continue;
}
match resolve_field(current_type, field) {
FieldResult::Ok(ty) => {
current_type = ty;
}
FieldResult::NotAvailable { reason } => {
errors.push(format!("'{traversed}': {reason}"));
return; }
FieldResult::Terminal => {
return;
}
}
}
}
enum FieldResult<'a> {
Ok(&'a VarType),
NotAvailable { reason: String },
Terminal,
}
fn resolve_field<'a>(ty: &'a VarType, field: &str) -> FieldResult<'a> {
match ty {
VarType::Enum(variants) => resolve_enum_field(variants, field),
VarType::Struct(fields) => {
if fields.is_empty() {
FieldResult::Terminal
} else if let Some(d) = fields.iter().find(|d| d.name == field) {
FieldResult::Ok(&d.var_type)
} else {
let declared: Vec<&str> = fields.iter().map(|d| d.name.as_str()).collect();
FieldResult::NotAvailable {
reason: format!(
"field '{field}' does not exist on dict \
(declared fields: {})",
declared.join(", ")
),
}
}
}
VarType::List(fields) => {
if fields.is_empty() {
FieldResult::Terminal
} else {
let declared: Vec<&str> = fields.iter().map(|d| d.name.as_str()).collect();
FieldResult::NotAvailable {
reason: format!(
"cannot access field '{field}' on list — \
use {{% for %}} to iterate (element fields: {})",
declared.join(", ")
),
}
}
}
VarType::Str | VarType::Int | VarType::Float | VarType::Bool | VarType::Tmpl(_) => {
FieldResult::NotAvailable {
reason: format!("cannot access field '{field}' on {ty}"),
}
}
VarType::Option(_) => FieldResult::NotAvailable {
reason: format!(
"cannot access field '{field}' on {ty} — \
use {{% if has(...) %}} or {{% match %}} to unwrap first"
),
},
}
}
fn resolve_enum_field<'a>(variants: &'a [VariantDecl], field: &str) -> FieldResult<'a> {
if variants.is_empty() {
return FieldResult::Terminal;
}
let mut resolved_type: Option<&VarType> = None;
let mut missing_on: Vec<&str> = Vec::new();
for variant in variants {
match variant.fields.iter().find(|d| d.name == field) {
Some(decl) => {
resolved_type = Some(&decl.var_type);
}
None => {
missing_on.push(&variant.name);
}
}
}
if missing_on.is_empty() {
match resolved_type {
Some(ty) => FieldResult::Ok(ty),
None => FieldResult::Terminal, }
} else if missing_on.len() == variants.len() {
let variant_names: Vec<&str> = variants.iter().map(|v| v.name.as_str()).collect();
FieldResult::NotAvailable {
reason: format!(
"field '{field}' does not exist on any variant ({})",
variant_names.join(", ")
),
}
} else {
let hint = if variants.len() > 1 {
format!(
"field '{field}' is not available on variant(s) {} — \
use {{% match %}} to narrow the type first",
missing_on.join(", ")
)
} else {
format!(
"field '{field}' is not available on variant '{}'",
missing_on[0]
)
};
FieldResult::NotAvailable { reason: hint }
}
}
fn validate_condition(condition: &Condition, env: &TypeEnv<'_>, errors: &mut Vec<String>) {
match condition {
Condition::Truthy(operand) => validate_operand(operand, env, errors),
Condition::Comparison { left, right, .. } => {
let left_is_enum =
resolve_operand_type(left, env).is_some_and(|ty| matches!(ty, VarType::Enum(_)));
let right_is_enum =
resolve_operand_type(right, env).is_some_and(|ty| matches!(ty, VarType::Enum(_)));
if left_is_enum || right_is_enum {
let enum_side = if left_is_enum { left } else { right };
errors.push(format!(
"cannot compare enum '{}' with '==' — use {{% match %}} instead",
operand_to_str(enum_side)
));
return;
}
validate_operand(left, env, errors);
validate_operand(right, env, errors);
}
}
}
fn extract_has_narrowing(condition: &Condition, env: &TypeEnv<'_>) -> Option<(String, VarType)> {
let Condition::Truthy(ConditionOperand::Has(path)) = condition else {
return None;
};
let path_str = path.as_str();
let ty = resolve_compiled_path_type(path, env)?;
if !ty.is_option() {
return None;
}
match ty {
VarType::Option(inner) => Some((path_str.to_string(), (**inner).clone())),
VarType::Enum(variants) => {
let some_only: Vec<VariantDecl> = variants
.iter()
.filter(|v| v.name == crate::consts::OPTION_SOME)
.cloned()
.collect();
if some_only.is_empty() {
return None;
}
Some((path_str.to_string(), VarType::Enum(some_only)))
}
_ => None,
}
}
fn validate_include(
inc: &CompiledInclude,
env: &TypeEnv<'_>,
errors: &mut Vec<String>,
visited: &mut HashSet<String>,
) {
for (_, val_expr) in &inc.with_vars {
validate_path(val_expr, env, errors);
}
if let Some((_, list_expr)) = &inc.for_each {
validate_path(list_expr, env, errors);
}
let Some(compiled) = &inc.inline_compiled else {
return;
};
validate_include_contract(inc, &compiled.declarations, errors);
validate_include_type_match(inc, &compiled.declarations, env, errors);
let identity_key = format!(
"{}@{:p}",
inc.path,
alloc::sync::Arc::as_ptr(&compiled.segments)
);
if visited.insert(identity_key.clone()) {
let mut child_env = TypeEnv::from_declarations(&compiled.declarations);
child_env.opaque_roots.clone_from(&env.opaque_roots);
walk_segments(&compiled.segments, &mut child_env, errors, visited);
visited.remove(&identity_key);
}
}
pub(crate) fn find_missing_include_params<I>(
declarations: &[VarDecl],
provided_keys: I,
) -> Vec<&VarDecl>
where
I: Iterator<Item: AsRef<str>>,
{
let provided: Vec<String> = provided_keys.map(|k| k.as_ref().to_string()).collect();
declarations
.iter()
.filter(|d| d.default_value.is_none() && !provided.iter().any(|p| p == &d.name))
.collect()
}
fn validate_include_contract(
inc: &CompiledInclude,
included_declarations: &[VarDecl],
errors: &mut Vec<String>,
) {
let provided = inc
.with_vars
.iter()
.map(|(k, _)| k.as_ref().to_string())
.chain(inc.for_each.iter().map(|(b, _)| b.as_ref().to_string()));
let missing = find_missing_include_params(included_declarations, provided);
if !missing.is_empty() {
let (descs, hints): (Vec<_>, Vec<_>) = missing
.iter()
.map(|d| {
(
format!("{}: {}", d.name, d.var_type),
format!("{}={}", d.name, d.name),
)
})
.unzip();
errors.push(format!(
"include '{}': missing required param(s): {}. \
Use 'with {}' to pass them",
inc.path,
descs.join(", "),
hints.join(", "),
));
}
}
fn validate_include_type_match(
inc: &CompiledInclude,
included_declarations: &[VarDecl],
parent_env: &TypeEnv<'_>,
errors: &mut Vec<String>,
) {
for (key, val_expr) in &inc.with_vars {
let Some(included_decl) = included_declarations
.iter()
.find(|d| d.name == key.as_ref())
else {
continue; };
let val = val_expr.trim();
if crate::consts::strip_string_literal(val).is_some()
|| val.starts_with(crate::consts::ANGLE_OPEN)
|| val.bytes().next().is_some_and(|b| b.is_ascii_digit())
{
continue;
}
if let Some(parent_type) = resolve_path_type(val, parent_env) {
if !types_compatible(parent_type, &included_decl.var_type) {
errors.push(format!(
"include '{}': type mismatch for '{}': \
parent provides '{}' but included template expects '{}'",
inc.path, key, parent_type, included_decl.var_type,
));
}
}
}
}
fn types_compatible(provided: &VarType, expected: &VarType) -> bool {
match (provided, expected) {
(VarType::Str, VarType::Str)
| (VarType::Int, VarType::Int)
| (VarType::Float, VarType::Float)
| (VarType::Bool, VarType::Bool) => true,
(VarType::List(a), VarType::List(b)) | (VarType::Struct(a), VarType::Struct(b)) => {
a.is_empty() || b.is_empty() || a == b
}
(VarType::Enum(a), VarType::Enum(b)) => a == b,
(VarType::Tmpl(a), VarType::Tmpl(b)) => a == b,
_ => false,
}
}
fn resolve_path_type<'a>(path: &str, env: &'a TypeEnv<'_>) -> Option<&'a VarType> {
let compiled = CompiledPath::compile(path);
resolve_compiled_path_type(&compiled, env)
}
fn resolve_compiled_path_type<'a>(
path: &CompiledPath,
env: &'a TypeEnv<'_>,
) -> Option<&'a VarType> {
let root = &path.parts()[0];
let mut current = env.lookup(root)?;
let mut traversed = root.clone();
for field in &path.parts()[1..] {
traversed.push(crate::consts::PATH_SEP);
traversed.push_str(field);
if let Some(narrowed) = env.narrowed.get(&traversed) {
current = narrowed;
continue;
}
match resolve_field(current, field) {
FieldResult::Ok(ty) => current = ty,
_ => return None,
}
}
Some(current)
}
fn resolve_operand_type<'a>(
operand: &ConditionOperand,
env: &'a TypeEnv<'_>,
) -> Option<&'a VarType> {
match operand {
ConditionOperand::Literal(_) => None,
ConditionOperand::Path { path, .. }
| ConditionOperand::Len(path)
| ConditionOperand::Kind(path)
| ConditionOperand::Has(path) => resolve_compiled_path_type(path, env),
ConditionOperand::Idx(_) => Some(&VarType::Int),
}
}
fn operand_to_str(operand: &ConditionOperand) -> &str {
match operand {
ConditionOperand::Literal(_) => "literal",
ConditionOperand::Path { path, .. }
| ConditionOperand::Len(path)
| ConditionOperand::Kind(path)
| ConditionOperand::Has(path) => path.as_str(),
ConditionOperand::Idx(binding) => binding.as_ref(),
}
}
fn validate_operand(operand: &ConditionOperand, env: &TypeEnv<'_>, errors: &mut Vec<String>) {
match operand {
ConditionOperand::Literal(_) | ConditionOperand::Idx(_) => {}
ConditionOperand::Path { path, .. }
| ConditionOperand::Len(path)
| ConditionOperand::Kind(path)
| ConditionOperand::Has(path) => {
validate_compiled_path(path, env, errors);
}
}
}
#[cfg(all(test, feature = "std"))]
#[path = "type_check_tests.rs"]
mod type_check_tests;