mod common;
use common::{
extract_codes, extract_diag_codes, extract_label_codes, find_args, is_severity_error,
is_severity_info, is_severity_warn,
};
use zpl_toolchain_core::grammar::ast::{Node, Presence};
use zpl_toolchain_core::grammar::diag::Span;
use zpl_toolchain_core::grammar::parser::{parse_str, parse_with_tables};
use zpl_toolchain_diagnostics::{Severity, codes};
fn tables_with_spacing_command(
code: &str,
no_space_after_opcode: bool,
) -> zpl_toolchain_spec_tables::ParserTables {
zpl_toolchain_spec_tables::ParserTables::new(
"1.0.0".into(),
"0.3.0".into(),
vec![zpl_toolchain_spec_tables::CommandEntry {
codes: vec![code.to_string()],
arity: 1,
raw_payload: false,
field_data: false,
opens_field: false,
closes_field: false,
hex_escape_modifier: false,
field_number: false,
serialization: false,
requires_field: false,
signature: Some(zpl_toolchain_spec_tables::Signature {
params: vec!["n".to_string()],
joiner: ",".to_string(),
no_space_after_opcode,
allow_empty_trailing: true,
split_rule: None,
}),
args: Some(vec![zpl_toolchain_spec_tables::ArgUnion::Single(Box::new(
zpl_toolchain_spec_tables::Arg {
name: Some("num".to_string()),
key: Some("n".to_string()),
r#type: "int".to_string(),
unit: None,
range: None,
min_length: None,
max_length: None,
optional: false,
presence: None,
default: None,
default_by_dpi: None,
default_from: None,
profile_constraint: None,
range_when: None,
rounding_policy: None,
rounding_policy_when: None,
resource: None,
r#enum: None,
},
))]),
constraints: None,
effects: None,
plane: None,
scope: None,
placement: None,
name: None,
category: None,
since: None,
deprecated: None,
deprecated_since: None,
stability: None,
composites: None,
defaults: None,
units: None,
printer_gates: None,
signature_overrides: None,
field_data_rules: None,
examples: None,
}],
None,
)
}
#[test]
fn empty_input_no_labels() {
let result = parse_str("");
assert_eq!(
result.ast.labels.len(),
0,
"empty input should produce no labels"
);
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_NO_LABELS),
"should emit no-labels diagnostic"
);
}
#[test]
fn single_label_xa_xz() {
let result = parse_str("^XA^XZ");
assert_eq!(result.ast.labels.len(), 1, "should produce 1 label");
let codes = extract_codes(&result);
assert_eq!(codes, vec!["^XA", "^XZ"]);
}
#[test]
fn multiple_labels() {
let result = parse_str("^XA^XZ^XA^XZ");
assert_eq!(result.ast.labels.len(), 2, "should produce 2 labels");
assert_eq!(extract_label_codes(&result, 0), vec!["^XA", "^XZ"]);
assert_eq!(extract_label_codes(&result, 1), vec!["^XA", "^XZ"]);
}
#[test]
fn nested_xa_flushes_label() {
let result = parse_str("^XA^FO10,10^XA^XZ");
assert_eq!(
result.ast.labels.len(),
2,
"nested ^XA should flush to 2 labels"
);
let first_codes = extract_label_codes(&result, 0);
assert!(
first_codes.contains(&"^XA".to_string()),
"first label should have ^XA"
);
assert!(
first_codes.contains(&"^FO".to_string()),
"first label should have ^FO"
);
let second_codes = extract_label_codes(&result, 1);
assert!(
second_codes.contains(&"^XA".to_string()),
"second label should have ^XA"
);
assert!(
second_codes.contains(&"^XZ".to_string()),
"second label should have ^XZ"
);
}
#[test]
fn heuristic_two_char_code() {
let result = parse_str("^XA^FO10,10^XZ");
let codes = extract_codes(&result);
assert!(
codes.contains(&"^FO".to_string()),
"heuristic should recognize ^FO as 2-char code"
);
}
#[test]
fn heuristic_single_char_code() {
let result = parse_str("^XA^A0N,22,26^XZ");
let codes = extract_codes(&result);
assert!(
codes.contains(&"^A0".to_string()),
"heuristic sees ^A0 as 2-char code (no tables to identify ^A): got {:?}",
codes,
);
}
#[test]
fn tilde_command() {
let result = parse_str("^XA~JA^XZ");
let codes = extract_codes(&result);
assert!(
codes.contains(&"~JA".to_string()),
"tilde command ~JA should be recognized: {:?}",
codes
);
}
#[test]
fn trie_longest_match() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^BY3,2,50^BCN,142,N,N,N^XZ", Some(tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"^BY".to_string()),
"trie should recognize ^BY: {:?}",
codes
);
assert!(
codes.contains(&"^BC".to_string()),
"trie should recognize ^BC separately: {:?}",
codes
);
}
#[test]
fn unknown_command_warning() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^QQ99^XZ", Some(tables));
let has_warning = result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_UNKNOWN_COMMAND && is_severity_warn(&d.severity));
assert!(
has_warning,
"unknown command should produce a warning: {:?}",
extract_diag_codes(&result)
);
}
#[test]
fn comma_separated_args() {
let result = parse_str("^XA^FO100,200^XZ");
let args = find_args(&result, "^FO");
assert_eq!(args.len(), 2, "^FO should have 2 args");
assert_eq!(args[0].value.as_deref(), Some("100"));
assert_eq!(args[1].value.as_deref(), Some("200"));
assert!(matches!(args[0].presence, Presence::Value));
assert!(matches!(args[1].presence, Presence::Value));
}
#[test]
fn empty_trailing_args() {
let result = parse_str("^XA^BC,,,,,^XZ");
let args = find_args(&result, "^BC");
assert_eq!(
args.len(),
6,
"^BC,,,,, should produce exactly 6 empty args, got {}",
args.len()
);
for (i, arg) in args.iter().enumerate() {
assert!(
matches!(arg.presence, Presence::Empty),
"arg {} should be Empty, got {:?}",
i,
arg.presence,
);
}
}
#[test]
fn mixed_present_empty_args() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^BCN,142,,N,^XZ", Some(tables));
let args = find_args(&result, "^BC");
assert_eq!(
args.len(),
6,
"^BC should have exactly 6 args, got {}: {:?}",
args.len(),
args
);
assert!(
matches!(args[0].presence, Presence::Value),
"arg 0 should be Value"
);
assert_eq!(args[0].value.as_deref(), Some("N"));
assert!(
matches!(args[1].presence, Presence::Value),
"arg 1 should be Value"
);
assert_eq!(args[1].value.as_deref(), Some("142"));
assert!(
matches!(args[2].presence, Presence::Empty),
"arg 2 should be Empty"
);
assert!(
matches!(args[3].presence, Presence::Value),
"arg 3 should be Value"
);
assert_eq!(args[3].value.as_deref(), Some("N"));
assert!(
matches!(args[4].presence, Presence::Empty),
"arg 4 should be Empty"
);
assert!(
matches!(args[5].presence, Presence::Empty),
"arg 5 should be Empty"
);
}
#[test]
fn signature_no_space_after_opcode_rejects_space() {
let tables = tables_with_spacing_command("^ZZN", true);
let result = parse_with_tables("^XA^ZZN 5^XZ", Some(&tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_INVALID_COMMAND
&& d.message.contains("should not include a space")),
"expected parser spacing diagnostic when noSpaceAfterOpcode=true: {:?}",
result.diagnostics
);
}
#[test]
fn signature_space_after_opcode_required() {
let tables = tables_with_spacing_command("^ZZS", false);
let result = parse_with_tables("^XA^ZZS5^XZ", Some(&tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_INVALID_COMMAND && d.message.contains("expects a space")),
"expected parser spacing diagnostic when noSpaceAfterOpcode=false: {:?}",
result.diagnostics
);
}
#[test]
fn signature_space_after_opcode_allows_space() {
let tables = tables_with_spacing_command("^ZZS", false);
let result = parse_with_tables("^XA^ZZS 5^XZ", Some(&tables));
assert!(
!result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_INVALID_COMMAND && d.message.contains("expects a space")),
"space should be accepted when noSpaceAfterOpcode=false: {:?}",
result.diagnostics
);
}
#[test]
fn diag_parser_1302_non_ascii_arg() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^CCé^XZ", Some(tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_NON_ASCII_ARG),
"non-ASCII arg for ^CC should emit ZPL.PARSER.1302: {:?}",
result.diagnostics
);
}
#[test]
fn special_a_font_orientation_split() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^A0N,22,26^XZ", Some(tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"^A".to_string()),
"tables should recognize ^A: {:?}",
codes
);
let args = find_args(&result, "^A");
assert!(
args.len() >= 4,
"^A should have at least 4 args (f, o, h, w), got {}: {:?}",
args.len(),
args
);
assert_eq!(args[0].value.as_deref(), Some("0"), "font");
assert_eq!(args[1].value.as_deref(), Some("N"), "orientation");
assert_eq!(args[2].value.as_deref(), Some("22"), "height");
assert_eq!(args[3].value.as_deref(), Some("26"), "width");
}
#[test]
fn field_data_preserved() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDHello World^FS^XZ", Some(tables));
let codes = extract_codes(&result);
assert!(codes.contains(&"^FD".to_string()), "should have ^FD");
assert!(codes.contains(&"^FS".to_string()), "should have ^FS");
let fd_args = find_args(&result, "^FD");
assert_eq!(
fd_args[0].value.as_deref(),
Some("Hello World"),
"^FD first arg should be exactly 'Hello World': {:?}",
fd_args,
);
}
#[test]
fn field_data_with_commas() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDPrice: $1,234.56^FS^XZ", Some(tables));
let fd_args = find_args(&result, "^FD");
assert_eq!(
fd_args.len(),
1,
"^FD should have exactly 1 arg (unsplit field data): {:?}",
fd_args
);
assert_eq!(
fd_args[0].value.as_deref(),
Some("Price: $1,234.56"),
"^FD arg should preserve the full field data including commas: {:?}",
fd_args,
);
}
#[test]
fn field_data_fv_also_works() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FVVariable data^FS^XZ", Some(tables));
let codes = extract_codes(&result);
assert!(codes.contains(&"^FV".to_string()), "should recognize ^FV");
assert!(codes.contains(&"^FS".to_string()), "should have ^FS");
let fv_args = find_args(&result, "^FV");
assert_eq!(
fv_args[0].value.as_deref(),
Some("Variable data"),
"^FV first arg should be exactly 'Variable data': {:?}",
fv_args,
);
}
#[test]
fn spans_present_on_all_nodes() {
let result = parse_str("^XA^FO10,20^XZ");
for label in &result.ast.labels {
for node in &label.nodes {
let span = match node {
Node::Command { span, .. } => span,
Node::FieldData { span, .. } => span,
Node::RawData { span, .. } => span,
Node::Trivia { span, .. } => span,
_ => unreachable!("unknown Node variant"),
};
assert!(
span.end >= span.start,
"span end should be >= start: {:?}",
span
);
}
}
}
#[test]
fn span_positions_correct() {
let input = "^XA^FO10,20^XZ";
let result = parse_str(input);
let xa_span = result.ast.labels[0].nodes.iter().find_map(|n| match n {
Node::Command { code, span, .. } if code == "^XA" => Some(*span),
_ => None,
});
assert_eq!(xa_span, Some(Span::new(0, 3)), "^XA span");
let fo_span = result.ast.labels[0].nodes.iter().find_map(|n| match n {
Node::Command { code, span, .. } if code == "^FO" => Some(*span),
_ => None,
});
assert_eq!(fo_span, Some(Span::new(3, 11)), "^FO span");
let xz_span = result.ast.labels[0].nodes.iter().find_map(|n| match n {
Node::Command { code, span, .. } if code == "^XZ" => Some(*span),
_ => None,
});
assert_eq!(xz_span, Some(Span::new(11, 14)), "^XZ span");
}
#[test]
fn comments_preserved_as_trivia() {
let result = parse_str("^XA;this is a comment\n^FO10,20^XZ");
let trivia_texts: Vec<String> = result
.ast
.labels
.iter()
.flat_map(|l| l.nodes.iter())
.filter_map(|n| match n {
Node::Trivia { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
assert!(
trivia_texts.iter().any(|t| t.contains("this is a comment")),
"comment should be preserved as Trivia: {:?}",
trivia_texts,
);
}
#[test]
fn missing_xz_terminator() {
let result = parse_str("^XA^FO10,10");
let diag_codes = extract_diag_codes(&result);
assert!(
diag_codes.contains(&codes::PARSER_MISSING_TERMINATOR.to_string()),
"should emit missing-^XZ diagnostic: {:?}",
diag_codes,
);
let diag = result
.diagnostics
.iter()
.find(|d| d.id == codes::PARSER_MISSING_TERMINATOR)
.unwrap();
assert!(
is_severity_error(&diag.severity),
"1102 should be Error severity"
);
}
#[test]
fn missing_fs_before_xz() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDHello^XZ", Some(tables));
let diag_codes = extract_diag_codes(&result);
let has_fs_diag = diag_codes.iter().any(|c| {
c == codes::PARSER_MISSING_FIELD_SEPARATOR || c == codes::PARSER_FIELD_DATA_INTERRUPTED
});
assert!(
has_fs_diag,
"should emit field-data diagnostic (1202 or 1203): {:?}",
diag_codes,
);
}
#[test]
fn no_labels_info() {
let result = parse_str("");
let diag = result
.diagnostics
.iter()
.find(|d| d.id == codes::PARSER_NO_LABELS);
assert!(
diag.is_some(),
"empty input should emit 0001 info diagnostic"
);
assert!(
is_severity_info(&diag.unwrap().severity),
"0001 should be Info severity"
);
}
#[test]
fn diagnostic_has_span() {
let result = parse_str("^XA^FO10,10");
let diag_1102 = result
.diagnostics
.iter()
.find(|d| d.id == codes::PARSER_MISSING_TERMINATOR);
assert!(diag_1102.is_some());
let result2 = parse_str("^^");
let diag_1001 = result2
.diagnostics
.iter()
.find(|d| d.id == codes::PARSER_INVALID_COMMAND);
assert!(diag_1001.is_some(), "^^ should emit 1001");
assert!(
diag_1001.unwrap().span.is_some(),
"1001 for invalid leader should have a span"
);
}
#[test]
fn only_leaders_no_code() {
let result = parse_str("^^");
let diag_codes = extract_diag_codes(&result);
assert!(
diag_codes
.iter()
.any(|c| c == codes::PARSER_INVALID_COMMAND),
"leader-only input should produce error: {:?}",
diag_codes,
);
}
#[test]
fn label_with_all_empty_args() {
let result = parse_str("^XA^BC,,,,,,^XZ");
let args = find_args(&result, "^BC");
assert_eq!(
args.len(),
7,
"^BC,,,,,, should produce exactly 7 empty args, got {}",
args.len()
);
for arg in &args {
assert!(
matches!(arg.presence, Presence::Empty),
"all args should be Empty"
);
}
}
#[test]
fn utf8_in_field_data() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDHéllo Wörld^FS^XZ", Some(tables));
let fd_args = find_args(&result, "^FD");
assert_eq!(
fd_args[0].value.as_deref(),
Some("Héllo Wörld"),
"^FD first arg should preserve UTF-8 content exactly: {:?}",
fd_args,
);
}
#[test]
fn multiple_fields_in_label() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FO10,10^FDLine1^FS^FO10,50^FDLine2^FS^XZ", Some(tables));
assert_eq!(result.ast.labels.len(), 1, "should be 1 label");
let codes = extract_codes(&result);
let fd_count = codes.iter().filter(|c| c.as_str() == "^FD").count();
let fs_count = codes.iter().filter(|c| c.as_str() == "^FS").count();
assert_eq!(fd_count, 2, "should have 2 ^FD commands");
assert_eq!(fs_count, 2, "should have 2 ^FS commands");
let fd_nodes: Vec<_> = result.ast.labels[0]
.nodes
.iter()
.filter(|n| matches!(n, Node::Command { code, .. } if code == "^FD"))
.collect();
assert_eq!(fd_nodes.len(), 2);
}
#[test]
fn field_data_interrupted_emits_1203() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDHello^FO10,10^FS^XZ", Some(tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_FIELD_DATA_INTERRUPTED),
"non-^FS command interrupting field data should emit ZPL.PARSER.1203: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn field_data_at_eof_emits_1202() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDunterminated", Some(tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_MISSING_FIELD_SEPARATOR),
"field data at EOF without ^FS should emit ZPL.PARSER.1202: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn empty_field_data() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FD^FS^XZ", Some(tables));
let fd_args = find_args(&result, "^FD");
assert!(
fd_args.is_empty(),
"^FD with no content should have no args: {:?}",
fd_args
);
assert!(
!result
.diagnostics
.iter()
.any(|d| matches!(d.severity, Severity::Error)),
"^FD^FS should not produce errors: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn known_set_fallback_without_trie() {
let mut tables = common::TABLES.clone();
tables.opcode_trie = None;
let result = parse_with_tables("^XA^FO10,10^XZ", Some(&tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"^FO".to_string()),
"known-set should recognize ^FO without trie: {:?}",
codes
);
}
#[test]
fn diagnostic_code_1002_distinct_from_1001() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^QQ99^XZ", Some(tables));
let has_1002 = result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_UNKNOWN_COMMAND);
let has_1001 = result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_INVALID_COMMAND);
assert!(has_1002, "unknown command should produce ZPL.PARSER.1002");
assert!(
!has_1001,
"unknown command should NOT produce ZPL.PARSER.1001 (that's for invalid syntax)"
);
}
#[test]
fn stray_content_warning() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA\n^FDHello^FS\nstray text here\n^XZ", Some(tables));
let stray = result
.diagnostics
.iter()
.filter(|d| d.id == codes::PARSER_STRAY_CONTENT)
.collect::<Vec<_>>();
assert!(
!stray.is_empty(),
"stray content should produce ZPL.PARSER.1301: {:?}",
extract_diag_codes(&result)
);
assert!(
is_severity_warn(&stray[0].severity),
"stray content should be a warning"
);
assert!(
stray[0].span.is_some(),
"stray content diagnostic should have a span"
);
}
#[test]
fn no_stray_warning_for_whitespace_between_commands() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA\n ^FO50,100\n ^FDHello^FS\n^XZ", Some(tables));
let stray = result
.diagnostics
.iter()
.filter(|d| d.id == codes::PARSER_STRAY_CONTENT)
.collect::<Vec<_>>();
assert!(
stray.is_empty(),
"whitespace between commands should not produce stray warnings: {:?}",
stray
);
}
#[test]
fn stray_content_coalesces_adjacent_tokens() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA\nhello,world\n^XZ", Some(tables));
let stray = result
.diagnostics
.iter()
.filter(|d| d.id == codes::PARSER_STRAY_CONTENT)
.collect::<Vec<_>>();
assert_eq!(
stray.len(),
1,
"adjacent stray tokens should coalesce into one diagnostic, got {:?}",
stray
);
}
#[test]
fn recovery_after_invalid_leader() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^^FDHello^FS^XZ", Some(tables));
let has_invalid = result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_INVALID_COMMAND);
assert!(
has_invalid,
"bare ^^ should produce an invalid command error"
);
let has_fd = result.ast.labels.iter().any(|l| {
l.nodes.iter().any(|n| match n {
zpl_toolchain_core::grammar::ast::Node::Command { code, .. } => code == "^FD",
_ => false,
})
});
assert!(has_fd, "parser should recover and parse ^FD after ^^");
}
#[test]
fn diag_parser_0001_no_labels() {
let result = parse_str(" \t\n ");
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_NO_LABELS),
"whitespace-only input should emit no-labels diagnostic: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn diag_parser_1001_leader_then_eof() {
let result = parse_str("^XA^");
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_INVALID_COMMAND),
"leader at EOF should emit 1001: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn diag_parser_1002_unknown_command() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^QQ99^XZ", Some(tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_UNKNOWN_COMMAND),
"unknown command should emit 1002: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn diag_parser_1102_missing_xz() {
let result = parse_str("^XA^FO10,10^FDHello^FS");
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_MISSING_TERMINATOR),
"missing ^XZ should emit 1102: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn diag_parser_1202_missing_fs_eof() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDdata without separator", Some(tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_MISSING_FIELD_SEPARATOR),
"field data at EOF without ^FS should emit 1202: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn diag_parser_1203_field_data_interrupted() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FDHello^CF0,30^FS^XZ", Some(tables));
assert!(
result
.diagnostics
.iter()
.any(|d| d.id == codes::PARSER_FIELD_DATA_INTERRUPTED),
"command interrupting field data should emit 1203: {:?}",
extract_diag_codes(&result),
);
}
#[test]
fn raw_payload_gf_inline_no_extra_node() {
let tables = &*common::TABLES;
let input = "^XA^GFA,8,8,1,FFAA5500FFAA5500^FS^XZ";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
let has_raw = nodes.iter().any(|n| matches!(n, Node::RawData { .. }));
assert!(
!has_raw,
"Fully-inline data should NOT produce a RawData node, got: {:?}",
nodes
);
let gf = nodes
.iter()
.find(|n| matches!(n, Node::Command { code, .. } if code == "^GF"))
.unwrap();
if let Node::Command { args, .. } = gf {
assert_eq!(
args[4].value.as_deref(),
Some("FFAA5500FFAA5500"),
"data should be in args[4]"
);
}
}
#[test]
fn raw_payload_gf_data_preserved() {
let tables = &*common::TABLES;
let input = "^XA^GFA,4,4,1,AABBCCDD^XZ";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
let has_gf = nodes
.iter()
.any(|n| matches!(n, Node::Command { code, .. } if code == "^GF"));
assert!(has_gf, "Expected ^GF command node");
}
#[test]
fn raw_payload_gf_multiline() {
let tables = &*common::TABLES;
let input = "^XA^GFA,8,8,1\nFFAA5500\nFFAA5500\n^FS^XZ";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
let raw = nodes.iter().find_map(|n| {
if let Node::RawData { data, .. } = n {
data.clone()
} else {
None
}
});
assert!(
raw.is_some(),
"Expected raw data content for multi-line ^GF, got nodes: {:?}",
nodes
);
let data = raw.unwrap();
assert!(
data.contains("FFAA5500"),
"Raw data should contain hex payload, got: {}",
data
);
}
#[test]
fn raw_payload_empty_data() {
let tables = &*common::TABLES;
let input = "^XA^GFA,0,0,1,^XZ";
let r = parse_with_tables(input, Some(tables));
assert!(
!r.ast.labels.is_empty(),
"Should produce at least one label"
);
}
#[test]
fn raw_payload_dg_basic() {
let tables = &*common::TABLES;
let input = "^XA~DGR:LOGO.GRF,4,1\nFFAA5500\n^XZ";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
let has_raw = nodes
.iter()
.any(|n| matches!(n, Node::RawData { command, .. } if command == "~DG"));
assert!(
has_raw,
"Expected RawData node for ~DG payload, got nodes: {:?}",
nodes
);
}
#[test]
fn raw_payload_span_tracking() {
let tables = &*common::TABLES;
let input = "^XA^GFA,4,4,1\nAABBCCDD\n^XZ";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
for node in nodes {
if let Node::RawData { span, .. } = node {
assert!(span.end >= span.start, "span end >= start: {:?}", span);
}
}
}
#[test]
fn raw_payload_at_eof() {
let tables = &*common::TABLES;
let input = "^XA^GFA,4,4,1\nAABBCCDD";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
let has_raw = nodes.iter().any(|n| matches!(n, Node::RawData { .. }));
assert!(
has_raw,
"Expected RawData node at EOF, got nodes: {:?}",
nodes
);
}
#[test]
fn raw_payload_inline_data_then_fs() {
let tables = &*common::TABLES;
let input = "^XA^GFA,8,8,1,FFAA5500FFAA5500^FS^XZ";
let r = parse_with_tables(input, Some(tables));
let gf_args = find_args(&r, "^GF");
assert!(!gf_args.is_empty(), "^GF should have args");
let data_arg = gf_args.iter().find(|a| a.key.as_deref() == Some("data"));
assert!(
data_arg.is_some(),
"^GF should have a 'data' arg: {:?}",
gf_args
);
assert_eq!(
data_arg.unwrap().value.as_deref(),
Some("FFAA5500FFAA5500"),
"data arg should contain hex payload"
);
}
#[test]
fn raw_payload_no_false_positives_non_raw_command() {
let tables = &*common::TABLES;
let input = "^XA^FO10,20^XZ";
let r = parse_with_tables(input, Some(tables));
let nodes = &r.ast.labels[0].nodes;
let has_raw = nodes.iter().any(|n| matches!(n, Node::RawData { .. }));
assert!(
!has_raw,
"Non-raw-payload commands should NOT produce RawData nodes"
);
}
#[test]
fn usps_sample_no_errors() {
let tables = &*common::TABLES;
let mut root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
root.pop();
root.pop();
let sample_path = root.join("samples/usps_surepost_sample.zpl");
let input = std::fs::read_to_string(&sample_path).expect("missing sample file");
let result = parse_with_tables(&input, Some(tables));
assert!(
!result.ast.labels.is_empty(),
"sample should produce at least one label"
);
let errors: Vec<_> = result
.diagnostics
.iter()
.filter(|d| is_severity_error(&d.severity))
.collect();
assert!(
errors.is_empty(),
"sample file should have no error-level diagnostics, got: {:?}",
errors,
);
}
#[test]
fn prefix_change_cc() {
let tables = &*common::TABLES;
let input = "^XA^CC+\n+FO10,10+FDHello+FS+XZ";
let result = parse_with_tables(input, Some(tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"^XA".to_string()),
"should have ^XA: {:?}",
codes
);
assert!(
codes.contains(&"^CC".to_string()),
"should have ^CC: {:?}",
codes
);
assert!(
codes.contains(&"^FO".to_string()),
"should have ^FO (via +FO): {:?}",
codes
);
assert!(
codes.contains(&"^FD".to_string()),
"should have ^FD (via +FD): {:?}",
codes
);
assert!(
codes.contains(&"^FS".to_string()),
"should have ^FS (via +FS): {:?}",
codes
);
assert!(
codes.contains(&"^XZ".to_string()),
"should have ^XZ (via +XZ): {:?}",
codes
);
assert_eq!(result.ast.labels.len(), 1, "should produce 1 label");
let fo_args = find_args(&result, "^FO");
assert!(fo_args.len() >= 2, "^FO should have at least 2 args");
assert_eq!(fo_args[0].value.as_deref(), Some("10"));
assert_eq!(fo_args[1].value.as_deref(), Some("10"));
}
#[test]
fn prefix_change_ct() {
let tables = &*common::TABLES;
let input = "^XA~CT#^FO10,10^FDtest^FS^XZ";
let result = parse_with_tables(input, Some(tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"~CT".to_string()),
"should have ~CT: {:?}",
codes
);
assert!(
codes.contains(&"^FO".to_string()),
"should have ^FO: {:?}",
codes
);
assert_eq!(result.ast.labels.len(), 1, "should produce 1 label");
}
#[test]
fn delimiter_change_cd() {
let tables = &*common::TABLES;
let input = "^XA^CD|^FO10|20^FS^XZ";
let result = parse_with_tables(input, Some(tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"^CD".to_string()),
"should have ^CD: {:?}",
codes
);
assert!(
codes.contains(&"^FO".to_string()),
"should have ^FO: {:?}",
codes
);
let fo_args = find_args(&result, "^FO");
assert!(
fo_args.len() >= 2,
"^FO should have at least 2 args with | delimiter"
);
assert_eq!(fo_args[0].value.as_deref(), Some("10"), "first arg");
assert_eq!(fo_args[1].value.as_deref(), Some("20"), "second arg");
}
#[test]
fn prefix_and_delimiter_regression() {
let tables = &*common::TABLES;
let input = "^XA^FO10,20^FS^XZ";
let result = parse_with_tables(input, Some(tables));
let codes = extract_codes(&result);
assert_eq!(codes, vec!["^XA", "^FO", "^FS", "^XZ"]);
let fo_args = find_args(&result, "^FO");
assert!(fo_args.len() >= 2, "^FO should have at least x,y args");
assert_eq!(fo_args[0].value.as_deref(), Some("10"));
assert_eq!(fo_args[1].value.as_deref(), Some("20"));
assert_eq!(result.ast.labels.len(), 1);
let errors: Vec<_> = result
.diagnostics
.iter()
.filter(|d| matches!(d.severity, Severity::Error))
.collect();
assert!(
errors.is_empty(),
"regression: should have no errors: {:?}",
errors
);
}
#[test]
fn prefix_change_cc_with_tilde_variant() {
let tables = &*common::TABLES;
let input = "^XA~CC++FO10,10+FDHello+FS+XZ";
let result = parse_with_tables(input, Some(tables));
let codes = extract_codes(&result);
assert!(
codes.contains(&"~CC".to_string()),
"should have ~CC: {:?}",
codes
);
assert!(
codes.contains(&"^FO".to_string()),
"should have ^FO (via +FO): {:?}",
codes
);
assert!(
codes.contains(&"^FD".to_string()),
"should have ^FD (via +FD): {:?}",
codes
);
assert!(
codes.contains(&"^XZ".to_string()),
"should have ^XZ (via +XZ): {:?}",
codes
);
}
#[test]
fn tokenize_with_config_basic() {
use zpl_toolchain_core::grammar::lexer::{TokKind, tokenize_with_config};
let toks = tokenize_with_config("+XA+FO10,20+XZ", '+', '~', ',');
let leaders: Vec<&str> = toks
.iter()
.filter(|t| t.kind == TokKind::Leader)
.map(|t| t.text)
.collect();
assert_eq!(leaders, vec!["+", "+", "+"], "should recognize + as leader");
let values: Vec<&str> = toks
.iter()
.filter(|t| t.kind == TokKind::Value)
.map(|t| t.text)
.collect();
assert!(values.contains(&"XA"), "should have XA value token");
assert!(values.contains(&"FO10"), "should have FO10 value token");
assert!(values.contains(&"20"), "should have 20 value token");
assert!(values.contains(&"XZ"), "should have XZ value token");
}
#[test]
fn tokenize_default_unchanged() {
use zpl_toolchain_core::grammar::lexer::{TokKind, tokenize};
let toks = tokenize("^XA~JA^XZ");
let leaders: Vec<&str> = toks
.iter()
.filter(|t| t.kind == TokKind::Leader)
.map(|t| t.text)
.collect();
assert_eq!(
leaders,
vec!["^", "~", "^"],
"default tokenize should recognize ^ and ~"
);
}
#[test]
fn context_parser_unknown_command() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^QQ99^XZ", Some(tables));
let d = common::find_diag(&result.diagnostics, codes::PARSER_UNKNOWN_COMMAND);
let ctx = d
.context
.as_ref()
.expect("parser diagnostic should have context");
assert_eq!(ctx.get("command").unwrap(), "^QQ");
}
#[test]
fn context_parser_missing_terminator() {
let tables = &*common::TABLES;
let result = parse_with_tables("^XA^FO10,10^FDHello^FS", Some(tables));
let d = common::find_diag(&result.diagnostics, codes::PARSER_MISSING_TERMINATOR);
let ctx = d
.context
.as_ref()
.expect("parser diagnostic should have context");
assert_eq!(ctx.get("expected").unwrap(), "^XZ");
}