pub use crate::grammar::diag::Diagnostic;
use crate::grammar::{ast::Ast, diag::Severity, diag::Span, diag::codes, tables::ParserTables};
use crate::state::{DeviceState, LabelValueState, ResolvedLabelState, Units, convert_to_dots};
use serde::Serialize;
use std::collections::{BTreeMap, HashMap, HashSet};
use zpl_toolchain_diagnostics::policy::{
OBJECT_BOUNDS_LOW_CONFIDENCE_MAX_OVERFLOW_DOTS,
OBJECT_BOUNDS_LOW_CONFIDENCE_MAX_OVERFLOW_RATIO, OBJECT_BOUNDS_LOW_CONFIDENCE_SEVERITY,
};
use zpl_toolchain_profile::Profile;
use zpl_toolchain_spec_tables::{
CommandScope, ComparisonOp, ConstraintKind, ConstraintScope, NoteAudience, Plane, RoundingMode,
};
mod diagnostics_util;
mod predicates;
mod profile_constraints;
use self::diagnostics_util::{
diagnostic_with_constraint_severity, diagnostic_with_spec_severity, map_sev,
render_diagnostic_message, trim_f64,
};
use self::predicates::{
any_target_in_set, enum_contains, evaluate_note_when_expression, predicate_matches,
};
#[cfg(test)]
pub(crate) use self::predicates::{firmware_version_gte, profile_predicate_matches};
use self::profile_constraints::check_profile_op;
pub use self::profile_constraints::resolve_profile_field;
macro_rules! ctx {
($($k:expr => $v:expr),+ $(,)?) => {
BTreeMap::from([$(($k.into(), $v.into())),+])
};
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
pub ok: bool,
pub issues: Vec<Diagnostic>,
pub resolved_labels: Vec<ResolvedLabelState>,
}
fn select_effective_arg<'a>(
u: &'a zpl_toolchain_spec_tables::ArgUnion,
slot: Option<&crate::grammar::ast::ArgSlot>,
) -> Option<&'a zpl_toolchain_spec_tables::Arg> {
match u {
zpl_toolchain_spec_tables::ArgUnion::Single(a) => Some(a),
zpl_toolchain_spec_tables::ArgUnion::OneOf { one_of } => {
if let Some(s) = slot
&& let Some(v) = s.value.as_ref()
{
if let Some(arg) = one_of.iter().find(|a| {
a.r#type == "enum" && a.r#enum.as_ref().is_some_and(|ev| enum_contains(ev, v))
}) {
return Some(arg);
}
if let Some(arg) = one_of.iter().find(|a| {
(a.r#type == "int" || a.r#type == "float") && v.parse::<f64>().is_ok()
}) {
return Some(arg);
}
}
one_of.first()
}
}
}
#[derive(Debug, Default)]
struct LabelState {
producers_seen: HashSet<String>,
field_numbers: HashMap<String, usize>, loaded_fonts: HashSet<char>,
last_producer_idx: HashMap<String, usize>,
producer_consumed: HashMap<String, bool>,
effective_width: Option<f64>,
effective_height: Option<f64>,
has_explicit_pw: bool,
has_explicit_ll: bool,
last_fo_x: Option<f64>,
last_fo_y: Option<f64>,
gf_total_bytes: u32,
value_state: LabelValueState,
}
impl LabelState {
fn record_producer(&mut self, code: &str, node_idx: usize) {
let key = code.to_string();
self.producers_seen.insert(key.clone());
self.last_producer_idx.insert(key.clone(), node_idx);
self.producer_consumed.insert(key, false);
}
fn has_producer(&self, code: &str) -> bool {
self.producers_seen.contains(code)
}
fn mark_consumed(&mut self, producer_code: &str) {
if let Some(consumed) = self.producer_consumed.get_mut(producer_code) {
*consumed = true;
}
}
}
struct FieldTracker {
open: bool,
has_fh: bool,
fh_indicator: u8,
has_fn: bool,
has_serial: bool,
start_idx: usize,
active_barcodes: Vec<(usize, String, zpl_toolchain_spec_tables::FieldDataRules)>,
}
impl Default for FieldTracker {
fn default() -> Self {
Self {
open: false,
has_fh: false,
fh_indicator: b'_',
has_fn: false,
has_serial: false,
start_idx: 0,
active_barcodes: Vec::new(),
}
}
}
impl FieldTracker {
fn reset(&mut self) {
self.has_fh = false;
self.fh_indicator = b'_';
self.has_fn = false;
self.has_serial = false;
self.active_barcodes.clear();
}
fn process_command(
&mut self,
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &LabelState,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.cmd.opens_field {
if self.open {
issues.push(
diagnostic_with_spec_severity(
codes::FIELD_NOT_CLOSED,
format!(
"{} opens a new field before previous field was closed with ^FS",
cmd_ctx.code
),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code)),
);
}
self.open = true;
self.reset();
self.start_idx = cmd_ctx.node_idx;
}
if cmd_ctx.cmd.closes_field {
self.validate_field_close(cmd_ctx, vctx, label_state, issues);
}
if (cmd_ctx.cmd.field_data || cmd_ctx.cmd.requires_field) && !self.open {
issues.push(
diagnostic_with_spec_severity(
codes::FIELD_DATA_WITHOUT_ORIGIN,
format!(
"{} without preceding field origin (no field origin)",
cmd_ctx.code
),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code)),
);
}
if cmd_ctx.cmd.hex_escape_modifier {
self.has_fh = true;
if let Some(slot) = cmd_ctx.args.first()
&& let Some(val) = slot.value.as_deref()
&& let Some(ch) = val.bytes().next()
{
self.fh_indicator = ch;
}
}
if cmd_ctx.cmd.field_number {
self.has_fn = true;
}
if cmd_ctx.cmd.serialization {
self.has_serial = true;
}
if let Some(rules) = &cmd_ctx.cmd.field_data_rules
&& (rules.character_set.is_some()
|| rules.exact_length.is_some()
|| rules.allowed_lengths.is_some()
|| rules.min_length.is_some()
|| rules.max_length.is_some()
|| rules.length_parity.is_some())
{
self.active_barcodes
.push((cmd_ctx.node_idx, cmd_ctx.code.to_string(), rules.clone()));
}
}
fn validate_field_close(
&mut self,
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &LabelState,
issues: &mut Vec<Diagnostic>,
) {
if !self.open {
issues.push(
diagnostic_with_spec_severity(
codes::ORPHANED_FIELD_SEPARATOR,
format!(
"{} without a preceding field origin (orphaned field separator)",
cmd_ctx.code
),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code)),
);
return;
}
if self.has_fh {
let indicator = self.fh_indicator;
for field_node in &vctx.label_nodes[self.start_idx..cmd_ctx.node_idx] {
let content_and_span = match field_node {
crate::grammar::ast::Node::FieldData { content, span, .. } => {
Some((content.as_str(), Some(*span)))
}
crate::grammar::ast::Node::Command {
code, args, span, ..
} if code == "^FD" || code == "^FV" => args
.first()
.and_then(|slot| slot.value.as_deref())
.map(|val| (val, Some(*span))),
_ => None,
};
if let Some((content, dspan)) = content_and_span {
for err in crate::hex_escape::validate_hex_escapes(content, indicator) {
issues.push(
diagnostic_with_spec_severity(
codes::INVALID_HEX_ESCAPE,
err.message,
dspan,
)
.with_context(ctx!(
"command" => "^FH",
"indicator" => String::from(indicator as char)
)),
);
}
}
}
}
if self.has_serial && !self.has_fn {
issues.push(
diagnostic_with_spec_severity(
codes::SERIALIZATION_WITHOUT_FIELD_NUMBER,
"Serialization (^SN/^SF) in field without ^FN field number",
cmd_ctx.span,
)
.with_context(ctx!("command" => "^SN/^SF")),
);
}
if !self.has_fh && !self.active_barcodes.is_empty() {
for (i, (barcode_idx, barcode_code, rules)) in self.active_barcodes.iter().enumerate() {
let seg_start = *barcode_idx;
let seg_end = self
.active_barcodes
.get(i + 1)
.map(|(next_idx, _, _)| *next_idx)
.unwrap_or(cmd_ctx.node_idx);
let mut combined_fd = String::new();
let mut has_any_fd = false;
let mut first_fd_span: Option<Span> = None;
for field_node in &vctx.label_nodes[seg_start..seg_end] {
match field_node {
crate::grammar::ast::Node::Command {
code, args, span, ..
} if code == "^FD" || code == "^FV" => {
if let Some(slot) = args.first()
&& let Some(val) = slot.value.as_deref()
{
has_any_fd = true;
combined_fd.push_str(val);
if first_fd_span.is_none() {
first_fd_span = Some(*span);
}
}
}
crate::grammar::ast::Node::FieldData { content, span, .. } => {
has_any_fd = true;
combined_fd.push_str(content);
if first_fd_span.is_none() {
first_fd_span = Some(*span);
}
}
_ => {}
}
}
if has_any_fd {
validate_barcode_field_data(
barcode_code,
&combined_fd,
rules,
first_fd_span.or(cmd_ctx.span),
issues,
);
}
}
}
validate_object_bounds(self, cmd_ctx, vctx, label_state, issues);
self.open = false;
self.reset();
}
}
fn validate_object_bounds(
field: &FieldTracker,
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &LabelState,
issues: &mut Vec<Diagnostic>,
) {
let Some(fo_x) = label_state.last_fo_x else {
return;
};
let Some(fo_y) = label_state.last_fo_y else {
return;
};
let max_x = label_state.effective_width.or_else(|| {
vctx.profile
.and_then(|p| resolve_profile_field(p, "page.width_dots"))
});
let max_y = label_state.effective_height.or_else(|| {
vctx.profile
.and_then(|p| resolve_profile_field(p, "page.height_dots"))
});
let (Some(max_x), Some(max_y)) = (max_x, max_y) else {
return;
};
let mut combined_fd = String::new();
for node in &vctx.label_nodes[field.start_idx..cmd_ctx.node_idx] {
match node {
crate::grammar::ast::Node::Command { code, args, .. }
if code == "^FD" || code == "^FV" =>
{
if let Some(slot) = args.first().and_then(|a| a.value.as_deref()) {
combined_fd.push_str(slot);
}
}
crate::grammar::ast::Node::FieldData { content, .. } => combined_fd.push_str(content),
_ => {}
}
}
let char_count = combined_fd.chars().count();
if char_count == 0 {
return;
}
let is_barcode = !field.active_barcodes.is_empty();
let (est_width, est_height, object_type) = if is_barcode {
let height = label_state.value_state.barcode.height.unwrap_or(50) as f64;
let mw = label_state.value_state.barcode.module_width.unwrap_or(2) as f64;
let modules_per_char = 11.0_f64;
let modules = (modules_per_char * char_count as f64 + 22.0).ceil();
let width = (modules * mw).ceil();
(width, height, "barcode")
} else {
let fh = label_state.value_state.font.height.unwrap_or(20) as f64;
let fw = label_state
.value_state
.font
.width
.unwrap_or_else(|| label_state.value_state.font.height.unwrap_or(20))
as f64;
let width = (char_count as f64 * fw).ceil();
let height = fh;
(width, height, "text")
};
let overflows_x = fo_x + est_width > max_x;
let overflows_y = fo_y + est_height > max_y;
if overflows_x || overflows_y {
let overflow_x = if overflows_x {
(fo_x + est_width - max_x).max(0.0)
} else {
0.0
};
let overflow_y = if overflows_y {
(fo_y + est_height - max_y).max(0.0)
} else {
0.0
};
let overflow_x_ratio = if max_x > 0.0 { overflow_x / max_x } else { 0.0 };
let overflow_y_ratio = if max_y > 0.0 { overflow_y / max_y } else { 0.0 };
let max_overflow_dots = overflow_x.max(overflow_y);
let max_overflow_ratio = overflow_x_ratio.max(overflow_y_ratio);
let low_confidence = max_overflow_dots <= OBJECT_BOUNDS_LOW_CONFIDENCE_MAX_OVERFLOW_DOTS
&& max_overflow_ratio <= OBJECT_BOUNDS_LOW_CONFIDENCE_MAX_OVERFLOW_RATIO;
let severity = if low_confidence {
OBJECT_BOUNDS_LOW_CONFIDENCE_SEVERITY
} else {
Severity::Warn
};
let confidence = if low_confidence { "low" } else { "high" };
let x = trim_f64(fo_x);
let y = trim_f64(fo_y);
let label_width = trim_f64(max_x);
let label_height = trim_f64(max_y);
let message = if low_confidence {
render_diagnostic_message(
codes::OBJECT_BOUNDS_OVERFLOW,
"lowConfidence",
&[
("object_type", object_type.to_string()),
("x", x.clone()),
("y", y.clone()),
("label_width", label_width.clone()),
("label_height", label_height.clone()),
],
format!(
"{object_type} at ({x}, {y}) may extend beyond label bounds ({label_width}×{label_height} dots, estimated)"
),
)
} else {
render_diagnostic_message(
codes::OBJECT_BOUNDS_OVERFLOW,
"highConfidence",
&[
("object_type", object_type.to_string()),
("x", x.clone()),
("y", y.clone()),
("label_width", label_width.clone()),
("label_height", label_height.clone()),
],
format!(
"{object_type} at ({x}, {y}) extends beyond label bounds ({label_width}×{label_height} dots)"
),
)
};
issues.push(
Diagnostic::new(
codes::OBJECT_BOUNDS_OVERFLOW,
severity,
message,
cmd_ctx.span,
)
.with_context(ctx!(
"object_type" => object_type,
"x" => x,
"y" => y,
"estimated_width" => trim_f64(est_width),
"estimated_height" => trim_f64(est_height),
"label_width" => label_width,
"label_height" => label_height,
"overflow_x" => trim_f64(overflow_x),
"overflow_y" => trim_f64(overflow_y),
"overflow_x_ratio" => trim_f64(overflow_x_ratio),
"overflow_y_ratio" => trim_f64(overflow_y_ratio),
"confidence" => confidence,
"audience" => "problem",
)),
);
}
}
fn validate_barcode_field_data(
barcode_code: &str,
fd_content: &str,
rules: &zpl_toolchain_spec_tables::FieldDataRules,
dspan: Option<Span>,
issues: &mut Vec<Diagnostic>,
) {
let charset_severity = rules
.character_set_severity
.unwrap_or(zpl_toolchain_spec_tables::ConstraintSeverity::Error);
let length_severity = rules
.length_severity
.unwrap_or(zpl_toolchain_spec_tables::ConstraintSeverity::Warn);
if let Some(charset) = &rules.character_set {
for (i, ch) in fd_content.chars().enumerate() {
if !char_in_set(ch, charset) {
let position = i.to_string();
let message = render_diagnostic_message(
codes::BARCODE_INVALID_CHAR,
"invalidChar",
&[
("character", ch.to_string()),
("position", position.clone()),
("command", barcode_code.to_string()),
("allowedSet", charset.clone()),
],
format!(
"invalid character '{}' at position {} in {} field data (allowed: [{}])",
ch, i, barcode_code, charset
),
);
issues.push(
diagnostic_with_constraint_severity(
codes::BARCODE_INVALID_CHAR,
charset_severity,
message,
dspan,
)
.with_context(ctx!(
"command" => barcode_code,
"character" => ch.to_string(),
"position" => position,
"allowedSet" => charset.clone(),
)),
);
break;
}
}
}
let len = fd_content.chars().count();
if let Some(allowed) = &rules.allowed_lengths {
if !allowed.contains(&len) {
let expected = allowed
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ");
let actual = len.to_string();
let message = render_diagnostic_message(
codes::BARCODE_DATA_LENGTH,
"allowedLengths",
&[
("command", barcode_code.to_string()),
("actual", actual.clone()),
("expected", expected.clone()),
],
format!(
"{} field data length {} (expected one of [{}])",
barcode_code, len, expected
),
);
issues.push(
diagnostic_with_constraint_severity(
codes::BARCODE_DATA_LENGTH,
length_severity,
message,
dspan,
)
.with_context(ctx!(
"command" => barcode_code,
"actual" => actual,
"expected" => expected,
)),
);
}
} else if let Some(exact) = rules.exact_length {
if len != exact {
let actual = len.to_string();
let expected = exact.to_string();
let message = render_diagnostic_message(
codes::BARCODE_DATA_LENGTH,
"exactLength",
&[
("command", barcode_code.to_string()),
("actual", actual.clone()),
("expected", expected.clone()),
],
format!(
"{} field data length {} (expected exactly {})",
barcode_code, len, exact
),
);
issues.push(
diagnostic_with_constraint_severity(
codes::BARCODE_DATA_LENGTH,
length_severity,
message,
dspan,
)
.with_context(ctx!(
"command" => barcode_code,
"actual" => actual,
"expected" => expected,
)),
);
}
} else {
if let Some(min) = rules.min_length
&& len < min
{
let actual = len.to_string();
let min = min.to_string();
let message = render_diagnostic_message(
codes::BARCODE_DATA_LENGTH,
"minLength",
&[
("command", barcode_code.to_string()),
("actual", actual.clone()),
("min", min.clone()),
],
format!(
"{} field data too short: {} chars (minimum {})",
barcode_code, actual, min
),
);
issues.push(
diagnostic_with_constraint_severity(
codes::BARCODE_DATA_LENGTH,
length_severity,
message,
dspan,
)
.with_context(ctx!(
"command" => barcode_code,
"actual" => actual,
"min" => min,
)),
);
}
if let Some(max) = rules.max_length
&& len > max
{
let actual = len.to_string();
let max = max.to_string();
let message = render_diagnostic_message(
codes::BARCODE_DATA_LENGTH,
"maxLength",
&[
("command", barcode_code.to_string()),
("actual", actual.clone()),
("max", max.clone()),
],
format!(
"{} field data too long: {} chars (maximum {})",
barcode_code, actual, max
),
);
issues.push(
diagnostic_with_constraint_severity(
codes::BARCODE_DATA_LENGTH,
length_severity,
message,
dspan,
)
.with_context(ctx!(
"command" => barcode_code,
"actual" => actual,
"max" => max,
)),
);
}
}
if let Some(parity) = &rules.length_parity {
let even = len.is_multiple_of(2);
let valid = match parity.as_str() {
"even" => even,
"odd" => !even,
_ => true,
};
if !valid {
let actual = len.to_string();
let actual_parity = if even { "even" } else { "odd" }.to_string();
let message = render_diagnostic_message(
codes::BARCODE_DATA_LENGTH,
"parity",
&[
("command", barcode_code.to_string()),
("actual", actual.clone()),
("parity", parity.clone()),
("actualParity", actual_parity.clone()),
],
format!(
"{} field data length {} should be {} (got {})",
barcode_code, len, parity, actual_parity
),
);
issues.push(
diagnostic_with_constraint_severity(
codes::BARCODE_DATA_LENGTH,
length_severity,
message,
dspan,
)
.with_context(ctx!(
"command" => barcode_code,
"actual" => actual,
"parity" => parity.clone(),
"actualParity" => actual_parity,
)),
);
}
}
}
fn char_in_set(ch: char, charset: &str) -> bool {
let bytes = charset.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
if ch == bytes[i + 1] as char {
return true;
}
i += 2;
continue;
}
if i + 2 < bytes.len() && bytes[i + 1] == b'-' && bytes[i + 2] != b'\\' {
let lo = bytes[i] as char;
let hi = bytes[i + 2] as char;
let (actual_lo, actual_hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
if ch >= actual_lo && ch <= actual_hi {
return true;
}
i += 3;
continue;
}
if ch == bytes[i] as char {
return true;
}
i += 1;
}
false
}
struct ValidationContext<'a> {
profile: Option<&'a Profile>,
label_nodes: &'a [crate::grammar::ast::Node],
label_codes: &'a HashSet<&'a str>,
device_state: &'a DeviceState,
}
struct CommandCtx<'a> {
code: &'a str,
args: &'a [crate::grammar::ast::ArgSlot],
cmd: &'a zpl_toolchain_spec_tables::CommandEntry,
span: Option<Span>,
node_idx: usize,
}
fn validate_arg_range(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
lookup_key: &str,
val: &str,
spec_arg: &zpl_toolchain_spec_tables::Arg,
issues: &mut Vec<Diagnostic>,
) {
let mut active_range: Option<[f64; 2]> = spec_arg.range;
if let Some(conds) = spec_arg.range_when.as_ref() {
for cr in conds {
if predicate_matches(&cr.when, cmd_ctx.args) {
active_range = Some(cr.range);
}
}
}
if let Some([lo, hi]) = active_range
&& let Ok(n) = val.parse::<f64>()
{
let effective_n =
if spec_arg.unit.as_deref() == Some("dots") && vctx.device_state.units != Units::Dots {
if let Some(dpi) = vctx.device_state.dpi {
convert_to_dots(n, vctx.device_state.units, dpi)
} else {
return;
}
} else {
n
};
if effective_n < lo || effective_n > hi {
issues.push(
diagnostic_with_spec_severity(
codes::OUT_OF_RANGE,
format!(
"{}.{} out of range [{},{}]",
cmd_ctx.code,
lookup_key,
trim_f64(lo),
trim_f64(hi)
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
"min" => trim_f64(lo),
"max" => trim_f64(hi),
)),
);
}
}
}
fn validate_arg_length(
cmd_ctx: &CommandCtx,
lookup_key: &str,
val: &str,
spec_arg: &zpl_toolchain_spec_tables::Arg,
issues: &mut Vec<Diagnostic>,
) {
if let Some(minl) = spec_arg.min_length
&& (val.len() as u32) < minl
{
issues.push(
diagnostic_with_spec_severity(
codes::STRING_TOO_SHORT,
format!(
"{}.{} shorter than minLength {}",
cmd_ctx.code, lookup_key, minl
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
"min_length" => minl.to_string(),
"actual_length" => val.len().to_string(),
)),
);
}
if let Some(maxl) = spec_arg.max_length
&& (val.len() as u32) > maxl
{
issues.push(
diagnostic_with_spec_severity(
codes::STRING_TOO_LONG,
format!("{}.{} exceeds maxLength {}", cmd_ctx.code, lookup_key, maxl),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
"max_length" => maxl.to_string(),
"actual_length" => val.len().to_string(),
)),
);
}
}
fn validate_arg_rounding(
cmd_ctx: &CommandCtx,
lookup_key: &str,
val: &str,
spec_arg: &zpl_toolchain_spec_tables::Arg,
issues: &mut Vec<Diagnostic>,
) {
let mut rp: Option<zpl_toolchain_spec_tables::RoundingPolicy> =
spec_arg.rounding_policy.clone();
let mut epsilon = rp.as_ref().map(|policy| policy.epsilon).unwrap_or(1e-9);
if let Some(rpw) = spec_arg.rounding_policy_when.as_ref() {
for c in rpw {
if predicate_matches(&c.when, cmd_ctx.args) {
epsilon = c.epsilon.unwrap_or(epsilon);
rp = Some(zpl_toolchain_spec_tables::RoundingPolicy {
unit: None,
mode: c.mode,
multiple: c.multiple,
epsilon,
});
}
}
}
if let Some(pol) = rp
&& pol.mode == RoundingMode::ToMultiple
&& let (Ok(n), Some(m)) = (val.parse::<f64>(), pol.multiple)
&& m > 0.0
{
let rem = (n / m).fract();
if rem > pol.epsilon && (1.0 - rem) > pol.epsilon {
let value = trim_f64(n);
let multiple = trim_f64(m);
let message = render_diagnostic_message(
codes::ROUNDING_VIOLATION,
"notMultiple",
&[
("command", cmd_ctx.code.to_string()),
("arg", lookup_key.to_string()),
("value", value.clone()),
("multiple", multiple.clone()),
],
format!(
"{}.{}={} not a multiple of {}",
cmd_ctx.code, lookup_key, value, multiple
),
);
issues.push(
diagnostic_with_spec_severity(codes::ROUNDING_VIOLATION, message, cmd_ctx.span)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
"multiple" => multiple,
"epsilon" => trim_f64(pol.epsilon),
)),
);
}
}
}
fn validate_arg_profile_constraint(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
lookup_key: &str,
val: &str,
spec_arg: &zpl_toolchain_spec_tables::Arg,
issues: &mut Vec<Diagnostic>,
) {
if let Some(pc) = &spec_arg.profile_constraint
&& let Some(p) = vctx.profile
&& let Ok(n) = val.parse::<f64>()
&& let Some(limit) = resolve_profile_field(p, &pc.field)
{
let effective_n =
if spec_arg.unit.as_deref() == Some("dots") && vctx.device_state.units != Units::Dots {
if let Some(dpi) = vctx.device_state.dpi {
convert_to_dots(n, vctx.device_state.units, dpi)
} else {
return;
}
} else {
n
};
if check_profile_op(effective_n, &pc.op, limit) {
return;
}
let op_desc = match pc.op {
ComparisonOp::Lte => "exceeds",
ComparisonOp::Gte => "below",
ComparisonOp::Lt => "exceeds or equals",
ComparisonOp::Gt => "below or equals",
ComparisonOp::Eq => "violates",
};
issues.push(
diagnostic_with_spec_severity(
codes::PROFILE_CONSTRAINT,
format!(
"{}.{} {} profile {} ({})",
cmd_ctx.code,
lookup_key,
op_desc,
pc.field,
trim_f64(limit),
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"field" => pc.field.clone(),
"op" => format!("{:?}", pc.op),
"limit" => trim_f64(limit),
"actual" => trim_f64(effective_n),
)),
);
}
}
fn validate_arg_enum_gates(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
lookup_key: &str,
val: &str,
spec_arg: &zpl_toolchain_spec_tables::Arg,
issues: &mut Vec<Diagnostic>,
) {
if let Some(ref enum_values) = spec_arg.r#enum
&& let Some(p) = vctx.profile
&& let Some(ref features) = p.features
{
for ev in enum_values {
if let zpl_toolchain_spec_tables::EnumValue::Object {
value: ev_val,
printer_gates: Some(gates),
..
} = ev
&& ev_val == val
{
for gate in gates {
if let Some(false) = zpl_toolchain_profile::resolve_gate(features, gate) {
issues.push(
diagnostic_with_spec_severity(
codes::PRINTER_GATE,
format!(
"{}.{}={} requires '{}' capability not available in profile '{}'",
cmd_ctx.code,
lookup_key,
val,
gate,
&p.id
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
"gate" => gate.clone(),
"level" => "enum",
"profile" => &p.id,
)),
);
}
}
}
}
}
}
fn validate_arg_value(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
lookup_key: &str,
val: &str,
spec_arg: &zpl_toolchain_spec_tables::Arg,
issues: &mut Vec<Diagnostic>,
) {
let type_valid = match spec_arg.r#type.as_str() {
"enum" => {
if let Some(ev) = spec_arg.r#enum.as_ref() {
let ok = enum_contains(ev, val);
if !ok {
issues.push(
diagnostic_with_spec_severity(
codes::INVALID_ENUM,
format!("{}.{} invalid enum", cmd_ctx.code, lookup_key),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
)),
);
}
}
true
}
"int" => {
val.parse::<i64>().is_ok() || {
issues.push(
diagnostic_with_spec_severity(
codes::EXPECTED_INTEGER,
format!(
"{}.{} expected integer, got \"{}\"",
cmd_ctx.code, lookup_key, val
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
)),
);
false
}
}
"float" => {
val.parse::<f64>().is_ok() || {
issues.push(
diagnostic_with_spec_severity(
codes::EXPECTED_NUMERIC,
format!(
"{}.{} expected number, got \"{}\"",
cmd_ctx.code, lookup_key, val
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
)),
);
false
}
}
"char" => {
val.chars().count() == 1 || {
issues.push(
diagnostic_with_spec_severity(
codes::EXPECTED_CHAR,
format!(
"{}.{} expected single character, got \"{}\"",
cmd_ctx.code, lookup_key, val
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key,
"value" => val,
)),
);
false
}
}
_ => true,
};
if !type_valid {
return;
}
validate_arg_range(cmd_ctx, vctx, lookup_key, val, spec_arg, issues);
validate_arg_length(cmd_ctx, lookup_key, val, spec_arg, issues);
validate_arg_rounding(cmd_ctx, lookup_key, val, spec_arg, issues);
validate_arg_profile_constraint(cmd_ctx, vctx, lookup_key, val, spec_arg, issues);
validate_arg_enum_gates(cmd_ctx, vctx, lookup_key, val, spec_arg, issues);
}
fn value_to_arg_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(if *b { "Y".to_string() } else { "N".to_string() }),
_ => None,
}
}
fn resolve_effective_default_value(
arg: &zpl_toolchain_spec_tables::Arg,
vctx: &ValidationContext,
label_state: &LabelState,
) -> Option<String> {
if let Some(df) = arg.default_from.as_deref()
&& label_state.has_producer(df)
&& let Some(key) = arg.default_from_state_key.as_deref()
&& let Some(v) = label_state.value_state.state_value_by_key(key)
{
return Some(v);
}
if let Some(map) = arg.default_by_dpi.as_ref()
&& let Some(dpi) = vctx.profile.map(|p| p.dpi)
&& let Some(v) = map.get(&dpi.to_string()).and_then(value_to_arg_string)
{
return Some(v);
}
arg.default.as_ref().and_then(value_to_arg_string)
}
fn validate_command_args(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &LabelState,
issues: &mut Vec<Diagnostic>,
) {
let Some(spec_args) = cmd_ctx.cmd.args.as_ref() else {
return;
};
let mut key_to_slot: HashMap<String, &crate::grammar::ast::ArgSlot> = HashMap::new();
for (idx, slot) in cmd_ctx.args.iter().enumerate() {
key_to_slot.insert(idx.to_string(), slot);
if let Some(k) = slot.key.as_ref() {
key_to_slot.insert(k.clone(), slot);
}
}
for (idx, spec_arg) in spec_args.iter().enumerate() {
let lookup_key = idx.to_string();
let slot_opt = key_to_slot.get(&lookup_key).copied();
let eff = select_effective_arg(spec_arg, slot_opt);
let resolved_default =
eff.and_then(|arg| resolve_effective_default_value(arg, vctx, label_state));
if let Some(arg) = eff
&& !arg.optional
{
let has_static_default = arg.default.is_some()
|| arg.default_by_dpi.as_ref().is_some_and(|m| {
vctx.profile
.map(|p| p.dpi)
.is_some_and(|d| m.contains_key(&d.to_string()))
});
let has_any_default = has_static_default || resolved_default.is_some();
match slot_opt {
None if !has_any_default => {
issues.push(
diagnostic_with_spec_severity(
codes::REQUIRED_MISSING,
format!("{}.{} is required but missing", cmd_ctx.code, lookup_key),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key.clone(),
)),
);
}
Some(slot) => {
if slot.presence == crate::grammar::ast::Presence::Unset && !has_any_default {
issues.push(
diagnostic_with_spec_severity(
codes::REQUIRED_MISSING,
format!("{}.{} is required but unset", cmd_ctx.code, lookup_key),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key.clone(),
)),
);
} else if slot.presence == crate::grammar::ast::Presence::Empty
&& !has_any_default
{
issues.push(
diagnostic_with_spec_severity(
codes::REQUIRED_EMPTY,
format!("{}.{} is empty but required", cmd_ctx.code, lookup_key),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"arg" => lookup_key.clone(),
)),
);
}
}
_ => {}
}
}
if let (Some(slot), Some(spec_arg)) = (slot_opt, eff)
&& let Some(val) = slot.value.as_ref()
{
validate_arg_value(cmd_ctx, vctx, &lookup_key, val, spec_arg, issues);
} else if let (Some(spec_arg), Some(default_val)) = (eff, resolved_default.as_ref()) {
validate_arg_value(cmd_ctx, vctx, &lookup_key, default_val, spec_arg, issues);
}
}
}
fn validate_command_constraints(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
seen_label_codes: &HashSet<&str>,
seen_field_codes: &HashSet<&str>,
current_field_codes: Option<&HashSet<&str>>,
issues: &mut Vec<Diagnostic>,
) {
let Some(constraints) = cmd_ctx.cmd.constraints.as_ref() else {
return;
};
let constraint_default_severity = cmd_ctx
.cmd
.constraint_defaults
.as_ref()
.and_then(|defaults| defaults.severity.as_ref());
for c in constraints {
match c.kind {
ConstraintKind::Order => {
if let Some(expr) = c.expr.as_ref() {
let eval_scope = c.scope.unwrap_or_else(|| {
if cmd_ctx.cmd.scope == Some(CommandScope::Field) {
ConstraintScope::Field
} else {
ConstraintScope::Label
}
});
let seen_codes = if eval_scope == ConstraintScope::Field {
seen_field_codes
} else {
seen_label_codes
};
if let Some(targets) = expr.strip_prefix("before:") {
if any_target_in_set(targets, seen_codes) {
issues.push(
Diagnostic::new(
codes::ORDER_BEFORE,
map_sev(c.severity.as_ref(), constraint_default_severity),
c.message.clone(),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"target" => targets,
"kind" => "order",
"scope" => if eval_scope == ConstraintScope::Field { "field" } else { "label" },
)),
);
}
} else if let Some(targets) = expr.strip_prefix("after:")
&& !any_target_in_set(targets, seen_codes)
{
issues.push(
Diagnostic::new(
codes::ORDER_AFTER,
map_sev(c.severity.as_ref(), constraint_default_severity),
c.message.clone(),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"target" => targets,
"kind" => "order",
"scope" => if eval_scope == ConstraintScope::Field { "field" } else { "label" },
)),
);
}
}
}
ConstraintKind::Requires => {
if let Some(expr) = c.expr.as_ref() {
let eval_scope = c.scope.unwrap_or(ConstraintScope::Label);
let empty_field_codes: HashSet<&str> = HashSet::new();
let target_codes = if eval_scope == ConstraintScope::Field {
current_field_codes.unwrap_or(&empty_field_codes)
} else {
vctx.label_codes
};
if !any_target_in_set(expr, target_codes) {
issues.push(
Diagnostic::new(
codes::REQUIRED_COMMAND,
map_sev(c.severity.as_ref(), constraint_default_severity),
c.message.clone(),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"target" => expr.clone(),
"kind" => "requires",
"scope" => if eval_scope == ConstraintScope::Field { "field" } else { "label" },
)),
);
}
}
}
ConstraintKind::Incompatible => {
if let Some(expr) = c.expr.as_ref() {
let eval_scope = c.scope.unwrap_or(ConstraintScope::Label);
let empty_field_codes: HashSet<&str> = HashSet::new();
let target_codes = if eval_scope == ConstraintScope::Field {
current_field_codes.unwrap_or(&empty_field_codes)
} else {
vctx.label_codes
};
if any_target_in_set(expr, target_codes) {
issues.push(
Diagnostic::new(
codes::INCOMPATIBLE_COMMAND,
map_sev(c.severity.as_ref(), constraint_default_severity),
c.message.clone(),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"target" => expr.clone(),
"kind" => "incompatible",
"scope" => if eval_scope == ConstraintScope::Field { "field" } else { "label" },
)),
);
}
}
}
ConstraintKind::EmptyData => {
let fd_has_content = cmd_ctx
.args
.first()
.and_then(|a| a.value.as_ref())
.is_some_and(|s| !s.is_empty());
let mut trailing_fd_has_content = false;
for n in &vctx.label_nodes[(cmd_ctx.node_idx + 1).min(vctx.label_nodes.len())..] {
match n {
crate::grammar::ast::Node::FieldData { content, .. } => {
if !content.is_empty() {
trailing_fd_has_content = true;
break;
}
}
crate::grammar::ast::Node::Command { .. } => break,
_ => {}
}
}
if !fd_has_content && !trailing_fd_has_content {
issues.push(
Diagnostic::new(
codes::EMPTY_FIELD_DATA,
map_sev(c.severity.as_ref(), constraint_default_severity),
c.message.clone(),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code)),
);
}
}
ConstraintKind::Note => {
let should_emit = if let Some(expr) = c.expr.as_deref() {
let eval_scope = c.scope.unwrap_or_else(|| {
if cmd_ctx.cmd.scope == Some(CommandScope::Field) {
ConstraintScope::Field
} else {
ConstraintScope::Label
}
});
let seen_codes = if eval_scope == ConstraintScope::Field {
seen_field_codes
} else {
seen_label_codes
};
if let Some(targets) = expr.strip_prefix("after:first:") {
any_target_in_set(targets, seen_codes)
} else if let Some(targets) = expr.strip_prefix("before:first:") {
!any_target_in_set(targets, seen_codes)
} else if let Some(targets) = expr.strip_prefix("after:") {
any_target_in_set(targets, seen_codes)
} else if let Some(targets) = expr.strip_prefix("before:") {
!any_target_in_set(targets, seen_codes)
} else if let Some(condition) = expr.strip_prefix("when:") {
evaluate_note_when_expression(
condition.trim(),
cmd_ctx.args,
seen_codes,
vctx.profile,
)
} else {
true
}
} else {
true
};
if !should_emit {
continue;
}
let mut diagnostic = Diagnostic::new(
codes::NOTE,
map_sev(c.severity.as_ref(), constraint_default_severity),
c.message.clone(),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code));
if matches!(c.audience, Some(NoteAudience::Contextual))
&& let Some(context) = diagnostic.context.as_mut()
{
context.insert("audience".to_string(), "contextual".to_string());
}
issues.push(diagnostic);
}
ConstraintKind::Range | ConstraintKind::Custom => {}
}
}
}
fn validate_field_number(
cmd_ctx: &CommandCtx,
label_state: &mut LabelState,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.code == "^FN"
&& let Some(slot) = cmd_ctx.args.first()
&& let Some(n) = slot.value.as_ref()
{
if let Some(&first_idx) = label_state.field_numbers.get(n) {
issues.push(
diagnostic_with_spec_severity(
codes::DUPLICATE_FIELD_NUMBER,
format!(
"Duplicate field number {} (first used at node {})",
n, first_idx
),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code, "field_number" => n.clone())),
);
} else {
label_state
.field_numbers
.insert(n.clone(), cmd_ctx.node_idx);
}
}
}
fn validate_position_bounds(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &mut LabelState,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.code == "^PW" {
if let Some(w) = label_state.value_state.layout.print_width {
label_state.effective_width = Some(w);
}
label_state.has_explicit_pw = true;
}
if cmd_ctx.code == "^LL" {
if let Some(h) = label_state.value_state.layout.label_length {
label_state.effective_height = Some(h);
}
label_state.has_explicit_ll = true;
}
if cmd_ctx.code == "^FO" || cmd_ctx.code == "^FT" {
label_state.last_fo_x = Some(label_state.value_state.label_home.x);
label_state.last_fo_y = Some(label_state.value_state.label_home.y);
if let Some(x_slot) = cmd_ctx.args.first()
&& let Some(x_val) = x_slot.value.as_ref()
&& let Ok(x) = x_val.parse::<f64>()
{
label_state.last_fo_x = Some(
if let Some(dpi) = vctx.device_state.dpi {
convert_to_dots(x, vctx.device_state.units, dpi)
} else {
x
} + label_state.value_state.label_home.x,
);
}
if let Some(y_slot) = cmd_ctx.args.get(1)
&& let Some(y_val) = y_slot.value.as_ref()
&& let Ok(y) = y_val.parse::<f64>()
{
label_state.last_fo_y = Some(
if let Some(dpi) = vctx.device_state.dpi {
convert_to_dots(y, vctx.device_state.units, dpi)
} else {
y
} + label_state.value_state.label_home.y,
);
}
}
if cmd_ctx.code != "^FO" && cmd_ctx.code != "^FT" {
return;
}
let max_x = label_state.effective_width.or_else(|| {
vctx.profile
.and_then(|p| resolve_profile_field(p, "page.width_dots"))
});
let max_y = label_state.effective_height.or_else(|| {
vctx.profile
.and_then(|p| resolve_profile_field(p, "page.height_dots"))
});
if let (Some(fo_x), Some(w)) = (label_state.last_fo_x, max_x)
&& fo_x > w
{
issues.push(
diagnostic_with_spec_severity(
codes::POSITION_OUT_OF_BOUNDS,
format!(
"{} x position {} exceeds label width {}",
cmd_ctx.code,
trim_f64(fo_x),
trim_f64(w)
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"axis" => "x",
"value" => trim_f64(fo_x),
"limit" => trim_f64(w),
)),
);
}
if let (Some(fo_y), Some(h)) = (label_state.last_fo_y, max_y)
&& fo_y > h
{
issues.push(
diagnostic_with_spec_severity(
codes::POSITION_OUT_OF_BOUNDS,
format!(
"{} y position {} exceeds label height {}",
cmd_ctx.code,
trim_f64(fo_y),
trim_f64(h)
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"axis" => "y",
"value" => trim_f64(fo_y),
"limit" => trim_f64(h),
)),
);
}
}
fn validate_font_reference(
cmd_ctx: &CommandCtx,
label_state: &mut LabelState,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.code == "^A"
&& let Some(slot) = cmd_ctx.args.first()
&& let Some(v) = slot.value.as_ref()
&& let Some(font_char) = v.chars().next()
{
let is_builtin = font_char.is_ascii_uppercase() || font_char.is_ascii_digit();
let is_loaded = label_state.loaded_fonts.contains(&font_char);
if !is_builtin && !is_loaded {
issues.push(
diagnostic_with_spec_severity(
codes::UNKNOWN_FONT,
format!(
"^A font '{}' is not a built-in font (A-Z, 0-9) and has not been loaded via ^CW",
font_char
),
cmd_ctx.span,
)
.with_context(ctx!("command" => cmd_ctx.code, "font" => font_char.to_string())),
);
}
}
if cmd_ctx.code == "^CW"
&& let Some(slot) = cmd_ctx.args.first()
&& let Some(v) = slot.value.as_ref()
&& let Some(ch) = v.chars().next()
{
label_state.loaded_fonts.insert(ch);
}
}
fn validate_media_modes(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.code == "^MM"
&& let Some(slot) = cmd_ctx.args.first()
&& let Some(val) = slot.value.as_ref()
&& let Some(p) = vctx.profile
&& let Some(ref media) = p.media
&& let Some(ref modes) = media.supported_modes
&& !modes.is_empty()
&& !modes.iter().any(|m| m == val)
{
issues.push(
diagnostic_with_spec_severity(
codes::MEDIA_MODE_UNSUPPORTED,
format!(
"^MM mode '{}' is not in profile's supported_modes {:?}",
val, modes
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => "^MM",
"kind" => "mode",
"value" => val.clone(),
"supported" => format!("{:?}", modes),
"profile" => &p.id,
)),
);
}
if cmd_ctx.code == "^MN"
&& let Some(slot) = cmd_ctx.args.first()
&& let Some(val) = slot.value.as_ref()
&& let Some(p) = vctx.profile
&& let Some(ref media) = p.media
&& let Some(ref tracking) = media.supported_tracking
&& !tracking.is_empty()
&& !tracking.iter().any(|t| t == val)
{
issues.push(
diagnostic_with_spec_severity(
codes::MEDIA_MODE_UNSUPPORTED,
format!(
"^MN tracking mode '{}' is not in profile's supported_tracking {:?}",
val, tracking
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => "^MN",
"kind" => "tracking",
"value" => val.clone(),
"supported" => format!("{:?}", tracking),
"profile" => &p.id,
)),
);
}
if cmd_ctx.code == "^MT"
&& let Some(slot) = cmd_ctx.args.first()
&& let Some(val) = slot.value.as_ref()
&& let Some(p) = vctx.profile
&& let Some(ref media) = p.media
&& let Some(ref method) = media.print_method
{
let compatible = match method {
zpl_toolchain_profile::PrintMethod::Both => true,
zpl_toolchain_profile::PrintMethod::DirectThermal => val == "D",
zpl_toolchain_profile::PrintMethod::ThermalTransfer => val == "T",
};
if !compatible {
issues.push(
diagnostic_with_spec_severity(
codes::MEDIA_MODE_UNSUPPORTED,
format!(
"^MT media type '{}' conflicts with profile print method '{:?}'",
val, method
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => "^MT",
"kind" => "method",
"value" => val.clone(),
"profile_method" => format!("{:?}", method),
"profile" => &p.id,
)),
);
}
}
}
fn validate_gf_data_length(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.code != "^GF" || cmd_ctx.args.len() < 5 {
return;
}
let compression = cmd_ctx.args[0].value.as_deref().unwrap_or("A"); let byte_count_val = cmd_ctx.args[1].value.as_deref(); let data_val = cmd_ctx.args[4].value.as_deref();
if let (Some(bc_str), Some(data)) = (byte_count_val, data_val)
&& let Ok(declared) = bc_str.parse::<usize>()
{
let strip_ws = compression != "B";
let effective_len = |s: &str| -> usize {
if strip_ws {
s.bytes().filter(|b| !b.is_ascii_whitespace()).count()
} else {
s.len()
}
};
let mut total_data_len = effective_len(data);
for continuation in &vctx.label_nodes[cmd_ctx.node_idx + 1..] {
if let crate::grammar::ast::Node::RawData {
command,
data: raw_data,
..
} = continuation
{
if command == "^GF" {
total_data_len += raw_data.as_deref().map_or(0, &effective_len);
} else {
break; }
} else {
break; }
}
let mismatch = match compression {
"A" => {
let expected = declared * 2;
if total_data_len != expected {
Some((total_data_len, expected, "ASCII hex (2 chars per byte)"))
} else {
None
}
}
"B" => {
if total_data_len != declared {
Some((total_data_len, declared, "binary (1:1)"))
} else {
None
}
}
_ => None,
};
if let Some((actual_len, expected_len, fmt)) = mismatch {
issues.push(
diagnostic_with_spec_severity(
codes::GF_DATA_LENGTH_MISMATCH,
format!(
"^GF data length mismatch: declared {} bytes ({}), but data is {} chars (expected {})",
declared, fmt, actual_len, expected_len
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"format" => compression,
"declared" => declared.to_string(),
"actual" => actual_len.to_string(),
"expected" => expected_len.to_string(),
)),
);
}
}
}
fn validate_gf_preflight_tracking(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &mut LabelState,
issues: &mut Vec<Diagnostic>,
) {
if cmd_ctx.code != "^GF" || cmd_ctx.args.len() < 4 {
return;
}
let gfc_val = cmd_ctx.args.get(2).and_then(|s| s.value.as_deref());
let bpr_val = cmd_ctx.args.get(3).and_then(|s| s.value.as_deref());
if let Some(gfc_str) = gfc_val
&& let Ok(graphic_field_count) = gfc_str.parse::<u32>()
{
label_state.gf_total_bytes = label_state
.gf_total_bytes
.saturating_add(graphic_field_count);
if let Some(bpr_str) = bpr_val
&& let Ok(bytes_per_row) = bpr_str.parse::<u32>()
&& bytes_per_row > 0
{
let graphic_width = bytes_per_row.saturating_mul(8);
let graphic_height = graphic_field_count.div_ceil(bytes_per_row);
let max_x = label_state.effective_width.or_else(|| {
vctx.profile
.and_then(|p| resolve_profile_field(p, "page.width_dots"))
});
let max_y = label_state.effective_height.or_else(|| {
vctx.profile
.and_then(|p| resolve_profile_field(p, "page.height_dots"))
});
let can_check_bounds =
vctx.device_state.dpi.is_some() || vctx.device_state.units == Units::Dots;
if can_check_bounds
&& let (Some(fo_x), Some(fo_y)) = (label_state.last_fo_x, label_state.last_fo_y)
{
let overflows_x = max_x.is_some_and(|w| fo_x + graphic_width as f64 > w);
let overflows_y = max_y.is_some_and(|h| fo_y + graphic_height as f64 > h);
if overflows_x || overflows_y {
let ew = max_x.map_or("?".to_string(), trim_f64);
let eh = max_y.map_or("?".to_string(), trim_f64);
issues.push(
diagnostic_with_spec_severity(
codes::GF_BOUNDS_OVERFLOW,
format!(
"Graphic field at ({}, {}) extends beyond label bounds ({}×{} dots)",
trim_f64(fo_x),
trim_f64(fo_y),
ew,
eh,
),
cmd_ctx.span,
)
.with_context(ctx!(
"command" => cmd_ctx.code,
"x" => trim_f64(fo_x),
"y" => trim_f64(fo_y),
"graphic_width" => graphic_width.to_string(),
"graphic_height" => graphic_height.to_string(),
"label_width" => ew,
"label_height" => eh,
)),
);
}
}
}
}
}
fn validate_semantic_state(
cmd_ctx: &CommandCtx,
vctx: &ValidationContext,
label_state: &mut LabelState,
issues: &mut Vec<Diagnostic>,
) {
if let Some(spec_args) = cmd_ctx.cmd.args.as_ref() {
for sa in spec_args {
let arg = match sa {
zpl_toolchain_spec_tables::ArgUnion::Single(a) => Some(a.as_ref()),
zpl_toolchain_spec_tables::ArgUnion::OneOf { one_of } => one_of.first(),
};
if let Some(a) = arg
&& let Some(df) = &a.default_from
{
label_state.mark_consumed(df);
}
}
}
validate_field_number(cmd_ctx, label_state, issues);
validate_position_bounds(cmd_ctx, vctx, label_state, issues);
validate_font_reference(cmd_ctx, label_state, issues);
validate_media_modes(cmd_ctx, vctx, issues);
validate_gf_data_length(cmd_ctx, vctx, issues);
validate_gf_preflight_tracking(cmd_ctx, vctx, label_state, issues);
}
fn validate_preflight(
vctx: &ValidationContext,
label_state: &LabelState,
label_span: Option<Span>,
issues: &mut Vec<Diagnostic>,
) {
if label_state.gf_total_bytes > 0
&& let Some(profile) = vctx.profile
&& let Some(ram_kb) = resolve_profile_field(profile, "memory.ram_kb")
{
let ram_bytes = ram_kb as u64 * 1024;
if label_state.gf_total_bytes as u64 > ram_bytes {
issues.push(
diagnostic_with_spec_severity(
codes::GF_MEMORY_EXCEEDED,
format!(
"Total graphic data ({} bytes) exceeds available RAM ({} bytes / {} KB)",
label_state.gf_total_bytes, ram_bytes, ram_kb as u64,
),
label_span,
)
.with_context(ctx!(
"command" => "^GF",
"total_bytes" => label_state.gf_total_bytes.to_string(),
"ram_bytes" => ram_bytes.to_string(),
)),
);
}
}
if let Some(profile) = vctx.profile {
let profile_has_width = resolve_profile_field(profile, "page.width_dots").is_some();
let profile_has_height = resolve_profile_field(profile, "page.height_dots").is_some();
if (profile_has_width || profile_has_height)
&& (!label_state.has_explicit_pw || !label_state.has_explicit_ll)
{
let mut missing = Vec::new();
if !label_state.has_explicit_pw && profile_has_width {
missing.push("^PW");
}
if !label_state.has_explicit_ll && profile_has_height {
missing.push("^LL");
}
if !missing.is_empty() {
let missing_str = missing.join(", ");
issues.push(
diagnostic_with_spec_severity(
codes::MISSING_EXPLICIT_DIMENSIONS,
format!(
"Label relies on profile for dimensions but does not contain explicit {} — consider adding for portability",
missing_str,
),
label_span,
)
.with_context(ctx!(
"missing_commands" => missing_str,
)),
);
}
}
}
}
pub fn validate_with_profile(
ast: &Ast,
tables: &ParserTables,
profile: Option<&Profile>,
) -> ValidationResult {
let mut issues = Vec::new();
let mut resolved_labels = Vec::new();
let known = tables.code_set();
let mut device_state = DeviceState::default();
if let Some(p) = profile {
device_state.dpi = Some(p.dpi);
}
for label in &ast.labels {
let mut label_state = LabelState::default();
let mut field_tracker = FieldTracker::default();
let mut has_printable = false;
let mut seen_codes: HashSet<&str> = HashSet::new();
let mut seen_field_codes: HashSet<&str> = HashSet::new();
let label_codes: HashSet<&str> = label
.nodes
.iter()
.filter_map(|n| {
if let crate::grammar::ast::Node::Command { code, .. } = n {
Some(code.as_str())
} else {
None
}
})
.collect();
let mut field_id_by_node: Vec<Option<usize>> = vec![None; label.nodes.len()];
let mut field_codes: Vec<HashSet<&str>> = Vec::new();
let mut current_field_id: Option<usize> = None;
for (idx, node) in label.nodes.iter().enumerate() {
if let crate::grammar::ast::Node::Command { code, .. } = node {
if known.contains(code)
&& let Some(cmd) = tables.cmd_by_code(code)
&& cmd.opens_field
{
let new_id = field_codes.len();
field_codes.push(HashSet::new());
current_field_id = Some(new_id);
}
if let Some(fid) = current_field_id {
field_id_by_node[idx] = Some(fid);
if let Some(set) = field_codes.get_mut(fid) {
set.insert(code.as_str());
}
}
if known.contains(code)
&& let Some(cmd) = tables.cmd_by_code(code)
&& cmd.closes_field
{
current_field_id = None;
}
}
}
let mut inside_format_bounds = false;
for (node_idx, node) in label.nodes.iter().enumerate() {
if let crate::grammar::ast::Node::Command { code, args, span } = node {
let dspan = Some(*span);
if code == "^XA" {
inside_format_bounds = true;
} else if code == "^XZ" {
inside_format_bounds = false;
}
if !matches!(code.as_str(), "^XA" | "^XZ") {
has_printable = true;
}
if known.contains(code)
&& let Some(cmd) = tables.cmd_by_code(code)
{
let producer_key = cmd.codes.first().map(String::as_str).unwrap_or(code);
if cmd.opens_field {
seen_field_codes.clear();
}
if cmd.effects.is_some()
&& let Some(&consumed) = label_state.producer_consumed.get(producer_key)
&& !consumed
{
issues.push(
diagnostic_with_spec_severity(
codes::REDUNDANT_STATE,
format!(
"{} overrides a previous {} without any command consuming the earlier value",
code, producer_key
),
dspan,
)
.with_context(ctx!("command" => code, "producer" => producer_key)),
);
}
if cmd.effects.is_some() {
label_state.record_producer(producer_key, node_idx);
label_state
.value_state
.apply_producer(code, args, &device_state);
}
if !cmd.field_data && (args.len() as u32) > cmd.arity {
issues.push(
diagnostic_with_spec_severity(
codes::ARITY,
format!(
"{} has too many arguments ({}>{})",
code,
args.len(),
cmd.arity
),
dspan,
)
.with_context(ctx!(
"command" => code,
"arity" => cmd.arity.to_string(),
"actual" => args.len().to_string(),
)),
);
}
let cmd_ctx = CommandCtx {
code,
args,
cmd,
span: dspan,
node_idx,
};
let vctx = ValidationContext {
profile,
label_nodes: &label.nodes,
label_codes: &label_codes,
device_state: &device_state,
};
validate_command_args(&cmd_ctx, &vctx, &label_state, &mut issues);
validate_command_constraints(
&cmd_ctx,
&vctx,
&seen_codes,
&seen_field_codes,
field_id_by_node[node_idx].and_then(|fid| field_codes.get(fid)),
&mut issues,
);
validate_semantic_state(&cmd_ctx, &vctx, &mut label_state, &mut issues);
if let Some(gates) = &cmd.printer_gates
&& let Some(p) = profile
&& let Some(ref features) = p.features
{
for gate in gates {
if let Some(false) = zpl_toolchain_profile::resolve_gate(features, gate)
{
issues.push(
diagnostic_with_spec_severity(
codes::PRINTER_GATE,
format!(
"{} requires '{}' capability not available in profile '{}'",
code, gate, &p.id
),
dspan,
)
.with_context(ctx!(
"command" => code,
"gate" => gate.clone(),
"level" => "command",
"profile" => &p.id,
)),
);
}
}
}
if inside_format_bounds && !matches!(code.as_str(), "^XA" | "^XZ") && {
let allowed_inside =
cmd.placement.as_ref().and_then(|p| p.allowed_inside_label);
match allowed_inside {
Some(flag) => !flag,
None => matches!(cmd.plane, Some(Plane::Host | Plane::Device)),
}
} {
let plane = match cmd.plane {
Some(Plane::Format) => "format",
Some(Plane::Device) => "device",
Some(Plane::Host) => "host",
Some(Plane::Config) => "config",
None => "unknown",
};
issues.push(
diagnostic_with_spec_severity(
codes::HOST_COMMAND_IN_LABEL,
format!("{} should not appear inside a label (^XA/^XZ)", code),
dspan,
)
.with_context(ctx!("command" => code, "plane" => plane)),
);
}
if !inside_format_bounds
&& !matches!(code.as_str(), "^XA" | "^XZ")
&& cmd.placement.as_ref().and_then(|p| p.allowed_outside_label)
== Some(false)
{
let plane = match cmd.plane {
Some(Plane::Format) => "format",
Some(Plane::Device) => "device",
Some(Plane::Host) => "host",
Some(Plane::Config) => "config",
None => "unknown",
};
issues.push(
diagnostic_with_spec_severity(
codes::HOST_COMMAND_IN_LABEL,
format!("{} should not appear outside a label (^XA/^XZ)", code),
dspan,
)
.with_context(ctx!("command" => code, "plane" => plane)),
);
}
field_tracker.process_command(&cmd_ctx, &vctx, &label_state, &mut issues);
if cmd.scope == Some(CommandScope::Session) {
if code == "^MU" {
device_state.apply_mu(args);
}
device_state
.session_producers
.insert(producer_key.to_string());
}
if cmd.closes_field {
seen_field_codes.clear();
} else if field_tracker.open || cmd.opens_field {
seen_field_codes.insert(code.as_str());
}
}
seen_codes.insert(code.as_str());
if field_tracker.open {
seen_field_codes.insert(code.as_str());
}
}
}
if field_tracker.open {
let dspan = label.nodes.last().and_then(|n| {
if let crate::grammar::ast::Node::Command { span, .. } = n {
Some(*span)
} else {
None
}
});
let mut diag = diagnostic_with_spec_severity(
codes::FIELD_NOT_CLOSED,
"field opened but never closed with ^FS before end of label",
dspan,
);
if let Some(crate::grammar::ast::Node::Command { code, .. }) =
label.nodes.get(field_tracker.start_idx)
{
diag = diag.with_context(ctx!("command" => code));
}
issues.push(diag);
}
{
let label_span = label.nodes.first().and_then(|n| {
if let crate::grammar::ast::Node::Command { span, .. } = n {
Some(*span)
} else {
None
}
});
let vctx = ValidationContext {
profile,
label_nodes: &label.nodes,
label_codes: &label_codes,
device_state: &device_state,
};
validate_preflight(&vctx, &label_state, label_span, &mut issues);
}
resolved_labels.push(ResolvedLabelState {
values: label_state.value_state.clone(),
effective_width: label_state.effective_width,
effective_height: label_state.effective_height,
});
if !has_printable {
let dspan = label.nodes.first().and_then(|n| {
if let crate::grammar::ast::Node::Command { span, .. } = n {
Some(*span)
} else {
None
}
});
issues.push(diagnostic_with_spec_severity(
codes::EMPTY_LABEL,
"Empty label (no commands between ^XA and ^XZ)",
dspan,
));
}
}
let ok = !issues.iter().any(|d| matches!(d.severity, Severity::Error));
ValidationResult {
ok,
issues,
resolved_labels,
}
}
pub fn validate(ast: &Ast, tables: &ParserTables) -> ValidationResult {
validate_with_profile(ast, tables, None)
}
#[cfg(test)]
mod tests {
use super::*;
use zpl_toolchain_profile::{Features, Memory, Profile};
#[test]
fn profile_predicate_id_matches() {
let p = Profile {
id: "zebra-xi4-203".into(),
schema_version: "1.0".into(),
dpi: 203,
page: None,
speed_range: None,
darkness_range: None,
features: None,
media: None,
memory: None,
};
assert!(profile_predicate_matches(
"profile:id:zebra-xi4-203",
Some(&p)
));
assert!(profile_predicate_matches(
"profile:id:zebra-xi4-203|other",
Some(&p)
));
assert!(!profile_predicate_matches("profile:id:other-id", Some(&p)));
assert!(!profile_predicate_matches("profile:id:zebra-xi4-203", None));
}
#[test]
fn profile_predicate_dpi_matches() {
let p = Profile {
id: "test".into(),
schema_version: "1.0".into(),
dpi: 600,
page: None,
speed_range: None,
darkness_range: None,
features: None,
media: None,
memory: None,
};
assert!(profile_predicate_matches("profile:dpi:600", Some(&p)));
assert!(profile_predicate_matches("profile:dpi:203|600", Some(&p)));
assert!(!profile_predicate_matches("profile:dpi:203", Some(&p)));
}
#[test]
fn profile_predicate_feature_matches() {
let p = Profile {
id: "test".into(),
schema_version: "1.0".into(),
dpi: 203,
page: None,
speed_range: None,
darkness_range: None,
features: Some(Features {
cutter: Some(true),
rfid: Some(false),
..Default::default()
}),
media: None,
memory: None,
};
assert!(profile_predicate_matches(
"profile:feature:cutter",
Some(&p)
));
assert!(profile_predicate_matches(
"profile:featureMissing:rfid",
Some(&p)
));
assert!(!profile_predicate_matches("profile:feature:rfid", Some(&p)));
assert!(!profile_predicate_matches(
"profile:featureMissing:cutter",
Some(&p)
));
}
#[test]
fn profile_predicate_firmware_prefix() {
let p = Profile {
id: "test".into(),
schema_version: "1.0".into(),
dpi: 203,
page: None,
speed_range: None,
darkness_range: None,
features: None,
media: None,
memory: Some(Memory {
ram_kb: None,
flash_kb: None,
firmware_version: Some("V60.19.15Z".into()),
}),
};
assert!(profile_predicate_matches("profile:firmware:V60", Some(&p)));
assert!(profile_predicate_matches(
"profile:firmware:V60.19",
Some(&p)
));
assert!(!profile_predicate_matches("profile:firmware:V50", Some(&p)));
}
#[test]
fn firmware_version_gte_ordering() {
assert!(firmware_version_gte("V60.19.15Z", "V60.14"));
assert!(firmware_version_gte("V60.19.15Z", "V60.19"));
assert!(firmware_version_gte("V60.14.0", "V60.14"));
assert!(!firmware_version_gte("V60.13.9", "V60.14"));
assert!(!firmware_version_gte("V50.20.0", "V60.14"));
assert!(firmware_version_gte("X60.16.0", "V60.16"));
}
#[test]
fn any_target_in_set_trims_whitespace() {
let seen = HashSet::from(["^FD", "^FV"]);
assert!(any_target_in_set("^FD | ^FO", &seen));
assert!(any_target_in_set(" ^FV ", &seen));
assert!(!any_target_in_set(" | ", &seen));
}
}