fix: implement nested array indentation

This commit is contained in:
Johann Schopplich
2025-10-23 17:55:29 +02:00
parent 8baff48b3c
commit e04468cb7b
3 changed files with 198 additions and 8 deletions

View File

@@ -335,6 +335,30 @@ items[2]{sku,qty,price}:
B2,1,14.5 B2,1,14.5
``` ```
**Tabular formatting applies recursively:** nested arrays of objects (whether as object properties or inside list items) also use tabular format if they meet the same requirements.
```ts
encode({
items: [
{
users: [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Bob' }
],
status: 'active'
}
]
})
```
```
items[1]:
- users[2]{id,name}:
1,Ada
2,Bob
status: active
```
#### Mixed and Non-Uniform Arrays #### Mixed and Non-Uniform Arrays
Arrays that don't meet the tabular requirements use list format: Arrays that don't meet the tabular requirements use list format:
@@ -357,6 +381,9 @@ items[2]:
extra: true extra: true
``` ```
> [!NOTE]
> **Nested array indentation:** When the first field of a list item is an array (primitive, tabular, or nested), its contents are indented two spaces under the header line, and subsequent fields of the same object appear at that same indentation level. This remains unambiguous because list items begin with `"- "`, tabular arrays declare a fixed row count in their header, and object fields contain `":"`.
#### Arrays of Arrays #### Arrays of Arrays
When you have arrays containing primitive inner arrays: When you have arrays containing primitive inner arrays:

View File

@@ -191,11 +191,7 @@ export function encodeArrayOfObjectsAsTabular(
const headerStr = formatHeader(rows.length, { key: prefix, fields: header }) const headerStr = formatHeader(rows.length, { key: prefix, fields: header })
writer.push(depth, `${headerStr}`) writer.push(depth, `${headerStr}`)
for (const row of rows) { writeTabularRows(rows, header, writer, depth + 1, options)
const values = header.map(key => row[key])
const joinedValue = joinEncodedValues(values as JsonPrimitive[], options.delimiter)
writer.push(depth + 1, joinedValue)
}
} }
export function detectTabularHeader(rows: readonly JsonObject[]): string[] | undefined { export function detectTabularHeader(rows: readonly JsonObject[]): string[] | undefined {
@@ -238,6 +234,20 @@ export function isTabularArray(
return true return true
} }
function writeTabularRows(
rows: readonly JsonObject[],
header: readonly string[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
for (const row of rows) {
const values = header.map(key => row[key])
const joinedValue = joinEncodedValues(values as JsonPrimitive[], options.delimiter)
writer.push(depth, joinedValue)
}
}
// #endregion // #endregion
// #region Array of objects (expanded format) // #region Array of objects (expanded format)
@@ -287,9 +297,46 @@ export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, dept
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`) writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`)
} }
else if (isJsonArray(firstValue)) { else if (isJsonArray(firstValue)) {
// For arrays, we need to put them on separate lines if (isArrayOfPrimitives(firstValue)) {
// Inline format for primitive arrays
const formatted = formatInlineArray(firstValue, options.delimiter, firstKey)
writer.push(depth, `${LIST_ITEM_PREFIX}${formatted}`)
}
else if (isArrayOfObjects(firstValue)) {
// Check if array of objects can use tabular format
const header = detectTabularHeader(firstValue)
if (header) {
// Tabular format for uniform arrays of objects
const headerStr = formatHeader(firstValue.length, { key: firstKey, fields: header })
writer.push(depth, `${LIST_ITEM_PREFIX}${headerStr}`)
writeTabularRows(firstValue, header, writer, depth + 1, options)
}
else {
// Fall back to list format for non-uniform arrays of objects
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}[${firstValue.length}]:`) writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}[${firstValue.length}]:`)
// ... handle array encoding (simplified for now) for (const item of firstValue) {
encodeObjectAsListItem(item, writer, depth + 1, options)
}
}
}
else {
// Complex arrays on separate lines (array of arrays, etc.)
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}[${firstValue.length}]:`)
// Encode array contents at depth + 1
for (const item of firstValue) {
if (isJsonPrimitive(item)) {
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${encodePrimitive(item, options.delimiter)}`)
}
else if (isJsonArray(item) && isArrayOfPrimitives(item)) {
const inline = formatInlineArray(item, options.delimiter)
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${inline}`)
}
else if (isJsonObject(item)) {
encodeObjectAsListItem(item, writer, depth + 1, options)
}
}
}
} }
else if (isJsonObject(firstValue)) { else if (isJsonObject(firstValue)) {
const nestedKeys = Object.keys(firstValue) const nestedKeys = Object.keys(firstValue)

View File

@@ -285,6 +285,122 @@ describe('arrays of objects (tabular and list items)', () => {
) )
}) })
it('preserves field order in list items', () => {
const obj = { items: [{ nums: [1, 2, 3], name: 'test' }] }
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - nums[3]: 1,2,3\n'
+ ' name: test',
)
})
it('preserves field order when primitive appears first', () => {
const obj = { items: [{ name: 'test', nums: [1, 2, 3] }] }
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - name: test\n'
+ ' nums[3]: 1,2,3',
)
})
it('uses list format for objects containing arrays of arrays', () => {
const obj = {
items: [
{ matrix: [[1, 2], [3, 4]], name: 'grid' },
],
}
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - matrix[2]:\n'
+ ' - [2]: 1,2\n'
+ ' - [2]: 3,4\n'
+ ' name: grid',
)
})
it('uses tabular format for nested uniform object arrays', () => {
const obj = {
items: [
{ users: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Bob' }], status: 'active' },
],
}
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - users[2]{id,name}:\n'
+ ' 1,Ada\n'
+ ' 2,Bob\n'
+ ' status: active',
)
})
it('uses list format for nested object arrays with mismatched keys', () => {
const obj = {
items: [
{ users: [{ id: 1, name: 'Ada' }, { id: 2 }], status: 'active' },
],
}
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - users[2]:\n'
+ ' - id: 1\n'
+ ' name: Ada\n'
+ ' - id: 2\n'
+ ' status: active',
)
})
it('uses list format for objects with multiple array fields', () => {
const obj = { items: [{ nums: [1, 2], tags: ['a', 'b'], name: 'test' }] }
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - nums[2]: 1,2\n'
+ ' tags[2]: a,b\n'
+ ' name: test',
)
})
it('uses list format for objects with only array fields', () => {
const obj = { items: [{ nums: [1, 2, 3], tags: ['a', 'b'] }] }
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - nums[3]: 1,2,3\n'
+ ' tags[2]: a,b',
)
})
it('handles objects with empty arrays in list format', () => {
const obj = {
items: [
{ name: 'test', data: [] },
],
}
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - name: test\n'
+ ' data[0]:',
)
})
it('places first field of nested tabular arrays on hyphen line', () => {
const obj = { items: [{ users: [{ id: 1 }, { id: 2 }], note: 'x' }] }
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - users[2]{id}:\n'
+ ' 1\n'
+ ' 2\n'
+ ' note: x',
)
})
it('places empty arrays on hyphen line when first', () => {
const obj = { items: [{ data: [], name: 'x' }] }
expect(encode(obj)).toBe(
'items[1]:\n'
+ ' - data[0]:\n'
+ ' name: x',
)
})
it('uses field order from first object for tabular headers', () => { it('uses field order from first object for tabular headers', () => {
const obj = { const obj = {
items: [ items: [