GerberParser.java

package com.varnernet.gerb4j;

import com.varnernet.gerb4j.render.DrawingOperation;
import com.varnernet.gerb4j.render.GerberOperation;
import org.parboiled.Action;
import org.parboiled.BaseParser;
import org.parboiled.Context;
import org.parboiled.Parboiled;
import org.parboiled.Rule;
import org.parboiled.annotations.BuildParseTree;
import org.parboiled.parserunners.ReportingParseRunner;
import org.parboiled.support.ParsingResult;
import org.parboiled.support.Var;

import java.awt.geom.Point2D;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Basic Rules of the design of the GerberParser:
 *
 * <p>For the major sequence rules (Start() and Block()) any sub-rules expect the GerberContext to
 * be at the top of the value stack at method entry, and ensure that the top of the value stack is
 * the GerberContext upon exit.
 */
@BuildParseTree
public class GerberParser extends BaseParser<Object> {

    /**
     * Creates a new GerberParser instance.
     * Instances are normally created via {@code Parboiled.createParser(GerberParser.class)}.
     */
    public GerberParser() {
    }

    // ── Convenience entry points ──────────────────────────────────────────────

    /**
     * Parses raw Gerber source text and returns the resulting context.
     *
     * <p>Whitespace is stripped from the input before parsing, consistent with
     * the Gerber specification (whitespace is not significant).
     *
     * @param gerberContent the complete Gerber source text
     * @return a fully-populated {@link GerberContext}
     * @throws GerberParseException if the input cannot be parsed
     */
    public static GerberContext parseString(final String gerberContent) {
        String stripped = gerberContent.replaceAll("\\s+", "");
        GerberParser parser = Parboiled.createParser(GerberParser.class);
        ParsingResult<?> result =
                new ReportingParseRunner<>(parser.Start()).run(stripped);
        if (!result.matched) {
            throw new GerberParseException("Failed to parse Gerber input");
        }
        return (GerberContext) result.resultValue;
    }

    /**
     * Reads and parses a Gerber file from the given {@link Path}.
     *
     * @param path path to the Gerber file
     * @return a fully-populated {@link GerberContext}
     * @throws IOException          if the file cannot be read
     * @throws GerberParseException if the file content cannot be parsed
     */
    public static GerberContext parsePath(final Path path) throws IOException {
        return parseString(Files.readString(path));
    }

    /**
     * Reads and parses a Gerber file from the given {@link File}.
     *
     * @param file the Gerber file
     * @return a fully-populated {@link GerberContext}
     * @throws IOException          if the file cannot be read
     * @throws GerberParseException if the file content cannot be parsed
     */
    public static GerberContext parseFile(final File file) throws IOException {
        return parsePath(file.toPath());
    }

    // ── Grammar rules ─────────────────────────────────────────────────────────

