Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd70538e0b | |||
| 4819128859 | |||
| 476e4a1784 | |||
| 18864cbd1f | |||
| d826be75d7 | |||
| 3d99b7b9ae | |||
| 1181b14bd7 | |||
| 0f0b1b3217 | |||
| 7dabdb303b | |||
| ef2f900d2f | |||
| e3458db261 | |||
| d5f50a2ce5 | |||
| 76c493ceab | |||
| 6edc9ac5e7 | |||
| 5a42db11f7 | |||
| 4cc24e7710 | |||
| a3b2f314ee | |||
| d71dfe8b86 | |||
| 26c52d26b4 | |||
| 47c6d3d712 | |||
| 1d947bf9b6 | |||
| a42c87bdb6 | |||
| df07261215 | |||
| 6f699bd4f5 | |||
| c6ab05141f | |||
| 8073392822 | |||
| 932f26189e | |||
| 2a335341cc | |||
| 9faa898e8f | |||
| 68f66bb5d6 | |||
| 9bb97ce103 | |||
| b1413925b7 | |||
| cc0a1b8e69 | |||
| 7b9235560e | |||
| 30a6d5d93d | |||
| 76c6133c8d | |||
| 626ea62889 | |||
| 69111cca5a | |||
| f15619de6c | |||
| aaef97a1ab | |||
| 189bbfa9bc | |||
| bbd7e8cf58 | |||
| 24746854bf | |||
| 19f9a5527f | |||
| 8a2c7e6715 | |||
| 19719a136f | |||
| 4852a09a76 |
@@ -17,7 +17,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -19,7 +19,7 @@ permissions: {}
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Docs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -14,7 +14,7 @@ concurrency:
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
name: Lint PR title
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
if: ${{ (github.event.action == 'opened' || github.event.changes.title != null) && github.actor != 'renovate[bot]' }}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ concurrency:
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
Vendored
+12
-4
@@ -1,7 +1,4 @@
|
||||
{
|
||||
// Enable the ESLint flat config support
|
||||
"eslint.useFlatConfig": true,
|
||||
|
||||
// Disable the default formatter, use ESLint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
@@ -37,6 +34,17 @@
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml"
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ Tests how well LLMs can answer questions about data in different formats (TOON,
|
||||
|
||||
1. Edit [`src/evaluate.ts`](./src/evaluate.ts) and add models to the exported `models` array:
|
||||
```ts
|
||||
export const models: LanguageModelV2[] = [
|
||||
export const models: LanguageModelV3[] = [
|
||||
openai('gpt-5-nano'),
|
||||
anthropic('claude-haiku-4-5-20251001'),
|
||||
google('gemini-2.5-flash'),
|
||||
xai('grok-4-fast-non-reasoning'),
|
||||
google('gemini-3-flash-preview'),
|
||||
xai('grok-4-1-fast-non-reasoning'),
|
||||
// Add your models here
|
||||
]
|
||||
```
|
||||
|
||||
+16
-16
@@ -3,26 +3,26 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"benchmark:tokens": "tsx scripts/token-efficiency-benchmark.ts",
|
||||
"benchmark:accuracy": "tsx --env-file=.env scripts/accuracy-benchmark.ts",
|
||||
"fetch:github-repos": "tsx scripts/fetch-github-repos.ts"
|
||||
"benchmark:tokens": "node scripts/token-efficiency-benchmark.ts",
|
||||
"benchmark:accuracy": "node --env-file=.env scripts/accuracy-benchmark.ts",
|
||||
"fetch:github-repos": "node scripts/fetch-github-repos.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.45",
|
||||
"@ai-sdk/google": "^2.0.42",
|
||||
"@ai-sdk/openai": "^2.0.71",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/xai": "^2.0.35",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"ai": "^5.0.101",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"fast-xml-parser": "^5.3.2",
|
||||
"@ai-sdk/anthropic": "^3.0.76",
|
||||
"@ai-sdk/google": "^3.0.71",
|
||||
"@ai-sdk/openai": "^3.0.63",
|
||||
"@ai-sdk/provider": "^3.0.10",
|
||||
"@ai-sdk/xai": "^3.0.89",
|
||||
"@clack/prompts": "^1.3.0",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"ai": "^6.0.177",
|
||||
"csv-stringify": "^6.7.0",
|
||||
"fast-xml-parser": "^5.7.3",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"ofetch": "^1.5.1",
|
||||
"p-map": "^7.0.4",
|
||||
"p-queue": "^9.0.1",
|
||||
"unstorage": "^1.17.3",
|
||||
"yaml": "^2.8.1"
|
||||
"p-queue": "^9.2.0",
|
||||
"unstorage": "^1.17.5",
|
||||
"yaml": "^2.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -36,17 +36,17 @@ Benchmarks test LLM comprehension across different input formats using 209 data
|
||||
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
|
||||
|
||||
```
|
||||
TOON ████████████████████ 26.9 acc%/1K tok │ 73.9% acc │ 2,744 tokens
|
||||
JSON compact █████████████████░░░ 22.9 acc%/1K tok │ 70.7% acc │ 3,081 tokens
|
||||
YAML ██████████████░░░░░░ 18.6 acc%/1K tok │ 69.0% acc │ 3,719 tokens
|
||||
JSON ███████████░░░░░░░░░ 15.3 acc%/1K tok │ 69.7% acc │ 4,545 tokens
|
||||
XML ██████████░░░░░░░░░░ 13.0 acc%/1K tok │ 67.1% acc │ 5,167 tokens
|
||||
TOON ████████████████████ 27.7 acc%/1K tok │ 76.4% acc │ 2,759 tokens
|
||||
JSON compact █████████████████░░░ 23.7 acc%/1K tok │ 73.7% acc │ 3,104 tokens
|
||||
YAML ██████████████░░░░░░ 19.9 acc%/1K tok │ 74.5% acc │ 3,749 tokens
|
||||
JSON ████████████░░░░░░░░ 16.4 acc%/1K tok │ 75.0% acc │ 4,587 tokens
|
||||
XML ██████████░░░░░░░░░░ 13.8 acc%/1K tok │ 72.1% acc │ 5,221 tokens
|
||||
```
|
||||
|
||||
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
|
||||
|
||||
> [!TIP]
|
||||
> TOON achieves **73.9%** accuracy (vs JSON's 69.7%) while using **39.6% fewer tokens**.
|
||||
> TOON achieves **76.4%** accuracy (vs JSON's 75.0%) while using **39.9% fewer tokens**.
|
||||
|
||||
**Note on CSV:** Excluded from ranking as it only supports 109 of 209 questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.
|
||||
|
||||
@@ -63,13 +63,13 @@ claude-haiku-4-5-20251001
|
||||
JSON compact ███████████░░░░░░░░░ 55.0% (115/209)
|
||||
CSV ██████████░░░░░░░░░░ 50.5% (55/109)
|
||||
|
||||
gemini-2.5-flash
|
||||
→ TOON ██████████████████░░ 87.6% (183/209)
|
||||
CSV █████████████████░░░ 86.2% (94/109)
|
||||
JSON compact ████████████████░░░░ 82.3% (172/209)
|
||||
YAML ████████████████░░░░ 79.4% (166/209)
|
||||
XML ████████████████░░░░ 79.4% (166/209)
|
||||
JSON ███████████████░░░░░ 77.0% (161/209)
|
||||
gemini-3-flash-preview
|
||||
XML ████████████████████ 98.1% (205/209)
|
||||
JSON ███████████████████░ 97.1% (203/209)
|
||||
YAML ███████████████████░ 97.1% (203/209)
|
||||
→ TOON ███████████████████░ 96.7% (202/209)
|
||||
JSON compact ███████████████████░ 96.7% (202/209)
|
||||
CSV ███████████████████░ 96.3% (105/109)
|
||||
|
||||
gpt-5-nano
|
||||
→ TOON ██████████████████░░ 90.9% (190/209)
|
||||
@@ -79,30 +79,30 @@ gpt-5-nano
|
||||
YAML █████████████████░░░ 87.1% (182/209)
|
||||
XML ████████████████░░░░ 80.9% (169/209)
|
||||
|
||||
grok-4-fast-non-reasoning
|
||||
→ TOON ███████████░░░░░░░░░ 57.4% (120/209)
|
||||
JSON ███████████░░░░░░░░░ 55.5% (116/209)
|
||||
JSON compact ███████████░░░░░░░░░ 54.5% (114/209)
|
||||
YAML ███████████░░░░░░░░░ 53.6% (112/209)
|
||||
XML ███████████░░░░░░░░░ 52.6% (110/209)
|
||||
CSV ██████████░░░░░░░░░░ 52.3% (57/109)
|
||||
grok-4-1-fast-non-reasoning
|
||||
→ TOON ████████████░░░░░░░░ 58.4% (122/209)
|
||||
YAML ████████████░░░░░░░░ 57.9% (121/209)
|
||||
JSON ███████████░░░░░░░░░ 56.5% (118/209)
|
||||
XML ███████████░░░░░░░░░ 54.1% (113/209)
|
||||
JSON compact ██████████░░░░░░░░░░ 52.2% (109/209)
|
||||
CSV ██████████░░░░░░░░░░ 51.4% (56/109)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> TOON achieves **73.9% accuracy** (vs JSON's 69.7%) while using **39.6% fewer tokens** on these datasets.
|
||||
> TOON achieves **76.4% accuracy** (vs JSON's 75.0%) while using **39.9% fewer tokens** on these datasets.
|
||||
|
||||
<details>
|
||||
<summary><strong>Performance by dataset, model, and question type</strong></summary>
|
||||
|
||||
#### Performance by Question Type
|
||||
|
||||
| Question Type | TOON | JSON compact | JSON | CSV | YAML | XML |
|
||||
| Question Type | TOON | JSON | YAML | JSON compact | XML | CSV |
|
||||
| ------------- | ---- | ---- | ---- | ---- | ---- | ---- |
|
||||
| Field Retrieval | 99.6% | 99.3% | 99.3% | 100.0% | 98.2% | 98.9% |
|
||||
| Aggregation | 54.4% | 47.2% | 48.8% | 44.0% | 47.6% | 41.3% |
|
||||
| Filtering | 56.3% | 57.3% | 50.5% | 49.1% | 51.0% | 47.9% |
|
||||
| Structure Awareness | 88.0% | 83.0% | 83.0% | 85.9% | 80.0% | 80.0% |
|
||||
| Structural Validation | 70.0% | 45.0% | 50.0% | 80.0% | 60.0% | 80.0% |
|
||||
| Field Retrieval | 99.6% | 99.3% | 98.5% | 98.5% | 98.9% | 100.0% |
|
||||
| Aggregation | 61.9% | 61.9% | 59.9% | 58.3% | 54.4% | 50.9% |
|
||||
| Filtering | 56.8% | 53.1% | 56.3% | 55.2% | 51.6% | 50.9% |
|
||||
| Structure Awareness | 89.0% | 87.0% | 84.0% | 84.0% | 81.0% | 85.9% |
|
||||
| Structural Validation | 70.0% | 60.0% | 60.0% | 55.0% | 85.0% | 80.0% |
|
||||
|
||||
#### Performance by Dataset
|
||||
|
||||
@@ -110,119 +110,119 @@ grok-4-fast-non-reasoning
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 72.0% | 2,352 | 118/164 |
|
||||
| `toon` | 73.8% | 2,518 | 121/164 |
|
||||
| `json-compact` | 69.5% | 3,953 | 114/164 |
|
||||
| `yaml` | 68.3% | 4,982 | 112/164 |
|
||||
| `json-pretty` | 68.3% | 6,360 | 112/164 |
|
||||
| `xml` | 69.5% | 7,324 | 114/164 |
|
||||
| `csv` | 73.2% | 2,334 | 120/164 |
|
||||
| `toon` | 73.2% | 2,498 | 120/164 |
|
||||
| `json-compact` | 73.8% | 3,924 | 121/164 |
|
||||
| `yaml` | 73.8% | 4,959 | 121/164 |
|
||||
| `json-pretty` | 73.8% | 6,331 | 121/164 |
|
||||
| `xml` | 74.4% | 7,296 | 122/164 |
|
||||
|
||||
##### E-commerce orders with nested structures
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 81.1% | 7,232 | 133/164 |
|
||||
| `json-compact` | 76.8% | 6,794 | 126/164 |
|
||||
| `yaml` | 75.6% | 8,347 | 124/164 |
|
||||
| `json-pretty` | 76.2% | 10,713 | 125/164 |
|
||||
| `xml` | 74.4% | 12,023 | 122/164 |
|
||||
| `toon` | 82.3% | 7,458 | 135/164 |
|
||||
| `json-compact` | 78.7% | 7,110 | 129/164 |
|
||||
| `yaml` | 79.9% | 8,755 | 131/164 |
|
||||
| `json-pretty` | 79.3% | 11,234 | 130/164 |
|
||||
| `xml` | 77.4% | 12,649 | 127/164 |
|
||||
|
||||
##### Time-series analytics data
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 73.3% | 1,406 | 88/120 |
|
||||
| `toon` | 72.5% | 1,548 | 87/120 |
|
||||
| `json-compact` | 71.7% | 2,349 | 86/120 |
|
||||
| `yaml` | 71.7% | 2,949 | 86/120 |
|
||||
| `json-pretty` | 68.3% | 3,676 | 82/120 |
|
||||
| `xml` | 68.3% | 4,384 | 82/120 |
|
||||
| `csv` | 75.0% | 1,411 | 90/120 |
|
||||
| `toon` | 78.3% | 1,553 | 94/120 |
|
||||
| `json-compact` | 74.2% | 2,354 | 89/120 |
|
||||
| `yaml` | 75.8% | 2,954 | 91/120 |
|
||||
| `json-pretty` | 75.0% | 3,681 | 90/120 |
|
||||
| `xml` | 72.5% | 4,389 | 87/120 |
|
||||
|
||||
##### Top 100 GitHub repositories
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 62.9% | 8,779 | 83/132 |
|
||||
| `csv` | 61.4% | 8,527 | 81/132 |
|
||||
| `yaml` | 59.8% | 13,141 | 79/132 |
|
||||
| `json-compact` | 55.3% | 11,464 | 73/132 |
|
||||
| `json-pretty` | 56.1% | 15,157 | 74/132 |
|
||||
| `xml` | 48.5% | 17,105 | 64/132 |
|
||||
| `csv` | 65.9% | 8,527 | 87/132 |
|
||||
| `toon` | 66.7% | 8,779 | 88/132 |
|
||||
| `yaml` | 65.2% | 13,141 | 86/132 |
|
||||
| `json-compact` | 59.8% | 11,464 | 79/132 |
|
||||
| `json-pretty` | 63.6% | 15,157 | 84/132 |
|
||||
| `xml` | 56.1% | 17,105 | 74/132 |
|
||||
|
||||
##### Semi-uniform event logs
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `json-compact` | 63.3% | 4,819 | 76/120 |
|
||||
| `toon` | 57.5% | 5,799 | 69/120 |
|
||||
| `json-pretty` | 59.2% | 6,797 | 71/120 |
|
||||
| `yaml` | 48.3% | 5,827 | 58/120 |
|
||||
| `xml` | 46.7% | 7,709 | 56/120 |
|
||||
| `json-compact` | 68.3% | 4,839 | 82/120 |
|
||||
| `toon` | 65.0% | 5,819 | 78/120 |
|
||||
| `json-pretty` | 69.2% | 6,817 | 83/120 |
|
||||
| `yaml` | 61.7% | 5,847 | 74/120 |
|
||||
| `xml` | 58.3% | 7,729 | 70/120 |
|
||||
|
||||
##### Deeply nested configuration
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `json-compact` | 92.2% | 574 | 107/116 |
|
||||
| `toon` | 95.7% | 666 | 111/116 |
|
||||
| `yaml` | 91.4% | 686 | 106/116 |
|
||||
| `json-pretty` | 94.0% | 932 | 109/116 |
|
||||
| `xml` | 92.2% | 1,018 | 107/116 |
|
||||
| `json-compact` | 90.5% | 568 | 105/116 |
|
||||
| `toon` | 94.8% | 655 | 110/116 |
|
||||
| `yaml` | 93.1% | 675 | 108/116 |
|
||||
| `json-pretty` | 92.2% | 924 | 107/116 |
|
||||
| `xml` | 91.4% | 1,013 | 106/116 |
|
||||
|
||||
##### Valid complete dataset (control)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 100.0% | 544 | 4/4 |
|
||||
| `json-compact` | 100.0% | 795 | 4/4 |
|
||||
| `yaml` | 100.0% | 1,003 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,282 | 4/4 |
|
||||
| `csv` | 25.0% | 492 | 1/4 |
|
||||
| `xml` | 0.0% | 1,467 | 0/4 |
|
||||
| `toon` | 100.0% | 535 | 4/4 |
|
||||
| `json-compact` | 100.0% | 787 | 4/4 |
|
||||
| `yaml` | 100.0% | 992 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,274 | 4/4 |
|
||||
| `xml` | 25.0% | 1,462 | 1/4 |
|
||||
| `csv` | 0.0% | 483 | 0/4 |
|
||||
|
||||
##### Array truncated: 3 rows removed from end
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 425 | 4/4 |
|
||||
| `xml` | 100.0% | 1,251 | 4/4 |
|
||||
| `toon` | 0.0% | 474 | 0/4 |
|
||||
| `json-compact` | 0.0% | 681 | 0/4 |
|
||||
| `json-pretty` | 0.0% | 1,096 | 0/4 |
|
||||
| `yaml` | 0.0% | 859 | 0/4 |
|
||||
| `csv` | 100.0% | 413 | 4/4 |
|
||||
| `xml` | 100.0% | 1,243 | 4/4 |
|
||||
| `toon` | 0.0% | 462 | 0/4 |
|
||||
| `json-pretty` | 0.0% | 1,085 | 0/4 |
|
||||
| `yaml` | 0.0% | 843 | 0/4 |
|
||||
| `json-compact` | 0.0% | 670 | 0/4 |
|
||||
|
||||
##### Extra rows added beyond declared length
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 566 | 4/4 |
|
||||
| `toon` | 75.0% | 621 | 3/4 |
|
||||
| `xml` | 100.0% | 1,692 | 4/4 |
|
||||
| `yaml` | 75.0% | 1,157 | 3/4 |
|
||||
| `json-compact` | 50.0% | 917 | 2/4 |
|
||||
| `json-pretty` | 50.0% | 1,476 | 2/4 |
|
||||
| `csv` | 100.0% | 550 | 4/4 |
|
||||
| `toon` | 75.0% | 605 | 3/4 |
|
||||
| `json-compact` | 75.0% | 901 | 3/4 |
|
||||
| `xml` | 100.0% | 1,678 | 4/4 |
|
||||
| `yaml` | 75.0% | 1,138 | 3/4 |
|
||||
| `json-pretty` | 50.0% | 1,460 | 2/4 |
|
||||
|
||||
##### Inconsistent field count (missing salary in row 10)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 75.0% | 489 | 3/4 |
|
||||
| `yaml` | 100.0% | 996 | 4/4 |
|
||||
| `toon` | 100.0% | 1,019 | 4/4 |
|
||||
| `json-compact` | 75.0% | 790 | 3/4 |
|
||||
| `xml` | 100.0% | 1,458 | 4/4 |
|
||||
| `json-pretty` | 75.0% | 1,274 | 3/4 |
|
||||
| `csv` | 100.0% | 480 | 4/4 |
|
||||
| `json-compact` | 100.0% | 782 | 4/4 |
|
||||
| `yaml` | 100.0% | 985 | 4/4 |
|
||||
| `toon` | 100.0% | 1,008 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,266 | 4/4 |
|
||||
| `xml` | 100.0% | 1,453 | 4/4 |
|
||||
|
||||
##### Missing required fields (no email in multiple rows)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 329 | 4/4 |
|
||||
| `xml` | 100.0% | 1,411 | 4/4 |
|
||||
| `toon` | 75.0% | 983 | 3/4 |
|
||||
| `yaml` | 25.0% | 960 | 1/4 |
|
||||
| `json-pretty` | 25.0% | 1,230 | 1/4 |
|
||||
| `json-compact` | 0.0% | 755 | 0/4 |
|
||||
| `csv` | 100.0% | 340 | 4/4 |
|
||||
| `xml` | 100.0% | 1,409 | 4/4 |
|
||||
| `toon` | 75.0% | 974 | 3/4 |
|
||||
| `json-pretty` | 50.0% | 1,225 | 2/4 |
|
||||
| `yaml` | 25.0% | 951 | 1/4 |
|
||||
| `json-compact` | 0.0% | 750 | 0/4 |
|
||||
|
||||
#### Performance by Model
|
||||
|
||||
@@ -237,16 +237,16 @@ grok-4-fast-non-reasoning
|
||||
| `json-compact` | 55.0% | 115/209 |
|
||||
| `csv` | 50.5% | 55/109 |
|
||||
|
||||
##### gemini-2.5-flash
|
||||
##### gemini-3-flash-preview
|
||||
|
||||
| Format | Accuracy | Correct/Total |
|
||||
| ------ | -------- | ------------- |
|
||||
| `toon` | 87.6% | 183/209 |
|
||||
| `csv` | 86.2% | 94/109 |
|
||||
| `json-compact` | 82.3% | 172/209 |
|
||||
| `yaml` | 79.4% | 166/209 |
|
||||
| `xml` | 79.4% | 166/209 |
|
||||
| `json-pretty` | 77.0% | 161/209 |
|
||||
| `xml` | 98.1% | 205/209 |
|
||||
| `json-pretty` | 97.1% | 203/209 |
|
||||
| `yaml` | 97.1% | 203/209 |
|
||||
| `toon` | 96.7% | 202/209 |
|
||||
| `json-compact` | 96.7% | 202/209 |
|
||||
| `csv` | 96.3% | 105/109 |
|
||||
|
||||
##### gpt-5-nano
|
||||
|
||||
@@ -259,16 +259,16 @@ grok-4-fast-non-reasoning
|
||||
| `yaml` | 87.1% | 182/209 |
|
||||
| `xml` | 80.9% | 169/209 |
|
||||
|
||||
##### grok-4-fast-non-reasoning
|
||||
##### grok-4-1-fast-non-reasoning
|
||||
|
||||
| Format | Accuracy | Correct/Total |
|
||||
| ------ | -------- | ------------- |
|
||||
| `toon` | 57.4% | 120/209 |
|
||||
| `json-pretty` | 55.5% | 116/209 |
|
||||
| `json-compact` | 54.5% | 114/209 |
|
||||
| `yaml` | 53.6% | 112/209 |
|
||||
| `xml` | 52.6% | 110/209 |
|
||||
| `csv` | 52.3% | 57/109 |
|
||||
| `toon` | 58.4% | 122/209 |
|
||||
| `yaml` | 57.9% | 121/209 |
|
||||
| `json-pretty` | 56.5% | 118/209 |
|
||||
| `xml` | 54.1% | 113/209 |
|
||||
| `json-compact` | 52.2% | 109/209 |
|
||||
| `csv` | 51.4% | 56/109 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -327,13 +327,13 @@ Eleven datasets designed to test different structural patterns and validation ca
|
||||
|
||||
#### Evaluation Process
|
||||
|
||||
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON compact, JSON, CSV, YAML, XML).
|
||||
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON, YAML, JSON compact, XML, CSV).
|
||||
2. **Query LLM**: Each model receives formatted data + question in a prompt and extracts the answer.
|
||||
3. **Validate deterministically**: Answers are validated using type-aware comparison (e.g., `50000` = `$50,000`, `Engineering` = `engineering`, `2025-01-01` = `January 1, 2025`) without requiring an LLM judge.
|
||||
|
||||
#### Models & Configuration
|
||||
|
||||
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-2.5-flash`, `gpt-5-nano`, `grok-4-fast-non-reasoning`
|
||||
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-3-flash-preview`, `gpt-5-nano`, `grok-4-1-fast-non-reasoning`
|
||||
- **Token counting**: Using `gpt-tokenizer` with `o200k_base` encoding (GPT-5 tokenizer)
|
||||
- **Temperature**: Not set (models use their defaults)
|
||||
- **Total evaluations**: 209 questions × 6 formats × 4 models = 5,016 LLM calls
|
||||
|
||||
@@ -5,34 +5,34 @@ Datasets with nested or semi-uniform structures. CSV excluded as it cannot prope
|
||||
```
|
||||
🛒 E-commerce orders with nested structures ┊ Tabular: 33%
|
||||
│
|
||||
TOON █████████████░░░░░░░ 72,771 tokens
|
||||
├─ vs JSON (−33.1%) 108,806 tokens
|
||||
├─ vs JSON compact (+5.5%) 68,975 tokens
|
||||
├─ vs YAML (−14.2%) 84,780 tokens
|
||||
└─ vs XML (−40.5%) 122,406 tokens
|
||||
TOON █████████████░░░░░░░ 73,126 tokens
|
||||
├─ vs JSON (−33.3%) 109,599 tokens
|
||||
├─ vs JSON compact (+5.3%) 69,459 tokens
|
||||
├─ vs YAML (−14.4%) 85,415 tokens
|
||||
└─ vs XML (−40.7%) 123,344 tokens
|
||||
|
||||
🧾 Semi-uniform event logs ┊ Tabular: 50%
|
||||
│
|
||||
TOON █████████████████░░░ 153,211 tokens
|
||||
├─ vs JSON (−15.0%) 180,176 tokens
|
||||
├─ vs JSON compact (+19.9%) 127,731 tokens
|
||||
├─ vs YAML (−0.8%) 154,505 tokens
|
||||
└─ vs XML (−25.2%) 204,777 tokens
|
||||
TOON █████████████████░░░ 154,084 tokens
|
||||
├─ vs JSON (−15.0%) 181,201 tokens
|
||||
├─ vs JSON compact (+19.9%) 128,529 tokens
|
||||
├─ vs YAML (−0.8%) 155,397 tokens
|
||||
└─ vs XML (−25.2%) 205,859 tokens
|
||||
|
||||
🧩 Deeply nested configuration ┊ Tabular: 0%
|
||||
│
|
||||
TOON ██████████████░░░░░░ 631 tokens
|
||||
├─ vs JSON (−31.3%) 919 tokens
|
||||
├─ vs JSON compact (+11.9%) 564 tokens
|
||||
├─ vs YAML (−6.2%) 673 tokens
|
||||
└─ vs XML (−37.4%) 1,008 tokens
|
||||
TOON ██████████████░░░░░░ 620 tokens
|
||||
├─ vs JSON (−31.9%) 911 tokens
|
||||
├─ vs JSON compact (+11.1%) 558 tokens
|
||||
├─ vs YAML (−6.3%) 662 tokens
|
||||
└─ vs XML (−38.2%) 1,003 tokens
|
||||
|
||||
──────────────────────────────────── Total ────────────────────────────────────
|
||||
TOON ████████████████░░░░ 226,613 tokens
|
||||
├─ vs JSON (−21.8%) 289,901 tokens
|
||||
├─ vs JSON compact (+14.9%) 197,270 tokens
|
||||
├─ vs YAML (−5.6%) 239,958 tokens
|
||||
└─ vs XML (−31.0%) 328,191 tokens
|
||||
TOON ████████████████░░░░ 227,830 tokens
|
||||
├─ vs JSON (−21.9%) 291,711 tokens
|
||||
├─ vs JSON compact (+14.7%) 198,546 tokens
|
||||
├─ vs YAML (−5.7%) 241,474 tokens
|
||||
└─ vs XML (−31.0%) 330,206 tokens
|
||||
```
|
||||
|
||||
#### Flat-Only Track
|
||||
@@ -42,21 +42,21 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
```
|
||||
👥 Uniform employee records ┊ Tabular: 100%
|
||||
│
|
||||
CSV ███████████████████░ 46,954 tokens
|
||||
TOON ████████████████████ 49,831 tokens (+6.1% vs CSV)
|
||||
├─ vs JSON (−60.7%) 126,860 tokens
|
||||
├─ vs JSON compact (−36.8%) 78,856 tokens
|
||||
├─ vs YAML (−50.0%) 99,706 tokens
|
||||
└─ vs XML (−66.0%) 146,444 tokens
|
||||
CSV ███████████████████░ 47,102 tokens
|
||||
TOON ████████████████████ 49,919 tokens (+6.0% vs CSV)
|
||||
├─ vs JSON (−60.7%) 127,063 tokens
|
||||
├─ vs JSON compact (−36.9%) 79,059 tokens
|
||||
├─ vs YAML (−50.1%) 100,011 tokens
|
||||
└─ vs XML (−65.9%) 146,579 tokens
|
||||
|
||||
📈 Time-series analytics data ┊ Tabular: 100%
|
||||
│
|
||||
CSV ██████████████████░░ 8,388 tokens
|
||||
TOON ████████████████████ 9,120 tokens (+8.7% vs CSV)
|
||||
├─ vs JSON (−59.0%) 22,250 tokens
|
||||
├─ vs JSON compact (−35.8%) 14,216 tokens
|
||||
├─ vs YAML (−48.9%) 17,863 tokens
|
||||
└─ vs XML (−65.7%) 26,621 tokens
|
||||
CSV ██████████████████░░ 8,383 tokens
|
||||
TOON ████████████████████ 9,115 tokens (+8.7% vs CSV)
|
||||
├─ vs JSON (−59.0%) 22,245 tokens
|
||||
├─ vs JSON compact (−35.9%) 14,211 tokens
|
||||
├─ vs YAML (−49.0%) 17,858 tokens
|
||||
└─ vs XML (−65.8%) 26,616 tokens
|
||||
|
||||
⭐ Top 100 GitHub repositories ┊ Tabular: 100%
|
||||
│
|
||||
@@ -68,12 +68,12 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
└─ vs XML (−48.9%) 17,095 tokens
|
||||
|
||||
──────────────────────────────────── Total ────────────────────────────────────
|
||||
CSV ███████████████████░ 63,854 tokens
|
||||
TOON ████████████████████ 67,695 tokens (+6.0% vs CSV)
|
||||
├─ vs JSON (−58.8%) 164,254 tokens
|
||||
├─ vs JSON compact (−35.2%) 104,526 tokens
|
||||
├─ vs YAML (−48.2%) 130,697 tokens
|
||||
└─ vs XML (−64.4%) 190,160 tokens
|
||||
CSV ███████████████████░ 63,997 tokens
|
||||
TOON ████████████████████ 67,778 tokens (+5.9% vs CSV)
|
||||
├─ vs JSON (−58.8%) 164,452 tokens
|
||||
├─ vs JSON compact (−35.3%) 104,724 tokens
|
||||
├─ vs YAML (−48.3%) 130,997 tokens
|
||||
└─ vs XML (−64.4%) 190,290 tokens
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -83,64 +83,64 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
|
||||
**Savings:** 13,130 tokens (59.0% reduction vs JSON)
|
||||
|
||||
**JSON** (22,250 tokens):
|
||||
**JSON** (22,245 tokens):
|
||||
|
||||
```json
|
||||
{
|
||||
"metrics": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"views": 5715,
|
||||
"clicks": 211,
|
||||
"conversions": 28,
|
||||
"revenue": 7976.46,
|
||||
"bounceRate": 0.47
|
||||
"views": 6138,
|
||||
"clicks": 174,
|
||||
"conversions": 12,
|
||||
"revenue": 2712.49,
|
||||
"bounceRate": 0.35
|
||||
},
|
||||
{
|
||||
"date": "2025-01-02",
|
||||
"views": 7103,
|
||||
"clicks": 393,
|
||||
"conversions": 28,
|
||||
"revenue": 8360.53,
|
||||
"bounceRate": 0.32
|
||||
"views": 4616,
|
||||
"clicks": 274,
|
||||
"conversions": 34,
|
||||
"revenue": 9156.29,
|
||||
"bounceRate": 0.56
|
||||
},
|
||||
{
|
||||
"date": "2025-01-03",
|
||||
"views": 7248,
|
||||
"clicks": 378,
|
||||
"conversions": 24,
|
||||
"revenue": 3212.57,
|
||||
"bounceRate": 0.5
|
||||
"views": 4460,
|
||||
"clicks": 143,
|
||||
"conversions": 8,
|
||||
"revenue": 1317.98,
|
||||
"bounceRate": 0.59
|
||||
},
|
||||
{
|
||||
"date": "2025-01-04",
|
||||
"views": 2927,
|
||||
"clicks": 77,
|
||||
"conversions": 11,
|
||||
"revenue": 1211.69,
|
||||
"bounceRate": 0.62
|
||||
"views": 4740,
|
||||
"clicks": 125,
|
||||
"conversions": 13,
|
||||
"revenue": 2934.77,
|
||||
"bounceRate": 0.37
|
||||
},
|
||||
{
|
||||
"date": "2025-01-05",
|
||||
"views": 3530,
|
||||
"clicks": 82,
|
||||
"conversions": 8,
|
||||
"revenue": 462.77,
|
||||
"bounceRate": 0.56
|
||||
"views": 6428,
|
||||
"clicks": 369,
|
||||
"conversions": 19,
|
||||
"revenue": 1317.24,
|
||||
"bounceRate": 0.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**TOON** (9,120 tokens):
|
||||
**TOON** (9,115 tokens):
|
||||
|
||||
```
|
||||
metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
|
||||
2025-01-01,5715,211,28,7976.46,0.47
|
||||
2025-01-02,7103,393,28,8360.53,0.32
|
||||
2025-01-03,7248,378,24,3212.57,0.5
|
||||
2025-01-04,2927,77,11,1211.69,0.62
|
||||
2025-01-05,3530,82,8,462.77,0.56
|
||||
2025-01-01,6138,174,12,2712.49,0.35
|
||||
2025-01-02,4616,274,34,9156.29,0.56
|
||||
2025-01-03,4460,143,8,1317.98,0.59
|
||||
2025-01-04,4740,125,13,2934.77,0.37
|
||||
2025-01-05,6428,369,19,1317.24,0.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Question } from '../src/types'
|
||||
import type { Question } from '../src/types.ts'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import * as prompts from '@clack/prompts'
|
||||
import PQueue from 'p-queue'
|
||||
import { BENCHMARKS_DIR, DEFAULT_CONCURRENCY, DRY_RUN, DRY_RUN_LIMITS, MODEL_RPM_LIMITS, ROOT_DIR } from '../src/constants'
|
||||
import { ACCURACY_DATASETS } from '../src/datasets'
|
||||
import { evaluateQuestion, models } from '../src/evaluate'
|
||||
import { formatters, supportsCSV } from '../src/formatters'
|
||||
import { generateQuestions } from '../src/questions'
|
||||
import { calculateFormatResults, calculateTokenCounts, generateAccuracyReport } from '../src/report'
|
||||
import { getAllModelResults, hasModelResults, saveModelResults } from '../src/storage'
|
||||
import { ensureDir } from '../src/utils'
|
||||
import { BENCHMARKS_DIR, DEFAULT_CONCURRENCY, DRY_RUN, DRY_RUN_LIMITS, MODEL_RPM_LIMITS, ROOT_DIR } from '../src/constants.ts'
|
||||
import { ACCURACY_DATASETS } from '../src/datasets.ts'
|
||||
import { evaluateQuestion, models } from '../src/evaluate.ts'
|
||||
import { formatters, supportsCSV } from '../src/formatters.ts'
|
||||
import { generateQuestions } from '../src/questions/index.ts'
|
||||
import { calculateFormatResults, calculateTokenCounts, generateAccuracyReport } from '../src/report.ts'
|
||||
import { getAllModelResults, hasModelResults, saveModelResults } from '../src/storage.ts'
|
||||
import { ensureDir } from '../src/utils.ts'
|
||||
|
||||
// Constants
|
||||
const PROGRESS_UPDATE_INTERVAL = 10
|
||||
|
||||
@@ -4,8 +4,8 @@ import process from 'node:process'
|
||||
import * as prompts from '@clack/prompts'
|
||||
import { ofetch } from 'ofetch'
|
||||
import pMap from 'p-map'
|
||||
import { BENCHMARKS_DIR } from '../src/constants'
|
||||
import { ensureDir } from '../src/utils'
|
||||
import { BENCHMARKS_DIR } from '../src/constants.ts'
|
||||
import { ensureDir } from '../src/utils.ts'
|
||||
|
||||
prompts.intro('GitHub Repositories Fetcher')
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Dataset } from '../src/types'
|
||||
import type { Dataset } from '../src/types.ts'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as prompts from '@clack/prompts'
|
||||
import { encode } from '../../packages/toon/src'
|
||||
import { BENCHMARKS_DIR, FORMATTER_DISPLAY_NAMES, ROOT_DIR } from '../src/constants'
|
||||
import { TOKEN_EFFICIENCY_DATASETS } from '../src/datasets'
|
||||
import { formatters, supportsCSV } from '../src/formatters'
|
||||
import { createProgressBar, ensureDir, tokenize } from '../src/utils'
|
||||
import { encode } from '../../packages/toon/src/index.ts'
|
||||
import { BENCHMARKS_DIR, FORMATTER_DISPLAY_NAMES, ROOT_DIR } from '../src/constants.ts'
|
||||
import { TOKEN_EFFICIENCY_DATASETS } from '../src/datasets.ts'
|
||||
import { formatters, supportsCSV } from '../src/formatters.ts'
|
||||
import { createProgressBar, ensureDir, tokenize } from '../src/utils.ts'
|
||||
|
||||
interface FormatMetrics {
|
||||
name: string
|
||||
|
||||
@@ -34,9 +34,9 @@ export const DRY_RUN_LIMITS = {
|
||||
/// keep-sorted
|
||||
export const MODEL_RPM_LIMITS: Record<string, number | undefined> = {
|
||||
'claude-haiku-4-5-20251001': 50,
|
||||
'gemini-2.5-flash': 25,
|
||||
'gemini-3-flash-preview': 25,
|
||||
'gpt-5-nano': 50,
|
||||
'grok-4-fast-non-reasoning': 50,
|
||||
'grok-4-1-fast-non-reasoning': 25,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dataset } from './types'
|
||||
import type { Dataset } from './types.ts'
|
||||
import { faker } from '@faker-js/faker'
|
||||
import githubRepos from '../data/github-repos.json' with { type: 'json' }
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { LanguageModelV2 } from '@ai-sdk/provider'
|
||||
import type { EvaluationResult, Question } from './types'
|
||||
import type { LanguageModelV3 } from '@ai-sdk/provider'
|
||||
import type { EvaluationResult, Question } from './types.ts'
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import { xai } from '@ai-sdk/xai'
|
||||
import { generateText } from 'ai'
|
||||
import { compareAnswers } from './normalize'
|
||||
import { compareAnswers } from './normalize.ts'
|
||||
|
||||
/**
|
||||
* Models used for evaluation
|
||||
*/
|
||||
export const models: LanguageModelV2[] = [
|
||||
export const models: LanguageModelV3[] = [
|
||||
anthropic('claude-haiku-4-5-20251001'),
|
||||
google('gemini-2.5-flash'),
|
||||
google('gemini-3-flash-preview'),
|
||||
openai('gpt-5-nano'),
|
||||
xai('grok-4-fast-non-reasoning'),
|
||||
xai('grok-4-1-fast-non-reasoning'),
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ export async function evaluateQuestion(
|
||||
question: Question
|
||||
formatName: string
|
||||
formattedData: string
|
||||
model: LanguageModelV2
|
||||
model: LanguageModelV3
|
||||
},
|
||||
): Promise<EvaluationResult> {
|
||||
const primer = PRIMERS[formatName] ?? ''
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Dataset } from './types'
|
||||
import type { Dataset } from './types.ts'
|
||||
import { stringify as stringifyCSV } from 'csv-stringify/sync'
|
||||
import { XMLBuilder } from 'fast-xml-parser'
|
||||
import { stringify as stringifyYAML } from 'yaml'
|
||||
import { encode as encodeToon } from '../../packages/toon/src'
|
||||
import { encode as encodeToon } from '../../packages/toon/src/index.ts'
|
||||
|
||||
/**
|
||||
* Format converters registry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AnalyticsMetric } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
|
||||
import type { AnalyticsMetric } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate analytics (website metrics) questions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { EventLog } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QUESTION_LIMITS } from '../constants'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
|
||||
import type { EventLog } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QUESTION_LIMITS } from '../constants.ts'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate event log questions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Repository } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
|
||||
import type { Repository } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate GitHub repository questions
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { AnalyticsMetric, Employee, EventLog, NestedConfig, Order, Repository } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { ACCURACY_DATASETS } from '../datasets'
|
||||
import { generateAnalyticsQuestions } from './analytics'
|
||||
import { generateEventLogsQuestions } from './event-logs'
|
||||
import { generateGithubQuestions } from './github'
|
||||
import { generateNestedQuestions } from './nested'
|
||||
import { generateNestedConfigQuestions } from './nested-config'
|
||||
import { generateStructuralValidationQuestions } from './structural-validation'
|
||||
import { generateStructureQuestions } from './structure'
|
||||
import { generateTabularQuestions } from './tabular'
|
||||
import { createIdGenerator } from './utils'
|
||||
import type { AnalyticsMetric, Employee, EventLog, NestedConfig, Order, Repository } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { ACCURACY_DATASETS } from '../datasets.ts'
|
||||
import { generateAnalyticsQuestions } from './analytics.ts'
|
||||
import { generateEventLogsQuestions } from './event-logs.ts'
|
||||
import { generateGithubQuestions } from './github.ts'
|
||||
import { generateNestedConfigQuestions } from './nested-config.ts'
|
||||
import { generateNestedQuestions } from './nested.ts'
|
||||
import { generateStructuralValidationQuestions } from './structural-validation.ts'
|
||||
import { generateStructureQuestions } from './structure.ts'
|
||||
import { generateTabularQuestions } from './tabular.ts'
|
||||
import { createIdGenerator } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate questions from all datasets
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NestedConfig } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QUESTION_LIMITS } from '../constants'
|
||||
import { QuestionBuilder } from './utils'
|
||||
import type { NestedConfig } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QUESTION_LIMITS } from '../constants.ts'
|
||||
import { QuestionBuilder } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate nested configuration questions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Order } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
|
||||
import type { Order } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate nested (orders) questions
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Question } from '../types'
|
||||
import { QuestionBuilder } from './utils'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QuestionBuilder } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate structural validation questions for all incompleteness fixtures
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AnalyticsMetric, Employee, EventLog, Order, Repository } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QuestionBuilder } from './utils'
|
||||
import type { AnalyticsMetric, Employee, EventLog, Order, Repository } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QuestionBuilder } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate structure-awareness questions across all datasets
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Employee } from '../datasets'
|
||||
import type { Question } from '../types'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils'
|
||||
import type { Employee } from '../datasets.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
import { QUESTION_LIMITS, QUESTION_THRESHOLDS } from '../constants.ts'
|
||||
import { QuestionBuilder, rotateQuestions, SAMPLE_STRIDES } from './utils.ts'
|
||||
|
||||
/**
|
||||
* Generate tabular (employee) questions
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnswerType, NormalizationOptions } from '../normalize'
|
||||
import type { Question } from '../types'
|
||||
import type { AnswerType, NormalizationOptions } from '../normalize.ts'
|
||||
import type { Question } from '../types.ts'
|
||||
|
||||
// Constants for sampling strides
|
||||
export const SAMPLE_STRIDES = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Dataset, EfficiencyRanking, EvaluationResult, FormatResult, Question } from './types'
|
||||
import { FORMATTER_DISPLAY_NAMES, QUESTION_TYPE_LABELS, QUESTION_TYPES } from './constants'
|
||||
import { ACCURACY_DATASETS } from './datasets'
|
||||
import { models, PRIMERS } from './evaluate'
|
||||
import { supportsCSV } from './formatters'
|
||||
import { generateQuestions } from './questions'
|
||||
import { createProgressBar, tokenize } from './utils'
|
||||
import type { Dataset, EfficiencyRanking, EvaluationResult, FormatResult, Question } from './types.ts'
|
||||
import { FORMATTER_DISPLAY_NAMES, QUESTION_TYPE_LABELS, QUESTION_TYPES } from './constants.ts'
|
||||
import { ACCURACY_DATASETS } from './datasets.ts'
|
||||
import { models, PRIMERS } from './evaluate.ts'
|
||||
import { supportsCSV } from './formatters.ts'
|
||||
import { generateQuestions } from './questions/index.ts'
|
||||
import { createProgressBar, tokenize } from './utils.ts'
|
||||
|
||||
const EFFICIENCY_CHART_STYLE: 'vertical' | 'horizontal' = 'horizontal'
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Storage, StorageValue } from 'unstorage'
|
||||
import type { EvaluationResult } from './types'
|
||||
import type { EvaluationResult } from './types.ts'
|
||||
import * as path from 'node:path'
|
||||
import { createStorage } from 'unstorage'
|
||||
import fsDriver from 'unstorage/drivers/fs'
|
||||
import { BENCHMARKS_DIR } from './constants'
|
||||
import { BENCHMARKS_DIR } from './constants.ts'
|
||||
|
||||
/**
|
||||
* Storage instance for model results
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DATASET_NAMES, QUESTION_TYPES, STRUCTURE_CLASSES } from './constants'
|
||||
import type { AnswerType, NormalizationOptions } from './normalize'
|
||||
import type { DATASET_NAMES, QUESTION_TYPES, STRUCTURE_CLASSES } from './constants.ts'
|
||||
import type { AnswerType, NormalizationOptions } from './normalize.ts'
|
||||
|
||||
export type QuestionType = typeof QUESTION_TYPES[number]
|
||||
export type DatasetName = typeof DATASET_NAMES[number]
|
||||
|
||||
@@ -92,7 +92,7 @@ export default defineConfig({
|
||||
|
||||
footer: {
|
||||
message: 'Released under the <a href="https://opensource.org/licenses/MIT" target="_blank">MIT License</a>.',
|
||||
copyright: 'Copyright © 2025-PRESENT <a href="https://github.com/johannschopplich" target="_blank">Johann Schopplich</a>',
|
||||
copyright: 'Copyright © 2025-PRESENT <a href="https://johannschopplich.com" target="_blank">Johann Schopplich</a>',
|
||||
},
|
||||
|
||||
search: {
|
||||
|
||||
@@ -7,8 +7,11 @@ import { computed, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { DEFAULT_DELIMITER, encode } from '../../../../packages/toon/src'
|
||||
import VPInput from './VPInput.vue'
|
||||
|
||||
type JsonFormat = 'pretty-2' | 'pretty-4' | 'pretty-tab' | 'compact'
|
||||
|
||||
interface PlaygroundState extends Required<Pick<EncodeOptions, 'delimiter' | 'indent' | 'keyFolding' | 'flattenDepth'>> {
|
||||
json: string
|
||||
jsonFormat: JsonFormat
|
||||
}
|
||||
|
||||
const PRESETS = {
|
||||
@@ -70,20 +73,37 @@ const DELIMITER_OPTIONS: { value: Delimiter, label: string }[] = [
|
||||
{ value: '\t', label: 'Tab (\\t)' },
|
||||
{ value: '|', label: 'Pipe (|)' },
|
||||
]
|
||||
const JSON_FORMAT_OPTIONS: { value: JsonFormat, label: string, indent: string | number | undefined }[] = [
|
||||
{ value: 'pretty-2', label: 'Pretty (2 spaces)', indent: 2 },
|
||||
{ value: 'pretty-4', label: 'Pretty (4 spaces)', indent: 4 },
|
||||
{ value: 'pretty-tab', label: 'Pretty (tabs)', indent: '\t' },
|
||||
{ value: 'compact', label: 'Compact', indent: undefined },
|
||||
]
|
||||
const DEFAULT_JSON = JSON.stringify(PRESETS.hikes, undefined, 2)
|
||||
const SHARE_URL_LIMIT = 8 * 1024
|
||||
|
||||
// Input state
|
||||
const jsonInput = ref(DEFAULT_JSON)
|
||||
const jsonFormat = ref<JsonFormat>('pretty-2')
|
||||
const currentFormatIndent = computed(() =>
|
||||
JSON_FORMAT_OPTIONS.find(opt => opt.value === jsonFormat.value)?.indent,
|
||||
)
|
||||
const formattedJson = computed(() => {
|
||||
try {
|
||||
return formatJson(JSON.parse(jsonInput.value))
|
||||
}
|
||||
catch {
|
||||
return jsonInput.value
|
||||
}
|
||||
})
|
||||
|
||||
// Encoder options
|
||||
const delimiter = ref<Delimiter>(DEFAULT_DELIMITER)
|
||||
const indent = ref(2)
|
||||
const keyFolding = ref<'off' | 'safe'>('safe')
|
||||
const flattenDepth = ref(2)
|
||||
|
||||
const canShareState = ref(true)
|
||||
const hasCopiedUrl = ref(false)
|
||||
|
||||
const tokenizer = shallowRef<typeof import('gpt-tokenizer') | undefined>()
|
||||
|
||||
// Encoding output
|
||||
const encodingResult = computed(() => {
|
||||
try {
|
||||
const parsedInput = JSON.parse(jsonInput.value)
|
||||
@@ -104,12 +124,13 @@ const encodingResult = computed(() => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const toonOutput = computed(() => encodingResult.value.output)
|
||||
const error = computed(() => encodingResult.value.error)
|
||||
|
||||
// Token analysis
|
||||
const tokenizer = shallowRef<typeof import('gpt-tokenizer') | undefined>()
|
||||
const jsonTokens = computed(() =>
|
||||
tokenizer.value?.encode(jsonInput.value).length,
|
||||
tokenizer.value?.encode(formattedJson.value).length,
|
||||
)
|
||||
const toonTokens = computed(() =>
|
||||
tokenizer.value && toonOutput.value ? tokenizer.value.encode(toonOutput.value).length : undefined,
|
||||
@@ -125,17 +146,11 @@ const tokenSavings = computed(() => {
|
||||
return { diff, percent, sign, isSavings: diff > 0 }
|
||||
})
|
||||
|
||||
// UI state
|
||||
const canShareState = ref(true)
|
||||
const hasCopiedUrl = ref(false)
|
||||
|
||||
const { copy, copied } = useClipboard({ source: toonOutput })
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!canShareState.value)
|
||||
return
|
||||
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
hasCopiedUrl.value = true
|
||||
setTimeout(() => (hasCopiedUrl.value = false), 2000)
|
||||
}
|
||||
|
||||
const updateUrl = useDebounceFn(() => {
|
||||
const hash = encodeState()
|
||||
const baseUrl = `${window.location.origin}${window.location.pathname}${window.location.search}`
|
||||
@@ -150,10 +165,17 @@ const updateUrl = useDebounceFn(() => {
|
||||
window.history.replaceState(null, '', `#${hash}`)
|
||||
}, 300)
|
||||
|
||||
watch([jsonInput, delimiter, indent, keyFolding, flattenDepth], () => {
|
||||
watch([jsonInput, delimiter, indent, keyFolding, flattenDepth, jsonFormat], () => {
|
||||
updateUrl()
|
||||
})
|
||||
|
||||
watch(jsonFormat, () => {
|
||||
try {
|
||||
jsonInput.value = formatJson(JSON.parse(jsonInput.value))
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTokenizer()
|
||||
|
||||
@@ -168,9 +190,14 @@ onMounted(() => {
|
||||
indent.value = state.indent
|
||||
keyFolding.value = state.keyFolding ?? 'safe'
|
||||
flattenDepth.value = state.flattenDepth ?? 2
|
||||
jsonFormat.value = state.jsonFormat ?? 'pretty-2'
|
||||
}
|
||||
})
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
return JSON.stringify(value, undefined, currentFormatIndent.value)
|
||||
}
|
||||
|
||||
function encodeState() {
|
||||
const state: PlaygroundState = {
|
||||
json: jsonInput.value,
|
||||
@@ -178,6 +205,7 @@ function encodeState() {
|
||||
indent: indent.value,
|
||||
keyFolding: keyFolding.value,
|
||||
flattenDepth: flattenDepth.value,
|
||||
jsonFormat: jsonFormat.value,
|
||||
}
|
||||
|
||||
const compressedData = zlibSync(stringToUint8Array(JSON.stringify(state)))
|
||||
@@ -196,7 +224,16 @@ function decodeState(hash: string) {
|
||||
}
|
||||
|
||||
function loadPreset(name: keyof typeof PRESETS) {
|
||||
jsonInput.value = JSON.stringify(PRESETS[name], undefined, 2)
|
||||
jsonInput.value = formatJson(PRESETS[name])
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!canShareState.value)
|
||||
return
|
||||
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
hasCopiedUrl.value = true
|
||||
setTimeout(() => (hasCopiedUrl.value = false), 2000)
|
||||
}
|
||||
|
||||
async function loadTokenizer() {
|
||||
@@ -258,7 +295,7 @@ async function loadTokenizer() {
|
||||
<VPInput id="preset" label="Preset">
|
||||
<select id="preset" @change="(e) => loadPreset((e.target as HTMLSelectElement).value as keyof typeof PRESETS)">
|
||||
<option value="" disabled selected>
|
||||
Load example...
|
||||
Load example…
|
||||
</option>
|
||||
<option value="hikes">
|
||||
Hikes (mixed structure)
|
||||
@@ -275,6 +312,14 @@ async function loadTokenizer() {
|
||||
</select>
|
||||
</VPInput>
|
||||
|
||||
<VPInput id="jsonFormat" label="JSON Baseline">
|
||||
<select id="jsonFormat" v-model="jsonFormat">
|
||||
<option v-for="opt in JSON_FORMAT_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</VPInput>
|
||||
|
||||
<button
|
||||
class="share-button"
|
||||
:class="[hasCopiedUrl && 'copied']"
|
||||
@@ -307,8 +352,8 @@ async function loadTokenizer() {
|
||||
<div class="pane-header">
|
||||
<span class="pane-title">JSON Input</span>
|
||||
<span class="pane-stats">
|
||||
<span>{{ jsonTokens ?? '...' }} tokens</span>
|
||||
<span>{{ jsonInput.length }} chars</span>
|
||||
<span class="stat-primary" title="Token count using selected JSON baseline format">{{ jsonTokens ?? '…' }} tokens</span>
|
||||
<span class="stat-secondary">{{ formattedJson.length }} chars</span>
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -319,7 +364,7 @@ async function loadTokenizer() {
|
||||
aria-label="JSON input"
|
||||
:aria-describedby="error ? 'json-error' : undefined"
|
||||
:aria-invalid="!!error"
|
||||
placeholder="Enter JSON here..."
|
||||
placeholder="Enter JSON here…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -333,8 +378,8 @@ async function loadTokenizer() {
|
||||
</span>
|
||||
</span>
|
||||
<span class="pane-stats">
|
||||
<span>{{ toonTokens ?? '...' }} tokens</span>
|
||||
<span>{{ toonOutput.length }} chars</span>
|
||||
<span class="stat-primary">{{ toonTokens ?? '…' }} tokens</span>
|
||||
<span class="stat-secondary">{{ toonOutput.length }} chars</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-output">
|
||||
@@ -541,6 +586,15 @@ async function loadTokenizer() {
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.stat-primary {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stat-secondary {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.savings-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 6px;
|
||||
|
||||
+60
-22
@@ -1,8 +1,12 @@
|
||||
---
|
||||
description: Convert JSON to TOON and back from the command line, with token statistics, streaming, and delimiter options.
|
||||
---
|
||||
|
||||
# Command Line Interface
|
||||
|
||||
The `@toon-format/cli` package provides a command-line interface for encoding JSON to TOON and decoding TOON back to JSON. Use it to analyze token savings before integrating TOON into your application, or to process JSON data through TOON in shell pipelines using stdin/stdout with tools like curl and jq. The CLI supports token statistics, streaming for large datasets, and all encoding options available in the library.
|
||||
The `@toon-format/cli` package converts JSON to TOON and TOON to JSON. Use it to measure token savings before integrating TOON into your application, or to pipe JSON through TOON in shell workflows alongside tools like `curl` and `jq`. The CLI supports stdin/stdout, token statistics, streaming for large datasets, and every encoding option in the library.
|
||||
|
||||
The CLI is built on top of the `@toon-format/toon` TypeScript implementation and adheres to the [latest specification](/reference/spec).
|
||||
The CLI is built on the `@toon-format/toon` TypeScript implementation and follows the [latest specification](/reference/spec).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -120,7 +124,7 @@ Both encoding and decoding operations use streaming output, writing incrementall
|
||||
- Uses the same event-based streaming decoder as the `decodeStream` API in `@toon-format/toon`.
|
||||
- Streams JSON tokens to output.
|
||||
- No full JSON string in memory.
|
||||
- When `--expand-paths safe` is enabled, falls back to non-streaming decode internally to apply deep-merge expansion before writing JSON.
|
||||
- When `--expandPaths safe` is enabled, falls back to non-streaming decode internally to apply deep-merge expansion before writing JSON.
|
||||
|
||||
Process large files with minimal memory usage:
|
||||
|
||||
@@ -138,7 +142,7 @@ cat million-records.toon | toon --decode > output.json
|
||||
|
||||
Peak memory usage scales with data depth, not total size. This allows processing arbitrarily large files as long as individual nested structures fit in memory.
|
||||
|
||||
::: info Token Statistics
|
||||
::: tip Token Statistics
|
||||
When using the `--stats` flag with encode, the CLI builds the full TOON string once to compute accurate token counts. For maximum memory efficiency on very large files, omit `--stats`.
|
||||
:::
|
||||
|
||||
@@ -149,13 +153,14 @@ When using the `--stats` flag with encode, the CLI builds the full TOON string o
|
||||
| `-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), `\t` (tab), `\|` (pipe) |
|
||||
| `--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` | Disable strict validation when decoding |
|
||||
| `--key-folding <mode>` | Key folding mode: `off`, `safe` (default: `off`) |
|
||||
| `--flatten-depth <number>` | Maximum segments to fold (default: `Infinity`) – requires `--key-folding safe` |
|
||||
| `--expand-paths <mode>` | Path expansion mode: `off`, `safe` (default: `off`) |
|
||||
| `--keyFolding <mode>` | Key folding mode: `off`, `safe` (default: `off`) |
|
||||
| `--flattenDepth <number>` | Maximum segments to fold (default: `Infinity`) – requires `--keyFolding safe` |
|
||||
| `--expandPaths <mode>` | Path expansion mode: `off`, `safe` (default: `off`) |
|
||||
| `--verbose` | Show full stack traces and cause chains for errors (default: `false`) |
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
@@ -180,12 +185,12 @@ Example output:
|
||||
|
||||
### Alternative Delimiters
|
||||
|
||||
TOON supports three delimiters: comma (default), tab, and pipe. Alternative delimiters can provide additional token savings in specific contexts.
|
||||
TOON supports three delimiters: comma (default), tab, and pipe. Alternative delimiters can save additional tokens depending on the data.
|
||||
|
||||
::: code-group
|
||||
|
||||
```bash [Tab-separated]
|
||||
toon data.json --delimiter "\t" -o output.toon
|
||||
```bash [Tab-separated (bash/zsh)]
|
||||
toon data.json --delimiter $'\t' -o output.toon
|
||||
```
|
||||
|
||||
```bash [Pipe-separated]
|
||||
@@ -194,6 +199,8 @@ toon data.json --delimiter "|" -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.
|
||||
|
||||
**Tab delimiter example:**
|
||||
|
||||
::: code-group
|
||||
@@ -212,8 +219,9 @@ items[2]{id,name,qty,price}:
|
||||
|
||||
:::
|
||||
|
||||
> [!TIP]
|
||||
> Tab delimiters often tokenize more efficiently than commas and reduce the need for quote-escaping. Use `--delimiter "\t"` for maximum token savings on large tabular data.
|
||||
::: tip
|
||||
Tab delimiters often tokenize more efficiently than commas and reduce the need for quote-escaping. Use `--delimiter $'\t'` (bash/zsh) for maximum token savings on large tabular data. See [Delimiter Strategies](/reference/api#delimiter-strategies) for full guidance.
|
||||
:::
|
||||
|
||||
### Lenient Decoding
|
||||
|
||||
@@ -225,6 +233,36 @@ toon data.toon --no-strict -o output.json
|
||||
|
||||
Lenient mode (`--no-strict`) disables strict validation checks like array count matching, indentation multiples, and delimiter consistency. Use this when you trust the input and want faster decoding.
|
||||
|
||||
### 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.
|
||||
|
||||
For an input file that uses a tab to indent the second line (rendered here with `→`):
|
||||
|
||||
```
|
||||
a:
|
||||
→b: 1
|
||||
```
|
||||
|
||||
The CLI prints:
|
||||
|
||||
```
|
||||
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 – useful when filing a bug report or diagnosing an unexpected error path:
|
||||
|
||||
```bash
|
||||
cat broken.toon | toon --decode --verbose
|
||||
```
|
||||
|
||||
::: tip Programmatic Access
|
||||
Decode errors are thrown as `ToonDecodeError` instances by the library. The CLI's caret rendering is built on the structured `line` and `source` fields exposed on that class. See the [Error Handling](/reference/api#error-handling) section of the API reference if you want the same diagnostic detail in your own code.
|
||||
:::
|
||||
|
||||
### Stdin Workflows
|
||||
|
||||
The CLI integrates seamlessly with Unix pipes and other command-line tools:
|
||||
@@ -234,7 +272,7 @@ The CLI integrates seamlessly with Unix pipes and other command-line tools:
|
||||
curl https://api.example.com/data | toon --stats
|
||||
|
||||
# Process large dataset
|
||||
cat large-dataset.json | toon --delimiter "\t" > output.toon
|
||||
cat large-dataset.json | toon --delimiter $'\t' > output.toon
|
||||
|
||||
# Chain with jq
|
||||
jq '.results' data.json | toon > filtered.toon
|
||||
@@ -247,11 +285,11 @@ Collapse nested wrapper chains to reduce tokens (since spec v1.5):
|
||||
::: code-group
|
||||
|
||||
```bash [Basic key folding]
|
||||
toon input.json --key-folding safe -o output.toon
|
||||
toon input.json --keyFolding safe -o output.toon
|
||||
```
|
||||
|
||||
```bash [Limit folding depth]
|
||||
toon input.json --key-folding safe --flatten-depth 2 -o output.toon
|
||||
toon input.json --keyFolding safe --flattenDepth 2 -o output.toon
|
||||
```
|
||||
|
||||
:::
|
||||
@@ -270,7 +308,7 @@ For data like:
|
||||
}
|
||||
```
|
||||
|
||||
With `--key-folding safe`, output becomes:
|
||||
With `--keyFolding safe`, output becomes:
|
||||
|
||||
```yaml
|
||||
data.metadata.items[2]: a,b
|
||||
@@ -289,19 +327,19 @@ data:
|
||||
Reconstruct nested structure from folded keys when decoding:
|
||||
|
||||
```bash
|
||||
toon data.toon --expand-paths safe -o output.json
|
||||
toon data.toon --expandPaths safe -o output.json
|
||||
```
|
||||
|
||||
This pairs with `--key-folding safe` for lossless round-trips.
|
||||
This pairs with `--keyFolding safe` for lossless round-trips.
|
||||
|
||||
### Round-Trip Workflow
|
||||
|
||||
```bash
|
||||
# Encode with folding
|
||||
toon input.json --key-folding safe -o compressed.toon
|
||||
toon input.json --keyFolding safe -o compressed.toon
|
||||
|
||||
# Decode with expansion (restores original structure)
|
||||
toon compressed.toon --expand-paths safe -o output.json
|
||||
toon compressed.toon --expandPaths safe -o output.json
|
||||
|
||||
# Verify round-trip
|
||||
diff input.json output.json
|
||||
@@ -313,5 +351,5 @@ Combine multiple options for maximum efficiency:
|
||||
|
||||
```bash
|
||||
# Key folding + tab delimiter + stats
|
||||
toon data.json --key-folding safe --delimiter "\t" --stats -o output.toon
|
||||
toon data.json --keyFolding safe --delimiter $'\t' --stats -o output.toon
|
||||
```
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: Official and community TOON implementations across languages, plus contribution pointers.
|
||||
---
|
||||
|
||||
# Implementations
|
||||
|
||||
TOON has official and community implementations across multiple programming languages. All implementations are intended to conform to the same [Specification](https://github.com/toon-format/spec) to ensure compatibility and interoperability.
|
||||
@@ -20,6 +24,7 @@ These implementations are actively being developed by dedicated teams. Contribut
|
||||
| **Julia** | [ToonFormat.jl](https://github.com/toon-format/ToonFormat.jl) | ✅ Stable | Official Team |
|
||||
| **Python** | [toon-python](https://github.com/toon-format/toon-python) | ✅ Stable | Official Team |
|
||||
| **Rust** | [toon-rust](https://github.com/toon-format/toon-rust) | ✅ Stable | Official Team |
|
||||
| **Swift** | [toon-swift](https://github.com/toon-format/toon-swift) | ✅ Stable | Official Team |
|
||||
| **TypeScript/JavaScript** | [toon](https://github.com/toon-format/toon/tree/main/packages/toon) | ✅ Stable | Official Team |
|
||||
|
||||
## Community Implementations
|
||||
@@ -29,14 +34,17 @@ Community members have created implementations in additional languages:
|
||||
| Language | Repository | Maintainer |
|
||||
|----------|------------|------------|
|
||||
| **Apex** | [ApexToon](https://github.com/Eacaw/ApexToon) | [@Eacaw](https://github.com/Eacaw) |
|
||||
| **C** | [TOONc](https://github.com/UsboKirishima/TOONc) | [@UsboKirishima](https://github.com/UsboKirishima) |
|
||||
| **C++** | [ctoon](https://github.com/mohammadraziei/ctoon) | [@mohammadraziei](https://github.com/mohammadraziei) |
|
||||
| **C#** | [ToonEncoder](https://github.com/Cysharp/ToonEncoder) | [@Cysharp](https://github.com/Cysharp/ToonEncoder) |
|
||||
| **Clojure** | [toon](https://github.com/vadelabs/toon) | [@vadelabs](https://github.com/vadelabs) |
|
||||
| **Crystal** | [toon-crystal](https://github.com/mamantoha/toon-crystal) | [@mamantoha](https://github.com/mamantoha) |
|
||||
| **Elixir** | [toon_ex](https://github.com/kentaro/toon_ex) | [@kentaro](https://github.com/kentaro) |
|
||||
| **Gleam** | [toon_codec](https://github.com/axelbellec/toon_codec) | [@axelbellec](https://github.com/axelbellec) |
|
||||
| **Go** | [gotoon](https://github.com/alpkeskin/gotoon) | [@alpkeskin](https://github.com/alpkeskin) |
|
||||
| **Kotlin** | [kotlin-toon](https://github.com/vexpera-br/kotlin-toon) | [@vexpera-br](https://github.com/vexpera-br) |
|
||||
| **Laravel Framework** | [laravel-toon](https://github.com/jobmetric/laravel-toon) | [@jobmetric](https://github.com/jobmetric) |
|
||||
| **Java** | [json-io](https://github.com/jdereg/json-io) | [@jdereg](https://github.com/jdereg) |
|
||||
| **Kotlin** | [ktoon](https://github.com/lukelast/ktoon)| [@lukelast](https://github.com/lukelast) |
|
||||
| **Laravel Framework** | [laravel-toon](https://github.com/mischasigtermans/laravel-toon) | [@mischasigtermans](https://github.com/mischasigtermans) |
|
||||
| **Lua/Neovim** | [toon.nvim](https://github.com/thalesgelinger/toon.nvim) | [@thalesgelinger](https://github.com/thalesgelinger) |
|
||||
| **OCaml** | [ocaml-toon](https://github.com/davesnx/ocaml-toon) | [@davesnx](https://github.com/davesnx) |
|
||||
| **Perl** | [Data::TOON](https://github.com/ytnobody/p5-Data-TOON) | [@ytnobody](https://github.com/ytnobody) |
|
||||
@@ -44,7 +52,7 @@ Community members have created implementations in additional languages:
|
||||
| **R** | [toon](https://github.com/laresbernardo/toon) | [@laresbernardo](https://github.com/laresbernardo) |
|
||||
| **Ruby** | [toon-ruby](https://github.com/andrepcg/toon-ruby) | [@andrepcg](https://github.com/andrepcg) |
|
||||
| **Scala** | [toon4s](https://github.com/vim89/toon4s) | [@vim89](https://github.com/vim89) |
|
||||
| **Swift** | [TOONEncoder](https://github.com/mattt/TOONEncoder) | [@mattt](https://github.com/mattt) |
|
||||
| **Python** (Rust backend) | [toons](https://github.com/alesanfra/toons) | [@alesanfra](https://github.com/alesanfra) |
|
||||
|
||||
## Contributing an Implementation
|
||||
|
||||
@@ -52,5 +60,5 @@ Building a TOON implementation for a new language? Great! Here are some steps to
|
||||
|
||||
1. **Follow the spec**: Implement the [latest specification](https://github.com/toon-format/spec/blob/main/SPEC.md).
|
||||
2. **Add tests**: Run the [reference test suite](https://github.com/toon-format/spec/tree/main/tests).
|
||||
3. **Document usage**: Provide clear README with installation and usage examples.
|
||||
3. **Document usage**: Provide a clear README with installation and usage examples.
|
||||
4. **Share it**: Open a PR to add your implementation to the README at [github.com/toon-format/toon](https://github.com/toon-format/toon).
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Tools & Playgrounds
|
||||
---
|
||||
description: TOON playgrounds, CLI, editor support, and ecosystem tools.
|
||||
---
|
||||
|
||||
# Tools and Playgrounds
|
||||
|
||||
Experiment with TOON format interactively using these tools for token comparison, format conversion, and validation.
|
||||
|
||||
@@ -25,7 +29,7 @@ npx @toon-format/cli input.json --stats -o output.toon
|
||||
|
||||
### VS Code
|
||||
|
||||
[TOON Language Support](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) - Syntax highlighting, validation, conversion, and token analysis.
|
||||
[TOON Language Support](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) – Syntax highlighting, validation, conversion, and token analysis.
|
||||
|
||||
Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) or via command line:
|
||||
|
||||
@@ -35,16 +39,28 @@ code --install-extension vishalraut.vscode-toon
|
||||
|
||||
### Tree-sitter Grammar
|
||||
|
||||
[tree-sitter-toon](https://github.com/3swordman/tree-sitter-toon) - Grammar for Tree-sitter-compatible editors (Neovim, Helix, Emacs, Zed).
|
||||
[tree-sitter-toon](https://github.com/3swordman/tree-sitter-toon) – Grammar for Tree-sitter-compatible editors (Neovim, Helix, Emacs, Zed).
|
||||
|
||||
### Neovim
|
||||
|
||||
[toon.nvim](https://github.com/thalesgelinger/toon.nvim) - Lua-based plugin for Neovim.
|
||||
[toon.nvim](https://github.com/thalesgelinger/toon.nvim) – Lua-based plugin for Neovim.
|
||||
|
||||
### Other Editors
|
||||
|
||||
Use YAML syntax highlighting as a close approximation. Most editors allow associating `.toon` files with YAML language mode.
|
||||
|
||||
## Databases
|
||||
|
||||
### ToonStore
|
||||
|
||||
[ToonStore](https://github.com/Kalama-Tech/toonstoredb) – Redis-compatible embedded database (Rust) that stores data in TOON format.
|
||||
|
||||
## ORMs
|
||||
|
||||
### TORM
|
||||
|
||||
[TORM](https://github.com/Kalama-Tech/torm) – ORM that works with the ToonStore database, with SDKs for Node.js, Python, Go, and PHP.
|
||||
|
||||
## Web APIs
|
||||
|
||||
If you're building web applications that work with TOON, you can use the TypeScript library in the browser:
|
||||
|
||||
+180
-176
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: Retrieval accuracy and token efficiency results for TOON across mixed-structure and flat-only tracks.
|
||||
---
|
||||
|
||||
# Benchmarks
|
||||
|
||||
The benchmarks on this page measure TOON's performance across two key dimensions:
|
||||
@@ -52,17 +56,17 @@ Benchmarks test LLM comprehension across different input formats using 209 data
|
||||
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
|
||||
|
||||
```
|
||||
TOON ████████████████████ 26.9 acc%/1K tok │ 73.9% acc │ 2,744 tokens
|
||||
JSON compact █████████████████░░░ 22.9 acc%/1K tok │ 70.7% acc │ 3,081 tokens
|
||||
YAML ██████████████░░░░░░ 18.6 acc%/1K tok │ 69.0% acc │ 3,719 tokens
|
||||
JSON ███████████░░░░░░░░░ 15.3 acc%/1K tok │ 69.7% acc │ 4,545 tokens
|
||||
XML ██████████░░░░░░░░░░ 13.0 acc%/1K tok │ 67.1% acc │ 5,167 tokens
|
||||
TOON ████████████████████ 27.7 acc%/1K tok │ 76.4% acc │ 2,759 tokens
|
||||
JSON compact █████████████████░░░ 23.7 acc%/1K tok │ 73.7% acc │ 3,104 tokens
|
||||
YAML ██████████████░░░░░░ 19.9 acc%/1K tok │ 74.5% acc │ 3,749 tokens
|
||||
JSON ████████████░░░░░░░░ 16.4 acc%/1K tok │ 75.0% acc │ 4,587 tokens
|
||||
XML ██████████░░░░░░░░░░ 13.8 acc%/1K tok │ 72.1% acc │ 5,221 tokens
|
||||
```
|
||||
|
||||
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
|
||||
|
||||
> [!TIP]
|
||||
> TOON achieves **73.9%** accuracy (vs JSON's 69.7%) while using **39.6% fewer tokens**.
|
||||
> TOON achieves **76.4%** accuracy (vs JSON's 75.0%) while using **39.9% fewer tokens**.
|
||||
|
||||
**Note on CSV:** Excluded from ranking as it only supports 109 of 209 questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.
|
||||
|
||||
@@ -79,13 +83,13 @@ claude-haiku-4-5-20251001
|
||||
JSON compact ███████████░░░░░░░░░ 55.0% (115/209)
|
||||
CSV ██████████░░░░░░░░░░ 50.5% (55/109)
|
||||
|
||||
gemini-2.5-flash
|
||||
→ TOON ██████████████████░░ 87.6% (183/209)
|
||||
CSV █████████████████░░░ 86.2% (94/109)
|
||||
JSON compact ████████████████░░░░ 82.3% (172/209)
|
||||
YAML ████████████████░░░░ 79.4% (166/209)
|
||||
XML ████████████████░░░░ 79.4% (166/209)
|
||||
JSON ███████████████░░░░░ 77.0% (161/209)
|
||||
gemini-3-flash-preview
|
||||
XML ████████████████████ 98.1% (205/209)
|
||||
JSON ███████████████████░ 97.1% (203/209)
|
||||
YAML ███████████████████░ 97.1% (203/209)
|
||||
→ TOON ███████████████████░ 96.7% (202/209)
|
||||
JSON compact ███████████████████░ 96.7% (202/209)
|
||||
CSV ███████████████████░ 96.3% (105/109)
|
||||
|
||||
gpt-5-nano
|
||||
→ TOON ██████████████████░░ 90.9% (190/209)
|
||||
@@ -95,30 +99,30 @@ gpt-5-nano
|
||||
YAML █████████████████░░░ 87.1% (182/209)
|
||||
XML ████████████████░░░░ 80.9% (169/209)
|
||||
|
||||
grok-4-fast-non-reasoning
|
||||
→ TOON ███████████░░░░░░░░░ 57.4% (120/209)
|
||||
JSON ███████████░░░░░░░░░ 55.5% (116/209)
|
||||
JSON compact ███████████░░░░░░░░░ 54.5% (114/209)
|
||||
YAML ███████████░░░░░░░░░ 53.6% (112/209)
|
||||
XML ███████████░░░░░░░░░ 52.6% (110/209)
|
||||
CSV ██████████░░░░░░░░░░ 52.3% (57/109)
|
||||
grok-4-1-fast-non-reasoning
|
||||
→ TOON ████████████░░░░░░░░ 58.4% (122/209)
|
||||
YAML ████████████░░░░░░░░ 57.9% (121/209)
|
||||
JSON ███████████░░░░░░░░░ 56.5% (118/209)
|
||||
XML ███████████░░░░░░░░░ 54.1% (113/209)
|
||||
JSON compact ██████████░░░░░░░░░░ 52.2% (109/209)
|
||||
CSV ██████████░░░░░░░░░░ 51.4% (56/109)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> TOON achieves **73.9% accuracy** (vs JSON's 69.7%) while using **39.6% fewer tokens** on these datasets.
|
||||
> TOON achieves **76.4% accuracy** (vs JSON's 75.0%) while using **39.9% fewer tokens** on these datasets.
|
||||
|
||||
<details>
|
||||
<summary><strong>Performance by dataset, model, and question type</strong></summary>
|
||||
|
||||
#### Performance by Question Type
|
||||
|
||||
| Question Type | TOON | JSON compact | JSON | CSV | YAML | XML |
|
||||
| Question Type | TOON | JSON | YAML | JSON compact | XML | CSV |
|
||||
| ------------- | ---- | ---- | ---- | ---- | ---- | ---- |
|
||||
| Field Retrieval | 99.6% | 99.3% | 99.3% | 100.0% | 98.2% | 98.9% |
|
||||
| Aggregation | 54.4% | 47.2% | 48.8% | 44.0% | 47.6% | 41.3% |
|
||||
| Filtering | 56.3% | 57.3% | 50.5% | 49.1% | 51.0% | 47.9% |
|
||||
| Structure Awareness | 88.0% | 83.0% | 83.0% | 85.9% | 80.0% | 80.0% |
|
||||
| Structural Validation | 70.0% | 45.0% | 50.0% | 80.0% | 60.0% | 80.0% |
|
||||
| Field Retrieval | 99.6% | 99.3% | 98.5% | 98.5% | 98.9% | 100.0% |
|
||||
| Aggregation | 61.9% | 61.9% | 59.9% | 58.3% | 54.4% | 50.9% |
|
||||
| Filtering | 56.8% | 53.1% | 56.3% | 55.2% | 51.6% | 50.9% |
|
||||
| Structure Awareness | 89.0% | 87.0% | 84.0% | 84.0% | 81.0% | 85.9% |
|
||||
| Structural Validation | 70.0% | 60.0% | 60.0% | 55.0% | 85.0% | 80.0% |
|
||||
|
||||
#### Performance by Dataset
|
||||
|
||||
@@ -126,119 +130,119 @@ grok-4-fast-non-reasoning
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 72.0% | 2,352 | 118/164 |
|
||||
| `toon` | 73.8% | 2,518 | 121/164 |
|
||||
| `json-compact` | 69.5% | 3,953 | 114/164 |
|
||||
| `yaml` | 68.3% | 4,982 | 112/164 |
|
||||
| `json-pretty` | 68.3% | 6,360 | 112/164 |
|
||||
| `xml` | 69.5% | 7,324 | 114/164 |
|
||||
| `csv` | 73.2% | 2,334 | 120/164 |
|
||||
| `toon` | 73.2% | 2,498 | 120/164 |
|
||||
| `json-compact` | 73.8% | 3,924 | 121/164 |
|
||||
| `yaml` | 73.8% | 4,959 | 121/164 |
|
||||
| `json-pretty` | 73.8% | 6,331 | 121/164 |
|
||||
| `xml` | 74.4% | 7,296 | 122/164 |
|
||||
|
||||
##### E-commerce orders with nested structures
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 81.1% | 7,232 | 133/164 |
|
||||
| `json-compact` | 76.8% | 6,794 | 126/164 |
|
||||
| `yaml` | 75.6% | 8,347 | 124/164 |
|
||||
| `json-pretty` | 76.2% | 10,713 | 125/164 |
|
||||
| `xml` | 74.4% | 12,023 | 122/164 |
|
||||
| `toon` | 82.3% | 7,458 | 135/164 |
|
||||
| `json-compact` | 78.7% | 7,110 | 129/164 |
|
||||
| `yaml` | 79.9% | 8,755 | 131/164 |
|
||||
| `json-pretty` | 79.3% | 11,234 | 130/164 |
|
||||
| `xml` | 77.4% | 12,649 | 127/164 |
|
||||
|
||||
##### Time-series analytics data
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 73.3% | 1,406 | 88/120 |
|
||||
| `toon` | 72.5% | 1,548 | 87/120 |
|
||||
| `json-compact` | 71.7% | 2,349 | 86/120 |
|
||||
| `yaml` | 71.7% | 2,949 | 86/120 |
|
||||
| `json-pretty` | 68.3% | 3,676 | 82/120 |
|
||||
| `xml` | 68.3% | 4,384 | 82/120 |
|
||||
| `csv` | 75.0% | 1,411 | 90/120 |
|
||||
| `toon` | 78.3% | 1,553 | 94/120 |
|
||||
| `json-compact` | 74.2% | 2,354 | 89/120 |
|
||||
| `yaml` | 75.8% | 2,954 | 91/120 |
|
||||
| `json-pretty` | 75.0% | 3,681 | 90/120 |
|
||||
| `xml` | 72.5% | 4,389 | 87/120 |
|
||||
|
||||
##### Top 100 GitHub repositories
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 62.9% | 8,779 | 83/132 |
|
||||
| `csv` | 61.4% | 8,527 | 81/132 |
|
||||
| `yaml` | 59.8% | 13,141 | 79/132 |
|
||||
| `json-compact` | 55.3% | 11,464 | 73/132 |
|
||||
| `json-pretty` | 56.1% | 15,157 | 74/132 |
|
||||
| `xml` | 48.5% | 17,105 | 64/132 |
|
||||
| `csv` | 65.9% | 8,527 | 87/132 |
|
||||
| `toon` | 66.7% | 8,779 | 88/132 |
|
||||
| `yaml` | 65.2% | 13,141 | 86/132 |
|
||||
| `json-compact` | 59.8% | 11,464 | 79/132 |
|
||||
| `json-pretty` | 63.6% | 15,157 | 84/132 |
|
||||
| `xml` | 56.1% | 17,105 | 74/132 |
|
||||
|
||||
##### Semi-uniform event logs
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `json-compact` | 63.3% | 4,819 | 76/120 |
|
||||
| `toon` | 57.5% | 5,799 | 69/120 |
|
||||
| `json-pretty` | 59.2% | 6,797 | 71/120 |
|
||||
| `yaml` | 48.3% | 5,827 | 58/120 |
|
||||
| `xml` | 46.7% | 7,709 | 56/120 |
|
||||
| `json-compact` | 68.3% | 4,839 | 82/120 |
|
||||
| `toon` | 65.0% | 5,819 | 78/120 |
|
||||
| `json-pretty` | 69.2% | 6,817 | 83/120 |
|
||||
| `yaml` | 61.7% | 5,847 | 74/120 |
|
||||
| `xml` | 58.3% | 7,729 | 70/120 |
|
||||
|
||||
##### Deeply nested configuration
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `json-compact` | 92.2% | 574 | 107/116 |
|
||||
| `toon` | 95.7% | 666 | 111/116 |
|
||||
| `yaml` | 91.4% | 686 | 106/116 |
|
||||
| `json-pretty` | 94.0% | 932 | 109/116 |
|
||||
| `xml` | 92.2% | 1,018 | 107/116 |
|
||||
| `json-compact` | 90.5% | 568 | 105/116 |
|
||||
| `toon` | 94.8% | 655 | 110/116 |
|
||||
| `yaml` | 93.1% | 675 | 108/116 |
|
||||
| `json-pretty` | 92.2% | 924 | 107/116 |
|
||||
| `xml` | 91.4% | 1,013 | 106/116 |
|
||||
|
||||
##### Valid complete dataset (control)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 100.0% | 544 | 4/4 |
|
||||
| `json-compact` | 100.0% | 795 | 4/4 |
|
||||
| `yaml` | 100.0% | 1,003 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,282 | 4/4 |
|
||||
| `csv` | 25.0% | 492 | 1/4 |
|
||||
| `xml` | 0.0% | 1,467 | 0/4 |
|
||||
| `toon` | 100.0% | 535 | 4/4 |
|
||||
| `json-compact` | 100.0% | 787 | 4/4 |
|
||||
| `yaml` | 100.0% | 992 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,274 | 4/4 |
|
||||
| `xml` | 25.0% | 1,462 | 1/4 |
|
||||
| `csv` | 0.0% | 483 | 0/4 |
|
||||
|
||||
##### Array truncated: 3 rows removed from end
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 425 | 4/4 |
|
||||
| `xml` | 100.0% | 1,251 | 4/4 |
|
||||
| `toon` | 0.0% | 474 | 0/4 |
|
||||
| `json-compact` | 0.0% | 681 | 0/4 |
|
||||
| `json-pretty` | 0.0% | 1,096 | 0/4 |
|
||||
| `yaml` | 0.0% | 859 | 0/4 |
|
||||
| `csv` | 100.0% | 413 | 4/4 |
|
||||
| `xml` | 100.0% | 1,243 | 4/4 |
|
||||
| `toon` | 0.0% | 462 | 0/4 |
|
||||
| `json-pretty` | 0.0% | 1,085 | 0/4 |
|
||||
| `yaml` | 0.0% | 843 | 0/4 |
|
||||
| `json-compact` | 0.0% | 670 | 0/4 |
|
||||
|
||||
##### Extra rows added beyond declared length
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 566 | 4/4 |
|
||||
| `toon` | 75.0% | 621 | 3/4 |
|
||||
| `xml` | 100.0% | 1,692 | 4/4 |
|
||||
| `yaml` | 75.0% | 1,157 | 3/4 |
|
||||
| `json-compact` | 50.0% | 917 | 2/4 |
|
||||
| `json-pretty` | 50.0% | 1,476 | 2/4 |
|
||||
| `csv` | 100.0% | 550 | 4/4 |
|
||||
| `toon` | 75.0% | 605 | 3/4 |
|
||||
| `json-compact` | 75.0% | 901 | 3/4 |
|
||||
| `xml` | 100.0% | 1,678 | 4/4 |
|
||||
| `yaml` | 75.0% | 1,138 | 3/4 |
|
||||
| `json-pretty` | 50.0% | 1,460 | 2/4 |
|
||||
|
||||
##### Inconsistent field count (missing salary in row 10)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 75.0% | 489 | 3/4 |
|
||||
| `yaml` | 100.0% | 996 | 4/4 |
|
||||
| `toon` | 100.0% | 1,019 | 4/4 |
|
||||
| `json-compact` | 75.0% | 790 | 3/4 |
|
||||
| `xml` | 100.0% | 1,458 | 4/4 |
|
||||
| `json-pretty` | 75.0% | 1,274 | 3/4 |
|
||||
| `csv` | 100.0% | 480 | 4/4 |
|
||||
| `json-compact` | 100.0% | 782 | 4/4 |
|
||||
| `yaml` | 100.0% | 985 | 4/4 |
|
||||
| `toon` | 100.0% | 1,008 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,266 | 4/4 |
|
||||
| `xml` | 100.0% | 1,453 | 4/4 |
|
||||
|
||||
##### Missing required fields (no email in multiple rows)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 329 | 4/4 |
|
||||
| `xml` | 100.0% | 1,411 | 4/4 |
|
||||
| `toon` | 75.0% | 983 | 3/4 |
|
||||
| `yaml` | 25.0% | 960 | 1/4 |
|
||||
| `json-pretty` | 25.0% | 1,230 | 1/4 |
|
||||
| `json-compact` | 0.0% | 755 | 0/4 |
|
||||
| `csv` | 100.0% | 340 | 4/4 |
|
||||
| `xml` | 100.0% | 1,409 | 4/4 |
|
||||
| `toon` | 75.0% | 974 | 3/4 |
|
||||
| `json-pretty` | 50.0% | 1,225 | 2/4 |
|
||||
| `yaml` | 25.0% | 951 | 1/4 |
|
||||
| `json-compact` | 0.0% | 750 | 0/4 |
|
||||
|
||||
#### Performance by Model
|
||||
|
||||
@@ -253,16 +257,16 @@ grok-4-fast-non-reasoning
|
||||
| `json-compact` | 55.0% | 115/209 |
|
||||
| `csv` | 50.5% | 55/109 |
|
||||
|
||||
##### gemini-2.5-flash
|
||||
##### gemini-3-flash-preview
|
||||
|
||||
| Format | Accuracy | Correct/Total |
|
||||
| ------ | -------- | ------------- |
|
||||
| `toon` | 87.6% | 183/209 |
|
||||
| `csv` | 86.2% | 94/109 |
|
||||
| `json-compact` | 82.3% | 172/209 |
|
||||
| `yaml` | 79.4% | 166/209 |
|
||||
| `xml` | 79.4% | 166/209 |
|
||||
| `json-pretty` | 77.0% | 161/209 |
|
||||
| `xml` | 98.1% | 205/209 |
|
||||
| `json-pretty` | 97.1% | 203/209 |
|
||||
| `yaml` | 97.1% | 203/209 |
|
||||
| `toon` | 96.7% | 202/209 |
|
||||
| `json-compact` | 96.7% | 202/209 |
|
||||
| `csv` | 96.3% | 105/109 |
|
||||
|
||||
##### gpt-5-nano
|
||||
|
||||
@@ -275,16 +279,16 @@ grok-4-fast-non-reasoning
|
||||
| `yaml` | 87.1% | 182/209 |
|
||||
| `xml` | 80.9% | 169/209 |
|
||||
|
||||
##### grok-4-fast-non-reasoning
|
||||
##### grok-4-1-fast-non-reasoning
|
||||
|
||||
| Format | Accuracy | Correct/Total |
|
||||
| ------ | -------- | ------------- |
|
||||
| `toon` | 57.4% | 120/209 |
|
||||
| `json-pretty` | 55.5% | 116/209 |
|
||||
| `json-compact` | 54.5% | 114/209 |
|
||||
| `yaml` | 53.6% | 112/209 |
|
||||
| `xml` | 52.6% | 110/209 |
|
||||
| `csv` | 52.3% | 57/109 |
|
||||
| `toon` | 58.4% | 122/209 |
|
||||
| `yaml` | 57.9% | 121/209 |
|
||||
| `json-pretty` | 56.5% | 118/209 |
|
||||
| `xml` | 54.1% | 113/209 |
|
||||
| `json-compact` | 52.2% | 109/209 |
|
||||
| `csv` | 51.4% | 56/109 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -343,13 +347,13 @@ Eleven datasets designed to test different structural patterns and validation ca
|
||||
|
||||
#### Evaluation Process
|
||||
|
||||
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON compact, JSON, CSV, YAML, XML).
|
||||
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON, YAML, JSON compact, XML, CSV).
|
||||
2. **Query LLM**: Each model receives formatted data + question in a prompt and extracts the answer.
|
||||
3. **Validate deterministically**: Answers are validated using type-aware comparison (e.g., `50000` = `$50,000`, `Engineering` = `engineering`, `2025-01-01` = `January 1, 2025`) without requiring an LLM judge.
|
||||
|
||||
#### Models & Configuration
|
||||
|
||||
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-2.5-flash`, `gpt-5-nano`, `grok-4-fast-non-reasoning`
|
||||
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-3-flash-preview`, `gpt-5-nano`, `grok-4-1-fast-non-reasoning`
|
||||
- **Token counting**: Using `gpt-tokenizer` with `o200k_base` encoding (GPT-5 tokenizer)
|
||||
- **Temperature**: Not set (models use their defaults)
|
||||
- **Total evaluations**: 209 questions × 6 formats × 4 models = 5,016 LLM calls
|
||||
@@ -371,34 +375,34 @@ Datasets with nested or semi-uniform structures. CSV excluded as it cannot prope
|
||||
```
|
||||
🛒 E-commerce orders with nested structures ┊ Tabular: 33%
|
||||
│
|
||||
TOON █████████████░░░░░░░ 72,771 tokens
|
||||
├─ vs JSON (−33.1%) 108,806 tokens
|
||||
├─ vs JSON compact (+5.5%) 68,975 tokens
|
||||
├─ vs YAML (−14.2%) 84,780 tokens
|
||||
└─ vs XML (−40.5%) 122,406 tokens
|
||||
TOON █████████████░░░░░░░ 73,126 tokens
|
||||
├─ vs JSON (−33.3%) 109,599 tokens
|
||||
├─ vs JSON compact (+5.3%) 69,459 tokens
|
||||
├─ vs YAML (−14.4%) 85,415 tokens
|
||||
└─ vs XML (−40.7%) 123,344 tokens
|
||||
|
||||
🧾 Semi-uniform event logs ┊ Tabular: 50%
|
||||
│
|
||||
TOON █████████████████░░░ 153,211 tokens
|
||||
├─ vs JSON (−15.0%) 180,176 tokens
|
||||
├─ vs JSON compact (+19.9%) 127,731 tokens
|
||||
├─ vs YAML (−0.8%) 154,505 tokens
|
||||
└─ vs XML (−25.2%) 204,777 tokens
|
||||
TOON █████████████████░░░ 154,084 tokens
|
||||
├─ vs JSON (−15.0%) 181,201 tokens
|
||||
├─ vs JSON compact (+19.9%) 128,529 tokens
|
||||
├─ vs YAML (−0.8%) 155,397 tokens
|
||||
└─ vs XML (−25.2%) 205,859 tokens
|
||||
|
||||
🧩 Deeply nested configuration ┊ Tabular: 0%
|
||||
│
|
||||
TOON ██████████████░░░░░░ 631 tokens
|
||||
├─ vs JSON (−31.3%) 919 tokens
|
||||
├─ vs JSON compact (+11.9%) 564 tokens
|
||||
├─ vs YAML (−6.2%) 673 tokens
|
||||
└─ vs XML (−37.4%) 1,008 tokens
|
||||
TOON ██████████████░░░░░░ 620 tokens
|
||||
├─ vs JSON (−31.9%) 911 tokens
|
||||
├─ vs JSON compact (+11.1%) 558 tokens
|
||||
├─ vs YAML (−6.3%) 662 tokens
|
||||
└─ vs XML (−38.2%) 1,003 tokens
|
||||
|
||||
──────────────────────────────────── Total ────────────────────────────────────
|
||||
TOON ████████████████░░░░ 226,613 tokens
|
||||
├─ vs JSON (−21.8%) 289,901 tokens
|
||||
├─ vs JSON compact (+14.9%) 197,270 tokens
|
||||
├─ vs YAML (−5.6%) 239,958 tokens
|
||||
└─ vs XML (−31.0%) 328,191 tokens
|
||||
TOON ████████████████░░░░ 227,830 tokens
|
||||
├─ vs JSON (−21.9%) 291,711 tokens
|
||||
├─ vs JSON compact (+14.7%) 198,546 tokens
|
||||
├─ vs YAML (−5.7%) 241,474 tokens
|
||||
└─ vs XML (−31.0%) 330,206 tokens
|
||||
```
|
||||
|
||||
#### Flat-Only Track
|
||||
@@ -408,21 +412,21 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
```
|
||||
👥 Uniform employee records ┊ Tabular: 100%
|
||||
│
|
||||
CSV ███████████████████░ 46,954 tokens
|
||||
TOON ████████████████████ 49,831 tokens (+6.1% vs CSV)
|
||||
├─ vs JSON (−60.7%) 126,860 tokens
|
||||
├─ vs JSON compact (−36.8%) 78,856 tokens
|
||||
├─ vs YAML (−50.0%) 99,706 tokens
|
||||
└─ vs XML (−66.0%) 146,444 tokens
|
||||
CSV ███████████████████░ 47,102 tokens
|
||||
TOON ████████████████████ 49,919 tokens (+6.0% vs CSV)
|
||||
├─ vs JSON (−60.7%) 127,063 tokens
|
||||
├─ vs JSON compact (−36.9%) 79,059 tokens
|
||||
├─ vs YAML (−50.1%) 100,011 tokens
|
||||
└─ vs XML (−65.9%) 146,579 tokens
|
||||
|
||||
📈 Time-series analytics data ┊ Tabular: 100%
|
||||
│
|
||||
CSV ██████████████████░░ 8,388 tokens
|
||||
TOON ████████████████████ 9,120 tokens (+8.7% vs CSV)
|
||||
├─ vs JSON (−59.0%) 22,250 tokens
|
||||
├─ vs JSON compact (−35.8%) 14,216 tokens
|
||||
├─ vs YAML (−48.9%) 17,863 tokens
|
||||
└─ vs XML (−65.7%) 26,621 tokens
|
||||
CSV ██████████████████░░ 8,383 tokens
|
||||
TOON ████████████████████ 9,115 tokens (+8.7% vs CSV)
|
||||
├─ vs JSON (−59.0%) 22,245 tokens
|
||||
├─ vs JSON compact (−35.9%) 14,211 tokens
|
||||
├─ vs YAML (−49.0%) 17,858 tokens
|
||||
└─ vs XML (−65.8%) 26,616 tokens
|
||||
|
||||
⭐ Top 100 GitHub repositories ┊ Tabular: 100%
|
||||
│
|
||||
@@ -434,12 +438,12 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
└─ vs XML (−48.9%) 17,095 tokens
|
||||
|
||||
──────────────────────────────────── Total ────────────────────────────────────
|
||||
CSV ███████████████████░ 63,854 tokens
|
||||
TOON ████████████████████ 67,695 tokens (+6.0% vs CSV)
|
||||
├─ vs JSON (−58.8%) 164,254 tokens
|
||||
├─ vs JSON compact (−35.2%) 104,526 tokens
|
||||
├─ vs YAML (−48.2%) 130,697 tokens
|
||||
└─ vs XML (−64.4%) 190,160 tokens
|
||||
CSV ███████████████████░ 63,997 tokens
|
||||
TOON ████████████████████ 67,778 tokens (+5.9% vs CSV)
|
||||
├─ vs JSON (−58.8%) 164,452 tokens
|
||||
├─ vs JSON compact (−35.3%) 104,724 tokens
|
||||
├─ vs YAML (−48.3%) 130,997 tokens
|
||||
└─ vs XML (−64.4%) 190,290 tokens
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -449,64 +453,64 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
|
||||
**Savings:** 13,130 tokens (59.0% reduction vs JSON)
|
||||
|
||||
**JSON** (22,250 tokens):
|
||||
**JSON** (22,245 tokens):
|
||||
|
||||
```json
|
||||
{
|
||||
"metrics": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"views": 5715,
|
||||
"clicks": 211,
|
||||
"conversions": 28,
|
||||
"revenue": 7976.46,
|
||||
"bounceRate": 0.47
|
||||
"views": 6138,
|
||||
"clicks": 174,
|
||||
"conversions": 12,
|
||||
"revenue": 2712.49,
|
||||
"bounceRate": 0.35
|
||||
},
|
||||
{
|
||||
"date": "2025-01-02",
|
||||
"views": 7103,
|
||||
"clicks": 393,
|
||||
"conversions": 28,
|
||||
"revenue": 8360.53,
|
||||
"bounceRate": 0.32
|
||||
"views": 4616,
|
||||
"clicks": 274,
|
||||
"conversions": 34,
|
||||
"revenue": 9156.29,
|
||||
"bounceRate": 0.56
|
||||
},
|
||||
{
|
||||
"date": "2025-01-03",
|
||||
"views": 7248,
|
||||
"clicks": 378,
|
||||
"conversions": 24,
|
||||
"revenue": 3212.57,
|
||||
"bounceRate": 0.5
|
||||
"views": 4460,
|
||||
"clicks": 143,
|
||||
"conversions": 8,
|
||||
"revenue": 1317.98,
|
||||
"bounceRate": 0.59
|
||||
},
|
||||
{
|
||||
"date": "2025-01-04",
|
||||
"views": 2927,
|
||||
"clicks": 77,
|
||||
"conversions": 11,
|
||||
"revenue": 1211.69,
|
||||
"bounceRate": 0.62
|
||||
"views": 4740,
|
||||
"clicks": 125,
|
||||
"conversions": 13,
|
||||
"revenue": 2934.77,
|
||||
"bounceRate": 0.37
|
||||
},
|
||||
{
|
||||
"date": "2025-01-05",
|
||||
"views": 3530,
|
||||
"clicks": 82,
|
||||
"conversions": 8,
|
||||
"revenue": 462.77,
|
||||
"bounceRate": 0.56
|
||||
"views": 6428,
|
||||
"clicks": 369,
|
||||
"conversions": 19,
|
||||
"revenue": 1317.24,
|
||||
"bounceRate": 0.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**TOON** (9,120 tokens):
|
||||
**TOON** (9,115 tokens):
|
||||
|
||||
```
|
||||
metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
|
||||
2025-01-01,5715,211,28,7976.46,0.47
|
||||
2025-01-02,7103,393,28,8360.53,0.32
|
||||
2025-01-03,7248,378,24,3212.57,0.5
|
||||
2025-01-04,2927,77,11,1211.69,0.62
|
||||
2025-01-05,3530,82,8,462.77,0.56
|
||||
2025-01-01,6138,174,12,2712.49,0.35
|
||||
2025-01-02,4616,274,34,9156.29,0.56
|
||||
2025-01-03,4460,143,8,1317.98,0.59
|
||||
2025-01-04,4740,125,13,2934.77,0.37
|
||||
2025-01-05,6428,369,19,1317.24,0.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
description: TOON syntax with concrete examples – objects, arrays, headers, key folding, and quoting rules.
|
||||
---
|
||||
|
||||
# Format Overview
|
||||
|
||||
TOON syntax reference with concrete examples. See [Getting Started](/guide/getting-started) for introduction.
|
||||
TOON syntax reference with concrete examples. See [Getting Started](/guide/getting-started) for an introduction.
|
||||
|
||||
## Data Model
|
||||
|
||||
@@ -181,7 +185,7 @@ Where:
|
||||
- `|` → pipe delimiter
|
||||
- **fields** (optional) for tabular arrays: `{field1,field2,field3}`
|
||||
|
||||
> [!TIP]
|
||||
> [!NOTE]
|
||||
> The array length `[N]` helps LLMs validate structure. If you ask a model to generate TOON output, explicit lengths let you detect truncation or malformed data.
|
||||
|
||||
### Delimiter Options
|
||||
@@ -326,6 +330,8 @@ Numbers are emitted in canonical decimal form (no exponent notation, no trailing
|
||||
| `BigInt` (within safe range) | Number |
|
||||
| `BigInt` (out of range) | Quoted decimal string (e.g., `"9007199254740993"`) |
|
||||
| `Date` | ISO string in quotes (e.g., `"2025-01-01T00:00:00.000Z"`) |
|
||||
| `Set` | Array of normalized values |
|
||||
| `Map` | Object with `String(key)` keys |
|
||||
| `undefined`, `function`, `symbol` | `null` |
|
||||
|
||||
Decoders accept both decimal and exponent forms on input (e.g., `42`, `-3.14`, `1e-6`), and treat tokens with forbidden leading zeros (e.g., `"05"`) as strings, not numbers.
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
---
|
||||
description: What TOON is, when to use it, and a first encode/decode example with the TypeScript library.
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
## What is TOON?
|
||||
## What Is TOON?
|
||||
|
||||
**Token-Oriented Object Notation** is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. It's intended for *LLM input* as a drop-in, lossless representation of your existing JSON.
|
||||
**Token-Oriented Object Notation** is a compact, human-readable encoding of the JSON data model that minimizes tokens and makes structure easy for models to follow. It is intended for *LLM input* as a drop-in, lossless representation of your existing JSON.
|
||||
|
||||
TOON combines YAML's indentation-based structure for nested objects with a CSV-style tabular layout for uniform arrays. TOON's sweet spot is uniform arrays of objects (multiple fields per row, same structure across items), achieving CSV-like compactness while adding explicit structure that helps LLMs parse and validate data reliably.
|
||||
|
||||
Think of it as a translation layer: use JSON programmatically, and encode it as TOON for LLM input.
|
||||
|
||||
### Why TOON?
|
||||
### Why TOON?
|
||||
|
||||
Standard JSON is verbose and token-expensive. For uniform arrays of objects, JSON repeats every field name for every record:
|
||||
|
||||
@@ -41,7 +45,7 @@ users[2]{id,name,role}:
|
||||
2,Bob,user
|
||||
```
|
||||
|
||||
The `[2]` declares the array length, enabling LLMs to answer dataset size questions and detect truncation. The `{id,name,role}` declares the field names. Each row is then a compact, comma-separated list of values. This is the core pattern: declare structure once, stream data compactly. The format approaches CSV's efficiency while adding explicit structure.
|
||||
The `[2]` declares the array length, letting LLMs answer dataset-size questions and detect truncation. The `{id,name,role}` declares the field names. Each row is a compact, comma-separated list of values. The pattern is the same throughout TOON: declare structure once, stream data compactly. The result lands close to CSV density with explicit structure preserved.
|
||||
|
||||
For a more realistic example, here's how TOON handles a dataset with both nested objects and tabular arrays:
|
||||
|
||||
@@ -123,11 +127,12 @@ TOON is not always the best choice. Consider alternatives when:
|
||||
|
||||
- **Deeply nested or non-uniform structures** (tabular eligibility ≈ 0%): JSON-compact often uses fewer tokens. Example: complex configuration objects with many nested levels.
|
||||
- **Semi-uniform arrays** (~40–60% tabular eligibility): Token savings diminish. Prefer JSON if your pipelines already rely on it.
|
||||
- **Pure tabular data**: CSV is smaller than TOON for flat tables. TOON adds minimal overhead (~5-10%) to provide structure (array length declarations, field headers, delimiter scoping) that improves LLM reliability.
|
||||
- **Pure tabular data**: CSV is smaller than TOON for flat tables. TOON adds minimal overhead (~5–10%) to provide structure (array length declarations, field headers, delimiter scoping) that improves LLM reliability.
|
||||
- **Latency-critical applications**: Benchmark on your exact setup. Some deployments (especially local/quantized models) may process compact JSON faster despite TOON's lower token count.
|
||||
|
||||
> [!NOTE]
|
||||
> For data-driven comparisons across different structures, see [benchmarks](/guide/benchmarks). When optimizing for latency, measure TTFT, tokens/sec, and total time for both TOON and JSON-compact and use whichever performs better in your specific environment.
|
||||
::: info
|
||||
For data-driven comparisons across different structures, see [Benchmarks](/guide/benchmarks). When optimizing for latency, measure TTFT, tokens/sec, and total time for both TOON and JSON-compact, and use whichever is faster in your specific environment.
|
||||
:::
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: Prompting strategies for sending TOON to LLMs and validating TOON they generate, with examples.
|
||||
---
|
||||
|
||||
# Using TOON with LLMs
|
||||
|
||||
TOON is designed for passing structured data to Large Language Models with reduced token costs and improved reliability. This guide shows how to use TOON effectively in prompts, both for input (sending data to models) and output (getting models to generate TOON).
|
||||
@@ -6,7 +10,7 @@ This guide is about the TOON format itself. Code examples use the TypeScript lib
|
||||
|
||||
## Why TOON for LLMs
|
||||
|
||||
LLM tokens cost money, and JSON is verbose – repeating every field name for every record in an array. TOON minimizes tokens especially for uniform arrays by declaring fields once and streaming data as rows, typically saving 30-60% compared to formatted JSON.
|
||||
LLM tokens cost money, and JSON is verbose – repeating every field name for every record in an array. TOON minimizes tokens especially for uniform arrays by declaring fields once and streaming data as rows, typically saving 30–60% compared to formatted JSON.
|
||||
|
||||
TOON adds structure guardrails: explicit `[N]` lengths and `{fields}` headers make it easier for models to track rows and for you to validate output. Strict mode helps detect truncation and malformed TOON when decoding model responses.
|
||||
|
||||
@@ -145,9 +149,9 @@ For streaming decode APIs, see [`decodeFromLines()`](/reference/api#decodefromli
|
||||
|
||||
## Tips and Pitfalls
|
||||
|
||||
**Show, don't describe.** Don't explain TOON syntax in detail – just show an example. Models learn the pattern from context. A simple code block with 2-5 rows is more effective than paragraphs of explanation.
|
||||
**Show, don't describe.** Don't explain TOON syntax in detail – just show an example. Models learn the pattern from context. A simple code block with 2–5 rows is more effective than paragraphs of explanation.
|
||||
|
||||
**Keep examples small.** Use 2-5 rows in your examples, not hundreds. The model generalizes from the pattern. Large examples waste tokens without improving accuracy.
|
||||
**Keep examples small.** Use 2–5 rows in your examples, not hundreds. The model generalizes from the pattern. Large examples waste tokens without improving accuracy.
|
||||
|
||||
**Always validate output.** Decode generated TOON with `strict: true` (default) to catch errors early. Don't assume model output is valid TOON without checking.
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ hero:
|
||||
features:
|
||||
- title: Token-Efficient & Accurate
|
||||
icon: 📊
|
||||
details: TOON reaches 74% accuracy (vs JSON's 70%) while using ~40% fewer tokens in mixed-structure benchmarks across 4 models.
|
||||
details: TOON reaches 76.4% accuracy (vs JSON's 75.0%) while using ~40% fewer tokens in mixed-structure benchmarks across 4 models.
|
||||
link: /guide/benchmarks
|
||||
- title: JSON Data Model
|
||||
icon: 🔁
|
||||
|
||||
+3
-3
@@ -8,13 +8,13 @@
|
||||
"preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"fflate": "^0.8.2",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"markdown-it-mathjax3": "^4.3.2",
|
||||
"uint8array-extras": "^1.5.0",
|
||||
"unocss": "^66.5.9",
|
||||
"unocss": "^66.6.8",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-plugin-llms": "^1.9.3"
|
||||
"vitepress-plugin-llms": "^1.12.2"
|
||||
}
|
||||
}
|
||||
|
||||
+94
-5
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: TypeScript and JavaScript encode and decode functions, options, error types, and streaming decoders for @toon-format/toon.
|
||||
---
|
||||
|
||||
# API Reference
|
||||
|
||||
TypeScript/JavaScript API documentation for the `@toon-format/toon` package. For format rules, see the [Format Overview](/guide/format-overview) or the [Specification](/reference/spec). For other languages, see [Implementations](/ecosystem/implementations).
|
||||
@@ -60,6 +64,8 @@ Non-JSON-serializable values are normalized before encoding:
|
||||
| `BigInt` (within safe range) | Number |
|
||||
| `BigInt` (out of range) | Quoted decimal string (e.g., `"9007199254740993"`) |
|
||||
| `Date` | ISO string in quotes (e.g., `"2025-01-01T00:00:00.000Z"`) |
|
||||
| `Set` | Array of normalized values |
|
||||
| `Map` | Object with `String(key)` keys |
|
||||
| `undefined`, `function`, `symbol` | `null` |
|
||||
|
||||
#### Example
|
||||
@@ -114,7 +120,7 @@ const lineArray = Array.from(encodeLines(data))
|
||||
|
||||
#### Return Value
|
||||
|
||||
Returns an `Iterable<string>` that yields TOON lines one at a time. **Each yielded string is a single line without a trailing newline character** — you must add `\n` when writing to streams or stdout.
|
||||
Returns an `Iterable<string>` that yields TOON lines one at a time. **Each yielded string is a single line without a trailing newline character** – you must add `\n` when writing to streams or stdout.
|
||||
|
||||
::: info Relationship to `encode()`
|
||||
`encode(value, options)` is equivalent to:
|
||||
@@ -251,7 +257,7 @@ user:
|
||||
created: 2025-01-02
|
||||
```
|
||||
|
||||
::: tip Replacer Execution Order
|
||||
::: info Replacer Execution Order
|
||||
The replacer is called in a depth-first manner:
|
||||
1. Root value first (key = `''`, path = `[]`)
|
||||
2. Then each property/element (with proper key and path)
|
||||
@@ -482,6 +488,53 @@ for await (const event of decodeStream(rl)) {
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Decoding throws a `ToonDecodeError` when input cannot be parsed. The class extends `SyntaxError`, so existing `error instanceof SyntaxError` checks keep working without code changes.
|
||||
|
||||
### `ToonDecodeError`
|
||||
|
||||
```ts
|
||||
import { ToonDecodeError } from '@toon-format/toon'
|
||||
```
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `'ToonDecodeError'` | Discriminator – `error.name === 'ToonDecodeError'` |
|
||||
| `message` | `string` | Human-readable message; prefixed with `Line N: ` when a line is known |
|
||||
| `line` | `number?` | 1-based line number where the error was detected |
|
||||
| `source` | `string?` | Raw source line (including its leading whitespace) |
|
||||
| `cause` | `unknown?` | The original error when the decoder enriched a lower-level parser failure |
|
||||
|
||||
The `line` and `source` fields are populated for every error that has line context – essentially every parse error during normal decoding. The `cause` chain points back to the underlying `SyntaxError` or `TypeError` thrown by the token-level parser, so debuggers and verbose loggers can show the original frame.
|
||||
|
||||
#### Example
|
||||
|
||||
```ts
|
||||
import { decode, ToonDecodeError } from '@toon-format/toon'
|
||||
|
||||
try {
|
||||
decode('a:\n\tb: 1')
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ToonDecodeError) {
|
||||
console.error(`Line ${error.line}:`, error.source)
|
||||
console.error(error.message)
|
||||
// Line 2: b: 1
|
||||
// Line 2: Tabs are not allowed in indentation in strict mode
|
||||
}
|
||||
else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: info Backwards Compatibility
|
||||
`ToonDecodeError` extends `SyntaxError`. Code written against earlier versions that catches `SyntaxError` continues to match these errors. The class adds structured fields without removing anything.
|
||||
:::
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### `EncodeOptions`
|
||||
@@ -534,6 +587,8 @@ By default (`strict: true`), the decoder validates input strictly:
|
||||
- **Delimiter mismatches**: Throws when row delimiters don't match header
|
||||
- **Indentation errors**: Throws when leading spaces aren't exact multiples of `indent`
|
||||
|
||||
All decode errors are thrown as [`ToonDecodeError`](#error-handling) instances with structured `line` and `source` fields.
|
||||
|
||||
Set `strict: false` to skip validation for lenient parsing.
|
||||
|
||||
See [Key Folding & Path Expansion](#key-folding-path-expansion) for more details on path expansion behavior and conflict resolution.
|
||||
@@ -565,10 +620,44 @@ type JsonStreamEvent
|
||||
| { type: 'endArray' }
|
||||
| { type: 'key', key: string, wasQuoted?: boolean }
|
||||
| { type: 'primitive', value: JsonPrimitive }
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null
|
||||
```
|
||||
|
||||
### JSON Value Types
|
||||
|
||||
```ts
|
||||
type JsonPrimitive = string | number | boolean | null
|
||||
type JsonArray = readonly JsonValue[]
|
||||
type JsonObject = { readonly [key: string]: JsonValue }
|
||||
type JsonValue = JsonPrimitive | JsonArray | JsonObject
|
||||
```
|
||||
|
||||
### Delimiters
|
||||
|
||||
```ts
|
||||
import { DEFAULT_DELIMITER, DELIMITERS } from '@toon-format/toon'
|
||||
|
||||
DEFAULT_DELIMITER // ','
|
||||
DELIMITERS // { comma: ',', tab: '\t', pipe: '|' }
|
||||
```
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `DEFAULT_DELIMITER` | The default delimiter character (`,`) used when none is specified |
|
||||
| `DELIMITERS` | Frozen record mapping delimiter names to their characters |
|
||||
| `Delimiter` | Type union of valid delimiter characters: `',' \| '\t' \| '\|'` |
|
||||
| `DelimiterKey` | Type union of delimiter names: `'comma' \| 'tab' \| 'pipe'` |
|
||||
|
||||
### Option Types
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `EncodeOptions` | Options accepted by [`encode()`](#encode-input-options) and [`encodeLines()`](#encodelines-input-options) |
|
||||
| `DecodeOptions` | Options accepted by [`decode()`](#decode-input-options) and [`decodeFromLines()`](#decodefromlines-lines-options) |
|
||||
| `DecodeStreamOptions` | Options accepted by [`decodeStreamSync()`](#decodestreamsync-lines-options) and [`decodeStream()`](#decodestream-source-options) |
|
||||
| `EncodeReplacer` | Signature of the [replacer function](#replacer-function) |
|
||||
| `ResolvedEncodeOptions` | `EncodeOptions` after defaults are applied (advanced) |
|
||||
| `ResolvedDecodeOptions` | `DecodeOptions` after defaults are applied (advanced) |
|
||||
|
||||
## Guides & Examples
|
||||
|
||||
### Round-Trip Compatibility
|
||||
@@ -653,7 +742,7 @@ When multiple expanded keys construct overlapping paths, the decoder merges them
|
||||
|
||||
### Delimiter Strategies
|
||||
|
||||
Tab delimiters (`\t`) often tokenize more efficiently than commas, as Tabs are single characters that rarely appear in natural text. This reduces the need for quote-escaping, leading to smaller token counts in large datasets.
|
||||
Tab delimiters (`\t`) often tokenize more efficiently than commas. Tabs are single characters that rarely appear in natural text, which reduces the need for quote-escaping and leads to smaller token counts in large datasets.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: Mathematical model of TOON's byte-level overhead vs JSON across structure families, with formulas and worked examples.
|
||||
---
|
||||
|
||||
# TOON vs JSON: Byte-Level Efficiency Model
|
||||
|
||||
A mathematical analysis of TOON's byte efficiency compared to JSON across different data structures.
|
||||
@@ -8,7 +12,7 @@ This page presents a theoretical, character-based comparison between TOON and JS
|
||||
|
||||
## Overview
|
||||
|
||||
Large Language Models increasingly rely on structured data for inference and function calling. However, standard formats like JSON introduce significant verbosity that inflates token usage and inference costs. This analysis presents a formal mathematical comparison between TOON and JSON to evaluate whether TOON achieves quantifiable efficiency gains by eliminating structural redundancy.
|
||||
Standard JSON introduces structural verbosity that inflates token usage and inference cost. This page formalises a byte-level comparison between TOON and JSON to evaluate whether TOON achieves quantifiable efficiency gains by removing structural redundancy.
|
||||
|
||||
Under the assumptions described below (compact JSON, canonical TOON, ASCII keys and punctuation, shallow to moderate nesting, and mostly unquoted TOON strings), TOON's **structural overhead is lower than compact JSON** for the structure families analyzed here, except arrays of arrays.
|
||||
|
||||
@@ -353,7 +357,7 @@ $$
|
||||
|
||||
Arrays of arrays of primitives are where TOON structurally loses: each inner array becomes a list item with its own header, so TOON pays a fixed overhead per inner array (`"- "` plus `"[m]: "`), while JSON just uses commas.
|
||||
|
||||
::: tip Practical Note
|
||||
::: info Practical Note
|
||||
For arrays of arrays of primitives, this model predicts that JSON is more byte-efficient than TOON, because TOON pays ~6 extra bytes per inner array (2 for `"- "`, 4 for `"[m]: "`), plus the length marker.
|
||||
:::
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
---
|
||||
description: Guided tour of the TOON specification – sections, conformance checklists, media type, and versioning.
|
||||
---
|
||||
|
||||
# Specification
|
||||
|
||||
The [TOON specification](https://github.com/toon-format/spec) is the authoritative reference for implementing encoders, decoders, and validators. It defines the concrete syntax, normative encoding/decoding behavior, and strict-mode validation rules.
|
||||
|
||||
You don't need this page to *use* TOON. It's mainly for implementers and contributors. If you're looking to learn how to use TOON, start with the [Getting Started](/guide/getting-started) guide instead.
|
||||
|
||||
> [!TIP]
|
||||
> The TOON specification is stable, but also an idea in progress. Nothing's set in stone – help shape where it goes by contributing to it or sharing feedback!
|
||||
> [!NOTE]
|
||||
> The TOON specification is stable, but also an idea in progress. Nothing's set in stone – help shape where it goes by contributing to it or sharing feedback.
|
||||
|
||||
## Current Version
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
description: JSON-to-TOON mappings at a glance for objects, arrays, quoting, key folding, and type conversions.
|
||||
---
|
||||
|
||||
# Syntax Cheatsheet
|
||||
|
||||
Quick reference for mapping JSON to TOON format. For rigorous, normative syntax rules and edge cases, see the [Specification](/reference/spec).
|
||||
@@ -78,7 +82,7 @@ items[2]{id,qty}:
|
||||
|
||||
:::
|
||||
|
||||
## Mixed / Non-Uniform Arrays
|
||||
## Mixed and Non-Uniform Arrays
|
||||
|
||||
::: code-group
|
||||
|
||||
@@ -352,4 +356,6 @@ See [Format Overview – Key Folding](/guide/format-overview#key-folding-optiona
|
||||
| `BigInt` (safe range) | Number |
|
||||
| `BigInt` (out of range) | Quoted decimal string |
|
||||
| `Date` | ISO string (quoted) |
|
||||
| `Set` | Array of normalized values |
|
||||
| `Map` | Object with `String(key)` keys |
|
||||
| `undefined`, `function`, `symbol` | `null` |
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
name = "toon-docs"
|
||||
compatibility_date = "2025-10-01"
|
||||
routes = [ { pattern = "toonformat.dev", custom_domain = true } ]
|
||||
|
||||
[[routes]]
|
||||
pattern = "toonformat.dev"
|
||||
custom_domain = true
|
||||
|
||||
[assets]
|
||||
directory = "./.vitepress/dist/"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// @ts-check
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu({
|
||||
rules: {
|
||||
'no-cond-assign': 'off',
|
||||
},
|
||||
// `README.md` is symlinked to this file so we
|
||||
// exclude it to avoid linting the same file twice.
|
||||
ignores: ['packages/toon/README.md'],
|
||||
}).append({
|
||||
files: ['README.md', 'SPEC.md', '**/docs/**/*'],
|
||||
rules: {
|
||||
'import/no-duplicates': 'off',
|
||||
'style/no-tabs': 'off',
|
||||
'yaml/quotes': 'off',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ConfigNames, TypedFlatConfigItem } from '@antfu/eslint-config'
|
||||
import type { FlatConfigComposer } from 'eslint-flat-config-utils'
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
const config: FlatConfigComposer<TypedFlatConfigItem, ConfigNames> = antfu({
|
||||
pnpm: false,
|
||||
rules: {
|
||||
'no-cond-assign': 'off',
|
||||
},
|
||||
}).append({
|
||||
files: ['**/README.md', 'SPEC.md', '**/benchmarks/**/*', '**/docs/**/*'],
|
||||
rules: {
|
||||
'markdown/no-missing-link-fragments': 'off',
|
||||
'markdown/fenced-code-language': 'off',
|
||||
'markdown/heading-increment': 'off',
|
||||
'import/no-duplicates': 'off',
|
||||
'style/no-tabs': 'off',
|
||||
'yaml/quotes': 'off',
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
+13
-12
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@toon-format/monorepo",
|
||||
"type": "module",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"scripts": {
|
||||
"build": "pnpm -r --filter=./packages/** run build",
|
||||
"automd": "automd",
|
||||
@@ -17,15 +17,16 @@
|
||||
"release": "bumpp -r"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.2.0",
|
||||
"@commitlint/types": "^20.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"automd": "^0.4.2",
|
||||
"bumpp": "^10.3.1",
|
||||
"eslint": "^9.39.1",
|
||||
"tsdown": "^0.16.6",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.13"
|
||||
"@antfu/eslint-config": "^8.2.0",
|
||||
"@commitlint/types": "^21.0.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"automd": "^0.4.3",
|
||||
"bumpp": "^11.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-flat-config-utils": "^3.2.0",
|
||||
"tsdown": "^0.22.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
+34
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
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—no code required.
|
||||
[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
|
||||
|
||||
@@ -60,13 +60,14 @@ cat data.toon | toon --decode
|
||||
| `-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), `\t` (tab), `\|` (pipe) |
|
||||
| `--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` | Disable strict validation when decoding |
|
||||
| `--key-folding <mode>` | Enable key folding: `off`, `safe` (default: `off`) |
|
||||
| `--flatten-depth <number>` | Maximum folded segment count when key folding is enabled (default: `Infinity`) |
|
||||
| `--expand-paths <mode>` | Enable path expansion: `off`, `safe` (default: `off`) |
|
||||
| `--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
|
||||
|
||||
@@ -92,9 +93,11 @@ Example output:
|
||||
#### Tab-separated (often more token-efficient)
|
||||
|
||||
```bash
|
||||
toon data.json --delimiter "\t" -o output.toon
|
||||
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 processing:
|
||||
@@ -103,6 +106,19 @@ Skip validation for faster processing:
|
||||
toon data.toon --no-strict -o output.json
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -110,7 +126,7 @@ toon data.toon --no-strict -o output.json
|
||||
curl https://api.example.com/data | toon --stats
|
||||
|
||||
# Process large dataset
|
||||
cat large-dataset.json | toon --delimiter "\t" > output.toon
|
||||
cat large-dataset.json | toon --delimiter $'\t' > output.toon
|
||||
|
||||
# Chain with other tools
|
||||
jq '.results' data.json | toon > filtered.toon
|
||||
@@ -136,9 +152,9 @@ cat million-records.toon | toon --decode > output.json
|
||||
- **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 `--expand-paths safe` is enabled, decode falls back to non-streaming mode internally to apply deep-merge expansion before writing JSON
|
||||
- When `--expandPaths safe` is enabled, decode falls back to non-streaming mode internally to apply deep-merge expansion before writing JSON
|
||||
|
||||
> [!NOTE]
|
||||
> [!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)
|
||||
@@ -149,7 +165,7 @@ Collapse nested wrapper chains to reduce tokens:
|
||||
|
||||
```bash
|
||||
# Encode with key folding
|
||||
toon input.json --key-folding safe -o output.toon
|
||||
toon input.json --keyFolding safe -o output.toon
|
||||
```
|
||||
|
||||
For data like:
|
||||
@@ -179,24 +195,24 @@ data:
|
||||
|
||||
```bash
|
||||
# Fold maximum 2 levels deep
|
||||
toon input.json --key-folding safe --flatten-depth 2 -o output.toon
|
||||
toon input.json --keyFolding safe --flattenDepth 2 -o output.toon
|
||||
```
|
||||
|
||||
#### Path expansion on decode
|
||||
|
||||
```bash
|
||||
# Reconstruct nested structure from folded keys
|
||||
toon data.toon --expand-paths safe -o output.json
|
||||
toon data.toon --expandPaths safe -o output.json
|
||||
```
|
||||
|
||||
#### Round-trip workflow
|
||||
|
||||
```bash
|
||||
# Encode with folding
|
||||
toon input.json --key-folding safe -o compressed.toon
|
||||
toon input.json --keyFolding safe -o compressed.toon
|
||||
|
||||
# Decode with expansion (restores original structure)
|
||||
toon compressed.toon --expand-paths safe -o output.json
|
||||
toon compressed.toon --expandPaths safe -o output.json
|
||||
|
||||
# Verify round-trip
|
||||
diff input.json output.json
|
||||
@@ -206,7 +222,7 @@ diff input.json output.json
|
||||
|
||||
```bash
|
||||
# Key folding + tab delimiter + stats
|
||||
toon data.json --key-folding safe --delimiter "\t" --stats -o output.toon
|
||||
toon data.json --keyFolding safe --delimiter $'\t' --stats -o output.toon
|
||||
```
|
||||
|
||||
## Why Use the CLI?
|
||||
@@ -220,9 +236,9 @@ toon data.json --key-folding safe --delimiter "\t" --stats -o output.toon
|
||||
|
||||
## 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
|
||||
- [@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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@toon-format/cli",
|
||||
"type": "module",
|
||||
"version": "2.1.0",
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"version": "2.2.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"description": "CLI for JSON ↔ TOON conversion using @toon-format/toon",
|
||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||
"license": "MIT",
|
||||
@@ -30,13 +30,13 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx ./src/cli-entry.ts --help",
|
||||
"dev": "node ./src/cli-entry.ts --help",
|
||||
"build": "tsdown",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"citty": "^0.2.2",
|
||||
"consola": "^3.4.2",
|
||||
"tokenx": "^1.2.1"
|
||||
"tokenx": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runMain } from 'citty'
|
||||
import { mainCommand } from '.'
|
||||
import { mainCommand } from './index.ts'
|
||||
|
||||
runMain(mainCommand)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import type { DecodeOptions, DecodeStreamOptions, EncodeOptions } from '../../toon/src'
|
||||
import type { InputSource } from './types'
|
||||
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'
|
||||
import { jsonStreamFromEvents } from './json-from-events'
|
||||
import { jsonStringifyLines } from './json-stringify-stream'
|
||||
import { formatInputLabel, readInput, readLinesFromSource } from './utils'
|
||||
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
|
||||
@@ -85,38 +85,27 @@ export async function decodeToJson(config: {
|
||||
if (config.expandPaths === 'safe') {
|
||||
const toonContent = await readInput(config.input)
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
const decodeOptions: DecodeOptions = {
|
||||
indent: config.indent,
|
||||
strict: config.strict,
|
||||
expandPaths: config.expandPaths,
|
||||
}
|
||||
data = decode(toonContent, decodeOptions)
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
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 {
|
||||
try {
|
||||
const lineSource = readLinesFromSource(config.input)
|
||||
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)
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
+65
-110
@@ -1,132 +1,87 @@
|
||||
import type { CommandDef } from 'citty'
|
||||
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src'
|
||||
import type { InputSource } from './types'
|
||||
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'
|
||||
import { name, version } from '../package.json' with { type: 'json' }
|
||||
import { decodeToJson, encodeToToon } from './conversion'
|
||||
import { detectMode } from './utils'
|
||||
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'
|
||||
|
||||
export const mainCommand: CommandDef<{
|
||||
const { name, version } = pkg
|
||||
|
||||
const args: ArgsDef = {
|
||||
input: {
|
||||
type: 'positional'
|
||||
description: string
|
||||
required: false
|
||||
}
|
||||
type: 'positional',
|
||||
description: 'Input file path (omit or use "-" to read from stdin)',
|
||||
required: false,
|
||||
},
|
||||
output: {
|
||||
type: 'string'
|
||||
description: string
|
||||
alias: string
|
||||
}
|
||||
type: 'string',
|
||||
description: 'Output file path',
|
||||
alias: 'o',
|
||||
},
|
||||
encode: {
|
||||
type: 'boolean'
|
||||
description: string
|
||||
alias: string
|
||||
}
|
||||
type: 'boolean',
|
||||
description: 'Encode JSON to TOON (auto-detected by default)',
|
||||
alias: 'e',
|
||||
},
|
||||
decode: {
|
||||
type: 'boolean'
|
||||
description: string
|
||||
alias: string
|
||||
}
|
||||
type: 'boolean',
|
||||
description: 'Decode TOON to JSON (auto-detected by default)',
|
||||
alias: 'd',
|
||||
},
|
||||
delimiter: {
|
||||
type: 'string'
|
||||
description: string
|
||||
default: string
|
||||
}
|
||||
type: 'string',
|
||||
description: 'Delimiter for arrays: comma (,), tab (\\t), or pipe (|)',
|
||||
default: ',',
|
||||
},
|
||||
indent: {
|
||||
type: 'string'
|
||||
description: string
|
||||
default: string
|
||||
}
|
||||
type: 'string',
|
||||
description: 'Indentation size',
|
||||
default: '2',
|
||||
},
|
||||
strict: {
|
||||
type: 'boolean'
|
||||
description: string
|
||||
default: true
|
||||
}
|
||||
type: 'boolean',
|
||||
description: 'Enable strict mode for decoding',
|
||||
default: true,
|
||||
},
|
||||
keyFolding: {
|
||||
type: 'string'
|
||||
description: string
|
||||
default: string
|
||||
}
|
||||
type: 'string',
|
||||
description: 'Enable key folding: off, safe (default: off)',
|
||||
default: 'off',
|
||||
},
|
||||
flattenDepth: {
|
||||
type: 'string'
|
||||
description: string
|
||||
}
|
||||
type: 'string',
|
||||
description: 'Maximum folded segment count when key folding is enabled (default: Infinity)',
|
||||
},
|
||||
expandPaths: {
|
||||
type: 'string'
|
||||
description: string
|
||||
default: string
|
||||
}
|
||||
type: 'string',
|
||||
description: 'Enable path expansion: off, safe (default: off)',
|
||||
default: 'off',
|
||||
},
|
||||
stats: {
|
||||
type: 'boolean'
|
||||
description: string
|
||||
default: false
|
||||
}
|
||||
}> = defineCommand({
|
||||
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',
|
||||
description: 'TOON CLI – Convert between JSON and TOON formats',
|
||||
version,
|
||||
},
|
||||
args: {
|
||||
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: 'Enable strict mode for decoding',
|
||||
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,
|
||||
},
|
||||
},
|
||||
args,
|
||||
async run({ args }) {
|
||||
const input = args.input
|
||||
|
||||
@@ -193,7 +148,7 @@ export const mainCommand: CommandDef<{
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
consola.error(error)
|
||||
consola.error(formatError(error, { isVerbose: args.verbose === true }))
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JsonStreamEvent } from '../../toon/src/types'
|
||||
import type { JsonStreamEvent } from '../../toon/src/types.ts'
|
||||
|
||||
/**
|
||||
* Context for tracking JSON structure state during event streaming.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputSource } from './types'
|
||||
import type { InputSource } from './types.ts'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
@@ -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'
|
||||
+ ' ^',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,11 +18,11 @@ describe('toon CLI', () => {
|
||||
|
||||
describe('version', () => {
|
||||
it('prints the version when using --version', async () => {
|
||||
const consolaLog = vi.spyOn(consola, 'log').mockImplementation(() => undefined)
|
||||
const consoleLog = vi.mocked(console.log)
|
||||
|
||||
await runCli({ rawArgs: ['--version'] })
|
||||
|
||||
expect(consolaLog).toHaveBeenCalledWith(version)
|
||||
expect(consoleLog).toHaveBeenCalledWith(version)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -230,6 +230,48 @@ describe('toon CLI', () => {
|
||||
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', () => {
|
||||
@@ -306,7 +348,7 @@ describe('toon CLI', () => {
|
||||
})
|
||||
|
||||
describe('encode options', () => {
|
||||
it('encodes with --key-folding safe', async () => {
|
||||
it('encodes with --keyFolding safe', async () => {
|
||||
const data = {
|
||||
data: {
|
||||
metadata: {
|
||||
@@ -332,7 +374,7 @@ describe('toon CLI', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes with --flatten-depth', async () => {
|
||||
it('encodes with --flattenDepth', async () => {
|
||||
const data = {
|
||||
level1: {
|
||||
level2: {
|
||||
@@ -362,7 +404,7 @@ describe('toon CLI', () => {
|
||||
})
|
||||
|
||||
describe('decode options', () => {
|
||||
it('decodes with --expand-paths safe', async () => {
|
||||
it('decodes with --expandPaths safe', async () => {
|
||||
const data = {
|
||||
data: {
|
||||
metadata: {
|
||||
@@ -655,7 +697,7 @@ describe('toon CLI', () => {
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
@@ -663,7 +705,7 @@ describe('toon CLI', () => {
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -679,7 +721,7 @@ describe('toon CLI', () => {
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
@@ -687,7 +729,7 @@ describe('toon CLI', () => {
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -715,12 +757,12 @@ describe('toon CLI', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid --key-folding value', async () => {
|
||||
it('rejects invalid --keyFolding value', async () => {
|
||||
const context = await createCliTestContext({
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
@@ -728,7 +770,7 @@ describe('toon CLI', () => {
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -744,7 +786,7 @@ describe('toon CLI', () => {
|
||||
'input.toon': 'key: value',
|
||||
})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
@@ -752,7 +794,7 @@ describe('toon CLI', () => {
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -768,7 +810,7 @@ describe('toon CLI', () => {
|
||||
'input.json': JSON.stringify({ value: 1 }),
|
||||
})
|
||||
|
||||
const consolaError = vi.spyOn(consola, 'error').mockImplementation(() => undefined)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const exitSpy = vi.mocked(process.exit)
|
||||
|
||||
try {
|
||||
@@ -776,7 +818,7 @@ describe('toon CLI', () => {
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
|
||||
const errorCall = consolaError.mock.calls.at(0)
|
||||
const errorCall = consoleError.mock.calls.at(0)
|
||||
expect(errorCall).toBeDefined()
|
||||
const [error] = errorCall!
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { UserConfig, UserConfigFn } from 'tsdown/config'
|
||||
import type { UserConfig } from 'tsdown/config'
|
||||
import { defineConfig } from 'tsdown/config'
|
||||
|
||||
const config: UserConfig | UserConfigFn = defineConfig({
|
||||
const config: UserConfig = defineConfig({
|
||||
entry: {
|
||||
index: 'src/cli-entry.ts',
|
||||
},
|
||||
|
||||
+184
-216
@@ -127,7 +127,7 @@ hikes[3]{id,name,distanceKm,elevationGain,companion,wasSunny}:
|
||||
|
||||
## Key Features
|
||||
|
||||
- 📊 **Token-Efficient & Accurate:** TOON reaches 74% accuracy (vs JSON's 70%) while using ~40% fewer tokens in mixed-structure benchmarks across 4 models.
|
||||
- 📊 **Token-Efficient & Accurate:** TOON reaches 76.4% accuracy (vs JSON's 75.0%) while using ~40% fewer tokens in mixed-structure benchmarks across 4 models.
|
||||
- 🔁 **JSON Data Model:** Encodes the same objects, arrays, and primitives as JSON with deterministic, lossless round-trips.
|
||||
- 🛤️ **LLM-Friendly Guardrails:** Explicit [N] lengths and {fields} headers give models a clear schema to follow, improving parsing reliability.
|
||||
- 📐 **Minimal Syntax:** Uses indentation instead of braces and minimizes quoting, giving YAML-like readability with CSV-style compactness.
|
||||
@@ -144,7 +144,7 @@ TOON excels with uniform arrays of objects, but there are cases where other form
|
||||
|
||||
- **Deeply nested or non-uniform structures** (tabular eligibility ≈ 0%): JSON-compact often uses fewer tokens. Example: complex configuration objects with many nested levels.
|
||||
- **Semi-uniform arrays** (~40–60% tabular eligibility): Token savings diminish. Prefer JSON if your pipelines already rely on it.
|
||||
- **Pure tabular data**: CSV is smaller than TOON for flat tables. TOON adds minimal overhead (~5-10%) to provide structure (array length declarations, field headers, delimiter scoping) that improves LLM reliability.
|
||||
- **Pure tabular data**: CSV is smaller than TOON for flat tables. TOON adds minimal overhead (~5–10%) to provide structure (array length declarations, field headers, delimiter scoping) that improves LLM reliability.
|
||||
- **Latency-critical applications**: If end-to-end response time is your top priority, benchmark on your exact setup. Some deployments (especially local/quantized models like Ollama) may process compact JSON faster despite TOON's lower token count. Measure TTFT, tokens/sec, and total time for both formats and use whichever is faster.
|
||||
|
||||
See [benchmarks](#benchmarks) for concrete comparisons across different data structures.
|
||||
@@ -198,17 +198,17 @@ Benchmarks test LLM comprehension across different input formats using 209 data
|
||||
Each format ranked by efficiency (accuracy percentage per 1,000 tokens):
|
||||
|
||||
```
|
||||
TOON ████████████████████ 26.9 acc%/1K tok │ 73.9% acc │ 2,744 tokens
|
||||
JSON compact █████████████████░░░ 22.9 acc%/1K tok │ 70.7% acc │ 3,081 tokens
|
||||
YAML ██████████████░░░░░░ 18.6 acc%/1K tok │ 69.0% acc │ 3,719 tokens
|
||||
JSON ███████████░░░░░░░░░ 15.3 acc%/1K tok │ 69.7% acc │ 4,545 tokens
|
||||
XML ██████████░░░░░░░░░░ 13.0 acc%/1K tok │ 67.1% acc │ 5,167 tokens
|
||||
TOON ████████████████████ 27.7 acc%/1K tok │ 76.4% acc │ 2,759 tokens
|
||||
JSON compact █████████████████░░░ 23.7 acc%/1K tok │ 73.7% acc │ 3,104 tokens
|
||||
YAML ██████████████░░░░░░ 19.9 acc%/1K tok │ 74.5% acc │ 3,749 tokens
|
||||
JSON ████████████░░░░░░░░ 16.4 acc%/1K tok │ 75.0% acc │ 4,587 tokens
|
||||
XML ██████████░░░░░░░░░░ 13.8 acc%/1K tok │ 72.1% acc │ 5,221 tokens
|
||||
```
|
||||
|
||||
*Efficiency score = (Accuracy % ÷ Tokens) × 1,000. Higher is better.*
|
||||
|
||||
> [!TIP]
|
||||
> TOON achieves **73.9%** accuracy (vs JSON's 69.7%) while using **39.6% fewer tokens**.
|
||||
> TOON achieves **76.4%** accuracy (vs JSON's 75.0%) while using **39.9% fewer tokens**.
|
||||
|
||||
**Note on CSV:** Excluded from ranking as it only supports 109 of 209 questions (flat tabular data only). While CSV is highly token-efficient for simple tabular data, it cannot represent nested structures that other formats handle.
|
||||
|
||||
@@ -225,13 +225,13 @@ claude-haiku-4-5-20251001
|
||||
JSON compact ███████████░░░░░░░░░ 55.0% (115/209)
|
||||
CSV ██████████░░░░░░░░░░ 50.5% (55/109)
|
||||
|
||||
gemini-2.5-flash
|
||||
→ TOON ██████████████████░░ 87.6% (183/209)
|
||||
CSV █████████████████░░░ 86.2% (94/109)
|
||||
JSON compact ████████████████░░░░ 82.3% (172/209)
|
||||
YAML ████████████████░░░░ 79.4% (166/209)
|
||||
XML ████████████████░░░░ 79.4% (166/209)
|
||||
JSON ███████████████░░░░░ 77.0% (161/209)
|
||||
gemini-3-flash-preview
|
||||
XML ████████████████████ 98.1% (205/209)
|
||||
JSON ███████████████████░ 97.1% (203/209)
|
||||
YAML ███████████████████░ 97.1% (203/209)
|
||||
→ TOON ███████████████████░ 96.7% (202/209)
|
||||
JSON compact ███████████████████░ 96.7% (202/209)
|
||||
CSV ███████████████████░ 96.3% (105/109)
|
||||
|
||||
gpt-5-nano
|
||||
→ TOON ██████████████████░░ 90.9% (190/209)
|
||||
@@ -241,30 +241,30 @@ gpt-5-nano
|
||||
YAML █████████████████░░░ 87.1% (182/209)
|
||||
XML ████████████████░░░░ 80.9% (169/209)
|
||||
|
||||
grok-4-fast-non-reasoning
|
||||
→ TOON ███████████░░░░░░░░░ 57.4% (120/209)
|
||||
JSON ███████████░░░░░░░░░ 55.5% (116/209)
|
||||
JSON compact ███████████░░░░░░░░░ 54.5% (114/209)
|
||||
YAML ███████████░░░░░░░░░ 53.6% (112/209)
|
||||
XML ███████████░░░░░░░░░ 52.6% (110/209)
|
||||
CSV ██████████░░░░░░░░░░ 52.3% (57/109)
|
||||
grok-4-1-fast-non-reasoning
|
||||
→ TOON ████████████░░░░░░░░ 58.4% (122/209)
|
||||
YAML ████████████░░░░░░░░ 57.9% (121/209)
|
||||
JSON ███████████░░░░░░░░░ 56.5% (118/209)
|
||||
XML ███████████░░░░░░░░░ 54.1% (113/209)
|
||||
JSON compact ██████████░░░░░░░░░░ 52.2% (109/209)
|
||||
CSV ██████████░░░░░░░░░░ 51.4% (56/109)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> TOON achieves **73.9% accuracy** (vs JSON's 69.7%) while using **39.6% fewer tokens** on these datasets.
|
||||
> TOON achieves **76.4% accuracy** (vs JSON's 75.0%) while using **39.9% fewer tokens** on these datasets.
|
||||
|
||||
<details>
|
||||
<summary><strong>Performance by dataset, model, and question type</strong></summary>
|
||||
|
||||
#### Performance by Question Type
|
||||
|
||||
| Question Type | TOON | JSON compact | JSON | CSV | YAML | XML |
|
||||
| Question Type | TOON | JSON | YAML | JSON compact | XML | CSV |
|
||||
| ------------- | ---- | ---- | ---- | ---- | ---- | ---- |
|
||||
| Field Retrieval | 99.6% | 99.3% | 99.3% | 100.0% | 98.2% | 98.9% |
|
||||
| Aggregation | 54.4% | 47.2% | 48.8% | 44.0% | 47.6% | 41.3% |
|
||||
| Filtering | 56.3% | 57.3% | 50.5% | 49.1% | 51.0% | 47.9% |
|
||||
| Structure Awareness | 88.0% | 83.0% | 83.0% | 85.9% | 80.0% | 80.0% |
|
||||
| Structural Validation | 70.0% | 45.0% | 50.0% | 80.0% | 60.0% | 80.0% |
|
||||
| Field Retrieval | 99.6% | 99.3% | 98.5% | 98.5% | 98.9% | 100.0% |
|
||||
| Aggregation | 61.9% | 61.9% | 59.9% | 58.3% | 54.4% | 50.9% |
|
||||
| Filtering | 56.8% | 53.1% | 56.3% | 55.2% | 51.6% | 50.9% |
|
||||
| Structure Awareness | 89.0% | 87.0% | 84.0% | 84.0% | 81.0% | 85.9% |
|
||||
| Structural Validation | 70.0% | 60.0% | 60.0% | 55.0% | 85.0% | 80.0% |
|
||||
|
||||
#### Performance by Dataset
|
||||
|
||||
@@ -272,119 +272,119 @@ grok-4-fast-non-reasoning
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 72.0% | 2,352 | 118/164 |
|
||||
| `toon` | 73.8% | 2,518 | 121/164 |
|
||||
| `json-compact` | 69.5% | 3,953 | 114/164 |
|
||||
| `yaml` | 68.3% | 4,982 | 112/164 |
|
||||
| `json-pretty` | 68.3% | 6,360 | 112/164 |
|
||||
| `xml` | 69.5% | 7,324 | 114/164 |
|
||||
| `csv` | 73.2% | 2,334 | 120/164 |
|
||||
| `toon` | 73.2% | 2,498 | 120/164 |
|
||||
| `json-compact` | 73.8% | 3,924 | 121/164 |
|
||||
| `yaml` | 73.8% | 4,959 | 121/164 |
|
||||
| `json-pretty` | 73.8% | 6,331 | 121/164 |
|
||||
| `xml` | 74.4% | 7,296 | 122/164 |
|
||||
|
||||
##### E-commerce orders with nested structures
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 81.1% | 7,232 | 133/164 |
|
||||
| `json-compact` | 76.8% | 6,794 | 126/164 |
|
||||
| `yaml` | 75.6% | 8,347 | 124/164 |
|
||||
| `json-pretty` | 76.2% | 10,713 | 125/164 |
|
||||
| `xml` | 74.4% | 12,023 | 122/164 |
|
||||
| `toon` | 82.3% | 7,458 | 135/164 |
|
||||
| `json-compact` | 78.7% | 7,110 | 129/164 |
|
||||
| `yaml` | 79.9% | 8,755 | 131/164 |
|
||||
| `json-pretty` | 79.3% | 11,234 | 130/164 |
|
||||
| `xml` | 77.4% | 12,649 | 127/164 |
|
||||
|
||||
##### Time-series analytics data
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 73.3% | 1,406 | 88/120 |
|
||||
| `toon` | 72.5% | 1,548 | 87/120 |
|
||||
| `json-compact` | 71.7% | 2,349 | 86/120 |
|
||||
| `yaml` | 71.7% | 2,949 | 86/120 |
|
||||
| `json-pretty` | 68.3% | 3,676 | 82/120 |
|
||||
| `xml` | 68.3% | 4,384 | 82/120 |
|
||||
| `csv` | 75.0% | 1,411 | 90/120 |
|
||||
| `toon` | 78.3% | 1,553 | 94/120 |
|
||||
| `json-compact` | 74.2% | 2,354 | 89/120 |
|
||||
| `yaml` | 75.8% | 2,954 | 91/120 |
|
||||
| `json-pretty` | 75.0% | 3,681 | 90/120 |
|
||||
| `xml` | 72.5% | 4,389 | 87/120 |
|
||||
|
||||
##### Top 100 GitHub repositories
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 62.9% | 8,779 | 83/132 |
|
||||
| `csv` | 61.4% | 8,527 | 81/132 |
|
||||
| `yaml` | 59.8% | 13,141 | 79/132 |
|
||||
| `json-compact` | 55.3% | 11,464 | 73/132 |
|
||||
| `json-pretty` | 56.1% | 15,157 | 74/132 |
|
||||
| `xml` | 48.5% | 17,105 | 64/132 |
|
||||
| `csv` | 65.9% | 8,527 | 87/132 |
|
||||
| `toon` | 66.7% | 8,779 | 88/132 |
|
||||
| `yaml` | 65.2% | 13,141 | 86/132 |
|
||||
| `json-compact` | 59.8% | 11,464 | 79/132 |
|
||||
| `json-pretty` | 63.6% | 15,157 | 84/132 |
|
||||
| `xml` | 56.1% | 17,105 | 74/132 |
|
||||
|
||||
##### Semi-uniform event logs
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `json-compact` | 63.3% | 4,819 | 76/120 |
|
||||
| `toon` | 57.5% | 5,799 | 69/120 |
|
||||
| `json-pretty` | 59.2% | 6,797 | 71/120 |
|
||||
| `yaml` | 48.3% | 5,827 | 58/120 |
|
||||
| `xml` | 46.7% | 7,709 | 56/120 |
|
||||
| `json-compact` | 68.3% | 4,839 | 82/120 |
|
||||
| `toon` | 65.0% | 5,819 | 78/120 |
|
||||
| `json-pretty` | 69.2% | 6,817 | 83/120 |
|
||||
| `yaml` | 61.7% | 5,847 | 74/120 |
|
||||
| `xml` | 58.3% | 7,729 | 70/120 |
|
||||
|
||||
##### Deeply nested configuration
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `json-compact` | 92.2% | 574 | 107/116 |
|
||||
| `toon` | 95.7% | 666 | 111/116 |
|
||||
| `yaml` | 91.4% | 686 | 106/116 |
|
||||
| `json-pretty` | 94.0% | 932 | 109/116 |
|
||||
| `xml` | 92.2% | 1,018 | 107/116 |
|
||||
| `json-compact` | 90.5% | 568 | 105/116 |
|
||||
| `toon` | 94.8% | 655 | 110/116 |
|
||||
| `yaml` | 93.1% | 675 | 108/116 |
|
||||
| `json-pretty` | 92.2% | 924 | 107/116 |
|
||||
| `xml` | 91.4% | 1,013 | 106/116 |
|
||||
|
||||
##### Valid complete dataset (control)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `toon` | 100.0% | 544 | 4/4 |
|
||||
| `json-compact` | 100.0% | 795 | 4/4 |
|
||||
| `yaml` | 100.0% | 1,003 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,282 | 4/4 |
|
||||
| `csv` | 25.0% | 492 | 1/4 |
|
||||
| `xml` | 0.0% | 1,467 | 0/4 |
|
||||
| `toon` | 100.0% | 535 | 4/4 |
|
||||
| `json-compact` | 100.0% | 787 | 4/4 |
|
||||
| `yaml` | 100.0% | 992 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,274 | 4/4 |
|
||||
| `xml` | 25.0% | 1,462 | 1/4 |
|
||||
| `csv` | 0.0% | 483 | 0/4 |
|
||||
|
||||
##### Array truncated: 3 rows removed from end
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 425 | 4/4 |
|
||||
| `xml` | 100.0% | 1,251 | 4/4 |
|
||||
| `toon` | 0.0% | 474 | 0/4 |
|
||||
| `json-compact` | 0.0% | 681 | 0/4 |
|
||||
| `json-pretty` | 0.0% | 1,096 | 0/4 |
|
||||
| `yaml` | 0.0% | 859 | 0/4 |
|
||||
| `csv` | 100.0% | 413 | 4/4 |
|
||||
| `xml` | 100.0% | 1,243 | 4/4 |
|
||||
| `toon` | 0.0% | 462 | 0/4 |
|
||||
| `json-pretty` | 0.0% | 1,085 | 0/4 |
|
||||
| `yaml` | 0.0% | 843 | 0/4 |
|
||||
| `json-compact` | 0.0% | 670 | 0/4 |
|
||||
|
||||
##### Extra rows added beyond declared length
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 566 | 4/4 |
|
||||
| `toon` | 75.0% | 621 | 3/4 |
|
||||
| `xml` | 100.0% | 1,692 | 4/4 |
|
||||
| `yaml` | 75.0% | 1,157 | 3/4 |
|
||||
| `json-compact` | 50.0% | 917 | 2/4 |
|
||||
| `json-pretty` | 50.0% | 1,476 | 2/4 |
|
||||
| `csv` | 100.0% | 550 | 4/4 |
|
||||
| `toon` | 75.0% | 605 | 3/4 |
|
||||
| `json-compact` | 75.0% | 901 | 3/4 |
|
||||
| `xml` | 100.0% | 1,678 | 4/4 |
|
||||
| `yaml` | 75.0% | 1,138 | 3/4 |
|
||||
| `json-pretty` | 50.0% | 1,460 | 2/4 |
|
||||
|
||||
##### Inconsistent field count (missing salary in row 10)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 75.0% | 489 | 3/4 |
|
||||
| `yaml` | 100.0% | 996 | 4/4 |
|
||||
| `toon` | 100.0% | 1,019 | 4/4 |
|
||||
| `json-compact` | 75.0% | 790 | 3/4 |
|
||||
| `xml` | 100.0% | 1,458 | 4/4 |
|
||||
| `json-pretty` | 75.0% | 1,274 | 3/4 |
|
||||
| `csv` | 100.0% | 480 | 4/4 |
|
||||
| `json-compact` | 100.0% | 782 | 4/4 |
|
||||
| `yaml` | 100.0% | 985 | 4/4 |
|
||||
| `toon` | 100.0% | 1,008 | 4/4 |
|
||||
| `json-pretty` | 100.0% | 1,266 | 4/4 |
|
||||
| `xml` | 100.0% | 1,453 | 4/4 |
|
||||
|
||||
##### Missing required fields (no email in multiple rows)
|
||||
|
||||
| Format | Accuracy | Tokens | Correct/Total |
|
||||
| ------ | -------- | ------ | ------------- |
|
||||
| `csv` | 100.0% | 329 | 4/4 |
|
||||
| `xml` | 100.0% | 1,411 | 4/4 |
|
||||
| `toon` | 75.0% | 983 | 3/4 |
|
||||
| `yaml` | 25.0% | 960 | 1/4 |
|
||||
| `json-pretty` | 25.0% | 1,230 | 1/4 |
|
||||
| `json-compact` | 0.0% | 755 | 0/4 |
|
||||
| `csv` | 100.0% | 340 | 4/4 |
|
||||
| `xml` | 100.0% | 1,409 | 4/4 |
|
||||
| `toon` | 75.0% | 974 | 3/4 |
|
||||
| `json-pretty` | 50.0% | 1,225 | 2/4 |
|
||||
| `yaml` | 25.0% | 951 | 1/4 |
|
||||
| `json-compact` | 0.0% | 750 | 0/4 |
|
||||
|
||||
#### Performance by Model
|
||||
|
||||
@@ -399,16 +399,16 @@ grok-4-fast-non-reasoning
|
||||
| `json-compact` | 55.0% | 115/209 |
|
||||
| `csv` | 50.5% | 55/109 |
|
||||
|
||||
##### gemini-2.5-flash
|
||||
##### gemini-3-flash-preview
|
||||
|
||||
| Format | Accuracy | Correct/Total |
|
||||
| ------ | -------- | ------------- |
|
||||
| `toon` | 87.6% | 183/209 |
|
||||
| `csv` | 86.2% | 94/109 |
|
||||
| `json-compact` | 82.3% | 172/209 |
|
||||
| `yaml` | 79.4% | 166/209 |
|
||||
| `xml` | 79.4% | 166/209 |
|
||||
| `json-pretty` | 77.0% | 161/209 |
|
||||
| `xml` | 98.1% | 205/209 |
|
||||
| `json-pretty` | 97.1% | 203/209 |
|
||||
| `yaml` | 97.1% | 203/209 |
|
||||
| `toon` | 96.7% | 202/209 |
|
||||
| `json-compact` | 96.7% | 202/209 |
|
||||
| `csv` | 96.3% | 105/109 |
|
||||
|
||||
##### gpt-5-nano
|
||||
|
||||
@@ -421,16 +421,16 @@ grok-4-fast-non-reasoning
|
||||
| `yaml` | 87.1% | 182/209 |
|
||||
| `xml` | 80.9% | 169/209 |
|
||||
|
||||
##### grok-4-fast-non-reasoning
|
||||
##### grok-4-1-fast-non-reasoning
|
||||
|
||||
| Format | Accuracy | Correct/Total |
|
||||
| ------ | -------- | ------------- |
|
||||
| `toon` | 57.4% | 120/209 |
|
||||
| `json-pretty` | 55.5% | 116/209 |
|
||||
| `json-compact` | 54.5% | 114/209 |
|
||||
| `yaml` | 53.6% | 112/209 |
|
||||
| `xml` | 52.6% | 110/209 |
|
||||
| `csv` | 52.3% | 57/109 |
|
||||
| `toon` | 58.4% | 122/209 |
|
||||
| `yaml` | 57.9% | 121/209 |
|
||||
| `json-pretty` | 56.5% | 118/209 |
|
||||
| `xml` | 54.1% | 113/209 |
|
||||
| `json-compact` | 52.2% | 109/209 |
|
||||
| `csv` | 51.4% | 56/109 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -489,13 +489,13 @@ Eleven datasets designed to test different structural patterns and validation ca
|
||||
|
||||
#### Evaluation Process
|
||||
|
||||
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON compact, JSON, CSV, YAML, XML).
|
||||
1. **Format conversion**: Each dataset is converted to all 6 formats (TOON, JSON, YAML, JSON compact, XML, CSV).
|
||||
2. **Query LLM**: Each model receives formatted data + question in a prompt and extracts the answer.
|
||||
3. **Validate deterministically**: Answers are validated using type-aware comparison (e.g., `50000` = `$50,000`, `Engineering` = `engineering`, `2025-01-01` = `January 1, 2025`) without requiring an LLM judge.
|
||||
|
||||
#### Models & Configuration
|
||||
|
||||
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-2.5-flash`, `gpt-5-nano`, `grok-4-fast-non-reasoning`
|
||||
- **Models tested**: `claude-haiku-4-5-20251001`, `gemini-3-flash-preview`, `gpt-5-nano`, `grok-4-1-fast-non-reasoning`
|
||||
- **Token counting**: Using `gpt-tokenizer` with `o200k_base` encoding (GPT-5 tokenizer)
|
||||
- **Temperature**: Not set (models use their defaults)
|
||||
- **Total evaluations**: 209 questions × 6 formats × 4 models = 5,016 LLM calls
|
||||
@@ -517,34 +517,34 @@ Datasets with nested or semi-uniform structures. CSV excluded as it cannot prope
|
||||
```
|
||||
🛒 E-commerce orders with nested structures ┊ Tabular: 33%
|
||||
│
|
||||
TOON █████████████░░░░░░░ 72,771 tokens
|
||||
├─ vs JSON (−33.1%) 108,806 tokens
|
||||
├─ vs JSON compact (+5.5%) 68,975 tokens
|
||||
├─ vs YAML (−14.2%) 84,780 tokens
|
||||
└─ vs XML (−40.5%) 122,406 tokens
|
||||
TOON █████████████░░░░░░░ 73,126 tokens
|
||||
├─ vs JSON (−33.3%) 109,599 tokens
|
||||
├─ vs JSON compact (+5.3%) 69,459 tokens
|
||||
├─ vs YAML (−14.4%) 85,415 tokens
|
||||
└─ vs XML (−40.7%) 123,344 tokens
|
||||
|
||||
🧾 Semi-uniform event logs ┊ Tabular: 50%
|
||||
│
|
||||
TOON █████████████████░░░ 153,211 tokens
|
||||
├─ vs JSON (−15.0%) 180,176 tokens
|
||||
├─ vs JSON compact (+19.9%) 127,731 tokens
|
||||
├─ vs YAML (−0.8%) 154,505 tokens
|
||||
└─ vs XML (−25.2%) 204,777 tokens
|
||||
TOON █████████████████░░░ 154,084 tokens
|
||||
├─ vs JSON (−15.0%) 181,201 tokens
|
||||
├─ vs JSON compact (+19.9%) 128,529 tokens
|
||||
├─ vs YAML (−0.8%) 155,397 tokens
|
||||
└─ vs XML (−25.2%) 205,859 tokens
|
||||
|
||||
🧩 Deeply nested configuration ┊ Tabular: 0%
|
||||
│
|
||||
TOON ██████████████░░░░░░ 631 tokens
|
||||
├─ vs JSON (−31.3%) 919 tokens
|
||||
├─ vs JSON compact (+11.9%) 564 tokens
|
||||
├─ vs YAML (−6.2%) 673 tokens
|
||||
└─ vs XML (−37.4%) 1,008 tokens
|
||||
TOON ██████████████░░░░░░ 620 tokens
|
||||
├─ vs JSON (−31.9%) 911 tokens
|
||||
├─ vs JSON compact (+11.1%) 558 tokens
|
||||
├─ vs YAML (−6.3%) 662 tokens
|
||||
└─ vs XML (−38.2%) 1,003 tokens
|
||||
|
||||
──────────────────────────────────── Total ────────────────────────────────────
|
||||
TOON ████████████████░░░░ 226,613 tokens
|
||||
├─ vs JSON (−21.8%) 289,901 tokens
|
||||
├─ vs JSON compact (+14.9%) 197,270 tokens
|
||||
├─ vs YAML (−5.6%) 239,958 tokens
|
||||
└─ vs XML (−31.0%) 328,191 tokens
|
||||
TOON ████████████████░░░░ 227,830 tokens
|
||||
├─ vs JSON (−21.9%) 291,711 tokens
|
||||
├─ vs JSON compact (+14.7%) 198,546 tokens
|
||||
├─ vs YAML (−5.7%) 241,474 tokens
|
||||
└─ vs XML (−31.0%) 330,206 tokens
|
||||
```
|
||||
|
||||
#### Flat-Only Track
|
||||
@@ -554,21 +554,21 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
```
|
||||
👥 Uniform employee records ┊ Tabular: 100%
|
||||
│
|
||||
CSV ███████████████████░ 46,954 tokens
|
||||
TOON ████████████████████ 49,831 tokens (+6.1% vs CSV)
|
||||
├─ vs JSON (−60.7%) 126,860 tokens
|
||||
├─ vs JSON compact (−36.8%) 78,856 tokens
|
||||
├─ vs YAML (−50.0%) 99,706 tokens
|
||||
└─ vs XML (−66.0%) 146,444 tokens
|
||||
CSV ███████████████████░ 47,102 tokens
|
||||
TOON ████████████████████ 49,919 tokens (+6.0% vs CSV)
|
||||
├─ vs JSON (−60.7%) 127,063 tokens
|
||||
├─ vs JSON compact (−36.9%) 79,059 tokens
|
||||
├─ vs YAML (−50.1%) 100,011 tokens
|
||||
└─ vs XML (−65.9%) 146,579 tokens
|
||||
|
||||
📈 Time-series analytics data ┊ Tabular: 100%
|
||||
│
|
||||
CSV ██████████████████░░ 8,388 tokens
|
||||
TOON ████████████████████ 9,120 tokens (+8.7% vs CSV)
|
||||
├─ vs JSON (−59.0%) 22,250 tokens
|
||||
├─ vs JSON compact (−35.8%) 14,216 tokens
|
||||
├─ vs YAML (−48.9%) 17,863 tokens
|
||||
└─ vs XML (−65.7%) 26,621 tokens
|
||||
CSV ██████████████████░░ 8,383 tokens
|
||||
TOON ████████████████████ 9,115 tokens (+8.7% vs CSV)
|
||||
├─ vs JSON (−59.0%) 22,245 tokens
|
||||
├─ vs JSON compact (−35.9%) 14,211 tokens
|
||||
├─ vs YAML (−49.0%) 17,858 tokens
|
||||
└─ vs XML (−65.8%) 26,616 tokens
|
||||
|
||||
⭐ Top 100 GitHub repositories ┊ Tabular: 100%
|
||||
│
|
||||
@@ -580,12 +580,12 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
└─ vs XML (−48.9%) 17,095 tokens
|
||||
|
||||
──────────────────────────────────── Total ────────────────────────────────────
|
||||
CSV ███████████████████░ 63,854 tokens
|
||||
TOON ████████████████████ 67,695 tokens (+6.0% vs CSV)
|
||||
├─ vs JSON (−58.8%) 164,254 tokens
|
||||
├─ vs JSON compact (−35.2%) 104,526 tokens
|
||||
├─ vs YAML (−48.2%) 130,697 tokens
|
||||
└─ vs XML (−64.4%) 190,160 tokens
|
||||
CSV ███████████████████░ 63,997 tokens
|
||||
TOON ████████████████████ 67,778 tokens (+5.9% vs CSV)
|
||||
├─ vs JSON (−58.8%) 164,452 tokens
|
||||
├─ vs JSON compact (−35.3%) 104,724 tokens
|
||||
├─ vs YAML (−48.3%) 130,997 tokens
|
||||
└─ vs XML (−64.4%) 190,290 tokens
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -595,64 +595,64 @@ Datasets with flat tabular structures where CSV is applicable.
|
||||
|
||||
**Savings:** 13,130 tokens (59.0% reduction vs JSON)
|
||||
|
||||
**JSON** (22,250 tokens):
|
||||
**JSON** (22,245 tokens):
|
||||
|
||||
```json
|
||||
{
|
||||
"metrics": [
|
||||
{
|
||||
"date": "2025-01-01",
|
||||
"views": 5715,
|
||||
"clicks": 211,
|
||||
"conversions": 28,
|
||||
"revenue": 7976.46,
|
||||
"bounceRate": 0.47
|
||||
"views": 6138,
|
||||
"clicks": 174,
|
||||
"conversions": 12,
|
||||
"revenue": 2712.49,
|
||||
"bounceRate": 0.35
|
||||
},
|
||||
{
|
||||
"date": "2025-01-02",
|
||||
"views": 7103,
|
||||
"clicks": 393,
|
||||
"conversions": 28,
|
||||
"revenue": 8360.53,
|
||||
"bounceRate": 0.32
|
||||
"views": 4616,
|
||||
"clicks": 274,
|
||||
"conversions": 34,
|
||||
"revenue": 9156.29,
|
||||
"bounceRate": 0.56
|
||||
},
|
||||
{
|
||||
"date": "2025-01-03",
|
||||
"views": 7248,
|
||||
"clicks": 378,
|
||||
"conversions": 24,
|
||||
"revenue": 3212.57,
|
||||
"bounceRate": 0.5
|
||||
"views": 4460,
|
||||
"clicks": 143,
|
||||
"conversions": 8,
|
||||
"revenue": 1317.98,
|
||||
"bounceRate": 0.59
|
||||
},
|
||||
{
|
||||
"date": "2025-01-04",
|
||||
"views": 2927,
|
||||
"clicks": 77,
|
||||
"conversions": 11,
|
||||
"revenue": 1211.69,
|
||||
"bounceRate": 0.62
|
||||
"views": 4740,
|
||||
"clicks": 125,
|
||||
"conversions": 13,
|
||||
"revenue": 2934.77,
|
||||
"bounceRate": 0.37
|
||||
},
|
||||
{
|
||||
"date": "2025-01-05",
|
||||
"views": 3530,
|
||||
"clicks": 82,
|
||||
"conversions": 8,
|
||||
"revenue": 462.77,
|
||||
"bounceRate": 0.56
|
||||
"views": 6428,
|
||||
"clicks": 369,
|
||||
"conversions": 19,
|
||||
"revenue": 1317.24,
|
||||
"bounceRate": 0.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**TOON** (9,120 tokens):
|
||||
**TOON** (9,115 tokens):
|
||||
|
||||
```
|
||||
metrics[5]{date,views,clicks,conversions,revenue,bounceRate}:
|
||||
2025-01-01,5715,211,28,7976.46,0.47
|
||||
2025-01-02,7103,393,28,8360.53,0.32
|
||||
2025-01-03,7248,378,24,3212.57,0.5
|
||||
2025-01-04,2927,77,11,1211.69,0.62
|
||||
2025-01-05,3530,82,8,462.77,0.56
|
||||
2025-01-01,6138,174,12,2712.49,0.35
|
||||
2025-01-02,4616,274,34,9156.29,0.56
|
||||
2025-01-03,4460,143,8,1317.98,0.59
|
||||
2025-01-04,4740,125,13,2934.77,0.37
|
||||
2025-01-05,6428,369,19,1317.24,0.3
|
||||
```
|
||||
|
||||
---
|
||||
@@ -828,7 +828,7 @@ The [TOON Playground](https://toonformat.dev/playground) lets you convert JSON t
|
||||
|
||||
### VS Code
|
||||
|
||||
[TOON Language Support](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) - Syntax highlighting, validation, conversion, and token analysis.
|
||||
[TOON Language Support](https://marketplace.visualstudio.com/items?itemName=vishalraut.vscode-toon) – Syntax highlighting, validation, conversion, and token analysis.
|
||||
|
||||
```bash
|
||||
code --install-extension vishalraut.vscode-toon
|
||||
@@ -836,11 +836,11 @@ code --install-extension vishalraut.vscode-toon
|
||||
|
||||
### Tree-sitter Grammar
|
||||
|
||||
[tree-sitter-toon](https://github.com/3swordman/tree-sitter-toon) - Grammar for Tree-sitter-compatible editors (Neovim, Helix, Emacs, Zed).
|
||||
[tree-sitter-toon](https://github.com/3swordman/tree-sitter-toon) – Grammar for Tree-sitter-compatible editors (Neovim, Helix, Emacs, Zed).
|
||||
|
||||
### Neovim
|
||||
|
||||
[toon.nvim](https://github.com/thalesgelinger/toon.nvim) - Lua-based plugin.
|
||||
[toon.nvim](https://github.com/thalesgelinger/toon.nvim) – Lua-based plugin.
|
||||
|
||||
### Other Editors
|
||||
|
||||
@@ -897,7 +897,7 @@ Comprehensive guides, references, and resources to help you get the most out of
|
||||
|
||||
### Tools & Integration
|
||||
|
||||
- [CLI](https://toonformat.dev/cli/) – Command-line tool for JSON↔TOON conversions
|
||||
- [CLI](https://toonformat.dev/cli/) – Command-line tool for JSON↔TOON conversions
|
||||
- [Using TOON with LLMs](https://toonformat.dev/guide/llm-prompts) – Prompting strategies & validation
|
||||
- [Playgrounds](https://toonformat.dev/ecosystem/tools-and-playgrounds) – Interactive tools
|
||||
|
||||
@@ -909,41 +909,9 @@ Comprehensive guides, references, and resources to help you get the most out of
|
||||
|
||||
## Other Implementations
|
||||
|
||||
> [!NOTE]
|
||||
> When implementing TOON in other languages, please follow the [Specification](https://github.com/toon-format/spec/blob/main/SPEC.md) to ensure compatibility across implementations. The [conformance tests](https://github.com/toon-format/spec/tree/main/tests) provide language-agnostic test fixtures that validate your implementations.
|
||||
TOON has official and community implementations across multiple languages including Python, Rust, Go, Java, Swift, .NET, and many more.
|
||||
|
||||
### Official Implementations
|
||||
|
||||
> [!TIP]
|
||||
> These implementations are actively being developed by dedicated teams. Contributions are welcome! Join the effort by opening issues, submitting PRs, or discussing implementation details in the respective repositories.
|
||||
|
||||
- **.NET:** [toon_format](https://github.com/toon-format/toon-dotnet) *(in development)*
|
||||
- **Dart:** [toon](https://github.com/toon-format/toon-dart) *(in development)*
|
||||
- **Go:** [toon-go](https://github.com/toon-format/toon-go) *(in development)*
|
||||
- **Java:** [JToon](https://github.com/toon-format/toon-java)
|
||||
- **Julia:** [ToonFormat.jl](https://github.com/toon-format/ToonFormat.jl)
|
||||
- **Python:** [toon_format](https://github.com/toon-format/toon-python)
|
||||
- **Rust:** [toon_format](https://github.com/toon-format/toon-rust)
|
||||
|
||||
### Community Implementations
|
||||
|
||||
- **Apex:** [ApexToon](https://github.com/Eacaw/ApexToon)
|
||||
- **C++:** [ctoon](https://github.com/mohammadraziei/ctoon)
|
||||
- **Clojure:** [toon](https://github.com/vadelabs/toon)
|
||||
- **Crystal:** [toon-crystal](https://github.com/mamantoha/toon-crystal)
|
||||
- **Elixir:** [toon_ex](https://github.com/kentaro/toon_ex)
|
||||
- **Gleam:** [toon_codec](https://github.com/axelbellec/toon_codec)
|
||||
- **Go:** [gotoon](https://github.com/alpkeskin/gotoon)
|
||||
- **Scala:** [toon4s](https://github.com/vim89/toon4s)
|
||||
- **Lua/Neovim:** [toon.nvim](https://github.com/thalesgelinger/toon.nvim)
|
||||
- **OCaml:** [ocaml-toon](https://github.com/davesnx/ocaml-toon)
|
||||
- **Perl:** [Data::TOON](https://github.com/ytnobody/p5-Data-TOON)
|
||||
- **PHP:** [toon-php](https://github.com/HelgeSverre/toon-php)
|
||||
- **Laravel Framework:** [laravel-toon](https://github.com/jobmetric/laravel-toon)
|
||||
- **R**: [toon](https://github.com/laresbernardo/toon)
|
||||
- **Ruby:** [toon-ruby](https://github.com/andrepcg/toon-ruby)
|
||||
- **Swift:** [TOONEncoder](https://github.com/mattt/TOONEncoder)
|
||||
- **Kotlin:** [Kotlin-Toon Encoder/Decoder](https://github.com/vexpera-br/kotlin-toon)
|
||||
See the full list of implementations in the [documentation](https://toonformat.dev/ecosystem/implementations).
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@toon-format/toon",
|
||||
"type": "module",
|
||||
"version": "2.1.0",
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"version": "2.2.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"description": "Token-Oriented Object Notation (TOON) – Compact, human-readable, schema-aware encoding of JSON for LLM prompts",
|
||||
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
||||
"license": "MIT",
|
||||
@@ -38,6 +38,6 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@toon-format/spec": "^3.0.0"
|
||||
"@toon-format/spec": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ArrayHeaderInfo, DecodeStreamOptions, Depth, JsonPrimitive, JsonStreamEvent, ParsedLine } from '../types'
|
||||
import type { StreamingScanState } from './scanner'
|
||||
import { COLON, DEFAULT_DELIMITER, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants'
|
||||
import { findClosingQuote } from '../shared/string-utils'
|
||||
import { isArrayHeaderContent, isKeyValueContent, mapRowValuesToPrimitives, parseArrayHeaderLine, parseDelimitedValues, parseKeyToken, parsePrimitiveToken } from './parser'
|
||||
import { createScanState, parseLinesAsync, parseLinesSync } from './scanner'
|
||||
import { assertExpectedCount, validateNoBlankLinesInRange, validateNoExtraListItems, validateNoExtraTabularRows } from './validation'
|
||||
import type { ArrayHeaderInfo, DecodeStreamOptions, Depth, JsonPrimitive, JsonStreamEvent, ParsedLine } from '../types.ts'
|
||||
import type { StreamingScanState } from './scanner.ts'
|
||||
import { COLON, DEFAULT_DELIMITER, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants.ts'
|
||||
import { findClosingQuote } from '../shared/string-utils.ts'
|
||||
import { ToonDecodeError, withLine } from './errors.ts'
|
||||
import { isArrayHeaderContent, isKeyValueContent, mapRowValuesToPrimitives, parseArrayHeaderLine, parseDelimitedValues, parseKeyToken, parsePrimitiveToken } from './parser.ts'
|
||||
import { createScanState, parseLinesAsync, parseLinesSync } from './scanner.ts'
|
||||
import { assertExpectedCount, validateNoBlankLinesInRange, validateNoExtraListItems, validateNoExtraTabularRows } from './validation.ts'
|
||||
|
||||
interface DecoderContext { indent: number, strict: boolean }
|
||||
|
||||
@@ -141,10 +142,10 @@ export function* decodeStreamSync(
|
||||
|
||||
// Check for root array
|
||||
if (isArrayHeaderContent(first.content)) {
|
||||
const headerInfo = parseArrayHeaderLine(first.content, DEFAULT_DELIMITER)
|
||||
const headerInfo = withLine(first, () => parseArrayHeaderLine(first.content, DEFAULT_DELIMITER))
|
||||
if (headerInfo) {
|
||||
cursor.advanceSync()
|
||||
yield* decodeArrayFromHeaderSync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions)
|
||||
yield* decodeArrayFromHeaderSync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions, first)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -154,13 +155,13 @@ export function* decodeStreamSync(
|
||||
const hasMore = !cursor.atEndSync()
|
||||
if (!hasMore && !isKeyValueLineSync(first)) {
|
||||
// Single non-key-value line is root primitive
|
||||
yield { type: 'primitive', value: parsePrimitiveToken(first.content.trim()) }
|
||||
yield { type: 'primitive', value: withLine(first, () => parsePrimitiveToken(first.content.trim())) }
|
||||
return
|
||||
}
|
||||
|
||||
// Root object
|
||||
yield { type: 'startObject' }
|
||||
yield* decodeKeyValueSync(first.content, cursor, 0, resolvedOptions)
|
||||
yield* decodeKeyValueSync(first, cursor, 0, resolvedOptions)
|
||||
|
||||
// Process remaining object fields
|
||||
while (!cursor.atEndSync()) {
|
||||
@@ -170,28 +171,30 @@ export function* decodeStreamSync(
|
||||
}
|
||||
|
||||
cursor.advanceSync()
|
||||
yield* decodeKeyValueSync(line.content, cursor, 0, resolvedOptions)
|
||||
yield* decodeKeyValueSync(line, cursor, 0, resolvedOptions)
|
||||
}
|
||||
|
||||
yield { type: 'endObject' }
|
||||
}
|
||||
|
||||
function* decodeKeyValueSync(
|
||||
content: string,
|
||||
line: ParsedLine,
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
): Generator<JsonStreamEvent> {
|
||||
const content = line.content
|
||||
|
||||
// Check for array header first
|
||||
const arrayHeader = parseArrayHeaderLine(content, DEFAULT_DELIMITER)
|
||||
if (arrayHeader && arrayHeader.header.key) {
|
||||
const arrayHeader = withLine(line, () => parseArrayHeaderLine(content, DEFAULT_DELIMITER))
|
||||
if (arrayHeader && arrayHeader.header.key !== undefined) {
|
||||
yield { type: 'key', key: arrayHeader.header.key }
|
||||
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options)
|
||||
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, line)
|
||||
return
|
||||
}
|
||||
|
||||
// Regular key-value pair
|
||||
const { key, isQuoted } = parseKeyToken(content, 0)
|
||||
const { key, isQuoted } = withLine(line, () => parseKeyToken(content, 0))
|
||||
const colonIndex = content.indexOf(COLON, key.length)
|
||||
const rest = colonIndex >= 0 ? content.slice(colonIndex + 1).trim() : ''
|
||||
|
||||
@@ -214,7 +217,7 @@ function* decodeKeyValueSync(
|
||||
}
|
||||
|
||||
// Inline primitive value
|
||||
yield { type: 'primitive', value: parsePrimitiveToken(rest) }
|
||||
yield { type: 'primitive', value: withLine(line, () => parsePrimitiveToken(rest)) }
|
||||
}
|
||||
|
||||
function* decodeObjectFieldsSync(
|
||||
@@ -236,7 +239,7 @@ function* decodeObjectFieldsSync(
|
||||
|
||||
if (line.depth === computedDepth) {
|
||||
cursor.advanceSync()
|
||||
yield* decodeKeyValueSync(line.content, cursor, computedDepth, options)
|
||||
yield* decodeKeyValueSync(line, cursor, computedDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
@@ -250,25 +253,26 @@ function* decodeArrayFromHeaderSync(
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): Generator<JsonStreamEvent> {
|
||||
yield { type: 'startArray', length: header.length }
|
||||
|
||||
// Inline primitive array
|
||||
if (inlineValues) {
|
||||
yield* decodeInlinePrimitiveArraySync(header, inlineValues, options)
|
||||
yield* decodeInlinePrimitiveArraySync(header, inlineValues, options, headerLine)
|
||||
yield { type: 'endArray' }
|
||||
return
|
||||
}
|
||||
|
||||
// Tabular array
|
||||
if (header.fields && header.fields.length > 0) {
|
||||
yield* decodeTabularArraySync(header, cursor, baseDepth, options)
|
||||
yield* decodeTabularArraySync(header, cursor, baseDepth, options, headerLine)
|
||||
yield { type: 'endArray' }
|
||||
return
|
||||
}
|
||||
|
||||
// List array
|
||||
yield* decodeListArraySync(header, cursor, baseDepth, options)
|
||||
yield* decodeListArraySync(header, cursor, baseDepth, options, headerLine)
|
||||
yield { type: 'endArray' }
|
||||
}
|
||||
|
||||
@@ -276,16 +280,17 @@ function* decodeInlinePrimitiveArraySync(
|
||||
header: ArrayHeaderInfo,
|
||||
inlineValues: string,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): Generator<JsonStreamEvent> {
|
||||
if (!inlineValues.trim()) {
|
||||
assertExpectedCount(0, header.length, 'inline array items', options)
|
||||
assertExpectedCount(0, header.length, 'inline array items', options, headerLine)
|
||||
return
|
||||
}
|
||||
|
||||
const values = parseDelimitedValues(inlineValues, header.delimiter)
|
||||
const primitives = mapRowValuesToPrimitives(values)
|
||||
const values = withLine(headerLine, () => parseDelimitedValues(inlineValues, header.delimiter))
|
||||
const primitives = withLine(headerLine, () => mapRowValuesToPrimitives(values))
|
||||
|
||||
assertExpectedCount(primitives.length, header.length, 'inline array items', options)
|
||||
assertExpectedCount(primitives.length, header.length, 'inline array items', options, headerLine)
|
||||
|
||||
for (const primitive of primitives) {
|
||||
yield { type: 'primitive', value: primitive }
|
||||
@@ -297,11 +302,13 @@ function* decodeTabularArraySync(
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): Generator<JsonStreamEvent> {
|
||||
const rowDepth = baseDepth + 1
|
||||
let rowCount = 0
|
||||
let startLine: number | undefined
|
||||
let endLine: number | undefined
|
||||
let lastRowLine: ParsedLine = headerLine
|
||||
|
||||
while (!cursor.atEndSync() && rowCount < header.length) {
|
||||
const line = cursor.peekSync()
|
||||
@@ -314,12 +321,13 @@ function* decodeTabularArraySync(
|
||||
startLine = line.lineNumber
|
||||
}
|
||||
endLine = line.lineNumber
|
||||
lastRowLine = line
|
||||
|
||||
cursor.advanceSync()
|
||||
const values = parseDelimitedValues(line.content, header.delimiter)
|
||||
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options)
|
||||
const values = withLine(line, () => parseDelimitedValues(line.content, header.delimiter))
|
||||
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options, line)
|
||||
|
||||
const primitives = mapRowValuesToPrimitives(values)
|
||||
const primitives = withLine(line, () => mapRowValuesToPrimitives(values))
|
||||
yield* yieldObjectFromFields(header.fields!, primitives)
|
||||
|
||||
rowCount++
|
||||
@@ -329,7 +337,7 @@ function* decodeTabularArraySync(
|
||||
}
|
||||
}
|
||||
|
||||
assertExpectedCount(rowCount, header.length, 'tabular rows', options)
|
||||
assertExpectedCount(rowCount, header.length, 'tabular rows', options, lastRowLine)
|
||||
|
||||
if (options.strict && startLine !== undefined && endLine !== undefined) {
|
||||
validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, 'tabular array')
|
||||
@@ -346,11 +354,13 @@ function* decodeListArraySync(
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): Generator<JsonStreamEvent> {
|
||||
const itemDepth = baseDepth + 1
|
||||
let itemCount = 0
|
||||
let startLine: number | undefined
|
||||
let endLine: number | undefined
|
||||
let lastItemLine: ParsedLine = headerLine
|
||||
|
||||
while (!cursor.atEndSync() && itemCount < header.length) {
|
||||
const line = cursor.peekSync()
|
||||
@@ -365,12 +375,14 @@ function* decodeListArraySync(
|
||||
startLine = line.lineNumber
|
||||
}
|
||||
endLine = line.lineNumber
|
||||
lastItemLine = line
|
||||
|
||||
yield* decodeListItemSync(cursor, itemDepth, options)
|
||||
|
||||
const currentLine = cursor.current()
|
||||
if (currentLine) {
|
||||
endLine = currentLine.lineNumber
|
||||
lastItemLine = currentLine
|
||||
}
|
||||
|
||||
itemCount++
|
||||
@@ -380,7 +392,7 @@ function* decodeListArraySync(
|
||||
}
|
||||
}
|
||||
|
||||
assertExpectedCount(itemCount, header.length, 'list array items', options)
|
||||
assertExpectedCount(itemCount, header.length, 'list array items', options, lastItemLine)
|
||||
|
||||
if (options.strict && startLine !== undefined && endLine !== undefined) {
|
||||
validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, 'list array')
|
||||
@@ -405,51 +417,19 @@ function* decodeListItemSync(
|
||||
let afterHyphen: string
|
||||
|
||||
if (line.content === LIST_ITEM_MARKER) {
|
||||
// Bare list item marker: either an empty object or fields at depth +1
|
||||
const followDepth = baseDepth + 1
|
||||
const nextLine = cursor.peekSync()
|
||||
|
||||
if (!nextLine || nextLine.depth < followDepth) {
|
||||
// No fields at the next depth: treat as empty object
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
|
||||
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
// Fields at depth +1: parse them as an object
|
||||
yield { type: 'startObject' }
|
||||
|
||||
while (!cursor.atEndSync()) {
|
||||
const fieldLine = cursor.peekSync()
|
||||
if (!fieldLine || fieldLine.depth < followDepth) {
|
||||
break
|
||||
}
|
||||
|
||||
if (fieldLine.depth === followDepth && !fieldLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
cursor.advanceSync()
|
||||
yield* decodeKeyValueSync(fieldLine.content, cursor, followDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
else {
|
||||
// Next line is another list item or at a different depth: treat as empty object
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
// Bare list item marker: always an empty object
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
else if (line.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
|
||||
}
|
||||
else {
|
||||
throw new SyntaxError(`Expected list item to start with "${LIST_ITEM_PREFIX}"`)
|
||||
throw new ToonDecodeError(
|
||||
`Expected list item to start with "${LIST_ITEM_PREFIX}"`,
|
||||
{ line: line.lineNumber, source: line.raw },
|
||||
)
|
||||
}
|
||||
|
||||
if (!afterHyphen.trim()) {
|
||||
@@ -458,25 +438,27 @@ function* decodeListItemSync(
|
||||
return
|
||||
}
|
||||
|
||||
const itemLine: ParsedLine = { ...line, content: afterHyphen }
|
||||
|
||||
// Check for array header after hyphen
|
||||
if (isArrayHeaderContent(afterHyphen)) {
|
||||
const arrayHeader = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
|
||||
const arrayHeader = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
|
||||
if (arrayHeader) {
|
||||
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options)
|
||||
yield* decodeArrayFromHeaderSync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, itemLine)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tabular-first list-item object: `- key[N]{fields}:`
|
||||
const headerInfo = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
|
||||
if (headerInfo && headerInfo.header.key && headerInfo.header.fields) {
|
||||
const headerInfo = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
|
||||
if (headerInfo && headerInfo.header.key !== undefined && headerInfo.header.fields !== undefined) {
|
||||
// Object with tabular array as first field
|
||||
const header = headerInfo.header
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'key', key: header.key! }
|
||||
|
||||
// Use baseDepth + 1 for the array so rows are at baseDepth + 2
|
||||
yield* decodeArrayFromHeaderSync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options)
|
||||
yield* decodeArrayFromHeaderSync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options, itemLine)
|
||||
|
||||
// Read sibling fields at depth = baseDepth + 1
|
||||
const followDepth = baseDepth + 1
|
||||
@@ -488,7 +470,7 @@ function* decodeListItemSync(
|
||||
|
||||
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
cursor.advanceSync()
|
||||
yield* decodeKeyValueSync(nextLine.content, cursor, followDepth, options)
|
||||
yield* decodeKeyValueSync(nextLine, cursor, followDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
@@ -502,7 +484,7 @@ function* decodeListItemSync(
|
||||
// Check for object first field after hyphen
|
||||
if (isKeyValueContent(afterHyphen)) {
|
||||
yield { type: 'startObject' }
|
||||
yield* decodeKeyValueSync(afterHyphen, cursor, baseDepth + 1, options)
|
||||
yield* decodeKeyValueSync(itemLine, cursor, baseDepth + 1, options)
|
||||
|
||||
// Read subsequent fields
|
||||
const followDepth = baseDepth + 1
|
||||
@@ -514,7 +496,7 @@ function* decodeListItemSync(
|
||||
|
||||
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
cursor.advanceSync()
|
||||
yield* decodeKeyValueSync(nextLine.content, cursor, followDepth, options)
|
||||
yield* decodeKeyValueSync(nextLine, cursor, followDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
@@ -526,7 +508,7 @@ function* decodeListItemSync(
|
||||
}
|
||||
|
||||
// Primitive value
|
||||
yield { type: 'primitive', value: parsePrimitiveToken(afterHyphen) }
|
||||
yield { type: 'primitive', value: withLine(itemLine, () => parsePrimitiveToken(afterHyphen)) }
|
||||
}
|
||||
|
||||
function isKeyValueLineSync(line: ParsedLine): boolean {
|
||||
@@ -579,10 +561,10 @@ export async function* decodeStream(
|
||||
|
||||
// Check for root array
|
||||
if (isArrayHeaderContent(first.content)) {
|
||||
const headerInfo = parseArrayHeaderLine(first.content, DEFAULT_DELIMITER)
|
||||
const headerInfo = withLine(first, () => parseArrayHeaderLine(first.content, DEFAULT_DELIMITER))
|
||||
if (headerInfo) {
|
||||
await cursor.advance()
|
||||
yield* decodeArrayFromHeaderAsync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions)
|
||||
yield* decodeArrayFromHeaderAsync(headerInfo.header, headerInfo.inlineValues, cursor, 0, resolvedOptions, first)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -591,13 +573,13 @@ export async function* decodeStream(
|
||||
await cursor.advance()
|
||||
const hasMore = !(await cursor.atEnd())
|
||||
if (!hasMore && !isKeyValueLineSync(first)) {
|
||||
yield { type: 'primitive', value: parsePrimitiveToken(first.content.trim()) }
|
||||
yield { type: 'primitive', value: withLine(first, () => parsePrimitiveToken(first.content.trim())) }
|
||||
return
|
||||
}
|
||||
|
||||
// Root object
|
||||
yield { type: 'startObject' }
|
||||
yield* decodeKeyValueAsync(first.content, cursor, 0, resolvedOptions)
|
||||
yield* decodeKeyValueAsync(first, cursor, 0, resolvedOptions)
|
||||
|
||||
// Process remaining object fields
|
||||
while (!(await cursor.atEnd())) {
|
||||
@@ -606,7 +588,7 @@ export async function* decodeStream(
|
||||
break
|
||||
}
|
||||
await cursor.advance()
|
||||
yield* decodeKeyValueAsync(line.content, cursor, 0, resolvedOptions)
|
||||
yield* decodeKeyValueAsync(line, cursor, 0, resolvedOptions)
|
||||
}
|
||||
|
||||
yield { type: 'endObject' }
|
||||
@@ -618,21 +600,23 @@ export async function* decodeStream(
|
||||
}
|
||||
|
||||
async function* decodeKeyValueAsync(
|
||||
content: string,
|
||||
line: ParsedLine,
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
): AsyncGenerator<JsonStreamEvent> {
|
||||
const content = line.content
|
||||
|
||||
// Check for array header first
|
||||
const arrayHeader = parseArrayHeaderLine(content, DEFAULT_DELIMITER)
|
||||
if (arrayHeader && arrayHeader.header.key) {
|
||||
const arrayHeader = withLine(line, () => parseArrayHeaderLine(content, DEFAULT_DELIMITER))
|
||||
if (arrayHeader && arrayHeader.header.key !== undefined) {
|
||||
yield { type: 'key', key: arrayHeader.header.key }
|
||||
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options)
|
||||
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, line)
|
||||
return
|
||||
}
|
||||
|
||||
// Regular key-value pair
|
||||
const { key, isQuoted } = parseKeyToken(content, 0)
|
||||
const { key, isQuoted } = withLine(line, () => parseKeyToken(content, 0))
|
||||
const colonIndex = content.indexOf(COLON, key.length)
|
||||
const rest = colonIndex >= 0 ? content.slice(colonIndex + 1).trim() : ''
|
||||
|
||||
@@ -655,7 +639,7 @@ async function* decodeKeyValueAsync(
|
||||
}
|
||||
|
||||
// Inline primitive value
|
||||
yield { type: 'primitive', value: parsePrimitiveToken(rest) }
|
||||
yield { type: 'primitive', value: withLine(line, () => parsePrimitiveToken(rest)) }
|
||||
}
|
||||
|
||||
async function* decodeObjectFieldsAsync(
|
||||
@@ -677,7 +661,7 @@ async function* decodeObjectFieldsAsync(
|
||||
|
||||
if (line.depth === computedDepth) {
|
||||
await cursor.advance()
|
||||
yield* decodeKeyValueAsync(line.content, cursor, computedDepth, options)
|
||||
yield* decodeKeyValueAsync(line, cursor, computedDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
@@ -691,25 +675,26 @@ async function* decodeArrayFromHeaderAsync(
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): AsyncGenerator<JsonStreamEvent> {
|
||||
yield { type: 'startArray', length: header.length }
|
||||
|
||||
// Inline primitive array
|
||||
if (inlineValues) {
|
||||
yield* decodeInlinePrimitiveArraySync(header, inlineValues, options)
|
||||
yield* decodeInlinePrimitiveArraySync(header, inlineValues, options, headerLine)
|
||||
yield { type: 'endArray' }
|
||||
return
|
||||
}
|
||||
|
||||
// Tabular array
|
||||
if (header.fields && header.fields.length > 0) {
|
||||
yield* decodeTabularArrayAsync(header, cursor, baseDepth, options)
|
||||
yield* decodeTabularArrayAsync(header, cursor, baseDepth, options, headerLine)
|
||||
yield { type: 'endArray' }
|
||||
return
|
||||
}
|
||||
|
||||
// List array
|
||||
yield* decodeListArrayAsync(header, cursor, baseDepth, options)
|
||||
yield* decodeListArrayAsync(header, cursor, baseDepth, options, headerLine)
|
||||
yield { type: 'endArray' }
|
||||
}
|
||||
|
||||
@@ -718,11 +703,13 @@ async function* decodeTabularArrayAsync(
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): AsyncGenerator<JsonStreamEvent> {
|
||||
const rowDepth = baseDepth + 1
|
||||
let rowCount = 0
|
||||
let startLine: number | undefined
|
||||
let endLine: number | undefined
|
||||
let lastRowLine: ParsedLine = headerLine
|
||||
|
||||
while (!(await cursor.atEnd()) && rowCount < header.length) {
|
||||
const line = await cursor.peek()
|
||||
@@ -735,12 +722,13 @@ async function* decodeTabularArrayAsync(
|
||||
startLine = line.lineNumber
|
||||
}
|
||||
endLine = line.lineNumber
|
||||
lastRowLine = line
|
||||
|
||||
await cursor.advance()
|
||||
const values = parseDelimitedValues(line.content, header.delimiter)
|
||||
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options)
|
||||
const values = withLine(line, () => parseDelimitedValues(line.content, header.delimiter))
|
||||
assertExpectedCount(values.length, header.fields!.length, 'tabular row values', options, line)
|
||||
|
||||
const primitives = mapRowValuesToPrimitives(values)
|
||||
const primitives = withLine(line, () => mapRowValuesToPrimitives(values))
|
||||
yield* yieldObjectFromFields(header.fields!, primitives)
|
||||
|
||||
rowCount++
|
||||
@@ -750,7 +738,7 @@ async function* decodeTabularArrayAsync(
|
||||
}
|
||||
}
|
||||
|
||||
assertExpectedCount(rowCount, header.length, 'tabular rows', options)
|
||||
assertExpectedCount(rowCount, header.length, 'tabular rows', options, lastRowLine)
|
||||
|
||||
if (options.strict && startLine !== undefined && endLine !== undefined) {
|
||||
validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, 'tabular array')
|
||||
@@ -767,11 +755,13 @@ async function* decodeListArrayAsync(
|
||||
cursor: StreamingLineCursor,
|
||||
baseDepth: Depth,
|
||||
options: DecoderContext,
|
||||
headerLine: ParsedLine,
|
||||
): AsyncGenerator<JsonStreamEvent> {
|
||||
const itemDepth = baseDepth + 1
|
||||
let itemCount = 0
|
||||
let startLine: number | undefined
|
||||
let endLine: number | undefined
|
||||
let lastItemLine: ParsedLine = headerLine
|
||||
|
||||
while (!(await cursor.atEnd()) && itemCount < header.length) {
|
||||
const line = await cursor.peek()
|
||||
@@ -786,12 +776,14 @@ async function* decodeListArrayAsync(
|
||||
startLine = line.lineNumber
|
||||
}
|
||||
endLine = line.lineNumber
|
||||
lastItemLine = line
|
||||
|
||||
yield* decodeListItemAsync(cursor, itemDepth, options)
|
||||
|
||||
const currentLine = cursor.current()
|
||||
if (currentLine) {
|
||||
endLine = currentLine.lineNumber
|
||||
lastItemLine = currentLine
|
||||
}
|
||||
|
||||
itemCount++
|
||||
@@ -801,7 +793,7 @@ async function* decodeListArrayAsync(
|
||||
}
|
||||
}
|
||||
|
||||
assertExpectedCount(itemCount, header.length, 'list array items', options)
|
||||
assertExpectedCount(itemCount, header.length, 'list array items', options, lastItemLine)
|
||||
|
||||
if (options.strict && startLine !== undefined && endLine !== undefined) {
|
||||
validateNoBlankLinesInRange(startLine, endLine, cursor.getBlankLines(), options.strict, 'list array')
|
||||
@@ -826,51 +818,19 @@ async function* decodeListItemAsync(
|
||||
let afterHyphen: string
|
||||
|
||||
if (line.content === LIST_ITEM_MARKER) {
|
||||
// Bare list item marker: either an empty object or fields at depth +1
|
||||
const followDepth = baseDepth + 1
|
||||
const nextLine = await cursor.peek()
|
||||
|
||||
if (!nextLine || nextLine.depth < followDepth) {
|
||||
// No fields at the next depth: treat as empty object
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
|
||||
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
// Fields at depth +1: parse them as an object
|
||||
yield { type: 'startObject' }
|
||||
|
||||
while (!cursor.atEnd()) {
|
||||
const fieldLine = await cursor.peek()
|
||||
if (!fieldLine || fieldLine.depth < followDepth) {
|
||||
break
|
||||
}
|
||||
|
||||
if (fieldLine.depth === followDepth && !fieldLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
await cursor.advance()
|
||||
yield* decodeKeyValueAsync(fieldLine.content, cursor, followDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
else {
|
||||
// Next line is another list item or at a different depth: treat as empty object
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
// Bare list item marker: always an empty object
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'endObject' }
|
||||
return
|
||||
}
|
||||
else if (line.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
afterHyphen = line.content.slice(LIST_ITEM_PREFIX.length)
|
||||
}
|
||||
else {
|
||||
throw new SyntaxError(`Expected list item to start with "${LIST_ITEM_PREFIX}"`)
|
||||
throw new ToonDecodeError(
|
||||
`Expected list item to start with "${LIST_ITEM_PREFIX}"`,
|
||||
{ line: line.lineNumber, source: line.raw },
|
||||
)
|
||||
}
|
||||
|
||||
if (!afterHyphen.trim()) {
|
||||
@@ -879,25 +839,27 @@ async function* decodeListItemAsync(
|
||||
return
|
||||
}
|
||||
|
||||
const itemLine: ParsedLine = { ...line, content: afterHyphen }
|
||||
|
||||
// Check for array header after hyphen
|
||||
if (isArrayHeaderContent(afterHyphen)) {
|
||||
const arrayHeader = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
|
||||
const arrayHeader = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
|
||||
if (arrayHeader) {
|
||||
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options)
|
||||
yield* decodeArrayFromHeaderAsync(arrayHeader.header, arrayHeader.inlineValues, cursor, baseDepth, options, itemLine)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tabular-first list-item object: `- key[N]{fields}:`
|
||||
const headerInfo = parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER)
|
||||
if (headerInfo && headerInfo.header.key && headerInfo.header.fields) {
|
||||
const headerInfo = withLine(itemLine, () => parseArrayHeaderLine(afterHyphen, DEFAULT_DELIMITER))
|
||||
if (headerInfo && headerInfo.header.key !== undefined && headerInfo.header.fields !== undefined) {
|
||||
// Object with tabular array as first field
|
||||
const header = headerInfo.header
|
||||
yield { type: 'startObject' }
|
||||
yield { type: 'key', key: header.key! }
|
||||
|
||||
// Use baseDepth + 1 for the array so rows are at baseDepth + 2
|
||||
yield* decodeArrayFromHeaderAsync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options)
|
||||
yield* decodeArrayFromHeaderAsync(header, headerInfo.inlineValues, cursor, baseDepth + 1, options, itemLine)
|
||||
|
||||
// Read sibling fields at depth = baseDepth + 1
|
||||
const followDepth = baseDepth + 1
|
||||
@@ -909,7 +871,7 @@ async function* decodeListItemAsync(
|
||||
|
||||
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
await cursor.advance()
|
||||
yield* decodeKeyValueAsync(nextLine.content, cursor, followDepth, options)
|
||||
yield* decodeKeyValueAsync(nextLine, cursor, followDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
@@ -923,7 +885,7 @@ async function* decodeListItemAsync(
|
||||
// Check for object first field after hyphen
|
||||
if (isKeyValueContent(afterHyphen)) {
|
||||
yield { type: 'startObject' }
|
||||
yield* decodeKeyValueAsync(afterHyphen, cursor, baseDepth + 1, options)
|
||||
yield* decodeKeyValueAsync(itemLine, cursor, baseDepth + 1, options)
|
||||
|
||||
// Read subsequent fields
|
||||
const followDepth = baseDepth + 1
|
||||
@@ -935,7 +897,7 @@ async function* decodeListItemAsync(
|
||||
|
||||
if (nextLine.depth === followDepth && !nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
await cursor.advance()
|
||||
yield* decodeKeyValueAsync(nextLine.content, cursor, followDepth, options)
|
||||
yield* decodeKeyValueAsync(nextLine, cursor, followDepth, options)
|
||||
}
|
||||
else {
|
||||
break
|
||||
@@ -947,7 +909,7 @@ async function* decodeListItemAsync(
|
||||
}
|
||||
|
||||
// Primitive value
|
||||
yield { type: 'primitive', value: parsePrimitiveToken(afterHyphen) }
|
||||
yield { type: 'primitive', value: withLine(itemLine, () => parsePrimitiveToken(afterHyphen)) }
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ParsedLine } from '../types.ts'
|
||||
|
||||
/**
|
||||
* Error thrown by the TOON decoder when input cannot be parsed.
|
||||
*
|
||||
* Extends `SyntaxError` so existing `instanceof SyntaxError` checks keep working.
|
||||
* Adds structured location fields for programmatic consumers and richer CLI output.
|
||||
*/
|
||||
export class ToonDecodeError extends SyntaxError {
|
||||
/** 1-based line number where the error was detected, if known. */
|
||||
readonly line?: number
|
||||
/** Raw source line (including indentation) where the error was detected, if known. */
|
||||
readonly source?: string
|
||||
|
||||
constructor(message: string, context?: { line?: number, source?: string, cause?: unknown }) {
|
||||
const prefix = context?.line !== undefined ? `Line ${context.line}: ` : ''
|
||||
super(prefix + message, context?.cause !== undefined ? { cause: context.cause } : undefined)
|
||||
this.name = 'ToonDecodeError'
|
||||
this.line = context?.line
|
||||
this.source = context?.source
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` and re-throws any non-`ToonDecodeError` `Error` as a `ToonDecodeError`
|
||||
* with line context attached and the original error preserved as `cause`.
|
||||
*
|
||||
* Pure parser helpers (parser.ts, string-utils.ts) don't know which line they're
|
||||
* parsing; this wrapper is how the streaming decoder enriches their errors.
|
||||
*/
|
||||
export function withLine<T>(line: ParsedLine, fn: () => T): T {
|
||||
try {
|
||||
return fn()
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ToonDecodeError)
|
||||
throw error
|
||||
if (error instanceof Error) {
|
||||
throw new ToonDecodeError(error.message, {
|
||||
line: line.lineNumber,
|
||||
source: line.raw,
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JsonObject, JsonStreamEvent, JsonValue } from '../types'
|
||||
import { QUOTED_KEY_MARKER } from './expand'
|
||||
import type { JsonObject, JsonStreamEvent, JsonValue } from '../types.ts'
|
||||
import { QUOTED_KEY_MARKER } from './expand.ts'
|
||||
|
||||
// #region Build context types
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JsonObject, JsonValue } from '../types'
|
||||
import { DOT } from '../constants'
|
||||
import { isJsonObject } from '../encode/normalize'
|
||||
import { isIdentifierSegment } from '../shared/validation'
|
||||
import type { JsonObject, JsonValue } from '../types.ts'
|
||||
import { DOT } from '../constants.ts'
|
||||
import { isJsonObject } from '../encode/normalize.ts'
|
||||
import { isIdentifierSegment } from '../shared/validation.ts'
|
||||
|
||||
// #region Path expansion (safe)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ArrayHeaderInfo, Delimiter, JsonPrimitive } from '../types'
|
||||
import { BACKSLASH, CLOSE_BRACE, CLOSE_BRACKET, COLON, DELIMITERS, DOUBLE_QUOTE, FALSE_LITERAL, NULL_LITERAL, OPEN_BRACE, OPEN_BRACKET, PIPE, TAB, TRUE_LITERAL } from '../constants'
|
||||
import { isBooleanOrNullLiteral, isNumericLiteral } from '../shared/literal-utils'
|
||||
import { findClosingQuote, findUnquotedChar, unescapeString } from '../shared/string-utils'
|
||||
import type { ArrayHeaderInfo, Delimiter, JsonPrimitive } from '../types.ts'
|
||||
import { BACKSLASH, CLOSE_BRACE, CLOSE_BRACKET, COLON, DELIMITERS, DOUBLE_QUOTE, FALSE_LITERAL, NULL_LITERAL, OPEN_BRACE, OPEN_BRACKET, PIPE, TAB, TRUE_LITERAL } from '../constants.ts'
|
||||
import { isBooleanOrNullLiteral, isNumericLiteral } from '../shared/literal-utils.ts'
|
||||
import { findClosingQuote, findUnquotedChar, unescapeString } from '../shared/string-utils.ts'
|
||||
|
||||
// #region Array header parsing
|
||||
|
||||
@@ -52,6 +52,12 @@ export function parseArrayHeaderLine(
|
||||
// Check for fields segment (braces come after bracket)
|
||||
const braceStart = content.indexOf(OPEN_BRACE, bracketEnd)
|
||||
if (braceStart !== -1 && braceStart < content.indexOf(COLON, bracketEnd)) {
|
||||
// Validate: no extraneous content between bracket end and brace start
|
||||
const gapBeforeBrace = content.slice(bracketEnd + 1, braceStart)
|
||||
if (gapBeforeBrace.trim() !== '') {
|
||||
return
|
||||
}
|
||||
|
||||
const foundBraceEnd = content.indexOf(CLOSE_BRACE, braceStart)
|
||||
if (foundBraceEnd !== -1) {
|
||||
braceEnd = foundBraceEnd + 1
|
||||
@@ -64,6 +70,13 @@ export function parseArrayHeaderLine(
|
||||
return
|
||||
}
|
||||
|
||||
// Validate: no extraneous content between bracket/fields end and colon
|
||||
const gapStart = Math.max(bracketEnd + 1, braceEnd)
|
||||
const gapBeforeColon = content.slice(gapStart, colonIndex)
|
||||
if (gapBeforeColon.trim() !== '') {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract and parse the key (might be quoted)
|
||||
let key: string | undefined
|
||||
if (bracketStart > 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BlankLineInfo, Depth, ParsedLine } from '../types'
|
||||
import { SPACE, TAB } from '../constants'
|
||||
import type { BlankLineInfo, Depth, ParsedLine } from '../types.ts'
|
||||
import { SPACE, TAB } from '../constants.ts'
|
||||
import { ToonDecodeError } from './errors.ts'
|
||||
|
||||
// #region Scan state
|
||||
|
||||
@@ -58,13 +59,17 @@ export function parseLineIncremental(
|
||||
|
||||
// Check for tabs in leading whitespace (before actual content)
|
||||
if (raw.slice(0, whitespaceEndIndex).includes(TAB)) {
|
||||
throw new SyntaxError(`Line ${lineNumber}: Tabs are not allowed in indentation in strict mode`)
|
||||
throw new ToonDecodeError(
|
||||
'Tabs are not allowed in indentation in strict mode',
|
||||
{ line: lineNumber, source: raw },
|
||||
)
|
||||
}
|
||||
|
||||
// Check for exact multiples of indentSize
|
||||
if (indent > 0 && indent % indentSize !== 0) {
|
||||
throw new SyntaxError(
|
||||
`Line ${lineNumber}: Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`,
|
||||
throw new ToonDecodeError(
|
||||
`Indentation must be exact multiple of ${indentSize}, but found ${indent} spaces`,
|
||||
{ line: lineNumber, source: raw },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ArrayHeaderInfo, BlankLineInfo, Delimiter, Depth, ParsedLine } from '../types'
|
||||
import { COLON, LIST_ITEM_PREFIX } from '../constants'
|
||||
import type { ArrayHeaderInfo, BlankLineInfo, Delimiter, Depth, ParsedLine } from '../types.ts'
|
||||
import { COLON, LIST_ITEM_PREFIX } from '../constants.ts'
|
||||
import { ToonDecodeError } from './errors.ts'
|
||||
|
||||
// #region Count and structure validation
|
||||
|
||||
@@ -11,9 +12,13 @@ export function assertExpectedCount(
|
||||
expected: number,
|
||||
itemType: string,
|
||||
options: { strict: boolean },
|
||||
line: ParsedLine,
|
||||
): void {
|
||||
if (options.strict && actual !== expected) {
|
||||
throw new RangeError(`Expected ${expected} ${itemType}, but got ${actual}`)
|
||||
throw new ToonDecodeError(
|
||||
`Expected ${expected} ${itemType}, but got ${actual}`,
|
||||
{ line: line.lineNumber, source: line.raw },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +31,10 @@ export function validateNoExtraListItems(
|
||||
expectedCount: number,
|
||||
): void {
|
||||
if (nextLine?.depth === itemDepth && nextLine.content.startsWith(LIST_ITEM_PREFIX)) {
|
||||
throw new RangeError(`Expected ${expectedCount} list array items, but found more`)
|
||||
throw new ToonDecodeError(
|
||||
`Expected ${expectedCount} list array items, but found more`,
|
||||
{ line: nextLine.lineNumber, source: nextLine.raw },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +51,10 @@ export function validateNoExtraTabularRows(
|
||||
&& !nextLine.content.startsWith(LIST_ITEM_PREFIX)
|
||||
&& isDataRow(nextLine.content, header.delimiter)
|
||||
) {
|
||||
throw new RangeError(`Expected ${header.length} tabular rows, but found more`)
|
||||
throw new ToonDecodeError(
|
||||
`Expected ${header.length} tabular rows, but found more`,
|
||||
{ line: nextLine.lineNumber, source: nextLine.raw },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +77,9 @@ export function validateNoBlankLinesInRange(
|
||||
)
|
||||
|
||||
if (firstBlank) {
|
||||
throw new SyntaxError(
|
||||
`Line ${firstBlank.lineNumber}: Blank lines inside ${context} are not allowed in strict mode`,
|
||||
throw new ToonDecodeError(
|
||||
`Blank lines inside ${context} are not allowed in strict mode`,
|
||||
{ line: firstBlank.lineNumber },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ResolvedEncodeOptions } from '../types'
|
||||
import { DOT, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants'
|
||||
import { tryFoldKeyChain } from './folding'
|
||||
import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isEmptyObject, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize'
|
||||
import { encodeAndJoinPrimitives, encodeKey, encodePrimitive, formatHeader } from './primitives'
|
||||
import type { Depth, JsonArray, JsonObject, JsonPrimitive, JsonValue, ResolvedEncodeOptions } from '../types.ts'
|
||||
import { DOT, LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from '../constants.ts'
|
||||
import { tryFoldKeyChain } from './folding.ts'
|
||||
import { isArrayOfArrays, isArrayOfObjects, isArrayOfPrimitives, isEmptyObject, isJsonArray, isJsonObject, isJsonPrimitive } from './normalize.ts'
|
||||
import { encodeAndJoinPrimitives, encodeKey, encodePrimitive, formatHeader } from './primitives.ts'
|
||||
|
||||
// #region Encode normalized JsonValue
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JsonValue, ResolvedEncodeOptions } from '../types'
|
||||
import { DOT } from '../constants'
|
||||
import { isIdentifierSegment } from '../shared/validation'
|
||||
import { isEmptyObject, isJsonObject } from './normalize'
|
||||
import type { JsonValue, ResolvedEncodeOptions } from '../types.ts'
|
||||
import { DOT } from '../constants.ts'
|
||||
import { isIdentifierSegment } from '../shared/validation.ts'
|
||||
import { isEmptyObject, isJsonObject } from './normalize.ts'
|
||||
|
||||
// #region Key folding helpers
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from '../types'
|
||||
import type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from '../types.ts'
|
||||
|
||||
// #region Normalization (unknown → JsonValue)
|
||||
|
||||
@@ -72,15 +72,15 @@ export function normalizeValue(value: unknown): JsonValue {
|
||||
|
||||
// Plain object
|
||||
if (isPlainObject(value)) {
|
||||
const normalized: Record<string, JsonValue> = {}
|
||||
const encodedValues: Record<string, JsonValue> = {}
|
||||
|
||||
for (const key in value) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
normalized[key] = normalizeValue(value[key])
|
||||
if (Object.hasOwn(value, key)) {
|
||||
encodedValues[key] = normalizeValue(value[key])
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
return encodedValues
|
||||
}
|
||||
|
||||
// Fallback: function, symbol, undefined, or other → null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JsonPrimitive } from '../types'
|
||||
import { COMMA, DEFAULT_DELIMITER, DOUBLE_QUOTE, NULL_LITERAL } from '../constants'
|
||||
import { escapeString } from '../shared/string-utils'
|
||||
import { isSafeUnquoted, isValidUnquotedKey } from '../shared/validation'
|
||||
import type { JsonPrimitive } from '../types.ts'
|
||||
import { COMMA, DEFAULT_DELIMITER, DOUBLE_QUOTE, NULL_LITERAL } from '../constants.ts'
|
||||
import { escapeString } from '../shared/string-utils.ts'
|
||||
import { isSafeUnquoted, isValidUnquotedKey } from '../shared/validation.ts'
|
||||
|
||||
// #region Primitive encoding
|
||||
|
||||
@@ -67,7 +67,7 @@ export function formatHeader(
|
||||
|
||||
let header = ''
|
||||
|
||||
if (key) {
|
||||
if (key != null) {
|
||||
header += encodeKey(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EncodeReplacer, JsonArray, JsonObject, JsonValue } from '../types'
|
||||
import { isJsonArray, isJsonObject, normalizeValue } from './normalize'
|
||||
import type { EncodeReplacer, JsonArray, JsonObject, JsonValue } from '../types.ts'
|
||||
import { isJsonArray, isJsonObject, normalizeValue } from './normalize.ts'
|
||||
|
||||
/**
|
||||
* Applies a replacer function to a `JsonValue` and all its descendants.
|
||||
|
||||
+11
-10
@@ -1,13 +1,14 @@
|
||||
import type { DecodeOptions, DecodeStreamOptions, EncodeOptions, JsonStreamEvent, JsonValue, ResolvedDecodeOptions, ResolvedEncodeOptions } from './types'
|
||||
import { DEFAULT_DELIMITER } from './constants'
|
||||
import { decodeStream as decodeStreamCore, decodeStreamSync as decodeStreamSyncCore } from './decode/decoders'
|
||||
import { buildValueFromEvents } from './decode/event-builder'
|
||||
import { expandPathsSafe } from './decode/expand'
|
||||
import { encodeJsonValue } from './encode/encoders'
|
||||
import { normalizeValue } from './encode/normalize'
|
||||
import { applyReplacer } from './encode/replacer'
|
||||
import type { DecodeOptions, DecodeStreamOptions, EncodeOptions, JsonStreamEvent, JsonValue, ResolvedDecodeOptions, ResolvedEncodeOptions } from './types.ts'
|
||||
import { DEFAULT_DELIMITER } from './constants.ts'
|
||||
import { decodeStream as decodeStreamCore, decodeStreamSync as decodeStreamSyncCore } from './decode/decoders.ts'
|
||||
import { buildValueFromEvents } from './decode/event-builder.ts'
|
||||
import { expandPathsSafe } from './decode/expand.ts'
|
||||
import { encodeJsonValue } from './encode/encoders.ts'
|
||||
import { normalizeValue } from './encode/normalize.ts'
|
||||
import { applyReplacer } from './encode/replacer.ts'
|
||||
|
||||
export { DEFAULT_DELIMITER, DELIMITERS } from './constants'
|
||||
export { DEFAULT_DELIMITER, DELIMITERS } from './constants.ts'
|
||||
export { ToonDecodeError } from './decode/errors.ts'
|
||||
export type {
|
||||
DecodeOptions,
|
||||
DecodeStreamOptions,
|
||||
@@ -22,7 +23,7 @@ export type {
|
||||
JsonValue,
|
||||
ResolvedDecodeOptions,
|
||||
ResolvedEncodeOptions,
|
||||
} from './types'
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
* Encodes a JavaScript value into TOON format string.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { FALSE_LITERAL, NULL_LITERAL, TRUE_LITERAL } from '../constants'
|
||||
import { FALSE_LITERAL, NULL_LITERAL, TRUE_LITERAL } from '../constants.ts'
|
||||
|
||||
const NUMERIC_LITERAL_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:e[+-]?\d+)?$/i
|
||||
|
||||
export function isBooleanOrNullLiteral(token: string): boolean {
|
||||
return token === TRUE_LITERAL || token === FALSE_LITERAL || token === NULL_LITERAL
|
||||
@@ -14,12 +16,10 @@ export function isNumericLiteral(token: string): boolean {
|
||||
if (!token)
|
||||
return false
|
||||
|
||||
// Must not have leading zeros (except for `"0"` itself or decimals like `"0.5"`)
|
||||
if (token.length > 1 && token[0] === '0' && token[1] !== '.') {
|
||||
// Enforce JSON-like grammar with no forbidden leading zeros
|
||||
if (!NUMERIC_LITERAL_PATTERN.test(token))
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a valid number
|
||||
const numericValue = Number(token)
|
||||
return !Number.isNaN(numericValue) && Number.isFinite(numericValue)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BACKSLASH, CARRIAGE_RETURN, DOUBLE_QUOTE, NEWLINE, TAB } from '../constants'
|
||||
import { BACKSLASH, CARRIAGE_RETURN, DOUBLE_QUOTE, NEWLINE, TAB } from '../constants.ts'
|
||||
|
||||
/**
|
||||
* Escapes special characters in a string for encoding.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { DEFAULT_DELIMITER, LIST_ITEM_MARKER } from '../constants'
|
||||
import { isBooleanOrNullLiteral } from './literal-utils'
|
||||
import { DEFAULT_DELIMITER, LIST_ITEM_MARKER } from '../constants.ts'
|
||||
import { isBooleanOrNullLiteral } from './literal-utils.ts'
|
||||
|
||||
const NUMERIC_LIKE_PATTERN = /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i
|
||||
const LEADING_ZERO_PATTERN = /^0\d+$/
|
||||
|
||||
/**
|
||||
* Checks if a key can be used without quotes.
|
||||
@@ -93,5 +96,5 @@ export function isSafeUnquoted(value: string, delimiter: string = DEFAULT_DELIMI
|
||||
* Match numbers like `42`, `-3.14`, `1e-6`, `05`, etc.
|
||||
*/
|
||||
function isNumericLike(value: string): boolean {
|
||||
return /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(value) || /^0\d+$/.test(value)
|
||||
return NUMERIC_LIKE_PATTERN.test(value) || LEADING_ZERO_PATTERN.test(value)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// #region JSON types
|
||||
|
||||
import type { Delimiter, DelimiterKey } from './constants'
|
||||
import type { Delimiter, DelimiterKey } from './constants.ts'
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null
|
||||
export type JsonObject = { [Key in string]: JsonValue } & { [Key in string]?: JsonValue | undefined }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decode, ToonDecodeError } from '../src/index'
|
||||
|
||||
describe('toonDecodeError line context', () => {
|
||||
it('reports line number when a parent key is missing its colon', () => {
|
||||
const error = captureDecodeError('_meta\n version: "1.0"\n name: test\n')
|
||||
|
||||
expect(error).toBeInstanceOf(SyntaxError)
|
||||
expect(error.line).toBe(1)
|
||||
expect(error.source).toBe('_meta')
|
||||
expect(error.message).toMatch(/^Line 1: /)
|
||||
expect(error.message).toMatch(/missing colon/i)
|
||||
})
|
||||
|
||||
it('reports the line of the missing-colon error in nested context', () => {
|
||||
const error = captureDecodeError('wrapper:\n inner\n version: "1.0"\n')
|
||||
|
||||
expect(error.line).toBe(2)
|
||||
})
|
||||
|
||||
it('includes line number when a list array has too few items', () => {
|
||||
const error = captureDecodeError('_meta:\n version: "1.0"\n\nrules[3]:\n - first\n')
|
||||
|
||||
expect(error.line).toBeDefined()
|
||||
expect(error.message).toMatch(/^Line \d+: /)
|
||||
expect(error.message).toMatch(/3.*1/)
|
||||
expect(error.message).toMatch(/list/i)
|
||||
})
|
||||
|
||||
it('includes line number when a tabular row count is wrong', () => {
|
||||
const error = captureDecodeError('rules[3]{id,rule}:\n R1,first\n R2,second\n')
|
||||
|
||||
expect(error.line).toBeDefined()
|
||||
expect(error.message).toMatch(/^Line \d+: /)
|
||||
expect(error.message).toMatch(/3.*2/)
|
||||
expect(error.message).toMatch(/tabular|rows?/i)
|
||||
})
|
||||
|
||||
it('reports indentation errors with line and source', () => {
|
||||
const error = captureDecodeError('a:\n b: 1\n')
|
||||
|
||||
expect(error.line).toBe(2)
|
||||
expect(error.source).toBe(' b: 1')
|
||||
})
|
||||
|
||||
it('attaches line context to errors raised during value parsing', () => {
|
||||
const error = captureDecodeError('name: alice\ngreeting: "hello\n')
|
||||
|
||||
expect(error.line).toBe(2)
|
||||
expect(error.source).toBe('greeting: "hello')
|
||||
expect(error.message).toMatch(/^Line 2: /)
|
||||
expect(error.message).toMatch(/unterminated|closing quote/i)
|
||||
})
|
||||
|
||||
it('reports tabs in indentation with line and source', () => {
|
||||
const error = captureDecodeError('a:\n\tb: 1\n')
|
||||
|
||||
expect(error.line).toBe(2)
|
||||
expect(error.source).toBe('\tb: 1')
|
||||
expect(error.message).toMatch(/^Line 2: /)
|
||||
expect(error.message).toMatch(/tabs?/i)
|
||||
})
|
||||
|
||||
it('reports blank lines inside an array with the line number of the blank', () => {
|
||||
const error = captureDecodeError('rules[3]{id,rule}:\n R1,first\n\n R2,second\n R3,third\n')
|
||||
|
||||
expect(error.line).toBe(3)
|
||||
expect(error.message).toMatch(/^Line 3: /)
|
||||
expect(error.message).toMatch(/blank lines?/i)
|
||||
})
|
||||
|
||||
it('points to the first extra item when an array exceeds its declared count', () => {
|
||||
const error = captureDecodeError('items[2]:\n - a\n - b\n - c\n')
|
||||
|
||||
expect(error.line).toBe(4)
|
||||
expect(error.source).toBe(' - c')
|
||||
expect(error.message).toMatch(/^Line 4: /)
|
||||
expect(error.message).toMatch(/list/i)
|
||||
expect(error.message).toMatch(/2|more/i)
|
||||
})
|
||||
|
||||
it('points to the offending row when a tabular row width does not match the field count', () => {
|
||||
const error = captureDecodeError('rules[2]{id,rule,priority}:\n R1,first\n R2,second,high\n')
|
||||
|
||||
expect(error.line).toBe(2)
|
||||
expect(error.source).toBe(' R1,first')
|
||||
expect(error.message).toMatch(/^Line 2: /)
|
||||
expect(error.message).toMatch(/3.*2/)
|
||||
expect(error.message).toMatch(/row|tabular/i)
|
||||
})
|
||||
})
|
||||
|
||||
function captureDecodeError(input: string): ToonDecodeError {
|
||||
try {
|
||||
decode(input)
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof ToonDecodeError)
|
||||
return error
|
||||
throw error
|
||||
}
|
||||
throw new Error('Expected decode to throw ToonDecodeError, but it returned normally')
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { JsonStreamEvent } from '../src/index'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildValueFromEvents } from '../src/decode/event-builder'
|
||||
import { decode, decodeFromLines, decodeStreamSync } from '../src/index'
|
||||
import { buildValueFromEvents, buildValueFromEventsAsync } from '../src/decode/event-builder'
|
||||
import { decode, decodeFromLines, decodeStream, decodeStreamSync } from '../src/index'
|
||||
|
||||
describe('streaming decode', () => {
|
||||
describe('decodeStreamSync', () => {
|
||||
it('decode simple object', () => {
|
||||
it('decodes simple object', () => {
|
||||
const input = 'name: Alice\nage: 30'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -19,7 +20,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode nested object', () => {
|
||||
it('decodes nested object', () => {
|
||||
const input = 'user:\n name: Alice\n age: 30'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -37,7 +38,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode inline primitive array', () => {
|
||||
it('decodes inline primitive array', () => {
|
||||
const input = 'scores[3]: 95, 87, 92'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -54,7 +55,23 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode list array', () => {
|
||||
it('decodes inline array with empty string key', () => {
|
||||
const input = '""[2]: 1,2'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: '' },
|
||||
{ type: 'startArray', length: 2 },
|
||||
{ type: 'primitive', value: 1 },
|
||||
{ type: 'primitive', value: 2 },
|
||||
{ type: 'endArray' },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes list array', () => {
|
||||
const input = 'items[2]:\n - Apple\n - Banana'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -70,7 +87,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode tabular array', () => {
|
||||
it('decodes tabular array', () => {
|
||||
const input = 'users[2]{name,age}:\n Alice, 30\n Bob, 25'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -96,7 +113,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode root primitive', () => {
|
||||
it('decodes root primitive', () => {
|
||||
const input = 'Hello World'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -106,7 +123,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode root array', () => {
|
||||
it('decodes root array', () => {
|
||||
const input = '[2]:\n - Apple\n - Banana'
|
||||
const lines = input.split('\n')
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
@@ -119,7 +136,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('decode empty input as empty object', () => {
|
||||
it('decodes empty input as empty object', () => {
|
||||
const lines: string[] = []
|
||||
const events = Array.from(decodeStreamSync(lines))
|
||||
|
||||
@@ -129,7 +146,7 @@ describe('streaming decode', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('throw on expandPaths option', () => {
|
||||
it('throws on expandPaths option', () => {
|
||||
const input = 'name: Alice'
|
||||
const lines = input.split('\n')
|
||||
|
||||
@@ -137,7 +154,7 @@ describe('streaming decode', () => {
|
||||
.toThrow('expandPaths is not supported in streaming decode')
|
||||
})
|
||||
|
||||
it('enforce strict mode validation', () => {
|
||||
it('enforces strict mode validation', () => {
|
||||
const input = 'items[2]:\n - Apple'
|
||||
const lines = input.split('\n')
|
||||
|
||||
@@ -145,11 +162,10 @@ describe('streaming decode', () => {
|
||||
.toThrow()
|
||||
})
|
||||
|
||||
it('allow count mismatch in non-strict mode', () => {
|
||||
it('allows count mismatch in non-strict mode', () => {
|
||||
const input = 'items[2]:\n - Apple'
|
||||
const lines = input.split('\n')
|
||||
|
||||
// Should not throw in non-strict mode
|
||||
const events = Array.from(decodeStreamSync(lines, { strict: false }))
|
||||
|
||||
expect(events).toBeDefined()
|
||||
@@ -157,8 +173,59 @@ describe('streaming decode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeStream (async)', () => {
|
||||
const equivalenceCases = [
|
||||
{ name: 'simple object', input: 'name: Alice\nage: 30' },
|
||||
{ name: 'nested object', input: 'user:\n name: Alice\n age: 30' },
|
||||
{ name: 'tabular array', input: 'users[2]{name,age}:\n Alice, 30\n Bob, 25' },
|
||||
{ name: 'list array', input: 'items[2]:\n - Apple\n - Banana' },
|
||||
{ name: 'root primitive', input: 'Hello World' },
|
||||
{ name: 'root array', input: '[2]:\n - Apple\n - Banana' },
|
||||
{ name: 'empty input', input: '' },
|
||||
]
|
||||
|
||||
for (const { name, input } of equivalenceCases) {
|
||||
it(`emits the same events as decodeStreamSync for ${name}`, async () => {
|
||||
const lines = input === '' ? [] : input.split('\n')
|
||||
const syncResult = Array.from(decodeStreamSync(lines))
|
||||
const asyncResult = await collect(decodeStream(asyncLines(lines)))
|
||||
expect(asyncResult).toEqual(syncResult)
|
||||
})
|
||||
}
|
||||
|
||||
it('accepts a sync iterable as source', async () => {
|
||||
const lines = ['name: Alice', 'age: 30']
|
||||
const events = await collect(decodeStream(lines))
|
||||
|
||||
expect(events).toEqual(Array.from(decodeStreamSync(lines)))
|
||||
})
|
||||
|
||||
it('rejects expandPaths option', async () => {
|
||||
const lines = ['name: Alice']
|
||||
|
||||
await expect(async () => {
|
||||
await collect(decodeStream(asyncLines(lines), { expandPaths: 'safe' } as any))
|
||||
}).rejects.toThrow('expandPaths is not supported in streaming decode')
|
||||
})
|
||||
|
||||
it('enforces strict mode validation', async () => {
|
||||
const lines = ['items[2]:', ' - Apple']
|
||||
|
||||
await expect(async () => {
|
||||
await collect(decodeStream(asyncLines(lines), { strict: true }))
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('allows count mismatch in non-strict mode', async () => {
|
||||
const lines = ['items[2]:', ' - Apple']
|
||||
const events = await collect(decodeStream(asyncLines(lines), { strict: false }))
|
||||
|
||||
expect(events[0]).toEqual({ type: 'startObject' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildValueFromEvents', () => {
|
||||
it('build object from events', () => {
|
||||
it('builds object from events', () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
@@ -173,7 +240,7 @@ describe('streaming decode', () => {
|
||||
expect(result).toEqual({ name: 'Alice', age: 30 })
|
||||
})
|
||||
|
||||
it('build nested object from events', () => {
|
||||
it('builds nested object from events', () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'user' },
|
||||
@@ -189,7 +256,7 @@ describe('streaming decode', () => {
|
||||
expect(result).toEqual({ user: { name: 'Alice' } })
|
||||
})
|
||||
|
||||
it('build array from events', () => {
|
||||
it('builds array from events', () => {
|
||||
const events = [
|
||||
{ type: 'startArray' as const, length: 3 },
|
||||
{ type: 'primitive' as const, value: 1 },
|
||||
@@ -203,7 +270,7 @@ describe('streaming decode', () => {
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('build primitive from events', () => {
|
||||
it('builds primitive from events', () => {
|
||||
const events = [
|
||||
{ type: 'primitive' as const, value: 'Hello' },
|
||||
]
|
||||
@@ -213,11 +280,10 @@ describe('streaming decode', () => {
|
||||
expect(result).toEqual('Hello')
|
||||
})
|
||||
|
||||
it('throw on incomplete event stream', () => {
|
||||
it('throws on incomplete event stream', () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
// Missing primitive and `endObject`
|
||||
]
|
||||
|
||||
expect(() => buildValueFromEvents(events))
|
||||
@@ -225,118 +291,115 @@ describe('streaming decode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildValueFromEventsAsync', () => {
|
||||
it('matches buildValueFromEvents for representative shapes', async () => {
|
||||
const cases: JsonStreamEvent[][] = [
|
||||
[
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'name' },
|
||||
{ type: 'primitive', value: 'Alice' },
|
||||
{ type: 'endObject' },
|
||||
],
|
||||
[
|
||||
{ type: 'startArray', length: 2 },
|
||||
{ type: 'primitive', value: 1 },
|
||||
{ type: 'primitive', value: 2 },
|
||||
{ type: 'endArray' },
|
||||
],
|
||||
[
|
||||
{ type: 'primitive', value: 'Hello' },
|
||||
],
|
||||
]
|
||||
|
||||
for (const events of cases) {
|
||||
const syncResult = buildValueFromEvents(events)
|
||||
const asyncResult = await buildValueFromEventsAsync(asyncEvents(events))
|
||||
expect(asyncResult).toEqual(syncResult)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws on incomplete event stream', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
]
|
||||
|
||||
await expect(buildValueFromEventsAsync(asyncEvents(events)))
|
||||
.rejects
|
||||
.toThrow('Incomplete event stream')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeFromLines', () => {
|
||||
it('produce same result as decode', () => {
|
||||
it('produces same result as decode', () => {
|
||||
const input = 'name: Alice\nage: 30\nscores[3]: 95, 87, 92'
|
||||
const lines = input.split('\n')
|
||||
|
||||
const fromLines = decodeFromLines(lines)
|
||||
const fromString = decode(input)
|
||||
|
||||
expect(fromLines).toEqual(fromString)
|
||||
expect(decodeFromLines(lines)).toEqual(decode(input))
|
||||
})
|
||||
|
||||
it('support expandPaths option', () => {
|
||||
const input = 'user.name: Alice\nuser.age: 30'
|
||||
const lines = input.split('\n')
|
||||
it('supports expandPaths option', () => {
|
||||
const lines = ['user.name: Alice', 'user.age: 30']
|
||||
|
||||
const result = decodeFromLines(lines, { expandPaths: 'safe' })
|
||||
|
||||
expect(result).toEqual({
|
||||
user: {
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
},
|
||||
expect(decodeFromLines(lines, { expandPaths: 'safe' })).toEqual({
|
||||
user: { name: 'Alice', age: 30 },
|
||||
})
|
||||
})
|
||||
|
||||
it('handle complex nested structures', () => {
|
||||
it('handles list item objects with empty string keyed tabular fields', () => {
|
||||
const input = [
|
||||
'users[2]:',
|
||||
' - name: Alice',
|
||||
' scores[3]: 95, 87, 92',
|
||||
' - name: Bob',
|
||||
' scores[3]: 88, 91, 85',
|
||||
'items[1]:',
|
||||
' - ""[2]{a}:',
|
||||
' 1',
|
||||
' 2',
|
||||
].join('\n')
|
||||
|
||||
const fromLines = decodeFromLines(input.split('\n'))
|
||||
const fromString = decode(input)
|
||||
|
||||
expect(fromLines).toEqual(fromString)
|
||||
expect(fromLines).toEqual({
|
||||
users: [
|
||||
{ name: 'Alice', scores: [95, 87, 92] },
|
||||
{ name: 'Bob', scores: [88, 91, 85] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('handle tabular arrays', () => {
|
||||
const input = [
|
||||
'users[3]{name,age,city}:',
|
||||
' Alice, 30, NYC',
|
||||
' Bob, 25, LA',
|
||||
' Charlie, 35, SF',
|
||||
].join('\n')
|
||||
|
||||
const fromLines = decodeFromLines(input.split('\n'))
|
||||
const fromString = decode(input)
|
||||
|
||||
expect(fromLines).toEqual(fromString)
|
||||
expect(fromLines).toEqual({
|
||||
users: [
|
||||
{ name: 'Alice', age: 30, city: 'NYC' },
|
||||
{ name: 'Bob', age: 25, city: 'LA' },
|
||||
{ name: 'Charlie', age: 35, city: 'SF' },
|
||||
],
|
||||
expect(decodeFromLines(input.split('\n'))).toEqual({
|
||||
items: [{ '': [{ a: 1 }, { a: 2 }] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming equivalence', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'simple object',
|
||||
input: 'name: Alice\nage: 30',
|
||||
},
|
||||
{
|
||||
name: 'nested objects',
|
||||
input: 'user:\n profile:\n name: Alice\n age: 30',
|
||||
},
|
||||
{
|
||||
name: 'mixed structures',
|
||||
input: 'name: Alice\nscores[3]: 95, 87, 92\naddress:\n city: NYC\n zip: 10001',
|
||||
},
|
||||
{
|
||||
name: 'list array with objects',
|
||||
input: 'users[2]:\n - name: Alice\n age: 30\n - name: Bob\n age: 25',
|
||||
},
|
||||
{
|
||||
name: 'root primitive number',
|
||||
input: '42',
|
||||
},
|
||||
{
|
||||
name: 'root primitive string',
|
||||
input: 'Hello World',
|
||||
},
|
||||
{
|
||||
name: 'root primitive boolean',
|
||||
input: 'true',
|
||||
},
|
||||
{
|
||||
name: 'root primitive null',
|
||||
input: 'null',
|
||||
},
|
||||
{ name: 'simple object', input: 'name: Alice\nage: 30' },
|
||||
{ name: 'nested objects', input: 'user:\n profile:\n name: Alice\n age: 30' },
|
||||
{ name: 'mixed structures', input: 'name: Alice\nscores[3]: 95, 87, 92\naddress:\n city: NYC\n zip: 10001' },
|
||||
{ name: 'list array with objects', input: 'users[2]:\n - name: Alice\n age: 30\n - name: Bob\n age: 25' },
|
||||
{ name: 'tabular array', input: 'users[3]{name,age,city}:\n Alice, 30, NYC\n Bob, 25, LA\n Charlie, 35, SF' },
|
||||
{ name: 'root primitive number', input: '42' },
|
||||
{ name: 'root primitive string', input: 'Hello World' },
|
||||
{ name: 'root primitive boolean', input: 'true' },
|
||||
{ name: 'root primitive null', input: 'null' },
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
it(`should match decode() for: ${testCase.name}`, () => {
|
||||
it(`decodeFromLines matches decode() for: ${testCase.name}`, () => {
|
||||
const lines = testCase.input.split('\n')
|
||||
const streamResult = decodeFromLines(lines)
|
||||
const regularResult = decode(testCase.input)
|
||||
|
||||
expect(streamResult).toEqual(regularResult)
|
||||
expect(decodeFromLines(lines)).toEqual(decode(testCase.input))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
const results: T[] = []
|
||||
for await (const item of iterable) {
|
||||
results.push(item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function* asyncLines(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) {
|
||||
await Promise.resolve()
|
||||
yield line
|
||||
}
|
||||
}
|
||||
|
||||
async function* asyncEvents<T>(events: T[]): AsyncGenerator<T> {
|
||||
for (const event of events) {
|
||||
await Promise.resolve()
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildValueFromEventsAsync } from '../src/decode/event-builder'
|
||||
import { decodeStream } from '../src/index'
|
||||
|
||||
describe('async streaming decode', () => {
|
||||
describe('decodeStream (async)', () => {
|
||||
it('decodes simple object', async () => {
|
||||
const input = 'name: Alice\nage: 30'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'name' },
|
||||
{ type: 'primitive', value: 'Alice' },
|
||||
{ type: 'key', key: 'age' },
|
||||
{ type: 'primitive', value: 30 },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes nested object', async () => {
|
||||
const input = 'user:\n name: Alice\n age: 30'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'user' },
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'name' },
|
||||
{ type: 'primitive', value: 'Alice' },
|
||||
{ type: 'key', key: 'age' },
|
||||
{ type: 'primitive', value: 30 },
|
||||
{ type: 'endObject' },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes inline primitive array', async () => {
|
||||
const input = 'scores[3]: 95, 87, 92'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'scores' },
|
||||
{ type: 'startArray', length: 3 },
|
||||
{ type: 'primitive', value: 95 },
|
||||
{ type: 'primitive', value: 87 },
|
||||
{ type: 'primitive', value: 92 },
|
||||
{ type: 'endArray' },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes list array', async () => {
|
||||
const input = 'items[2]:\n - Apple\n - Banana'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'items' },
|
||||
{ type: 'startArray', length: 2 },
|
||||
{ type: 'primitive', value: 'Apple' },
|
||||
{ type: 'primitive', value: 'Banana' },
|
||||
{ type: 'endArray' },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes tabular array', async () => {
|
||||
const input = 'users[2]{name,age}:\n Alice, 30\n Bob, 25'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'users' },
|
||||
{ type: 'startArray', length: 2 },
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'name' },
|
||||
{ type: 'primitive', value: 'Alice' },
|
||||
{ type: 'key', key: 'age' },
|
||||
{ type: 'primitive', value: 30 },
|
||||
{ type: 'endObject' },
|
||||
{ type: 'startObject' },
|
||||
{ type: 'key', key: 'name' },
|
||||
{ type: 'primitive', value: 'Bob' },
|
||||
{ type: 'key', key: 'age' },
|
||||
{ type: 'primitive', value: 25 },
|
||||
{ type: 'endObject' },
|
||||
{ type: 'endArray' },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes root primitive', async () => {
|
||||
const input = 'Hello World'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'primitive', value: 'Hello World' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes root array', async () => {
|
||||
const input = '[2]:\n - Apple\n - Banana'
|
||||
const lines = input.split('\n')
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startArray', length: 2 },
|
||||
{ type: 'primitive', value: 'Apple' },
|
||||
{ type: 'primitive', value: 'Banana' },
|
||||
{ type: 'endArray' },
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes empty input as empty object', async () => {
|
||||
const lines: string[] = []
|
||||
const events = await collect(decodeStream(asyncLines(lines)))
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'startObject' },
|
||||
{ type: 'endObject' },
|
||||
])
|
||||
})
|
||||
|
||||
it('throws on expandPaths option', async () => {
|
||||
const input = 'name: Alice'
|
||||
const lines = input.split('\n')
|
||||
|
||||
await expect(async () => {
|
||||
await collect(decodeStream(asyncLines(lines), { expandPaths: 'safe' } as any))
|
||||
}).rejects.toThrow('expandPaths is not supported in streaming decode')
|
||||
})
|
||||
|
||||
it('enforces strict mode validation', async () => {
|
||||
const input = 'items[2]:\n - Apple'
|
||||
const lines = input.split('\n')
|
||||
|
||||
await expect(async () => {
|
||||
await collect(decodeStream(asyncLines(lines), { strict: true }))
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('allows count mismatch in non-strict mode', async () => {
|
||||
const input = 'items[2]:\n - Apple'
|
||||
const lines = input.split('\n')
|
||||
|
||||
// Should not throw in non-strict mode
|
||||
const events = await collect(decodeStream(asyncLines(lines), { strict: false }))
|
||||
|
||||
expect(events).toBeDefined()
|
||||
expect(events[0]).toEqual({ type: 'startObject' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildValueFromEventsAsync', () => {
|
||||
it('builds object from events', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
{ type: 'key' as const, key: 'age' },
|
||||
{ type: 'primitive' as const, value: 30 },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
|
||||
const result = await buildValueFromEventsAsync(asyncEvents(events))
|
||||
|
||||
expect(result).toEqual({ name: 'Alice', age: 30 })
|
||||
})
|
||||
|
||||
it('builds nested object from events', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'user' },
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
{ type: 'primitive' as const, value: 'Alice' },
|
||||
{ type: 'endObject' as const },
|
||||
{ type: 'endObject' as const },
|
||||
]
|
||||
|
||||
const result = await buildValueFromEventsAsync(asyncEvents(events))
|
||||
|
||||
expect(result).toEqual({ user: { name: 'Alice' } })
|
||||
})
|
||||
|
||||
it('builds array from events', 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 result = await buildValueFromEventsAsync(asyncEvents(events))
|
||||
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('builds primitive from events', async () => {
|
||||
const events = [
|
||||
{ type: 'primitive' as const, value: 'Hello' },
|
||||
]
|
||||
|
||||
const result = await buildValueFromEventsAsync(asyncEvents(events))
|
||||
|
||||
expect(result).toEqual('Hello')
|
||||
})
|
||||
|
||||
it('throws on incomplete event stream', async () => {
|
||||
const events = [
|
||||
{ type: 'startObject' as const },
|
||||
{ type: 'key' as const, key: 'name' },
|
||||
// Missing primitive and `endObject`
|
||||
]
|
||||
|
||||
await expect(async () => {
|
||||
await buildValueFromEventsAsync(asyncEvents(events))
|
||||
}).rejects.toThrow('Incomplete event stream')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Collects all items from an async iterable into an array.
|
||||
*/
|
||||
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
const results: T[] = []
|
||||
for await (const item of iterable) {
|
||||
results.push(item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts array of lines to async iterable.
|
||||
*/
|
||||
async function* asyncLines(lines: string[]): AsyncGenerator<string> {
|
||||
for (const line of lines) {
|
||||
await Promise.resolve()
|
||||
yield line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts array of events to async iterable.
|
||||
*/
|
||||
async function* asyncEvents<T>(events: T[]): AsyncGenerator<T> {
|
||||
for (const event of events) {
|
||||
await Promise.resolve()
|
||||
yield event
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { encodeLines } from '../src/index'
|
||||
|
||||
describe('encodeLines', () => {
|
||||
it('yield lines without newline characters', () => {
|
||||
it('yields lines without newline characters', () => {
|
||||
const value = { name: 'Alice', age: 30, city: 'Paris' }
|
||||
const lines = Array.from(encodeLines(value))
|
||||
|
||||
@@ -11,26 +11,13 @@ describe('encodeLines', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('yield zero lines for empty object', () => {
|
||||
it('yields zero lines for empty object', () => {
|
||||
const lines = Array.from(encodeLines({}))
|
||||
|
||||
expect(lines.length).toBe(0)
|
||||
})
|
||||
|
||||
it('be iterable with for-of loop', () => {
|
||||
const value = { x: 10, y: 20 }
|
||||
const collectedLines: string[] = []
|
||||
|
||||
for (const line of encodeLines(value)) {
|
||||
collectedLines.push(line)
|
||||
}
|
||||
|
||||
expect(collectedLines.length).toBe(2)
|
||||
expect(collectedLines[0]).toBe('x: 10')
|
||||
expect(collectedLines[1]).toBe('y: 20')
|
||||
})
|
||||
|
||||
it('not have trailing spaces in lines', () => {
|
||||
it('yields lines without trailing spaces', () => {
|
||||
const value = {
|
||||
user: {
|
||||
name: 'Alice',
|
||||
@@ -46,11 +33,4 @@ describe('encodeLines', () => {
|
||||
expect(line).not.toMatch(/\s$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('yield correct number of lines', () => {
|
||||
const value = { a: 1, b: 2, c: 3 }
|
||||
const lines = Array.from(encodeLines(value))
|
||||
|
||||
expect(lines.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ describe('replacer function', () => {
|
||||
it('removes array elements by returning undefined', () => {
|
||||
const input = [1, 2, 3, 4, 5]
|
||||
const replacer: EncodeReplacer = (key, value) => {
|
||||
// Remove even numbers (key is index as string)
|
||||
if (typeof value === 'number' && value % 2 === 0)
|
||||
return undefined
|
||||
return value
|
||||
@@ -63,7 +62,6 @@ describe('replacer function', () => {
|
||||
it('transforms primitive values', () => {
|
||||
const input = { name: 'alice', age: 30 }
|
||||
const replacer: EncodeReplacer = (key, value) => {
|
||||
// Uppercase all strings
|
||||
if (typeof value === 'string')
|
||||
return value.toUpperCase()
|
||||
return value
|
||||
@@ -78,7 +76,6 @@ describe('replacer function', () => {
|
||||
it('transforms objects', () => {
|
||||
const input = { user: { name: 'Alice' } }
|
||||
const replacer: EncodeReplacer = (key, value, path) => {
|
||||
// Add metadata to all objects at depth 1
|
||||
if (path.length === 1 && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return { ...value as object, _id: `${key}_123` }
|
||||
}
|
||||
@@ -96,7 +93,6 @@ describe('replacer function', () => {
|
||||
it('transforms arrays', () => {
|
||||
const input = { numbers: [1, 2, 3] }
|
||||
const replacer: EncodeReplacer = (key, value) => {
|
||||
// Double all numbers
|
||||
if (typeof value === 'number')
|
||||
return value * 2
|
||||
return value
|
||||
@@ -147,7 +143,6 @@ describe('replacer function', () => {
|
||||
it('does not omit root when replacer returns undefined', () => {
|
||||
const input = { name: 'Alice' }
|
||||
const replacer: EncodeReplacer = (key, value, path) => {
|
||||
// Try to omit root (should be ignored)
|
||||
if (path.length === 0)
|
||||
return undefined
|
||||
return value
|
||||
@@ -156,7 +151,6 @@ describe('replacer function', () => {
|
||||
const result = encode(input, { replacer })
|
||||
const decoded = decode(result)
|
||||
|
||||
// Root should still be encoded
|
||||
expect(decoded).toEqual({ name: 'Alice' })
|
||||
})
|
||||
|
||||
@@ -183,7 +177,6 @@ describe('replacer function', () => {
|
||||
|
||||
encode(input, { replacer })
|
||||
|
||||
// First call should be root
|
||||
expect(calls[0]).toEqual({ key: '', path: [] })
|
||||
})
|
||||
})
|
||||
@@ -225,7 +218,6 @@ describe('replacer function', () => {
|
||||
|
||||
encode(input, { replacer })
|
||||
|
||||
// Array indices should be string '0', '1', '2'
|
||||
expect(seenKeys).toEqual(['0', '1', '2'])
|
||||
})
|
||||
|
||||
@@ -297,7 +289,6 @@ describe('replacer function', () => {
|
||||
const result = encode(input, { replacer })
|
||||
const decoded = decode(result) as JsonObject
|
||||
|
||||
// Date should be normalized to ISO string
|
||||
expect(typeof decoded.date).toBe('string')
|
||||
expect(decoded.date).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||||
})
|
||||
@@ -305,7 +296,6 @@ describe('replacer function', () => {
|
||||
it('handles all properties being filtered out', () => {
|
||||
const input = { a: 1, b: 2, c: 3 }
|
||||
const replacer: EncodeReplacer = (key, value, path) => {
|
||||
// Filter out all properties (but not root)
|
||||
if (path.length > 0)
|
||||
return undefined
|
||||
return value
|
||||
@@ -314,14 +304,12 @@ describe('replacer function', () => {
|
||||
const result = encode(input, { replacer })
|
||||
const decoded = decode(result)
|
||||
|
||||
// Should result in empty object
|
||||
expect(decoded).toEqual({})
|
||||
})
|
||||
|
||||
it('handles all array elements being filtered out', () => {
|
||||
const input = [1, 2, 3]
|
||||
const replacer: EncodeReplacer = (key, value, path) => {
|
||||
// Filter out all elements
|
||||
if (path.length > 0)
|
||||
return undefined
|
||||
return value
|
||||
@@ -330,7 +318,6 @@ describe('replacer function', () => {
|
||||
const result = encode(input, { replacer })
|
||||
const decoded = decode(result)
|
||||
|
||||
// Should result in empty array
|
||||
expect(decoded).toEqual([])
|
||||
})
|
||||
|
||||
@@ -363,7 +350,6 @@ describe('replacer function', () => {
|
||||
it('handles arrays with some elements removed', () => {
|
||||
const input = { items: [{ id: 1, keep: true }, { id: 2, keep: false }, { id: 3, keep: true }] }
|
||||
const replacer: EncodeReplacer = (key, value) => {
|
||||
// Remove objects where keep is false
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value) && 'keep' in value && value.keep === false) {
|
||||
return undefined
|
||||
}
|
||||
@@ -415,49 +401,7 @@ describe('replacer function', () => {
|
||||
const replacer: EncodeReplacer = (key, value) => value
|
||||
|
||||
const result = encode(input, { replacer, indent: 4 })
|
||||
// Should use 4-space indent
|
||||
expect(result).toContain(' name: Alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe('comparison with JSON.stringify replacer', () => {
|
||||
it('behaves similarly to JSON.stringify for filtering', () => {
|
||||
const input = { name: 'Alice', password: 'secret' }
|
||||
|
||||
// TOON replacer
|
||||
const toonReplacer: EncodeReplacer = (key, value) => {
|
||||
if (key === 'password')
|
||||
return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
// JSON.stringify replacer
|
||||
const jsonReplacer = (key: string, value: unknown) => {
|
||||
if (key === 'password')
|
||||
return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
const toonResult = decode(encode(input, { replacer: toonReplacer }))
|
||||
const jsonResult = JSON.parse(JSON.stringify(input, jsonReplacer))
|
||||
|
||||
expect(toonResult).toEqual(jsonResult)
|
||||
})
|
||||
|
||||
it('uses string indices for arrays like JSON.stringify', () => {
|
||||
const input = ['a', 'b', 'c']
|
||||
const keys: string[] = []
|
||||
|
||||
const replacer: EncodeReplacer = (key, value, path) => {
|
||||
if (path.length > 0)
|
||||
keys.push(key)
|
||||
return value
|
||||
}
|
||||
|
||||
encode(input, { replacer })
|
||||
|
||||
// Should match JSON.stringify behavior (string indices)
|
||||
expect(keys).toEqual(['0', '1', '2'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { UserConfig, UserConfigFn } from 'tsdown/config'
|
||||
import type { UserConfig } from 'tsdown/config'
|
||||
import { defineConfig } from 'tsdown/config'
|
||||
|
||||
const config: UserConfig | UserConfigFn = defineConfig({
|
||||
const config: UserConfig = defineConfig({
|
||||
entry: 'src/index.ts',
|
||||
dts: true,
|
||||
})
|
||||
|
||||
Generated
+2827
-2631
File diff suppressed because it is too large
Load Diff
+3
-5
@@ -1,17 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"rootDir": ".",
|
||||
"moduleDetection": "force",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedDeclarations": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"skipLibCheck": true
|
||||
|
||||
Reference in New Issue
Block a user