Skip to content

Commit 3b4f21c

Browse files
authored
feat: Check key uniqueness; add uniqueKeys option (#271)
1 parent 025fd94 commit 3b4f21c

File tree

8 files changed

+94
-9
lines changed

8 files changed

+94
-9
lines changed

docs/03_options.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ Parse options affect the parsing and composition of a YAML Document from it sour
2222

2323
Used by: `parse()`, `parseDocument()`, `parseAllDocuments()`, `new Composer()`, and `new Document()`
2424

25-
| Name | Type | Default | Description |
26-
| ------------ | ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
27-
| intAsBigInt | `boolean` | `false` | Whether integers should be parsed into [BigInt] rather than `number` values. |
28-
| lineCounter | `LineCounter` | | If set, newlines will be tracked, to allow for `lineCounter.linePos(offset)` to provide the `{ line, col }` positions within the input. |
29-
| prettyErrors | `boolean` | `true` | Include line/col position in errors, along with an extract of the source string. |
30-
| strict | `boolean` | `true` | When parsing, do not ignore errors required by the YAML 1.2 spec, but caused by unambiguous content. |
25+
| Name | Type | Default | Description |
26+
| ------------ | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27+
| intAsBigInt | `boolean` | `false` | Whether integers should be parsed into [BigInt] rather than `number` values. |
28+
| lineCounter | `LineCounter` | | If set, newlines will be tracked, to allow for `lineCounter.linePos(offset)` to provide the `{ line, col }` positions within the input. |
29+
| prettyErrors | `boolean` | `true` | Include line/col position in errors, along with an extract of the source string. |
30+
| strict | `boolean` | `true` | When parsing, do not ignore errors required by the YAML 1.2 spec, but caused by unambiguous content. |
31+
| uniqueKeys | `boolean ⎮ (a, b) => boolean` | `true` | Whether key uniqueness is checked, or customised. If set to be a function, it will be passed two parsed nodes and should return a boolean value indicating their equality. |
3132

3233
[bigint]: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/BigInt
3334

docs/08_errors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ To identify errors for special handling, you should primarily use `code` to diff
3333
| `BLOCK_AS_IMPLICIT_KEY` | There's probably something wrong with the indentation, or you're trying to parse something like `a: b: c`, where it's not clear what's the key and what's the value. |
3434
| `BLOCK_IN_FLOW` | YAML scalars and collections both have block and flow styles. Flow is allowed within block, but not the other way around. |
3535
| `COMMENT_SPACE` | Comments need to be separated from their preceding content by a space. |
36+
| `DUPLICATE_KEY` | Map keys must be unique. Use the `uniqueKeys` option to disable or customise this check when parsing. |
3637
| `IMPOSSIBLE` | This really should not happen. If you encounter this error code, please file a bug. |
3738
| `KEY_OVER_1024_CHARS` | Due to legacy reasons, implicit keys must have their following `:` indicator after at most 1k characters. |
3839
| `MISSING_ANCHOR` | Aliases can only dereference anchors that are before them in the document. |

src/compose/resolve-block-map.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type { ParsedNode } from '../nodes/Node.js'
12
import { Pair } from '../nodes/Pair.js'
23
import { YAMLMap } from '../nodes/YAMLMap.js'
34
import type { BlockMap, Token } from '../parse/cst.js'
45
import type { ComposeContext, ComposeNode } from './compose-node.js'
56
import type { ComposeErrorHandler } from './composer.js'
67
import { resolveProps } from './resolve-props.js'
78
import { containsNewline } from './util-contains-newline.js'
9+
import { mapIncludes } from './util-map-includes.js'
810

911
const startColMsg = 'All mapping items must start at the same column'
1012

@@ -14,7 +16,7 @@ export function resolveBlockMap(
1416
bm: BlockMap,
1517
onError: ComposeErrorHandler
1618
) {
17-
const map = new YAMLMap(ctx.schema)
19+
const map = new YAMLMap<ParsedNode, ParsedNode>(ctx.schema)
1820

1921
let offset = bm.offset
2022
for (const { start, key, sep, value } of bm.items) {
@@ -61,6 +63,9 @@ export function resolveBlockMap(
6163
? composeNode(ctx, key, keyProps, onError)
6264
: composeEmptyNode(ctx, keyStart, start, null, keyProps, onError)
6365

66+
if (mapIncludes(ctx, map.items, keyNode))
67+
onError(keyStart, 'DUPLICATE_KEY', 'Map keys must be unique')
68+
6469
// value properties
6570
const valueProps = resolveProps(sep || [], {
6671
ctx,

src/compose/resolve-flow-collection.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ComposeErrorHandler } from './composer.js'
88
import { resolveEnd } from './resolve-end.js'
99
import { resolveProps } from './resolve-props.js'
1010
import { containsNewline } from './util-contains-newline.js'
11+
import { mapIncludes } from './util-map-includes.js'
1112

1213
const blockMsg = 'Block collections are not allowed within flow collections'
1314
const isBlock = (token: Token | null | undefined) =>
@@ -169,8 +170,12 @@ export function resolveFlowCollection(
169170
}
170171

171172
const pair = new Pair(keyNode, valueNode)
172-
if (isMap) (coll as YAMLMap.Parsed).items.push(pair)
173-
else {
173+
if (isMap) {
174+
const map = coll as YAMLMap.Parsed
175+
if (mapIncludes(ctx, map.items, keyNode))
176+
onError(keyStart, 'DUPLICATE_KEY', 'Map keys must be unique')
177+
map.items.push(pair)
178+
} else {
174179
const map = new YAMLMap(ctx.schema)
175180
map.flow = true
176181
map.items.push(pair)

src/compose/util-map-includes.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { isScalar, ParsedNode } from '../nodes/Node'
2+
import { Pair } from '../nodes/Pair'
3+
import { ComposeContext } from './compose-node'
4+
5+
export function mapIncludes(
6+
ctx: ComposeContext,
7+
items: Pair<ParsedNode>[],
8+
search: ParsedNode
9+
) {
10+
const { uniqueKeys } = ctx.options
11+
if (uniqueKeys === false) return false
12+
const isEqual =
13+
typeof uniqueKeys === 'function'
14+
? uniqueKeys
15+
: (a: ParsedNode, b: ParsedNode) =>
16+
a === b ||
17+
(isScalar(a) &&
18+
isScalar(b) &&
19+
a.value === b.value &&
20+
!(a.value === '<<' && ctx.schema.merge))
21+
return items.some(pair => isEqual(pair.key, search))
22+
}

src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type ErrorCode =
1010
| 'BLOCK_AS_IMPLICIT_KEY'
1111
| 'BLOCK_IN_FLOW'
1212
| 'COMMENT_SPACE'
13+
| 'DUPLICATE_KEY'
1314
| 'IMPOSSIBLE'
1415
| 'KEY_OVER_1024_CHARS'
1516
| 'MISSING_ANCHOR'

src/options.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Reviver } from './doc/applyReviver.js'
22
import type { Directives } from './doc/directives.js'
33
import type { LogLevelId } from './log.js'
4+
import type { ParsedNode } from './nodes/Node.js'
45
import type { Pair } from './nodes/Pair.js'
56
import type { Scalar } from './nodes/Scalar.js'
67
import type { LineCounter } from './parse/line-counter.js'
@@ -38,6 +39,20 @@ export type ParseOptions = {
3839
* Default: `true`
3940
*/
4041
strict?: boolean
42+
43+
/**
44+
* YAML requires map keys to be unique. By default, this is checked by
45+
* comparing scalar values with `===`; deep equality is not checked for
46+
* aliases or collections. If merge keys are enabled by the schema,
47+
* multiple `<<` keys are allowed.
48+
*
49+
* Set `false` to disable, or provide your own comparator function to
50+
* customise. The comparator will be passed two `ParsedNode` values, and
51+
* is expected to return a `boolean` indicating their equality.
52+
*
53+
* Default: `true`
54+
*/
55+
uniqueKeys?: boolean | ((a: ParsedNode, b: ParsedNode) => boolean)
4156
}
4257

4358
export type DocumentOptions = {
@@ -305,5 +320,6 @@ export const defaultOptions: Required<
305320
logLevel: 'warn',
306321
prettyErrors: true,
307322
strict: true,
323+
uniqueKeys: true,
308324
version: '1.2'
309325
}

tests/doc/parse.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,40 @@ test('Anchor for empty node (6KGN)', () => {
612612
expect(YAML.parse(src)).toMatchObject({ a: null, b: null })
613613
})
614614

615+
describe('duplicate keys', () => {
616+
test('block collection scalars', () => {
617+
const doc = YAML.parseDocument('foo: 1\nbar: 2\nfoo: 3\n')
618+
expect(doc.errors).toMatchObject([{ code: 'DUPLICATE_KEY' }])
619+
})
620+
621+
test('flow collection scalars', () => {
622+
const doc = YAML.parseDocument('{ foo: 1, bar: 2, foo: 3 }\n')
623+
expect(doc.errors).toMatchObject([{ code: 'DUPLICATE_KEY' }])
624+
})
625+
626+
test('merge key (disabled)', () => {
627+
const doc = YAML.parseDocument('<<: 1\nbar: 2\n<<: 3\n', { merge: false })
628+
expect(doc.errors).toMatchObject([{ code: 'DUPLICATE_KEY' }])
629+
})
630+
631+
test('disable with option', () => {
632+
const doc = YAML.parseDocument('foo: 1\nbar: 2\nfoo: 3\n', {
633+
uniqueKeys: false
634+
})
635+
expect(doc.errors).toMatchObject([])
636+
})
637+
638+
test('customise with option', () => {
639+
const doc = YAML.parseDocument('foo: 1\nbar: 2\nfoo: 3\n', {
640+
uniqueKeys: () => true
641+
})
642+
expect(doc.errors).toMatchObject([
643+
{ code: 'DUPLICATE_KEY' },
644+
{ code: 'DUPLICATE_KEY' }
645+
])
646+
})
647+
})
648+
615649
describe('handling complex keys', () => {
616650
let origEmitWarning
617651
beforeAll(() => {

0 commit comments

Comments
 (0)