Replace regex parsing and string concatenation with a real parser and SwiftSyntax - #8
Open
BenjaminBriggs wants to merge 30 commits into
Open
Replace regex parsing and string concatenation with a real parser and SwiftSyntax#8BenjaminBriggs wants to merge 30 commits into
BenjaminBriggs wants to merge 30 commits into
Conversation
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>
There was a problem hiding this comment.
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
primitiveTypesis documented as excluding integer-like proto types (since they needInt/UIntconversion), 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) |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 throwsParseErrorwith 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/extendblocks) while staying strict about what it generates from.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
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 likedescription)field.type.contains("int")misclassified types likePrintJobas integers; integer conversion force-unwrapped (Int(exactly:)!) now classified by explicit type sets and converted via the matching Swift type (Int/UInt)ProtoInnerinstead ofProtoOuter.Inner); models now carry the full parent pathcase = 0(empty name);allow_aliasenums generated duplicate raw values, both fixedAlso in this branch
feature/xcode-27-updates:swiftbuffet.jsonplugin configuration, Xcode project plugin support,quietflag rename, repeated-URL field support, CI workflowallow_alias, three-level nesting)600.0.0..<602.0.0(510 produces different formatting, verified empirically)ProtoConversionswitch and result-builder style throughoutBreaking/visible changes for consumers
Test plan
swift test)swiftbuffet.json)🤖 Generated with Claude Code