parse method

SchemaRule parse(
  1. Map<String, dynamic> schema
)

Parses schema into a SchemaRule tree.

Returns a CompositeRule whose child rules correspond to each recognised JSON Schema keyword present in schema. An empty schema produces a CompositeRule with no children, which always validates successfully.

Implementation

SchemaRule parse(Map<String, dynamic> schema) {
  final rules = <SchemaRule>[];

  // type — spec §6.1.1 allows either a string or an array of strings.
  final type = schema['type'];
  if (type is String) {
    rules.add(TypeRule(type));
  } else if (type is List) {
    // Array form: value is valid when its type matches any listed entry.
    rules.add(TypeRule.fromList(List<String>.from(type)));
  }

  // required
  final required = schema['required'];
  if (required is List) {
    rules.add(RequiredRule(List<String>.from(required)));
  }

  // enum
  final enumValues = schema['enum'];
  if (enumValues is List) {
    rules.add(EnumRule(List<dynamic>.from(enumValues)));
  }

  // numeric constraints
  final minimum = schema['minimum'] as num?;
  final maximum = schema['maximum'] as num?;
  final exclusiveMinimum = schema['exclusiveMinimum'] as num?;
  final exclusiveMaximum = schema['exclusiveMaximum'] as num?;
  if (minimum != null ||
      maximum != null ||
      exclusiveMinimum != null ||
      exclusiveMaximum != null) {
    rules.add(
      NumericRule(
        minimum: minimum,
        maximum: maximum,
        exclusiveMinimum: exclusiveMinimum,
        exclusiveMaximum: exclusiveMaximum,
      ),
    );
  }

  // string constraints
  final minLength = schema['minLength'] as int?;
  final maxLength = schema['maxLength'] as int?;
  final patternStr = schema['pattern'] as String?;
  if (minLength != null || maxLength != null || patternStr != null) {
    rules.add(
      StringRule(
        minLength: minLength,
        maxLength: maxLength,
        pattern: patternStr != null ? RegExp(patternStr) : null,
      ),
    );
  }

  // format
  final format = schema['format'] as String?;
  if (format != null) {
    rules.add(FormatRule(format));
  }

  // properties — parse each sub-schema recursively
  final propertiesRaw = schema['properties'];
  Map<String, SchemaRule>? parsedProperties;
  if (propertiesRaw is Map) {
    parsedProperties = {
      for (final entry in propertiesRaw.entries)
        if (entry.value is Map<String, dynamic>)
          entry.key as String: parse(entry.value as Map<String, dynamic>),
    };
    rules.add(PropertiesRule(parsedProperties));
  }

  // patternProperties — a map of ECMA-262 regex strings to sub-schemas.
  // Per spec §6.5.5, pattern matching is unanchored (hasMatch).
  // An invalid regex key throws FormatException at parse time: malformed
  // regexes are schema-authoring errors that must be surfaced immediately.
  final patternPropertiesRaw = schema['patternProperties'];
  List<(RegExp, SchemaRule)>? parsedPatterns;
  if (patternPropertiesRaw is Map) {
    parsedPatterns = [];
    for (final entry in patternPropertiesRaw.entries) {
      if (entry.value is! Map<String, dynamic>) continue;
      // Throws FormatException if the key is not a valid regex.
      final regex = RegExp(entry.key as String);
      final subRule = parse(entry.value as Map<String, dynamic>);
      parsedPatterns.add((regex, subRule));
    }
    if (parsedPatterns.isNotEmpty) {
      rules.add(PatternPropertiesRule(parsedPatterns));
    }
  }

  // additionalProperties — handles both `false` (reject all extras) and a
  // Map sub-schema (validate extras). Removed the parsedProperties != null
  // guard so this activates even when `properties` is absent.
  final additionalPropertiesRaw = schema['additionalProperties'];
  final declaredKeys = parsedProperties?.keys.toSet() ?? const <String>{};
  final patternRegexes =
      parsedPatterns?.map((p) => p.$1).toList() ?? const <RegExp>[];

  if (additionalPropertiesRaw == false) {
    // Reject all properties not covered by `properties` or
    // `patternProperties`. Use AdditionalPropertiesSchemaRule (with an
    // AlwaysInvalidRule payload) in all cases so that patternProperties
    // patterns are respected at runtime — pattern-matched keys are skipped
    // and not counted as "additional". When no patterns are present the
    // patternRegexes list is empty so all non-declared keys are rejected.
    rules.add(
      AdditionalPropertiesSchemaRule(
        schema: const AlwaysInvalidRule(),
        declaredKeys: declaredKeys,
        patternRegexes: patternRegexes,
      ),
    );
  } else if (additionalPropertiesRaw is Map<String, dynamic>) {
    final additionalSchema = parse(additionalPropertiesRaw);
    rules.add(
      AdditionalPropertiesSchemaRule(
        schema: additionalSchema,
        declaredKeys: declaredKeys,
        patternRegexes: patternRegexes,
      ),
    );
  }

  // prefixItems — list of sub-schemas applied positionally (2020-12 §6.4.1).
  // Parse before `items` so we can set the start index for items validation.
  final prefixItemsRaw = schema['prefixItems'];
  int prefixLength = 0;
  if (prefixItemsRaw is List) {
    final prefixSchemas = <SchemaRule>[];
    for (final entry in prefixItemsRaw) {
      if (entry is Map<String, dynamic>) {
        prefixSchemas.add(parse(entry));
      }
    }
    if (prefixSchemas.isNotEmpty) {
      rules.add(PrefixItemsRule(prefixSchemas));
      prefixLength = prefixSchemas.length;
    }
  }

  // array constraints — `items` applies to elements beyond the prefix when
  // `prefixItems` is present (itemsStartIndex = prefixLength), or uniformly
  // to all elements when `prefixItems` is absent (itemsStartIndex = 0).
  final minItems = schema['minItems'] as int?;
  final maxItems = schema['maxItems'] as int?;
  final itemsRaw = schema['items'];
  SchemaRule? itemsRule;
  if (itemsRaw is Map<String, dynamic>) {
    itemsRule = parse(itemsRaw);
  } else if (itemsRaw == false) {
    // Boolean `items: false` — emit a rule that rejects any element in scope.
    // The "in scope" set is elements beyond the prefix (if prefixItems is
    // present) or all elements (if not).
    itemsRule = AlwaysInvalidRule();
  }
  // items: true is a no-op — leave itemsRule as null.
  // Also emit ArrayRule when only prefixItems is present (no items/minItems/
  // maxItems) so that prefixItems can still coexist with an items schema
  // added later. When all three are absent there is nothing to add.
  if (minItems != null || maxItems != null || itemsRule != null) {
    rules.add(
      ArrayRule(
        minItems: minItems,
        maxItems: maxItems,
        items: itemsRule,
        itemsStartIndex: prefixLength,
      ),
    );
  }

  // contains / minContains / maxContains (spec §6.4.5, §6.4.4, §6.4.6).
  // minContains and maxContains have no effect without contains.
  // Use `is Map` (not Map<String,dynamic>) so that the empty schema {}
  // (which Dart infers as Map<dynamic,dynamic>) is also accepted.
  final containsRaw = schema['contains'];
  if (containsRaw is Map) {
    final containsSchema = parse(Map<String, dynamic>.from(containsRaw));
    final minContains = schema['minContains'] as int? ?? 1;
    final maxContains = schema['maxContains'] as int?;
    rules.add(
      ContainsRule(
        itemSchema: containsSchema,
        minContains: minContains,
        maxContains: maxContains,
      ),
    );
  }

  // const — value may be any JSON type including null; always active when key
  // is present (even when the declared constant is null).
  if (schema.containsKey('const')) {
    rules.add(ConstRule(schema['const']));
  }

  // multipleOf — read as num? to support both integer and float divisors
  // (e.g. 0.1). Only numeric values are validated; non-numerics are skipped
  // by the rule itself.
  final multipleOf = schema['multipleOf'] as num?;
  if (multipleOf != null) {
    rules.add(MultipleOfRule(multipleOf));
  }

  // uniqueItems — only activate when the value is exactly `true`. A value of
  // `false` (or absence) means no uniqueness constraint, so the parser must
  // guard here rather than relying solely on the rule.
  if (schema['uniqueItems'] == true) {
    rules.add(const UniqueItemsRule());
  }

  // minProperties / maxProperties — both read as int?; a single ObjectSizeRule
  // is emitted when either key is present, mirroring NumericRule's pattern.
  final minProperties = schema['minProperties'] as int?;
  final maxProperties = schema['maxProperties'] as int?;
  if (minProperties != null || maxProperties != null) {
    rules.add(
      ObjectSizeRule(
        minProperties: minProperties,
        maxProperties: maxProperties,
      ),
    );
  }

  // dependentRequired — the keyword value is a JSON object mapping trigger
  // property names to arrays of required dependent property names.
  final dependentRequiredRaw = schema['dependentRequired'];
  if (dependentRequiredRaw is Map) {
    // Cast each value to List<String>; ignore entries whose value is not a
    // list (consistent with the silent-ignore policy for unknown keyword forms).
    final dependencies = <String, List<String>>{
      for (final entry in dependentRequiredRaw.entries)
        if (entry.value is List)
          entry.key as String: List<String>.from(entry.value as List),
    };
    rules.add(DependentRequiredRule(dependencies));
  }

  return CompositeRule(rules);
}