diff --git a/src/error.rs b/src/error.rs index 4706d7b..7caed91 100644 --- a/src/error.rs +++ b/src/error.rs @@ -137,9 +137,11 @@ pub enum ContentError { } impl ContentError { - /// line number is 1-based, for humans. - pub fn to_with_context(self, line: Option<(usize, String)>) -> GerberParserErrorWithContext { - GerberParserErrorWithContext { error: self, line } + pub fn to_with_context(self, context: Option) -> GerberParserErrorWithContext { + GerberParserErrorWithContext { + error: self, + context, + } } } @@ -150,18 +152,36 @@ impl PartialEq for ContentError { } } +/// Where in the source a parse error occurred. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ErrorContext { + /// 1-based line number on which the failing token starts. + pub line: usize, + /// 1-based column offset of the failing token within that line. + pub offset: usize, + /// The token (command block) that failed to parse. + pub token: String, +} + #[derive(Error, Debug, PartialEq)] pub struct GerberParserErrorWithContext { pub error: ContentError, - /// line number is 1-based, for humans. - pub line: Option<(usize, String)>, + pub context: Option, } impl std::fmt::Display for GerberParserErrorWithContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match &self.line { - Some((number, content)) => { - write!(f, "Error: {}\nLine {}: '{}'", self.error, number, content) + match &self.context { + Some(ErrorContext { + line, + offset, + token, + }) => { + write!( + f, + "Error: {}\nLine {}:{}: '{}'", + self.error, line, offset, token + ) } _ => { write!(f, "Error at unspecified line: {}", self.error) diff --git a/src/parser.rs b/src/parser.rs index 5df5332..3b1b626 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,5 +1,5 @@ use crate::document::GerberDoc; -use crate::error::ContentError; +use crate::error::{ContentError, ErrorContext}; use crate::gerber_types::{ Aperture, ApertureAttribute, ApertureFunction, ApertureMacro, CenterLinePrimitive, Circle, CirclePrimitive, Command, CoordinateFormat, DCode, ExtendedCode, FiducialScope, FileAttribute, @@ -22,7 +22,7 @@ use gerber_types::{ use lazy_regex::*; use regex::Regex; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Lines, Read}; +use std::io::{BufReader, Bytes, Read}; use std::iter::FromIterator; use std::str::Chars; use std::sync::LazyLock; @@ -78,37 +78,128 @@ enum ModalOperationMode { Interpolate, } +// 1-based source line and column. Gerber treats newlines as insignificant, so this is +// only carried for error reporting. +#[derive(Copy, Clone)] +struct SourcePos { + line: usize, + offset: usize, +} + struct ParserContext { - line_number: usize, - lines: Lines>, + bytes: Bytes>, + // Position of the byte stream consumed so far. + current: SourcePos, aperture_attributes: HashMap, object_attributes: HashMap, modal_operation: ModalOperationMode, } impl ParserContext { - pub fn new(lines: Lines>) -> ParserContext { + pub fn new(reader: BufReader) -> ParserContext { ParserContext { - line_number: 0, - lines, + bytes: reader.bytes(), + current: SourcePos { line: 1, offset: 0 }, aperture_attributes: HashMap::new(), object_attributes: HashMap::new(), modal_operation: ModalOperationMode::Undefined, } } - pub fn next(&mut self) -> Option> { - let line = self.lines.next(); - if line.is_some() { - self.line_number += 1; + // Yield the next Gerber command block together with the position where it starts. + // Commands are delimited by the end-of-block char `*` (gerber spec 4.1), so a single + // physical line may hold many commands. Extended commands `%...%` may contain several + // `*`-terminated words and span many lines (e.g. an aperture macro), so the whole + // `%...%` span is one block. Outside such a span a newline also ends the block, + // isolating each physical line so stray junk errors on its own rather than merging + // into the next command. Whitespace inside a block is kept (attribute/comment text may + // contain spaces) but trimmed. + pub fn next(&mut self) -> Option> { + let mut buf: Vec = Vec::new(); + let mut in_extended = false; + let mut block_start: Option = None; + // Skip whitespace that leads a block (or follows a newline within one). + let mut skip_ws = true; + + loop { + let byte = match self.bytes.next() { + None => break, // EOF + Some(Ok(byte)) => byte, + Some(Err(e)) => { + return Some(Err(ContentError::IoError(format!( + "IO error on line: {}, error: {}", + self.current.line, e + )))); + } + }; + self.current.offset += 1; + + if byte == b'\r' { + continue; // carriage returns are never significant + } + + if byte == b'\n' { + self.current.line += 1; + self.current.offset = 0; // next byte is column 1 + // Trim trailing whitespace and skip whatever leads the next line. + while buf.last().is_some_and(u8::is_ascii_whitespace) { + buf.pop(); + } + skip_ws = true; + // Inside a `%...%` span newlines are insignificant; elsewhere a newline + // ends the block, isolating each physical line (junk included). + if !in_extended && !buf.is_empty() { + break; + } + continue; + } + + if skip_ws && byte.is_ascii_whitespace() { + continue; + } + skip_ws = false; + + if block_start.is_none() { + block_start = Some(self.current); + } + + match byte { + b'%' if in_extended => { + buf.push(byte); + break; // end of extended `%...%` block + } + b'%' if buf.is_empty() => { + in_extended = true; + buf.push(byte); // start of extended `%...%` block + } + b'*' if !in_extended => { + buf.push(byte); + break; // end-of-block for a normal command + } + _ => buf.push(byte), + } } - line.map(|result| { - result.map_err(|e| { - ContentError::IoError( - format!("IO error on line: {}, error: {}", self.line_number, e).to_string(), - ) - }) - }) + + // Leading whitespace was already skipped; only a block ended by EOF (rather than a + // delimiter) can still carry trailing whitespace, so trim that here. + while buf.last().is_some_and(u8::is_ascii_whitespace) { + buf.pop(); + } + if buf.is_empty() { + return None; // nothing left but trailing whitespace / EOF + } + + let position = block_start.unwrap_or(self.current); + Some( + String::from_utf8(buf) + .map(|text| (text, position)) + .map_err(|e| { + ContentError::IoError(format!( + "Invalid UTF-8 on line: {}, error: {}", + position.line, e + )) + }), + ) } // Update the modal operation mode after a command was parsed (gerber spec 8.3). @@ -141,7 +232,7 @@ impl ParserContext { pub fn parse(reader: BufReader) -> Result { let mut gerber_doc = GerberDoc::default(); - let mut parser_context = ParserContext::new(reader.lines()); + let mut parser_context = ParserContext::new(reader); let mut parse_error: Option = None; @@ -150,10 +241,8 @@ pub fn parse(reader: BufReader) -> Result line, + let (raw_line, position) = match line_result { + Ok(block) => block, Err(ContentError::IoError(error)) => { parse_error = Some(ParseError::IoError(error)); break; @@ -162,12 +251,17 @@ pub fn parse(reader: BufReader) -> Result results, + Err(error) => vec![Err(error)], + }; + for result in line_results { let final_result = match result { Ok(command) => { log::trace!("Parsed command: {:?}", command); @@ -180,8 +274,12 @@ pub fn parse(reader: BufReader) -> Result { - let contexted_error = error_without_context - .to_with_context(Some((line_number, line.to_string()))); + let contexted_error = + error_without_context.to_with_context(Some(ErrorContext { + line: position.line, + offset: position.offset, + token: line.to_string(), + })); log::error!("Content error: {}", contexted_error); Err(contexted_error) } @@ -207,6 +305,32 @@ pub fn parse(reader: BufReader) -> Result Vec> { + // Sized for two: the combined form adds a second command, and these G-codes are the + // hot path in large flashed/region files. + let mut commands = Vec::with_capacity(2); + commands.push(Ok( + FunctionCode::GCode(GCode::InterpolationMode(mode)).into() + )); + // More than the trailing `*` means operation data follows on the same block. + if remaining_line.len() > 1 { + commands.push(parse_interpolate_move_or_flash( + remaining_line, + gerber_doc, + modal, + )); + } + commands +} + fn parse_line( line: &str, gerber_doc: &mut GerberDoc, @@ -218,68 +342,59 @@ fn parse_line( // Safety: already explicitly checked that the line is not empty 'G' => { match linechars.next().ok_or(ContentError::UnknownCommand {})? { - '0' => { - let remaining_line = &line[3..]; - let using_deprecated_syntax = remaining_line.len() > 1; - let mut commands = Vec::with_capacity(1); - match linechars.next().ok_or(ContentError::UnknownCommand {})? { - '1' => { - // G01 - commands.push(Ok(FunctionCode::GCode(GCode::InterpolationMode( - InterpolationMode::Linear, - )) - .into())); - if using_deprecated_syntax { - commands.push(parse_interpolate_move_or_flash( - remaining_line, - gerber_doc, - parser_context.modal_operation, - )); - } - } - '2' => { - // G02 - commands.push(Ok(FunctionCode::GCode(GCode::InterpolationMode( - InterpolationMode::ClockwiseCircular, - )) - .into())); - if using_deprecated_syntax { - commands.push(parse_interpolate_move_or_flash( - remaining_line, - gerber_doc, - parser_context.modal_operation, - )); - } - } - '3' => { - // G03 - commands.push(Ok(FunctionCode::GCode(GCode::InterpolationMode( - InterpolationMode::CounterclockwiseCircular, - )) - .into())); - if using_deprecated_syntax { - commands.push(parse_interpolate_move_or_flash( - remaining_line, - gerber_doc, - parser_context.modal_operation, - )); - } - } - '4' => { - // G04 - commands.push(parse_comment(line, parser_context)) - } - _ => commands.push(Err(ContentError::UnknownCommand {})), - } - Ok(commands) - } - '3' => Ok(vec![ - match linechars.next().ok_or(ContentError::UnknownCommand {})? { - '6' => Ok(FunctionCode::GCode(GCode::RegionMode(true)).into()), // G36 - '7' => Ok(FunctionCode::GCode(GCode::RegionMode(false)).into()), // G37 - _ => Err(ContentError::UnknownCommand {}), - }, - ]), + '0' => match linechars.next().ok_or(ContentError::UnknownCommand {})? { + '1' => Ok(interpolation_mode_commands( + InterpolationMode::Linear, + &line[3..], + gerber_doc, + parser_context.modal_operation, + )), + '2' => Ok(interpolation_mode_commands( + InterpolationMode::ClockwiseCircular, + &line[3..], + gerber_doc, + parser_context.modal_operation, + )), + '3' => Ok(interpolation_mode_commands( + InterpolationMode::CounterclockwiseCircular, + &line[3..], + gerber_doc, + parser_context.modal_operation, + )), + '4' => Ok(vec![parse_comment(line, parser_context)]), + _ => Ok(vec![Err(ContentError::UnknownCommand {})]), + }, + // Deprecated single-digit interpolation modes `G1`/`G2`/`G3` (gerber spec + // 8.3 style variations); ViewMate and others emit these instead of `G0n`. + '1' => Ok(interpolation_mode_commands( + InterpolationMode::Linear, + &line[2..], + gerber_doc, + parser_context.modal_operation, + )), + '2' => Ok(interpolation_mode_commands( + InterpolationMode::ClockwiseCircular, + &line[2..], + gerber_doc, + parser_context.modal_operation, + )), + // `G3` is ambiguous: the G36/G37 region commands, or deprecated single-digit + // `G3` (= G03). Peek the next char; `6`/`7` select a region, anything else is + // taken as G03 (matching how `G1`/`G01` tolerate trailing operation data). + '3' => match linechars.next() { + Some('6') => Ok(vec![ + Ok(FunctionCode::GCode(GCode::RegionMode(true)).into()), + ]), + Some('7') => Ok(vec![Ok( + FunctionCode::GCode(GCode::RegionMode(false)).into() + )]), + _ => Ok(interpolation_mode_commands( + InterpolationMode::CounterclockwiseCircular, + &line[2..], + gerber_doc, + parser_context.modal_operation, + )), + }, '7' => Ok(vec![ match linechars.next().ok_or(ContentError::UnknownCommand {})? { // the G74 command is technically part of the Deprecated commands @@ -884,8 +999,8 @@ fn parse_aperture_macro_definition( let Some(line_result) = parser_context.next() else { break; }; - let line = line_result?.trim().to_string(); - macro_content.push_str(&line); + let (block, _) = line_result?; + macro_content.push_str(block.trim()); } // Extract the macro name from the AM command @@ -912,6 +1027,9 @@ fn parse_aperture_macro_definition( log::trace!("macro chunks: {:?}", chunks); for chunk in chunks { + // A block may span several physical lines; the tokenizer drops newlines but + // keeps the surrounding indentation, so trim each primitive before matching. + let chunk = chunk.trim(); if let Some(stripped) = chunk.strip_prefix("0 ") { // Handle the special-case comment primitive diff --git a/tests/component_tests.rs b/tests/component_tests.rs index 919ceea..d7b423b 100644 --- a/tests/component_tests.rs +++ b/tests/component_tests.rs @@ -1,4 +1,4 @@ -use gerber_parser::{parse, ContentError, GerberParserErrorWithContext}; +use gerber_parser::{parse, ContentError, ErrorContext, GerberParserErrorWithContext}; use gerber_types::{ Aperture, ApertureAttribute, ApertureBlock, ApertureDefinition, ApertureFunction, ApertureMacro, AxisSelect, Circle, CirclePrimitive, Command, CommentContent, @@ -568,6 +568,152 @@ fn deprecated_modal_d01_invalid_without_preceding_d01() { assert_eq!(filtered_commands.len(), 3) } +/// Commands are delimited by the end-of-block char `*`, not by newlines (gerber spec 4.1): +/// a file may pack the whole stream onto a single physical line. Each `*` must still be +/// parsed as its own command, including modal D01 coordinate-only blocks. +#[test] +fn commands_separated_by_block_terminator_on_one_line() { + // given + logging_init(); + + // Header, aperture, and a modal-D01 run all on one line, separated only by `*`. + let reader = gerber_to_reader( + "%FSLAX23Y23*%%MOMM*%%ADD10C, 0.01*%D10*X700Y1000D01*X1200Y1000*X1200Y1300*M02*", + ); + + let fs = CoordinateFormat::new(ZeroOmission::Leading, CoordinateMode::Absolute, 2, 3); + + // when + parse_and_filter!(reader, commands, filtered_commands, |cmd| matches!( + cmd, + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate(_, _)) + ))) + )); + + // then + assert_eq_commands!( + filtered_commands, + vec![ + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate( + coordinates_from_gerber(700, 1000, fs).unwrap(), + None, + )) + ))), + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate( + coordinates_from_gerber(1200, 1000, fs).unwrap(), + None, + )) + ))), + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate( + coordinates_from_gerber(1200, 1300, fs).unwrap(), + None, + )) + ))), + ] + ) +} + +/// Error context reports the line, column offset, and failing token. With several commands +/// packed onto one line, the offset is what pinpoints which one failed. +#[test] +fn error_context_reports_line_offset_and_token() { + // given + logging_init(); + + // `X100Y100*` at column 21 is invalid: no D01 in modal effect (gerber spec 8.3). + let reader = gerber_to_reader("%FSLAX23Y23*%%MOMM*%X100Y100*M02*"); + + // when + let doc = parse(reader).unwrap(); + + // then + assert!(matches!( + doc.errors().first().unwrap(), + GerberParserErrorWithContext { + error: ContentError::CoordinateDataWithoutOperationCode, + context: Some(ErrorContext { line, offset, token }), + } if *line == 1 && *offset == 21 && token.eq("X100Y100*") + )); +} + +/// A truncated command makes `parse_line` bail before producing any command. That outer +/// error must still be recorded with context, not silently dropped. +#[test] +fn outer_parse_error_is_recorded_with_context() { + // given + logging_init(); + + // A lone `G` is truncated: `parse_line` returns an outer Err before any command. + let reader = gerber_to_reader("%FSLAX23Y23*%\n%MOMM*%\nG\nM02*\n"); + + // when + let doc = parse(reader).unwrap(); + + // then + assert!(doc.errors().iter().any(|error| matches!( + error, + GerberParserErrorWithContext { + error: ContentError::UnknownCommand {}, + context: Some(ErrorContext { line, token, .. }), + } if *line == 3 && token.eq("G") + ))); +} + +/// Deprecated single-digit G-codes `G1`/`G2`/`G3` (gerber spec 8.3 style variations) must +/// parse like `G01`/`G02`/`G03`, including the combined `G1X..Y..D1*` form that arms modal +/// D01. `G3` must still not shadow the `G36`/`G37` region commands. As emitted by ViewMate. +#[test] +fn deprecated_single_digit_g_codes() { + // given + logging_init(); + + // A region drawn with single-digit `G1` + combined D01, then modal-D01 coordinate lines. + let reader = gerber_to_reader( + "%FSLAX25Y25*%%MOIN*%%ADD111C,0.03937*%D111*X0Y0D2*G36*G1X200Y100D1*X200Y200*X100Y200*G37*M02*", + ); + + // when + let doc = parse(reader).unwrap(); + + // then + assert!( + doc.errors().is_empty(), + "unexpected errors: {:?}", + doc.errors() + ); + let ok_commands: Vec<_> = doc + .commands + .iter() + .filter_map(|c| c.as_ref().ok()) + .collect(); + // G36 open and G37 close survived the `G3` disambiguation. + assert!(ok_commands.iter().any(|c| matches!( + c, + Command::FunctionCode(FunctionCode::GCode(GCode::RegionMode(true))) + ))); + assert!(ok_commands.iter().any(|c| matches!( + c, + Command::FunctionCode(FunctionCode::GCode(GCode::RegionMode(false))) + ))); + // The explicit `G1...D1` plus two modal-D01 lines = three interpolations. + let interpolations = ok_commands + .iter() + .filter(|c| { + matches!( + c, + Command::FunctionCode(FunctionCode::DCode(DCode::Operation( + Operation::Interpolate(..) + ))) + ) + }) + .count(); + assert_eq!(interpolations, 3); +} + /// Test the D01* statements (circular) #[test] #[allow(non_snake_case)] @@ -906,7 +1052,11 @@ fn test_load_scaling_zero() { error: ContentError::InvalidParameter { parameter, }, - line: Some((number, content)), + context: Some(ErrorContext { + line: number, + token: content, + .. + }), } if parameter.eq("0") && *number == 2 && content.eq("%LS0*%") )); } @@ -2280,7 +2430,7 @@ fn missing_eof() { let reader = gerber_to_reader( " %FSLAX23Y23*% - %MOMM*%- + %MOMM*% G04 We should have a MO2 at the end, but what if we forget it?* ", @@ -2384,7 +2534,11 @@ fn coordinates_not_within_format() { format, cause: GerberError::CoordinateFormatError(_) }, - line: Some((number, content)), + context: Some(ErrorContext { + line: number, + token: content, + .. + }), } if format.integer == 2 && format.decimal == 3 && *number == 8 && content.eq("X100000Y0D01*") )); } @@ -3874,7 +4028,7 @@ fn malformed_aperture_definition() { let reader = gerber_to_reader( " %FSLAX23Y23*% - %MOMM*%- + %MOMM*% G04 Too many parameters * @@ -3920,7 +4074,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("C") && content.eq("%ADD10C,1X2X3*%") )); @@ -3932,7 +4086,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("C") && content.eq("%ADD10C*%") )); @@ -3944,7 +4098,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("R") && content.eq("%ADD10R,1X2X3X4*%") )); @@ -3956,7 +4110,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("R") && content.eq("%ADD10R,1*%") )); @@ -3968,7 +4122,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("R") && content.eq("%ADD10R*%") )); @@ -3980,7 +4134,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("O") && content.eq("%ADD10O,1X2X3X4*%") )); @@ -3992,7 +4146,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("O") && content.eq("%ADD10O,1*%") )); @@ -4004,7 +4158,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("O") && content.eq("%ADD10O*%") )); @@ -4016,7 +4170,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("P") && content.eq("%ADD10P,1X2X3X4X5*%") )); @@ -4028,7 +4182,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("P") && content.eq("%ADD10P,1*%") )); @@ -4040,7 +4194,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("P") && content.eq("%ADD10P*%") )); @@ -4051,7 +4205,7 @@ fn malformed_aperture_definition() { error: ContentError::UnknownApertureType { type_str }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if type_str.eq("T") && content.eq("%ADD10T*%") )); }