yaml-dsl-spec 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,20 +9,4 @@ if (read.problems) for (const problem of read.problems) console.error(String(pro
9
9
  else for (const { definition } of read.dsls) console.log(definition.id);
10
10
  ```
11
11
 
12
- | Export | Is |
13
- |---|---|
14
- | `readDslDefinitions(file, text)` | `{ dsls }`, each DSL of a `yaml-dsl.yml`, `{ definition, block }`, validated against its version's schema and the checks the schema cannot state; or `{ problems }`, every problem at the field it is about |
15
- | `Diagnostic`, `Position` | a problem at its place: `message`, `position` and `notes` (`label`, `rule`, `help`) |
16
- | `yamlReader.parseYaml(file, text, tagKeys)` | `{ node }`, YAML read with each value's place, or `{ problem }` |
17
- | `Node`, `numberJson` | a YAML value as written, and a number's text as JSON |
18
- | `dslDefinition.dslDefinitionFromJson(block)` | a DSL block with its defaults, its fields named as the standard names them |
19
- | `jsonPath` | `PathPattern` and `PathSet`, the standard's JSONPath subset |
20
- | `resolved` | `Value`, a resolved document's value with its place |
21
- | `functionTable` | `FunctionTable`, a schema's `x-yaml-dsl-functions` |
22
- | `schemaValidation` | `SchemaCache` and `LoadedSchema`, a document validated against its schema, each problem at its place |
23
- | `parseCallKey`, `closestName` | a call written in a key, and the allowed name closest to a misspelt one |
24
- | `yamlDslSchema(version)`, `yamlDslSchemas` | the JSON Schema of `yaml-dsl.yml`, at a version, or by version |
25
- | `functionTableSchema(version)`, `functionTableSchemas` | the JSON Schema of a DSL's function table, `x-yaml-dsl-functions`, at a version, or by version |
26
- | `YAML_DSL_VERSIONS`, `FUNCTION_TABLE_VERSIONS` | the versions the package reads |
27
-
28
- A version is a minor, such as `1.0`, or a major, such as `1`, which is its newest minor in the package.
12
+ Its API is the core's, designed in the [package design](https://github.com/litebitus/yaml-dsl-spec/blob/main/docs/package-design.md).
package/index.js CHANGED
@@ -49,6 +49,7 @@ module.exports = {
49
49
  schemaValidation: {
50
50
  SchemaCache: schemaValidation.SchemaCache,
51
51
  LoadedSchema: schemaValidation.LoadedSchema,
52
+ ValidationRules: schemaValidation.ValidationRules,
52
53
  childValue: schemaValidation.childValue,
53
54
  },
54
55
  };
@@ -13,6 +13,7 @@ const { compilePattern, groupNamesOf } = require('./pattern-dialect');
13
13
  const { Position } = require('./source-position');
14
14
  const { Value } = require('./resolved-value');
15
15
  const { SchemaCache, closestName, nodeJsonText } = require('./schema-validation');
16
+ const { ValidationRules } = require('./validation-rules');
16
17
  const { parseYaml } = require('./yaml-reader');
17
18
  const { YAML_DSL_VERSIONS, yamlDslSchemaFile } = require('./schema-catalog');
18
19
 
@@ -37,7 +38,7 @@ function readDslDefinitions(file, text) {
37
38
  } catch (error) {
38
39
  return { problems: [Diagnostic.unplaced(error.message)] };
39
40
  }
40
- const schemaProblems = schema.validate(Value.fromNode(root), []);
41
+ const schemaProblems = schema.validate(Value.fromNode(root), new ValidationRules());
41
42
  if (schemaProblems.length > 0) return { problems: schemaProblems };
42
43
  const blocks = (root.get('dsls') && root.get('dsls').items()) || [];
43
44
  const problems = [];
@@ -125,7 +125,9 @@ const DSL_FIELDS = {
125
125
  })), emptyList),
126
126
  tag_keys: withDefault(listOf(struct({ tag: required(text), key: required(text) })), emptyList),
127
127
  validator: optional(struct({
128
+ merge_base_keys: withDefault(listOf(text), emptyList),
128
129
  child_values_from_parent_key: withDefault(listOf(struct({ pattern: required(text) })), emptyList),
130
+ allow_quoted_values: withDefault(listOf(oneOf(['boolean', 'integer', 'number'])), emptyList),
129
131
  })),
130
132
  };
131
133
 
@@ -64,7 +64,7 @@ function compareText(first, second) {
64
64
  }
65
65
 
66
66
  function functionEntry(name, entry) {
67
- const listed = isObject(entry) ? entry.arguments : undefined;
67
+ const listed = Array.isArray(entry) ? entry : isObject(entry) ? entry.arguments : undefined;
68
68
  if (!Array.isArray(listed)) throw new Error(`function \`${name}\` has no arguments list`);
69
69
  const functionArguments = listed.map((argument) => {
70
70
  const argumentName = isObject(argument) ? argument.name : undefined;
@@ -75,7 +75,9 @@ function functionEntry(name, entry) {
75
75
  default: hasOwn(argument, 'default') ? argument.default : null,
76
76
  };
77
77
  });
78
- const macroEntry = hasOwn(entry, 'macro') ? macroEntryOf(name, entry.macro) : null;
78
+ const macroEntry = isObject(entry) && hasOwn(entry, 'macro')
79
+ ? macroEntryOf(name, entry.macro)
80
+ : null;
79
81
  return { arguments: functionArguments, macroEntry };
80
82
  }
81
83
 
@@ -101,6 +103,9 @@ function macroEntryOf(name, value) {
101
103
  for (const role of ROLE_NAMES) {
102
104
  if (typeof value[role] === 'string') roles[role] = value[role];
103
105
  }
106
+ if (!hasOwn(roles, 'loop_variables') && typeof value.loop_variable === 'string') {
107
+ roles.loop_variables = value.loop_variable;
108
+ }
104
109
  return new MacroEntry(kind, written || 'shallow', nameSuffix, roles);
105
110
  }
106
111
 
package/lib/json-path.js CHANGED
@@ -11,6 +11,23 @@ function indexStep(index) {
11
11
  return { index };
12
12
  }
13
13
 
