Skip to content

Replace regex parsing and string concatenation with a real parser and SwiftSyntax - #8

Open
BenjaminBriggs wants to merge 30 commits into
guardian:mainfrom
BenjaminBriggs:feature/byond-regex
Open

Replace regex parsing and string concatenation with a real parser and SwiftSyntax#8
BenjaminBriggs wants to merge 30 commits into
guardian:mainfrom
BenjaminBriggs:feature/byond-regex

Conversation

@BenjaminBriggs

@BenjaminBriggs BenjaminBriggs commented Jun 12, 2026

Copy link
Copy Markdown

Summary

This is a change I've wanted to make for a while but never had the time to do it but using Cluade Fable has helped make this change in a day instead of a week. I ended up rewriting the generator by hand but used Fables atempt as a reference.
This branch replaces both halves of the codegen pipeline with type-safe equivalents, behind a characterization test suite built first:

  • Parsing: the four regexes and remove-and-reparse recursion are gone. Proto source now goes through a fabel written lexer (Lexer.swift) and recursive-descent parser (ProtoParser.swift) producing a tree AST that is flattened into the existing models. Nesting is handled by actual recursion, malformed input throws ParseError with line/column instead of silently dropping fields, and the parser is deliberately lenient about what it skips (custom options, aggregate values, doc comments anywhere, empty statements, service/extend blocks) while staying strict about what it generates from.
  • Generation: string concatenation is replaced by SwiftSyntax builders (Sources/SwiftBuffet/Generator/). Every fragment is parsed at generation time, and the assembled file is re-parsed with SwiftParser before being written, invalid Swift fails the codegen run, not when we try and build.

Bugs fixed along the way

  • Five regex parser bugs (single-line/empty messages, indented or same-line closing braces, deep nesting, nested enums without indented closing braces), characterized as expected failures first, then fixed by the rewrite
  • init?(proto:) assigned the wrong variable / used the Swift property name instead of the proto name in the URL, integer, and message branches (wrong for fields like description)
  • field.type.contains("int") misclassified types like PrintJob as integers; integer conversion force-unwrapped (Int(exactly:)!) now classified by explicit type sets and converted via the matching Swift type (Int/UInt)
  • Nested messages referenced nonexistent proto types (ProtoInner instead of ProtoOuter.Inner); models now carry the full parent path
  • Name collisions (two types flattening to the same Swift name) now fail with a clear error instead of emitting duplicate symbols
  • Single-case enums generated case = 0 (empty name); allow_alias enums generated duplicate raw values, both fixed
  • oneof members are parsed as ordinary fields, preserving the output shape the legacy generator produced

Also in this branch

  • Merge of feature/xcode-27-updates: swiftbuffet.json plugin configuration, Xcode project plugin support, quiet flag rename, repeated-URL field support, CI workflow
  • Test suite migrated to Swift Testing (79 tests in 10 suites, including parameterized round-trip validation and a torture-test proto exercising the full grammar: hex/octal literals, escaped strings, custom options, leading-dot types, allow_alias, three-level nesting)
  • swift-syntax pinned to 600.0.0..<602.0.0 (510 produces different formatting, verified empirically)
  • DocC documentation catalog (getting started, CLI and plugin reference, generated-code anatomy, supported feature matrix) and a rewritten README with the project rationale
  • Generator split into focused files with a ProtoConversion switch and result-builder style throughout

Breaking/visible changes for consumers

  • Generated output changes cosmetically (SwiftSyntax formatting); a one-time diff in generated files
  • Clean builds compile swift-syntax (~15s, cached thereafter)
  • Malformed protos that previously half-generated now fail loudly with positions

Test plan

  • 79 tests across 10 suites (swift test)
  • Golden snapshot tests pin exact generator output; parser swap was verified byte-identical before the generator rewrite
  • Round-trip suite: every corpus proto × all flag combinations re-parses cleanly
  • Example project builds end-to-end through the plugin (exercises swiftbuffet.json)
  • CI workflow added (runs both of the above on macos-15)

🤖 Generated with Claude Code

