Skip to content

Commit 89b2273

Browse files
fix(path-expanding): overwrite with new value
1 parent eefb024 commit 89b2273

File tree

10 files changed

+199
-94
lines changed

10 files changed

+199
-94
lines changed

packages/toon/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@
3838
"test": "vitest"
3939
},
4040
"devDependencies": {
41-
"@toon-format/spec": "^1.4.0"
41+
"@toon-format/spec": "^1.5.2"
4242
}
4343
}

packages/toon/src/decode/decoders.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { ArrayHeaderInfo, Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ParsedLine, ResolvedDecodeOptions } from '../types'
2+
import type { ObjectWithQuotedKeys } from './expand'
23
import type { LineCursor } from './scanner'
3-
import { COLON, DEFAULT_DELIMITER, LIST_ITEM_PREFIX } from '../constants'
4+
import { COLON, DEFAULT_DELIMITER, DOT, LIST_ITEM_PREFIX } from '../constants'
45
import { findClosingQuote } from '../shared/string-utils'
6+
import { QUOTED_KEY_MARKER } from './expand'
57
import { isArrayHeaderAfterHyphen, isObjectFirstFieldAfterHyphen, mapRowValuesToPrimitives, parseArrayHeaderLine, parseDelimitedValues, parseKeyToken, parsePrimitiveToken } from './parser'
68
import { assertExpectedCount, validateNoBlankLinesInRange, validateNoExtraListItems, validateNoExtraTabularRows } from './validation'
79

@@ -55,6 +57,7 @@ function isKeyValueLine(line: ParsedLine): boolean {
5557

5658
function decodeObject(cursor: LineCursor, baseDepth: Depth, options: ResolvedDecodeOptions): JsonObject {
5759
const obj: JsonObject = {}
60+
const quotedKeys: Set<string> = new Set()
5861

5962
// Detect the actual depth of the first field (may differ from baseDepth in nested structures)
6063
let computedDepth: Depth | undefined
@@ -70,15 +73,25 @@ function decodeObject(cursor: LineCursor, baseDepth: Depth, options: ResolvedDec
7073
}
7174

7275
if (line.depth === computedDepth) {
73-
const [key, value] = decodeKeyValuePair(line, cursor, computedDepth, options)
76+
const [key, value, isQuoted] = decodeKeyValuePair(line, cursor, computedDepth, options)
7477
obj[key] = value
78+
79+
// Track quoted dotted keys for expansion phase
80+
if (isQuoted && key.includes(DOT)) {
81+
quotedKeys.add(key)
82+
}
7583
}
7684
else {
7785
// Different depth (shallower or deeper) - stop object parsing
7886
break
7987
}
8088
}
8189

90+
// Attach quoted key metadata if any were found
91+
if (quotedKeys.size > 0) {
92+
(obj as ObjectWithQuotedKeys)[QUOTED_KEY_MARKER] = quotedKeys
93+
}
94+
8295
return obj
8396
}
8497

@@ -87,48 +100,49 @@ function decodeKeyValue(
87100
cursor: LineCursor,
88101
baseDepth: Depth,
89102
options: ResolvedDecodeOptions,
90-
): { key: string, value: JsonValue, followDepth: Depth } {
103+
): { key: string, value: JsonValue, followDepth: Depth, isQuoted: boolean } {
91104
// Check for array header first (before parsing key)
92105
const arrayHeader = parseArrayHeaderLine(content, DEFAULT_DELIMITER)
93106
if (arrayHeader && arrayHeader.header.key) {
94-
const value = decodeArrayFromHeader(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options)
107+
const decodedValue = decodeArrayFromHeader(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options)
95108
// After an array, subsequent fields are at baseDepth + 1 (where array content is)
96109
return {
97110
key: arrayHeader.header.key,
98-
value,
111+
value: decodedValue,
99112
followDepth: baseDepth + 1,
113+
isQuoted: false, // Array keys parsed separately in `parseArrayHeaderLine`
100114
}
101115
}
102116

103117
// Regular key-value pair
104-
const { key, end } = parseKeyToken(content, 0)
118+
const { key, end, isQuoted } = parseKeyToken(content, 0)
105119
const rest = content.slice(end).trim()
106120

107121
// No value after colon - expect nested object or empty
108122
if (!rest) {
109123
const nextLine = cursor.peek()
110124
if (nextLine && nextLine.depth > baseDepth) {
111125
const nested = decodeObject(cursor, baseDepth + 1, options)
112-
return { key, value: nested, followDepth: baseDepth + 1 }
126+
return { key, value: nested, followDepth: baseDepth + 1, isQuoted }
113127
}
114128
// Empty object
115-
return { key, value: {}, followDepth: baseDepth + 1 }
129+
return { key, value: {}, followDepth: baseDepth + 1, isQuoted }
116130
}
117131

118132
// Inline primitive value
119-
const value = parsePrimitiveToken(rest)
120-
return { key, value, followDepth: baseDepth + 1 }
133+
const decodedValue = parsePrimitiveToken(rest)
134+
return { key, value: decodedValue, followDepth: baseDepth + 1, isQuoted }
121135
}
122136