    /**
     * The start rule for parsing a Gerber file.
     *
     * @return the parsing rule
     */
    Rule Start() {
        return Sequence(
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = new GerberContext();
                        CURRENT_PARSING_CONTEXT.set(ctx);
                        push(ctx);
                        return true;
                    }
                },
                ZeroOrMore(
                        FirstOf(
                                G04(),
                                MO(),
                                FS(),
                                AD(),
                                AM(),
                                G54(),
                                Dnn(),
                                D01(),
                                D02(),
                                D03(),
                                G01(),
                                G02(),
                                G03(),
                                G74(),
                                G75(),
                                G70(),
                                G71(),
                                G90(),
                                G91(),
                                LP(),
                                LM(),
                                LR(),
                                LS(),
                                RegionStatement(),
                                ABStatement(),
                                SRStatement(),
                                IP(),
                                AS(),
                                OF(),
                                MI(),
                                SF(),
                                IN(),
                                LN(),
                                TF(),
                                TA(),
                                TO(),
                                TD(),
                                UnknownExtendedCommand())),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        // Clean up thread-local
                        CURRENT_PARSING_CONTEXT.remove();
                        return true;
                    }
                });
    }

    /**
     * Format specification command. Sets the coordinate format for parsing X and Y values.
     *
     * @return the parsing rule
     */
    Rule FS() {
        Var<GerberContext> gerber = new Var<>();

        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%FS"),
                String("LA"),
                String("X"),
                CoordinateDigits(), // push()
                String("Y"),
                CoordinateDigits(), // push()
                "*%",
                swap(), // invert and....
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        gerber.get().setFormat(new FormatSpecification((String) pop(), (String) pop()));
                        return true;
                    }
                },
                push(gerber.get()));
    }

    Rule CoordinateDigits() {
        return Sequence(Sequence(CharRange('1', '6'), CharRange('5', '6')), push(match()));
    }

    /**
     * Parses a coordinate value string (digits representing a coordinate in Gerber format). Used by
     * D01, D02, D03 commands to extract X/Y/I/J coordinate values. Coordinates can be negative (e.g.,
     * -1240000 for -12.40000 in format 36).
     *
     * <p>NOTE: The sign character must be captured explicitly into a Var because parboiled's inline
     * match() only returns the match of the LAST non-action rule in the Sequence (i.e., the digits
     * from OneOrMore), not the full Sequence match including the Optional sign.
     *
     * @param result Variable to hold the parsed coordinate string (including sign)
     * @return Rule matching coordinate digits and optional sign, setting the result
     */
    Rule CoordinateString(final Var<String> result) {
        Var<String> sign = new Var<>("");
        return Sequence(
                Optional(Sequence(AnyOf("+-"), sign.set(match()))),
                OneOrMore(Digit()),
                result.set(sign.get() + match()));
    }

    /**
     * Mode command. Sets the file to either metric or imperial.
     *
     * @return the parsing rule
     */
    Rule MO() {
        Var<GerberContext> gerber = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%MO"),
                FirstOf(String("MM"), String("IN")),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        gerber.get().setMode(Mode.valueOf(match()));
                        return true;
                    }
                },
                String("*%"),
                push(gerber.get()));
    }

    /**
     * Operation D01 Linear or circular line segment by plotting from the current point to the
     * coordinate pair in the command.
     *
     * @return the parsing rule
     */
    Rule D01() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> xDest = new Var<>();
        Var<String> yDest = new Var<>();
        Var<String> xOffset = new Var<>();
        Var<String> yOffset = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                Optional(String("X"), CoordinateString(xDest)),
                Optional(String("Y"), CoordinateString(yDest)),
                Optional(String("I"), CoordinateString(xOffset), String("J"), CoordinateString(yOffset)),
                String("D01*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = gerber.get();

                        // Capture FROM point BEFORE updating currentPoint to the destination.
                        // This is the arc start for G02/G03 and the pen position for linear draws.
                        Point2D fromPoint =
                                ctx.getCurrentPoint() != null
                                        ? new Point2D.Double(ctx.getCurrentPoint().getX(), ctx.getCurrentPoint().getY())
                                        : new Point2D.Double(0, 0);

                        double x = fromPoint.getX();
                        double y = fromPoint.getY();

                        // Resolve destination coordinates
                        if (xDest.get() != null || yDest.get() != null) {
                            x = xDest.get() != null ? ctx.requireFormat().getX(xDest.get()) : x;
                            y = yDest.get() != null ? ctx.requireFormat().getY(yDest.get()) : y;
                            ctx.setCurrentPoint(x, y);
                        }

                        Point2D point = new Point2D.Double(x, y);

                        // Extract arc centre offset if specified (I/J for G02/G03)
                        Point2D interpolationPoint = null;
                        if (xOffset.get() != null || yOffset.get() != null) {
                            double ix = xOffset.get() != null ? ctx.requireFormat().getX(xOffset.get()) : 0.0;
                            double iy = yOffset.get() != null ? ctx.requireFormat().getY(yOffset.get()) : 0.0;
                            interpolationPoint = new Point2D.Double(ix, iy);
                        }

                        // Capture arc direction at parse time (G02=CW, G03=CCW).
                        boolean clockwise = (ctx.getInterpolationMode() == InterpolationMode.CLOCKWISE_ARC);
                        // Capture quadrant mode at parse time (G74=SINGLE, G75=MULTI).
                        QuadrantMode quadrantMode = ctx.getQuadrantMode();

                        if (ctx.isInRegion()) {
                            if (interpolationPoint != null) {
                                ctx.addArcToRegion(fromPoint, point, interpolationPoint, clockwise, quadrantMode);
                            } else {
                                ctx.addPointToRegion(point);
                            }
                        } else {
                            DrawingOperation op =
                                    new DrawingOperation(
                                            DrawingOperation.Type.DRAW,
                                            ctx.getCurrentApertureId(),
                                            point,
                                            ctx.getPolarity(),
                                            new ApertureTransform(
                                                    ctx.getMirroring(), ctx.getRotation(), ctx.getScaling()),
                                            interpolationPoint,
                                            clockwise,
                                            quadrantMode);
                            ctx.recordOperation(op);
                        }

                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Moves the current point to the coordinate pair. No graphical object is created.
     *
     * @return the parsing rule
     */
    Rule D02() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> xDest = new Var<>();
        Var<String> yDest = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                Optional(String("X"), CoordinateString(xDest)),
                Optional(String("Y"), CoordinateString(yDest)),
                String("D02*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = gerber.get();
                        double x = ctx.getCurrentPoint() != null ? ctx.getCurrentPoint().getX() : 0.0;
                        double y = ctx.getCurrentPoint() != null ? ctx.getCurrentPoint().getY() : 0.0;

                        // Update current point if coordinates specified
                        if (xDest.get() != null || yDest.get() != null) {
                            x = xDest.get() != null ? ctx.requireFormat().getX(xDest.get()) : x;
                            y = yDest.get() != null ? ctx.requireFormat().getY(yDest.get()) : y;
                            ctx.setCurrentPoint(x, y);
                        }

                        Point2D point = new Point2D.Double(x, y);

                        if (ctx.isInRegion()) {
                            ctx.startContourInRegion(point);
                        } else {
                            DrawingOperation op =
                                    new DrawingOperation(
                                            DrawingOperation.Type.MOVE,
                                            ctx.getCurrentApertureId(),
                                            point,
                                            ctx.getPolarity(),
                                            ctx.getMirroring(),
                                            ctx.getRotation(),
                                            ctx.getScaling(),
                                            null);
                            ctx.recordOperation(op);
                        }

                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Creates a flash object by flashing the current aperture. The origin of the aperture is
     * positioned at the specified coordinate pair.
     *
     * @return the parsing rule
     */
    Rule D03() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> xDest = new Var<>();
        Var<String> yDest = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                Optional(String("X"), CoordinateString(xDest)),
                Optional(String("Y"), CoordinateString(yDest)),
                String("D03*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = gerber.get();
                        // Use current point if coordinates not specified
                        double x =
                                xDest.get() != null
                                        ? ctx.requireFormat().getX(xDest.get())
                                        : (ctx.getCurrentPoint() != null ? ctx.getCurrentPoint().getX() : 0.0);
                        double y =
                                yDest.get() != null
                                        ? ctx.requireFormat().getY(yDest.get())
                                        : (ctx.getCurrentPoint() != null ? ctx.getCurrentPoint().getY() : 0.0);

                        // Update flash position if coordinates specified
                        if (xDest.get() != null || yDest.get() != null) {
                            ctx.setCurrentPoint(x, y);
                        }

                        // Record flash operation
                        Point2D point = new Point2D.Double(x, y);
                        DrawingOperation op =
                                new DrawingOperation(
                                        DrawingOperation.Type.FLASH,
                                        ctx.getCurrentApertureId(),
                                        point,
                                        ctx.getPolarity(),
                                        ctx.getMirroring(),
                                        ctx.getRotation(),
                                        ctx.getScaling(),
                                        null);
                        ctx.recordOperation(op);

                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Sets interpolation mode to linear.
     *
     * @return the parsing rule
     */
    Rule G01() {
        return Sequence(
                String("G01*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setInterpolationMode(InterpolationMode.LINEAR);
                        }
                        return true;
                    }
                });
    }

    /**
     * Sets interpolation mode to clockwise arc.
     *
     * @return the parsing rule
     */
    Rule G02() {
        return Sequence(
                String("G02*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setInterpolationMode(InterpolationMode.CLOCKWISE_ARC);
                        }
                        return true;
                    }
                });
    }

    Rule G03() {
        return Sequence(
                String("G03*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setInterpolationMode(InterpolationMode.COUNTERCLOCKWISE_ARC);
                        }
                        return true;
                    }
                });
    }

    Rule G75() {
        // Multi-quadrant arc mode (§4.7.3) — the default.
        return Sequence(
                String("G75*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setQuadrantMode(QuadrantMode.MULTI);
                        }
                        return true;
                    }
                });
    }

    /**
     * Set Current Aperture
     *
     * <p>Selects the aperture to use for subsequent drawing operations. The aperture must have been
     * previously defined with an AD command.
     *
     * @return the parsing rule
     */
    Rule Dnn() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> identifier = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                ApertureIdentifier(identifier),
                String("*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        // ApertureIdentifier includes the "D" prefix, so strip it
                        String id = identifier.get().substring(1);
                        gerber.get().setCurrentApertureId(id);
                        return true;
                    }
                },
                push(gerber.get()));
    }

    Rule G04() {
        Var<String> comment = new Var<>();
        // Comments are parsed and discarded (intentional behavior).
        // Comments are metadata that do not affect parsing logic or aperture definitions.
        // They are safely consumed and not stored in the context.
        return Sequence(String("G04"), AString(comment), String("*"));
    }

    /**
     * End Of File.
     *
     * @return the parsing rule
     */
    Rule M02() {
        return FirstOf(String("M02*"), String("M00*"), String("M01*"));
    }

    // ── Deprecated commands (§8) — accepted for backward compatibility ──────

    /**
     * G54 — legacy select-aperture prefix. {@code G54Dnn*} is equivalent to {@code Dnn*}. Very common
     * in older Gerber files.
     *
     * @return the parsing rule
     */
    Rule G54() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> identifier = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                String("G54"),
                ApertureIdentifier(identifier),
                String("*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        String id = identifier.get().substring(1);
                        gerber.get().setCurrentApertureId(id);
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * G70 — legacy imperial unit command. Equivalent to {@code %MOIN*%}.
     *
     * @return the parsing rule
     */
    Rule G70() {
        return Sequence(
                String("G70*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setMode(Mode.IN);
                        }
                        return true;
                    }
                });
    }

    /**
     * G71 — legacy metric unit command. Equivalent to {@code %MOMM*%}.
     *
     * @return the parsing rule
     */
    Rule G71() {
        return Sequence(
                String("G71*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setMode(Mode.MM);
                        }
                        return true;
                    }
                });
    }

    /**
     * G90 — legacy absolute coordinate mode (the only valid mode). Accepted and ignored.
     *
     * @return the parsing rule
     */
    Rule G90() {
        return String("G90*");
    }

    /**
     * G91 — legacy incremental coordinate mode (deprecated, not supported). Accepted with warning.
     *
     * @return the parsing rule
     */
    Rule G91() {
        return Sequence(
                String("G91*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        java.util.logging.Logger.getLogger(GerberParser.class.getName())
                                .warning(
                                        "G91 (incremental mode) is deprecated and not supported. Coordinates will be treated as absolute.");
                        return true;
                    }
                });
    }

    /**
     * %IP — image polarity (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule IP() {
        return Sequence(String("%IP"), FirstOf(String("POS"), String("NEG")), String("*%"));
    }

    /**
     * %AS — axis select (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule AS() {
        Var<String> content = new Var<>();
        return Sequence(String("%AS"), AString(content), String("*%"));
    }

    /**
     * %OF — offset (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule OF() {
        Var<String> content = new Var<>();
        return Sequence(String("%OF"), AString(content), String("*%"));
    }

    /**
     * %MI — mirror image (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule MI() {
        Var<String> content = new Var<>();
        return Sequence(String("%MI"), AString(content), String("*%"));
    }

    /**
     * %SF — scale factor (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule SF() {
        Var<String> content = new Var<>();
        return Sequence(String("%SF"), AString(content), String("*%"));
    }

    /**
     * %IN — image name (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule IN() {
        Var<String> content = new Var<>();
        return Sequence(String("%IN"), AString(content), String("*%"));
    }

    /**
     * %LN — layer name (deprecated). Parsed and ignored.
     *
     * @return the parsing rule
     */
    Rule LN() {
        Var<String> content = new Var<>();
        return Sequence(String("%LN"), AString(content), String("*%"));
    }

    /**
     * Catch-all for unknown extended commands ({@code %XX…*%}).
     *
     * <p>Per spec §3.4, unknown extended commands should be accepted and ignored for forward
     * compatibility. This rule must be the <b>last</b> alternative in the {@code FirstOf} list so
     * that it only matches when no known command does.
     *
     * <p>Negative lookaheads prevent this rule from consuming block-close markers ({@code %AB*%},
     * {@code %SR*%}) that terminate AB/SR statements.
     *
     * @return the parsing rule
     */
    Rule UnknownExtendedCommand() {
        Var<String> content = new Var<>();
        return Sequence(
                Ch('%'),
                TestNot(String("AB*%")),
                TestNot(String("SR*%")),
                Sequence(OneOrMore(new AnyExceptMatcher(new char[]{'%'})), content.set(match())),
                Ch('%'),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        java.util.logging.Logger.getLogger(GerberParser.class.getName())
                                .warning("Ignoring unknown extended command: %" + content.get() + "%");
                        return true;
                    }
                });
    }

    /**
     * Load Polarity Aperture Transformation Sets the polarity mode for subsequent apertures and
     * operations.
     *
     * @return the parsing rule
     */
    Rule LP() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> polarityCode = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%LP"),
                FirstOf(String("C"), String("D")),
                polarityCode.set(match()),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        gerber.get().setPolarity(Polarity.fromCode(polarityCode.get()));
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Load Mirroring Aperture Transformation Sets the mirroring mode for subsequent apertures and
     * operations.
     *
     * @return the parsing rule
     */
    Rule LM() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> mirrorCode = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%LM"),
                FirstOf(String("XY"), String("N"), String("X"), String("Y")),
                mirrorCode.set(match()),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        gerber.get().setMirroring(Mirror.fromCode(mirrorCode.get()));
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Load rotation (rotation angle) Sets the rotation angle in degrees for subsequent apertures and
     * operations.
     *
     * @return the parsing rule
     */
    Rule LR() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> rotationAngle = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%LR"),
                Decimal(rotationAngle),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        gerber.get().setRotation(Double.parseDouble(rotationAngle.get()));
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Load scaling (scaling factor) Sets the scaling factor for subsequent apertures and operations.
     *
     * @return the parsing rule
     */
    Rule LS() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> scalingFactor = new Var<>();
        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%LS"),
                Decimal(scalingFactor),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        gerber.get().setScaling(Double.parseDouble(scalingFactor.get()));
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * The AD command creates an aperture, attaches the aperture attributes at that moment in the
     * attribute dictionary to it and adds it to the aperture dictionary.
     *
     * <p>For template apertures (macros), evaluates macro variables with the provided parameters.
     *
     * @return the parsing rule
     */
    Rule AD() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> identifier = new Var<>();
        Var<Aperture> template = new Var<>();
        Var<String> macroName = new Var<>();
        Var<List<String>> parameters = new Var<>(new ArrayList<>());

        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%AD"),
                ApertureIdentifier(identifier),
                FirstOf(
                        CircleAperture(template, identifier),
                        RectangleAperture(template, identifier),
                        ObroundAperture(template, identifier),
                        PolygonAperture(template, identifier),
                        TemplateCallWithEvaluation(template, identifier, gerber, macroName, parameters)),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        // ApertureIdentifier includes the "D" prefix, so strip it
                        String id = identifier.get().substring(1);
                        Aperture apt = template.get();
                        // Per spec §5.3: "the AD command attaches the aperture
                        // attributes at that moment in the attribute dictionary to it."
                        apt.setAttributes(gerber.get().snapshotApertureAttributes());
                        gerber.get().addAperture(id, apt);
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * Template aperture call with macro variable evaluation. Looks up the macro in context, evaluates
     * variables with parameters, and constructs a fully-initialised {@link TemplateAperture} in one
     * step.
     *
     * <p>Per the Gerber specification {@code AM} always precedes {@code AD}, so the macro is always
     * available at this point. If it is somehow missing (malformed file) a bare TemplateAperture with
     * no primitives is produced.
     *
     * @param result     the variable to set with the created aperture
     * @param identifier the aperture identifier
     * @param gerber     the gerber context
     * @param macroName  the macro name
     * @param parameters the parameters for the macro
     * @return the parsing rule
     */
    Rule TemplateCallWithEvaluation(
            final Var<Aperture> result,
            final Var<String> identifier,
            final Var<GerberContext> gerber,
            final Var<String> macroName,
            final Var<List<String>> parameters) {

        return Sequence(
                Name(macroName),
                Optional(
                        Ch(','),
                        AppendDecimalToList(parameters),
                        ZeroOrMore(Ch('X'), AppendDecimalToList(parameters))),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        MacroDefinition macro = gerber.get().getMacro(macroName.get());

                        // Evaluate variables if the macro is known and has parameters
                        Map<Integer, Double> evaluatedVars;
                        try {
                            evaluatedVars =
                                    (macro != null && !parameters.get().isEmpty())
                                            ? macro.evaluateVariables(parameters.get())
                                            : new java.util.HashMap<>();
                        } catch (Exception e) {
                            throw new RuntimeException(
                                    "Failed to evaluate macro "
                                            + macroName.get()
                                            + " with parameters "
                                            + parameters.get()
                                            + ": "
                                            + e.getMessage(),
                                    e);
                        }

                        // Always use the full constructor — produces a fully-initialised object.
                        result.set(
                                new TemplateAperture(
                                        identifier.get(), macroName.get(), parameters.get(), macro, evaluatedVars));
                        return true;
                    }
                });
    }

    Rule CircleAperture(final Var<Aperture> result, final Var<String> identifier) {
        Var<String> diameter = new Var<>();
        Var<String> holeDiameter = new Var<>();
        return Sequence(
                String("C"),
                Ch(','),
                Decimal(diameter),
                Optional(Ch('X'), Decimal(holeDiameter)),
                result.set(new CircleAperture(identifier.get(), diameter.get(), holeDiameter.get())));
    }

    Rule RectangleAperture(final Var<Aperture> result, final Var<String> identifier) {
        Var<String> xSize = new Var<>();
        Var<String> ySize = new Var<>();
        Var<String> holeDiameter = new Var<>();
        return Sequence(
                String("R"),
                Ch(','),
                Decimal(xSize),
                Ch('X'),
                Decimal(ySize),
                Optional(Ch('X'), Decimal(holeDiameter)),
                result.set(
                        new RectangleAperture(identifier.get(), xSize.get(), ySize.get(), holeDiameter.get())));
    }

    Rule ObroundAperture(final Var<Aperture> result, final Var<String> identifier) {
        Var<String> xSize = new Var<>();
        Var<String> ySize = new Var<>();
        Var<String> holeDiameter = new Var<>();
        return Sequence(
                String("O"),
                Ch(','),
                Decimal(xSize),
                Ch('X'),
                Decimal(ySize),
                Optional(Ch('X'), Decimal(holeDiameter)),
                result.set(
                        new ObroundAperture(identifier.get(), xSize.get(), ySize.get(), holeDiameter.get())));
    }

    Rule PolygonAperture(final Var<Aperture> result, final Var<String> identifier) {
        Var<String> outerDiameter = new Var<>();
        Var<String> vertices = new Var<>();
        Var<String> rotation = new Var<>();
        Var<String> holeDiameter = new Var<>();

        return Sequence(
                String("P"),
                Ch(','),
                Decimal(outerDiameter),
                Ch('X'),
                Decimal(vertices),
                Optional(Ch('X'), Decimal(rotation), Optional(Ch('X'), Decimal(holeDiameter))),
                result.set(
                        new PolygonAperture(
                                identifier.get(),
                                outerDiameter.get(),
                                vertices.get(),
                                rotation.get(),
                                holeDiameter.get())));
    }

    Rule AM() {
        Var<GerberContext> gerber = new Var<>();
        Var<String> macroName = new Var<>();
        Var<MacroDefinition> macroDef = new Var<>();

        return Sequence(
                gerber.set((GerberContext) pop()),
                String("%AM"),
                Name(macroName),
                Ch('*'),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        // Create a new macro definition when we see %AM name*
                        macroDef.set(new MacroDefinition(macroName.get()));
                        return true;
                    }
                },
                MacroBody(macroDef),
                Ch('%'),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        // Store the macro definition in the context
                        if (macroDef.get() != null) {
                            gerber.get().addMacro(macroName.get(), macroDef.get());
                        }
                        return true;
                    }
                },
                push(gerber.get()));
    }

    /**
     * @param macroDef the macro definition to populate
     * @return the parsing rule
     */
    Rule MacroBody(final Var<MacroDefinition> macroDef) {
        return OneOrMore(
                FirstOf(
                        // Variable assignment: $n=<expr>*
                        Sequence(
                                VariableDefinition(),
                                new Action() {
                                    @Override
                                    public boolean run(final Context context) {
                                        // match() returns text of VariableDefinition: e.g., "$5=$1/2-$3*"
                                        String full = match();
                                        int eq = full.indexOf('=');
                                        if (eq > 1) {
                                            try {
                                                int varNum = Integer.parseInt(full.substring(1, eq));
                                                // Strip trailing '*'
                                                String expr =
                                                        full.substring(
                                                                eq + 1, full.endsWith("*") ? full.length() - 1 : full.length());
                                                macroDef.get().addVariableExpression(varNum, expr);
                                            } catch (NumberFormatException ignored) {
                                            }
                                        }
                                        return true;
                                    }
                                }),
                        // Primitive definition (including type-0 comment primitives)
                        Sequence(
                                PrimitiveDefinition(),
                                new Action() {
                                    @Override
                                    public boolean run(final Context context) {
                                        macroDef.get().addPrimitive(match());
                                        return true;
                                    }
                                })));
    }

    /**
     * @return the parsing rule
     */
    Rule PrimitiveDefinition() {
        return FirstOf(
                Sequence(Ch('0'), AString(new Var<>()), Ch('*')), // comment
                Sequence(
                        Ch('1'),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Optional(Ch(','), Expr()),
                        Ch('*')), // circle
                Sequence(
                        String("20"),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch('*')), // vector line
                Sequence(
                        String("21"),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch('*')), // center line
                Sequence(
                        Ch('4'),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        Ch(','),
                        Expr(),
                        OneOrMore(Ch(','), Expr(), Ch(','), Expr()),
                        Ch(','),
                        Expr(),
                        Ch('*')), // outline
                Sequence(
                        Ch('5'), Ch(','), Expr(), Ch(','), Expr(), Ch(','), Expr(), Ch(','), Expr(), Ch(','),
                        Expr(), Ch(','), Expr(), Ch('*')), // polygon
                Sequence(
                        Ch('7'), Ch(','), Expr(), Ch(','), Expr(), Ch(','), Expr(), Ch(','), Expr(), Ch(','),
                        Expr(), Ch(','), Expr(), Ch('*')) // thermal
        );
    }

    /**
     * Matches a macro variable reference: {@code $N} where N is a positive integer.
     * Variable {@code $0} is not valid per the PEG specification.
     *
     * @return the parsing rule
     */
    Rule MacroVariable() {
        return Sequence(
                Ch('$'),
                FirstOf(
                        // Leading zeros then non-zero digit then optional trailing digits: e.g. $010, $0234
                        Sequence(OneOrMore(Ch('0')), CharRange('1', '9'), ZeroOrMore(Digit())),
                        // Non-zero digit then optional trailing digits: e.g. $1, $10, $99
                        Sequence(CharRange('1', '9'), ZeroOrMore(Digit()))));
    }

    /**
     * Parameterless VariableDefinition for testing.
     *
     * @return the parsing rule
     */
    Rule VariableDefinition() {
        return Sequence(MacroVariable(), Ch('='), Expr(), Ch('*'));
    }

    Rule Expr() {
        return FirstOf(
                OneOrMore(AnyOf("+-"), Term()),
                // expr [+-] term. Expand Expr here into a sequence of one or more of the things Expr
                // matches.
                Sequence(
                        FirstOf(OneOrMore(AnyOf("+-"), Term()), Term()),
                        OneOrMore(Sequence(AnyOf("+-"), Term()))),
                Term());
    }

    Rule Term() {
        return FirstOf(
                // term [x/] factor. Expand Term here into a sequence of one or more of the things Term
                // matches.
                Sequence(
                        FirstOf(OneOrMore(AnyOf("x/"), Factor()), Factor()),
                        OneOrMore(Sequence(AnyOf("x/"), Factor()))),
                Factor());
    }

    Rule Factor() {
        // Variables are handled via MacroVariable() rule.
        // Full expression evaluation including variable substitution is performed
        // in MacroExpressionEvaluator during macro instantiation (Phase 2 implementation).
        return FirstOf(
                UnsignedDecimal(new Var<>()), MacroVariable(), Sequence(Ch('('), Expr(), Ch(')')));
    }

    /**
     * Thread-local handle to the {@link GerberContext} currently being built.
     *
     * <h3>Why this exists</h3>
     *
     * <p>parboiled operates on a value stack ({@code push}/{@code pop}). Most rules pop the context
     * off the stack at entry, mutate it, and push it back on exit. That works for rules that are
     * <em>sequentially composed</em> via the rule-return value on the stack.
     *
     * <p>Some rules ({@code G01/G02/G03}, {@code RegionStatement}, {@code ABStatement}, {@code
     * SRStatement}) are called as alternatives inside {@code ZeroOrMore(FirstOf(…))} <em>without</em>
     * the context on the stack — they participate as side-effect-only rules that do not consume or
     * produce a stack value. These rules need to access the context to update interpolation mode,
     * start/end regions, or push/pop operation scopes.
     *
     * <p>The thread-local provides a safe, parboiled-compatible way for those rules to reach the
     * in-progress context without breaking the stack protocol. It is set when {@code Start()} creates
     * the context and cleared in the {@code Start()} post-action.
     *
     * <h3>Thread safety</h3>
     *
     * <p>Each call to {@code Parboiled.createParser()} returns a new parser instance which runs on a
     * single thread, so the thread-local is safe for concurrent use across independent parse calls.
     */
    private static final ThreadLocal<GerberContext> CURRENT_PARSING_CONTEXT = new ThreadLocal<>();

    Rule RegionStatement() {
        return Sequence(
                G36(),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.startRegion();
                        }
                        return true;
                    }
                },
                OneOrMore(Contour()),
                G37(),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.endRegion();
                        }
                        return true;
                    }
                });
    }

    Rule Contour() {
        return Sequence(D02(), ZeroOrMore(FirstOf(D01(), G01(), G02(), G03(), G74(), G75())));
    }

    Rule G36() {
        return String("G36*");
    }

    Rule G37() {
        return String("G37*");
    }

    Rule G74() {
        // Single-quadrant arc mode (§4.7.2).
        return Sequence(
                String("G74*"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setQuadrantMode(QuadrantMode.SINGLE);
                        }
                        return true;
                    }
                });
    }

    Rule ABStatement() {
        Var<String> blockId = new Var<>();
        return Sequence(
                ABOpen(blockId),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.pushGraphicsState();
                            ctx.pushOperationScope();
                        }
                        return true;
                    }
                },
                Block(),
                ABClose(),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            java.util.List<GerberOperation> ops = ctx.popOperationScope();
                            // Strip "D" prefix from the aperture identifier
                            String id = blockId.get() != null ? blockId.get().substring(1) : null;
                            ctx.popGraphicsState();
                            if (id != null) {
                                // Register in the global aperture dictionary
                                ctx.addAperture(id, new BlockAperture(id, ops));
                            }
                        }
                        return true;
                    }
                });
    }

    Rule ABOpen(final Var<String> identifier) {
        return Sequence(String("%AB"), ApertureIdentifier(identifier), String("*%"));
    }

    Rule ABClose() {
        return Sequence(String("%AB"), String("*%"));
    }

    Rule SRStatement() {
        Var<Integer> repeatX = new Var<>();
        Var<Integer> repeatY = new Var<>();
        Var<Double> offsetI = new Var<>();
        Var<Double> offsetJ = new Var<>();

        return Sequence(
                SROpen(repeatX, repeatY, offsetI, offsetJ),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            // SR blocks do NOT save/restore graphics state per the Gerber spec.
                            // Only push an operation scope to collect the block's operations.
                            ctx.pushOperationScope();
                        }
                        return true;
                    }
                },
                Block(),
                SRClose(),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            java.util.List<GerberOperation> ops = ctx.popOperationScope();
                            com.varnernet.gerb4j.render.BlockOperation block =
                                    new com.varnernet.gerb4j.render.BlockOperation();
                            block.setSRParameters(
                                    repeatX.get(), repeatY.get(),
                                    offsetI.get(), offsetJ.get());
                            for (GerberOperation op : ops) {
                                block.addOperation(op);
                            }
                            ctx.recordBlockOperation(block);
                        }
                        return true;
                    }
                });
    }

    Rule SROpen(
            final Var<Integer> repeatX,
            final Var<Integer> repeatY,
            final Var<Double> offsetI,
            final Var<Double> offsetJ) {
        Var<String> offsetIStr = new Var<>();
        Var<String> offsetJStr = new Var<>();
        return Sequence(
                String("%SR"),
                Ch('X'),
                PositiveInteger(repeatX),
                Ch('Y'),
                PositiveInteger(repeatY),
                Ch('I'),
                Decimal(offsetIStr),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        offsetI.set(Double.parseDouble(offsetIStr.get()));
                        return true;
                    }
                },
                Ch('J'),
                Decimal(offsetJStr),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        offsetJ.set(Double.parseDouble(offsetJStr.get()));
                        return true;
                    }
                },
                String("*%"));
    }

    Rule SRClose() {
        return Sequence(String("%SR"), String("*%"));
    }

    Rule Block() {
        return ZeroOrMore(
                FirstOf(
                        G04(),
                        MO(),
                        FS(),
                        AD(),
                        AM(),
                        G54(),
                        Dnn(),
                        D01(),
                        D02(),
                        D03(),
                        G01(),
                        G02(),
                        G03(),
                        G74(),
                        G75(),
                        G70(),
                        G71(),
                        G90(),
                        G91(),
                        LP(),
                        LM(),
                        LR(),
                        LS(),
                        RegionStatement(),
                        ABStatement(),
                        SRStatement(),
                        IP(),
                        AS(),
                        OF(),
                        MI(),
                        SF(),
                        IN(),
                        LN(),
                        TF(),
                        TA(),
                        TO(),
                        TD(),
                        UnknownExtendedCommand()));
    }

    /**
     * @return the parsing rule
     */
    Rule TF() {
        Var<String> name = new Var<>();
        Var<List<String>> values = new Var<>(new ArrayList<>());
        return Sequence(
                String("%TF"),
                Sequence(FileAttributeName(), name.set(match())),
                ZeroOrMore(String(","), CaptureAField(values)),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setFileAttribute(name.get(), new ArrayList<>(values.get()));
                        }
                        return true;
                    }
                });
    }

    /**
     * @return the parsing rule
     */
    Rule TA() {
        Var<String> name = new Var<>();
        Var<List<String>> values = new Var<>(new ArrayList<>());
        return Sequence(
                String("%TA"),
                Sequence(ApertureAttributeName(), name.set(match())),
                ZeroOrMore(String(","), CaptureAField(values)),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setApertureAttribute(name.get(), new ArrayList<>(values.get()));
                        }
                        return true;
                    }
                });
    }

    /**
     * @return the parsing rule
     */
    Rule TO() {
        Var<String> name = new Var<>();
        Var<List<String>> values = new Var<>(new ArrayList<>());
        return Sequence(
                String("%TO"),
                Sequence(ObjectAttributeName(), name.set(match())),
                ZeroOrMore(String(","), CaptureAField(values)),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            ctx.setObjectAttribute(name.get(), new ArrayList<>(values.get()));
                        }
                        return true;
                    }
                });
    }

    /**
     * @return the parsing rule
     */
    Rule TD() {
        Var<String> name = new Var<>();
        return Sequence(
                String("%TD"),
                Optional(
                        Sequence(
                                FirstOf(
                                        FileAttributeName(), ApertureAttributeName(),
                                        ObjectAttributeName(), UserName()),
                                name.set(match()))),
                String("*%"),
                new Action() {
                    @Override
                    public boolean run(final Context context) {
                        GerberContext ctx = CURRENT_PARSING_CONTEXT.get();
                        if (ctx != null) {
                            if (name.get() != null) {
                                ctx.deleteAttribute(name.get());
                            } else {
                                ctx.deleteAllApertureAndObjectAttributes();
                            }
                        }
                        return true;
                    }
                });
    }

    /**
     * Helper rule: captures the text matched by {@link #AField()} and appends it to the supplied list
     * variable.
     *
     * @param values the list to append the captured field to
     * @return the parsing rule
     */
    Rule CaptureAField(final Var<List<String>> values) {
        Var<String> val = new Var<>();
        return Sequence(AField(), val.set(match()), values.get().add(val.get()));
    }

    Rule FileAttributeName() {
        return FirstOf(
                String(".Part"),
                String(".FileFunction"),
                String(".FilePolarity"),
                String(".SameCoordinates"),
                String(".CreationDate"),
                String(".GenerationSoftware"),
                String(".ProjectId"),
                String(".MD5"),
                UserName());
    }

    Rule ApertureAttributeName() {
        return FirstOf(
                String(".AperFunction"), String(".DrillTolerance"), String(".FlashText"), UserName());
    }

    Rule ObjectAttributeName() {
        return FirstOf(
                String(".N"),
                String(".P"),
                Sequence(String(".C"), Test(String(","))),
                String(".CRot"),
                String(".CMfr"),
                String(".CMPN"),
                String(".CVal"),
                String(".CMnt"),
                String(".CFtp"),
                String(".CPgN"),
                String(".CPgD"),
                String(".CHgt"),
                String(".CLbN"),
                String(".CLbD"),
                String(".CSup"),
                UserName());
    }

    Rule Digit() {
        return CharRange('0', '9');
    }

    Rule PositiveInteger(final Var<Integer> result) {
        return Sequence(OneOrMore(Digit()), result.set(Integer.parseInt(match())));
    }

    Rule UnsignedDecimal(final Var<String> result) {
        return Sequence(
                FirstOf(
                        // Digit preceding decimal and digits after.
                        Sequence(OneOrMore(Digit()), Optional(Ch('.'), ZeroOrMore(Digit()))),
                        // Decimal then digits after.
                        Sequence(".", OneOrMore(Digit()))),
                result.set(match()));
    }

    Rule AppendDecimalToList(final Var<List<String>> results) {
        Var<String> result = new Var<>();
        return Sequence(Decimal(result), results.get().add(result.get()));
    }

    Rule Decimal(final Var<String> result) {
        Var<String> sign = new Var<>("");
        return Sequence(
                Optional(Sequence(AnyOf("+-"), sign.set(match()))),
                FirstOf(
                        Sequence(OneOrMore(Digit()), Optional(Ch('.'), ZeroOrMore(Digit()))),
                        Sequence(Ch('.'), OneOrMore(Digit()))),
                result.set(sign.get() + match()));
    }

    /**
     * @param identifier Output variable set to matched identifier.
     * @return the parsing rule
     */
    Rule ApertureIdentifier(final Var<String> identifier) {
        return Sequence(
                Sequence("D", ZeroOrMore("0"), CharRange('1', '9'), OneOrMore(Digit())),
                identifier.set(match()));
    }

    Rule Name(final Var<String> result) {
        return Sequence(
                Sequence(
                        AnyOf("._abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$"),
                        ZeroOrMore(AnyOf("._abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"))),
                result.set(match()));
    }

    Rule UserName() {
        return Sequence(
                AnyOf("_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$"),
                ZeroOrMore(AnyOf("._abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")));
    }

    Rule AString(final Var<String> result) {
        return Sequence(ZeroOrMore(new AnyExceptMatcher(new char[]{'*', '%'})), result.set(match()));
    }

    Rule AField() {
        return ZeroOrMore(new AnyExceptMatcher(new char[]{'*', '%', ','}));
    }
}