Repository navigation
Releases: nerdmenot-swift/assay
Release list
Assay 1.0.0
The first public release. Everything in it is "added" by definition; the highlights that
distinguish it:
@Schemamacro decoding for JSON (straight from bytes into the struct), YAML,
XML and TOML (via a format-neutralRawValueprojection) — noCodable, no
CodingKeys, measured at 5–9× Foundation on the published corpus
(Benchmarks/RESULTS.md; one arm64 Mac, stated as such).- Errors that name the byte: every decode and validation failure carries a code,
structured params, a path, and a source span; four renderers including terminal
carets and RFC 9457 problem details. All the errors are collected, not just the
first — and the error path is faster than Foundation's throw-on-first. - Validation (
@Validate,@Check/@AsyncCheck,@Preprocess,@Transform,
@Fallback) type-checked at macro expansion with purpose-written diagnostics. Date+@DateFormat: hand-written ISO-8601 / unix / RFC 9110 / fixed-pattern
parsers (pure arithmetic, no ICU, Foundation-free core), ordered candidate chains
with warnings on fallback matches, 5.40× Foundation's.iso8601strategy, verified
exact against Foundation on 2,279 instants.[String: T]dictionary fields, fully recursive, with the "worst case"
measured at 6.95× rather than assumed.- Key handling: compile-time key conversion (
.snakeCaseand friends), aliases
that warn which one matched,@Extrasopen maps, unknown-key policies with
Damerau–Levenshtein did-you-mean. - Security by construction: XXE unfetchable, expansion bombs budget-capped,
limits first-class (seeSECURITY.md). - Verification as a feature: differential oracles against JSONSerialization,
Yams/libyaml, and Foundation's XMLParser; deterministic fuzzing; live-allocation
gate; compile-time budget gate (~81 ms per type against a 100 ms ceiling). - Rows and column stores are not part of Assay (2026-09-11).
ColumnarSource,
ColumnDecodable,RowBatch,RowDecoder<T>,RowSinkand@Schema(sources: true)
were built, measured and removed before release. Not for being slow — the columnar path
was the fastest thing here at 11 ns/row — but because nothing depended on it, the audience
is small, and a decoder that also owns column stores is two libraries wearing one name.
It cost ~1,900 lines and doubled the expansion cost of any type that used it.
T.validate(_:)is the answer for a fast external reader: it decodes at its own speed in
its own module, and Assay runs the rules afterwards.ROADMAP.mdkeeps the short record; if
it returns it will be a separate package. - The value model is 2.1× faster (2026-09-11):
JSON.Value.parseallocated a path
array per value in the document, for a diagnostic path nothing reads unless the document
is malformed. The corpus-wide sweep goes 1.51× → 3.35× overJSONSerialization, and
the DOM-vs-DOM gap against yyjson closes from 16× to 7×. - A type mismatch on the YAML/XML/TOML/plist path now carries a caret even when the
field has no rules; previously only JSON did. @Key(_:or:)now actually warns which alias matched (2026-09-10) —alias_matched,
on the JSON and YAML/XML/TOML paths. Three documents had promised it and no code did.- TOML (
AssayTOML, 2026-09-10): a hand-written TOML 1.0.0 parser passing the
official toml-test suite in CI (709 cases at the time of writing), differential against
toml++,
parse(toml:)/diagnose(toml:),SchemaFormats.toml,WireFormat.toml, and
encodedTOML()with nil members omitted and every other null reported
(docs/TOML.md). - Encoding for JSON, YAML, XML and TOML (
@Schema(encodes: true)), with round-trip as a
stated law and a closed exception list (docs/ENCODING.md). - Unions —
@Schema(discriminator: "type")and.untagged— decode and encode,
JSON only (docs/UNIONS.md). Assayer<T>runtime schemas,@Wraps,@Inline,@Key(path:),@OneOrMany,
@XMLplacement and@XML(root:),@Schema(context:),parse(plist:),
parse(body:contentType:accepting:)andjsonSchema(for:).- Collections report one issue per bad element and continue; a dictionary value's
issue names its key (m.j,d.a[1]). A backticked property name (`default`)
decodes. A UTF-8 BOM is skipped.@Checkmisuse, undecodable field types (Set,
tuples,Data,URL,T!, generic structs…) and a non-@Schemanested type all get
purpose-written diagnostics instead ofhas no member '_assay'. - One error type.
JSON.Value.parse,YAML.parseandXML.parsethrowAssayError
like every other entry point, with the source retained, so their failures render carets
too.JSONValueError,YAMLParseErrorandXMLParseErrorare gone. - Errors print.
print(error),"\(diagnosis)"and (withAssayFoundation)
localizedDescriptionshow the caret render; they used to show a reflection dump.
Renamed before release, no deprecation shims: Discriminator.none → .untagged (the
old spelling warned on every use — it resolved to Optional.none), and the assertion
rules .trimmed/.lowercased → .isTrimmed/.isLowercase (they never normalised;
@Preprocess does). Every message-less rule now has an (or:) overload.
Known limitations at this release: unions have no YAML/XML path (docs/UNIONS.md); and,
deliberately deferred with reasons in ROADMAP.md, @Key(path:) refuses index segments,
StandardSchema waits on a third package, there is no incremental parsing of a single
document (a decision, not a gap), and the .past/.future date rules wait on a clock seam.
Changes made before the first tag
No version was tagged before 1.0.0. These are the changes made on main in the weeks
before it, kept for anyone who built against an untagged commit.
Breaking
-
Five names now follow the API Design Guidelines. A naming pass against the guidelines
and against how swift-collections, swift-nio, swift-argument-parser and Foundation spell
the same things:was is why AssayReader.advanceBy(_:)advance(by:)the preposition belongs in the label; the stdlib spells it advanced(by:)Diagnosis.truncatedIssuesissuesWereTruncateda Boolmust read as an assertion —if d.truncatedIssuesreads like a collectionAssayReader.atEndisAtEndFoundation's own Scanner.isAtEndKeyRange.simpleisSimplesame rule as above, and the initialiser label with it PreprocessOpPreprocessStepno abbreviations, and it rhymes with PathStepnow -
PathComponentis nowPathStep.issue.pathis[PathStep], and the cases are
unchanged —.key("email"),.index(3). Vapor exports aPathComponentof its own for
routing, so an app with both imported could not write the bare name as a type: Swift
reports "'PathComponent' is ambiguous for type lookup". Pattern matches and inferred uses
were never affected, which is why it took a collision audit to notice. Renamed rather than
documented because almost nobody spells this type — the macro emits it fully qualified, and
users readissue.path— so the rename costs one line in a migration and removes a
collision permanently. -
EncodedBytes.toArray()is nowArray(_:).Array(try value.encodedJSON())rather
thantry value.encodedJSON().toArray(). The API Design Guidelines put a non-mutating
conversion on the destination type as an initialiser —Array(someSequence),
String(someCharacters)— andtoArray()is the Objective-C spelling of the same idea.
The initialiser isconsuming, so the copy is made and the original freed, which is what a
caller asking for anArraywants. Renamed now because it costs a line here rather than a
deprecation cycle later. -
encodedJSON(),encodedYAML(),encodedXML()andencodedTOML()return
EncodedBytes, not[UInt8]. The writers own the buffer they write into, so a write is
a store rather than anArrayappend with a uniqueness check behind it — 36,004 of those
perbase/encodecall, about a sixth of the call — andfinish()hands the allocation
over instead of copying the document into a freshArray.EncodedBytesis~Copyable
because it owns a heap allocation and must free it exactly once: usewithUnsafeBytesto
write it somewhere with no copy,text()for aString, orArray(_:)where a value type
is genuinely needed, which copies visibly at the call site.jsonText()and friends are
unchanged, andEncodeDiagnosis.bytesstays a plain[UInt8]and staysSendable— it is
the diagnostic path, and one copy there is worth more than the counter. Encoding measures
8.75×JSONEncoderat 50 items against 2.98× before this and the two emitter changes
beside it.docs/EFFICIENCY.mdrows 5 and 22. -
A number that cannot be represented as a
Doubleis now an error, on every format.
1e309decoded to+infinityand1e-400to zero; both are refused with
number_overflowon the JSON and TOML paths, and on the YAML path resolve to a string so
the schema reportsmust be a number, found "1e309". Reading a number as a different
number is the one failure a decoder must not have, and this codebase refuses it elsewhere
(a 128-bit plist integer is refused rather than truncated). Foundation'sJSONDecoder
throws on every one of these and toml++ rejects them, so Assay was the outlier.
Subnormals are values and still decode —5e-324is the least positiveDouble, has
an exact bit pattern, and is accepted. A document relying on a literal silently becoming
infinity or zero will now get an issue. -
IssueCode.xml_expected_elementis removed. It was unreachable: both of the XML
parser'sparseElementcall sites already establish that the current byte is<before
calling, so the guard inside it could never fail. The guard is gone too — a check tha...