use super::Checker;
use super::scope::Scope;
use super::universal::Universal;
use crate::breakpoint::BREAKPOINTS;
use crate::cel::Type;
use crate::diagnostic::{Code, Fix, Location, Range};
use crate::md::Signatures;
use crate::nearest::nearest;
use crate::reserved::{self, Keyword};
use crate::resolve::{Binding, Body, Component};
use crate::tree::{
Container, Declaration, DeclaredType, Node, Param, ParamValue, Point, Position, Signature,
SlotDeclaration, StringPart, TypeName,
};
use crate::values::Kind;
use std::collections::{HashMap, HashSet};
fn literal(value: &ParamValue) -> Option<String> {
match value {
ParamValue::Bare(text) => Some(text.clone()),
ParamValue::String(parts) => parts
.iter()
.map(|part| match part {
StringPart::Text(text) => Some(text.value.as_str()),
StringPart::Interpolation(_) => None,
})
.collect(),
_ => None,
}
}
pub(crate) fn binds(param: &DeclaredType, input: &DeclaredType) -> bool {
match (param, input) {
(DeclaredType::Name(TypeName::Enum), DeclaredType::Enum { .. }) => true,
(DeclaredType::Enum { members: wanted }, DeclaredType::Enum { members }) => {
wanted == members
}
(DeclaredType::Name(wanted), DeclaredType::Name(name)) => wanted == name,
(DeclaredType::Other(wanted), DeclaredType::Other(name)) => wanted == name,
_ => false,
}
}
pub(crate) fn value_at(param: &Param) -> Location {
let start = param
.position
.map(|position| position.start)
.unwrap_or_default();
let key = param.key.as_deref().unwrap_or("");
let prefix = match param.value {
ParamValue::Flag | ParamValue::Class(_) | ParamValue::Id(_) => 0,
_ => {
param
.breakpoint
.as_ref()
.map_or(0, |breakpoint| breakpoint.chars().count() + 1)
+ key.chars().count()
+ 1
}
};
Location::new(start.line, start.column + prefix)
}
fn param_at(param: &Param) -> Location {
param
.position
.map_or(Location::new(1, 1), |position| position.start.into())
}
fn key_at(param: &Param) -> Location {
let start = param_at(param);
let prefix = param
.breakpoint
.as_ref()
.map_or(0, |breakpoint| breakpoint.chars().count() + 1);
Location::new(start.line, start.column + prefix)
}
struct Target<'r> {
component: &'r Component,
signature: &'r Signature,
contents: bool,
}
impl Target<'_> {
fn is(&self, keyword: Keyword) -> bool {
self.component.keyword() == Some(keyword)
}
}
#[derive(Debug)]
struct Template {
slot: String,
bound: String,
element: Type,
}
#[derive(Debug, Default)]
pub(super) struct Invoked {
templates: Vec<Template>,
}
impl Invoked {
pub(super) fn scope<'r>(&self, name: &str, outer: &Scope<'r>) -> Scope<'r> {
self.templates
.iter()
.find(|template| template.slot == name)
.map_or_else(
|| outer.clone(),
|template| outer.template(&template.bound, template.element.clone()),
)
}
}
enum Verdict {
Declared(Option<Type>),
Universal,
Refused,
}
impl<'r> Checker<'r, '_> {
pub(super) fn invocation(
&mut self,
name: &str,
params: &'r [Param],
position: Option<Position>,
container: Option<&'r Container>,
scope: &Scope<'r>,
) -> Invoked {
let start: Point = position.map(|position| position.start).unwrap_or_default();
let resolved = self.resolved;
let Some(Binding::Component(id, _)) = resolved.document(self.id).names.get(name) else {
self.loose(params, scope);
return Invoked::default();
};
let component = resolved.component(*id);
if !component.body.supported() {
self.report(
Code::W102,
start.into(),
format!("this processor has no binding for `{name}`'s language or runtime, so it renders the fallback"),
);
self.fall_back(position);
}
let Some(signature) = &component.signature else {
self.untyped(params, scope);
return Invoked::default();
};
let target = Target {
component,
signature,
contents: component.is_contents(),
};
let mut given = HashSet::new();
let mut refused = HashSet::new();
let mut types = HashMap::new();
for param in params {
let key = param.key.as_deref().unwrap_or("");
match self.param(&target, param, scope) {
Verdict::Declared(ty) => {
given.insert(key);
types.extend(ty.map(|ty| (key, ty)));
}
Verdict::Refused => {
refused.insert(key);
self.refuse_param(param);
}
Verdict::Universal => {}
}
}
let required = signature
.params
.iter()
.filter(|param| !param.optional && param.default.is_none());
let mut falls_back = false;
for declared in required.filter(|param| !given.contains(param.name.as_str())) {
falls_back = true;
if !refused.contains(declared.name.as_str()) {
self.report(
Code::F010,
start.into(),
format!(
"`{name}` needs `{}`; it renders its fallback",
declared.name
),
);
}
}
if falls_back {
self.fall_back(position);
}
if let Some(container) = container {
self.slots(name, container, signature, start);
if target.is(Keyword::Each)
&& !given.contains("join")
&& !refused.contains("join")
&& is_single_list_item(&container.children)
{
self.report(
Code::W103,
start.into(),
"the default slot is one list item, so each item becomes a list of its own; `join=list` makes one list",
);
}
}
Invoked {
templates: templates(signature, params, &types),
}
}
fn untyped(&mut self, params: &'r [Param], scope: &Scope<'r>) {
for param in params {
match param.key.as_deref().and_then(Universal::from_name) {
Some(universal) => {
if !self.universal(universal, param, scope) {
self.refuse_param(param);
}
}
None => self.loose(std::slice::from_ref(param), scope),
}
}
}
fn loose(&mut self, params: &'r [Param], scope: &Scope<'r>) {
for param in params {
let at = value_at(param);
match ¶m.value {
ParamValue::Expr(source) => {
self.expression(source, at, scope, None, "");
}
ParamValue::String(parts) => self.string_paths(parts, at, scope),
_ => {}
}
}
}
fn param(&mut self, target: &Target<'r>, param: &'r Param, scope: &Scope<'r>) -> Verdict {
if matches!(param.value, ParamValue::Class(_) | ParamValue::Id(_)) {
if target.contents {
self.no_box(param);
return Verdict::Refused;
}
return Verdict::Universal;
}
let key = param.key.as_deref().unwrap_or("");
if let Some(breakpoint) = param
.breakpoint
.as_deref()
.filter(|b| !BREAKPOINTS.contains(b))
{
self.report(
Code::F008,
param_at(param),
format!("`{breakpoint}` isn't a breakpoint; they are `sm`, `md` and `lg`. The param is ignored"),
);
return Verdict::Refused;
}
if let Some(declared) = target
.signature
.params
.iter()
.find(|declared| declared.name == key)
{
if param.breakpoint.is_some() && !target.component.takes_breakpoints() {
self.no_breakpoint(param, key);
return Verdict::Refused;
}
return self.declared(target, declared, param, scope);
}
match Universal::from_name(key) {
Some(universal) if target.contents && universal.needs_box() => self.no_box(param),
Some(universal) if self.universal(universal, param, scope) => {
return Verdict::Universal;
}
Some(_) => {}
None => {
let known = target
.signature
.params
.iter()
.map(|declared| declared.name.as_str())
.chain(Universal::NAMES.iter().copied());
let near = nearest(key, known);
let hint =
near.map_or_else(String::new, |near| format!("; did you mean `{near}`?"));
let fix = near.map(|near| Fix::replacing(key_at(param), key, near));
let name = &target.component.name;
self.report_fixed(
Code::F008,
param_at(param),
format!("`{name}` has no param `{key}`, so it is ignored{hint}"),
fix,
);
}
}
Verdict::Refused
}
fn no_box(&mut self, param: &Param) {
self.report(
Code::W120,
param_at(param),
"the component is `display=contents`, so it has no box for this param; ignored",
);
}
fn no_breakpoint(&mut self, param: &Param, key: &str) {
self.report(
Code::F008,
param_at(param),
format!("`{key}` takes no breakpoint; the param is ignored"),
);
}
fn declared(
&mut self,
target: &Target<'r>,
declared: &Declaration,
param: &'r Param,
scope: &Scope<'r>,
) -> Verdict {
let at = value_at(param);
let key = declared.name.as_str();
if target.is(Keyword::Box) && param.is_display_contents() {
self.report(
Code::F009,
at,
"`display=contents` is only for a definition fence, which has the box it removes",
);
return Verdict::Refused;
}
match (¶m.value, &declared.declared) {
(ParamValue::Expr(_), DeclaredType::Name(TypeName::Name)) => self.refuse(
at,
&format!("`{key}` binds a name, written as is, not an expression"),
),
(ParamValue::Expr(_), DeclaredType::Input { .. }) => self.refuse(
at,
&format!("`{key}` binds an input by its name, not an expression"),
),
(ParamValue::Expr(source), declared_type) => {
match self.expression(source, at, scope, Some(&Type::declared(declared_type)), key)
{
Some(ty) => Verdict::Declared(Some(ty)),
None => Verdict::Refused,
}
}
(value, DeclaredType::Input { input }) => {
self.bind(target, key, value, input, at, scope)
}
(value, declared_type) => {
if let ParamValue::String(parts) = value {
self.string_paths(parts, at, scope);
}
let kind = Kind::declared(declared_type);
if !kind.accepts(value) {
return self.refused(param, key, &kind);
}
if let (ParamValue::Bare(bound), DeclaredType::Name(TypeName::Name)) =
(value, declared_type)
&& let Some(why) = reserved::as_binding(bound)
{
self.report(Code::F013, at, why);
}
Verdict::Declared(None)
}
}
}
fn refuse(&mut self, at: Location, message: &str) -> Verdict {
self.report(
Code::F009,
at,
format!("{message}; the param is treated as absent"),
);
Verdict::Refused
}
fn refused(&mut self, param: &Param, key: &str, kind: &Kind) -> Verdict {
let at = value_at(param);
let written = literal(¶m.value);
let near = written.as_deref().and_then(|written| kind.suggest(written));
let hint = near.map_or_else(String::new, |near| format!("; did you mean `{near}`?"));
let fix = near.zip(param.position).map(|(near, position)| Fix {
range: Range {
start: at,
end: Some(position.end.into()),
},
text: near.to_string(),
});
self.report_fixed(
Code::F009,
at,
format!(
"`{key}` takes {}, so the param is treated as absent{hint}",
kind.describe()
),
fix,
);
Verdict::Refused
}
fn bind(
&mut self,
target: &Target,
key: &str,
value: &ParamValue,
input_type: &DeclaredType,
at: Location,
scope: &Scope,
) -> Verdict {
let ParamValue::Bare(name) = value else {
return self.refuse(at, &format!("`{key}` binds an input by its name"));
};
let Some(input) = scope.input(name) else {
self.report(
Code::F030,
at,
format!("no input `{name}` is declared under `md.inputs` of this document"),
);
return Verdict::Refused;
};
if !binds(input_type, &input.declaration.declared) {
self.report(
Code::F030,
at,
format!(
"`{key}` binds {}, but `{name}` is {}",
input_type.written(),
input.declaration.declared.written()
),
);
return Verdict::Refused;
}
if target.component.body.is_host() {
self.found.bound_by_hosts.push(name.clone());
}
Verdict::Declared(None)
}
fn universal(&mut self, universal: Universal, param: &'r Param, scope: &Scope<'r>) -> bool {
let key = universal.name();
if param.breakpoint.is_some() && !universal.takes_breakpoint() {
self.no_breakpoint(param, key);
return false;
}
let at = value_at(param);
let value = ¶m.value;
if let ParamValue::Expr(source) = value {
return self
.expression(source, at, scope, Some(&Type::String), key)
.is_some();
}
if let ParamValue::String(parts) = value {
self.string_paths(parts, at, scope);
}
let kind = universal.kind();
let fits = kind.accepts(value);
if !fits {
self.refused(param, key, &kind);
}
fits
}
pub(super) fn expression(
&mut self,
source: &str,
at: Location,
scope: &Scope,
expected: Option<&Type>,
key: &str,
) -> Option<Type> {
let inside = Location::new(at.line, at.column + 1);
let checked = self
.parse(source)
.and_then(|program| program.check(&scope.cel(), &Signatures));
match checked {
Err(error) => {
let at = Location::new(inside.line, inside.column + error.at());
self.report(Code::F024, at, error.message());
None
}
Ok(ty) => {
let fits = expected.is_none_or(|expected| {
ty.fits(expected) || (*expected == Type::Double && ty.is_numeric())
});
if fits {
return Some(ty);
}
let expected = expected.map(ToString::to_string).unwrap_or_default();
self.report(
Code::F024,
inside,
format!("`{key}` takes {expected}, but the expression gives {ty}"),
);
None
}
}
}
fn string_paths(&mut self, parts: &[StringPart], at: Location, scope: &Scope) {
let paths: Vec<&str> = parts
.iter()
.filter_map(|part| match part {
StringPart::Interpolation(interpolation) => Some(interpolation.path.as_str()),
StringPart::Text(_) => None,
})
.collect();
if paths.is_empty() {
return;
}
let start = Point {
line: at.line,
column: at.column,
offset: 0,
};
for (path, at) in paths.into_iter().zip(self.lines.paths_from(start)) {
self.path(path, at, scope);
}
}
fn slots(&mut self, name: &str, container: &Container, signature: &Signature, start: Point) {
let Some(slots) = &signature.slots else {
if !container.children.is_empty() || !container.slots.is_empty() {
self.report(
Code::F018,
start.into(),
format!("`{name}` declares no slots, so it takes no body; the body follows it as normal content"),
);
}
return;
};
let declared: Vec<&str> = slots
.iter()
.map(|slot| match slot {
SlotDeclaration::Name(slot) | SlotDeclaration::Template { name: slot, .. } => {
slot.as_str()
}
})
.collect();
for slot in container
.slots
.iter()
.filter(|slot| !declared.contains(&slot.name.as_str()))
{
let near = nearest(&slot.name, declared.iter().copied());
let hint = near.map_or_else(String::new, |near| format!("; did you mean `{near}`?"));
let at = slot
.position
.map_or(start.into(), |position| position.start.into());
let fix = near.zip(slot.position).map(|(near, position)| {
let hash = self.lines.name_at(position.start);
Fix::replacing(Location::new(hash.line, hash.column + 1), &slot.name, near)
});
self.report_fixed(
Code::F006,
at,
format!(
"`{name}` has no slot `{}`, so its content joins the default slot{hint}",
slot.name
),
fix,
);
}
}
pub(super) fn fence(&mut self, component: &'r Component, scope: &Scope<'r>) {
let compmd = matches!(component.body, Body::Compmd(_));
let contents = component.is_contents();
let resolved = self.resolved;
let layout = match resolved.document(self.id).names.get("box") {
Some(Binding::Component(id, _)) if compmd => {
let layout = resolved.component(*id);
layout.signature.as_ref().map(|signature| Target {
component: layout,
signature,
contents: false,
})
}
_ => None,
};
for param in &component.fence {
if !self.fence_param(param, compmd, contents, layout.as_ref(), scope) {
self.refuse_param(param);
}
}
}
fn fence_param(
&mut self,
param: &'r Param,
compmd: bool,
contents: bool,
layout: Option<&Target<'r>>,
scope: &Scope<'r>,
) -> bool {
if param.is_display_contents() {
if param.breakpoint.is_some() {
self.report(
Code::F009,
value_at(param),
"`display=contents` is written bare, with no breakpoint: a component has a box or not",
);
return false;
}
if compmd {
return true;
}
}
if contents {
self.no_box(param);
return false;
}
if matches!(param.value, ParamValue::Class(_) | ParamValue::Id(_)) {
return true;
}
let key = param.key.as_deref().unwrap_or("");
if let Some(universal) = Universal::from_name(key) {
return self.universal(universal, param, scope);
}
let own = layout.and_then(|layout| {
let declared = layout
.signature
.params
.iter()
.find(|declared| declared.name == key)?;
Some((layout, declared))
});
if let Some((layout, declared)) = own {
return matches!(
self.declared(layout, declared, param, scope),
Verdict::Declared(_)
);
}
let takes = if compmd {
"the common params, `class`, `id`, `lang`, `dir` and `box`'s params"
} else {
"the common params, `class`, `id`, `lang` and `dir`"
};
self.report(
Code::F008,
param_at(param),
format!("this fence takes {takes}, not `{key}`; ignored"),
);
false
}
}
fn is_single_list_item(children: &[Node]) -> bool {
matches!(children, [Node::List(list)] if list.children.len() == 1)
}
fn templates(
signature: &Signature,
params: &[Param],
types: &HashMap<&str, Type>,
) -> Vec<Template> {
let slots = signature.slots.as_deref().unwrap_or_default();
slots
.iter()
.filter_map(|slot| {
let SlotDeclaration::Template {
name,
each,
binding,
} = slot
else {
return None;
};
let bound = params.iter().find_map(|param| match ¶m.value {
ParamValue::Bare(bound) if param.key.as_deref() == Some(binding.as_str()) => {
Some(bound.clone())
}
_ => None,
})?;
let element = match types.get(each.as_str()) {
Some(Type::List(element)) => (**element).clone(),
_ => Type::Dyn,
};
Some(Template {
slot: name.clone(),
bound,
element,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn bare(text: &str) -> ParamValue {
ParamValue::Bare(text.into())
}
fn accepts(name: TypeName, value: &ParamValue) -> bool {
name.kind().accepts(value)
}
fn fits(declared: &DeclaredType, value: &ParamValue) -> bool {
Kind::declared(declared).accepts(value)
}
#[test]
fn kinds_accept_what_their_type_reads() {
assert!(accepts(TypeName::Number, &bare("3")));
assert!(!accepts(TypeName::Number, &bare("three")));
assert!(!accepts(TypeName::Number, &bare("inf")));
assert!(accepts(TypeName::Boolean, &ParamValue::Flag));
assert!(accepts(TypeName::Boolean, &bare("false")));
assert!(accepts(TypeName::Date, &bare("2026-10-21")));
assert!(!accepts(TypeName::Date, &bare("2026-13-01")));
assert!(accepts(TypeName::Array, &ParamValue::Json("[1]".into())));
assert!(!accepts(TypeName::Object, &ParamValue::Json("[1]".into())));
assert!(accepts(TypeName::Name, &bare("t")));
assert!(!accepts(TypeName::Name, &bare("a-b")));
assert!(accepts(TypeName::Space, &bare("m")));
assert!(!accepts(TypeName::Array, &bare("x")));
assert!(accepts(TypeName::Enum, &ParamValue::Flag));
for (name, good, bad) in [
(TypeName::Length, "2rem", "wide"),
(TypeName::Color, "#fff", "loud"),
(TypeName::Tracks, "1fr 2fr", "wide"),
(TypeName::Areas, "'a b' 'c d'", "a b"),
(TypeName::Surface, "subtle", "loud"),
(TypeName::Border, "1px solid", "thick-dotted"),
(TypeName::Radius, "full", "round"),
] {
assert!(
accepts(name, &bare(good)) && !accepts(name, &bare(bad)),
"{name:?}"
);
}
assert!(!accepts(TypeName::String, &ParamValue::Flag));
assert!(accepts(
TypeName::Number,
&ParamValue::String(vec![StringPart::Interpolation(
crate::tree::Interpolation::default()
)])
));
}
#[test]
fn inputs_bind_by_type() {
let name = DeclaredType::named;
let members = |m: &[&str]| DeclaredType::Enum {
members: m.iter().map(|s| (*s).to_string()).collect(),
};
assert!(binds(&name("enum"), &members(&["a", "b"])));
assert!(binds(&members(&["a"]), &members(&["a"])));
assert!(!binds(&members(&["a"]), &members(&["b"])));
assert!(binds(&name("date"), &name("date")));
assert!(!binds(&name("date"), &members(&["a"])));
assert!(!binds(&members(&["a"]), &name("string")));
assert!(binds(&name("odd"), &name("odd")));
assert_eq!(name("odd").written(), "a odd");
let input = DeclaredType::Input {
input: Box::new(name("date")),
};
assert!(fits(&input, &ParamValue::Flag));
assert!(!fits(&members(&["a"]), &ParamValue::Flag));
assert!(fits(&name("unknown-type"), &ParamValue::Flag));
assert_eq!(input.written(), "an input of a date");
}
}