14
+ /** A `['name']` step's name and the text after the step, or `null`. */
15
+ function quotedName(text) {
16
+ if (!text.startsWith("['")) return null;
17
+ const close = text.indexOf("'", 2);
18
+ if (close <= 2 || text[close + 1] !== ']') return null;
19
+ return { name: text.slice(2, close), remaining: text.slice(close + 2) };
20
+ }
21
+
22
+ /** A dotted step's name, which runs to the next `.` or `[`, and the text after it, or `null`. */
23
+ function dottedName(text) {
24
+ if (text.startsWith('*')) return null;
25
+ const stepEnd = text.search(/[.[]/);
26
+ const end = stepEnd === -1 ? text.length : stepEnd;
27
+ if (end === 0) return null;
28
+ return { name: text.slice(0, end), remaining: text.slice(end) };
29
+ }
30
+
14
31
  /** A parsed path and the keys its wildcard steps do not select or descend into. */
15
32
  class PathPattern {
16
33
  constructor(selectors, skipKeys) {
@@ -23,38 +40,37 @@ class PathPattern {
23
40
  const invalid = () =>
24
41
  Diagnostic.unplaced(`\`${text}\` is not a path of the supported JSONPath subset`);
25
42
  if (!text.startsWith('$')) throw invalid();
26
- const rest = text.slice(1);
43
+ let rest = text.slice(1);
27
44
  const selectors = [];
28
- let index = 0;
29
- while (index < rest.length) {
30
- const descendant = rest.startsWith('..', index);
31
- if (descendant) index += 2;
32
- else if (rest[index] === '.') index += 1;
33
- let name;
34
- let consumed;
35
- if (rest.startsWith("['", index)) {
36
- const close = rest.indexOf("']", index + 2);
37
- if (close === -1) throw invalid();
38
- name = rest.slice(index + 2, close);
39
- consumed = close - index + 2;
40
- } else if (rest.startsWith('[*]', index)) {
41
- name = null;
42
- consumed = 3;
43
- } else if (rest.startsWith('*', index)) {
44
- name = null;
45
- consumed = 1;
45
+ while (rest.length > 0) {
46
+ let step;
47
+ if (rest.startsWith('..')) {
48
+ const after = rest.slice(2);
49
+ if (after === '*') {
50
+ step = { selector: { kind: 'descendant_all' }, remaining: '' };
51
+ } else {
52
+ const named = quotedName(after) || dottedName(after);
53
+ if (!named) throw invalid();
54
+ step = {
55
+ selector: { kind: 'descendant_name', name: named.name },
56
+ remaining: named.remaining,
57
+ };
58
+ }
59
+ } else if (rest.startsWith('.*')) {
60
+ step = { selector: { kind: 'wildcard' }, remaining: rest.slice(2) };
61
+ } else if (rest.startsWith('.')) {
62
+ const named = dottedName(rest.slice(1));
63
+ if (!named) throw invalid();
64
+ step = { selector: { kind: 'name', name: named.name }, remaining: named.remaining };
65
+ } else if (rest.startsWith('[*]')) {
66
+ step = { selector: { kind: 'wildcard' }, remaining: rest.slice(3) };
46
67
  } else {
47
- const ends = [rest.indexOf('.', index), rest.indexOf('[', index)].filter((end) => end !== -1);
48
- const end = ends.length > 0 ? Math.min(...ends) : rest.length;
49
- if (end === index) throw invalid();
50
- name = rest.slice(index, end);
51
- consumed = end - index;
68
+ const named = quotedName(rest);
69
+ if (!named) throw invalid();
70
+ step = { selector: { kind: 'name', name: named.name }, remaining: named.remaining };
52
71
  }
53
- index += consumed;
54
- if (!descendant) selectors.push(name === null ? { kind: 'wildcard' } : { kind: 'name', name });
55
- else if (name !== null) selectors.push({ kind: 'descendant_name', name });
56
- else if (index === rest.length) selectors.push({ kind: 'descendant_all' });
57
- else throw invalid();
72
+ selectors.push(step.selector);
73
+ rest = step.remaining;
58
74
  }
59
75
  return new PathPattern(selectors, [...skipKeys]);
60
76
  }
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ /* How a schema problem is worded: the field it names and the sentence about it. */
4
+
5
+ const { descriptionText } = require('./requirement-markers');
6
+
7
+ const DEPENDENCY_KEYWORDS = ['dependencies', 'dependentRequired', 'dependentSchemas'];
8
+ const SCHEMA_MAP_KEYWORDS = [
9
+ '$defs',
10
+ 'definitions',
11
+ 'dependencies',
12
+ 'dependentSchemas',
13
+ 'patternProperties',
14
+ 'properties',
15
+ ];
16
+ const DEFINITION_KEYWORDS = ['$defs', 'definitions'];
17
+ const UNSTATED_CONDITION = 'a condition the schema states';
18
+
19
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
20
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+
22
+ function unescapePointer(step) {
23
+ return step.replaceAll('~1', '/').replaceAll('~0', '~');
24
+ }
25
+
26
+ function escapePointer(key) {
27
+ return key.replaceAll('~', '~0').replaceAll('/', '~1');
28
+ }
29
+
30
+ function schemaNodeAt(root, pointer) {
31
+ if (pointer === '') return root;
32
+ let node = root;
33
+ for (const step of pointer.slice(1).split('/')) {
34
+ const key = unescapePointer(step);
35
+ if (node === null || typeof node !== 'object' || !hasOwn(node, key)) return undefined;
36
+ node = node[key];
37
+ }
38
+ return node;
39
+ }
40
+
41
+ /**
42
+ * The field a problem names: the key of the value at `pointer` in `document`, then
43
+ * `[<index>]` for each list index below it; the root is `the document`.
44
+ */
45
+ function fieldName(document, pointer) {
46
+ if (pointer === '') return 'the document';
47
+ const steps = [];
48
+ let node = document;
49
+ for (const step of pointer.slice(1).split('/')) {
50
+ const key = unescapePointer(step);
51
+ const inList = Array.isArray(node);
52
+ if (inList) node = node[Number(key)];
53
+ else node = isObject(node) && hasOwn(node, key) ? node[key] : undefined;
54
+ steps.push({ key, inList });
55
+ }
56
+ let keyAt = steps.length - 1;
57
+ while (keyAt >= 0 && steps[keyAt].inList) keyAt -= 1;
58
+ const key = keyAt >= 0 ? steps[keyAt].key : '';
59
+ const indexes = steps.slice(keyAt + 1).map((step) => `[${step.key}]`).join('');
60
+ return `\`${key}${indexes}\``;
61
+ }
62
+
63
+ /** A JSON number as a report writes it. */
64
+ function numberText(number) {
65
+ return String(number);
66
+ }
67
+
68
+ /**
69
+ * The sentence of an alternative none of whose forms holds: the first sentence of
70
+ * the definition holding the alternatives, where they are a described
71
+ * definition's, `#/$defs/<name>` or `#/definitions/<name>`; else `null`.
72
+ */
73
+ function describedForm(schema, holderPointer) {
74
+ const steps = holderPointer.split('/').slice(1);
75
+ if (steps.length !== 2 || !DEFINITION_KEYWORDS.includes(steps[0])) return null;
76
+ const description = descriptionText(schemaNodeAt(schema, holderPointer));
77
+ if (!description) return null;
78
+ const sentence = description.split(/(?<=\.)\s/u)[0];
79
+ const firstSentence = sentence.endsWith('.') ? sentence.slice(0, -1) : sentence;
80
+ const leadingWord = /^[A-Za-z0-9]*/u.exec(firstSentence)[0];
81
+ if (leadingWord.length >= 2 && /^[A-Z]+$/u.test(leadingWord)) return firstSentence;
82
+ const [first, ...rest] = Array.from(firstSentence);
83
+ return first === undefined ? null : `${first.toLowerCase()}${rest.join('')}`;
84
+ }
85
+
86
+ /**
87
+ * The dependency keyword's pointer and the key it names, `{ keywordPointer, key }`,
88
+ * where `keywordPointer` lies inside a dependency: the key read from the pointer,
89
+ * or, for a dependency listing fields, from the field `holder` misses; else `null`.
90
+ */
91
+ function dependencyOf(schema, keywordPointer, holder, missingField) {
92
+ const steps = keywordPointer.split('/').slice(1).map(unescapePointer);
93
+ let at = steps.length - 1;
94
+ while (at >= 0 && !(DEPENDENCY_KEYWORDS.includes(steps[at])
95
+ && (at === 0 || !SCHEMA_MAP_KEYWORDS.includes(steps[at - 1])))) at -= 1;
96
+ if (at < 0) return null;
97
+ const dependencyPointer = steps.slice(0, at + 1)
98
+ .map((step) => `/${escapePointer(step)}`)
99
+ .join('');
100
+ let key = steps[at + 1];
101
+ if (key === undefined) {
102
+ const listed = schemaNodeAt(schema, dependencyPointer);
103
+ if (typeof missingField !== 'string' || !isObject(listed)) return null;
104
+ key = Object.keys(listed).find((candidate) => isObject(holder) && hasOwn(holder, candidate)
105
+ && Array.isArray(listed[candidate]) && listed[candidate].includes(missingField));
106
+ if (key === undefined) return null;
107
+ }
108
+ if (!isObject(holder) || !hasOwn(holder, key)) return null;
109
+ return { keywordPointer: dependencyPointer, key };
110
+ }
111
+
112
+ /**
113
+ * `` `key` needs <what> ``: what each dependency of the node holding
114
+ * `keywordPointer` states for `key`.
115
+ */
116
+ function dependencyMessage(schema, keywordPointer, key) {
117
+ const holder = schemaNodeAt(schema, keywordPointer.slice(0, keywordPointer.lastIndexOf('/')));
118
+ const statesKey = (keyword) => isObject(holder)
119
+ && isObject(holder[keyword])
120
+ && hasOwn(holder[keyword], key);
121
+ const neededTexts = DEPENDENCY_KEYWORDS
122
+ .filter(statesKey)
123
+ .map((keyword) => neededText(holder[keyword][key]));
124
+ return `\`${key}\` needs ${neededTexts.length > 0 ? neededTexts.join('; ') : UNSTATED_CONDITION}`;
125
+ }
126
+
127
+ function neededText(needed) {
128
+ if (Array.isArray(needed)) return keyList(needed);
129
+ return consequenceText(needed) || UNSTATED_CONDITION;
130
+ }
131
+
132
+ function consequenceText(node) {
133
+ if (!isObject(node)) return null;
134
+ const parts = [];
135
+ if (Array.isArray(node.required) && node.required.length > 0) parts.push(keyList(node.required));
136
+ if (isObject(node.not) && Array.isArray(node.not.required) && node.not.required.length > 0) {
137
+ parts.push(`${keyList(node.not.required)} must not be set`);
138
+ }
139
+ const properties = isObject(node.properties) ? node.properties : {};
140
+ for (const [field, fieldSchema] of Object.entries(properties)) {
141
+ if (isObject(fieldSchema) && typeof fieldSchema.$ref === 'string') {
142
+ parts.push(`\`${field}\` is a \`${referenceName(fieldSchema.$ref)}\``);
143
+ }
144
+ }
145
+ for (const part of Array.isArray(node.allOf) ? node.allOf : []) {
146
+ const partText = consequenceText(part);
147
+ if (partText) parts.push(partText);
148
+ }
149
+ const alternatives = ['anyOf', 'oneOf']
150
+ .flatMap((keyword) => (Array.isArray(node[keyword]) ? node[keyword] : []));
151
+ if (alternatives.length > 0) {
152
+ parts.push(alternatives
153
+ .map((alternative) => consequenceText(alternative) || UNSTATED_CONDITION)
154
+ .join(', or '));
155
+ }
156
+ if (parts.length === 0 && typeof node.$ref === 'string') {
157
+ parts.push(`a \`${referenceName(node.$ref)}\``);
158
+ }
159
+ return parts.length > 0 ? parts.join('; ') : null;
160
+ }
161
+
162
+ function referenceName(reference) {
163
+ return reference.slice(reference.lastIndexOf('/') + 1).replace(/^#/u, '');
164
+ }
165
+
166
+ function keyList(keys) {
167
+ const spans = keys.map((key) => `\`${typeof key === 'string' ? key : JSON.stringify(key)}\``);
168
+ return spans.length > 1 ? `${spans.slice(0, -1).join(', ')} and ${spans.at(-1)}` : spans.join('');
169
+ }
170
+
171
+ /**
172
+ * The indexes of the duplicate items a list reports, later first, `[later, earlier]`:
173
+ * from the last item back, the first one equal to an item after it, and the
174
+ * nearest such item; `null` for a list without duplicates.
175
+ */
176
+ function duplicateItems(items) {
177
+ for (let earlier = items.length - 1; earlier >= 0; earlier -= 1) {
178
+ for (let later = earlier + 1; later < items.length; later += 1) {
179
+ if (jsonEqual(items[earlier], items[later])) return [later, earlier];
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function jsonEqual(first, second) {
186
+ if (Array.isArray(first) && Array.isArray(second)) {
187
+ return first.length === second.length
188
+ && first.every((item, index) => jsonEqual(item, second[index]));
189
+ }
190
+ if (isObject(first) && isObject(second)) {
191
+ const keys = Object.keys(first);
192
+ return keys.length === Object.keys(second).length
193
+ && keys.every((key) => hasOwn(second, key) && jsonEqual(first[key], second[key]));
194
+ }
195
+ return first === second;
196
+ }
197
+
198
+ /**
199
+ * `must contain at least <min> valid item(s)`, with `and no more than <max>`
200
+ * where `holder` bounds it.
201
+ */
202
+ function containsMessage(holder) {
203
+ const bound = (keyword) => (isObject(holder) && typeof holder[keyword] === 'number'
204
+ ? numberText(holder[keyword])
205
+ : null);
206
+ const minimum = bound('minContains') || '1';
207
+ const maximum = bound('maxContains');
208
+ return maximum === null
209
+ ? `must contain at least ${minimum} valid item(s)`
210
+ : `must contain at least ${minimum} and no more than ${maximum} valid item(s)`;
211
+ }
212
+
213
+ module.exports = {
214
+ fieldName,
215
+ numberText,
216
+ describedForm,
217
+ dependencyOf,
218
+ dependencyMessage,
219
+ duplicateItems,
220
+ containsMessage,
221
+ schemaNodeAt,
222
+ escapePointer,
223
+ };
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Requirement markers: a field's `description` opening with `[required]`,
5
+ * `[~required]` or `[optional]` decides whether the field is required, over its
6
+ * parent's `required` array.
7
+ */
8
+
9
+ const REQUIRED_MARKER = '[required]';
10
+ const REQUIREMENT_MARKERS = [REQUIRED_MARKER, '[~required]', '[optional]'];
11
+ const CONDITION_KEYWORDS = new Set(['if', 'not']);
12
+ const FORM_SELECTING_KEYWORDS = new Set(['anyOf', 'oneOf']);
13
+ const SAME_INSTANCE_SCHEMA_KEYWORDS = new Set(['else', 'if', 'not', 'then']);
14
+ const SAME_INSTANCE_LIST_KEYWORDS = new Set(['allOf', 'anyOf', 'oneOf']);
15
+ const SCHEMA_MAP_KEYWORDS = new Set([
16
+ '$defs',
17
+ 'definitions',
18
+ 'dependentSchemas',
19
+ 'patternProperties',
20
+ 'properties',
21
+ ]);
22
+ const DRAFT_KEYWORDS = {
23
+ 'draft-07': {
24
+ childSchema: new Set([
25
+ 'additionalItems',
26
+ 'additionalProperties',
27
+ 'contains',
28
+ 'items',
29
+ 'propertyNames',
30
+ ]),
31
+ schemaMap: new Set([
32
+ '$defs',
33
+ 'definitions',
34
+ 'dependencies',
35
+ 'patternProperties',
36
+ 'properties',
37
+ ]),
38
+ },
39
+ 'draft 2019-09': {
40
+ childSchema: new Set([
41
+ 'additionalItems',
42
+ 'additionalProperties',
43
+ 'contains',
44
+ 'contentSchema',
45
+ 'items',
46
+ 'propertyNames',
47
+ 'unevaluatedItems',
48
+ 'unevaluatedProperties',
49
+ ]),
50
+ schemaMap: SCHEMA_MAP_KEYWORDS,
51
+ },
52
+ 'draft 2020-12': {
53
+ childSchema: new Set([
54
+ 'additionalProperties',
55
+ 'contains',
56
+ 'contentSchema',
57
+ 'items',
58
+ 'prefixItems',
59
+ 'propertyNames',
60
+ 'unevaluatedItems',
61
+ 'unevaluatedProperties',
62
+ ]),
63
+ schemaMap: SCHEMA_MAP_KEYWORDS,
64
+ },
65
+ };
66
+
67
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
68
+
69
+ /** The marker a schema node's `description` opens with, or `null`. */
70
+ function requirementMarker(node) {
71
+ if (!isObject(node) || typeof node.description !== 'string') return null;
72
+ const description = node.description.trimStart();
73
+ return REQUIREMENT_MARKERS.find((marker) => description.startsWith(marker)) || null;
74
+ }
75
+
76
+ /** A node's description without the marker it opens with, or `null`. */
77
+ function descriptionText(node) {
78
+ if (!isObject(node) || typeof node.description !== 'string') return null;
79
+ const marker = requirementMarker(node);
80
+ const text = marker ? node.description.trimStart().slice(marker.length) : node.description;
81
+ return text.trim() || null;
82
+ }
83
+
84
+ /** `schema`, read in the draft named `draftName`, with each `required` array decided by markers. */
85
+ function markedSchema(schema, draftName) {
86
+ return markedNode(schema, null, DRAFT_KEYWORDS[draftName]);
87
+ }
88
+
89
+ function markersOfFields(properties) {
90
+ const markers = new Map();
91
+ for (const [key, field] of Object.entries(properties)) {
92
+ const marker = requirementMarker(field);
93
+ if (marker) markers.set(key, marker);
94
+ }
95
+ return markers;
96
+ }
97
+
98
+ function markedNode(node, inheritedMarkers, keywords) {
99
+ if (!isObject(node)) return node;
100
+ const ownMarkers = isObject(node.properties) ? markersOfFields(node.properties) : null;
101
+ const markers = ownMarkers || inheritedMarkers;
102
+ const marked = {};
103
+ const markedChild = (child) => markedNode(child, null, keywords);
104
+ for (const [keyword, value] of Object.entries(node)) {
105
+ if (keyword === 'required') continue;
106
+ if (CONDITION_KEYWORDS.has(keyword)) marked[keyword] = value;
107
+ else if (SAME_INSTANCE_SCHEMA_KEYWORDS.has(keyword)) {
108
+ marked[keyword] = markedNode(value, markers, keywords);
109
+ } else if (SAME_INSTANCE_LIST_KEYWORDS.has(keyword) && Array.isArray(value)) {
110
+ const branchMarkers = FORM_SELECTING_KEYWORDS.has(keyword) ? null : markers;
111
+ marked[keyword] = value.map((branch) => markedNode(branch, branchMarkers, keywords));
112
+ } else if (keywords.childSchema.has(keyword)) {
113
+ marked[keyword] = Array.isArray(value) ? value.map(markedChild) : markedChild(value);
114
+ } else if (keywords.schemaMap.has(keyword) && isObject(value)) {
115
+ marked[keyword] = Object.fromEntries(Object.entries(value)
116
+ .map(([name, schema]) => [name, markedChild(schema)]));
117
+ } else {
118
+ marked[keyword] = value;
119
+ }
120
+ }
121
+ const listed = Array.isArray(node.required) ? node.required : [];
122
+ const required = listed.filter((key) => !markers || !markers.has(key));
123
+ for (const [key, marker] of ownMarkers || []) if (marker === REQUIRED_MARKER) required.push(key);
124
+ if (required.length > 0) marked.required = required;
125
+ return marked;
126
+ }
127
+
128
+ module.exports = { requirementMarker, descriptionText, markedSchema };
@@ -7,6 +7,24 @@ const AjvDraft202012 = require('ajv/dist/2020').default;
7
7
  const { Diagnostic } = require('./diagnostic-report');
8
8
  const { FunctionTable } = require('./function-table');
9
9
  const { numberJson, integerOfText } = require('./yaml-node');
10
+ const { markedSchema } = require('./requirement-markers');
11
+ const {
12
+ fieldName,
13
+ numberText,
14
+ describedForm,
15
+ dependencyOf,
16
+ dependencyMessage,
17
+ duplicateItems,
18
+ containsMessage,
19
+ schemaNodeAt,
20
+ } = require('./problem-wording');
21
+ const {
22
+ ValidationRules,
23
+ childValue,
24
+ mergedOver,
25
+ copiedPointers,
26
+ escapePointer,
27
+ } = require('./validation-rules');
10
28
 
11
29
  const VALIDATOR_OPTIONS = {
12
30
  allErrors: true,
@@ -49,23 +67,22 @@ const READS_BELOW_KEYWORDS = new Set([
49
67
  'uniqueItems',
50
68
  ]);
51
69
  const REQUIRED_KEYWORDS = new Set(['required', 'dependentRequired', 'dependencies']);
52
- const LIMIT_PLURALS = {
53
- maxLength: ['is longer than', 'character', 'characters'],
54
- minLength: ['is shorter than', 'character', 'characters'],
55
- maxItems: ['has more than', 'item', 'items'],
56
- minItems: ['has less than', 'item', 'items'],
57
- maxProperties: ['has more than', 'property', 'properties'],
58
- minProperties: ['has less than', 'property', 'properties'],
70
+ const BOUND_COMPARISONS = {
71
+ minimum: '>=',
72
+ maximum: '<=',
73
+ exclusiveMinimum: '>',
74
+ exclusiveMaximum: '<',
59
75
  };
60
- const BOUNDS = {
61
- maximum: 'is greater than the maximum of',
62
- minimum: 'is less than the minimum of',
63
- exclusiveMaximum: 'is greater than or equal to the maximum of',
64
- exclusiveMinimum: 'is less than or equal to the minimum of',
76
+ const LENGTH_LIMITS = {
77
+ minLength: ['fewer', 'characters'],
78
+ maxLength: ['more', 'characters'],
79
+ minItems: ['fewer', 'items'],
80
+ maxItems: ['more', 'items'],
81
+ minProperties: ['fewer', 'properties'],
82
+ maxProperties: ['more', 'properties'],
65
83
  };
66
- const KEYWORD_NAMES = { 'false schema': 'falseSchema', items: 'additionalItems' };
84
+ const KEYWORD_NAMES = { 'false schema': 'falseSchema' };
67
85
  const FALSE_SCHEMA_STEP = /\/false schema$/;
68
- const SHOWN_ENUM_OPTIONS = 3;
69
86
 
70
87
  const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
71
88
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -88,10 +105,11 @@ function draftOfSchema(schema) {
88
105
 
89
106
  /** A document's schema, as its caller hands it over: its function table, and the validation against it. */
90
107
  class LoadedSchema {
91
- constructor(table, validator, schema, name, pointersBySchemaObject) {
108
+ constructor(table, validator, schema, marked, name, pointersBySchemaObject) {
92
109
  this.table = table;
93
110
  this.validator = validator;
94
111
  this.schema = schema;
112
+ this.marked = marked;
95
113
  this.name = name;
96
114
  this.pointersBySchemaObject = pointersBySchemaObject;
97
115
  }
@@ -112,119 +130,234 @@ class LoadedSchema {
112
130
  /** The schema `text` holds, named `name` in reports, or throws why it holds none. */
113
131
  static parse(name, text) {
114
132
  const failed = (error) => new Error(`schema ${name}: ${error.message}`);
115
- let schema;
116
- let table;
117
- let validator;
118
133
  try {
119
- schema = JSON.parse(text);
120
- table = FunctionTable.fromSchema(schema);
134
+ const schema = JSON.parse(text);
135
+ const table = FunctionTable.fromSchema(schema);
121
136
  const draft = draftOfSchema(schema);
122
- const compiled = isObject(schema) && schema.$schema !== undefined && OLDER_DRAFT_URIS.includes(schema.$schema)
137
+ const declaresOlderDraft = isObject(schema)
138
+ && schema.$schema !== undefined
139
+ && OLDER_DRAFT_URIS.includes(schema.$schema);
140
+ const declared = declaresOlderDraft
123
141
  ? { ...schema, $schema: ASSUMED_DRAFT.schemaUris[0] }
124
142
  : schema;
125
- validator = new draft.ValidatorClass(VALIDATOR_OPTIONS).compile(compiled);
126
- return new LoadedSchema(table, validator, schema, name, schemaObjectPointers(compiled));
143
+ const marked = markedSchema(declared, draft.name);
144
+ const validator = new draft.ValidatorClass(VALIDATOR_OPTIONS).compile(marked);
145
+ const pointers = schemaObjectPointers(marked);
146
+ return new LoadedSchema(table, validator, schema, marked, name, pointers);
127
147
  } catch (error) {
128
148
  throw failed(error);
129
149
  }
130
150
  }
131
151
 
132
152
  /**
133
- * Validates `document`, a resolved value. A value not known when the document
134
- * compiles, such as a call or a reference to a declaration, is not checked.
153
+ * Validates `document`, a resolved value, read by `rules`. A value not known
154
+ * when the document compiles, such as a call or a reference to a declaration,
155
+ * is not checked.
135
156
  */
136
- validate(document, childValuePatterns = []) {
137
- const instance = new InstanceRecord(childValuePatterns);
138
- const json = instance.json(document, '', document.position);
157
+ validate(document, rules) {
158
+ const instance = new InstanceRecord(rules);
159
+ const json = this.withQuotedValuesRead(instance.json(document, '', document.position), rules);
139
160
  this.validator(json);
140
- const errors = reportedErrors(this.validator.errors || [], this.schema)
161
+ const errors = reportedErrors(this.validator.errors || [], this.marked)
141
162
  .filter((error) => !instance.readsUnknown(error.instancePath, error.keyword));
142
- const diagnostics = errors.flatMap((error) => this.errorDiagnostics(error, instance));
163
+ const reported = { dependencies: new Set(), itemLists: new Set(), propertyNames: new Set() };
164
+ const diagnostics = errors
165
+ .flatMap((error) => this.errorProblems(error, json, instance, reported))
166
+ .filter((problem) => !instance.isCopied(problem.subject))
167
+ .map((problem) => problem.diagnostic);
143
168
  diagnostics.sort(compareDiagnostics);
144
169
  return diagnostics.filter((diagnostic, index) =>
145
170
  index === 0 || !sameDiagnostic(diagnostic, diagnostics[index - 1]));
146
171
  }
147
172
 
148
173
  /**
149
- * A schema error as the report a reader fixes it from: placed at the value or
150
- * the field that is wrong, labeled with what is wrong there, naming the rule it
151
- * breaks, and the allowed name closest to a misspelt one.
174
+ * `json` with each quoted value that spells a type the schema expects of it, and
175
+ * `rules` allow, read as that type, until no more are read.
152
176
  */
153
- errorDiagnostics(error, instance) {
177
+ withQuotedValuesRead(json, rules) {
178
+ if (rules.quotedValueTypes.length === 0) return json;
179
+ let read = json;
180
+ for (;;) {
181
+ this.validator(read);
182
+ const readings = new Map();
183
+ for (const error of this.validator.errors || []) {
184
+ if (error.keyword !== 'type' || typeof error.data !== 'string') continue;
185
+ const reading = rules.quotedValue(error.data, [].concat(error.schema));
186
+ if (reading !== null && !readings.has(error.instancePath)) {
187
+ readings.set(error.instancePath, reading.value);
188
+ }
189
+ }
190
+ if (readings.size === 0) return read;
191
+ for (const [pointer, value] of readings) read = withValueAt(read, pointer, value);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * A schema error as the problems a reader fixes it from, `{ subject, diagnostic }`:
197
+ * placed at the value or the field that is wrong, worded by the standard,
198
+ * labeled with what is wrong there, naming the rule it breaks, and the allowed
199
+ * name closest to a misspelt one.
200
+ */
201
+ errorProblems(error, json, instance, reported) {
154
202
  const pointer = error.instancePath;
155
203
  const keyword = keywordName(error);
156
204
  const schemaPointer = this.keywordPointer(error);
157
- const rule = `${keyword}, at ${this.name}#${schemaPointer}`;
158
- if (error.propertyName !== undefined) {
159
- return [
160
- Diagnostic.at(instance.valuePosition(pointer), `${displayPointer(pointer)}: ${errorText(error, instance)}`)
161
- .withLabel(`breaks \`${keyword}\``)
162
- .withRule(rule),
163
- ];
164
- }
165
- if (error.keyword === 'items' && error.schema === false) {
166
- const extra = Array.isArray(error.data) ? error.data.slice(error.params.limit) : [];
167
- return extra.map((item, index) => {
168
- const itemPointer = `${pointer}/${error.params.limit + index}`;
169
- return Diagnostic.at(
170
- instance.valuePosition(itemPointer),
171
- `${displayPointer(itemPointer)}: False schema does not allow ${instance.jsonText(item, itemPointer)}`,
205
+ const ruleAt = (name, at) => `${name}, at ${this.name}#${at}`;
206
+ const rule = ruleAt(keyword, schemaPointer);
207
+ const field = fieldName(json, pointer);
208
+ const missingField = REQUIRED_KEYWORDS.has(error.keyword)
209
+ ? error.params.missingProperty
210
+ : null;
211
+ const dependency = error.propertyName === undefined
212
+ ? dependencyOf(this.marked, schemaPointer, valueAt(json, pointer), missingField)
213
+ : null;
214
+ if (dependency !== null) {
215
+ const subject = `${pointer}/${escapePointer(dependency.key)}`;
216
+ if (reported.dependencies.has(subject)) return [];
217
+ reported.dependencies.add(subject);
218
+ const { keywordPointer } = dependency;
219
+ const dependencyKeyword = keywordPointer.slice(keywordPointer.lastIndexOf('/') + 1);
220
+ const at = `${dependency.keywordPointer}/${escapePointer(dependency.key)}`;
221
+ return [{
222
+ subject,
223
+ diagnostic: Diagnostic.at(
224
+ instance.position(subject),
225
+ dependencyMessage(this.marked, dependency.keywordPointer, dependency.key),
172
226
  )
173
- .withLabel('breaks `falseSchema`')
174
- .withRule(`falseSchema, at ${this.name}#${schemaPointer}`);
175
- });
227
+ .withLabel(`breaks \`${dependencyKeyword}\``)
228
+ .withRule(ruleAt(dependencyKeyword, at)),
229
+ }];
176
230
  }
177
- const step = lastStep(pointer);
178
- const holder = step === null ? 'the document' : `\`${step}\``;
179
231
  const suggestion = (name, candidates) => {
180
232
  const closest = closestName(name, candidates);
181
233
  return closest === null ? null : `did you mean \`${closest}\`?`;
182
234
  };
235
+ const atValue = (message) => [{
236
+ subject: pointer,
237
+ diagnostic: Diagnostic.at(instance.valuePosition(pointer), message)
238
+ .withLabel(`breaks \`${keyword}\``)
239
+ .withRule(rule),
240
+ }];
241
+ if (error.propertyName !== undefined) {
242
+ const subject = `${pointer}/${escapePointer(error.propertyName)}`;
243
+ if (reported.propertyNames.has(subject)) return [];
244
+ reported.propertyNames.add(subject);
245
+ return [{
246
+ subject,
247
+ diagnostic: Diagnostic.at(
248
+ instance.position(subject),
249
+ `\`${error.propertyName}\` is not a valid key of ${field}`,
250
+ )
251
+ .withLabel('breaks `propertyNames`')
252
+ .withRule(rule),
253
+ }];
254
+ }
255
+ const itemsBeyondTuple = (error.keyword === 'items' && error.schema === false)
256
+ || error.keyword === 'additionalItems';
257
+ if (itemsBeyondTuple) {
258
+ if (reported.itemLists.has(pointer)) return [];
259
+ reported.itemLists.add(pointer);
260
+ const listKeyword = schemaPointer.slice(schemaPointer.lastIndexOf('/') + 1);
261
+ return [{
262
+ subject: pointer,
263
+ diagnostic: Diagnostic.at(
264
+ instance.valuePosition(pointer),
265
+ `${field} must NOT have more than ${error.params.limit} items`,
266
+ )
267
+ .withLabel(`breaks \`${listKeyword}\``)
268
+ .withRule(ruleAt(listKeyword, schemaPointer)),
269
+ }];
270
+ }
183
271
  if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties') {
184
272
  const name = error.keyword === 'additionalProperties'
185
273
  ? error.params.additionalProperty
186
274
  : error.params.unevaluatedProperty;
187
275
  const allowed = allowedFields(this.schema, schemaPointer);
188
- const field = `${pointer}/${escapePointer(name)}`;
189
- return [
190
- Diagnostic.at(instance.position(field), `field \`${name}\` is not allowed`)
191
- .withLabel(`${holder} has no field \`${name}\``)
276
+ const subject = `${pointer}/${escapePointer(name)}`;
277
+ return [{
278
+ subject,
279
+ diagnostic: Diagnostic.at(
280
+ instance.position(subject),
281
+ `\`${name}\` is not a field of ${field}`,
282
+ )
283
+ .withLabel(`${field} has no field \`${name}\``)
192
284
  .withRule(rule)
193
285
  .withHelp(suggestion(name, allowed)),
194
- ];
286
+ }];
195
287
  }
196
288
  if (error.keyword === 'enum') {
197
289
  const options = Array.isArray(error.schema) ? error.schema : [];
198
290
  const allowed = options.filter((option) => typeof option === 'string');
199
- const value = error.data;
200
- return [
201
- Diagnostic.at(instance.valuePosition(pointer), `\`${shownValue(value, pointer, instance)}\` is not an allowed value`)
291
+ const shown = options.map((option) => `\`${jsonText(option)}\``).join(', ');
292
+ return [{
293
+ subject: pointer,
294
+ diagnostic: Diagnostic.at(
295
+ instance.valuePosition(pointer),
296
+ `${field} must be one of ${shown}`,
297
+ )
202
298
  .withLabel(`not one of the ${options.length} values \`enum\` allows`)
203
299
  .withRule(rule)
204
- .withHelp(typeof value === 'string' ? suggestion(value, allowed) : null),
205
- ];
300
+ .withHelp(typeof error.data === 'string' ? suggestion(error.data, allowed) : null),
301
+ }];
206
302
  }
207
303
  if (error.keyword === 'type') {
208
- const expected = typesOf(error.schema);
209
- return [
210
- Diagnostic.at(instance.valuePosition(pointer), `${holder} is not ${withArticle(expected.join(' or '))}`)
304
+ const expected = [].concat(error.schema);
305
+ return [{
306
+ subject: pointer,
307
+ diagnostic: Diagnostic.at(
308
+ instance.valuePosition(pointer),
309
+ `${field} must be ${expected.join(' or ')}`,
310
+ )
211
311
  .withLabel(`is ${withArticle(instance.jsonType(pointer, error.data))}`)
212
312
  .withRule(rule),
213
- ];
313
+ }];
214
314
  }
215
315
  if (REQUIRED_KEYWORDS.has(error.keyword)) {
216
316
  const name = error.params.missingProperty;
217
- return [
218
- Diagnostic.at(instance.position(pointer), `field \`${name}\` is required`)
219
- .withLabel(`${holder} has no field \`${name}\``)
317
+ return [{
318
+ subject: pointer,
319
+ diagnostic: Diagnostic.at(instance.position(pointer), `\`${name}\` is required`)
320
+ .withLabel(`${field} has no field \`${name}\``)
220
321
  .withRule(rule),
221
- ];
322
+ }];
323
+ }
324
+ if (ALTERNATIVE_KEYWORDS.has(error.keyword)) {
325
+ if (Array.isArray(error.params.passingSchemas)) {
326
+ return atValue(`${field} matches more than one of its allowed forms`);
327
+ }
328
+ const holderPointer = schemaPointer.slice(0, schemaPointer.lastIndexOf('/'));
329
+ const form = describedForm(this.schema, holderPointer);
330
+ return atValue(form === null
331
+ ? `${field} matches none of its allowed forms`
332
+ : `${field} must be ${form}`);
333
+ }
334
+ if (hasOwn(BOUND_COMPARISONS, error.keyword)) {
335
+ const comparison = BOUND_COMPARISONS[error.keyword];
336
+ return atValue(`${field} must be ${comparison} ${numberText(error.schema)}`);
337
+ }
338
+ if (hasOwn(LENGTH_LIMITS, error.keyword)) {
339
+ const [comparison, unit] = LENGTH_LIMITS[error.keyword];
340
+ return atValue(`${field} must NOT have ${comparison} than ${error.params.limit} ${unit}`);
341
+ }
342
+ switch (error.keyword) {
343
+ case 'const': return atValue(`${field} must be equal to constant`);
344
+ case 'multipleOf':
345
+ return atValue(`${field} must be multiple of ${numberText(error.schema)}`);
346
+ case 'pattern': return atValue(`${field} must match pattern "${error.params.pattern}"`);
347
+ case 'uniqueItems': {
348
+ const items = Array.isArray(error.data) ? error.data : [];
349
+ const [later, earlier] = duplicateItems(items) || [0, 0];
350
+ return atValue(
351
+ `${field} must NOT have duplicate items (items ## ${later} and ${earlier} are identical)`,
352
+ );
353
+ }
354
+ case 'contains': return atValue(`${field} ${containsMessage(error.parentSchema)}`);
355
+ case 'not': return atValue(`${field} must NOT be valid`);
356
+ case 'false schema': return atValue(`${field} boolean schema is false`);
357
+ case 'unevaluatedItems':
358
+ return atValue(`${field} must NOT have more than ${error.params.len} items`);
359
+ default: return atValue(`${field} breaks \`${keyword}\``);
222
360
  }
223
- return [
224
- Diagnostic.at(instance.valuePosition(pointer), `${displayPointer(pointer)}: ${errorText(error, instance)}`)
225
- .withLabel(`breaks \`${keyword}\``)
226
- .withRule(rule),
227
- ];
228
361
  }
229
362
  }
230
363
 
@@ -322,17 +455,6 @@ function pointerOfSchemaPath(path) {
322
455
  }
323
456
  }
324
457
 
325
- function schemaNodeAt(root, pointer) {
326
- if (pointer === '') return root;
327
- let node = root;
328
- for (const step of pointer.slice(1).split('/')) {
329
- const key = step.replaceAll('~1', '/').replaceAll('~0', '~');
330
- if (node === null || typeof node !== 'object' || !hasOwn(node, key)) return undefined;
331
- node = node[key];
332
- }
333
- return node;
334
- }
335
-
336
458
  /**
337
459
  * The fields a schema node allows, by the `properties` of the node holding the
338
460
  * keyword at `keywordPointer`, of the schemas it references and of those it
@@ -397,61 +519,10 @@ function editDistance(first, second) {
397
519
  return previous[secondCharacters.length];
398
520
  }
399
521
 
400
- /**
401
- * What a JSON pointer names, as a report shows it: its last key, or for a list
402
- * item, the list's key and the item's index, such as `dsls[0]`; `null` for the
403
- * document's root.
404
- */
405
- function lastStep(pointer) {
406
- const slash = pointer.lastIndexOf('/');
407
- if (slash === -1) return null;
408
- const parent = pointer.slice(0, slash);
409
- const step = pointer.slice(slash + 1);
410
- if (/^\d+$/.test(step)) {
411
- const list = lastStep(parent);
412
- if (list !== null) return `${list}[${step}]`;
413
- }
414
- return step.replaceAll('~1', '/').replaceAll('~0', '~');
415
- }
416
-
417
- function escapePointer(key) {
418
- return key.replaceAll('~', '~0').replaceAll('/', '~1');
419
- }
420
-
421
- function displayPointer(pointer) {
422
- if (pointer === '') return 'the document';
423
- const steps = pointer.slice(1).split('/').map((step) => step.replaceAll('~1', '/').replaceAll('~0', '~'));
424
- return `\`${steps.join('.')}\``;
425
- }
426
-
427
522
  function withArticle(noun) {
428
523
  return `${/^[aeiou]/.test(noun) ? 'an' : 'a'} ${noun}`;
429
524
  }
430
525
 
431
- /** A schema's `type` as the crate lists several: in its fixed order of JSON types. */
432
- function typesOf(typeKeyword) {
433
- if (!Array.isArray(typeKeyword)) return [String(typeKeyword)];
434
- const order = ['null', 'boolean', 'integer', 'number', 'string', 'array', 'object'];
435
- return order.filter((type) => typeKeyword.includes(type));
436
- }
437
-
438
- /** A string as Rust's `Debug` writes it. */
439
- function debugText(text) {
440
- let written = '"';
441
- for (const character of text) {
442
- const code = character.codePointAt(0);
443
- if (character === '"') written += '\\"';
444
- else if (character === '\\') written += '\\\\';
445
- else if (character === '\n') written += '\\n';
446
- else if (character === '\r') written += '\\r';
447
- else if (character === '\t') written += '\\t';
448
- else if (character === '\0') written += '\\0';
449
- else if (code < 0x20 || code === 0x7f) written += `\\u{${code.toString(16)}}`;
450
- else written += character;
451
- }
452
- return `${written}"`;
453
- }
454
-
455
526
  /** A floating-point number as `serde_json` writes one. */
456
527
  function floatText(number) {
457
528
  if (Object.is(number, -0)) return '-0.0';
@@ -496,72 +567,6 @@ function nodeJsonText(node) {
496
567
  return jsonText(node.toJson(), (at) => (numberTexts.has(at) ? numberTexts.get(at) : null));
497
568
  }
498
569
 
499
- function shownValue(value, pointer, instance) {
500
- return typeof value === 'string' ? debugText(value) : instance.jsonText(value, pointer);
501
- }
502
-
503
- function plural(count, words) {
504
- return count === 1 ? words[1] : words[2];
505
- }
506
-
507
- /** An error's text as the crate's validator words it. */
508
- function errorText(error, instance) {
509
- const shown = instance.jsonText(error.data, error.instancePath);
510
- const { keyword, params } = error;
511
- if (hasOwn(LIMIT_PLURALS, keyword)) {
512
- const words = LIMIT_PLURALS[keyword];
513
- return `${shown} ${words[0]} ${params.limit} ${plural(params.limit, words)}`;
514
- }
515
- if (hasOwn(BOUNDS, keyword)) return `${shown} ${BOUNDS[keyword]} ${jsonText(params.limit)}`;
516
- switch (keyword) {
517
- case 'enum': return `${shown} is not one of ${enumOptionsText(Array.isArray(error.schema) ? error.schema : [])}`;
518
- case 'type': {
519
- const types = typesOf(error.schema);
520
- return types.length === 1
521
- ? `${shown} is not of type ${JSON.stringify(types[0])}`
522
- : `${shown} is not of types ${types.map((type) => JSON.stringify(type)).join(', ')}`;
523
- }
524
- case 'const': return `${jsonText(error.schema)} was expected`;
525
- case 'pattern': return `${shown} does not match "${params.pattern}"`;
526
- case 'multipleOf': return `${shown} is not a multiple of ${params.multipleOf}`;
527
- case 'uniqueItems': return `${shown} has non-unique elements`;
528
- case 'not': return `${jsonText(error.schema)} is not allowed for ${shown}`;
529
- case 'anyOf': return `${shown} is not valid under any of the schemas listed in the 'anyOf' keyword`;
530
- case 'oneOf':
531
- return params.passingSchemas
532
- ? `${shown} is valid under more than one of the schemas listed in the 'oneOf' keyword`
533
- : `${shown} is not valid under any of the schemas listed in the 'oneOf' keyword`;
534
- case 'contains': return `None of ${shown} are valid under the given schema`;
535
- case 'false schema': return `False schema does not allow ${shown}`;
536
- case 'propertyNames': return `${JSON.stringify(params.propertyName)} is not valid under the property names schema`;
537
- case 'items':
538
- case 'additionalItems': {
539
- const extra = Array.isArray(error.data) ? error.data.slice(params.limit) : [];
540
- const listed = extra.map((item, index) =>
541
- instance.jsonText(item, `${error.instancePath}/${params.limit + index}`));
542
- return `Additional items are not allowed (${listed.join(', ')} ${extra.length === 1 ? 'was' : 'were'} unexpected)`;
543
- }
544
- case 'unevaluatedItems': {
545
- const extra = Array.isArray(error.data) ? error.data.slice(params.len) : [];
546
- const listed = extra.map((item, index) =>
547
- `'${instance.jsonText(item, `${error.instancePath}/${params.len + index}`)}'`);
548
- return `Unevaluated items are not allowed (${listed.join(', ')} ${extra.length === 1 ? 'was' : 'were'} unexpected)`;
549
- }
550
- default: return error.message;
551
- }
552
- }
553
-
554
- /** An enum's options as the crate's validator lists them: a few, then how many more. */
555
- function enumOptionsText(options) {
556
- const written = options.map((option) => jsonText(option));
557
- if (written.length <= SHOWN_ENUM_OPTIONS) {
558
- if (written.length <= 1) return written.join('');
559
- return `${written.slice(0, -1).join(', ')} or ${written.at(-1)}`;
560
- }
561
- const shown = written.slice(0, SHOWN_ENUM_OPTIONS - 1);
562
- return `${shown.join(', ')} or ${written.length - shown.length} other candidates`;
563
- }
564
-
565
570
  function compareDiagnostics(first, second) {
566
571
  const firstPlace = first.position ? [first.position.line, first.position.column] : [0, 0];
567
572
  const secondPlace = second.position ? [second.position.line, second.position.column] : [0, 0];
@@ -573,14 +578,39 @@ function sameDiagnostic(first, second) {
573
578
  return JSON.stringify(first) === JSON.stringify(second);
574
579
  }
575
580
 
581
+ function pointerSteps(pointer) {
582
+ return pointer.slice(1).split('/')
583
+ .map((step) => step.replaceAll('~1', '/').replaceAll('~0', '~'));
584
+ }
585
+
586
+ /** The value at a JSON pointer of `json`, `null` where it holds none. */
587
+ function valueAt(json, pointer) {
588
+ if (pointer === '') return json;
589
+ let node = json;
590
+ for (const step of pointerSteps(pointer)) {
591
+ if (node === null || typeof node !== 'object' || !hasOwn(node, step)) return null;
592
+ node = node[step];
593
+ }
594
+ return node;
595
+ }
596
+
597
+ /** `json` with the value at a JSON pointer it holds replaced by `value`. */
598
+ function withValueAt(json, pointer, value) {
599
+ if (pointer === '') return value;
600
+ const holder = valueAt(json, pointer.slice(0, pointer.lastIndexOf('/')));
601
+ if (holder !== null && typeof holder === 'object') holder[pointerSteps(pointer).at(-1)] = value;
602
+ return json;
603
+ }
604
+
576
605
  /** The document as JSON, and where each of its values was written. */
577
606
  class InstanceRecord {
578
- constructor(childValuePatterns) {
579
- this.childValuePatterns = childValuePatterns;
607
+ constructor(rules) {
608
+ this.rules = rules;
580
609
  this.positions = new Map();
581
610
  this.valuePositions = new Map();
582
611
  this.numberTexts = new Map();
583
612
  this.unknown = [];
613
+ this.copied = [];
584
614
  }
585
615
 
586
616
  json(value, pointer, keyPosition) {
@@ -604,6 +634,7 @@ class InstanceRecord {
604
634
  if (isObject(json)) this.setChildValue(entry.key, json);
605
635
  object[entry.key] = json;
606
636
  }
637
+ this.mergeBaseKeys(object, pointer);
607
638
  return object;
608
639
  }
609
640
  default:
@@ -614,10 +645,30 @@ class InstanceRecord {
614
645
 
615
646
  /** A key such as `name mock-orders` sets the field `name` of the entry it keys, unless it writes the field. */
616
647
  setChildValue(key, fields) {
617
- const found = childValue(key, this.childValuePatterns);
648
+ const found = this.rules.childValue(key);
618
649
  if (found !== null && !hasOwn(fields, found.field)) fields[found.field] = found.value;
619
650
  }
620
651
 
652
+ /** Merges the content of each merge base key `object` holds into each sibling map. */
653
+ mergeBaseKeys(object, pointer) {
654
+ const { mergeBaseKeys } = this.rules;
655
+ for (const baseKey of mergeBaseKeys.filter((key) => hasOwn(object, key))) {
656
+ const base = object[baseKey];
657
+ for (const [key, written] of Object.entries(object)) {
658
+ if (mergeBaseKeys.includes(key) || !isObject(written)) continue;
659
+ const entryPointer = `${pointer}/${escapePointer(key)}`;
660
+ const merged = mergedOver(base, written, entryPointer, (at) => this.isUnknown(at));
661
+ this.copied.push(...copiedPointers(merged, written, entryPointer));
662
+ object[key] = merged;
663
+ }
664
+ }
665
+ }
666
+
667
+ /** Whether `pointer` names a value a merge base key copied in, or one below it. */
668
+ isCopied(pointer) {
669
+ return this.copied.some((copied) => pointer === copied || pointer.startsWith(`${copied}/`));
670
+ }
671
+
621
672
  /**
622
673
  * Whether an error at `pointer` comes from a value not known when the document
623
674
  * compiles: the value itself, or a check that reads a value below it through a
@@ -658,35 +709,15 @@ class InstanceRecord {
658
709
  }
659
710
  return 'object';
660
711
  }
661
-
662
- jsonText(value, pointer) {
663
- return jsonText(value, (at) => (this.numberTexts.has(at) ? this.numberTexts.get(at) : null), pointer);
664
- }
665
- }
666
-
667
- /**
668
- * The field a key such as `name mock-orders` sets on the entry it keys, and the
669
- * value it sets, `{ field, value }`, by the first of `patterns` with `field` and
670
- * `value` groups that matches; else `null`.
671
- */
672
- function childValue(key, patterns) {
673
- for (const pattern of patterns) {
674
- const match = pattern.exec(key);
675
- const groups = match && match.groups;
676
- if (groups && groups.field !== undefined && groups.value !== undefined) {
677
- return { field: groups.field, value: groups.value };
678
- }
679
- }
680
- return null;
681
712
  }
682
713
 
683
714
  module.exports = {
684
715
  LoadedSchema,
685
716
  SchemaCache,
717
+ ValidationRules,
686
718
  childValue,
687
719
  closestName,
688
720
  nodeJsonText,
689
721
  draftOfSchema,
690
722
  compareText,
691
- debugText,
692
723
  };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * How a document is read before it is validated: the DSL definition's
5
+ * `validator` block, ready to apply.
6
+ */
7
+
8
+ const { compilePattern } = require('./pattern-dialect');
9
+ const { numberJson } = require('./yaml-node');
10
+
11
+ const INTEGER_TEXT = /^-?(0|[1-9][0-9]*)$/;
12
+ const NUMBER_TEXT = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
13
+ const QUOTED_VALUE_READERS = {
14
+ boolean: (text) => ({ true: true, false: false })[text],
15
+ integer: (text) => (INTEGER_TEXT.test(text) ? numberJson(text) : undefined),
16
+ number: (text) => (NUMBER_TEXT.test(text) ? numberJson(text) : undefined),
17
+ };
18
+
19
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
20
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+
22
+ /** A DSL's `validator` block with its patterns compiled. */
23
+ class ValidationRules {
24
+ constructor({ mergeBaseKeys = [], childValuePatterns = [], quotedValueTypes = [] } = {}) {
25
+ this.mergeBaseKeys = mergeBaseKeys;
26
+ this.childValuePatterns = childValuePatterns;
27
+ this.quotedValueTypes = quotedValueTypes;
28
+ }
29
+
30
+ /** The rules a DSL's `validator` block states, or throws a pattern that does not compile. */
31
+ static fromValidator(validator) {
32
+ if (!validator) return new ValidationRules();
33
+ const childValuePatterns = validator.child_values_from_parent_key.map((rule) => {
34
+ const compiled = compilePattern(rule.pattern);
35
+ if (compiled === null) throw new Error(`\`${rule.pattern}\` is not a pattern`);
36
+ return compiled;
37
+ });
38
+ return new ValidationRules({
39
+ mergeBaseKeys: [...validator.merge_base_keys],
40
+ childValuePatterns,
41
+ quotedValueTypes: [...validator.allow_quoted_values],
42
+ });
43
+ }
44
+
45
+ /** The field a key such as `name mock-orders` sets, `{ field, value }`; else `null`. */
46
+ childValue(key) {
47
+ return childValue(key, this.childValuePatterns);
48
+ }
49
+
50
+ /**
51
+ * The value a quoted `text` is validated as where the schema expects one of
52
+ * `expectedTypes`, in their order, `{ value }`; `null` where it reads as none.
53
+ */
54
+ quotedValue(text, expectedTypes) {
55
+ for (const expectedType of expectedTypes) {
56
+ const allowed = this.quotedValueTypes.includes(expectedType)
57
+ && hasOwn(QUOTED_VALUE_READERS, expectedType);
58
+ if (!allowed) continue;
59
+ const value = QUOTED_VALUE_READERS[expectedType](text);
60
+ if (value !== undefined) return { value };
61
+ }
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The field a key such as `name mock-orders` sets on the entry it keys, and the
68
+ * value it sets, `{ field, value }`, by the first of `patterns` with `field` and
69
+ * `value` groups that matches; else `null`.
70
+ */
71
+ function childValue(key, patterns) {
72
+ for (const pattern of patterns) {
73
+ const match = pattern.exec(key);
74
+ const groups = match && match.groups;
75
+ if (groups && groups.field !== undefined && groups.value !== undefined) {
76
+ return { field: groups.field, value: groups.value };
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * `overlay` merged over `base` as an overlay merges over its common layer: maps
84
+ * merge key by key, an empty map or any other value replaces, and a null deletes
85
+ * the key unless `holdsUnknown` says the null stands for a value not yet known.
86
+ */
87
+ function mergedOver(base, overlay, overlayPointer, holdsUnknown) {
88
+ const merged = isObject(base) ? structuredClone(base) : {};
89
+ mergeInto(merged, overlay, overlayPointer, holdsUnknown);
90
+ return merged;
91
+ }
92
+
93
+ function mergeInto(target, overlay, overlayPointer, holdsUnknown) {
94
+ for (const [key, value] of Object.entries(overlay)) {
95
+ const childPointer = `${overlayPointer}/${escapePointer(key)}`;
96
+ if (value === null && !holdsUnknown(childPointer)) {
97
+ delete target[key];
98
+ continue;
99
+ }
100
+ if (isObject(target[key]) && isObject(value) && Object.keys(value).length > 0) {
101
+ mergeInto(target[key], value, childPointer, holdsUnknown);
102
+ } else {
103
+ target[key] = structuredClone(value);
104
+ }
105
+ }
106
+ }
107
+
108
+ /** The pointers of `merged` that `written` does not hold: the values a merge copied in. */
109
+ function copiedPointers(merged, written, pointer) {
110
+ if (!isObject(merged) || !isObject(written)) return [];
111
+ return Object.entries(merged).flatMap(([key, value]) => {
112
+ const childPointer = `${pointer}/${escapePointer(key)}`;
113
+ return hasOwn(written, key)
114
+ ? copiedPointers(value, written[key], childPointer)
115
+ : [childPointer];
116
+ });
117
+ }
118
+
119
+ function escapePointer(key) {
120
+ return key.replaceAll('~', '~0').replaceAll('/', '~1');
121
+ }
122
+
123
+ module.exports = { ValidationRules, childValue, mergedOver, copiedPointers, escapePointer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaml-dsl-spec",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "The YAML DSL standard: the JSON Schemas of yaml-dsl.yml and of a DSL's function table, and a reader returning a yaml-dsl.yml's DSL definitions validated.",
5
5
  "license": "MIT",
6
6
  "repository": {