123137
function decodeKeyValuePair(
124138
line: ParsedLine,
125139
cursor: LineCursor,
126140
baseDepth: Depth,
127141
options: ResolvedDecodeOptions,
128-
): [key: string, value: JsonValue] {
142+
): [key: string, value: JsonValue, isQuoted: boolean] {
129143
cursor.advance()
130-
const { key, value } = decodeKeyValue(line.content, cursor, baseDepth, options)
131-
return [key, value]
144+
const { key, value, isQuoted } = decodeKeyValue(line.content, cursor, baseDepth, options)
145+
return [key, value, isQuoted]
132146
}
133147

134148
// #endregion
@@ -364,9 +378,15 @@ function decodeObjectFromListItem(
364378
options: ResolvedDecodeOptions,
365379
): JsonObject {
366380
const afterHyphen = firstLine.content.slice(LIST_ITEM_PREFIX.length)
367-
const { key, value, followDepth } = decodeKeyValue(afterHyphen, cursor, baseDepth, options)
381+
const { key, value, followDepth, isQuoted } = decodeKeyValue(afterHyphen, cursor, baseDepth, options)
368382

369383
const obj: JsonObject = { [key]: value }
384+
const quotedKeys: Set<string> = new Set()
385+
386+
// Track if first key was quoted and dotted
387+
if (isQuoted && key.includes(DOT)) {
388+
quotedKeys.add(key)
389+
}
370390

371391
// Read subsequent fields
372392
while (!cursor.atEnd()) {
@@ -376,14 +396,24 @@ function decodeObjectFromListItem(
376396
}
377397

378398
if (line.depth === followDepth && !line.content.startsWith(LIST_ITEM_PREFIX)) {
379-
const [k, v] = decodeKeyValuePair(line, cursor, followDepth, options)
399+
const [k, v, kIsQuoted] = decodeKeyValuePair(line, cursor, followDepth, options)
380400
obj[k] = v
401+
402+
// Track quoted dotted keys
403+
if (kIsQuoted && k.includes(DOT)) {
404+
quotedKeys.add(k)
405+
}
381406
}
382407
else {
383408
break
384409
}
385410
}
386411

412+
// Attach quoted key metadata if any were found
413+
if (quotedKeys.size > 0) {
414+
(obj as ObjectWithQuotedKeys)[QUOTED_KEY_MARKER] = quotedKeys
415+
}
416+
387417
return obj
388418
}
389419

packages/toon/src/decode/expand.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import { isIdentifierSegment } from '../shared/validation'
55

66
// #region Path expansion (safe)
77

8+
/**
9+
* Symbol used to mark object keys that were originally quoted in the TOON source.
10+
* Quoted dotted keys should not be expanded, even if they meet expansion criteria.
11+
*/
12+
export const QUOTED_KEY_MARKER: unique symbol = Symbol('quotedKey')
13+
14+
/**
15+
* Type for objects that may have quoted key metadata attached.
16+
*/
17+
export interface ObjectWithQuotedKeys extends JsonObject {
18+
[QUOTED_KEY_MARKER]?: Set<string>
19+
}
20+
821
/**
922
* Checks if two values can be merged (both are plain objects).
1023
*/
@@ -41,30 +54,59 @@ export function expandPathsSafe(value: JsonValue, strict: boolean): JsonValue {
4154
}
4255

