fix: add delimiter option

This commit is contained in:
Johann Schopplich
2025-10-22 21:58:55 +02:00
parent f105551c3e
commit 67459f4da6
7 changed files with 324 additions and 45 deletions

View File

@@ -10,6 +10,7 @@ export const LIST_ITEM_PREFIX = '- '
export const COMMA = ','
export const COLON = ':'
export const SPACE = ' '
export const PIPE = '|'
// #endregion

View File

@@ -30,16 +30,16 @@ import { LineWriter } from './writer'
export function encodeValue(value: JsonValue, options: ResolvedEncodeOptions): string {
if (isJsonPrimitive(value)) {
return encodePrimitive(value)
return encodePrimitive(value, options.delimiter)
}
const writer = new LineWriter(options.indent)
if (isJsonArray(value)) {
encodeRootArray(value, writer)
encodeRootArray(value, writer, options)
}
else if (isJsonObject(value)) {
encodeObject(value, writer, 0)
encodeObject(value, writer, 0, options)
}
return writer.toString()
@@ -49,22 +49,22 @@ export function encodeValue(value: JsonValue, options: ResolvedEncodeOptions): s
// #region Object encoding
export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth): void {
export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void {
const keys = Object.keys(value)
for (const key of keys) {
encodeKeyValuePair(key, value[key]!, writer, depth)
encodeKeyValuePair(key, value[key]!, writer, depth, options)
}
}
export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWriter, depth: Depth): void {
export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void {
const encodedKey = encodeKey(key)
if (isJsonPrimitive(value)) {
writer.push(depth, `${encodedKey}: ${encodePrimitive(value)}`)
writer.push(depth, `${encodedKey}: ${encodePrimitive(value, options.delimiter)}`)
}
else if (isJsonArray(value)) {
encodeArrayProperty(key, value, writer, depth)
encodeArrayProperty(key, value, writer, depth, options)
}
else if (isJsonObject(value)) {
const nestedKeys = Object.keys(value)
@@ -74,7 +74,7 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr
}
else {
writer.push(depth, `${encodedKey}:`)
encodeObject(value, writer, depth + 1)
encodeObject(value, writer, depth + 1, options)
}
}
}
@@ -83,7 +83,7 @@ export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWr
// #region Array encoding
export function encodeRootArray(value: JsonArray, writer: LineWriter): void {
export function encodeRootArray(value: JsonArray, writer: LineWriter, options: ResolvedEncodeOptions): void {
if (value.length === 0) {
writer.push(0, '[0]:')
return
@@ -91,7 +91,7 @@ export function encodeRootArray(value: JsonArray, writer: LineWriter): void {
// Primitive array
if (isArrayOfPrimitives(value)) {
encodeInlinePrimitiveArray(undefined, value, writer, 0)
encodeInlinePrimitiveArray(undefined, value, writer, 0, options)
return
}
@@ -99,7 +99,7 @@ export function encodeRootArray(value: JsonArray, writer: LineWriter): void {
if (isArrayOfArrays(value)) {
const allPrimitiveArrays = value.every(arr => isArrayOfPrimitives(arr))
if (allPrimitiveArrays) {
encodeArrayOfArraysAsListItems(undefined, value, writer, 0)
encodeArrayOfArraysAsListItems(undefined, value, writer, 0, options)
return
}
}
@@ -108,19 +108,19 @@ export function encodeRootArray(value: JsonArray, writer: LineWriter): void {
if (isArrayOfObjects(value)) {
const header = detectTabularHeader(value)
if (header) {
encodeArrayOfObjectsAsTabular(undefined, value, header, writer, 0)
encodeArrayOfObjectsAsTabular(undefined, value, header, writer, 0, options)
}
else {
encodeArrayOfObjectsAsListItems(undefined, value, writer, 0)
encodeArrayOfObjectsAsListItems(undefined, value, writer, 0, options)
}
return
}
// Mixed array: fallback to expanded format (not in spec, but safe default)
encodeMixedArrayAsListItems(undefined, value, writer, 0)
encodeMixedArrayAsListItems(undefined, value, writer, 0, options)
}
export function encodeArrayProperty(key: string, value: JsonArray, writer: LineWriter, depth: Depth): void {
export function encodeArrayProperty(key: string, value: JsonArray, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void {
if (value.length === 0) {
const encodedKey = encodeKey(key)
writer.push(depth, `${encodedKey}[0]:`)
@@ -129,7 +129,7 @@ export function encodeArrayProperty(key: string, value: JsonArray, writer: LineW
// Primitive array
if (isArrayOfPrimitives(value)) {
encodeInlinePrimitiveArray(key, value, writer, depth)
encodeInlinePrimitiveArray(key, value, writer, depth, options)
return
}
@@ -137,7 +137,7 @@ export function encodeArrayProperty(key: string, value: JsonArray, writer: LineW
if (isArrayOfArrays(value)) {
const allPrimitiveArrays = value.every(arr => isArrayOfPrimitives(arr))
if (allPrimitiveArrays) {
encodeArrayOfArraysAsListItems(key, value, writer, depth)
encodeArrayOfArraysAsListItems(key, value, writer, depth, options)
return
}
}
@@ -146,16 +146,16 @@ export function encodeArrayProperty(key: string, value: JsonArray, writer: LineW
if (isArrayOfObjects(value)) {
const header = detectTabularHeader(value)
if (header) {
encodeArrayOfObjectsAsTabular(key, value, header, writer, depth)
encodeArrayOfObjectsAsTabular(key, value, header, writer, depth, options)
}
else {
encodeArrayOfObjectsAsListItems(key, value, writer, depth)
encodeArrayOfObjectsAsListItems(key, value, writer, depth, options)
}
return
}
// Mixed array: fallback to expanded format
encodeMixedArrayAsListItems(key, value, writer, depth)
encodeMixedArrayAsListItems(key, value, writer, depth, options)
}
// #endregion
@@ -167,9 +167,10 @@ export function encodeInlinePrimitiveArray(
values: readonly JsonPrimitive[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, values.length) : formatArrayHeader(values.length)
const joinedValue = joinEncodedValues(values)
const joinedValue = joinEncodedValues(values, options.delimiter)
// Only add space if there are values
if (values.length === 0) {
writer.push(depth, header)
@@ -188,21 +189,22 @@ export function encodeArrayOfArraysAsListItems(
values: readonly JsonArray[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, values.length) : formatArrayHeader(values.length)
writer.push(depth, header)
for (const arr of values) {
if (isArrayOfPrimitives(arr)) {
const inline = formatInlineArray(arr)
const inline = formatInlineArray(arr, options.delimiter)
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${inline}`)
}
}
}
export function formatInlineArray(values: readonly JsonPrimitive[]): string {
export function formatInlineArray(values: readonly JsonPrimitive[], delimiter: string): string {
const header = formatArrayHeader(values.length)
const joinedValue = joinEncodedValues(values)
const joinedValue = joinEncodedValues(values, delimiter)
// Only add space if there are values
if (values.length === 0) {
return header
@@ -220,6 +222,7 @@ export function encodeArrayOfObjectsAsTabular(
header: readonly string[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
const headerStr = prefix
? formatKeyedTableHeader(prefix, rows.length, header)
@@ -228,7 +231,7 @@ export function encodeArrayOfObjectsAsTabular(
for (const row of rows) {
const values = header.map(key => row[key])
const joinedValue = joinEncodedValues(values as JsonPrimitive[])
const joinedValue = joinEncodedValues(values as JsonPrimitive[], options.delimiter)
writer.push(depth + 1, joinedValue)
}
}
@@ -282,6 +285,7 @@ export function encodeMixedArrayAsListItems(
items: readonly JsonValue[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, items.length) : formatArrayHeader(items.length)
writer.push(depth, header)
@@ -289,18 +293,18 @@ export function encodeMixedArrayAsListItems(
for (const item of items) {
if (isJsonPrimitive(item)) {
// Direct primitive as list item
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${encodePrimitive(item)}`)
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${encodePrimitive(item, options.delimiter)}`)
}
else if (isJsonArray(item)) {
// Direct array as list item
if (isArrayOfPrimitives(item)) {
const inline = formatInlineArray(item)
const inline = formatInlineArray(item, options.delimiter)
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${inline}`)
}
}
else if (isJsonObject(item)) {
// Object as list item
encodeObjectAsListItem(item, writer, depth + 1)
encodeObjectAsListItem(item, writer, depth + 1, options)
}
}
}
@@ -310,16 +314,17 @@ export function encodeArrayOfObjectsAsListItems(
rows: readonly JsonObject[],
writer: LineWriter,
depth: Depth,
options: ResolvedEncodeOptions,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, rows.length) : formatArrayHeader(rows.length)
writer.push(depth, `${header}`)
for (const obj of rows) {
encodeObjectAsListItem(obj, writer, depth + 1)
encodeObjectAsListItem(obj, writer, depth + 1, options)
}
}
export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, depth: Depth): void {
export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, depth: Depth, options: ResolvedEncodeOptions): void {
const keys = Object.keys(obj)
if (keys.length === 0) {
writer.push(depth, LIST_ITEM_MARKER)
@@ -332,7 +337,7 @@ export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, dept
const firstValue = obj[firstKey]!
if (isJsonPrimitive(firstValue)) {
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}: ${encodePrimitive(firstValue)}`)
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}: ${encodePrimitive(firstValue, options.delimiter)}`)
}
else if (isJsonArray(firstValue)) {
// For arrays, we need to put them on separate lines
@@ -346,14 +351,14 @@ export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, dept
}
else {
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}:`)
encodeObject(firstValue, writer, depth + 2)
encodeObject(firstValue, writer, depth + 2, options)
}
}
// Remaining keys on indented lines
for (let i = 1; i < keys.length; i++) {
const key = keys[i]!
encodeKeyValuePair(key, obj[key]!, writer, depth + 1)
encodeKeyValuePair(key, obj[key]!, writer, depth + 1, options)
}
}

View File

@@ -23,5 +23,6 @@ export function encode(input: unknown, options?: EncodeOptions): string {
function resolveOptions(options?: EncodeOptions): ResolvedEncodeOptions {
return {
indent: options?.indent ?? 2,
delimiter: options?.delimiter ?? ',',
}
}

View File

@@ -11,7 +11,7 @@ import {
// #region Primitive encoding
export function encodePrimitive(value: JsonPrimitive): string {
export function encodePrimitive(value: JsonPrimitive, delimiter?: string): string {
if (value === null) {
return NULL_LITERAL
}
@@ -24,11 +24,11 @@ export function encodePrimitive(value: JsonPrimitive): string {
return String(value)
}
return encodeStringLiteral(value)
return encodeStringLiteral(value, delimiter)
}
export function encodeStringLiteral(value: string): string {
if (isSafeUnquoted(value)) {
export function encodeStringLiteral(value: string, delimiter: string = COMMA): string {
if (isSafeUnquoted(value, delimiter)) {
return value
}
@@ -44,7 +44,7 @@ export function escapeString(value: string): string {
.replace(/\t/g, `${BACKSLASH}t`)
}
export function isSafeUnquoted(value: string): boolean {
export function isSafeUnquoted(value: string, delimiter: string = COMMA): boolean {
if (!value) {
return false
}
@@ -61,8 +61,33 @@ export function isSafeUnquoted(value: string): boolean {
return false
}
// Check for structural characters: comma, colon, brackets, braces, hyphen at start, newline, carriage return, tab, double-quote
if (/[,:\n\r\t"[\]{}]/.test(value) || value.startsWith(LIST_ITEM_MARKER)) {
// Check for colon (always structural)
if (value.includes(':')) {
return false
}
// Check for quotes and backslash (always need escaping)
if (value.includes('"') || value.includes('\\')) {
return false
}
// Check for brackets and braces (always structural)
if (/[[\]{}]/.test(value)) {
return false
}
// Check for control characters (newline, carriage return, tab - always need quoting/escaping)
if (/[\n\r\t]/.test(value)) {
return false
}
// Check for the active delimiter
if (value.includes(delimiter)) {
return false
}
// Check for hyphen at start (list marker)
if (value.startsWith(LIST_ITEM_MARKER)) {
return false
}
@@ -98,8 +123,8 @@ function isValidUnquotedKey(key: string): boolean {
// #region Value joining
export function joinEncodedValues(values: readonly JsonPrimitive[]): string {
return values.map(v => encodePrimitive(v)).join(COMMA)
export function joinEncodedValues(values: readonly JsonPrimitive[], delimiter: string = COMMA): string {
return values.map(v => encodePrimitive(v, delimiter)).join(delimiter)
}
// #endregion

View File

@@ -11,6 +11,11 @@ export type JsonValue = JsonPrimitive | JsonObject | JsonArray
export interface EncodeOptions {
indent?: number
/**
* Delimiter to use for tabular array rows and inline primitive arrays.
* @default ','
*/
delimiter?: ',' | '\t' | '|'
}
export type ResolvedEncodeOptions = Readonly<Required<EncodeOptions>>