diff --git a/Documentation/Architecture/Release1.1/Reading.md b/Documentation/Architecture/Release1.1/Reading.md new file mode 100644 index 0000000..173d029 --- /dev/null +++ b/Documentation/Architecture/Release1.1/Reading.md @@ -0,0 +1,52 @@ +# Scoped reads and native coding + +The additive public surface is JSON/JSONDocument `read(limits:_:)` and +`readWithReport(limits:_:)`, JSONReadCursor and JSONAccessFailure. Throwing read-only +String/JSONKey/Int brackets preserve typed selection. Required value/string/Boolean/ +number/object/array access never coerces, defaults or manufactures null. Integer +conversion is exact; Double/Decimal require an explicit `.exact` or `.rounded` +choice. Both key overloads provide raw/scalar/model typed presence. + +The supported compiler requires experimental features for the intended returned +nonescapable shape. The public implementation uses the qualified guarded fallback. +A handle owns only its bounded typed path and a synchronous non-Sendable scope. +Scope exit invalidates it and releases root/document/source/coding-state references. +A caller-thread guard runs before accessing mutable state; a cross-thread attempt +fails with configuration/invalidOperation. This introduces no actor isolation or +Sendable escape hatch. Public compiler probes and guarded misuse tests are required. + +One noncopyable core-domain meter admits the full root once. Navigation, equivalent +Pointer path text, overlapping path copies, text extraction, numeric work, source +lookup and native model projection use that ledger. Dead path ownership is refunded +at the next owned boundary; work is never refunded. Paths retain literal scalar keys +and integer indices; missing intermediate parents fail at their first missing hop. +Grouping calls amortizes root admission. It does not selectively parse the input or +make all deep navigation, root admission, edits or arbitrary caller work constant-time. + +Coding options temporarily select native strategies and userInfo while their limits +permanently tighten the scope's ceilings. Prior cumulative usage and already admitted +capacities must fit that restriction; live retention is checked against current live +ownership. No state/meter copy or new decoder facade resets the budget. Numeric +temporary storage is released after primitive projection. Returned values and caller +model computation retain the ordinary caller-work and retention boundaries. + +Resource/cancellation failure is latched at the owned meter/scope. Even a caller +Decodable that catches an owned conversion refusal cannot certify successful scope +or native facade completion. Ordinary access/type/conversion errors can be caught +explicitly. JSONAccessFailure holds a complete bounded typed path and structured +cause, with no rejected value or document. Mapped missing members use their parent +span; replaced/unmapped roots have no invented location. Diagnostic classification +uses the cause and never renders arbitrary caller descriptions. + +JSON `init(encoding:coding:)`, `decode(_:coding:)` and `decodeWithReport(_:coding:)` +delegate to the existing native facades. Natural foreign JSON Codable keeps its +representation and fidelity limitations. Exact persistence uses a separate explicit +Version 1 wrapper. No new kind, dictionary authority, defaulting, dynamic-member +lookup, mutation bracket or projection parser is introduced. + +The new modern-date compatibility case exposed an existing ISO-8601 strategy bug: +the formatter selected only time fields. Both native writer and reader now explicitly +select year/month/day plus fractional UTC time. Baseline epoch-only tests could not +detect losing the calendar date. Record this as a real affected coding qualification +failure and refresh its native/safety/consumer/DocC evidence; do not redefine it as +permitted rounding or silently reduce the assertion. diff --git a/Scripts/ci.py b/Scripts/ci.py index 5f05c01..c32f7ed 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -23,6 +23,7 @@ def commands(lane, output): ['swift', 'build', '-c', 'release'], ['swift', 'test'], ['swift', 'test', '-c', 'release'], ['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'ownership', '--output', str(output / 'ownership.json')], + ['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'reader', '--output', str(output / 'reader.json')], ['swift', 'run', '-c', 'release', '--package-path', 'Examples/Consumer', 'Consumer'], ['swift', 'test', '--package-path', 'Tools/MCPReadiness'], ['swift', 'run', '-c', 'release', '--package-path', 'Tools/MCPReadiness', 'MCPReadiness', str(output / 'consumer.json')], @@ -68,7 +69,7 @@ def check_result(command, code, log): counts = [int(n) for n in re.findall(r'Test run with (\d+) tests? in[^\n]*passed', log)] passed = code == 0 if command[:2] == ['swift', 'test']: - expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 2] if 'thread' in command else [9, 11, 12, 38, 68] + expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 2] if 'thread' in command else [9, 11, 12, 38, 78] passed = passed and sorted(counts) == expected if 'Tools/Qualification' in command: probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE) diff --git a/Scripts/qualify_release_1_1.py b/Scripts/qualify_release_1_1.py index 696fc01..2022260 100644 --- a/Scripts/qualify_release_1_1.py +++ b/Scripts/qualify_release_1_1.py @@ -41,7 +41,7 @@ def verify_index_samples(samples): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--mode', choices=['ownership', 'sdk', 'mobile', 'index'], required=True) + parser.add_argument('--mode', choices=['ownership', 'reader', 'sdk', 'mobile', 'index'], required=True) parser.add_argument('--output', default='.build/ci/r0/probe-receipt.json') args = parser.parse_args() output = ROOT / args.output @@ -94,6 +94,16 @@ def run(name, command, expected=0, diagnostic=None): run('lifetime-feature-boundary', swift + ['-typecheck', str(PROBES / 'LifetimeAnnotated.swift')], 1, "'@_lifetime' attribute is only valid when experimental feature Lifetimes is enabled") receipt['positive_commands'] = 3; receipt['negative_compiler_cases'] = 4 + elif args.mode == 'reader': + binary = directory / 'public-reader-consumer' + imports = ['-I', str(products)] + run('public-consumer-compile', swift + imports + ['-L', str(products), '-lJSON', str(PROBES / 'PublicReaderConsumer.swift'), '-o', str(binary)]) + result = run('public-consumer-run', [str(binary)]) + if 'PUBLIC_READER_CONSUMER passed' not in result: raise RuntimeError('Missing public reader outcomes') + run('public-sendable-negative', swift + imports + ['-typecheck', str(PROBES / 'PublicTaskEscape.swift')], 1, "capture of 'cursor' with non-Sendable type") + run('public-task-transfer-negative', swift + imports + ['-c', str(PROBES / 'PublicTaskTransfer.swift'), '-o', str(directory / 'illegal-public.o')], 1, + "passing closure as a 'sending' parameter risks causing data races") + receipt['positive_commands'] = 2; receipt['negative_compiler_cases'] = 2 elif args.mode == 'sdk': for sdk, target in [('macosx', 'arm64-apple-macosx27.0'), ('iphoneos', 'arm64-apple-ios27.0'), ('iphonesimulator', 'arm64-apple-ios27.0-simulator')]: path = run(sdk + '-path', ['xcrun', '--sdk', sdk, '--show-sdk-path']).strip() diff --git a/Sources/JSON/Coding/JSONCodingStrategies.swift b/Sources/JSON/Coding/JSONCodingStrategies.swift index d65478c..17a4fd0 100644 --- a/Sources/JSON/Coding/JSONCodingStrategies.swift +++ b/Sources/JSON/Coding/JSONCodingStrategies.swift @@ -43,10 +43,11 @@ public struct JSONCodingOptions: Sendable { } package final class JSONCodingState { package var meter: JSONWorkMeter - package let options: JSONCodingOptions + package private(set) var options: JSONCodingOptions package var failure: (any Error)? package private(set) var bridgedUserInfo: [CodingUserInfoKey: Any] = [:] private var prepared = false + private var preparedBytes: UInt64 = 0 package init(options: JSONCodingOptions, meter: consuming JSONWorkMeter) { self.options = options; self.meter = meter } @@ -55,13 +56,27 @@ package final class JSONCodingState { if !prepared { try meter.require(.objectMembers, UInt64(options.userInfo.count)) for (key, value) in options.userInfo { + let before = meter.used(.retainedBytes) try JSONAdmission.text(key.rawValue, key: true, meter: &meter, owned: true) try meter.work(); try meter.retain(JSONLogicalWeights.member) bridgedUserInfo[key] = value + preparedBytes += meter.used(.retainedBytes) - before } prepared = true } } + package func withOptions(_ requested: JSONCodingOptions, _ body: () throws -> T) throws -> T { + try meter.restrict(to: requested.limits) + let oldOptions = options, oldInfo = bridgedUserInfo, oldPrepared = prepared, oldBytes = preparedBytes + options = requested; bridgedUserInfo = [:]; prepared = false; preparedBytes = 0 + func restore() throws { + try meter.release(preparedBytes) + options = oldOptions; bridgedUserInfo = oldInfo; prepared = oldPrepared; preparedBytes = oldBytes + } + do { + try check(); let result = try body(); try check(); try restore(); return result + } catch { try restore(); throw error } + } package func latch(_ action: () throws -> Void) { do { try action() } catch { if failure == nil { failure = error } } } package func path(_ path: [any CodingKey], appending key: any CodingKey) throws -> [any CodingKey] { try check(); try meter.require(.maxDepth, UInt64(path.count) + 1) diff --git a/Sources/JSON/Coding/JSONValueConveniences.swift b/Sources/JSON/Coding/JSONValueConveniences.swift new file mode 100644 index 0000000..56b66ab --- /dev/null +++ b/Sources/JSON/Coding/JSONValueConveniences.swift @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +extension JSON { + /// Encodes a Swift model directly through the existing native value coder. + /// No Foundation serialization intermediate or persistence envelope is introduced. + /// - Parameters: + /// - value: The Encodable model; caller callback errors propagate. + /// - coding: Native Date/Data strategies, Sendable context and finite limits. + /// - Throws: Native/caller coding errors, resource/configuration failure or cancellation. + public init(encoding value: T, coding: JSONCodingOptions = .init()) throws { + self = try JSONValueEncoder(options: coding).encode(value) + } + /// Decodes a Swift model with the existing native strategies and exact integers. + /// - Parameters: + /// - type: The target Decodable model. + /// - coding: Native strategies, Sendable context and finite limits. + /// - Throws: Native/caller coding errors, resource/configuration failure or cancellation. + public func decode(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> T { + try JSONValueDecoder(options: coding).decode(type, from: self) + } + /// Decodes a Swift model and reports successful owned native coding work. + /// - Parameters: + /// - type: The target Decodable model. + /// - coding: Native strategies, Sendable context and finite limits. + /// - Throws: Native/caller coding errors, resource/configuration failure or cancellation. + public func decodeWithReport(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONOperationReport { + try JSONValueDecoder(options: coding).decodeWithReport(type, from: self) + } +} diff --git a/Sources/JSON/Coding/JSONValueDecoder.swift b/Sources/JSON/Coding/JSONValueDecoder.swift index cabbe2f..323ff74 100644 --- a/Sources/JSON/Coding/JSONValueDecoder.swift +++ b/Sources/JSON/Coding/JSONValueDecoder.swift @@ -87,7 +87,7 @@ package final class JSONNativeDecoder: Decoder { guard length <= 128 else { throw DecodingError.dataCorrupted(.init(codingPath: codingPath,debugDescription: "ISO-8601 text exceeds fixed format capacity")) } } try state.meter.checkpoint() - let strategy = Date.ISO8601FormatStyle().timeZone(separator: .colon).time(includingFractionalSeconds: true) + let strategy = Date.ISO8601FormatStyle().year().month().day().timeZone(separator: .colon).time(includingFractionalSeconds: true) guard let date = try? strategy.parse(string) else { throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Invalid ISO-8601 date")) } try state.meter.checkpoint(); return date as! T } diff --git a/Sources/JSON/Coding/JSONValueEncoder.swift b/Sources/JSON/Coding/JSONValueEncoder.swift index 412da71..c215974 100644 --- a/Sources/JSON/Coding/JSONValueEncoder.swift +++ b/Sources/JSON/Coding/JSONValueEncoder.swift @@ -159,7 +159,7 @@ package final class JSONNativeEncoder: Encoder { throw EncodingError.invalidValue(date,.init(codingPath: codingPath,debugDescription: "ISO-8601 year must be 0000...9999")) } try state.meter.retain(128); try state.meter.checkpoint() - let text = date.ISO8601Format(.iso8601.timeZone(separator: .colon).time(includingFractionalSeconds: true)) + let text = date.ISO8601Format(.iso8601.year().month().day().timeZone(separator: .colon).time(includingFractionalSeconds: true)) try state.meter.checkpoint(); try set(.string(text)); try state.meter.release(128) } } else { try value.encode(to: self) } diff --git a/Sources/JSON/Diagnostics/JSONDiagnostic.swift b/Sources/JSON/Diagnostics/JSONDiagnostic.swift index 4385b7d..38a5acf 100644 --- a/Sources/JSON/Diagnostics/JSONDiagnostic.swift +++ b/Sources/JSON/Diagnostics/JSONDiagnostic.swift @@ -22,6 +22,7 @@ public struct JSONDiagnostic: Sendable, Equatable { /// - error: The error to classify without rendering its arbitrary description. public init(_ error: any Error) { if let error = error as? JSONFailure { kind = .failure; failure = error } + else if let error = error as? JSONAccessFailure { kind = .failure; failure = error.cause } else if error is CancellationError { kind = .cancellation; failure = nil } else { kind = .callerError; failure = nil } } diff --git a/Sources/JSON/Document/JSONSourceMap.swift b/Sources/JSON/Document/JSONSourceMap.swift index 2c1d783..3df9aac 100644 --- a/Sources/JSON/Document/JSONSourceMap.swift +++ b/Sources/JSON/Document/JSONSourceMap.swift @@ -39,6 +39,9 @@ public struct JSONSourceMap: Sendable { /// - limits: Finite inclusive capacities and work quotas for this call. public func span(at path: [JSONLocationComponent], kind: Entry.Kind = .value, limits: JSONLimits = .balanced) throws -> JSONSourceSpan? { var meter = try JSONWorkMeter(limits: limits, domain: .core) + return try span(at: path, kind: kind, meter: &meter) + } + package func span(at path: [JSONLocationComponent], kind: Entry.Kind = .value, meter: inout JSONWorkMeter) throws -> JSONSourceSpan? { for entry in entries { try meter.work() if entry.kind != kind || entry.path.count != path.count { continue } diff --git a/Sources/JSON/Documentation.docc/JSON.md b/Sources/JSON/Documentation.docc/JSON.md index 20ae563..f5b82b5 100644 --- a/Sources/JSON/Documentation.docc/JSON.md +++ b/Sources/JSON/Documentation.docc/JSON.md @@ -83,3 +83,9 @@ belongs exclusively to each invocation. - ``JSONResourceMetric`` - ``JSONOperationStatistics`` - ``JSONOperationReport`` + +### Scoped reads + +- +- ``JSONReadCursor`` +- ``JSONAccessFailure`` diff --git a/Sources/JSON/Documentation.docc/ScopedReads.md b/Sources/JSON/Documentation.docc/ScopedReads.md new file mode 100644 index 0000000..e0783e6 --- /dev/null +++ b/Sources/JSON/Documentation.docc/ScopedReads.md @@ -0,0 +1,39 @@ +# Navigate and decode within one read scope + +Group related field reads under one finite cumulative budget. + +```swift +import JSON + +let response = try JSONParser().parse( + #"{"users":[{"name":"Ada","age":9007199254740993,"nickname":null}]}"#) +let report = try response.readWithReport { root in + let user = try root["users"][0] + let name = try user["name"].requiredString() + let age = try user["age"].requiredInteger(as: UInt64.self) + return (name, age, try user.stringPresence(of: "nickname")) +} +``` + +``JSONReadCursor`` brackets require a present member or valid array element. An +integer bracket never means a numeric object key. Keys are literal decoded scalar +sequences: slash, tilde, empty and dotted keys need no special bracket syntax. +Use parent presence to distinguish absence, explicit null and a typed non-null +value. Wrong kinds and incompatible models throw; they do not select a default. + +Required integer conversion preserves values above 2^53 and checks the target range. +For floating point, choose the fidelity explicitly: decimal 0.1 rejects +`requiredDouble(policy: .exact)` and permits finite `.rounded` conversion. +Native model projection uses `decode(_:coding:)`; native Float/Double models keep +their existing finite-rounding semantics. + +Root admission runs once. Owned path/copy/search/conversion/model work shares one +ledger, including prior work when coding limits tighten it. The report's core domain +and numeric counters describe owned operations, not caller instructions or RSS. +Catching resource failure or cancellation inside the callback cannot produce success. + +Cursors are synchronous and non-Sendable. Escaped handles become unusable, and the +scope releases its root and source references. Return ordinary values or models from +the callback. Later direct collection operations follow their existing contracts. +``JSONAccessFailure`` describes ordinary failures with a complete bounded typed path; +``JSONDocument`` reads can add coherent source locations. Replaced roots have none. diff --git a/Sources/JSON/Errors/JSONAccessFailure.swift b/Sources/JSON/Errors/JSONAccessFailure.swift new file mode 100644 index 0000000..b2ceed8 --- /dev/null +++ b/Sources/JSON/Errors/JSONAccessFailure.swift @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +/// An ordinary read failure with a complete bounded typed path and no JSON payload. +/// +/// Resource failures, limit configuration errors and cancellation keep their own +/// types. A mapped read can attach the selected value or missing member's parent +/// location to the cause. Replaced/unmapped documents never invent a location. +public struct JSONAccessFailure: Error, Sendable { + /// The stable navigation, kind or conversion failure, with an optional source location. + public let cause: JSONFailure + /// The exact attempted key/index path, including the failing hop. + public let path: [JSONLocationComponent] + package init(cause: JSONFailure, path: [JSONLocationComponent]) { self.cause = cause; self.path = path } +} diff --git a/Sources/JSON/Limits/JSONWorkMeter.swift b/Sources/JSON/Limits/JSONWorkMeter.swift index 2366971..79b6332 100644 --- a/Sources/JSON/Limits/JSONWorkMeter.swift +++ b/Sources/JSON/Limits/JSONWorkMeter.swift @@ -1,7 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 /// Noncopyable so a nested helper cannot accidentally fork an invocation's budget. +private final class JSONMeterHealth { + var failure: (any Error)? + var observed = Array(repeating: UInt64(0), count: JSONResourceMetric.allCases.count) +} package struct JSONWorkMeter: ~Copyable { - package let limits: JSONLimits + package private(set) var limits: JSONLimits + private let health = JSONMeterHealth() + private let trackAcceptedCapacities: Bool package let domain: JSONOperationDomain private var counters: [UInt64] private var retired: [UInt64] @@ -13,29 +19,52 @@ package struct JSONWorkMeter: ~Copyable { /// Observer execution itself is trusted test code, outside owned work quotas. package var checkpointObserver: (@Sendable (UInt64) -> Void)? package mutating func schemaEvaluationPhase(_ active: Bool) { compilingEvaluation = active } - package init(limits: JSONLimits, domain: JSONOperationDomain) throws { + package init(limits: JSONLimits, domain: JSONOperationDomain, trackAcceptedCapacities: Bool = false) throws { try Task.checkCancellation() try limits.validate() self.limits = limits; self.domain = domain + self.trackAcceptedCapacities = trackAcceptedCapacities || domain == .coding counters = Array(repeating: 0, count: JSONResourceMetric.allCases.count) retired = Array(repeating: 0, count: JSONResourceMetric.allCases.count) } package func checkpoint() throws { - checkpointObserver?(used(.workUnits)) - try Task.checkCancellation() + if let failure = health.failure { throw failure } + do { + checkpointObserver?(used(.workUnits)) + try Task.checkCancellation() + } catch { health.failure = error; throw error } } package func used(_ metric: JSONResourceMetric) -> UInt64 { counters[metric.index] } package func require(_ metric: JSONResourceMetric, _ attempted: UInt64) throws { - guard attempted <= limits[metric] else { throw violation(metric, attempted) } + if let failure = health.failure { throw failure } + guard attempted <= limits[metric] else { + let failure = violation(metric, attempted); health.failure = failure; throw failure + } + // Fixed-policy operations never tighten after admission. Coding and read + // scopes retain the capacity history needed by restrict. Cumulative work + // already lives in counters; live ownership must use current, not peak. + if trackAcceptedCapacities { + switch metric { + case .workUnits, .numericWorkUnits, .compileWorkUnits, .evaluationWorkUnits, + .patchWorkUnits, .totalWorkUnits, .regexCompileWorkUnits, + .regexSearchWorkUnits, .aggregateRegexWorkUnits, .retainedBytes, + .regexRetainedBytes, .annotationBytes, .compiledPlanBytes: break + default: health.observed[metric.index] = max(health.observed[metric.index], attempted) + } + } } private func violation(_ metric: JSONResourceMetric, _ attempted: UInt64?, overflow: Bool = false) -> JSONFailure { - JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, + let failure = JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, resource: .init(domain: domain, metric: metric, limit: limits[metric], attemptedUsage: attempted, overflow: overflow)) + if health.failure == nil { health.failure = failure } + return failure } package func adding(_ metric: JSONResourceMetric, _ current: UInt64, _ amount: UInt64) throws -> UInt64 { let (next, overflow) = current.addingReportingOverflow(amount) - guard !overflow else { throw violation(metric, nil, overflow: true) } + guard !overflow else { + let failure = violation(metric, nil, overflow: true); health.failure = failure; throw failure + } try require(metric, next) return next } @@ -83,6 +112,19 @@ package struct JSONWorkMeter: ~Copyable { if let plan { counters[JSONResourceMetric.compiledPlanBytes.index] = plan } counters[JSONResourceMetric.retainedBytes.index] = retained; peak = max(peak,retained) } + /// Tightening keeps the same ledger. Earlier accepted capacities and consumed + /// work must fit the new ceiling; failures stay terminal even if caught. + package mutating func restrict(to requested: JSONLimits) throws { + try checkpoint() + guard trackAcceptedCapacities else { throw JSONFailure(.configuration, code: .invalidOperation) } + let restricted = try limits.intersection(requested) + limits = restricted + for metric in JSONResourceMetric.allCases where metric != .cancellationQuantum { + let live = metric == .retainedBytes || metric == .regexRetainedBytes || metric == .annotationBytes || metric == .compiledPlanBytes + try require(metric, live ? used(metric) : max(used(metric), health.observed[metric.index])) + } + sinceCancellationCheck = 0 + } /// Only live ownership is refunded; work and lifetime admission remain monotonic. package mutating func release(_ bytes: UInt64) throws { guard bytes <= used(.retainedBytes) else { throw JSONFailure(.configuration, code: .invalidLimits) } diff --git a/Sources/JSON/Number/JSONNumberConversions.swift b/Sources/JSON/Number/JSONNumberConversions.swift index 0990214..46e6a3e 100644 --- a/Sources/JSON/Number/JSONNumberConversions.swift +++ b/Sources/JSON/Number/JSONNumberConversions.swift @@ -147,6 +147,9 @@ extension JSONNumber { /// - limits: Finite inclusive capacities and work quotas for this call. public func doubleValue(policy: JSONNumberConversionPolicy = .exact, limits: JSONLimits = .balanced) throws -> Double { var meter = try JSONWorkMeter(limits: limits, domain: .core) + return try doubleValue(policy: policy, meter: &meter) + } + package func doubleValue(policy: JSONNumberConversionPolicy, meter: inout JSONWorkMeter) throws -> Double { let text = try binaryConversionText(meter: &meter) try meter.checkpoint() guard let value = Double(text), value.isFinite else { throw JSONFailure(.conversion, code: .inexactConversion) } @@ -170,6 +173,9 @@ extension JSONNumber { /// - limits: Finite inclusive capacities and work quotas for this call. public func decimalValue(policy: JSONNumberConversionPolicy = .exact, limits: JSONLimits = .balanced) throws -> Decimal { var meter = try JSONWorkMeter(limits: limits, domain: .core) + return try decimalValue(policy: policy, meter: &meter) + } + package func decimalValue(policy: JSONNumberConversionPolicy, meter: inout JSONWorkMeter) throws -> Decimal { let c = components guard c.digits.count <= 39, c.exponent >= -128, c.exponent <= 165 else { throw JSONFailure(.conversion, code: .inexactConversion) diff --git a/Sources/JSON/Reading/JSONReadCursor.swift b/Sources/JSON/Reading/JSONReadCursor.swift new file mode 100644 index 0000000..b494f2f --- /dev/null +++ b/Sources/JSON/Reading/JSONReadCursor.swift @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Synchronous typed access to one admitted immutable root under a shared budget. +/// +/// A cursor is deliberately not Sendable. Escaped handles become unusable when +/// their read scope exits; they retain no selected value or source snapshot. +/// Ordinary failures carry JSONAccessFailure's complete typed path. Resource +/// failure and cancellation are terminal even if caught inside the callback. +public struct JSONReadCursor { + private let scope: JSONReadScope + private let selection: JSONReadPath + private var path: [JSONLocationComponent] { selection.components } + package init(scope: JSONReadScope, selection: JSONReadPath) { self.scope = scope; self.selection = selection } + /// Requires an exactly matching object member; keys are literal decoded scalars. + /// - Parameter key: The member name; empty, slash, tilde and dotted keys are literal. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public subscript(_ key: String) -> Self { + get throws { + try scope.run(path) { state in + try JSONAdmission.text(key, key: true, meter: &state.meter) + return try child(.key(JSONKey(key, meter: &state.meter)), state: state) + } + } + } + /// Requires an exactly matching object member using an existing scalar key. + /// - Parameter key: The exact member key, without canonical normalization. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public subscript(_ key: JSONKey) -> Self { + get throws { + try scope.run(path) { state in + try JSONAdmission.text(key.string, key: true, meter: &state.meter) + return try child(.key(key), state: state) + } + } + } + /// Requires an array and a nonnegative in-range element; numeric keys stay keys. + /// - Parameter index: The zero-based element index, without negative indexing. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public subscript(_ index: Int) -> Self { + get throws { try scope.run(path) { try child(.index(index), state: $0) } } + } + private func child(_ part: JSONLocationComponent, state: JSONCodingState) throws -> Self { + let attempted = try scope.append(part, to: path, state: state) + return try scope.run(attempted.components) { state in + _ = try scope.select(attempted.components, state: state) + return Self(scope: scope, selection: attempted) + } + } + private func selected(_ body: (JSON, JSONCodingState) throws -> T) throws -> T { + try scope.run(path) { state in try body(scope.select(path, state: state), state) } + } + /// Returns the selected value, including JSON null, without coercion. + /// Subsequent caller collection operations retain their ordinary accounting contracts. + /// - Throws: Bounded access failure, resource/configuration failure or cancellation. + public func requiredValue() throws -> JSON { try selected { value, state in try state.meter.work(); return value } } + /// Requires a JSON string and admits its returned text under the active meter. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredString() throws -> String { + try selected { value, state in + guard case .string(let text) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + try JSONAdmission.text(text, meter: &state.meter, owned: true) + try state.meter.release(state.meter.used(.retainedBytes) - before) + return text + } + } + /// Requires a JSON Boolean; numbers and strings are not coerced. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredBoolean() throws -> Bool { + try selected { value, _ in + guard case .boolean(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an exact JSON number, retaining its admitted original lexeme. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredNumber() throws -> JSONNumber { + try selected { value, _ in + guard case .number(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an ordered exact-key object; later direct lookup is caller work. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredObject() throws -> JSONObject { + try selected { value, _ in + guard case .object(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an array; later direct collection traversal is caller work. + /// - Throws: Wrong-kind access failure, resource/configuration failure or cancellation. + public func requiredArray() throws -> [JSON] { + try selected { value, _ in + guard case .array(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) }; return result + } + } + /// Requires an integral exact number within the target's signed/unsigned range. + /// - Parameter type: The requested fixed-width integer type. + /// - Throws: Kind/conversion access failure, resource/configuration failure or cancellation. + public func requiredInteger(as type: T.Type) throws -> T { + try selected { value, state in + guard case .number(let number) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + return try number.integer(as: type, meter: &state.meter) + } + } + /// Projects an exact number to a finite Double with an explicit fidelity choice. + /// Decimal 0.1 rejects `.exact`; `.rounded` permits finite approximation. + /// - Parameter policy: Mathematical equality or explicitly permitted finite rounding. + /// - Throws: Kind/conversion access failure, resource/configuration failure or cancellation. + public func requiredDouble(policy: JSONNumberConversionPolicy) throws -> Double { + try selected { value, state in + guard case .number(let number) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + do { + let result = try number.doubleValue(policy: policy, meter: &state.meter) + try state.meter.release(state.meter.used(.retainedBytes) - before); return result + } catch { try state.meter.release(state.meter.used(.retainedBytes) - before); throw error } + } + } + /// Projects an exact number to finite Decimal with an explicit fidelity choice. + /// - Parameter policy: Mathematical equality or explicitly permitted finite rounding. + /// - Throws: Kind/conversion access failure, resource/configuration failure or cancellation. + public func requiredDecimal(policy: JSONNumberConversionPolicy) throws -> Decimal { + try selected { value, state in + guard case .number(let number) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + do { + let result = try number.decimalValue(policy: policy, meter: &state.meter) + try state.meter.release(state.meter.used(.retainedBytes) - before); return result + } catch { try state.meter.release(state.meter.used(.retainedBytes) - before); throw error } + } + } + private func field(_ key: JSONKey, _ body: (JSON, JSONCodingState) throws -> T) throws -> JSONPresence { + try scope.run(path) { state in + try JSONAdmission.text(key.string, key: true, meter: &state.meter) + let attempted = try scope.append(.key(key), to: path, state: state) + return try scope.run(attempted.components) { state in + guard case .object(let object) = try scope.select(path, state: state) else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard let value = try object.lookup(key.string, meter: &state.meter) else { return .absent } + if value.isNull { return .null } + return .value(try body(value, state)) + } + } + } + private func admitted(_ key: String, _ body: (JSONKey) throws -> JSONPresence) throws -> JSONPresence { + try scope.run(path) { state in + try JSONAdmission.text(key, key: true, meter: &state.meter) + return try body(JSONKey(key, meter: &state.meter)) + } + } + /// Distinguishes absence, explicit null and an existing member's exact value. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func presence(of key: JSONKey) throws -> JSONPresence { try field(key) { value, state in try state.meter.work(); return value } } + /// Distinguishes absence, explicit null and an existing member's exact value. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func presence(of key: String) throws -> JSONPresence { try admitted(key) { try presence(of: $0) } } + + /// Distinguishes absence and null while requiring a non-null JSON string. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func stringPresence(of key: JSONKey) throws -> JSONPresence { + try field(key) { value, state in + guard case .string(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + let before = state.meter.used(.retainedBytes) + try JSONAdmission.text(result, meter: &state.meter, owned: true) + try state.meter.release(state.meter.used(.retainedBytes) - before) + return result + } + } + /// Distinguishes absence and null while requiring a non-null JSON string. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func stringPresence(of key: String) throws -> JSONPresence { try admitted(key) { try stringPresence(of: $0) } } + + /// Distinguishes absence and null while requiring a non-null JSON boolean. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func booleanPresence(of key: JSONKey) throws -> JSONPresence { + try field(key) { value, state in + guard case .boolean(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + try state.meter.work() + return result + } + } + /// Distinguishes absence and null while requiring a non-null JSON boolean. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func booleanPresence(of key: String) throws -> JSONPresence { try admitted(key) { try booleanPresence(of: $0) } } + + /// Distinguishes absence and null while requiring a non-null JSON number. + /// - Parameter key: The exact decoded-scalar member key. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func numberPresence(of key: JSONKey) throws -> JSONPresence { + try field(key) { value, state in + guard case .number(let result) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + try state.meter.work() + return result + } + } + /// Distinguishes absence and null while requiring a non-null JSON number. + /// - Parameter key: The literal decoded-scalar member name. + /// - Throws: Kind/access failure, resource/configuration failure or cancellation. + public func numberPresence(of key: String) throws -> JSONPresence { try admitted(key) { try numberPresence(of: $0) } } + private func project(_ type: T.Type, value: JSON, coding: JSONCodingOptions, state: JSONCodingState) throws -> T { + let before = state.meter.used(.retainedBytes) + do { + let result = try state.withOptions(coding) { try JSONNativeDecoder(value: value, state: state, codingPath: []).decode(type) } + try state.meter.release(state.meter.used(.retainedBytes) - before) + return result + } catch { try state.meter.release(state.meter.used(.retainedBytes) - before); throw error } + } + /// Projects the selected value through the existing native decoder and ledger. + /// Coding ceilings permanently tighten this scope against prior usage. Caller + /// Decodable computation remains caller work; its errors propagate. + /// - Parameters: + /// - type: The target Decodable model. + /// - coding: Native strategies/context and intersected finite limits. + /// - Throws: Native/caller decoding errors, resource/configuration failure or cancellation. + public func decode(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> T { + try selected { value, state in + try project(type, value: value, coding: coding, state: state) + } + } + /// Decodes an existing non-null member, without calling the model for absence/null. + /// - Parameters: + /// - key: The exact decoded-scalar member key. + /// - type: The target Decodable model. + /// - coding: Native strategies/context and intersected finite limits. + /// - Throws: Native/caller decoding errors, resource/configuration failure or cancellation. + public func presence(of key: JSONKey, as type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONPresence { + try field(key) { value, state in + try project(type, value: value, coding: coding, state: state) + } + } + /// Decodes an existing non-null member, without calling the model for absence/null. + /// - Parameters: + /// - key: The literal decoded-scalar member name. + /// - type: The target Decodable model. + /// - coding: Native strategies/context and intersected finite limits. + /// - Throws: Native/caller decoding errors, resource/configuration failure or cancellation. + public func presence(of key: String, as type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONPresence { + try scope.run(path) { state in + try JSONAdmission.text(key, key: true, meter: &state.meter) + return try presence(of: JSONKey(key, meter: &state.meter), as: type, coding: coding) + } + } +} + +extension JSON { + /// Runs related typed reads with one root admission and cumulative finite budget. + /// The synchronous callback receives a non-Sendable, scope-bound cursor. + /// - Parameters: + /// - limits: Inclusive root, path, work, numeric and logical retention ceilings. + /// - body: Caller computation; returned payloads retain ordinary value semantics. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func read(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> Result { + try readWithReport(limits: limits, body).result + } + /// Runs related reads and reports all successful owned work in the core domain. + /// Catching terminal failure in the callback cannot produce a successful report. + /// - Parameters: + /// - limits: Inclusive root, path, work, numeric and logical retention ceilings. + /// - body: Synchronous caller computation, outside owned instruction/RSS claims. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func readWithReport(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> JSONOperationReport { + try JSONDocument(root: self).readWithReport(limits: limits, body) + } +} +extension JSONDocument { + /// Runs related typed reads with optional coherent mapped source diagnostics. + /// - Parameters: + /// - limits: Inclusive root, source, path and cumulative work/retention ceilings. + /// - body: Synchronous caller computation using a scope-bound non-Sendable cursor. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func read(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> Result { + try readWithReport(limits: limits, body).result + } + /// Reports successful reads while preserving this document's source coherence. + /// Scope exit releases library root/source ownership and invalidates escaped handles. + /// - Parameters: + /// - limits: Inclusive root, source, path and cumulative work/retention ceilings. + /// - body: Synchronous caller computation, outside owned instruction/RSS claims. + /// - Throws: Access/caller errors, resource/configuration failure or cancellation. + public func readWithReport(limits: JSONLimits = .balanced, _ body: (JSONReadCursor) throws -> Result) throws -> JSONOperationReport { + let scope = try JSONReadScope(self, limits: limits) + defer { scope.invalidate() } + let root = JSONReadCursor(scope: scope, selection: JSONReadPath(scope: scope, components: [], bytes: 0)) + return try scope.complete(body(root)) + } +} diff --git a/Sources/JSON/Reading/JSONReadScope.swift b/Sources/JSON/Reading/JSONReadScope.swift new file mode 100644 index 0000000..1c9dbef --- /dev/null +++ b/Sources/JSON/Reading/JSONReadScope.swift @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Guarded synchronous ownership; deliberately not Sendable. An escaped handle +/// stores only a bounded path and this invalidated scope, never a selected DOM. +package final class JSONReadScope { + private let owner = ObjectIdentifier(Thread.current) + package var document: JSONDocument? + package var state: JSONCodingState? + private var rootBytes: UInt64 = 0 + private var pathBytes: UInt64 = 0 + private var retiredPathBytes: UInt64 = 0 + package init(_ document: JSONDocument, limits: JSONLimits) throws { + let state = JSONCodingState(options: .init(limits: limits), meter: try JSONWorkMeter(limits: limits, domain: .core, trackAcceptedCapacities: true)) + try JSONAdmission.value(document.root, meter: &state.meter, owned: true) + if let source = document.source { try JSONAdmission.text(source, meter: &state.meter, owned: true) } + if let bytes = document.sourceBytes { + try state.meter.require(.inputBytes, UInt64(bytes.count)) + for _ in bytes { try state.meter.work(); try state.meter.retain(1) } + } + if let map = document.sourceMap { + for entry in map.entries { + try state.meter.work(); try state.meter.charge(.sourceMapEntries) + try state.meter.retain(JSONLogicalWeights.sourceMapEntry) + for part in entry.path { + try state.meter.work(); try state.meter.retain(JSONLogicalWeights.frame) + if case .key(let key) = part { try JSONAdmission.text(key.string, key: true, meter: &state.meter, owned: true) } + } + } + } + self.document = document; self.state = state + rootBytes = state.meter.used(.retainedBytes) + } + package func retirePath(_ bytes: UInt64) { if ObjectIdentifier(Thread.current) == owner, state != nil { retiredPathBytes += bytes } } + package func check() throws -> JSONCodingState { + guard ObjectIdentifier(Thread.current) == owner else { throw JSONFailure(.configuration, code: .invalidOperation) } + guard document != nil, let state else { throw JSONFailure(.configuration, code: .invalidOperation) } + if retiredPathBytes != 0 { + try state.meter.release(retiredPathBytes); pathBytes -= retiredPathBytes; retiredPathBytes = 0 + } + try state.check(); return state + } + package func invalidate() { document = nil; state = nil } + package func complete(_ result: T) throws -> JSONOperationReport { + let state = try check() + // Paths/root/source cease to be scope-owned. Any escaped path is caller + // storage and cannot access this invalidated root or ledger. + try state.meter.release(rootBytes + pathBytes) + let statistics = state.meter.statistics() + invalidate() + return .init(result: result, statistics: statistics) + } + package func run(_ path: [JSONLocationComponent], _ body: (JSONCodingState) throws -> T) throws -> T { + do { return try body(check()) } + catch is CancellationError { + let error = CancellationError(); if state?.failure == nil { state?.failure = error }; throw error + } + catch let failure as JSONFailure where failure.category == .resource { + if state?.failure == nil { state?.failure = failure }; throw failure + } + catch let failure as JSONFailure where failure.category == .typeMismatch || failure.category == .missingValue || failure.category == .conversion || failure.code == .invalidIndex { + guard let state else { throw failure } + let location = try location(for: path, missing: failure.category == .missingValue, state: state) + throw JSONAccessFailure(cause: .init(failure.category, code: failure.code, location: location ?? failure.location, resource: failure.resource), path: path) + } + } + private func location(for path: [JSONLocationComponent], missing: Bool, state: JSONCodingState) throws -> JSONSourceLocation? { + guard let document, let map = document.sourceMap else { return nil } + var selected: [JSONLocationComponent] = [] + for part in path.prefix(missing ? max(0, path.count - 1) : path.count) { + try state.meter.work(); try state.meter.retain(JSONLogicalWeights.frame); selected.append(part) + } + var span: JSONSourceSpan? + do { + span = try map.span(at: selected, meter: &state.meter) + if span == nil, !missing, !selected.isEmpty { + // A wrong-container or negative-index hop has no child span. + // Borrow the already bounded prefix; do not invent a location. + selected.removeLast(); try state.meter.release(JSONLogicalWeights.frame) + span = try map.span(at: selected, meter: &state.meter) + } + } + catch { try state.meter.release(UInt64(selected.count) * JSONLogicalWeights.frame); throw error } + try state.meter.release(UInt64(selected.count) * JSONLogicalWeights.frame) + guard let span else { return nil } + guard let bytes = document.sourceBytes else { return .init(byteOffset: span.start) } + var line: UInt64 = 1, column: UInt64 = 1, offset: UInt64 = 0 + for byte in bytes { + if offset == span.start { break } + try state.meter.work(); offset += 1 + if byte == 10 { line += 1; column = 1 } else { column += 1 } + } + return .init(byteOffset: span.start, line: line, column: column) + } + package func select(_ path: [JSONLocationComponent], state: JSONCodingState) throws -> JSON { + guard let document else { throw JSONFailure(.configuration, code: .invalidOperation) } + var value = document.root + for part in path { + try state.meter.work() + switch part { + case .key(let key): + guard case .object(let object) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard let child = try object.lookup(key.string, meter: &state.meter) else { throw JSONFailure(.missingValue, code: .missingMember) } + value = child + case .index(let index): + guard case .array(let array) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard index >= 0 else { throw JSONFailure(.configuration, code: .invalidIndex) } + guard index < array.count else { throw JSONFailure(.missingValue, code: .missingTarget) } + value = array[index] + } + } + return value + } + package func append(_ part: JSONLocationComponent, to path: [JSONLocationComponent], state: JSONCodingState) throws -> JSONReadPath { + try state.meter.require(.pointerTokens, UInt64(path.count) + 1) + var pointerBytes: UInt64 = 0, owned: UInt64 = 0 + for position in 0...path.count { + let component = position == path.count ? part : path[position] + pointerBytes = try state.meter.adding(.pointerBytes, pointerBytes, 1) + let text: String + switch component { + case .key(let key): text = key.string + case .index(let index): + // Preflight the bounded formatting bridge before it allocates. + try state.meter.retain(21); try state.meter.checkpoint() + text = String(index) + try state.meter.work(UInt64(text.utf8.count)); try state.meter.release(21) + } + try state.meter.work(); owned = try state.meter.adding(.retainedBytes, owned, JSONLogicalWeights.indexSlot) + for byte in text.utf8 { + try state.meter.work() + pointerBytes = try state.meter.adding(.pointerBytes, pointerBytes, byte == 47 || byte == 126 ? 2 : 1) + if case .key = component { owned = try state.meter.adding(.retainedBytes, owned, 1) } + } + } + try state.meter.require(.diagnosticBytes, pointerBytes) + try state.meter.retain(owned) + var result: [JSONLocationComponent] = [] + do { + for component in path { try state.meter.work(); result.append(component) } + try state.meter.work(); result.append(part) + } catch { try state.meter.release(owned); throw error } + pathBytes += owned + return JSONReadPath(scope: self, components: result, bytes: owned) + } +} +package final class JSONReadPath { + package let scope: JSONReadScope + package let components: [JSONLocationComponent] + private let bytes: UInt64 + package init(scope: JSONReadScope, components: [JSONLocationComponent], bytes: UInt64) { + self.scope = scope; self.components = components; self.bytes = bytes + } + deinit { scope.retirePath(bytes) } +} diff --git a/Tests/JSONTests/Release1_1/ReadAccessTests.swift b/Tests/JSONTests/Release1_1/ReadAccessTests.swift new file mode 100644 index 0000000..777e934 --- /dev/null +++ b/Tests/JSONTests/Release1_1/ReadAccessTests.swift @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +@testable import JSON + +private struct ReadModel: Codable, Equatable { let count: UInt64; let name: String; let bytes: Data } +private struct MeterWitness: Decodable { + let identity: ObjectIdentifier + let work: UInt64 + init(from decoder: any Decoder) throws { + let native = try #require(decoder as? JSONNativeDecoder) + identity = ObjectIdentifier(native.state); work = native.state.meter.statistics()[.workUnits] + } +} +private struct UserInfoWitness: Decodable { + let marker: String + init(from decoder: any Decoder) throws { + marker = try #require(decoder.userInfo[CodingUserInfoKey(rawValue: "marker")!] as? String) + } +} +private enum ReadCallerFailure: Error { case expected } +private struct RefusingDecode: Decodable { init(from decoder: any Decoder) throws { throw ReadCallerFailure.expected } } +private struct CatchingOwnedFailure: Decodable { + init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + do { _ = try c.decode(UInt64.self) } catch {} + } +} +private func expectAccess(_ path: [JSONLocationComponent], _ code: JSONFailure.Code, _ body: () throws -> Void) { + do { try body(); Issue.record("Expected a typed access failure") } + catch let error as JSONAccessFailure { #expect(error.path == path); #expect(error.cause.code == code) } + catch { Issue.record("Unexpected error: \(type(of: error))") } +} + +@Test func readAccessChainedBracketsAndRequiredKinds() throws { + let value = try JSONParser().parse(#"{"users":[{"name":"Ada","age":9007199254740993}],"0":false,"empty":"","zero":0,"false":false,"null":null}"#) + try value.read { root in + let user = try root[JSONKey("users")][0] + #expect(try user["name"].requiredString() == "Ada") + #expect(try user["age"].requiredInteger(as: UInt64.self) == 9_007_199_254_740_993) + #expect(try root["0"].requiredBoolean() == false) + #expect(try root["users"].requiredArray().count == 1) + #expect(try user.requiredObject().count == 2) + #expect(try root["null"].requiredValue() == .null) + #expect(try root.stringPresence(of: "empty") == .value("")) + #expect(try root.numberPresence(of: "zero") == .value(JSONNumber(integer: 0))) + #expect(try root.booleanPresence(of: "false") == .value(false)) + #expect(try root.presence(of: JSONKey("missing")) == .absent) + #expect(try root.stringPresence(of: JSONKey("null")) == .null) + } +} + +@Test func readAccessExactKeysAndFirstFailingHop() throws { + let keys = ["", "0", "/", "~", "a.b", "\u{e9}", "e\u{301}", "🚀"] + let value = JSON.object(try JSONObject(members: keys.map { .init($0, value: .string($0)) })) + try value.read { root in + for key in keys { let text = try root[key].requiredString(); #expect(text.utf8.elementsEqual(key.utf8)) } + expectAccess([.key("missing")], .missingMember) { _ = try root["missing"]["later"] } + expectAccess([.index(0)], .wrongKind) { _ = try root[0] } + expectAccess([.key("0"), .key("later")], .wrongKind) { _ = try root["0"]["later"] } + expectAccess([.key("0")], .wrongKind) { _ = try root["0"].requiredBoolean() } + } + try JSON.array([.null]).read { root in + expectAccess([.index(-1)], .invalidIndex) { _ = try root[-1] } + expectAccess([.index(1)], .missingTarget) { _ = try root[1] } + expectAccess([.key("0")], .wrongKind) { _ = try root["0"] } + } +} + +@Test func readAccessExactNumericPoliciesAndBounds() throws { + for text in ["9007199254740993", "18446744073709551615"] { + let value = try JSONParser().parse(text) + #expect(try value.read { try $0.requiredInteger(as: UInt64.self) } == UInt64(text)) + } + #expect(try JSONParser().parse("-9223372036854775808").read { try $0.requiredInteger(as: Int64.self) } == Int64.min) + for text in ["1.5", "18446744073709551616", "-1"] { + let value = try JSONParser().parse(text) + expectAccess([], .inexactConversion) { _ = try value.read { try $0.requiredInteger(as: UInt64.self) } } + } + let tenth = try JSONParser().parse("0.1") + expectAccess([], .inexactConversion) { _ = try tenth.read { try $0.requiredDouble(policy: .exact) } } + #expect(try tenth.read { try $0.requiredDouble(policy: .rounded) } == 0.1) + #expect(try tenth.read { try $0.requiredDecimal(policy: .exact) } == Decimal(string: "0.1")) + let huge = try JSONParser().parse("1e100000") + expectAccess([], .inexactConversion) { _ = try huge.read { try $0.requiredDouble(policy: .rounded) } } +} + +@Test func readAccessMappedDiagnosticsAndReplacedRootCoherence() throws { + let bytes = Data("{\n \"user\": {\"age\": \"wrong\"}\n}".utf8) + let document = try JSONParser().parseDocument(bytes, source: "fixture") + do { + _ = try document.read { try $0["user"]["age"].requiredInteger(as: Int.self) } + Issue.record("Expected mapped kind failure") + } catch let error as JSONAccessFailure { + #expect(error.path == [.key("user"), .key("age")]) + #expect(error.cause.location?.byteOffset == 20) + #expect(error.cause.location?.line == 2) + #expect(error.cause.location?.column == 19) + #expect(JSONDiagnostic(error).failure == error.cause) + } + do { _ = try document.read { try $0["user"]["age"]["child"].requiredValue() } } + catch let error as JSONAccessFailure { #expect(error.cause.location?.byteOffset == 20) } + do { _ = try document.read { try $0["user"]["missing"].requiredValue() } } + catch let error as JSONAccessFailure { #expect(error.cause.location?.byteOffset == 12) } + do { _ = try document.replacingRoot(document.root).read { try $0["user"]["age"].requiredBoolean() } } + catch let error as JSONAccessFailure { #expect(error.cause.location == nil) } +} + +@Test func readAccessSharedProjectionLimitsAndTerminalAccounting() throws { + var history = JSONCodingOptions(); history.limits[.stringBytes] = 6 + #expect(throws: JSONFailure.self) { try JSON.string("history").read { try $0.decode(String.self, coding: history) } } + var huge = JSONLimits.balanced; huge[.inputBytes] = UInt64(Int.max) - 1 + var lifetime = try JSONWorkMeter(limits: huge, domain: .core) + for _ in 0..<2 { try lifetime.charge(.inputBytes, huge[.inputBytes]); try lifetime.resetValueBounds() } + #expect(throws: JSONFailure.self) { try lifetime.charge(.inputBytes, huge[.inputBytes]) } + let terminal = lifetime.statistics()[.inputBytes] + #expect(throws: JSONFailure.self) { try lifetime.checkpoint() } + #expect(lifetime.statistics()[.inputBytes] == terminal) + let model = ReadModel(count: .max, name: "e\u{301}", bytes: Data([0, 1, 255])) + let value = try JSON(encoding: model) + #expect(try value.decode(ReadModel.self) == model) + #expect(try value.read { try $0.decode(ReadModel.self) } == model) + let baseline = try value.readWithReport { _ in 0 } + let decoded = try value.readWithReport { try $0.decode(ReadModel.self) } + #expect(decoded.statistics.domain == .core) + #expect(decoded.statistics[.workUnits] > baseline.statistics[.workUnits]) + #expect(decoded.statistics[.numericWorkUnits] > baseline.statistics[.numericWorkUnits]) + try value.read { root in + let first = try root.decode(MeterWitness.self) + let second = try root["count"].decode(MeterWitness.self) + #expect(first.identity == second.identity && second.work > first.work) + let info = JSONCodingOptions(userInfo: [CodingUserInfoKey(rawValue: "marker")!: "qualified"]) + #expect(try root.decode(UserInfoWitness.self, coding: info).marker == "qualified") + } + var coding = JSONCodingOptions(); coding.limits[.workUnits] = baseline.statistics[.workUnits] - 1 + #expect(throws: JSONFailure.self) { + try value.read { root in + do { _ = try root.decode(ReadModel.self, coding: coding) } catch {} + return 0 + } + } + var limits = JSONLimits.balanced; limits[.workUnits] = decoded.statistics[.workUnits] + #expect(try value.read(limits: limits) { try $0.decode(ReadModel.self) } == model) + #expect(throws: JSONFailure.self) { + try value.read(limits: limits) { root in + _ = try root.decode(ReadModel.self) + return try root.decode(ReadModel.self) + } + } + #expect(throws: ReadCallerFailure.self) { try value.read { try $0.decode(RefusingDecode.self) } } + let text = try JSONParser().parse("18446744073709551615") + let admitted = try text.readWithReport { _ in 0 } + var tight = JSONCodingOptions(); tight.limits[.workUnits] = admitted.statistics[.workUnits] + 2 + #expect(throws: JSONFailure.self) { try text.read { try $0.decode(CatchingOwnedFailure.self, coding: tight) } } + #expect(throws: JSONFailure.self) { try text.decode(CatchingOwnedFailure.self, coding: tight) } +} + +@Test func readAccessPresenceAvoidsCallerDecodeAndPreservesNativeFidelity() throws { + let value = try JSONParser().parse(#"{"null":null,"wrong":"no","good":9007199254740993,"é":1,"e\u0301":2}"#) + try value.read { root in + switch try root.presence(of: "missing", as: RefusingDecode.self) { case .absent: break; default: Issue.record("Missing decoded") } + switch try root.presence(of: JSONKey("null"), as: RefusingDecode.self) { case .null: break; default: Issue.record("Null decoded") } + #expect(try root.presence(of: "good", as: UInt64.self) == .value(9_007_199_254_740_993)) + #expect(throws: DecodingError.self) { try root.presence(of: "wrong", as: UInt64.self) } + #expect(throws: ReadCallerFailure.self) { try root.presence(of: "good", as: RefusingDecode.self) } + #expect(try root.decode(JSON.self) == value) + #expect(throws: DecodingError.self) { try root.decode([String: JSON].self) } + } + #expect(try value.decode(JSON.self) == value) + #expect(try JSONParser().parse("9007199254740993").decode(JSONNumber.self) == JSONNumber(parsing: "9007199254740993")) + #expect(try value.decode(JSONObject.self).count == 5) +} + +@Test func readAccessEscapedHandlesInvalidateAndReleaseRootAndSource() throws { + let document = try JSONParser().parseDocument(Data(#"{"child":{"name":"value"}}"#.utf8)) + let escaped = try document.read { try $0["child"] } + #expect(throws: JSONFailure.self) { try escaped.requiredValue() } + let scope = try JSONReadScope(document, limits: .balanced) + weak let state = scope.state + let cursor = JSONReadCursor(scope: scope, selection: JSONReadPath(scope: scope, components: [], bytes: 0)) + _ = try scope.complete(try cursor["child"]) + #expect(scope.document == nil && scope.state == nil && state == nil) + #expect(throws: JSONFailure.self) { try cursor.requiredObject() } +} + +@Test func readAccessPathCopiesReleaseLiveRetentionAndFailCompletePaths() throws { + let value = try JSONParser().parse(#"{"child":{"name":"value"}}"#) + func attempt(_ root: JSONReadCursor) throws { + do { + _ = try root["child"]["name"].requiredBoolean() + Issue.record("Expected ordinary kind failure") + } catch let error as JSONAccessFailure { #expect(error.path == [.key("child"), .key("name")]) } + } + let one = try value.readWithReport { root in try attempt(root) } + var limits = JSONLimits.balanced; limits[.retainedBytes] = one.statistics.peakLogicalRetainedBytes + let repeated = try value.readWithReport(limits: limits) { root in + for _ in 0..<100 { try attempt(root) } + } + #expect(repeated.statistics.peakLogicalRetainedBytes == one.statistics.peakLogicalRetainedBytes) + #expect(repeated.statistics[.retainedBytes] == 0) + #expect(repeated.statistics[.workUnits] > one.statistics[.workUnits]) + let oneText = try value.readWithReport { try $0["child"]["name"].requiredString() } + limits = .balanced; limits[.retainedBytes] = oneText.statistics.peakLogicalRetainedBytes + let repeatedText = try value.readWithReport(limits: limits) { root in + for _ in 0..<100 { _ = try root["child"]["name"].requiredString() } + } + #expect(repeatedText.statistics[.retainedBytes] == 0) + #expect(repeatedText.statistics.peakLogicalRetainedBytes == oneText.statistics.peakLogicalRetainedBytes) + limits = .balanced; limits[.pointerBytes] = 5 + #expect(throws: JSONFailure.self) { try value.read(limits: limits) { try $0["child"] } } + limits = .balanced; limits[.diagnosticBytes] = 5 + #expect(throws: JSONFailure.self) { try value.read(limits: limits) { try $0["child"] } } + +} + +@Test func readAccessCaughtCancellationCannotCertifyCompletion() async throws { + let task = Task.detached { () throws -> Bool in + let value = try JSONParser().parse(#"{"name":"value"}"#) + do { + _ = try value.read { root in + withUnsafeCurrentTask { $0?.cancel() } + do { _ = try root["name"].requiredString() } catch is CancellationError {} + return 0 + } + return false + } catch is CancellationError { return true } + } + #expect(try await task.value) +} + +@Test func readAccessBridgeStrategiesAndForeignNaturalCodingRemainDistinct() throws { + let value = ReadModel(count: .max, name: "sensor", bytes: Data([1, 2, 255])) + for strategy in [JSONCodingOptions.DataStrategy.base64, .deferredToData] { + let coding = JSONCodingOptions(dataStrategy: strategy) + let encoded = try JSON(encoding: value, coding: coding) + #expect(try encoded == JSONValueEncoder(options: coding).encode(value)) + #expect(try encoded.decode(ReadModel.self, coding: coding) == value) + } + for strategy in [JSONCodingOptions.DateStrategy.deferredToDate, .secondsSince1970, .iso8601] { + let coding = JSONCodingOptions(dateStrategy: strategy) + let date = Date(timeIntervalSince1970: 1_600_000_000.25) + let encoded = try JSON(encoding: date, coding: coding) + let decoded = try encoded.decode(Date.self, coding: coding) + let reference = try JSONValueDecoder(options: coding).decode(Date.self, from: encoded) + #expect(decoded == reference) + #expect(decoded == date) + } + for text in ["-9223372036854775808", "18446744073709551615", "0.1"] { + let number = try JSONNumber(parsing: text) + let bytes = try Foundation.JSONEncoder().encode(number) + #expect(try Foundation.JSONDecoder().decode(JSONNumber.self, from: bytes) == number) + } + let huge = try JSONNumber(parsing: "1e100000") + #expect(throws: EncodingError.self) { try Foundation.JSONEncoder().encode(huge) } +} diff --git a/Tools/Release1_1Probes/PublicReaderConsumer.swift b/Tools/Release1_1Probes/PublicReaderConsumer.swift new file mode 100644 index 0000000..a9e1389 --- /dev/null +++ b/Tools/Release1_1Probes/PublicReaderConsumer.swift @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import JSON + +private struct Reading: Decodable { let count: UInt64; let name: String; let bytes: Data } +private let value = try JSONParser().parse(#"{"users":[{"count":18446744073709551615,"name":"sensor","bytes":"AAH/","nullable":null}]}"#) +private let report = try value.readWithReport { root in + let user = try root[JSONKey("users")][0] + let name = try user["name"].requiredString(); precondition(name == "sensor") + let count = try user["count"].requiredInteger(as: UInt64.self); precondition(count == UInt64.max) + let missing = try user.presence(of: "missing"); precondition(missing == .absent) + let nullable = try user.stringPresence(of: "nullable"); precondition(nullable == .null) + return try user.decode(Reading.self) +} +precondition(report.result.count == UInt64.max && report.result.bytes == Data([0, 1, 255])) +precondition(report.statistics.domain == .core && report.statistics[.numericWorkUnits] > 0) +private let escaped = try value.read { $0 } +do { _ = try escaped.requiredValue(); fatalError("Escaped cursor remained valid") } +catch let error as JSONFailure { precondition(error.code == .invalidOperation) } +print("PUBLIC_READER_CONSUMER passed work=\(report.statistics[.workUnits]) numeric=\(report.statistics[.numericWorkUnits])") diff --git a/Tools/Release1_1Probes/PublicTaskEscape.swift b/Tools/Release1_1Probes/PublicTaskEscape.swift new file mode 100644 index 0000000..1a298ee --- /dev/null +++ b/Tools/Release1_1Probes/PublicTaskEscape.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +func forbidden(_ cursor: JSONReadCursor) { + let closure: @Sendable () throws -> JSON = { try cursor.requiredValue() } + _ = closure +} diff --git a/Tools/Release1_1Probes/PublicTaskTransfer.swift b/Tools/Release1_1Probes/PublicTaskTransfer.swift new file mode 100644 index 0000000..17b339c --- /dev/null +++ b/Tools/Release1_1Probes/PublicTaskTransfer.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +func forbidden(_ cursor: JSONReadCursor) { + Task.detached { try cursor.requiredValue() } + _ = try? cursor.requiredValue() +}