4356
if (isJsonObject(value)) {
44-
const result: JsonObject = {}
57+
const expandedObject: JsonObject = {}
4558
const keys = Object.keys(value)
4659

60+
// Check if this object has quoted key metadata
61+
const quotedKeys = (value as ObjectWithQuotedKeys)[QUOTED_KEY_MARKER]
62+
4763
for (const key of keys) {
48-
const val = value[key]!
64+
const keyValue = value[key]!
65+
66+
// Skip expansion for keys that were originally quoted
67+
const isQuoted = quotedKeys?.has(key)
4968

50-
// Check if key contains dots
51-
if (key.includes(DOT)) {
69+
// Check if key contains dots and should be expanded
70+
if (key.includes(DOT) && !isQuoted) {
5271
const segments = key.split(DOT)
5372

5473
// Validate all segments are identifiers
5574
if (segments.every(seg => isIdentifierSegment(seg))) {
5675
// Expand this dotted key
57-
const expandedValue = expandPathsSafe(val, strict)
58-
insertPathSafe(result, segments, expandedValue, strict)
76+
const expandedValue = expandPathsSafe(keyValue, strict)
77+
insertPathSafe(expandedObject, segments, expandedValue, strict)
5978
continue
6079
}
6180
}
6281

6382
// Not expandable - keep as literal key, but still recursively expand the value
64-
result[key] = expandPathsSafe(val, strict)
83+
const expandedValue = expandPathsSafe(keyValue, strict)
84+
85+
// Check for conflicts with already-expanded keys
86+
if (key in expandedObject) {
87+
const conflictingValue = expandedObject[key]!
88+
// If both are objects, try to merge them
89+
if (canMerge(conflictingValue, expandedValue)) {
90+
mergeObjects(conflictingValue as JsonObject, expandedValue as JsonObject, strict)
91+
}
92+
else {
93+
// Conflict: incompatible types
94+
if (strict) {
95+
throw new TypeError(
96+
`Path expansion conflict at key "${key}": cannot merge ${typeof conflictingValue} with ${typeof expandedValue}`,
97+
)
98+
}
99+
// Non-strict: overwrite (LWW)
100+
expandedObject[key] = expandedValue
101+
}
102+
}
103+
else {
104+
// No conflict - insert directly
105+
expandedObject[key] = expandedValue
106+
}
65107
}
66108

67-
return result
109+
return expandedObject
68110
}
69111

70112
// Primitive value - return as-is
@@ -80,7 +122,7 @@ export function expandPathsSafe(value: JsonValue, strict: boolean): JsonValue {
80122
* - If both are objects: deep merge (continue insertion)
81123
* - If values differ: conflict
82124
* - strict=true: throw TypeError
83-
* - strict=false: overwrite with new value (last-wins)
125+
* - strict=false: overwrite with new value (LWW)
84126
*
85127
* @param target - The object to insert into
86128
* @param segments - Array of path segments (e.g., ['data', 'metadata', 'items'])
@@ -94,58 +136,58 @@ function insertPathSafe(
94136
value: JsonValue,
95137
strict: boolean,
96138
): void {
97-
let current: JsonObject = target
139+
let currentNode: JsonObject = target
98140

99141
// Walk to the penultimate segment, creating objects as needed
100142
for (let i = 0; i < segments.length - 1; i++) {
101143
const seg = segments[i]!
102-
const existing = current[seg]
144+
const segmentValue = currentNode[seg]
103145

104-
if (existing === undefined) {
146+
if (segmentValue === undefined) {
105147
// Create new intermediate object
106148
const newObj: JsonObject = {}
107-
current[seg] = newObj
108-
current = newObj
149+
currentNode[seg] = newObj
150+
currentNode = newObj
109151
}
110-
else if (isJsonObject(existing)) {
152+
else if (isJsonObject(segmentValue)) {
111153
// Continue into existing object
112-
current = existing
154+
currentNode = segmentValue
113155
}
114156
else {
115157
// Conflict: existing value is not an object
116158
if (strict) {
117159
throw new TypeError(
118-
`Path expansion conflict at segment "${seg}": expected object but found ${typeof existing}`,
160+
`Path expansion conflict at segment "${seg}": expected object but found ${typeof segmentValue}`,
119161
)
120162
}
121163
// Non-strict: overwrite with new object
122164
const newObj: JsonObject = {}
123-
current[seg] = newObj
124-
current = newObj
165+
currentNode[seg] = newObj
166+
currentNode = newObj
125167
}
126168
}
127169

128170
// Insert at the final segment
129171
const lastSeg = segments[segments.length - 1]!
130-
const existing = current[lastSeg]
172+
const destinationValue = currentNode[lastSeg]
131173

132-
if (existing === undefined) {
174+
if (destinationValue === undefined) {
133175
// No conflict - insert directly
134-
current[lastSeg] = value
176+
currentNode[lastSeg] = value
135177
}
136-
else if (canMerge(existing, value)) {
178+
else if (canMerge(destinationValue, value)) {
137179
// Both are objects - deep merge
138-
mergeObjects(existing as JsonObject, value as JsonObject, strict)
180+
mergeObjects(destinationValue as JsonObject, value as JsonObject, strict)
139181
}
140182
else {
141183
// Conflict: incompatible types
142184
if (strict) {
143185
throw new TypeError(
144-
`Path expansion conflict at key "${lastSeg}": cannot merge ${typeof existing} with ${typeof value}`,
186+
`Path expansion conflict at key "${lastSeg}": cannot merge ${typeof destinationValue} with ${typeof value}`,
145187
)
146188
}
147189
// Non-strict: overwrite (LWW)
148-
current[lastSeg] = value
190+
currentNode[lastSeg] = value
149191
}
150192
}
151193

packages/toon/src/decode/parser.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export function parseBracketSegment(
146146

147147
export function parseDelimitedValues(input: string, delimiter: Delimiter): string[] {
148148
const values: string[] = []
149-
let current = ''
149+
let valueBuffer = ''
150150
let inQuotes = false
151151
let i = 0
152152

@@ -155,32 +155,32 @@ export function parseDelimitedValues(input: string, delimiter: Delimiter): strin
155155

156156
if (char === BACKSLASH && i + 1 < input.length && inQuotes) {
157157
// Escape sequence in quoted string
158-
current += char + input[i + 1]
158+
valueBuffer += char + input[i + 1]
159159
i += 2
160160
continue
161161
}
162162

163163
if (char === DOUBLE_QUOTE) {
164164
inQuotes = !inQuotes
165-
current += char
165+
valueBuffer += char
166166
i++
167167
continue
168168
}
169169

170170
if (char === delimiter && !inQuotes) {
171-
values.push(current.trim())
172-
current = ''
171+
values.push(valueBuffer.trim())
172+
valueBuffer = ''
173173
i++
174174
continue
175175
}
176176

177-
current += char
177+
valueBuffer += char
178178
i++
179179
}
180180

181181
// Add last value
182-
if (current || values.length > 0) {
183-
values.push(current.trim())
182+
if (valueBuffer || values.length > 0) {
183+
values.push(valueBuffer.trim())
184184
}
185185

186186
return values
@@ -292,12 +292,12 @@ export function parseQuotedKey(content: string, start: number): { key: string, e
292292
return { key, end }
293293
}
294294

295-
export function parseKeyToken(content: string, start: number): { key: string, end: number } {
295+
export function parseKeyToken(content: string, start: number): { key: string, end: number, isQuoted: boolean } {
296296
if (content[start] === DOUBLE_QUOTE) {
297-
return parseQuotedKey(content, start)
297+
return { ...parseQuotedKey(content, start), isQuoted: true }
298298
}
299299
else {
300-
return parseUnquotedKey(content, start)
300+
return { ...parseUnquotedKey(content, start), isQuoted: false }
301301
}
302302
}
303303

0 commit comments

Comments
 (0)