BenjaminBriggs and others added 29 commits June 11, 2026 12:27
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five inputs the regex parser mishandles are wrapped in XCTExpectFailure:
single-line/empty messages, indented or same-line closing braces, deep
nesting, and nested enums with unindented closing braces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Golden snapshot tests confirm byte-identical generator output. The five
characterized regex bugs (single-line/empty messages, indented and
same-line closing braces, deep nesting, nested enums without indented
closing braces) now parse correctly. Malformed input throws ParseError
with line/column instead of silently dropping fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every declaration is built with SwiftSyntaxBuilder and the assembled file
is re-parsed with SwiftParser as a final validation gate, so invalid
output fails the codegen run rather than the consumer's build. All
indentation/padding logic is gone; BasicFormat owns layout.

Behavior fixes over the legacy generator: init?(proto:) now uses
caseCorrectProtoName for proto property access and assigns the if-let
binding correctly in the URL, integer, and non-optional message branches
(previously wrong for fields whose Swift and proto names differ, e.g.
'description'). Dead writers (CodingKeys, Codable inits, TimeInterval
helper, date formatter) and readFileContents are removed.

Golden snapshots re-baselined to the formatted output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parser was stricter than main on real-world protos, failing whole
files that previously generated fine. Now:

- Doc comments are tolerated anywhere (top level, dangling before a
  closing brace, consecutive runs — the last comment wins), not just
  directly before a field.
- Field option lists are scanned with bracket matching instead of being
  grammar-parsed, so parenthesized custom options like
  [(validate.rules).string.min_len = 1], aggregate { ... } values, and
  float literals can never fail the parse. Only deprecated = true is
  extracted, as before.
- Unknown characters (e.g. ':' in aggregate option values) lex as
  .unknown tokens instead of throwing, so skipped regions can contain
  them; strict parse positions still report them as ParseErrors with
  line/column.
- skipToSemicolon steps over balanced braces so aggregate option values
  cannot end a skip early.
- oneof members are parsed as ordinary fields of the enclosing message,
  matching the properties the legacy generator emitted, instead of being
  silently dropped from generated structs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
field.type.contains("int") routed any type whose name contains 'int'
(e.g. a message type PrintJob) into the integer branch, and the branch
force-unwrapped Int(exactly:)! — a runtime crash for uint64 values above
Int.max and a compile error for unsigned fields (Int assigned to UInt).

Integer scalars are now classified by an explicit type set alongside
swiftType's mapping, convert via their own Swift type, and fail the
initializer instead of crashing. The if-let/else-return-nil shape shared
by URL, integer, and message conversions is one helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sions

dataInit/protoInit referenced protoPrefix + simple name, producing
nonexistent SwiftProtobuf types for nested messages (ProtoInner instead
of ProtoOuter.Inner) — enums already did this correctly via fullName.
Models now carry the full parentPath from the AST (flatten was
discarding everything but the immediate parent), so fullName is correct
at any depth for both messages and enums.

Two proto types flattening to the same top-level Swift struct name
(Order.Item and Invoice.Item -> AppItem) previously emitted duplicate
symbols with no diagnostic; generation now fails with a clear error
naming both types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified empirically: 510's BasicFormat emits 'private (set)' (space
before the parenthesis) so generated output would vary with the
consumer's resolved version; 600 and 601 format identically to the
golden baselines. Both majors stay accepted to coexist with consumers'
macro dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Delete TimeInterval+String.swift: its fileprivate initializer was only
  embedded into generated output by the Codable writer that died with
  the legacy generator; generated Duration handling uses SwiftProtobuf's
  .timeInterval.
- GenerationError now reports the first error's line with an 11-line
  excerpt instead of dumping the entire generated file into the build
  log.
- Token spellings live once on TokenKind: CustomStringConvertible
  replaces the parallel switch in unexpected() and the hand-written
  description argument at every expect() call site.
- RoundTripTests no longer re-parses generated output: the assertion
  could never fire because generateSwiftCode's internal gate throws on
  the same condition first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Duplicate-name guard now covers top-level enums, which share the
  top-level namespace with structs (a nested message Status colliding
  with a top-level enum Status was not caught).
- parseFieldOptions only matches 'deprecated' at an option-name position
  ('[' or a depth-1 comma), so dotted custom options like
  (my.ext).deprecated = true no longer mark the field deprecated.
- skipToSemicolon balances <...> aggregate delimiters like it already
  balanced {...}, since both forms may contain semicolons.
- stripCommonPrefix strips only whole _-delimited words and never strips
  a case name to nothing. Previously a single-case enum generated
  'case  = 0' — invalid Swift the legacy generator emitted silently;
  the SwiftParser validation gate surfaced it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	Sources/SwiftBuffet/Generator.swift
