LCOV - code coverage report
Current view: top level - src - schema_rule.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 96.2 % 208 200
Test Date: 2026-06-16 03:31:00 Functions: - 0 0
Legend: Lines: hit not hit

            Line data    Source code
       1              : // Copyright 2026 The Authors
       2              : //
       3              : // Licensed under the Apache License, Version 2.0 (the "License");
       4              : // you may not use this file except in compliance with the License.
       5              : // You may obtain a copy of the License at
       6              : //
       7              : //      https://www.apache.org/licenses/LICENSE-2.0
       8              : //
       9              : // Unless required by applicable law or agreed to in writing, software
      10              : // distributed under the License is distributed on an "AS IS" BASIS,
      11              : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12              : // See the License for the specific language governing permissions and
      13              : // limitations under the License.
      14              : 
      15              : import 'package:characters/characters.dart';
      16              : import 'package:collection/collection.dart';
      17              : 
      18              : import 'formats/formats_base.dart';
      19              : import 'schema_violation.dart';
      20              : 
      21              : /// Compiled representation of a JSON Schema keyword group.
      22              : ///
      23              : /// Each [SchemaRule] subtype corresponds to one keyword group from the JSON
      24              : /// Schema subset supported by this package. Rules validate a runtime
      25              : /// [dynamic] value and return every [SchemaViolation] found — all rules are
      26              : /// always evaluated so callers receive the complete list in one pass.
      27              : ///
      28              : /// Rule trees are produced by [SchemaParser.parse] and can also be constructed
      29              : /// directly for programmatic schema building (see the examples in `example/`).
      30              : /// [JsonSchemaValidator] is a higher-level convenience that handles both parsing
      31              : /// and validation in a single call.
      32              : sealed class SchemaRule {
      33           19 :   const SchemaRule();
      34              : 
      35              :   /// Validates [value] at [path] and returns every violation found.
      36              :   ///
      37              :   /// [path] is a dot-notation string identifying the location of [value]
      38              :   /// within the document (e.g. `'address.city'`, `''` for the root). Violations
      39              :   /// use [path] as their [SchemaViolation.path].
      40              :   List<SchemaViolation> validate(dynamic value, String path);
      41              : }
      42              : 
      43              : // ── AlwaysInvalid ─────────────────────────────────────────────────────────────
      44              : 
      45              : /// A sentinel rule that always fails validation with a fixed message.
      46              : ///
      47              : /// Used internally by [SchemaParser] to represent boolean `false` schemas
      48              : /// (e.g. `items: false`, or `additionalProperties: false` when combined with
      49              : /// `patternProperties`). Any value validated against this rule produces a
      50              : /// violation regardless of type.
      51              : final class AlwaysInvalidRule extends SchemaRule {
      52           19 :   const AlwaysInvalidRule();
      53              : 
      54            2 :   @override
      55            2 :   List<SchemaViolation> validate(dynamic value, String path) => [
      56            2 :     SchemaViolation(path: path, message: 'value is not allowed here'),
      57              :   ];
      58              : }
      59              : 
      60              : // ── Composite ────────────────────────────────────────────────────────────────
      61              : 
      62              : /// Runs multiple rules against the same value and collects all violations.
      63              : final class CompositeRule extends SchemaRule {
      64            4 :   const CompositeRule(this.rules);
      65              : 
      66              :   final List<SchemaRule> rules;
      67              : 
      68            4 :   @override
      69              :   List<SchemaViolation> validate(dynamic value, String path) {
      70            4 :     final violations = <SchemaViolation>[];
      71            8 :     for (final rule in rules) {
      72            8 :       violations.addAll(rule.validate(value, path));
      73              :     }
      74              :     return violations;
      75              :   }
      76              : }
      77              : 
      78              : // ── Type ─────────────────────────────────────────────────────────────────────
      79              : 
      80              : /// Checks whether [value] matches a single JSON Schema type string.
      81              : ///
      82              : /// Returns `true` if the value satisfies [type]. Unknown type strings
      83              : /// are silently accepted (spec says unknown types are ignored).
      84            4 : bool _matchesType(String type, dynamic value) {
      85              :   return switch (type) {
      86            7 :     'string' => value is String,
      87            6 :     'number' => value is num,
      88              :     // Per JSON Schema spec §6.1.1, an integer is any number without a
      89              :     // fractional part — so 1.0 (a Dart double) must be accepted.
      90              :     // Non-finite doubles (NaN, Infinity) are excluded because their
      91              :     // modulo is NaN, not 0.
      92            3 :     'integer' =>
      93            9 :       value is int || (value is double && value.isFinite && value % 1 == 0),
      94            3 :     'boolean' => value is bool,
      95            4 :     'array' => value is List,
      96            2 :     'object' => value is Map,
      97            1 :     'null' => value == null,
      98              :     _ => true, // unknown types are silently ignored
      99              :   };
     100              : }
     101              : 
     102              : /// Validates the Dart runtime type of a value against a JSON Schema `type`.
     103              : ///
     104              : /// Supports both the string form (`"type": "string"`) and the array form
     105              : /// (`"type": ["string", "null"]`) as required by JSON Schema spec §6.1.1.
     106              : /// In the array form the value is valid if it matches *any* of the listed
     107              : /// types (logical OR).
     108              : final class TypeRule extends SchemaRule {
     109              :   /// Creates a rule that accepts a single [type] string.
     110            8 :   TypeRule(String type) : types = [type], _single = type;
     111              : 
     112              :   /// Creates a rule that accepts any of [types] (array form).
     113              :   ///
     114              :   /// Per JSON Schema spec §6.1.1, a value is valid when its type matches
     115              :   /// at least one entry in the list.
     116            1 :   TypeRule.fromList(this.types) : _single = null;
     117              : 
     118              :   /// The JSON Schema type strings accepted by this rule.
     119              :   ///
     120              :   /// Contains exactly one entry in the single-string form, and two or more
     121              :   /// entries in the array form.
     122              :   final List<String> types;
     123              : 
     124              :   // Backing field for the single-string form; null when constructed via
     125              :   // [TypeRule.fromList].
     126              :   final String? _single;
     127              : 
     128            4 :   @override
     129              :   List<SchemaViolation> validate(dynamic value, String path) {
     130            4 :     if (_single != null) {
     131              :       // Single-type form: produce a targeted error message.
     132            8 :       if (!_matchesType(_single, value)) {
     133           16 :         return [SchemaViolation(path: path, message: 'expected type $_single')];
     134              :       }
     135            3 :       return [];
     136              :     }
     137              :     // Array form — value must match at least one listed type.
     138            2 :     for (final t in types) {
     139            2 :       if (_matchesType(t, value)) return [];
     140              :     }
     141            1 :     return [
     142            1 :       SchemaViolation(
     143              :         path: path,
     144            3 :         message: 'expected type to be one of: ${types.join(', ')}',
     145              :       ),
     146              :     ];
     147              :   }
     148              : }
     149              : 
     150              : // ── Required ─────────────────────────────────────────────────────────────────
     151              : 
     152              : /// Validates that all named fields are present in a map.
     153              : ///
     154              : /// A field is considered present when its key exists in the map, even if
     155              : /// the value is `null`.
     156              : final class RequiredRule extends SchemaRule {
     157            3 :   const RequiredRule(this.fields);
     158              : 
     159              :   final List<String> fields;
     160              : 
     161            3 :   @override
     162              :   List<SchemaViolation> validate(dynamic value, String path) {
     163            3 :     if (value is! Map) return [];
     164            3 :     return [
     165            3 :       for (final field in fields)
     166            3 :         if (!value.containsKey(field))
     167            3 :           SchemaViolation(
     168            4 :             path: path.isEmpty ? field : '$path.$field',
     169              :             message: 'required field is missing',
     170              :           ),
     171              :     ];
     172              :   }
     173              : }
     174              : 
     175              : // ── Properties ───────────────────────────────────────────────────────────────
     176              : 
     177              : /// Recursively validates named fields in a map against per-field schemas.
     178              : ///
     179              : /// Fields that are absent in the value are skipped — use [RequiredRule] to
     180              : /// enforce presence.
     181              : final class PropertiesRule extends SchemaRule {
     182            3 :   const PropertiesRule(this.properties);
     183              : 
     184              :   final Map<String, SchemaRule> properties;
     185              : 
     186            3 :   @override
     187              :   List<SchemaViolation> validate(dynamic value, String path) {
     188            3 :     if (value is! Map) return [];
     189            3 :     final violations = <SchemaViolation>[];
     190           15 :     for (final MapEntry(:key, value: rule) in properties.entries) {
     191            3 :       if (!value.containsKey(key)) continue;
     192            4 :       final fieldPath = path.isEmpty ? key : '$path.$key';
     193            9 :       violations.addAll(rule.validate(value[key], fieldPath));
     194              :     }
     195              :     return violations;
     196              :   }
     197              : }
     198              : 
     199              : // ── AdditionalProperties ─────────────────────────────────────────────────────
     200              : 
     201              : /// Rejects map keys that are not in the declared set.
     202              : ///
     203              : /// Corresponds to `additionalProperties: false` in JSON Schema for programmatic
     204              : /// schema construction where no `patternProperties` are involved.
     205              : ///
     206              : /// **Limitation:** this rule has no knowledge of [PatternPropertiesRule]. If
     207              : /// both are included in the same [CompositeRule], keys matched by a pattern
     208              : /// will still be rejected as "additional". For schemas that combine
     209              : /// `additionalProperties` with `patternProperties`, use
     210              : /// [AdditionalPropertiesSchemaRule] with an [AlwaysInvalidRule] payload and
     211              : /// supply the relevant [AdditionalPropertiesSchemaRule.patternRegexes] —
     212              : /// this is what [SchemaParser] always produces.
     213              : final class AdditionalPropertiesRule extends SchemaRule {
     214            0 :   const AdditionalPropertiesRule(this.allowed);
     215              : 
     216              :   final Set<String> allowed;
     217              : 
     218            0 :   @override
     219              :   List<SchemaViolation> validate(dynamic value, String path) {
     220            0 :     if (value is! Map) return [];
     221            0 :     return [
     222            0 :       for (final key in value.keys.cast<String>())
     223            0 :         if (!allowed.contains(key))
     224            0 :           SchemaViolation(
     225            0 :             path: path.isEmpty ? key : '$path.$key',
     226              :             message: 'additional property not allowed',
     227              :           ),
     228              :     ];
     229              :   }
     230              : }
     231              : 
     232              : // ── Enum ─────────────────────────────────────────────────────────────────────
     233              : 
     234              : /// Validates that a value is one of an enumerated set.
     235              : final class EnumRule extends SchemaRule {
     236            1 :   const EnumRule(this.values);
     237              : 
     238              :   final List<dynamic> values;
     239              : 
     240            1 :   @override
     241              :   List<SchemaViolation> validate(dynamic value, String path) {
     242            2 :     if (!values.contains(value)) {
     243            1 :       return [
     244            1 :         SchemaViolation(
     245              :           path: path,
     246            3 :           message: 'must be one of: ${values.join(', ')}',
     247              :         ),
     248              :       ];
     249              :     }
     250            1 :     return [];
     251              :   }
     252              : }
     253              : 
     254              : // ── Numeric ──────────────────────────────────────────────────────────────────
     255              : 
     256              : /// Validates numeric range constraints.
     257              : final class NumericRule extends SchemaRule {
     258            3 :   const NumericRule({
     259              :     this.minimum,
     260              :     this.maximum,
     261              :     this.exclusiveMinimum,
     262              :     this.exclusiveMaximum,
     263              :   });
     264              : 
     265              :   final num? minimum;
     266              :   final num? maximum;
     267              :   final num? exclusiveMinimum;
     268              :   final num? exclusiveMaximum;
     269              : 
     270            3 :   @override
     271              :   List<SchemaViolation> validate(dynamic value, String path) {
     272            5 :     if (value is! num) return [];
     273            3 :     final violations = <SchemaViolation>[];
     274            9 :     if (minimum != null && value < minimum!) {
     275            2 :       violations.add(
     276            6 :         SchemaViolation(path: path, message: 'must be >= $minimum'),
     277              :       );
     278              :     }
     279            5 :     if (maximum != null && value > maximum!) {
     280            1 :       violations.add(
     281            3 :         SchemaViolation(path: path, message: 'must be <= $maximum'),
     282              :       );
     283              :     }
     284            5 :     if (exclusiveMinimum != null && value <= exclusiveMinimum!) {
     285            1 :       violations.add(
     286            3 :         SchemaViolation(path: path, message: 'must be > $exclusiveMinimum'),
     287              :       );
     288              :     }
     289            5 :     if (exclusiveMaximum != null && value >= exclusiveMaximum!) {
     290            1 :       violations.add(
     291            3 :         SchemaViolation(path: path, message: 'must be < $exclusiveMaximum'),
     292              :       );
     293              :     }
     294              :     return violations;
     295              :   }
     296              : }
     297              : 
     298              : // ── String ───────────────────────────────────────────────────────────────────
     299              : 
     300              : /// Validates string length and pattern constraints.
     301              : final class StringRule extends SchemaRule {
     302            3 :   const StringRule({this.minLength, this.maxLength, this.pattern});
     303              : 
     304              :   final int? minLength;
     305              :   final int? maxLength;
     306              : 
     307              :   /// Pre-compiled regex for the `pattern` keyword.
     308              :   final RegExp? pattern;
     309              : 
     310            3 :   @override
     311              :   List<SchemaViolation> validate(dynamic value, String path) {
     312            4 :     if (value is! String) return [];
     313            3 :     final violations = <SchemaViolation>[];
     314            6 :     final len = value.characters.length;
     315            9 :     if (minLength != null && len < minLength!) {
     316            2 :       violations.add(
     317            2 :         SchemaViolation(
     318              :           path: path,
     319            4 :           message: 'must have at least $minLength characters',
     320              :         ),
     321              :       );
     322              :     }
     323            5 :     if (maxLength != null && len > maxLength!) {
     324            1 :       violations.add(
     325            1 :         SchemaViolation(
     326              :           path: path,
     327            2 :           message: 'must have at most $maxLength characters',
     328              :         ),
     329              :       );
     330              :     }
     331            3 :     if (pattern != null) {
     332              :       // Per JSON Schema spec §6.3.3, patterns are not implicitly anchored —
     333              :       // the pattern only needs to match somewhere within the string.
     334            2 :       if (!pattern!.hasMatch(value)) {
     335            1 :         violations.add(
     336            1 :           SchemaViolation(
     337              :             path: path,
     338            3 :             message: 'must match pattern ${pattern!.pattern}',
     339              :           ),
     340              :         );
     341              :       }
     342              :     }
     343              :     return violations;
     344              :   }
     345              : }
     346              : 
     347              : // ── Format ───────────────────────────────────────────────────────────────────
     348              : 
     349              : /// Surface-validates a string against a named format from [StringFormatValidator].
     350              : ///
     351              : /// Unknown format names produce no violations (the spec says implementations
     352              : /// SHOULD support formats but are not required to reject unknown ones).
     353              : final class FormatRule extends SchemaRule {
     354            1 :   FormatRule(this.format)
     355            3 :     : _fn = StringFormatValidator().getValidator(format)?.function;
     356              : 
     357              :   final String format;
     358              :   final bool Function(String)? _fn;
     359              : 
     360            1 :   @override
     361              :   List<SchemaViolation> validate(dynamic value, String path) {
     362            3 :     if (value is! String || _fn == null) return [];
     363            2 :     if (!_fn(value)) {
     364            4 :       return [SchemaViolation(path: path, message: 'must be a valid $format')];
     365              :     }
     366            1 :     return [];
     367              :   }
     368              : }
     369              : 
     370              : // ── Array ────────────────────────────────────────────────────────────────────
     371              : 
     372              : /// Validates array length and per-element schema constraints.
     373              : ///
     374              : /// When `prefixItems` is present in a schema, [items] must only apply to
     375              : /// elements beyond the prefix. The [itemsStartIndex] parameter controls this:
     376              : /// the [items] schema is applied only to elements at indices ≥
     377              : /// [itemsStartIndex]. When [itemsStartIndex] is 0 (the default, used when no
     378              : /// `prefixItems` is present), [items] applies uniformly to all elements, which
     379              : /// is the pre-2020-12 behaviour.
     380              : final class ArrayRule extends SchemaRule {
     381            3 :   const ArrayRule({
     382              :     this.minItems,
     383              :     this.maxItems,
     384              :     this.items,
     385              :     this.itemsStartIndex = 0,
     386              :   });
     387              : 
     388              :   final int? minItems;
     389              :   final int? maxItems;
     390              : 
     391              :   /// Schema applied to elements at indices ≥ [itemsStartIndex].
     392              :   ///
     393              :   /// When `null`, no per-element constraint is applied.
     394              :   final SchemaRule? items;
     395              : 
     396              :   /// The first index at which [items] applies.
     397              :   ///
     398              :   /// Set to `prefixItems.length` when `prefixItems` is present in the schema,
     399              :   /// so that [items] only constrains elements beyond the positional prefix.
     400              :   /// Defaults to 0 (all elements).
     401              :   final int itemsStartIndex;
     402              : 
     403            3 :   @override
     404              :   List<SchemaViolation> validate(dynamic value, String path) {
     405            4 :     if (value is! List) return [];
     406            3 :     final violations = <SchemaViolation>[];
     407           12 :     if (minItems != null && value.length < minItems!) {
     408            3 :       violations.add(
     409            3 :         SchemaViolation(
     410              :           path: path,
     411            6 :           message: 'must have at least $minItems items',
     412              :         ),
     413              :       );
     414              :     }
     415            9 :     if (maxItems != null && value.length > maxItems!) {
     416            1 :       violations.add(
     417            1 :         SchemaViolation(
     418              :           path: path,
     419            2 :           message: 'must have at most $maxItems items',
     420              :         ),
     421              :       );
     422              :     }
     423            3 :     if (items != null) {
     424              :       // Apply the items schema only to elements at indices >= itemsStartIndex.
     425              :       // When prefixItems is absent, itemsStartIndex is 0 so all elements are
     426              :       // covered (backwards-compatible behaviour). When prefixItems is present,
     427              :       // itemsStartIndex equals the prefix length so only elements beyond the
     428              :       // prefix are validated by the items schema.
     429            8 :       for (var i = itemsStartIndex; i < value.length; i++) {
     430           10 :         violations.addAll(items!.validate(value[i], '$path[$i]'));
     431              :       }
     432              :     }
     433              :     return violations;
     434              :   }
     435              : }
     436              : 
     437              : // ── Const ─────────────────────────────────────────────────────────────────────
     438              : 
     439              : /// Validates that a value is exactly equal to the schema-declared constant.
     440              : ///
     441              : /// Corresponds to `const` in JSON Schema spec §6.1.3. The comparison uses
     442              : /// [DeepCollectionEquality] so that nested [List] and [Map] values are
     443              : /// compared by structural value rather than by reference. Primitive values
     444              : /// (numbers, strings, booleans, `null`) are also handled correctly.
     445              : ///
     446              : /// Unlike `required`, the `const` keyword validates the *value* of the
     447              : /// instance — presence is enforced separately via `required`. A `const: null`
     448              : /// schema accepts only the value `null`.
     449              : final class ConstRule extends SchemaRule {
     450              :   /// Creates a rule that accepts only [constValue].
     451              :   ///
     452              :   /// [constValue] may be any JSON-representable type, including `null`.
     453            1 :   const ConstRule(this.constValue);
     454              : 
     455              :   /// The single accepted value.
     456              :   final dynamic constValue;
     457              : 
     458              :   // Deep equality is required for JSON value comparison — Dart's == operator
     459              :   // compares List and Map by identity, not by structural value.
     460              :   static const _deep = DeepCollectionEquality();
     461              : 
     462            1 :   @override
     463              :   List<SchemaViolation> validate(dynamic value, String path) {
     464            2 :     if (!_deep.equals(constValue, value)) {
     465            1 :       return [
     466            3 :         SchemaViolation(path: path, message: 'must be equal to $constValue'),
     467              :       ];
     468              :     }
     469            1 :     return [];
     470              :   }
     471              : }
     472              : 
     473              : // ── MultipleOf ───────────────────────────────────────────────────────────────
     474              : 
     475              : /// Validates that a numeric value is a multiple of the schema-declared divisor.
     476              : ///
     477              : /// Corresponds to `multipleOf` in JSON Schema spec §6.2.1. Non-numeric
     478              : /// instances are silently skipped. Uses a floating-point-safe algorithm:
     479              : /// divides the value by the divisor and checks whether the quotient is within
     480              : /// an epsilon of a whole number. The naive `value % divisor == 0` check fails
     481              : /// for decimal divisors (e.g. `0.3 % 0.1 ≠ 0` in IEEE-754 arithmetic).
     482              : final class MultipleOfRule extends SchemaRule {
     483              :   /// Creates a rule that requires values to be a multiple of [divisor].
     484              :   ///
     485              :   /// [divisor] must be strictly greater than zero per the JSON Schema spec.
     486              :   /// A [divisor] of zero acts as a schema-error guard: validation always fails
     487              :   /// for any numeric value (spec §6.2.1 disallows zero divisors).
     488            1 :   const MultipleOfRule(this.divisor);
     489              : 
     490              :   /// The required divisor.
     491              :   final num divisor;
     492              : 
     493              :   // Tolerance used when checking whether the quotient is a whole number.
     494              :   // 1e-10 is small enough to avoid false positives for common decimal values
     495              :   // while remaining robust to typical IEEE-754 rounding errors.
     496              :   static const double _epsilon = 1e-10;
     497              : 
     498            1 :   @override
     499              :   List<SchemaViolation> validate(dynamic value, String path) {
     500              :     // Applies only to numbers; other types are silently skipped.
     501            2 :     if (value is! num) return [];
     502            2 :     if (divisor == 0) {
     503            1 :       return [
     504            1 :         SchemaViolation(path: path, message: 'multipleOf divisor must be > 0'),
     505              :       ];
     506              :     }
     507              :     // Compute the quotient and check that its fractional part is negligibly
     508              :     // small, guarding against IEEE-754 rounding in decimal arithmetic.
     509            2 :     final quotient = value / divisor;
     510            4 :     if ((quotient - quotient.roundToDouble()).abs() >= _epsilon) {
     511            1 :       return [
     512            3 :         SchemaViolation(path: path, message: 'must be a multiple of $divisor'),
     513              :       ];
     514              :     }
     515            1 :     return [];
     516              :   }
     517              : }
     518              : 
     519              : // ── UniqueItems ───────────────────────────────────────────────────────────────
     520              : 
     521              : /// Validates that all elements in an array are pairwise distinct.
     522              : ///
     523              : /// Corresponds to `uniqueItems: true` in JSON Schema spec §6.4.3. When
     524              : /// `uniqueItems` is `false` (or absent) no validation is performed.
     525              : /// Non-array instances are silently skipped.
     526              : ///
     527              : /// Uses an O(n²) pairwise [DeepCollectionEquality] comparison so that nested
     528              : /// [List] and [Map] elements are compared by structural value rather than by
     529              : /// reference. A plain `toSet()` check would fail to detect duplicate nested
     530              : /// objects.
     531              : final class UniqueItemsRule extends SchemaRule {
     532           19 :   const UniqueItemsRule();
     533              : 
     534              :   static const _deep = DeepCollectionEquality();
     535              : 
     536            1 :   @override
     537              :   List<SchemaViolation> validate(dynamic value, String path) {
     538              :     // Applies only to arrays; other types are silently skipped.
     539            2 :     if (value is! List) return [];
     540              :     // Pairwise O(n²) deep-equality check. An empty list or single-element
     541              :     // list is vacuously unique.
     542            3 :     for (var i = 0; i < value.length; i++) {
     543            4 :       for (var j = i + 1; j < value.length; j++) {
     544            3 :         if (_deep.equals(value[i], value[j])) {
     545            2 :           return [SchemaViolation(path: path, message: 'items must be unique')];
     546              :         }
     547              :       }
     548              :     }
     549            1 :     return [];
     550              :   }
     551              : }
     552              : 
     553              : // ── ObjectSize ────────────────────────────────────────────────────────────────
     554              : 
     555              : /// Validates the number of properties in an object (map).
     556              : ///
     557              : /// Covers both `minProperties` (spec §6.5.2) and `maxProperties` (spec §6.5.1).
     558              : /// Non-map instances are silently skipped. Either bound may be `null` to
     559              : /// indicate no constraint in that direction.
     560              : final class ObjectSizeRule extends SchemaRule {
     561            1 :   const ObjectSizeRule({this.minProperties, this.maxProperties});
     562              : 
     563              :   /// Inclusive minimum number of properties, or `null` for no lower bound.
     564              :   final int? minProperties;
     565              : 
     566              :   /// Inclusive maximum number of properties, or `null` for no upper bound.
     567              :   final int? maxProperties;
     568              : 
     569            1 :   @override
     570              :   List<SchemaViolation> validate(dynamic value, String path) {
     571              :     // Applies only to objects (maps); other types are silently skipped.
     572            2 :     if (value is! Map) return [];
     573            1 :     final violations = <SchemaViolation>[];
     574            4 :     if (minProperties != null && value.length < minProperties!) {
     575            1 :       violations.add(
     576            1 :         SchemaViolation(
     577              :           path: path,
     578            2 :           message: 'must have at least $minProperties properties',
     579              :         ),
     580              :       );
     581              :     }
     582            4 :     if (maxProperties != null && value.length > maxProperties!) {
     583            1 :       violations.add(
     584            1 :         SchemaViolation(
     585              :           path: path,
     586            2 :           message: 'must have at most $maxProperties properties',
     587              :         ),
     588              :       );
     589              :     }
     590              :     return violations;
     591              :   }
     592              : }
     593              : 
     594              : // ── Contains ──────────────────────────────────────────────────────────────────
     595              : 
     596              : /// Validates that an array contains at least [minContains] (default 1) and at
     597              : /// most [maxContains] (optional) elements that satisfy [itemSchema].
     598              : ///
     599              : /// Corresponds to the `contains`, `minContains`, and `maxContains` keywords in
     600              : /// JSON Schema spec §6.4.5, §6.4.4, and §6.4.6. The three keywords are tightly
     601              : /// coupled and implemented together in this rule.
     602              : ///
     603              : /// Sub-schema violations are used only as a counting signal — they are never
     604              : /// emitted to the caller. The rule reports a single violation when the count
     605              : /// falls outside the permitted range.
     606              : ///
     607              : /// Non-array instances are silently skipped (no violation).
     608              : ///
     609              : /// `minContains: 0` makes the rule effectively optional unless [maxContains] is
     610              : /// set and exceeded.
     611              : final class ContainsRule extends SchemaRule {
     612              :   /// Creates a rule enforcing that [minContains] to [maxContains] elements
     613              :   /// satisfy [itemSchema].
     614              :   ///
     615              :   /// [minContains] defaults to 1 per spec §6.4.4. [maxContains] is optional;
     616              :   /// when `null` there is no upper bound.
     617            1 :   const ContainsRule({
     618              :     required this.itemSchema,
     619              :     this.minContains = 1,
     620              :     this.maxContains,
     621              :   });
     622              : 
     623              :   /// The sub-schema that each candidate element is tested against.
     624              :   final SchemaRule itemSchema;
     625              : 
     626              :   /// The minimum number of elements that must satisfy [itemSchema].
     627              :   ///
     628              :   /// Defaults to 1, which means at least one element must match. A value of 0
     629              :   /// means the rule always passes unless [maxContains] is violated.
     630              :   final int minContains;
     631              : 
     632              :   /// The maximum number of elements allowed to satisfy [itemSchema].
     633              :   ///
     634              :   /// When `null` there is no upper bound.
     635              :   final int? maxContains;
     636              : 
     637            1 :   @override
     638              :   List<SchemaViolation> validate(dynamic value, String path) {
     639              :     // Applies only to arrays; other types are silently skipped.
     640            2 :     if (value is! List) return [];
     641              : 
     642              :     // Count elements that satisfy the sub-schema. Violations from the
     643              :     // sub-schema are used only as a non-match signal and are never forwarded
     644              :     // to the caller.
     645              :     var matchCount = 0;
     646            2 :     for (final element in value) {
     647            3 :       if (itemSchema.validate(element, path).isEmpty) {
     648            1 :         matchCount++;
     649              :       }
     650              :     }
     651              : 
     652            1 :     final violations = <SchemaViolation>[];
     653              : 
     654            2 :     if (matchCount < minContains) {
     655            1 :       violations.add(
     656            1 :         SchemaViolation(
     657              :           path: path,
     658            1 :           message:
     659            1 :               'must contain at least $minContains matching item(s) '
     660              :               '(found $matchCount)',
     661              :         ),
     662              :       );
     663              :     }
     664              : 
     665            3 :     if (maxContains != null && matchCount > maxContains!) {
     666            1 :       violations.add(
     667            1 :         SchemaViolation(
     668              :           path: path,
     669            1 :           message:
     670            1 :               'must contain at most $maxContains matching item(s) '
     671              :               '(found $matchCount)',
     672              :         ),
     673              :       );
     674              :     }
     675              : 
     676              :     return violations;
     677              :   }
     678              : }
     679              : 
     680              : // ── PrefixItems ───────────────────────────────────────────────────────────────
     681              : 
     682              : /// Validates array elements positionally against a list of per-index schemas.
     683              : ///
     684              : /// Corresponds to `prefixItems` in JSON Schema spec §6.4.1 (2020-12). Element
     685              : /// at index `i` is validated against `schemas[i]`. If the array is shorter than
     686              : /// the prefix list, the extra prefix schemas simply do not apply — there is no
     687              : /// violation for a short array. Elements beyond the prefix are not validated by
     688              : /// this rule; use [ArrayRule] with an [ArrayRule.items] schema to constrain
     689              : /// those.
     690              : ///
     691              : /// Non-array instances are silently skipped (no violation).
     692              : final class PrefixItemsRule extends SchemaRule {
     693              :   /// Creates a rule that validates positional elements against [schemas].
     694            1 :   const PrefixItemsRule(this.schemas);
     695              : 
     696              :   /// Per-index sub-schemas. `schemas[i]` is applied to element `i`.
     697              :   final List<SchemaRule> schemas;
     698              : 
     699            1 :   @override
     700              :   List<SchemaViolation> validate(dynamic value, String path) {
     701              :     // Applies only to arrays; other types are silently skipped.
     702            2 :     if (value is! List) return [];
     703            1 :     final violations = <SchemaViolation>[];
     704              :     // Only iterate as far as the shorter of the array and the prefix list.
     705            7 :     final limit = schemas.length < value.length ? schemas.length : value.length;
     706            2 :     for (var i = 0; i < limit; i++) {
     707            6 :       violations.addAll(schemas[i].validate(value[i], '$path[$i]'));
     708              :     }
     709              :     return violations;
     710              :   }
     711              : }
     712              : 
     713              : // ── PatternProperties ─────────────────────────────────────────────────────────
     714              : 
     715              : /// Validates object properties whose names match ECMA-262 regex patterns.
     716              : ///
     717              : /// Corresponds to `patternProperties` in JSON Schema spec §6.5.5. For each
     718              : /// property in the instance, every pattern in [patterns] is tested against the
     719              : /// property name using [RegExp.hasMatch] (unanchored, per spec). If a pattern
     720              : /// matches, the associated sub-schema is applied to the property value. A
     721              : /// property may be matched by zero, one, or more patterns — every matching
     722              : /// sub-schema is applied and all violations are collected.
     723              : ///
     724              : /// Non-object instances are silently skipped (no violation).
     725              : final class PatternPropertiesRule extends SchemaRule {
     726              :   /// Creates a rule from a list of (regex, sub-schema) pairs.
     727              :   ///
     728              :   /// [patterns] is a list of `(RegExp, SchemaRule)` records. Each regex is
     729              :   /// tested unanchored against property names; the associated rule is applied
     730              :   /// to the value of every matching property.
     731            1 :   const PatternPropertiesRule(this.patterns);
     732              : 
     733              :   /// The list of pattern→schema pairs.
     734              :   final List<(RegExp, SchemaRule)> patterns;
     735              : 
     736            1 :   @override
     737              :   List<SchemaViolation> validate(dynamic value, String path) {
     738              :     // Applies only to objects (maps); other types are silently skipped.
     739            2 :     if (value is! Map) return [];
     740            1 :     final violations = <SchemaViolation>[];
     741            3 :     for (final key in value.keys.cast<String>()) {
     742            2 :       final fieldPath = path.isEmpty ? key : '$path.$key';
     743            2 :       for (final (regex, rule) in patterns) {
     744              :         // Per JSON Schema spec §6.5.5, pattern matching is unanchored.
     745            1 :         if (regex.hasMatch(key)) {
     746            3 :           violations.addAll(rule.validate(value[key], fieldPath));
     747              :         }
     748              :       }
     749              :     }
     750              :     return violations;
     751              :   }
     752              : }
     753              : 
     754              : // ── AdditionalPropertiesSchema ────────────────────────────────────────────────
     755              : 
     756              : /// Validates properties not covered by `properties` or `patternProperties`
     757              : /// against a sub-schema.
     758              : ///
     759              : /// Corresponds to a schema-valued `additionalProperties` in JSON Schema spec
     760              : /// §6.5.6. Properties whose names are in [declaredKeys] or match any regex in
     761              : /// [patternRegexes] are considered "evaluated" and are skipped. All remaining
     762              : /// properties are validated against [schema].
     763              : ///
     764              : /// Non-object instances are silently skipped (no violation).
     765              : ///
     766              : /// This complements [AdditionalPropertiesRule] (which uses `false` to reject
     767              : /// extra properties outright). When `additionalProperties` is a schema, this
     768              : /// rule is emitted instead.
     769              : final class AdditionalPropertiesSchemaRule extends SchemaRule {
     770              :   /// Creates a rule that applies [schema] to every non-evaluated property.
     771              :   ///
     772              :   /// [declaredKeys] is the set of keys covered by `properties`.
     773              :   /// [patternRegexes] is the list of compiled regex patterns from
     774              :   /// `patternProperties`. A property is "additional" only if its name is
     775              :   /// neither in [declaredKeys] nor matched by any regex in [patternRegexes].
     776            2 :   const AdditionalPropertiesSchemaRule({
     777              :     required this.schema,
     778              :     required this.declaredKeys,
     779              :     required this.patternRegexes,
     780              :   });
     781              : 
     782              :   /// The sub-schema applied to each additional property value.
     783              :   final SchemaRule schema;
     784              : 
     785              :   /// Keys explicitly declared under `properties`.
     786              :   final Set<String> declaredKeys;
     787              : 
     788              :   /// Compiled regexes from `patternProperties` used to identify
     789              :   /// pattern-matched keys.
     790              :   final List<RegExp> patternRegexes;
     791              : 
     792            2 :   @override
     793              :   List<SchemaViolation> validate(dynamic value, String path) {
     794              :     // Applies only to objects (maps); other types are silently skipped.
     795            3 :     if (value is! Map) return [];
     796            2 :     final violations = <SchemaViolation>[];
     797            6 :     for (final key in value.keys.cast<String>()) {
     798              :       // Skip keys covered by `properties`.
     799            4 :       if (declaredKeys.contains(key)) continue;
     800              :       // Skip keys matched by any `patternProperties` pattern.
     801            6 :       if (patternRegexes.any((r) => r.hasMatch(key))) continue;
     802              :       // Apply the sub-schema to the additional property value.
     803            3 :       final fieldPath = path.isEmpty ? key : '$path.$key';
     804            8 :       violations.addAll(schema.validate(value[key], fieldPath));
     805              :     }
     806              :     return violations;
     807              :   }
     808              : }
     809              : 
     810              : // ── DependentRequired ─────────────────────────────────────────────────────────
     811              : 
     812              : /// Validates conditional property dependencies in an object (map).
     813              : ///
     814              : /// Corresponds to `dependentRequired` in JSON Schema spec §6.5.4. Each entry
     815              : /// in [dependencies] maps a trigger property name to a list of property names
     816              : /// that must also be present whenever the trigger is present. If the trigger
     817              : /// is absent, no validation is performed for that entry.
     818              : ///
     819              : /// Non-map instances are silently skipped. One [SchemaViolation] is emitted
     820              : /// per missing dependent property, with the path pointing at the missing
     821              : /// property name (consistent with [RequiredRule]).
     822              : final class DependentRequiredRule extends SchemaRule {
     823              :   /// Creates a rule from a dependency map.
     824              :   ///
     825              :   /// [dependencies] maps each trigger property name to the list of property
     826              :   /// names that must be present when the trigger is present.
     827            1 :   const DependentRequiredRule(this.dependencies);
     828              : 
     829              :   /// The dependency map: trigger → required dependents.
     830              :   final Map<String, List<String>> dependencies;
     831              : 
     832            1 :   @override
     833              :   List<SchemaViolation> validate(dynamic value, String path) {
     834              :     // Applies only to objects (maps); other types are silently skipped.
     835            2 :     if (value is! Map) return [];
     836            1 :     final violations = <SchemaViolation>[];
     837            5 :     for (final MapEntry(:key, value: dependents) in dependencies.entries) {
     838              :       // Only validate when the trigger property is present.
     839            1 :       if (!value.containsKey(key)) continue;
     840            2 :       for (final dependent in dependents) {
     841            1 :         if (!value.containsKey(dependent)) {
     842              :           // Emit one violation per missing dependent, with the path pointing
     843              :           // at the missing property — consistent with RequiredRule path format.
     844            1 :           violations.add(
     845            1 :             SchemaViolation(
     846            2 :               path: path.isEmpty ? dependent : '$path.$dependent',
     847              :               message: 'required field is missing',
     848              :             ),
     849              :           );
     850              :         }
     851              :       }
     852              :     }
     853              :     return violations;
     854              :   }
     855              : }
        

Generated by: LCOV version 2.0-1