chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user