#	Sources/SwiftBuffet/Parser.swift
#	Sources/SwiftBuffet/Utilities.swift
#	Tests/ParserTests.swift
One valid proto3 file exercising the full grammar surface: every scalar,
hex/octal field numbers, single-quoted and escaped strings, empty
statements, doc comments in every legal position, custom options with
aggregate values, oneof with options, all map key kinds, leading-dot
absolute type references, three-level nesting, shadowed nested enums,
negative enum values, allow_alias, reserved, extend, and a streaming
service.

It exposed five real gaps, all fixed:
- empty statements (';') are legal at file, message, enum, and oneof
  scope and now parse
- string literals support '\'-escapes and single-quote delimiters
- integer literals support hex (0x1F) and octal (017) forms
- leading-dot absolute type references parse and normalize away
- allow_alias enums collapse to one Swift case per raw value instead of
  emitting duplicate raw values that fail the consumer's build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All ten test files convert to @suite structs with @test functions and
#expect assertions. Throwing expectations use #expect(throws:) and
inspect the typed error it returns; the ProtoInitTests fragment helper
forwards #_sourceLocation so failures point at the call site. XCTest now
reports zero tests; Swift Testing runs all 79.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Multi-word test names become sentence-case raw identifiers
- RoundTripTests is a parameterized @test(arguments:) over the corpus
  and flag matrix (32 cases reported individually) instead of nested
  loops with Issue.record
- ProtoParserErrorTests inspects the typed error returned by
  #expect(throws:) instead of a do/catch helper
- #_sourceLocation (non-public API) replaced with the public
  SourceLocation(fileID:filePath:line:column:) initializer

Deliberate deviation from the skill's mappings: XCTAssertFalse(x)
converts to #expect(x == false), not #expect(!x), per project style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Module landing page plus five articles: getting started, command-line
usage, plugin configuration (swiftbuffet.json), generated-code anatomy
(type mapping, naming rules, bridging initializers), and the supported
proto feature matrix with limitations. Validated with xcodebuild
docbuild — Xcode's Product > Build Documentation renders the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix doc comments that predate this branch: swiftType documented a
nonexistent isMap parameter; generateSwiftCode and parseProtoFile listed
a fraction of their parameters. Document the non-obvious model
properties (why swiftPrefix lives on ProtoField, SwiftProtobuf's
description_p escaping, the URL-suffix heuristic, the "<key, value>"
map encoding) and why primitiveTypes deliberately excludes plain int
types. Clarify the CLI help for --local-id-messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

This PR rewrites SwiftBuffet’s proto parsing and Swift code generation pipelines to be type-safe and validated: a new lexer + recursive-descent parser replaces regex parsing, and a SwiftSyntax-based generator replaces string concatenation (with a SwiftParser re-parse gate to prevent emitting invalid Swift).

Changes:

  • Replace regex-driven proto parsing with a lexer (Lexer.swift) and recursive-descent parser (ProtoParser.swift) producing a tree AST flattened into existing models.
  • Replace string-concatenation generation with SwiftSyntax builders under Sources/SwiftBuffet/Generator/, adding collision detection and SwiftParser validation.
  • Add a comprehensive Swift Testing suite (goldens, round-trips, torture fixture, error tests) plus DocC + README updates, plugin config support, and CI.

Reviewed changes

