chore: import upstream snapshot with attribution
CI / ci (push) Has been cancelled
Deploy Docs / Deploy Docs (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:56 +08:00
commit bde7ea0d58
129 changed files with 28635 additions and 0 deletions
+247
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
'use strict'
import('../dist/index.mjs')
+42
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
import { runMain } from 'citty'
import { mainCommand } from './index.ts'
runMain(mainCommand)
+194
View File
@@ -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')
}
}
+70
View File
@@ -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
+155
View File
@@ -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)
}
},
})
+217
View File
@@ -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')
}
}
+161
View File
@@ -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 '}'
}
}
+3
View File
@@ -0,0 +1,3 @@
export type InputSource
= | { type: 'stdin' }
| { type: 'file', path: string }
+109
View File
@@ -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
}
}
+78
View File
@@ -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'
+ ' ^',
)
})
})
+832
View File
@@ -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()
}
})
})
})
+423
View File
@@ -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('')
}
+96
View File
@@ -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,
})
}
}
+11
View File
@@ -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