Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 53 additions & 36 deletions src/utils/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function getOutputFormat(command: { optsWithGlobals: () => Record<string,
* @returns Formatted string ready for output
*
* @example
* // TOON format (default) - uses tab delimiter for 58.9% token savings
* // TOON format (default) - uses tab delimiter, ~37% token savings vs JSON
* formatOutput({ tasks: [{ id: 1, name: 'Task 1' }] }, { format: 'toon' })
* // Output: tasks[1<TAB>]{id<TAB>name}:\n 1<TAB>Task 1
*
Expand Down Expand Up @@ -72,7 +72,9 @@ export function formatOutput(data: any, options: FormatterOptions): string {
/**
* Format data as TOON (Token-Oriented Object Notation)
*
* Uses tab delimiter for maximum token efficiency (58.9% savings vs JSON).
* Uses tab delimiter for token efficiency: ~37% savings vs JSON, measured
* with the o200k/cl100k tokenizers on TaskView-shaped list output (10-200
* rows). Savings on single-item detail views with long notes are ~6%.
* Powered by @pleaseai/cli-toolkit.
*
* @param data - The data to format
Expand Down Expand Up @@ -100,70 +102,85 @@ function formatJson(data: any): string {
* @returns Plain text formatted string
*/
function formatPlain(data: any, colors: boolean): string {
// This will be implemented based on the specific data structure
// For now, return a basic representation
if (Array.isArray(data)) {
return formatPlainArray(data, colors)
return formatPlainArray(data, colors, '')
}

if (typeof data === 'object' && data !== null) {
return formatPlainObject(data, colors)
return formatPlainObject(data, colors, '')
}

return String(data)
}

/**
* Format array as plain text
* Format array as plain text. Each item starts with a `- ` marker and every
* continuation line is indented to align under the marker.
*/
function formatPlainArray(data: any[], colors: boolean): string {
const lines: string[] = []

for (const item of data) {
if (typeof item === 'object' && item !== null) {
lines.push(formatPlainObject(item, colors))
}
else {
lines.push(String(item))
}
}

return lines.join('\n')
function formatPlainArray(data: any[], colors: boolean, indent: string): string {
const itemIndent = `${indent} `

return data
.map((item) => {
if (Array.isArray(item)) {
const body = formatPlainArray(item, colors, itemIndent)
return `${indent}- ${body.slice(itemIndent.length)}`
}
if (typeof item === 'object' && item !== null) {
Comment thread
amondnet marked this conversation as resolved.
const body = formatPlainObject(item, colors, itemIndent)
// Replace the first line's indent with the `- ` marker.
return `${indent}- ${body.slice(itemIndent.length)}`
}
// Indent continuation lines of multiline strings under the marker.
const scalar = formatPlainScalar(item, colors).split('\n').join(`\n${indent} `)
return `${indent}- ${scalar}`
})
Comment thread
amondnet marked this conversation as resolved.
.join('\n')
}

/**
* Format object as plain text with key-value pairs
* Format object as plain text with key-value pairs. Keys with `undefined` or
* `null` values are omitted — plain output is human-facing, and empty fields
* are noise there (json keeps null for scripting).
*/
function formatPlainObject(data: Record<string, any>, colors: boolean): string {
function formatPlainObject(data: Record<string, any>, colors: boolean, indent: string): string {
const lines: string[] = []

for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === null) {
continue
}
const keyLabel = colors ? chalk.bold(key) : key
if (Array.isArray(value)) {
lines.push(`${keyLabel}:`)
lines.push(...value.map(item => ` ${formatPlainValue(item, colors)}`))
const body = formatPlainArray(value, colors, `${indent} `)
lines.push(`${indent}${keyLabel}:`)
if (body) {
lines.push(body)
}
}
Comment thread
amondnet marked this conversation as resolved.
else if (typeof value === 'object') {
const body = formatPlainObject(value, colors, `${indent} `)
lines.push(`${indent}${keyLabel}:`)
if (body) {
lines.push(body)
}
}
Comment thread
amondnet marked this conversation as resolved.
else {
lines.push(`${keyLabel}: ${formatPlainValue(value, colors)}`)
// Indent continuation lines of multiline strings under their key.
const scalar = formatPlainScalar(value, colors).split('\n').join(`\n${indent} `)
lines.push(`${indent}${keyLabel}: ${scalar}`)
}
}

return lines.join('\n')
}

/**
* Format a single value for plain text output
* Format a scalar value for plain text output
*/
function formatPlainValue(value: any, colors: boolean): string {
if (typeof value === 'boolean') {
if (colors) {
return value ? chalk.green('true') : chalk.yellow('false')
}
return String(value)
}

if (typeof value === 'object' && value !== null) {
return formatPlainObject(value, colors)
function formatPlainScalar(value: any, colors: boolean): string {
if (typeof value === 'boolean' && colors) {
return value ? chalk.green('true') : chalk.yellow('false')
}

return String(value)
Expand Down
113 changes: 112 additions & 1 deletion test/utils/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('formatOutput', () => {
}
const result = formatOutput(data, { format: 'toon' })

// cli-toolkit uses tab delimiter (not comma) for 58.9% token savings
// cli-toolkit uses tab delimiter (not comma) for ~37% token savings vs JSON
expect(result).toContain('\t')
// Should have tabular structure with field names separated by tabs
expect(result).toMatch(/\{[^\t}]*\t[^}]*\}/)
Expand Down Expand Up @@ -169,6 +169,117 @@ describe('formatOutput', () => {
expect(result).toContain('john@example.com')
})

test('should omit undefined values instead of printing "undefined"', () => {
const data = { name: 'John', due_on: undefined }
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result).not.toContain('undefined')
expect(result).not.toContain('due_on')
expect(result).toContain('name: John')
})

test('should omit null values instead of printing "null"', () => {
const data = { name: 'John', assignee: null }
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result).not.toContain('null')
expect(result).not.toContain('assignee')
expect(result).toContain('name: John')
})

test('should indent every line of array items and mark item boundaries', () => {
const data = {
tasks: [
{ gid: '123', name: 'Task 1' },
{ gid: '456', name: 'Task 2' },
],
}
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result.split('\n')).toEqual([
'tasks:',
' - gid: 123',
' name: Task 1',
' - gid: 456',
' name: Task 2',
])
})

test('should not emit a blank line for empty arrays or objects', () => {
const data = { tasks: [], meta: {}, name: 'John' }
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result.split('\n')).toEqual([
'tasks:',
'meta:',
'name: John',
])
})

test('should correctly format and indent nested arrays', () => {
const data = {
matrix: [
[1, 2],
[3, 4],
],
}
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result.split('\n')).toEqual([
'matrix:',
' - - 1',
' - 2',
' - - 3',
' - 4',
])
})

test('should indent nested object lines under their parent key', () => {
const data = {
user: {
name: 'John',
email: 'john@example.com',
},
}
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result.split('\n')).toEqual([
'user:',
' name: John',
' email: john@example.com',
])
})
Comment thread
amondnet marked this conversation as resolved.
Comment thread
amondnet marked this conversation as resolved.

test('should correctly indent multiline strings in plain format', () => {
const data = {
description: 'Line 1\nLine 2',
tasks: [
{ name: 'Task 1\nDetail 1' },
],
}
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result.split('\n')).toEqual([
'description: Line 1',
' Line 2',
'tasks:',
' - name: Task 1',
' Detail 1',
])
})

test('should indent multiline scalar array items under their marker', () => {
const data = { tags: ['line1\nline2', 'single'] }
const result = formatOutput(data, { format: 'plain', colors: false })

expect(result.split('\n')).toEqual([
'tags:',
' - line1',
' line2',
' - single',
])
})
Comment thread
amondnet marked this conversation as resolved.

test('should not throw error with colors enabled in plain format', () => {
const data = { name: 'John', completed: true }

Expand Down