use super::cascade::NodeFacts;
use super::ir::{
Along, AttrMap, LinkKind, MarkerKind, Markers, MeasureOp, ResolvedEndpoint, ResolvedLink,
ResolvedText, ResolvedValue, Strategy,
};
use super::merge::{collapse, resolve_markers};
use super::scene::{PathIndex, SceneCtx};
use super::value::{resolve_groups, resolve_property};
use crate::ast::{ChainOp, DrawOp, LineStyle, LinkMarker, Side};
use crate::error::Error;
use crate::syntax::ast::{Endpoint, EndpointGroup, Link};
pub const LINK_CLASS: &str = "lini-link";
pub const DIMENSION_CLASS: &str = "lini-dimension";
pub struct LinkScope {
pub drawing: bool,
pub flow_in_drawing: Option<String>,
}
pub fn resolve_link(
w: &Link,
ctx: &SceneCtx,
paths: &PathIndex,
path_prefix: &[String],
scope_ancestors: &[NodeFacts],
base: &[(String, ResolvedValue)],
scope_kind: &LinkScope,
) -> Result<Vec<ResolvedLink>, Error> {
for class in &w.classes {
if !ctx.sheet.defines_class(class) {
return Err(Error::at(w.span, format!("unknown class '.{}'", class)));
}
}
let drawing_scope = scope_kind.drawing;
validate_statement(w, scope_kind)?;
let kind = match w.op {
ChainOp::Wire(_) => LinkKind::Wire,
ChainOp::Measure(DrawOp::Linear) => LinkKind::Measure(MeasureOp::Linear),
ChainOp::Measure(DrawOp::Round) => LinkKind::Measure(MeasureOp::Round),
ChainOp::Measure(DrawOp::Angle) => LinkKind::Measure(MeasureOp::Angle),
ChainOp::Mate => LinkKind::Mate,
};
let is_dim = matches!(kind, LinkKind::Measure(_));
let link_facts = NodeFacts {
classes: std::iter::once(LINK_CLASS.to_string())
.chain(is_dim.then(|| DIMENSION_CLASS.to_string()))
.chain(w.classes.iter().cloned())
.collect(),
id: None,
};
let mut ordered: Vec<(String, ResolvedValue)> = base.to_vec();
ordered.extend(ctx.sheet.class_decls(LINK_CLASS));
if is_dim {
ordered.extend(ctx.sheet.class_decls(DIMENSION_CLASS));
}
ordered.extend(ctx.sheet.node_layers(scope_ancestors, &link_facts));
for d in &w.style {
ordered.push((
d.name.clone(),
resolve_property(&d.name, &d.groups, d.span, ctx.vars, ctx.funcs)?,
));
}
let markers = match w.op.wire() {
Some(op) => resolve_markers(
&ordered,
MarkerKind::from_marker(op.start),
MarkerKind::from_marker(op.end),
w.span,
)?,
None => Markers::default(),
};
let mut attrs = collapse(&ordered);
if let Some(op) = w.op.wire() {
inject_line_style(&mut attrs, op.line);
}
if !drawing_scope && attrs.get("tol").is_some() {
return Err(Error::at(
w.span,
"'tol' composes a dimension's text — it belongs in a 'layout: drawing'",
));
}
if matches!(attrs.get("stroke-style"), Some(ResolvedValue::Ident(s)) if s == "center" || s == "phantom")
{
return Err(Error::at(
w.span,
"a link's stroke-style is solid, dashed, dotted, or wavy",
));
}
let routing = parse_routing(&attrs, w.span)?;
attrs.map.remove("routing");
let along: Vec<f64> = attrs
.get("along")
.map(collect_fractions)
.unwrap_or_default();
attrs.map.remove("along");
let mut texts: Vec<ResolvedText> = Vec::new();
for (i, label) in w.labels.iter().enumerate() {
let pos = along.get(i).copied().map_or(Along::Auto, Along::Fraction);
let mut lattrs = link_text_attrs(&attrs);
for d in &label.style {
if !super::scene::is_text_prop(&d.name) {
return Err(Error::at(
d.span,
format!("'{}' needs a box — a link label is text", d.name),
));
}
lattrs.insert(
d.name.as_str(),
resolve_groups(&d.groups, d.span, ctx.vars, ctx.funcs)?,
);
}
texts.push(ResolvedText {
text: label.text.clone(),
along: pos,
attrs: lattrs,
});
}
let mut out = Vec::new();
for (fan_index, chain) in expand_chain(&w.chain).into_iter().enumerate() {
let mut endpoints = Vec::with_capacity(chain.len());
for ep in chain {
let qualified: Vec<String> = if path_prefix.is_empty() {
ep.path.clone()
} else {
let mut p = path_prefix.to_vec();
p.extend(ep.path.iter().cloned());
p
};
let path = paths
.resolve(&qualified)
.ok_or_else(|| endpoint_error(&ep, paths, path_prefix, w.op, drawing_scope))?;
let (side, point) = resolve_point(&ep, drawing_scope)?;
endpoints.push(ResolvedEndpoint {
path,
side,
point,
span: ep.span,
});
}
out.push(ResolvedLink {
endpoints,
kind,
scope: path_prefix.join("."),
line: w.op.wire().map_or(LineStyle::Solid, |op| op.line),
routing,
attrs: attrs.clone(),
applied_styles: w.classes.clone(),
markers: markers.clone(),
texts: if fan_index == 0 {
texts.clone()
} else {
Vec::new()
},
span: w.span,
});
}
Ok(out)
}
fn validate_statement(w: &Link, scope: &LinkScope) -> Result<(), Error> {
let drawing = scope.drawing;
if !drawing {
match w.op {
ChainOp::Measure(d) => {
return Err(Error::at(
w.span,
format!(
"'{}' draws a dimension — it belongs in a 'layout: drawing'",
d.as_str()
),
));
}
ChainOp::Mate => {
if let Some(ty) = &scope.flow_in_drawing {
return Err(Error::at(
w.span,
format!("a '|{ty}|' places its own children — mates seat a drawing's"),
));
}
return Err(Error::at(
w.span,
"a mate seats a drawing's parts — '||' belongs in a 'layout: drawing'",
));
}
ChainOp::Wire(_) => {}
}
}
let labelled = w.label.is_some() || !w.labels.is_empty();
if matches!(w.op, ChainOp::Mate) && labelled {
return Err(Error::at(w.span, "a mate takes no label"));
}
if matches!(w.op, ChainOp::Measure(crate::ast::DrawOp::Round)) && w.chain.len() > 1 {
return Err(Error::at(
w.span,
"'(o)' measures one round feature — write 'a:top (o)' for a span",
));
}
if w.chain.len() > 1 {
return Ok(());
}
match w.op {
ChainOp::Measure(crate::ast::DrawOp::Linear) => {
Err(Error::at(w.span, "a linear dimension measures two anchors"))
}
ChainOp::Measure(_) => Ok(()),
ChainOp::Mate => Err(Error::at(w.span, "a mate seats two parts")),
ChainOp::Wire(op) => {
if !drawing {
return Err(Error::at(w.span, "link requires at least two endpoints"));
}
let leader_tip = matches!(
op.start,
LinkMarker::Arrow | LinkMarker::Dot | LinkMarker::Crow
) && op.end == LinkMarker::None;
if leader_tip {
if !labelled {
return Err(Error::at(
w.span,
"a leader needs its text — 'bolt <- \"THRU\"'",
));
}
return Ok(());
}
if op.start == LinkMarker::None && op.end != LinkMarker::None {
return Err(Error::at(
w.span,
"a leader points back at its feature — write 'a <- \"…\"'",
));
}
Err(Error::at(w.span, "link requires at least two endpoints"))
}
}
}
fn resolve_point(ep: &Endpoint, drawing: bool) -> Result<(Option<Side>, Option<String>), Error> {
let Some(p) = &ep.point else {
return Ok((None, None));
};
if let Some(side) = Side::parse(&p.name) {
return Ok((Some(side), None));
}
if let Some(fix) = corner_reorder(&p.name) {
return Err(Error::at(
p.span,
format!("':{}' is not an anchor — did you mean ':{}'?", p.name, fix),
));
}
if drawing {
return Ok((None, Some(p.name.clone())));
}
if matches!(
p.name.as_str(),
"center" | "top-left" | "top-right" | "bottom-left" | "bottom-right"
) {
Err(Error::at(
p.span,
format!(
"':{}' is a drawing anchor — it belongs in a 'layout: drawing'",
p.name
),
))
} else {
Err(Error::at(
p.span,
format!(
"':{}' is not a side — use top, bottom, left, or right",
p.name
),
))
}
}
fn corner_reorder(name: &str) -> Option<String> {
let (a, b) = name.split_once('-')?;
(matches!(a, "left" | "right") && matches!(b, "top" | "bottom")).then(|| format!("{b}-{a}"))
}
fn inject_line_style(attrs: &mut AttrMap, line: LineStyle) {
let style = match line {
LineStyle::Solid => return,
LineStyle::Dashed => "dashed",
LineStyle::Dotted => "dotted",
LineStyle::Wavy => "wavy",
};
if attrs.get("stroke-style").is_none() {
attrs.insert("stroke-style", ResolvedValue::Ident(style.into()));
}
}
fn parse_routing(attrs: &AttrMap, span: crate::span::Span) -> Result<Strategy, Error> {
match attrs.get("routing") {
None => Ok(Strategy::Orthogonal),
Some(ResolvedValue::Ident(r)) if r == "orthogonal" => Ok(Strategy::Orthogonal),
Some(ResolvedValue::Ident(r)) if r == "straight" => Ok(Strategy::Straight),
Some(_) => Err(Error::at(
span,
"routing: 'orthogonal' and 'straight' are built; 'curved' is deferred (SPEC 23)",
)),
}
}
fn collect_fractions(v: &ResolvedValue) -> Vec<f64> {
match v {
ResolvedValue::Number(n) => vec![*n],
ResolvedValue::Tuple(xs) | ResolvedValue::List(xs) => {
xs.iter().filter_map(ResolvedValue::as_number).collect()
}
_ => Vec::new(),
}
}
fn link_text_attrs(link_attrs: &AttrMap) -> AttrMap {
let mut map = AttrMap::new();
for name in super::scene::INHERITED_TEXT {
if let Some(v) = link_attrs.get(name) {
map.insert(*name, v.clone());
}
}
map
}
fn expand_chain(chain: &[EndpointGroup]) -> Vec<Vec<Endpoint>> {
let mut acc: Vec<Vec<Endpoint>> = vec![Vec::new()];
for group in chain {
let mut next = Vec::with_capacity(acc.len() * group.endpoints.len());
for trail in &acc {
for ep in &group.endpoints {
let mut t = trail.clone();
t.push(ep.clone());
next.push(t);
}
}
acc = next;
}
acc
}
fn endpoint_error(
ep: &Endpoint,
paths: &PathIndex,
scope: &[String],
op: ChainOp,
drawing: bool,
) -> Error {
let where_ = if scope.is_empty() {
"at scene root".to_string()
} else {
format!("in '{}'", scope.join("."))
};
let noun = match (op, drawing) {
(_, false) => "link",
(ChainOp::Mate, true) => "mate",
(_, true) => "dimension",
};
let mut msg = format!(
"{noun} endpoint '{}' not found {}",
ep.path.join("."),
where_
);
let suggestions = paths.suggest(ep.path.last().expect("non-empty path"), scope);
if !suggestions.is_empty() {
let quoted: Vec<String> = suggestions.iter().map(|s| format!("'{}'", s)).collect();
msg.push_str(&format!("; did you mean {}?", quoted.join(", ")));
}
Error::at(ep.span, msg)
}