Copilot reviewed 38 out of 39 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Tests/UtilitiesTests.swift Adds unit tests for enum-case prefix stripping and snake→camel conversion.
Tests/TortureProtoTests.swift Adds a hostile-but-valid proto fixture exercising broad grammar + generation assertions.
Tests/RoundTripTests.swift Round-trip matrix: corpus protos × generator flag combinations must re-parse as Swift.
Tests/ProtoParserErrorTests.swift Adds negative tests validating ParseError line/column/expected tokens + skip behavior.
Tests/ProtoInitTests.swift Validates generated init?(proto:) conversion logic for key field categories.
Tests/ParserTests.swift Migrates parser tests to Swift Testing and new parseProto entrypoint.
Tests/ParserCorpusTests.swift Adds characterization corpus pinning legacy parsing behavior and prior regex bugs.
Tests/LexerTests.swift Adds lexer tokenization/positioning/error-behavior tests.
Tests/GoldenTests.swift Adds golden snapshot tests for full pipeline output formatting/content.
Tests/GeneratorTests.swift Migrates generator tests to Swift Testing and adds new regression coverage.
Sources/SwiftBuffet/Utilities.swift Updates type utilities, enum prefix stripping rules, and integer type classification helpers.
Sources/SwiftBuffet/TimeInterval+String.swift Removes legacy TimeInterval parsing helper (no longer used).
Sources/SwiftBuffet/Regex.swift Removes legacy regex patterns used by the old parser.
Sources/SwiftBuffet/ProtoParser.swift Adds recursive-descent parser implementing supported proto subset + skipping rules.
Sources/SwiftBuffet/Parser.swift Replaces regex recursion with stable parseProto + flatten flow; updates CLI parsing entrypoint.
Sources/SwiftBuffet/Models.swift Introduces parentPath for nested type identity and improves field name/type mapping helpers.
Sources/SwiftBuffet/main.swift Updates CLI flags (--quiet, --include-protobuf, --store-backing-data) and new throwing generator call.
Sources/SwiftBuffet/Lexer.swift Adds lexer/token model + ParseError with line/column.
Sources/SwiftBuffet/Generator/GeneratorErrors.swift Adds GenerationError (SwiftParser diagnostics) + DuplicateTypeNameError.
Sources/SwiftBuffet/Generator/Generator+ProtoConversion.swift Adds conversion taxonomy + statement templates for init?(proto:).
Sources/SwiftBuffet/Generator/Generator+Messages.swift SwiftSyntax-based struct generation, property docs/deprecation, initializers.
Sources/SwiftBuffet/Generator/Generator+Enums.swift SwiftSyntax-based enum generation incl. allow_alias collapsing + nesting via extensions.
Sources/SwiftBuffet/Generator/Generator.swift Assembles/formats output and re-parses to enforce valid Swift emission.
Sources/SwiftBuffet/Generator.swift Removes legacy string-concatenation generator implementation.
Sources/SwiftBuffet/Documentation.docc/SwiftBuffet.md Adds DocC landing page.
Sources/SwiftBuffet/Documentation.docc/SupportedProtoFeatures.md Documents supported/skipped/rejected proto features and limitations.
Sources/SwiftBuffet/Documentation.docc/PluginConfiguration.md Documents swiftbuffet.json plugin configuration mapping to CLI flags.
Sources/SwiftBuffet/Documentation.docc/GettingStarted.md Adds getting-started guide for plugin usage.
Sources/SwiftBuffet/Documentation.docc/GeneratedCode.md Documents generated code anatomy, naming, type mapping, and bridging rules.
Sources/SwiftBuffet/Documentation.docc/CommandLineUsage.md Documents CLI options, examples, and error modes.
Sources/SwiftBuffet/AST.swift Adds AST node types + flatten into generator models.
README.md Expands README with rationale, examples, plugin config info, and docs pointer.
Plugins/SwiftBuffetPlugin/Plugin.swift Adds swiftbuffet.json support for SwiftPM + Xcode plugin execution and tracks it as an input.
Package.swift Adds SwiftSyntax/SwiftParser dependencies and pins swift-syntax version range.
Package.resolved Updates resolved dependencies to include swift-syntax.
Example/Sources/swiftbuffet.json Adds example plugin configuration file.
Example/Package.swift Excludes swiftbuffet.json from target sources/resources to avoid SwiftPM warnings.
Example/Package.resolved Updates example’s resolved dependencies to include swift-syntax.
.github/workflows/ci.yml Adds CI workflow running tests and building the example package.
Comments suppressed due to low confidence (1)

Sources/SwiftBuffet/Utilities.swift:39

  • primitiveTypes is documented as excluding integer-like proto types (since they need Int/UInt conversion), but the list currently includes several integer flavors (sint32, sfixed32, sint64, sfixed64, fixed32, fixed64). This is misleading and makes it easy for future code to accidentally treat these as passthrough scalars.
let primitiveTypes = [
   "double",
   "float",
   "sint32",
   "sfixed32",

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +139 to +142
if case .identifier("map") = peek().kind, peekNext().kind == .openAngle {
isMap = true
advance() // "map"
try expect(.openAngle)
Comment thread Sources/SwiftBuffet/AST.swift Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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.

4 participants