chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# @toon-format/cli
|
||||
|
||||
Command-line tool for converting JSON to TOON and back, with token analysis and streaming support.
|
||||
|
||||
[TOON (Token-Oriented Object Notation)](https://toonformat.dev) is a compact, human-readable encoding of the JSON data model that minimizes tokens for LLM input. The CLI lets you test conversions, analyze token savings, and integrate TOON into shell pipelines with stdin/stdout support.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install -g @toon-format/cli
|
||||
|
||||
# pnpm
|
||||
pnpm add -g @toon-format/cli
|
||||
|
||||
# yarn
|
||||
yarn global add @toon-format/cli
|
||||
```
|
||||
|
||||
Or use directly with `npx`:
|
||||
|
||||
```bash
|
||||
npx @toon-format/cli [options] [input]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
toon [options] [input]
|
||||
```
|
||||
|
||||
**Standard input:** Omit the input argument or use `-` to read from stdin. This enables piping data directly from other commands.
|
||||
|
||||
**Auto-detection:** The CLI automatically detects the operation based on file extension (`.json` → encode, `.toon` → decode). When reading from stdin, use `--encode` or `--decode` flags to specify the operation (defaults to encode).
|
||||
|
||||
### Basic Examples
|
||||
|
||||
```bash
|
||||
# Encode JSON to TOON (auto-detected)
|
||||
toon input.json -o output.toon
|
||||
|
||||
# Decode TOON to JSON (auto-detected)
|
||||
toon data.toon -o output.json
|
||||
|
||||
# Output to stdout
|
||||
toon input.json
|
||||
|
||||
# Pipe from stdin
|
||||
cat data.json | toon
|
||||
echo '{"name": "Ada"}' | toon
|
||||
|
||||
# Decode from stdin
|
||||
cat data.toon | toon --decode
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| `-o, --output <file>` | Output file path (prints to stdout if omitted) |
|
||||
| `-e, --encode` | Force encode mode (overrides auto-detection) |
|
||||
| `-d, --decode` | Force decode mode (overrides auto-detection) |
|
||||
| `--delimiter <char>` | Array delimiter: `,` (comma), tab character, `\|` (pipe). Pass tab as `$'\t'` in bash/zsh |
|
||||
| `--indent <number>` | Indentation size (default: `2`) |
|
||||
| `--stats` | Show token count estimates and savings (encode only) |
|
||||
| `--no-strict` | Skip decode validation (array counts, indentation, header delimiter); last-write-wins on duplicate keys |
|
||||
| `--keyFolding <mode>` | Enable key folding: `off`, `safe` (default: `off`) |
|
||||
| `--flattenDepth <number>` | Maximum folded segment count when key folding is enabled (default: `Infinity`) |
|
||||
| `--expandPaths <mode>` | Enable path expansion: `off`, `safe` (default: `off`) |
|
||||
| `--verbose` | Show full stack traces and cause chains for errors (default: `false`) |
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Token Statistics
|
||||
|
||||
Show token savings when encoding:
|
||||
|
||||
```bash
|
||||
toon data.json --stats -o output.toon
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
✔ Encoded data.json → output.toon
|
||||
|
||||
ℹ Token estimates: ~15,145 (JSON) → ~8,745 (TOON)
|
||||
✔ Saved ~6,400 tokens (-42.3%)
|
||||
```
|
||||
|
||||
### Alternative Delimiters
|
||||
|
||||
#### Tab-separated (often more token-efficient)
|
||||
|
||||
```bash
|
||||
toon data.json --delimiter $'\t' -o output.toon
|
||||
```
|
||||
|
||||
The `--delimiter` value must be the actual delimiter character. In bash/zsh, use `$'\t'` to pass a real tab; literal `"\t"` is rejected as an invalid delimiter.
|
||||
|
||||
### Lenient Decoding
|
||||
|
||||
Skip validation for faster, more forgiving decoding:
|
||||
|
||||
```bash
|
||||
toon data.toon --no-strict -o output.json
|
||||
```
|
||||
|
||||
With `--no-strict`, the decoder stops enforcing array count matches, indentation multiples, and header delimiter mismatches. Duplicate sibling keys no longer throw – the last value wins. Malformed array headers fall back to plain `key: value` lines instead of erroring.
|
||||
|
||||
### Decode Error Output
|
||||
|
||||
When a TOON document fails to parse, the CLI renders the offending line with a caret pointing at the first non-whitespace character. Tabs are shown as `→` so the caret column reflects what the decoder actually saw:
|
||||
|
||||
```
|
||||
ERROR Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode
|
||||
|
||||
2 | →b: 1
|
||||
^
|
||||
```
|
||||
|
||||
The exit code is `1` on any error. Stack traces are suppressed by default. Pass `--verbose` to include the full stack and the underlying cause chain.
|
||||
|
||||
### Stdin Workflows
|
||||
|
||||
```bash
|
||||
# Convert API response to TOON
|
||||
curl https://api.example.com/data | toon --stats
|
||||
|
||||
# Process large dataset
|
||||
cat large-dataset.json | toon --delimiter $'\t' > output.toon
|
||||
|
||||
# Chain with other tools
|
||||
jq '.results' data.json | toon > filtered.toon
|
||||
```
|
||||
|
||||
### Large Dataset Processing
|
||||
|
||||
The CLI uses streaming output for both encoding and decoding, writing incrementally without building the full output string in memory:
|
||||
|
||||
```bash
|
||||
# Encode large JSON file with minimal memory usage
|
||||
toon huge-dataset.json -o output.toon
|
||||
|
||||
# Decode large TOON file with streaming JSON output
|
||||
toon huge-dataset.toon -o output.json
|
||||
|
||||
# Process millions of records efficiently via stdin
|
||||
cat million-records.json | toon > output.toon
|
||||
cat million-records.toon | toon --decode > output.json
|
||||
```
|
||||
|
||||
**Memory efficiency:**
|
||||
- **Encode (JSON → TOON)**: Streams TOON lines to output without full string in memory
|
||||
- **Decode (TOON → JSON)**: Uses the same event-based streaming decoder as the `decodeStream` API in `@toon-format/toon`, streaming JSON tokens to output without full string in memory
|
||||
- Peak memory usage scales with data depth, not total size
|
||||
- When `--expandPaths safe` is enabled, decode falls back to non-streaming mode internally to apply deep-merge expansion before writing JSON
|
||||
|
||||
> [!TIP]
|
||||
> When using `--stats` with encode, the full output string is kept in memory for token counting. Omit `--stats` for maximum memory efficiency with very large datasets.
|
||||
|
||||
### Key Folding (Since v1.5)
|
||||
|
||||
Collapse nested wrapper chains to reduce tokens:
|
||||
|
||||
#### Basic key folding
|
||||
|
||||
```bash
|
||||
# Encode with key folding
|
||||
toon input.json --keyFolding 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 --keyFolding safe --flattenDepth 2 -o output.toon
|
||||
```
|
||||
|
||||
#### Path expansion on decode
|
||||
|
||||
```bash
|
||||
# Reconstruct nested structure from folded keys
|
||||
toon data.toon --expandPaths safe -o output.json
|
||||
```
|
||||
|
||||
#### Round-trip workflow
|
||||
|
||||
```bash
|
||||
# Encode with folding
|
||||
toon input.json --keyFolding safe -o compressed.toon
|
||||
|
||||
# Decode with expansion (restores original structure)
|
||||
toon compressed.toon --expandPaths 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 --keyFolding 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** to collapse nested wrappers for additional token savings
|
||||
- **Memory-efficient streaming** for both encode and decode operations - process large datasets without loading entire outputs into memory
|
||||
|
||||
## Related
|
||||
|
||||
- [@toon-format/toon](https://www.npmjs.com/package/@toon-format/toon) – JavaScript/TypeScript library
|
||||
- [Full specification](https://github.com/toon-format/spec) – Complete format documentation
|
||||
- [Website](https://toonformat.dev) – Interactive examples and guides
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/toon-format/toon/blob/main/LICENSE) License © 2025-PRESENT [Johann Schopplich](https://github.com/johannschopplich)
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
import('../dist/index.mjs')
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@toon-format/cli",
|
||||
"type": "module",
|
||||
"version": "2.3.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
|
||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://toonformat.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/toon-format/toon.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/toon-format/toon/issues"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"types": "./dist/index.d.mts",
|
||||
"bin": {
|
||||
"toon": "bin/toon.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node ./src/cli-entry.ts --help",
|
||||
"build": "tsdown",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"citty": "^0.2.2",
|
||||
"consola": "^3.4.2",
|
||||
"tokenx": "^1.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { runMain } from 'citty'
|
||||
import { mainCommand } from './index.ts'
|
||||
|
||||
runMain(mainCommand)
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import type { DecodeOptions, DecodeStreamOptions, EncodeOptions } from '../../toon/src/index.ts'
|
||||
import type { InputSource } from './types.ts'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { consola } from 'consola'
|
||||
import { estimateTokenCount } from 'tokenx'
|
||||
import { decode, decodeStream, encode, encodeLines } from '../../toon/src/index.ts'
|
||||
import { jsonStreamFromEvents } from './json-from-events.ts'
|
||||
import { jsonStringifyLines } from './json-stringify-stream.ts'
|
||||
import { formatInputLabel, readInput, readLinesFromSource } from './utils.ts'
|
||||
|
||||
export async function encodeToToon(config: {
|
||||
input: InputSource
|
||||
output?: string
|
||||
indent: NonNullable<EncodeOptions['indent']>
|
||||
delimiter: NonNullable<EncodeOptions['delimiter']>
|
||||
keyFolding?: NonNullable<EncodeOptions['keyFolding']>
|
||||
flattenDepth?: number
|
||||
printStats: boolean
|
||||
}): Promise<void> {
|
||||
const jsonContent = await readInput(config.input)
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(jsonContent)
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
const encodeOptions: EncodeOptions = {
|
||||
delimiter: config.delimiter,
|
||||
indent: config.indent,
|
||||
keyFolding: config.keyFolding,
|
||||
flattenDepth: config.flattenDepth,
|
||||
}
|
||||
|
||||
// When printing stats, we need the full string for token counting
|
||||
if (config.printStats) {
|
||||
const toonOutput = encode(data, encodeOptions)
|
||||
|
||||
if (config.output) {
|
||||
await fsp.writeFile(config.output, toonOutput, 'utf-8')
|
||||
}
|
||||
else {
|
||||
console.log(toonOutput)
|
||||
}
|
||||
|
||||
const jsonTokens = estimateTokenCount(jsonContent)
|
||||
const toonTokens = estimateTokenCount(toonOutput)
|
||||
const diff = jsonTokens - toonTokens
|
||||
const percent = ((diff / jsonTokens) * 100).toFixed(1)
|
||||
|
||||
if (config.output) {
|
||||
const relativeInputPath = formatInputLabel(config.input)
|
||||
const relativeOutputPath = path.relative(process.cwd(), config.output)
|
||||
consola.success(`Encoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``)
|
||||
}
|
||||
|
||||
console.log()
|
||||
consola.info(`Token estimates: ~${jsonTokens} (JSON) → ~${toonTokens} (TOON)`)
|
||||
consola.success(`Saved ~${diff} tokens (-${percent}%)`)
|
||||
}
|
||||
else {
|
||||
await writeStreamingToon(encodeLines(data, encodeOptions), config.output)
|
||||
|
||||
if (config.output) {
|
||||
const relativeInputPath = formatInputLabel(config.input)
|
||||
const relativeOutputPath = path.relative(process.cwd(), config.output)
|
||||
consola.success(`Encoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeToJson(config: {
|
||||
input: InputSource
|
||||
output?: string
|
||||
indent: NonNullable<DecodeOptions['indent']>
|
||||
strict: NonNullable<DecodeOptions['strict']>
|
||||
expandPaths?: NonNullable<DecodeOptions['expandPaths']>
|
||||
}): Promise<void> {
|
||||
// Path expansion requires full value in memory, so use non-streaming path
|
||||
if (config.expandPaths === 'safe') {
|
||||
const toonContent = await readInput(config.input)
|
||||
|
||||
const decodeOptions: DecodeOptions = {
|
||||
indent: config.indent,
|
||||
strict: config.strict,
|
||||
expandPaths: config.expandPaths,
|
||||
}
|
||||
const data = decode(toonContent, decodeOptions)
|
||||
|
||||
await writeStreamingJson(jsonStringifyLines(data, config.indent), config.output)
|
||||
}
|
||||
else {
|
||||
const lineSource = readLinesFromSource(config.input)
|
||||
|
||||
const decodeStreamOptions: DecodeStreamOptions = {
|
||||
indent: config.indent,
|
||||
strict: config.strict,
|
||||
}
|
||||
|
||||
const events = decodeStream(lineSource, decodeStreamOptions)
|
||||
const jsonChunks = jsonStreamFromEvents(events, config.indent)
|
||||
|
||||
await writeStreamingJson(jsonChunks, config.output)
|
||||
}
|
||||
|
||||
if (config.output) {
|
||||
const relativeInputPath = formatInputLabel(config.input)
|
||||
const relativeOutputPath = path.relative(process.cwd(), config.output)
|
||||
consola.success(`Decoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes JSON chunks to a file or stdout using streaming approach.
|
||||
* Chunks are written one at a time without building the full string in memory.
|
||||
*/
|
||||
async function writeStreamingJson(
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
outputPath?: string,
|
||||
): Promise<void> {
|
||||
// Stream to file using fs/promises API
|
||||
if (outputPath) {
|
||||
let fileHandle: FileHandle | undefined
|
||||
|
||||
try {
|
||||
fileHandle = await fsp.open(outputPath, 'w')
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
await fileHandle.write(chunk)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await fileHandle?.close()
|
||||
}
|
||||
}
|
||||
// Stream to stdout
|
||||
else {
|
||||
for await (const chunk of chunks) {
|
||||
process.stdout.write(chunk)
|
||||
}
|
||||
|
||||
// Add final newline for stdout
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes TOON lines to a file or stdout using streaming approach.
|
||||
* Lines are written one at a time without building the full string in memory.
|
||||
*/
|
||||
async function writeStreamingToon(
|
||||
lines: Iterable<string>,
|
||||
outputPath?: string,
|
||||
): Promise<void> {
|
||||
let isFirst = true
|
||||
|
||||
// Stream to file using fs/promises API
|
||||
if (outputPath) {
|
||||
let fileHandle: FileHandle | undefined
|
||||
|
||||
try {
|
||||
fileHandle = await fsp.open(outputPath, 'w')
|
||||
|
||||
for (const line of lines) {
|
||||
if (!isFirst)
|
||||
await fileHandle.write('\n')
|
||||
|
||||
await fileHandle.write(line)
|
||||
isFirst = false
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await fileHandle?.close()
|
||||
}
|
||||
}
|
||||
// Stream to stdout
|
||||
else {
|
||||
for (const line of lines) {
|
||||
if (!isFirst)
|
||||
process.stdout.write('\n')
|
||||
|
||||
process.stdout.write(line)
|
||||
isFirst = false
|
||||
}
|
||||
|
||||
// Add final newline for stdout
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ToonDecodeError } from '../../toon/src/index.ts'
|
||||
|
||||
export interface FormatErrorOptions {
|
||||
isVerbose: boolean
|
||||
}
|
||||
|
||||
// #region Public API
|
||||
|
||||
export function formatError(error: unknown, options: FormatErrorOptions): string {
|
||||
const sections: string[] = []
|
||||
|
||||
if (error instanceof ToonDecodeError && error.line !== undefined) {
|
||||
sections.push(formatDecodeError(error))
|
||||
}
|
||||
else {
|
||||
sections.push(String(error))
|
||||
}
|
||||
|
||||
if (options.isVerbose) {
|
||||
const causeChain = formatCauseChain(error)
|
||||
if (causeChain) {
|
||||
sections.push(causeChain)
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.stack) {
|
||||
sections.push(error.stack)
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Internal renderers
|
||||
|
||||
function formatDecodeError(error: ToonDecodeError): string {
|
||||
const linePrefix = `Line ${error.line}: `
|
||||
const messageWithoutPrefix = error.message.startsWith(linePrefix)
|
||||
? error.message.slice(linePrefix.length)
|
||||
: error.message
|
||||
|
||||
const header = `Failed to decode TOON at line ${error.line}: ${messageWithoutPrefix}`
|
||||
|
||||
if (error.source === undefined) {
|
||||
return header
|
||||
}
|
||||
|
||||
const visibleSource = error.source.replace(/\t/g, '→')
|
||||
const firstNonWhitespaceIndex = visibleSource.search(/\S/)
|
||||
const gutter = ` ${error.line} | `
|
||||
const caretIndent = ' '.repeat(gutter.length + Math.max(firstNonWhitespaceIndex, 0))
|
||||
|
||||
return `${header}\n\n${gutter}${visibleSource}\n${caretIndent}^`
|
||||
}
|
||||
|
||||
function formatCauseChain(error: unknown): string {
|
||||
const causeLines: string[] = []
|
||||
let current: unknown = error instanceof Error ? error.cause : undefined
|
||||
|
||||
while (current instanceof Error) {
|
||||
const name = current.name || 'Error'
|
||||
causeLines.push(`Caused by: ${name}: ${current.message}`)
|
||||
current = current.cause
|
||||
}
|
||||
|
||||
return causeLines.join('\n')
|
||||
}
|
||||
|
||||
// #endregion
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ArgsDef, CommandDef } from 'citty'
|
||||
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src/index.ts'
|
||||
import type { InputSource } from './types.ts'
|
||||
import * as path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { defineCommand } from 'citty'
|
||||
import { consola } from 'consola'
|
||||
import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src/index.ts'
|
||||
import pkg from '../package.json' with { type: 'json' }
|
||||
import { decodeToJson, encodeToToon } from './conversion.ts'
|
||||
import { formatError } from './format-error.ts'
|
||||
import { detectMode } from './utils.ts'
|
||||
|
||||
const { name, version } = pkg
|
||||
|
||||
const args: ArgsDef = {
|
||||
input: {
|
||||
type: 'positional',
|
||||
description: 'Input file path (omit or use "-" to read from stdin)',
|
||||
required: false,
|
||||
},
|
||||
output: {
|
||||
type: 'string',
|
||||
description: 'Output file path',
|
||||
alias: 'o',
|
||||
},
|
||||
encode: {
|
||||
type: 'boolean',
|
||||
description: 'Encode JSON to TOON (auto-detected by default)',
|
||||
alias: 'e',
|
||||
},
|
||||
decode: {
|
||||
type: 'boolean',
|
||||
description: 'Decode TOON to JSON (auto-detected by default)',
|
||||
alias: 'd',
|
||||
},
|
||||
delimiter: {
|
||||
type: 'string',
|
||||
description: 'Delimiter for arrays: comma (,), tab (\\t), or pipe (|)',
|
||||
default: ',',
|
||||
},
|
||||
indent: {
|
||||
type: 'string',
|
||||
description: 'Indentation size',
|
||||
default: '2',
|
||||
},
|
||||
strict: {
|
||||
type: 'boolean',
|
||||
description: 'Strict decode validation (disable with --no-strict)',
|
||||
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',
|
||||
default: false,
|
||||
},
|
||||
verbose: {
|
||||
type: 'boolean',
|
||||
description: 'Show full stack traces and cause chains for errors',
|
||||
default: false,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const mainCommand: CommandDef<ArgsDef> = defineCommand({
|
||||
meta: {
|
||||
name,
|
||||
description: 'TOON CLI – Convert between JSON and TOON formats',
|
||||
version,
|
||||
},
|
||||
args,
|
||||
async run({ args }) {
|
||||
const input = args.input
|
||||
|
||||
const inputSource: InputSource = !input || input === '-'
|
||||
? { type: 'stdin' }
|
||||
: { type: 'file', path: path.resolve(input) }
|
||||
const outputPath = args.output ? path.resolve(args.output) : undefined
|
||||
|
||||
// Parse and validate indent
|
||||
const indent = Number.parseInt(args.indent || '2', 10)
|
||||
if (Number.isNaN(indent) || indent < 0) {
|
||||
throw new Error(`Invalid indent value: ${args.indent}`)
|
||||
}
|
||||
|
||||
// Validate delimiter
|
||||
const delimiter = args.delimiter || DEFAULT_DELIMITER
|
||||
if (!(Object.values(DELIMITERS)).includes(delimiter as Delimiter)) {
|
||||
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 {
|
||||
if (mode === 'encode') {
|
||||
await encodeToToon({
|
||||
input: inputSource,
|
||||
output: outputPath,
|
||||
delimiter: delimiter as Delimiter,
|
||||
indent,
|
||||
keyFolding: keyFolding as NonNullable<EncodeOptions['keyFolding']>,
|
||||
flattenDepth,
|
||||
printStats: args.stats === true,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await decodeToJson({
|
||||
input: inputSource,
|
||||
output: outputPath,
|
||||
indent,
|
||||
strict: args.strict !== false,
|
||||
expandPaths: expandPaths as NonNullable<DecodeOptions['expandPaths']>,
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
consola.error(formatError(error, { isVerbose: args.verbose === true }))
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { JsonStreamEvent } from '../../toon/src/types.ts'
|
||||
|
||||
/**
|
||||
* Context for tracking JSON structure state during event streaming.
|
||||
*/
|
||||
type JsonContext
|
||||
= | { type: 'object', needsComma: boolean, expectValue: boolean }
|
||||
| { type: 'array', needsComma: boolean }
|
||||
|
||||
/**
|
||||
* Converts a stream of `JsonStreamEvent` into formatted JSON string chunks.
|
||||
*
|
||||
* Similar to `jsonStringifyLines` but driven by events instead of a value tree.
|
||||
* Useful for streaming TOON decode directly to JSON output without building
|
||||
* the full data structure in memory.
|
||||
*
|
||||
* @param events - Async iterable of JSON stream events
|
||||
* @param indent - Number of spaces for indentation (0 = compact, >0 = pretty)
|
||||
* @returns Async iterable of JSON string chunks
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const lines = readLinesFromSource(input)
|
||||
* const events = decodeStream(lines)
|
||||
* for await (const chunk of jsonStreamFromEvents(events, 2)) {
|
||||
* process.stdout.write(chunk)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function* jsonStreamFromEvents(
|
||||
events: AsyncIterable<JsonStreamEvent>,
|
||||
indent: number = 2,
|
||||
): AsyncIterable<string> {
|
||||
const stack: JsonContext[] = []
|
||||
let depth = 0
|
||||
|
||||
for await (const event of events) {
|
||||
const parent = stack.length > 0 ? stack[stack.length - 1] : undefined
|
||||
|
||||
switch (event.type) {
|
||||
case 'startObject': {
|
||||
// Emit comma if needed (inside array or after previous object field value)
|
||||
if (parent) {
|
||||
if (parent.type === 'array' && parent.needsComma) {
|
||||
yield ','
|
||||
}
|
||||
else if (parent.type === 'object' && !parent.expectValue) {
|
||||
// Object field value already emitted, this is a nested object after a key
|
||||
// The comma is handled by the key event
|
||||
}
|
||||
}
|
||||
|
||||
// Emit newline and indent for pretty printing
|
||||
if (indent > 0 && parent) {
|
||||
if (parent.type === 'array') {
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
}
|
||||
}
|
||||
|
||||
yield '{'
|
||||
stack.push({ type: 'object', needsComma: false, expectValue: false })
|
||||
depth++
|
||||
break
|
||||
}
|
||||
|
||||
case 'endObject': {
|
||||
const context = stack.pop()
|
||||
if (!context || context.type !== 'object') {
|
||||
throw new Error('Mismatched endObject event')
|
||||
}
|
||||
|
||||
depth--
|
||||
|
||||
// Emit newline and indent for closing brace (pretty print)
|
||||
if (indent > 0 && context.needsComma) {
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
}
|
||||
|
||||
yield '}'
|
||||
|
||||
// Mark parent as needing comma for next item
|
||||
const newParent = stack.length > 0 ? stack[stack.length - 1] : undefined
|
||||
if (newParent) {
|
||||
if (newParent.type === 'object') {
|
||||
newParent.expectValue = false
|
||||
newParent.needsComma = true
|
||||
}
|
||||
else if (newParent.type === 'array') {
|
||||
newParent.needsComma = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'startArray': {
|
||||
// Emit comma if needed
|
||||
if (parent) {
|
||||
if (parent.type === 'array' && parent.needsComma) {
|
||||
yield ','
|
||||
}
|
||||
}
|
||||
|
||||
// Emit newline and indent for pretty printing
|
||||
if (indent > 0 && parent) {
|
||||
if (parent.type === 'array') {
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
}
|
||||
}
|
||||
|
||||
yield '['
|
||||
stack.push({
|
||||
type: 'array',
|
||||
needsComma: false,
|
||||
})
|
||||
depth++
|
||||
break
|
||||
}
|
||||
|
||||
case 'endArray': {
|
||||
const context = stack.pop()
|
||||
if (!context || context.type !== 'array') {
|
||||
throw new Error('Mismatched endArray event')
|
||||
}
|
||||
|
||||
depth--
|
||||
|
||||
// Emit newline and indent for closing bracket (pretty print)
|
||||
if (indent > 0 && context.needsComma) {
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
}
|
||||
|
||||
yield ']'
|
||||
|
||||
// Mark parent as needing comma for next item
|
||||
const newParent = stack.length > 0 ? stack[stack.length - 1] : undefined
|
||||
if (newParent) {
|
||||
if (newParent.type === 'object') {
|
||||
newParent.expectValue = false
|
||||
newParent.needsComma = true
|
||||
}
|
||||
else if (newParent.type === 'array') {
|
||||
newParent.needsComma = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'key': {
|
||||
if (!parent || parent.type !== 'object') {
|
||||
throw new Error('Key event outside of object context')
|
||||
}
|
||||
|
||||
// Emit comma before this field if needed
|
||||
if (parent.needsComma) {
|
||||
yield ','
|
||||
}
|
||||
|
||||
// Emit newline and indent (pretty print)
|
||||
if (indent > 0) {
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
}
|
||||
|
||||
// Emit key
|
||||
yield JSON.stringify(event.key)
|
||||
yield indent > 0 ? ': ' : ':'
|
||||
|
||||
parent.expectValue = true
|
||||
parent.needsComma = true
|
||||
break
|
||||
}
|
||||
|
||||
case 'primitive': {
|
||||
// Emit comma if needed
|
||||
if (parent) {
|
||||
if (parent.type === 'array' && parent.needsComma) {
|
||||
yield ','
|
||||
}
|
||||
else if (parent.type === 'object' && !parent.expectValue) {
|
||||
// This shouldn't happen in well-formed events
|
||||
throw new Error('Primitive event in object without preceding key')
|
||||
}
|
||||
}
|
||||
|
||||
// Emit newline and indent for array items (pretty print)
|
||||
if (indent > 0 && parent && parent.type === 'array') {
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
}
|
||||
|
||||
// Emit primitive value
|
||||
yield JSON.stringify(event.value)
|
||||
|
||||
// Update parent context
|
||||
if (parent) {
|
||||
if (parent.type === 'object') {
|
||||
parent.expectValue = false
|
||||
// needsComma already true from key event
|
||||
}
|
||||
else if (parent.type === 'array') {
|
||||
parent.needsComma = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure stack is empty
|
||||
if (stack.length !== 0) {
|
||||
throw new Error('Incomplete event stream: unclosed objects or arrays')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Streaming JSON stringifier.
|
||||
*
|
||||
* Yields JSON tokens one at a time, allowing streaming output without holding
|
||||
* the entire JSON string in memory.
|
||||
*
|
||||
* @param value - The value to stringify (must be JSON-serializable)
|
||||
* @param indent - Number of spaces for indentation (0 = compact, >0 = pretty)
|
||||
* @returns Generator that yields JSON string chunks
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const data = { name: "Alice", scores: [95, 87, 92] }
|
||||
* for (const chunk of jsonStringifyLines(data, 2)) {
|
||||
* process.stdout.write(chunk)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function* jsonStringifyLines(
|
||||
value: unknown,
|
||||
indent: number = 2,
|
||||
): Iterable<string> {
|
||||
yield* stringifyValue(value, 0, indent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal generator for recursive stringification.
|
||||
*/
|
||||
function* stringifyValue(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
indent: number,
|
||||
): Iterable<string> {
|
||||
// Handle null
|
||||
if (value === null) {
|
||||
yield 'null'
|
||||
return
|
||||
}
|
||||
|
||||
const type = typeof value
|
||||
|
||||
// Handle primitives
|
||||
if (type === 'boolean' || type === 'number') {
|
||||
yield JSON.stringify(value)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
yield JSON.stringify(value)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
yield* stringifyArray(value, depth, indent)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
if (type === 'object') {
|
||||
yield* stringifyObject(value as Record<string, unknown>, depth, indent)
|
||||
return
|
||||
}
|
||||
|
||||
// Undefined, functions, symbols become null in JSON
|
||||
yield 'null'
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an array with proper formatting.
|
||||
*/
|
||||
function* stringifyArray(
|
||||
arr: unknown[],
|
||||
depth: number,
|
||||
indent: number,
|
||||
): Iterable<string> {
|
||||
if (arr.length === 0) {
|
||||
yield '[]'
|
||||
return
|
||||
}
|
||||
|
||||
yield '['
|
||||
|
||||
if (indent > 0) {
|
||||
// Pretty-printed format
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
yield '\n'
|
||||
yield ' '.repeat((depth + 1) * indent)
|
||||
yield* stringifyValue(arr[i], depth + 1, indent)
|
||||
if (i < arr.length - 1) {
|
||||
yield ','
|
||||
}
|
||||
}
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
yield ']'
|
||||
}
|
||||
else {
|
||||
// Compact format
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
yield* stringifyValue(arr[i], depth + 1, indent)
|
||||
if (i < arr.length - 1) {
|
||||
yield ','
|
||||
}
|
||||
}
|
||||
yield ']'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an object with proper formatting.
|
||||
*/
|
||||
function* stringifyObject(
|
||||
obj: Record<string, unknown>,
|
||||
depth: number,
|
||||
indent: number,
|
||||
): Iterable<string> {
|
||||
const keys = Object.keys(obj)
|
||||
|
||||
if (keys.length === 0) {
|
||||
yield '{}'
|
||||
return
|
||||
}
|
||||
|
||||
yield '{'
|
||||
|
||||
if (indent > 0) {
|
||||
// Pretty-printed format
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]!
|
||||
const value = obj[key]
|
||||
|
||||
yield '\n'
|
||||
yield ' '.repeat((depth + 1) * indent)
|
||||
yield JSON.stringify(key)
|
||||
yield ': '
|
||||
yield* stringifyValue(value, depth + 1, indent)
|
||||
if (i < keys.length - 1) {
|
||||
yield ','
|
||||
}
|
||||
}
|
||||
yield '\n'
|
||||
yield ' '.repeat(depth * indent)
|
||||
yield '}'
|
||||
}
|
||||
else {
|
||||
// Compact format
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]!
|
||||
const value = obj[key]
|
||||
|
||||
yield JSON.stringify(key)
|
||||
yield ':'
|
||||
yield* stringifyValue(value, depth + 1, indent)
|
||||
if (i < keys.length - 1) {
|
||||
yield ','
|
||||
}
|
||||
}
|
||||
yield '}'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type InputSource
|
||||
= | { type: 'stdin' }
|
||||
| { type: 'file', path: string }
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { InputSource } from './types.ts'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
export function detectMode(
|
||||
input: InputSource,
|
||||
encodeFlag?: boolean,
|
||||
decodeFlag?: boolean,
|
||||
): 'encode' | 'decode' {
|
||||
// Explicit flags take precedence
|
||||
if (encodeFlag)
|
||||
return 'encode'
|
||||
if (decodeFlag)
|
||||
return 'decode'
|
||||
|
||||
// Auto-detect based on file extension
|
||||
if (input.type === 'file') {
|
||||
if (input.path.endsWith('.json'))
|
||||
return 'encode'
|
||||
if (input.path.endsWith('.toon'))
|
||||
return 'decode'
|
||||
}
|
||||
|
||||
// Default to encode
|
||||
return 'encode'
|
||||
}
|
||||
|
||||
export async function readInput(source: InputSource): Promise<string> {
|
||||
if (source.type === 'stdin')
|
||||
return readFromStdin()
|
||||
|
||||
return fsp.readFile(source.path, 'utf-8')
|
||||
}
|
||||
|
||||
export function formatInputLabel(source: InputSource): string {
|
||||
if (source.type === 'stdin')
|
||||
return 'stdin'
|
||||
|
||||
const relativePath = path.relative(process.cwd(), source.path)
|
||||
return relativePath || path.basename(source.path)
|
||||
}
|
||||
|
||||
function readFromStdin(): Promise<string> {
|
||||
const { stdin } = process
|
||||
|
||||
if (stdin.readableEnded)
|
||||
return Promise.resolve('')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = ''
|
||||
|
||||
const onData = (chunk: string) => {
|
||||
data += chunk
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
stdin.off('data', onData)
|
||||
stdin.off('error', onError)
|
||||
stdin.off('end', onEnd)
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
cleanup()
|
||||
resolve(data)
|
||||
}
|
||||
|
||||
stdin.setEncoding('utf-8')
|
||||
stdin.on('data', onData)
|
||||
stdin.once('error', onError)
|
||||
stdin.once('end', onEnd)
|
||||
stdin.resume()
|
||||
})
|
||||
}
|
||||
|
||||
export async function* readLinesFromSource(source: InputSource): AsyncIterable<string> {
|
||||
const stream = source.type === 'stdin'
|
||||
? process.stdin
|
||||
: createReadStream(source.path, { encoding: 'utf-8' })
|
||||
|
||||
// Explicitly set encoding for stdin
|
||||
if (source.type === 'stdin') {
|
||||
stream.setEncoding('utf-8')
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk
|
||||
let index: number
|
||||
|
||||
while ((index = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, index)
|
||||
buffer = buffer.slice(index + 1)
|
||||
yield line
|
||||
}
|
||||
}
|
||||
|
||||
// Emit last line if buffer is not empty and doesn't end with newline
|
||||
if (buffer.length > 0) {
|
||||
yield buffer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ToonDecodeError } from '../../toon/src/index'
|
||||
import { formatError } from '../src/format-error'
|
||||
|
||||
describe('formatError', () => {
|
||||
it('renders a decode error with line and source as a header, source line, and caret', () => {
|
||||
const error = new ToonDecodeError(
|
||||
'Tabs are not allowed in indentation in strict mode',
|
||||
{ line: 2, source: '\tb: 1' },
|
||||
)
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe(
|
||||
'Failed to decode TOON at line 2: Tabs are not allowed in indentation in strict mode\n'
|
||||
+ '\n'
|
||||
+ ' 2 | →b: 1\n'
|
||||
+ ' ^',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a decode error without source as a header only', () => {
|
||||
const error = new ToonDecodeError('Something went wrong', { line: 5 })
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe('Failed to decode TOON at line 5: Something went wrong')
|
||||
})
|
||||
|
||||
it('appends the cause chain under verbose mode', () => {
|
||||
const cause = new SyntaxError('Unterminated string: missing closing quote')
|
||||
const error = new ToonDecodeError(
|
||||
'Unterminated string: missing closing quote',
|
||||
{ line: 2, source: 'greeting: "hello', cause },
|
||||
)
|
||||
|
||||
const output = formatError(error, { isVerbose: true })
|
||||
|
||||
expect(output).toContain('Failed to decode TOON at line 2:')
|
||||
expect(output).toContain(' 2 | greeting: "hello')
|
||||
expect(output).toContain('Caused by: SyntaxError: Unterminated string: missing closing quote')
|
||||
})
|
||||
|
||||
it('appends the stack trace under verbose mode and omits it otherwise', () => {
|
||||
const error = new ToonDecodeError('Boom', { line: 1, source: 'x' })
|
||||
error.stack = 'ToonDecodeError: Line 1: Boom\n at fakeFrame (file.ts:1:1)'
|
||||
|
||||
const verbose = formatError(error, { isVerbose: true })
|
||||
const quiet = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(verbose).toContain('at fakeFrame (file.ts:1:1)')
|
||||
expect(quiet).not.toContain('at fakeFrame')
|
||||
})
|
||||
|
||||
it('renders a generic Error as its message only when not verbose', () => {
|
||||
const error = new Error('something went wrong')
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe('Error: something went wrong')
|
||||
})
|
||||
|
||||
it('places the caret under the first non-whitespace character of the source line', () => {
|
||||
const error = new ToonDecodeError(
|
||||
'Indentation must be exact multiple of 2, but found 3 spaces',
|
||||
{ line: 2, source: ' b: 1' },
|
||||
)
|
||||
|
||||
const output = formatError(error, { isVerbose: false })
|
||||
|
||||
expect(output).toBe(
|
||||
'Failed to decode TOON at line 2: Indentation must be exact multiple of 2, but found 3 spaces\n'
|
||||
+ '\n'
|
||||
+ ' 2 | b: 1\n'
|
||||
+ ' ^',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,832 @@
|
||||
import process from 'node:process'
|
||||
import { consola } from 'consola'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULT_DELIMITER, encode } from '../../toon/src'
|
||||
import { version } from '../package.json' with { type: 'json' }
|
||||
import { createCliTestContext, mockStdin, runCli } from './utils'
|
||||
|
||||
describe('toon CLI', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => 0 as never)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('version', () => {
|
||||
it('prints the version when using --version', async () => {
|
||||
const consoleLog = vi.mocked(console.log)
|
||||
|
||||
await runCli({ rawArgs: ['--version'] })
|
||||
|
||||
expect(consoleLog).toHaveBeenCalledWith(version)
|
||||
})
|
||||
})
|
||||
|
||||
describe('encode (JSON → TOON)', () => {
|
||||
it('encodes JSON from stdin', async () => {
|
||||
const data = {
|
||||
title: 'TOON test',
|
||||
count: 3,
|
||||
nested: { ok: true },
|
||||
}
|
||||
const cleanup = mockStdin(JSON.stringify(data))
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli()
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data)}\n`)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes a JSON file into a TOON file', async () => {
|
||||
const data = {
|
||||
title: 'TOON test',
|
||||
count: 3,
|
||||
nested: { ok: true },
|
||||
}
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data, undefined, 2),
|
||||
})
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
const expected = encode(data, {
|
||||
delimiter: DEFAULT_DELIMITER,
|
||||
indent: 2,
|
||||
})
|
||||
|
||||
expect(output).toBe(expected)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('writes to stdout when output not specified', async () => {
|
||||
const data = { ok: true }
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json'])
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data)}\n`)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes JSON from stdin to output file', async () => {
|
||||
const data = { key: 'value' }
|
||||
const context = await createCliTestContext({})
|
||||
const cleanup = mockStdin(JSON.stringify(data))
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
expect(output).toBe(encode(data))
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded.*stdin[^\n\r\u2028\u2029\u2192]*\u2192.*output\.toon/))
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('decode (TOON → JSON)', () => {
|
||||
it('decodes a TOON file into a JSON file', async () => {
|
||||
const data = {
|
||||
items: ['alpha', 'beta'],
|
||||
meta: { done: false },
|
||||
}
|
||||
const toonInput = encode(data)
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': toonInput,
|
||||
})
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
expect(JSON.parse(output)).toEqual(data)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes TOON from stdin', async () => {
|
||||
const data = { items: ['a', 'b'], count: 2 }
|
||||
const toonInput = encode(data)
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
const fullOutput = writeChunks.join('')
|
||||
// Remove trailing newline before parsing
|
||||
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
|
||||
const result = JSON.parse(jsonOutput)
|
||||
expect(result).toEqual(data)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes TOON from stdin to output file', async () => {
|
||||
const data = { name: 'test', values: [1, 2, 3] }
|
||||
const toonInput = encode(data)
|
||||
const context = await createCliTestContext({})
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['--decode', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
expect(JSON.parse(output)).toEqual(data)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded.*stdin[^\n\r\u2028\u2029\u2192]*\u2192.*output\.json/))
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdin edge cases', () => {
|
||||
it('handles invalid JSON from stdin', async () => {
|
||||
const cleanup = mockStdin('{ invalid json }')
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: [] })
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
expect(consolaError).toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles invalid TOON from stdin', async () => {
|
||||
const cleanup = mockStdin('key: "unterminated string')
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
expect(consolaError).toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a TOON decode error with line context, source, and caret', async () => {
|
||||
const cleanup = mockStdin('a:\n\tb: 1\n')
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [rendered] = errorCall!
|
||||
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
|
||||
expect(rendered).toEqual(expect.stringContaining(' 2 | →b: 1'))
|
||||
expect(rendered).toEqual(expect.stringContaining(' ^'))
|
||||
expect(rendered).not.toEqual(expect.stringMatching(/^\s+at \S+/m))
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes the stack trace when --verbose is passed', async () => {
|
||||
const cleanup = mockStdin('a:\n\tb: 1\n')
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode', '--verbose'] })
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [rendered] = errorCall!
|
||||
expect(rendered).toEqual(expect.stringContaining('Failed to decode TOON at line 2:'))
|
||||
expect(rendered).toEqual(expect.stringMatching(/at \S+/))
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdin with options', () => {
|
||||
it('encodes JSON from stdin with custom delimiter', async () => {
|
||||
const data = { items: [1, 2, 3] }
|
||||
const cleanup = mockStdin(JSON.stringify(data))
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--delimiter', '|'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data, { delimiter: '|' })}\n`)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes JSON from stdin with custom indent', async () => {
|
||||
const data = {
|
||||
nested: {
|
||||
deep: { value: 1 },
|
||||
},
|
||||
}
|
||||
const cleanup = mockStdin(JSON.stringify(data))
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--indent', '4'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe(`${encode(data, { indent: 4 })}\n`)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes TOON from stdin with --no-strict', async () => {
|
||||
const data = { test: true }
|
||||
const toonInput = encode(data)
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode', '--no-strict'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
// Remove trailing newline before parsing
|
||||
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
|
||||
const result = JSON.parse(jsonOutput)
|
||||
expect(result).toEqual(data)
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('encode options', () => {
|
||||
it('encodes with --keyFolding safe', async () => {
|
||||
const data = {
|
||||
data: {
|
||||
metadata: {
|
||||
items: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--keyFolding', 'safe', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
const expected = encode(data, { keyFolding: 'safe' })
|
||||
|
||||
expect(output).toBe(expected)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes with --flattenDepth', async () => {
|
||||
const data = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--keyFolding', 'safe', '--flattenDepth', '2', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
const expected = encode(data, { keyFolding: 'safe', flattenDepth: 2 })
|
||||
|
||||
expect(output).toBe(expected)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('decode options', () => {
|
||||
it('decodes with --expandPaths safe', async () => {
|
||||
const data = {
|
||||
data: {
|
||||
metadata: {
|
||||
items: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
}
|
||||
const toonInput = encode(data, { keyFolding: 'safe' })
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': toonInput,
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--decode', '--expandPaths', 'safe', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
const result = JSON.parse(output)
|
||||
|
||||
expect(result).toEqual(data)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes with --indent for JSON formatting', async () => {
|
||||
const data = {
|
||||
a: 1,
|
||||
b: [2, 3],
|
||||
c: { nested: true },
|
||||
}
|
||||
const toonInput = encode(data, { indent: 4 })
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': toonInput,
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--decode', '--indent', '4', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
const result = JSON.parse(output)
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(output).toContain(' ') // Should have 4-space indentation
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes root primitive number', async () => {
|
||||
const toonInput = '42'
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe('42\n')
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes root primitive string', async () => {
|
||||
const toonInput = '"Hello World"'
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
const jsonOutput = fullOutput.endsWith('\n') ? fullOutput.slice(0, -1) : fullOutput
|
||||
expect(JSON.parse(jsonOutput)).toBe('Hello World')
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes root primitive boolean', async () => {
|
||||
const toonInput = 'true'
|
||||
|
||||
const cleanup = mockStdin(toonInput)
|
||||
|
||||
const writeChunks: string[] = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await runCli({ rawArgs: ['--decode'] })
|
||||
|
||||
const fullOutput = writeChunks.join('')
|
||||
expect(fullOutput).toBe('true\n')
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming output', () => {
|
||||
it('streams large JSON to TOON file with identical output', async () => {
|
||||
const data = {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item ${i}`,
|
||||
value: Math.random(),
|
||||
})),
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'large-input.json': JSON.stringify(data, undefined, 2),
|
||||
})
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['large-input.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
// Verify streaming produces identical output to `encode()`
|
||||
const expected = encode(data, {
|
||||
delimiter: DEFAULT_DELIMITER,
|
||||
indent: 2,
|
||||
})
|
||||
|
||||
expect(output).toBe(expected)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Encoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('streams large TOON to JSON file with streaming decode', async () => {
|
||||
const data = {
|
||||
records: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i,
|
||||
title: `Record ${i}`,
|
||||
score: Math.random() * 100,
|
||||
})),
|
||||
}
|
||||
|
||||
const toonContent = encode(data, {
|
||||
delimiter: DEFAULT_DELIMITER,
|
||||
indent: 2,
|
||||
})
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'large-input.toon': toonContent,
|
||||
})
|
||||
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['large-input.toon', '--decode', '--output', 'output.json'])
|
||||
|
||||
const output = await context.read('output.json')
|
||||
const result = JSON.parse(output)
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Decoded .* → .*/))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('streams to stdout using process.stdout.write', async () => {
|
||||
const data = {
|
||||
users: [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
const writeChunks: string[] = []
|
||||
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
writeChunks.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['input.json'])
|
||||
|
||||
expect(writeSpy).toHaveBeenCalled()
|
||||
|
||||
// Verify complete output matches `encode()`
|
||||
const fullOutput = writeChunks.join('')
|
||||
const expected = `${encode(data)}\n`
|
||||
expect(fullOutput).toBe(expected)
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles empty object streaming correctly', async () => {
|
||||
const data = {}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'empty.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['empty.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
expect(output).toBe(encode(data))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles single-line output streaming correctly', async () => {
|
||||
const data = { key: 'value' }
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'single.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
try {
|
||||
await context.run(['single.json', '--output', 'output.toon'])
|
||||
|
||||
const output = await context.read('output.toon')
|
||||
expect(output).toBe(encode(data))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses non-streaming path when stats are enabled', async () => {
|
||||
const data = {
|
||||
items: [
|
||||
{ id: 1, value: 'test' },
|
||||
{ id: 2, value: 'data' },
|
||||
],
|
||||
}
|
||||
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify(data),
|
||||
})
|
||||
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const consolaInfo = vi.spyOn(consola, 'info').mockImplementation(() => undefined)
|
||||
const consolaSuccess = vi.spyOn(consola, 'success').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--stats'])
|
||||
|
||||
expect(consolaInfo).toHaveBeenCalledWith(expect.stringMatching(/Token estimates:/))
|
||||
expect(consolaSuccess).toHaveBeenCalledWith(expect.stringMatching(/Saved.*tokens/))
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(encode(data))
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('rejects invalid delimiter', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--delimiter', ';'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid delimiter')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid indent value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--indent', 'abc'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid indent value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles missing input file', async () => {
|
||||
const context = await createCliTestContext({})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['nonexistent.json'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
expect(consolaError).toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --keyFolding value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--keyFolding', 'invalid'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid keyFolding value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --expandPaths value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.toon': 'key: value',
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.toon', '--decode', '--expandPaths', 'invalid'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid expandPaths value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --flattenDepth value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
await context.run(['input.json', '--flattenDepth', '-1'])
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.message).toContain('Invalid flattenDepth value')
|
||||
}
|
||||
finally {
|
||||
await context.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { JsonStreamEvent } from '../../toon/src/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonStreamFromEvents } from '../src/json-from-events'
|
||||
|
||||
describe('jsonStreamFromEvents', () => {
|
||||
describe('primitives', () => {
|
||||
it('converts null event', async () => {
|
||||
const events = [
|
||||
{ type: 'primitive' as const, value: null },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(null))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(null, null, 2))
|
||||
})
|
||||
|
||||
it('converts boolean events', async () => {
|
||||
const eventsTrue = [{ type: 'primitive' as const, value: true }]
|
||||
const eventsFalse = [{ type: 'primitive' as const, value: false }]
|
||||
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 0))).toBe(JSON.stringify(true))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFalse), 0))).toBe(JSON.stringify(false))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsTrue), 2))).toBe(JSON.stringify(true, null, 2))
|
||||
})
|
||||
|
||||
it('converts number events', async () => {
|
||||
const events0 = [{ type: 'primitive' as const, value: 0 }]
|
||||
const events42 = [{ type: 'primitive' as const, value: 42 }]
|
||||
const eventsNeg = [{ type: 'primitive' as const, value: -17 }]
|
||||
const eventsFloat = [{ type: 'primitive' as const, value: 3.14159 }]
|
||||
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events0), 0))).toBe(JSON.stringify(0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 0))).toBe(JSON.stringify(42))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsNeg), 0))).toBe(JSON.stringify(-17))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsFloat), 0))).toBe(JSON.stringify(3.14159))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events42), 2))).toBe(JSON.stringify(42, null, 2))
|
||||
})
|
||||
|
||||
it('converts string events', async () => {
|
||||
const eventsEmpty = [{ type: 'primitive' as const, value: '' }]
|
||||
const eventsHello = [{ type: 'primitive' as const, value: 'hello' }]
|
||||
const eventsQuotes = [{ type: 'primitive' as const, value: 'with "quotes"' }]
|
||||
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsEmpty), 0))).toBe(JSON.stringify(''))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsHello), 0))).toBe(JSON.stringify('hello'))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(eventsQuotes), 0))).toBe(JSON.stringify('with "quotes"'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty containers', () => {
|
||||
it('converts empty array events', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 0 },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify([], null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify([], null, 2))
|
||||
})
|
||||
|
||||
it('converts empty object events', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify({}, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify({}, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays', () => {
|
||||
it('converts simple array events with compact formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [1, 2, 3]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('converts simple array events with pretty formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [1, 2, 3]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts mixed-type array events', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 5 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 'two' },
|
||||
{ type: 'primitive' as const, value: true },
|
||||
{ type: 'primitive' as const, value: null },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'key' },
|
||||
{ type: 'primitive' as const, value: 'value' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [1, 'two', true, null, { key: 'value' }]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts nested array events', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'primitive' as const, value: 4 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 5 },
|
||||
{ type: 'primitive' as const, value: 6 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [[1, 2], [3, 4], [5, 6]]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects', () => {
|
||||
it('converts simple object events with compact formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'a' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'b' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'c' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('converts simple object events with pretty formatting', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'a' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'b' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'c' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts object events with mixed value types', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'num' },
|
||||
{ type: 'primitive' as const, value: 42 },
|
||||
{ type: 'key' as const, key: 'str' },
|
||||
{ type: 'primitive' as const, value: 'hello' },
|
||||
{ type: 'key' as const, key: 'bool' },
|
||||
{ type: 'primitive' as const, value: true },
|
||||
{ type: 'key' as const, key: 'nil' },
|
||||
{ type: 'primitive' as const, value: null },
|
||||
{ type: 'key' as const, key: 'arr' },
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
num: 42,
|
||||
str: 'hello',
|
||||
bool: true,
|
||||
nil: null,
|
||||
arr: [1, 2, 3],
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts nested object events', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'level1' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'level2' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'level3' },
|
||||
{ type: 'primitive' as const, value: 'deep' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: 'deep',
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles special characters in keys', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'normal-key' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'key with spaces' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'key:with:colons' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'key' as const, key: 'key"with"quotes' },
|
||||
{ type: 'primitive' as const, value: 4 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
'normal-key': 1,
|
||||
'key with spaces': 2,
|
||||
'key:with:colons': 3,
|
||||
'key"with"quotes': 4,
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex nested structures', () => {
|
||||
it('converts object containing arrays', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
{ type: 'key' as const, key: 'scores' },
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 95 },
|
||||
{ type: 'primitive' as const, value: 87 },
|
||||
{ type: 'primitive' as const, value: 92 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'key' as const, key: 'metadata' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'tags' },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 'math' },
|
||||
{ type: 'primitive' as const, value: 'science' },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = {
|
||||
name: 'Alice',
|
||||
scores: [95, 87, 92],
|
||||
metadata: {
|
||||
tags: ['math', 'science'],
|
||||
},
|
||||
}
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('converts array of objects', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'id' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'id' },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Bob' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'id' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Charlie' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endArray' as const },
|
||||
]
|
||||
const value = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
{ id: 3, name: 'Charlie' },
|
||||
]
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('indentation levels', () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'a' },
|
||||
{ type: 'startArray' as const, length: 2 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
{ type: 'primitive' as const, value: 2 },
|
||||
{ type: 'endArray' as const },
|
||||
{ type: 'key' as const, key: 'b' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'c' },
|
||||
{ type: 'primitive' as const, value: 3 },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
const value = { a: [1, 2], b: { c: 3 } }
|
||||
|
||||
it('handles indent=0 (compact)', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('handles indent=2', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles indent=4', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 4))).toBe(JSON.stringify(value, null, 4))
|
||||
})
|
||||
|
||||
it('handles indent=8', async () => {
|
||||
expect(await join(jsonStreamFromEvents(asyncEvents(events), 8))).toBe(JSON.stringify(value, null, 8))
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws on mismatched endObject event', async () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 0 },
|
||||
{ type: 'endObject' as const }, // Wrong closing event
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Mismatched endObject event')
|
||||
})
|
||||
|
||||
it('throws on mismatched endArray event', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'endArray' as const }, // Wrong closing event
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Mismatched endArray event')
|
||||
})
|
||||
|
||||
it('throws on key event outside object context', async () => {
|
||||
const events = [
|
||||
{ type: 'key' as const, key: 'invalid' },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Key event outside of object context')
|
||||
})
|
||||
|
||||
it('throws on primitive in object without preceding key', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'primitive' as const, value: 'invalid' }, // No key before primitive
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Primitive event in object without preceding key')
|
||||
})
|
||||
|
||||
it('throws on incomplete event stream', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
// Missing `endObject`
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await join(jsonStreamFromEvents(asyncEvents(events), 0))
|
||||
}).rejects.toThrow('Incomplete event stream: unclosed objects or arrays')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Converts array of events to async iterable.
|
||||
*/
|
||||
async function* asyncEvents(events: JsonStreamEvent[]): AsyncIterable<JsonStreamEvent> {
|
||||
for (const event of events) {
|
||||
await Promise.resolve()
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins chunks from an async iterable into a single string.
|
||||
*/
|
||||
async function join(iter: AsyncIterable<string>): Promise<string> {
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of iter) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonStringifyLines } from '../src/json-stringify-stream'
|
||||
|
||||
describe('jsonStringifyLines', () => {
|
||||
describe('primitives', () => {
|
||||
it('stringifies null', () => {
|
||||
expect(join(jsonStringifyLines(null, 0))).toBe(JSON.stringify(null))
|
||||
expect(join(jsonStringifyLines(null, 2))).toBe(JSON.stringify(null, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies booleans', () => {
|
||||
expect(join(jsonStringifyLines(true, 0))).toBe(JSON.stringify(true))
|
||||
expect(join(jsonStringifyLines(false, 0))).toBe(JSON.stringify(false))
|
||||
expect(join(jsonStringifyLines(true, 2))).toBe(JSON.stringify(true, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies numbers', () => {
|
||||
expect(join(jsonStringifyLines(0, 0))).toBe(JSON.stringify(0))
|
||||
expect(join(jsonStringifyLines(42, 0))).toBe(JSON.stringify(42))
|
||||
expect(join(jsonStringifyLines(-17, 0))).toBe(JSON.stringify(-17))
|
||||
expect(join(jsonStringifyLines(3.14159, 0))).toBe(JSON.stringify(3.14159))
|
||||
expect(join(jsonStringifyLines(1e10, 2))).toBe(JSON.stringify(1e10, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies strings', () => {
|
||||
expect(join(jsonStringifyLines('', 0))).toBe(JSON.stringify(''))
|
||||
expect(join(jsonStringifyLines('hello', 0))).toBe(JSON.stringify('hello'))
|
||||
expect(join(jsonStringifyLines('with "quotes"', 0))).toBe(JSON.stringify('with "quotes"'))
|
||||
expect(join(jsonStringifyLines('with\nnewlines', 2))).toBe(JSON.stringify('with\nnewlines', null, 2))
|
||||
expect(join(jsonStringifyLines('with\ttabs', 0))).toBe(JSON.stringify('with\ttabs'))
|
||||
})
|
||||
|
||||
it('converts undefined to null', () => {
|
||||
expect(join(jsonStringifyLines(undefined, 0))).toBe('null')
|
||||
expect(join(jsonStringifyLines(undefined, 2))).toBe('null')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty containers', () => {
|
||||
it('stringifies empty arrays', () => {
|
||||
expect(join(jsonStringifyLines([], 0))).toBe(JSON.stringify([], null, 0))
|
||||
expect(join(jsonStringifyLines([], 2))).toBe(JSON.stringify([], null, 2))
|
||||
})
|
||||
|
||||
it('stringifies empty objects', () => {
|
||||
expect(join(jsonStringifyLines({}, 0))).toBe(JSON.stringify({}, null, 0))
|
||||
expect(join(jsonStringifyLines({}, 2))).toBe(JSON.stringify({}, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('arrays', () => {
|
||||
it('stringifies arrays with compact formatting (indent=0)', () => {
|
||||
const value = [1, 2, 3]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('stringifies arrays with pretty formatting (indent=2)', () => {
|
||||
const value = [1, 2, 3]
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies mixed-type arrays', () => {
|
||||
const value = [1, 'two', true, null, { key: 'value' }]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies nested arrays', () => {
|
||||
const value = [[1, 2], [3, 4], [5, 6]]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies deeply nested arrays', () => {
|
||||
const value = [[[1]], [[2]], [[3]]]
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
expect(join(jsonStringifyLines(value, 4))).toBe(JSON.stringify(value, null, 4))
|
||||
})
|
||||
})
|
||||
|
||||
describe('objects', () => {
|
||||
it('stringifies simple objects with compact formatting', () => {
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('stringifies simple objects with pretty formatting', () => {
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies objects with mixed value types', () => {
|
||||
const value = {
|
||||
num: 42,
|
||||
str: 'hello',
|
||||
bool: true,
|
||||
nil: null,
|
||||
arr: [1, 2, 3],
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies nested objects', () => {
|
||||
const value = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: 'deep',
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('preserves key order', () => {
|
||||
const value = { z: 1, a: 2, m: 3 }
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles special characters in keys', () => {
|
||||
const value = {
|
||||
'normal-key': 1,
|
||||
'key with spaces': 2,
|
||||
'key:with:colons': 3,
|
||||
'key"with"quotes': 4,
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex nested structures', () => {
|
||||
it('stringifies objects containing arrays', () => {
|
||||
const value = {
|
||||
name: 'Alice',
|
||||
scores: [95, 87, 92],
|
||||
metadata: {
|
||||
tags: ['math', 'science'],
|
||||
},
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies arrays of objects', () => {
|
||||
const value = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
{ id: 3, name: 'Charlie' },
|
||||
]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('stringifies deeply nested mixed structures', () => {
|
||||
const value = {
|
||||
users: [
|
||||
{
|
||||
name: 'Alice',
|
||||
roles: ['admin', 'user'],
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
roles: ['user'],
|
||||
settings: {
|
||||
theme: 'light',
|
||||
notifications: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('indentation levels', () => {
|
||||
const value = { a: [1, 2], b: { c: 3 } }
|
||||
|
||||
it('handles indent=0 (compact)', () => {
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
})
|
||||
|
||||
it('handles indent=2', () => {
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles indent=4', () => {
|
||||
expect(join(jsonStringifyLines(value, 4))).toBe(JSON.stringify(value, null, 4))
|
||||
})
|
||||
|
||||
it('handles indent=8', () => {
|
||||
expect(join(jsonStringifyLines(value, 8))).toBe(JSON.stringify(value, null, 8))
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles arrays with undefined values (converted to null)', () => {
|
||||
const value = [1, undefined, 3]
|
||||
const expected = JSON.stringify(value, null, 2)
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(expected)
|
||||
})
|
||||
|
||||
it('handles single-element arrays', () => {
|
||||
const value = [42]
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles single-property objects', () => {
|
||||
const value = { only: 'one' }
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles objects with many properties', () => {
|
||||
const value: Record<string, number> = {}
|
||||
for (let i = 0; i < 100; i++) {
|
||||
value[`key${i}`] = i
|
||||
}
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
|
||||
it('handles large arrays', () => {
|
||||
const value = Array.from({ length: 1000 }, (_, i) => i)
|
||||
expect(join(jsonStringifyLines(value, 0))).toBe(JSON.stringify(value, null, 0))
|
||||
expect(join(jsonStringifyLines(value, 2))).toBe(JSON.stringify(value, null, 2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Joins chunks from an iterable into a single string.
|
||||
*/
|
||||
function join(iter: Iterable<string>): string {
|
||||
return Array.from(iter).join('')
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { Readable } from 'node:stream'
|
||||
import { runMain } from 'citty'
|
||||
import { mainCommand } from '../src/index'
|
||||
|
||||
interface FileRecord {
|
||||
[relativePath: string]: string
|
||||
}
|
||||
|
||||
export function runCli(options?: Parameters<typeof runMain>[1]): Promise<void> {
|
||||
return runMain(mainCommand, options)
|
||||
}
|
||||
|
||||
export interface CliTestContext {
|
||||
readonly dir: string
|
||||
run: (args?: string[]) => Promise<void>
|
||||
read: (relativePath: string) => Promise<string>
|
||||
write: (relativePath: string, contents: string) => Promise<void>
|
||||
resolve: (relativePath: string) => string
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
const TEMP_PREFIX = path.join(os.tmpdir(), 'toon-cli-test-')
|
||||
|
||||
export async function createCliTestContext(initialFiles: FileRecord = {}): Promise<CliTestContext> {
|
||||
const dir = await fsp.mkdtemp(TEMP_PREFIX)
|
||||
await writeFiles(dir, initialFiles)
|
||||
|
||||
async function run(args: string[] = []): Promise<void> {
|
||||
const previousCwd = process.cwd()
|
||||
process.chdir(dir)
|
||||
try {
|
||||
await runCli({ rawArgs: args })
|
||||
}
|
||||
finally {
|
||||
process.chdir(previousCwd)
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(relativePath: string): string {
|
||||
return path.join(dir, relativePath)
|
||||
}
|
||||
|
||||
async function read(relativePath: string): Promise<string> {
|
||||
return fsp.readFile(resolvePath(relativePath), 'utf8')
|
||||
}
|
||||
|
||||
async function write(relativePath: string, contents: string): Promise<void> {
|
||||
const targetPath = resolvePath(relativePath)
|
||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||
await fsp.writeFile(targetPath, contents, 'utf8')
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
await fsp.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
run,
|
||||
read,
|
||||
write,
|
||||
resolve: resolvePath,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFiles(baseDir: string, files: FileRecord): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([relativePath, contents]) => {
|
||||
const filePath = path.join(baseDir, relativePath)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fsp.writeFile(filePath, contents, 'utf8')
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function mockStdin(input: string): () => void {
|
||||
const mockStream = Readable.from([input])
|
||||
|
||||
const originalStdin = process.stdin
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: mockStream,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: originalStdin,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { UserConfig } from 'tsdown/config'
|
||||
import { defineConfig } from 'tsdown/config'
|
||||
|
||||
const config: UserConfig = defineConfig({
|
||||
entry: {
|
||||
index: 'src/cli-entry.ts',
|
||||
},
|
||||
dts: true,
|
||||
})
|
||||
|
||||
export default config
|
||||
Reference in New Issue
Block a user