feat: opt-in key folding and path expansion (closes #86)

This commit is contained in:
Johann Schopplich
2025-11-10 09:56:09 +01:00
parent e1f5d1313d
commit eefb0242e2
14 changed files with 647 additions and 12 deletions

View File

@@ -65,6 +65,9 @@ cat data.toon | toon --decode
| `--length-marker` | Add `#` prefix to array lengths (e.g., `items[#3]`) |
| `--stats` | Show token count estimates and savings (encode only) |
| `--no-strict` | Disable strict validation when decoding |
| `--key-folding <mode>` | Enable key folding: `off`, `safe` (default: `off`) - v1.5 |
| `--flatten-depth <number>` | Maximum folded segment count when key folding is enabled (default: `Infinity`) - v1.5 |
| `--expand-paths <mode>` | Enable path expansion: `off`, `safe` (default: `off`) - v1.5 |
## Advanced Examples
@@ -119,12 +122,81 @@ cat large-dataset.json | toon --delimiter "\t" > output.toon
jq '.results' data.json | toon > filtered.toon
```
### Key Folding (v1.5)
Collapse nested wrapper chains to reduce tokens:
#### Basic key folding
```bash
# Encode with key folding
toon input.json --key-folding safe -o output.toon
```
For data like:
```json
{
"data": {
"metadata": {
"items": ["a", "b"]
}
}
}
```
Output becomes:
```
data.metadata.items[2]: a,b
```
Instead of:
```
data:
metadata:
items[2]: a,b
```
#### Limit folding depth
```bash
# Fold maximum 2 levels deep
toon input.json --key-folding safe --flatten-depth 2 -o output.toon
```
#### Path expansion on decode
```bash
# Reconstruct nested structure from folded keys
toon data.toon --expand-paths safe -o output.json
```
#### Round-trip workflow
```bash
# Encode with folding
toon input.json --key-folding safe -o compressed.toon
# Decode with expansion (restores original structure)
toon compressed.toon --expand-paths safe -o output.json
# Verify round-trip
diff input.json output.json
```
#### Combined with other options
```bash
# Key folding + tab delimiter + stats
toon data.json --key-folding safe --delimiter "\t" --stats -o output.toon
```
## Why Use the CLI?
- **Quick conversions** between formats without writing code
- **Token analysis** to see potential savings before sending to LLMs
- **Pipeline integration** with existing JSON-based workflows
- **Flexible formatting** with delimiter and indentation options
- **Key folding (v1.5)** to collapse nested wrappers for additional token savings
## Related

View File

@@ -1,4 +1,4 @@
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src'
import type { DecodeOptions, EncodeOptions } from '../../toon/src'
import type { InputSource } from './types'
import * as fsp from 'node:fs/promises'
import * as path from 'node:path'
@@ -11,9 +11,11 @@ import { formatInputLabel, readInput } from './utils'
export async function encodeToToon(config: {
input: InputSource
output?: string
delimiter: Delimiter
indent: number
indent: NonNullable<EncodeOptions['indent']>
delimiter: NonNullable<EncodeOptions['delimiter']>
lengthMarker: NonNullable<EncodeOptions['lengthMarker']>
keyFolding?: NonNullable<EncodeOptions['keyFolding']>
flattenDepth?: number
printStats: boolean
}): Promise<void> {
const jsonContent = await readInput(config.input)
@@ -30,6 +32,8 @@ export async function encodeToToon(config: {
delimiter: config.delimiter,
indent: config.indent,
lengthMarker: config.lengthMarker,
keyFolding: config.keyFolding,
flattenDepth: config.flattenDepth,
}
const toonOutput = encode(data, encodeOptions)
@@ -59,8 +63,9 @@ export async function encodeToToon(config: {
export async function decodeToJson(config: {
input: InputSource
output?: string
indent: number
strict: boolean
indent: NonNullable<DecodeOptions['indent']>
strict: NonNullable<DecodeOptions['strict']>
expandPaths?: NonNullable<DecodeOptions['expandPaths']>
}): Promise<void> {
const toonContent = await readInput(config.input)
@@ -69,6 +74,7 @@ export async function decodeToJson(config: {
const decodeOptions: DecodeOptions = {
indent: config.indent,
strict: config.strict,
expandPaths: config.expandPaths,
}
data = decode(toonContent, decodeOptions)
}

View File

@@ -1,5 +1,5 @@
import type { CommandDef } from 'citty'
import type { Delimiter } from '../../toon/src'
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src'
import type { InputSource } from './types'
import * as path from 'node:path'
import process from 'node:process'
@@ -51,6 +51,20 @@ export const mainCommand: CommandDef<{
description: string
default: true
}
keyFolding: {
type: 'string'
description: string
default: string
}
flattenDepth: {
type: 'string'
description: string
}
expandPaths: {
type: 'string'
description: string
default: string
}
stats: {
type: 'boolean'
description: string
@@ -103,6 +117,20 @@ export const mainCommand: CommandDef<{
description: 'Enable strict mode for decoding',
default: true,
},
keyFolding: {
type: 'string',
description: 'Enable key folding: off, safe (default: off)',
default: 'off',
},
flattenDepth: {
type: 'string',
description: 'Maximum folded segment count when key folding is enabled (default: Infinity)',
},
expandPaths: {
type: 'string',
description: 'Enable path expansion: off, safe (default: off)',
default: 'off',
},
stats: {
type: 'boolean',
description: 'Show token statistics',
@@ -129,6 +157,27 @@ export const mainCommand: CommandDef<{
throw new Error(`Invalid delimiter "${delimiter}". Valid delimiters are: comma (,), tab (\\t), pipe (|)`)
}
// Validate `keyFolding`
const keyFolding = args.keyFolding || 'off'
if (keyFolding !== 'off' && keyFolding !== 'safe') {
throw new Error(`Invalid keyFolding value "${keyFolding}". Valid values are: off, safe`)
}
// Parse and validate `flattenDepth`
let flattenDepth: number | undefined
if (args.flattenDepth !== undefined) {
flattenDepth = Number.parseInt(args.flattenDepth, 10)
if (Number.isNaN(flattenDepth) || flattenDepth < 0) {
throw new Error(`Invalid flattenDepth value: ${args.flattenDepth}`)
}
}
// Validate `expandPaths`
const expandPaths = args.expandPaths || 'off'
if (expandPaths !== 'off' && expandPaths !== 'safe') {
throw new Error(`Invalid expandPaths value "${expandPaths}". Valid values are: off, safe`)
}
const mode = detectMode(inputSource, args.encode, args.decode)
try {
@@ -140,6 +189,8 @@ export const mainCommand: CommandDef<{
indent,
lengthMarker: args.lengthMarker === true ? '#' : false,
printStats: args.stats === true,
keyFolding: keyFolding as NonNullable<EncodeOptions['keyFolding']>,
flattenDepth,
})
}
else {
@@ -148,6 +199,7 @@ export const mainCommand: CommandDef<{
output: outputPath,
indent,
strict: args.strict !== false,
expandPaths: expandPaths as NonNullable<DecodeOptions['expandPaths']>,
})
}
}