Skip to content

Refactor/parser context extraction - #112

Merged
u8array merged 6 commits into
mainfrom
refactor/parser-context-extraction
May 31, 2026
Merged

u8array merged 6 commits into
mainfrom
refactor/parser-context-extraction

Conversation

@u8array

@u8array u8array commented May 31, 2026

Copy link
Copy Markdown
Owner

No description provided.

u8array added 6 commits May 31, 2026 08:22
Architecture epic #4, Stage 3 — the closure-soup-to-typed-context
migration the boundary planner flagged as the riskiest stage.

Replaces ~50 \`let\` declarations across the top of parseZPL with a
single typed \`ParserState\` object passed through the body as \`s\`.
Every primitive state read/write becomes \`s.foo\` / \`s.foo = …\`;
collection refs (objects, labelConfig, printerProfile, variables,
findings sinks) destructure as bare names for terseness since their
mutations are method-call shaped.

Mechanically migrated via a one-shot \`scripts/migrate_parser_state.local.py\`
that prefixed all 60 state-var references with \`s.\` using word-boundary
regex with a negative-lookbehind for \`.\` (so existing property
accesses like \`modelPos.x\` and \`pendingReverseBg.y\` stay untouched).
Three manual fixups: the destructure block at parseZPL entry, the
\`pendingReverseBg = { x, y, … }\` shorthand literal (now explicit
\`x: s.x, y: s.y\`), and the import-cleanup pass (dropped 5 unused
type imports that the closure-let removal made redundant).

New module: src/lib/zplParser/context.ts (210 LOC) — ParserState
interface, PendingReverseBg interface, REVERSE_BBOX_TOLERANCE_DOTS,
createParserState() factory.

zplParser.ts shrinks 1757 → 1592 (−165 LOC). Combined with the
prior Stage 1-2 extractions (decoders, helpers, types) the parser
subtree is now 8 focused modules; what's left in zplParser.ts is
the orchestrator + closure helpers + handler registry — none of
which depend on closure-captured state anymore.

Unlocks the next epic-#4 step: extracting the barcodes / graphics /
fields handler families as \`create{Family}(state)\` factories,
same pattern as setupScript / labelConfig / unsupported already
landed.

Net diff: 1409 tests pass (zero behaviour change verified by the
existing suite + the round-trip + tripwire coverage). Lint 0 errors,
build green.
Architecture epic #4, Stage 4 — the three remaining handler families
extracted from parseZPL.ts using the family pattern established in
the cleanup-PR (setupScript / labelConfig / unsupported). Each
family file exports a `create{Family}Handlers(state, …deps)` factory
returning a `Record<string, Handler>` and gets merged into the
dispatch map via `Object.assign(handlers, createX(s, …))`.

Three families:

- `src/lib/zplParser/handlers/barcodes.ts` (164 LOC) — `mkBarcode`
  factory + ^BC, ^BE, ^BU, ^B3, ^BA, ^B1, ^B2, ^BI, ^BJ, ^BK, ^BL,
  ^BP, ^BZ, ^BS, ^BM (MSI with check-letter logic), ^BR (GS1 Databar
  with byModuleWidth side-effect), ^BQ (QR), ^BX (DataMatrix), ^B7
  (PDF417), ^B0 / ^BO (Aztec via shared `handleAztec`), ^BV
  (Maxicode), ^BF (MicroPDF417), ^BB (CODABLOCK), plus ^BY barcode
  defaults that the rest of the family inherits via state.

- `src/lib/zplParser/handlers/graphics.ts` (503 LOC) — ^GB / ^GC /
  ^GD / ^GE / ^GF / ^GS / ^XG / ~DY. Owns the reverse-text-on-black
  collapse protocol: `pushGBObject` + `commitPendingReverseBg` +
  `getReverseFlag` are exported via `GraphicsFamily.helpers` so
  `flushField` (still in parseZPL.ts) can drive the collapse-or-
  commit decision on the next field. ~DY also owns the graphic-upload
  preamble that registers entries in `s.downloadedGraphics` for a
  later ^XG to recall, and the TTF/OTF font-bytes path that loads
  into `loadFontBytesSync`.

- `src/lib/zplParser/handlers/fields.ts` (304 LOC) — ^FO / ^FT / ^A0
  / ^A@ / ^TB / ^CF / ^FW / ^FB / ^FH / ^FD / ^FS / ^SN / ^SF / ^LR
  / ^FR / ^LH / ^LT / ^CW / ^FX / ^CI / ^FN / ^FC / ^FE. Takes
  `flushField` + `appendComment` via a `FieldHelpers` DI arg since
  both are owned by parseZPL.ts (`flushField` is the 445-LOC
  gravitational center, the next epic-#4 stage candidate).

Plus post-extraction polish folded through three review rounds:

- `readRotation` extracted to `src/lib/zplParser/helpers.ts` as a
  shared helper with an optional `fallback` param (defaults to 'N').
  Was duplicated as inline closures in barcodes / graphics and as
  bare `(rest[0] as TextProps["rotation"]) ?? fwRotation` type-casts
  at four sites (parseZPL.ts dynamic ^A, fields.ts A0/A@/TB).

  HARDENING: the old cast was structural — invalid chars like "X"
  would slip through and land in state. The new helper validates via
  `isZplRotation` and falls back. Behaviour-preserving for every
  valid ZPL input; rejects previously-silently-stored garbage.

- `getReverseFlag` deduplicated. parseZPL.ts had a one-liner copy of
  the closure that lives inside `createGraphicsHandlers`. Now
  exposed via `GraphicsFamily.helpers.getReverseFlag` so `flushField`
  destructures it from the same factory call that the graphics
  handlers close over. Single closure, single `s.lrActive` / `s.frActive`
  read path.

- `IMPORT_FINDING_PAYLOAD_LIMIT` replaces a magic `80` literal at
  the DY hex-error inline-summary path. `dySummary` const reused at
  the same site instead of rebuilding the same template literal.

- Stale `dpmm` JSDoc dropped from `createFieldHandlers` (the param
  wasn't on the signature).

zplParser.ts: 1592 → 738 LOC (−854 / −54% in this stage; combined
with Stage 3 the parser is 2466 → 738 LOC, −70% since the start of
the epic). What stays in parseZPL.ts: orchestrator, `flushField`,
`bootstrapVariable`, `applyFnEmbeds`, `takeComment`, `resetFB`,
`resetComment`, `appendComment`, XA / XZ structural handlers, dynamic
`^A{X}` font fallthrough, findings-assembly tail.

1409 tests pass; lint 0 errors; build green. No behaviour change
across any of the 5 critical invariants (^CW alias resolution, ^CI
→ ^FH chain, ^BY → barcode defaults, ^FC / ^FE per-block reset,
^CF → ^A field-emit) and the cross-block printerProfile fold.
Architecture epic #4, flushField extraction. The 445-LOC field-emit
closure (giant switch by `s.fieldType` that turns cached state into
a pushed LabelObject) moves to `src/lib/zplParser/flushField.ts`
behind a `createFlushField(s, deps) → () => void` factory.

Three closure helpers move with it (only called from inside the
flushField body):
- `bootstrapVariable` — get-or-create the Variable for an ^FN slot
- `applyFnEmbeds` — bootstrap embed-referenced FNs at flush time
- (resetFB stays in parseZPL.ts — used by the ^FS handler in the
  fields family AND by the field-block reset paths)

`takeComment` stays at parseZPL.ts level as a peer-shared closure
(graphics family + flushField both need it). Idiomatic alternative
to a build-order workaround: declare `takeComment` first, build
graphics next (takes it), build flushField last (takes both
`takeComment` and the graphics-family helpers as plain DI).

zplParser.ts: 738 → 200 LOC (−538, the file is now almost entirely
imports + orchestration + the structural ^XA/^XZ handlers + dynamic
^A{X} fallthrough + findings tail).

flushField.ts: 562 LOC (446 LOC of the switch + 80 LOC of bootstrap-
Variable / applyFnEmbeds + factory shape).

Combined with Stage 3 + Stage 4, the parser has gone from 2466 LOC
in one file to ~200 LOC orchestrator + 14 focused modules under
`src/lib/zplParser/` (types, helpers, context, flushField, 3
decoder modules, 6 handler-family modules). The remaining stage
candidate is per-type splitting of the flushField switch — that's
where the ParserState sub-state grouping would naturally emerge
(text-emit needs only the text-pending slice, etc).

1409 tests pass; lint 0 errors; build green.
The previously-flat 60-field ParserState is now organised into 8
typed sub-slices, each owning a coherent concept:

- `result` (8 fields) — public output accumulators: objects,
  labelConfig, printerProfile, variables, plus the 4 findings
  sinks (skipped, partialCmds, browserLimit, unknown).
- `label` (4 fields) — ^LH / ^LT / ^LR persistent label-frame state
  that applies to every following field until overridden.
- `comment` (3 fields) — ^FX carry + ^FN slot machinery.
  pendingComment → comment.pending, pendingFn → comment.fnNumber,
  pendingFnComment → comment.fnComment.
- `format` (5 fields) — ^FE / ^FC / ^FH / ^CI format-scoped template
  + decoding state. Reset at ^XA / ^XZ to per-format defaults.
- `defaults` (10 fields) — ^CF / ^FW / ^FB / ^BY persistent defaults
  applied to following fields. ^FB is the only group that resets
  after each text field (handled inside flushField); the others
  persist until overridden.
- `fonts` (3 fields) — ^CW alias map + ~DY upload tracking that
  spans the whole parse. fontAliases → fonts.aliases.
- `reverseBg` (top-level, not nested) — ^GB stash for the reverse-
  text collapse. Stays flat because the value is null in the common
  case and the collapse is a one-field stash, not a sub-state.
  pendingReverseBg → reverseBg.
- `field` (32 fields) — per-field accumulator: position (x, y,
  positionIsFT), type discriminator + ^FD payload, ^FR per-field
  reverse, plus 27 per-symbology pending params (text/barcode/
  symbol/qr/dm/pdf/aztec/maxicode/mpdf/codablock/sn/font).
  Populated by handlers, consumed and reset by flushField at ^FS.

Type-safety win: each handler family can now take just the slices
it actually mutates, so a barcode handler accidentally writing
`s.label.lhX` becomes a compile error instead of a silent
correctness bug. Families currently still pass `s` as a whole
because their constructors haven't been updated to narrow the
slice — that's a follow-up refactor that lands per family without
behaviour change.

Migration via `scripts/migrate_parser_state_grouping.local.py` —
mechanical word-boundary regex rewrite of `s.foo` → `s.<group>.foo`
across 8 parser files (420 rewrite sites total: zplParser.ts 22,
flushField.ts 168, barcodes.ts 61, graphics.ts 65, fields.ts 104;
setupScript/labelConfig/unsupported.ts don't touch state directly
so 0 each). The createParserState() factory in context.ts produces
the matching nested literal.

1409 tests pass; lint 0 errors; build green. No behaviour change —
the field-name accesses just move from flat to nested, no logic
flips.
Picks up where e50546e (barcodes narrowed to `(field, defaults)`)
left off. Three review-driven polish items:

- **fields**: drop the redundant `labelConfig` parameter. It was
  reachable via `s.result.labelConfig` already; passing it as a
  separate arg was an artefact of the pre-grouping signature.
  Family now takes `(s, helpers)` and pulls `labelConfig` from
  `s.result` inline. Consistent with every other family's
  `s.result.*` access path.

- **context.ts**: document the narrowing heuristic next to
  `ParserState`. Rule: pass individual slices when the family
  mutates ≤ 2 of them (barcodes → field+defaults, setupScript →
  printerProfile, labelConfig → labelConfig); pass `s` whole when
  ≥ 3 slices, because the long parameter list duplicates the
  ParserState interface without adding type-safety. Sub-slices
  still enforce ownership at the access path even when the whole
  `s` is passed — graphics needs `s.reverseBg` (top-level
  mutation) plus four other slices and stays on `s`, but the
  per-slice access path keeps the intent visible.

- **upsertVariable**: rename from `bootstrapVariable` to surface
  the silent-backfill semantics (the function does both lookup-
  update of an empty `defaultValue` and create). Docstring spells
  out the upsert contract.

- **reverseBg docstring**: acknowledge the parallel with
  `comment.fnNumber` ("nullable pending until next field consumes
  it" — same shape, different home). Document why it's still flat:
  one such field at this level, grouping it solo would add
  ceremony.

Plus discovered (NOT fixed in this commit, ticketed as
`project_ticket_sn_lifecycle_leak`): `s.field.snPending` /
`snIncrement` / `snMode` are grouped under `field.*` but their
reset semantics differ from the rest — only the text-branch of
flushField clears them. Non-text field after ^SF leaks. Pre-
existing, surfaced by the slice grouping.

1409 tests pass; lint 0 errors; build green.
…ping

Pragmatic schulds the prior refactors stepped on but didn't fix.
Seven idiomatic collapses in one pass — every one of them was
already a pattern duplicated across 2-12 sites in the parser
subtree.

**Shared helpers (context.ts):**

- `pushBrowserLimit(result, token)` — replaces the
  `result.skipped.push(X); result.browserLimit.push(X);` paired
  push (6 sites in graphics.ts, 5 inline in unsupported.ts).
  Encodes the `browserLimit ⊆ skipped` subset invariant once.
- `getDefaultTextH(defaults)` / `getDefaultTextW(defaults)` —
  replaces 7 inline `s.defaults.cfHeight || 30` / `cfWidth || 0`
  fallback chains across fields.ts + parseZPL.ts dynamic ^A. The
  "30 / 0 are the historical baselines" invariant now lives in
  one place.
- `getPosType(field)` — collapses 3 sites of
  `const posType: "FT" | "FO" = field.positionIsFT ? "FT" : "FO"`
  in flushField + graphics. The literal-pair can't drift.

**Shared helpers (helpers.ts):**

- `firstChar(rest)` — replaces 4 sites of
  `(rest.trim()[0] ?? "").toUpperCase()` across labelConfig single-
  char enum handlers. The "lenient about leading whitespace,
  case-insensitive" idiom is now one place.
- `readColor(raw)` — replaces 4 `(p[N] ?? "B") as "B" | "W"`
  unchecked type assertions in graphics.ts (^GB / ^GC / ^GD / ^GE
  colour slot). Sibling of `readRotation` with the same
  validate-or-default contract.

**API-surface cleanup (handlers/unsupported.ts):**

- `createUnsupportedHandlers(result: ParserResult)` — was
  `({ skipped, browserLimit })`; now takes the whole result slice
  so the family uses `pushBrowserLimit(result, …)` consistently
  with graphics.

**Documentation (parseZPL.ts):**

- 50-line module-header JSDoc on parseZPL with a pipeline
  diagram (tokenize → dispatch → per-family handlers → flushField
  → findings) + state-layout summary + file map. Orients the
  next contributor without forcing them to reverse-engineer the
  decomposition from imports.

No behavior change. 1409 tests pass; lint 0 errors; build green.
The audit by an external "idiomatic completeness" check flagged
six items pre-push, all addressed here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the monolithic ZPL parser in src/lib/zplParser.ts by modularizing its logic into separate files for context, field flushing, and command handlers (barcodes, fields, graphics, etc.). A review comment identifies a potential TypeScript compilation error in flushField.ts where a string | null property is passed to a function expecting a string, and suggests destructuring the state properties into local variables to ensure robust type narrowing.

Comment thread src/lib/zplParser/flushField.ts
@u8array
u8array merged commit c51f96b into main May 31, 2026
2 checks passed
@u8array
u8array deleted the refactor/parser-context-extraction branch May 31, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant