LCOV - code coverage report
Current view: top level - src - schema_parser.dart (source / functions) Coverage Total Hit
Test: lcov.info Lines: 100.0 % 97 97
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 'schema_rule.dart';
      16              : 
      17              : /// Parses a JSON Schema subset map into a [SchemaRule] tree.
      18              : ///
      19              : /// Example:
      20              : /// ```dart
      21              : /// final rule = SchemaParser().parse({
      22              : ///   'required': ['name', 'email'],
      23              : ///   'properties': {
      24              : ///     'name': {'type': 'string', 'minLength': 1},
      25              : ///     'email': {'type': 'string', 'format': 'email'},
      26              : ///     'age': {'type': 'integer', 'minimum': 0},
      27              : ///   },
      28              : /// });
      29              : /// final violations = rule.validate({'name': 'Alice'}, '');
      30              : /// // → [{path: 'email', message: 'required field is missing'}]
      31              : /// ```
      32              : final class SchemaParser {
      33              :   /// Parses [schema] into a [SchemaRule] tree.
      34              :   ///
      35              :   /// Returns a [CompositeRule] whose child rules correspond to each recognised
      36              :   /// JSON Schema keyword present in [schema]. An empty schema produces a
      37              :   /// [CompositeRule] with no children, which always validates successfully.
      38            4 :   SchemaRule parse(Map<String, dynamic> schema) {
      39            4 :     final rules = <SchemaRule>[];
      40              : 
      41              :     // type — spec §6.1.1 allows either a string or an array of strings.
      42            4 :     final type = schema['type'];
      43            4 :     if (type is String) {
      44            8 :       rules.add(TypeRule(type));
      45            4 :     } else if (type is List) {
      46              :       // Array form: value is valid when its type matches any listed entry.
      47            3 :       rules.add(TypeRule.fromList(List<String>.from(type)));
      48              :     }
      49              : 
      50              :     // required
      51            4 :     final required = schema['required'];
      52            4 :     if (required is List) {
      53            9 :       rules.add(RequiredRule(List<String>.from(required)));
      54              :     }
      55              : 
      56              :     // enum
      57            4 :     final enumValues = schema['enum'];
      58            4 :     if (enumValues is List) {
      59            3 :       rules.add(EnumRule(List<dynamic>.from(enumValues)));
      60              :     }
      61              : 
      62              :     // numeric constraints
      63            4 :     final minimum = schema['minimum'] as num?;
      64            4 :     final maximum = schema['maximum'] as num?;
      65            4 :     final exclusiveMinimum = schema['exclusiveMinimum'] as num?;
      66            4 :     final exclusiveMaximum = schema['exclusiveMaximum'] as num?;
      67              :     if (minimum != null ||
      68              :         maximum != null ||
      69              :         exclusiveMinimum != null ||
      70              :         exclusiveMaximum != null) {
      71            3 :       rules.add(
      72            3 :         NumericRule(
      73              :           minimum: minimum,
      74              :           maximum: maximum,
      75              :           exclusiveMinimum: exclusiveMinimum,
      76              :           exclusiveMaximum: exclusiveMaximum,
      77              :         ),
      78              :       );
      79              :     }
      80              : 
      81              :     // string constraints
      82            4 :     final minLength = schema['minLength'] as int?;
      83            4 :     final maxLength = schema['maxLength'] as int?;
      84            4 :     final patternStr = schema['pattern'] as String?;
      85              :     if (minLength != null || maxLength != null || patternStr != null) {
      86            3 :       rules.add(
      87            3 :         StringRule(
      88              :           minLength: minLength,
      89              :           maxLength: maxLength,
      90            1 :           pattern: patternStr != null ? RegExp(patternStr) : null,
      91              :         ),
      92              :       );
      93              :     }
      94              : 
      95              :     // format
      96            4 :     final format = schema['format'] as String?;
      97              :     if (format != null) {
      98            2 :       rules.add(FormatRule(format));
      99              :     }
     100              : 
     101              :     // properties — parse each sub-schema recursively
     102            4 :     final propertiesRaw = schema['properties'];
     103              :     Map<String, SchemaRule>? parsedProperties;
     104            4 :     if (propertiesRaw is Map) {
     105            3 :       parsedProperties = {
     106            3 :         for (final entry in propertiesRaw.entries)
     107            6 :           if (entry.value is Map<String, dynamic>)
     108           12 :             entry.key as String: parse(entry.value as Map<String, dynamic>),
     109              :       };
     110            6 :       rules.add(PropertiesRule(parsedProperties));
     111              :     }
     112              : 
     113              :     // patternProperties — a map of ECMA-262 regex strings to sub-schemas.
     114              :     // Per spec §6.5.5, pattern matching is unanchored (hasMatch).
     115              :     // An invalid regex key throws FormatException at parse time: malformed
     116              :     // regexes are schema-authoring errors that must be surfaced immediately.
     117            4 :     final patternPropertiesRaw = schema['patternProperties'];
     118              :     List<(RegExp, SchemaRule)>? parsedPatterns;
     119            4 :     if (patternPropertiesRaw is Map) {
     120            1 :       parsedPatterns = [];
     121            2 :       for (final entry in patternPropertiesRaw.entries) {
     122            2 :         if (entry.value is! Map<String, dynamic>) continue;
     123              :         // Throws FormatException if the key is not a valid regex.
     124            2 :         final regex = RegExp(entry.key as String);
     125            2 :         final subRule = parse(entry.value as Map<String, dynamic>);
     126            1 :         parsedPatterns.add((regex, subRule));
     127              :       }
     128            1 :       if (parsedPatterns.isNotEmpty) {
     129            2 :         rules.add(PatternPropertiesRule(parsedPatterns));
     130              :       }
     131              :     }
     132              : 
     133              :     // additionalProperties — handles both `false` (reject all extras) and a
     134              :     // Map sub-schema (validate extras). Removed the parsedProperties != null
     135              :     // guard so this activates even when `properties` is absent.
     136            4 :     final additionalPropertiesRaw = schema['additionalProperties'];
     137            6 :     final declaredKeys = parsedProperties?.keys.toSet() ?? const <String>{};
     138              :     final patternRegexes =
     139            3 :         parsedPatterns?.map((p) => p.$1).toList() ?? const <RegExp>[];
     140              : 
     141            4 :     if (additionalPropertiesRaw == false) {
     142              :       // Reject all properties not covered by `properties` or
     143              :       // `patternProperties`. Use AdditionalPropertiesSchemaRule (with an
     144              :       // AlwaysInvalidRule payload) in all cases so that patternProperties
     145              :       // patterns are respected at runtime — pattern-matched keys are skipped
     146              :       // and not counted as "additional". When no patterns are present the
     147              :       // patternRegexes list is empty so all non-declared keys are rejected.
     148            2 :       rules.add(
     149            2 :         AdditionalPropertiesSchemaRule(
     150              :           schema: const AlwaysInvalidRule(),
     151              :           declaredKeys: declaredKeys,
     152              :           patternRegexes: patternRegexes,
     153              :         ),
     154              :       );
     155            4 :     } else if (additionalPropertiesRaw is Map<String, dynamic>) {
     156            1 :       final additionalSchema = parse(additionalPropertiesRaw);
     157            1 :       rules.add(
     158            1 :         AdditionalPropertiesSchemaRule(
     159              :           schema: additionalSchema,
     160              :           declaredKeys: declaredKeys,
     161              :           patternRegexes: patternRegexes,
     162              :         ),
     163              :       );
     164              :     }
     165              : 
     166              :     // prefixItems — list of sub-schemas applied positionally (2020-12 §6.4.1).
     167              :     // Parse before `items` so we can set the start index for items validation.
     168            4 :     final prefixItemsRaw = schema['prefixItems'];
     169              :     int prefixLength = 0;
     170            4 :     if (prefixItemsRaw is List) {
     171            1 :       final prefixSchemas = <SchemaRule>[];
     172            2 :       for (final entry in prefixItemsRaw) {
     173            1 :         if (entry is Map<String, dynamic>) {
     174            2 :           prefixSchemas.add(parse(entry));
     175              :         }
     176              :       }
     177            1 :       if (prefixSchemas.isNotEmpty) {
     178            2 :         rules.add(PrefixItemsRule(prefixSchemas));
     179            1 :         prefixLength = prefixSchemas.length;
     180              :       }
     181              :     }
     182              : 
     183              :     // array constraints — `items` applies to elements beyond the prefix when
     184              :     // `prefixItems` is present (itemsStartIndex = prefixLength), or uniformly
     185              :     // to all elements when `prefixItems` is absent (itemsStartIndex = 0).
     186            4 :     final minItems = schema['minItems'] as int?;
     187            4 :     final maxItems = schema['maxItems'] as int?;
     188            4 :     final itemsRaw = schema['items'];
     189              :     SchemaRule? itemsRule;
     190            4 :     if (itemsRaw is Map<String, dynamic>) {
     191            2 :       itemsRule = parse(itemsRaw);
     192            4 :     } else if (itemsRaw == false) {
     193              :       // Boolean `items: false` — emit a rule that rejects any element in scope.
     194              :       // The "in scope" set is elements beyond the prefix (if prefixItems is
     195              :       // present) or all elements (if not).
     196            1 :       itemsRule = AlwaysInvalidRule();
     197              :     }
     198              :     // items: true is a no-op — leave itemsRule as null.
     199              :     // Also emit ArrayRule when only prefixItems is present (no items/minItems/
     200              :     // maxItems) so that prefixItems can still coexist with an items schema
     201              :     // added later. When all three are absent there is nothing to add.
     202              :     if (minItems != null || maxItems != null || itemsRule != null) {
     203            3 :       rules.add(
     204            3 :         ArrayRule(
     205              :           minItems: minItems,
     206              :           maxItems: maxItems,
     207              :           items: itemsRule,
     208              :           itemsStartIndex: prefixLength,
     209              :         ),
     210              :       );
     211              :     }
     212              : 
     213              :     // contains / minContains / maxContains (spec §6.4.5, §6.4.4, §6.4.6).
     214              :     // minContains and maxContains have no effect without contains.
     215              :     // Use `is Map` (not Map<String,dynamic>) so that the empty schema {}
     216              :     // (which Dart infers as Map<dynamic,dynamic>) is also accepted.
     217            4 :     final containsRaw = schema['contains'];
     218            4 :     if (containsRaw is Map) {
     219            2 :       final containsSchema = parse(Map<String, dynamic>.from(containsRaw));
     220            1 :       final minContains = schema['minContains'] as int? ?? 1;
     221            1 :       final maxContains = schema['maxContains'] as int?;
     222            1 :       rules.add(
     223            1 :         ContainsRule(
     224              :           itemSchema: containsSchema,
     225              :           minContains: minContains,
     226              :           maxContains: maxContains,
     227              :         ),
     228              :       );
     229              :     }
     230              : 
     231              :     // const — value may be any JSON type including null; always active when key
     232              :     // is present (even when the declared constant is null).
     233            4 :     if (schema.containsKey('const')) {
     234            3 :       rules.add(ConstRule(schema['const']));
     235              :     }
     236              : 
     237              :     // multipleOf — read as num? to support both integer and float divisors
     238              :     // (e.g. 0.1). Only numeric values are validated; non-numerics are skipped
     239              :     // by the rule itself.
     240            4 :     final multipleOf = schema['multipleOf'] as num?;
     241              :     if (multipleOf != null) {
     242            2 :       rules.add(MultipleOfRule(multipleOf));
     243              :     }
     244              : 
     245              :     // uniqueItems — only activate when the value is exactly `true`. A value of
     246              :     // `false` (or absence) means no uniqueness constraint, so the parser must
     247              :     // guard here rather than relying solely on the rule.
     248            8 :     if (schema['uniqueItems'] == true) {
     249            1 :       rules.add(const UniqueItemsRule());
     250              :     }
     251              : 
     252              :     // minProperties / maxProperties — both read as int?; a single ObjectSizeRule
     253              :     // is emitted when either key is present, mirroring NumericRule's pattern.
     254            4 :     final minProperties = schema['minProperties'] as int?;
     255            4 :     final maxProperties = schema['maxProperties'] as int?;
     256              :     if (minProperties != null || maxProperties != null) {
     257            1 :       rules.add(
     258            1 :         ObjectSizeRule(
     259              :           minProperties: minProperties,
     260              :           maxProperties: maxProperties,
     261              :         ),
     262              :       );
     263              :     }
     264              : 
     265              :     // dependentRequired — the keyword value is a JSON object mapping trigger
     266              :     // property names to arrays of required dependent property names.
     267            4 :     final dependentRequiredRaw = schema['dependentRequired'];
     268            4 :     if (dependentRequiredRaw is Map) {
     269              :       // Cast each value to List<String>; ignore entries whose value is not a
     270              :       // list (consistent with the silent-ignore policy for unknown keyword forms).
     271            1 :       final dependencies = <String, List<String>>{
     272            1 :         for (final entry in dependentRequiredRaw.entries)
     273            2 :           if (entry.value is List)
     274            4 :             entry.key as String: List<String>.from(entry.value as List),
     275              :       };
     276            2 :       rules.add(DependentRequiredRule(dependencies));
     277              :     }
     278              : 
     279            4 :     return CompositeRule(rules);
     280              :   }
     281              : }
        

Generated by: LCOV version 2.0-1