diff --git a/README.md b/README.md index 6f7edf3..1c9495b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,47 @@ node.update({ The `update` method reconciles the new content with the existing document, preserving comments, indentation, and spacing. +To merge two documents while preserving comments, use the `merge` method: + +```ts + +const destinationCode = `{ + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "baz": [1, 2, 3] +} +`; + +const sourceCode = `{ + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ +} +`; + +const source = JsonParser.parse(sourceCode, JsonObjectNode); +const destination = JsonParser.parse(destinationCode, JsonObjectNode); + +console.log(JsonObjectNode.merge(source, destination).toString()); +``` + +Output: + +```json5 +{ + // Destination pre-foo comment + "foo": "value", + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ +} +``` + +The `merge` method removes any existing destination properties that clash with source, along with their leading/trailing trivia, to avoid duplicate keys. + ## Contributing Contributions are welcome! diff --git a/src/node/objectNode.ts b/src/node/objectNode.ts index b2297fe..c2c8f41 100644 --- a/src/node/objectNode.ts +++ b/src/node/objectNode.ts @@ -8,6 +8,13 @@ import {JsonPrimitiveNode, JsonStringNode} from './primitiveNode'; import {JsonValueFactory} from './factory'; import {JsonIdentifierNode} from './identifierNode'; import {JsonError} from '../error'; +import {NodeMatcher} from '../manipulator'; +import {JsonTokenNode} from './tokenNode'; +import {JsonTokenType} from '../token'; +import INSIGNIFICANT = NodeMatcher.INSIGNIFICANT; +import NEWLINE = NodeMatcher.NEWLINE; +import SIGNIFICANT = NodeMatcher.SIGNIFICANT; +import SPACE = NodeMatcher.SPACE; export interface JsonObjectDefinition extends JsonCompositeDefinition { readonly properties: readonly JsonPropertyNode[]; @@ -33,6 +40,159 @@ export class JsonObjectNode extends JsonStructureNode implements JsonCompositeDe }); } + public merge(source: JsonObjectNode): void { + if (source.propertyNodes.length === 0) { + return; + } + + if (this.propertyNodes.length === 0) { + this.propertyNodes.push(...source.propertyNodes.map(property => property.clone())); + this.children.splice(0, this.children.length, ...source.children.map(child => child.clone())); + + return; + } + + for (const property of source.propertyNodes) { + const key = property.key.toJSON(); + const sourceRange = source.findPropertyRange(key); + + let sourceChildren: JsonNode[] = [property]; + + if (sourceRange !== null) { + sourceChildren = source.children.slice(sourceRange[0], sourceRange[1] + 1); + } + + const newProperty = property.clone(); + + sourceChildren = sourceChildren.map(node => (node === property ? newProperty : node.clone())); + + const range = this.findPropertyRange(key); + + if (range === null) { + this.propertyNodes.push(newProperty); + this.insert(sourceChildren); + + continue; + } + + const currentIndex = this.propertyNodes.findIndex(candidate => candidate.key.toJSON() === key); + + this.propertyNodes.splice(currentIndex, 1, newProperty); + this.children.splice(range[0], range[1] - range[0] + 1, ...sourceChildren); + } + } + + private insert(nodes: JsonNode[]): void { + let insertionIndex = this.children.length; + + for (let index = this.children.length - 1; index >= 0; index--) { + const child = this.children[index]; + + if (child instanceof JsonTokenNode) { + if (child.isType(JsonTokenType.BRACE_RIGHT)) { + insertionIndex = index; + + continue; + } + + if (child.isType(JsonTokenType.COMMA)) { + insertionIndex = index + 1; + + break; + } + } + + if (SIGNIFICANT(child)) { + break; + } + + if (NEWLINE(child)) { + while (index > 0 && SPACE(this.children[index - 1])) { + index--; + } + + insertionIndex = index; + + break; + } + + insertionIndex = index; + } + + let needsComma = false; + + for (let index = insertionIndex - 1; index >= 0; index--) { + const child = this.children[index]; + + if (child instanceof JsonTokenNode) { + if (child.isType(JsonTokenType.COMMA)) { + needsComma = false; + + break; + } + } + + if (SIGNIFICANT(child)) { + needsComma = true; + + break; + } + } + + if (needsComma) { + this.children.splice(insertionIndex, 0, new JsonTokenNode({ + type: JsonTokenType.COMMA, + value: ',', + })); + + insertionIndex++; + } + + this.children.splice(insertionIndex, 0, ...nodes); + } + + private findPropertyRange(name: string): [number, number] | null { + let startIndex = this.children.findIndex( + node => node instanceof JsonPropertyNode && node.key.toJSON() === name, + ); + + if (startIndex === -1) { + return null; + } + + let endIndex = startIndex; + + for (let lookBehind = startIndex - 1; lookBehind >= 0; lookBehind--) { + const child = this.children[lookBehind]; + + if (!INSIGNIFICANT(child)) { + break; + } + + if (NEWLINE(child)) { + startIndex = lookBehind; + } + } + + for (let lookAhead = endIndex + 1; lookAhead < this.children.length; lookAhead++) { + const child = this.children[lookAhead]; + + if (!(child instanceof JsonTokenNode) || (SIGNIFICANT(child) && !child.isType(JsonTokenType.COMMA))) { + break; + } + + if (NEWLINE(child)) { + endIndex = lookAhead - 1; + + break; + } + + endIndex = lookAhead; + } + + return [startIndex, endIndex]; + } + public update(other: JsonValueNode|JsonValue): JsonValueNode { if (!(other instanceof JsonValueNode)) { if (typeof other !== 'object' || other === null || Array.isArray(other)) { diff --git a/test/node/objectNode.test.ts b/test/node/objectNode.test.ts index 77dec8e..63255b5 100644 --- a/test/node/objectNode.test.ts +++ b/test/node/objectNode.test.ts @@ -3,6 +3,7 @@ import { JsonArrayNode, JsonIdentifierNode, JsonObjectNode, + JsonParser, JsonPrimitiveNode, JsonPropertyNode, JsonStringNode, @@ -381,4 +382,226 @@ describe('ObjectNode', () => { key: value, }); }); + + type MergeScenario = { + description: string, + sourceCode: string, + destinationCode: string, + expected: string, + }; + + it.each([ + { + description: 'empty source', + sourceCode: multiline` + { + }`, + destinationCode: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "baz": [1, 2, 3] + }`, + expected: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "baz": [1, 2, 3] + }`, + }, + { + description: 'empty source children', + sourceCode: '', + destinationCode: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "baz": [1, 2, 3] + }`, + expected: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "baz": [1, 2, 3] + }`, + }, + { + description: 'empty destination', + sourceCode: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + destinationCode: multiline` + { + }`, + expected: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + }, + { + description: 'empty destination children', + sourceCode: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + destinationCode: '', + expected: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + }, + { + description: 'without line breaks', + sourceCode: multiline` + {/* Source pre-bar comment */ "bar": 123, /* Inline comment */} + `, + destinationCode: multiline` + { "foo": "value" } + `, + expected: multiline` + { "foo": "value","bar": 123, /* Inline comment */ } + `, + }, + { + description: 'destination with multiple line breaks', + sourceCode: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + } + `, + destinationCode: multiline` + { + "foo": "value" + + + } + `, + expected: multiline` + { + "foo": "value", + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + + + } + `, + }, + { + description: 'destination with trailing comma and closing brace without preceding newline', + sourceCode: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + }`, + destinationCode: multiline` + { + // Destination pre-foo comment + "foo": "value",}`, + expected: multiline` + { + // Destination pre-foo comment + "foo": "value", + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */}`, + }, + { + description: 'overlapping properties', + sourceCode: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + destinationCode: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "baz": [1, 2, 3] + }`, + expected: multiline` + { + // Destination pre-foo comment + "foo": "value", + /* Source post-bar comment */ + "baz": true, /* Another inline comment */ + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + }`, + }, + { + description: 'disjoint properties', + sourceCode: multiline` + { + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + destinationCode: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "fizz": 42 + }`, + expected: multiline` + { + // Destination pre-foo comment + "foo": "value", + // Destination post-foo comment + "fizz": 42, + /* Source pre-bar comment */ + "bar": 123, /* Inline comment */ + /* Source post-bar comment */ + "baz": true /* Another inline comment */ + }`, + }, + ])('should merge objects with $description', ({sourceCode, destinationCode, expected}) => { + const source = sourceCode === '' + ? JsonObjectNode.of({}) + : JsonParser.parse(sourceCode, JsonObjectNode); + + const destination = destinationCode === '' + ? JsonObjectNode.of({}) + : JsonParser.parse(destinationCode, JsonObjectNode); + + destination.merge(source); + + expect(destination.toString()).toStrictEqual(expected); + }); + + function multiline(strings: TemplateStringsArray): string { + const lines = strings.join('').split('\n'); + + if (lines.length < 2) { + return strings.join(''); + } + + const indent = lines[1].search(/\S/); + + return lines + .map(line => line.slice(indent)) + .join('\n') + .trim(); + } });