diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..2bd5a0a
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+22
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..fcda722
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,17 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run Extension",
+ "type": "extensionHost",
+ "request": "launch",
+ "args": [
+ "--extensionDevelopmentPath=${workspaceFolder}"
+ ],
+ "outFiles": [
+ "${workspaceFolder}/dist/**/*.mjs"
+ ],
+ "preLaunchTask": "npm: build"
+ }
+ ]
+}
diff --git a/.vscodeignore b/.vscodeignore
index 6de55ed..fe6f377 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -8,6 +8,10 @@ src/
.gitignore
eslint.config.mjs
pnpm-lock.yaml
+pnpm-workspace.yaml
tsconfig.json
-*.md
-!README.md
+tsdown.config.ts
+CONTRIBUTING.md
+**/*.map
+**/*.ts
+!dist/**
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5002af6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Changelog
+
+## 0.1.0
+
+Initial release.
+
+### Features
+
+- Bidirectional JSON/JSONC to TOON conversion
+- Selection-aware conversion in any file type
+- Explorer context menu for file-to-file conversion with save dialog
+- Editor context menu when text is selected
+- Keyboard shortcuts: `Cmd+Alt+T` / `Ctrl+Alt+T` (to TOON), `Cmd+Alt+J` / `Ctrl+Alt+J` (to JSON)
+- TextMate grammar for `.toon` syntax highlighting
+- Real-time TOON validation with diagnostics in the Problems panel
+- Configurable encoding options (indent, delimiter, key folding)
+- Configurable decoding options (path expansion)
diff --git a/README.md b/README.md
index 1a3c884..d5795f7 100644
--- a/README.md
+++ b/README.md
@@ -1,77 +1,88 @@
-# TOON Format for Visual Studio Code
+# TOON — JSON ↔ TOON Converter for VS Code
-> **⚠️ Development Status:** This extension is in early development. Bare minimum setup for team collaboration.
+Bidirectional conversion between JSON and [TOON](https://toonformat.dev) (Token-Oriented Object Notation), with syntax highlighting and real-time validation.
-Visual Studio Code extension for TOON format support. TOON is a compact, human-readable serialization format for LLM contexts with 30-60% token reduction vs JSON.
+TOON is a compact, human-readable format that achieves ~40% fewer tokens than JSON while maintaining lossless, deterministic round-trips. Designed for LLM contexts where every token has a cost.
## Features
-Currently in development. Planned features:
+- **Bidirectional conversion** — Convert JSON/JSONC to TOON and back
+- **Selection-aware** — Select text in any file to convert just that fragment
+- **Context menu** — Right-click `.json` or `.toon` files in the explorer
+- **Keyboard shortcuts** — `Cmd+Alt+T` (to TOON), `Cmd+Alt+J` (to JSON)
+- **Syntax highlighting** — Full TextMate grammar for `.toon` files
+- **Real-time validation** — Errors appear in the Problems panel as you type
+- **Configurable** — Indent size, delimiter style, key folding, path expansion
-- Syntax highlighting for `.toon` files
-- Format validation and error detection
-- Code formatting and auto-completion
-- Integration with TOON specification
+## Usage
-## Installation
+### Convert in the editor
-This extension is not yet published to the Visual Studio Marketplace. To install locally:
+1. Open a `.json` file
+2. Press `Cmd+Alt+T` (Mac) or `Ctrl+Alt+T` (Windows/Linux)
+3. The file content is replaced with TOON and the language mode switches
-```bash
-git clone https://github.com/toon-format/vscode.git
-cd toon-vscode
-pnpm install
-pnpm build
-```
+To convert back: open the `.toon` file and press `Cmd+Alt+J` / `Ctrl+Alt+J`.
-## Development
+### Convert a selection
-```bash
-# Setup
-git clone https://github.com/toon-format/vscode.git
-cd toon-vscode
-pnpm install
+Select any JSON or TOON text in any file (`.md`, `.txt`, etc.) and:
+- Right-click → "Convert JSON to TOON" or "Convert TOON to JSON"
+- Or use the keyboard shortcuts
-# Build
-pnpm build
+### Save as a new file
-# Development mode (watch)
-pnpm dev
+Right-click a `.json` file in the Explorer → "Convert to TOON (Save As...)"
+Right-click a `.toon` file in the Explorer → "Convert to JSON (Save As...)"
-# Run linting
-pnpm lint
+A save dialog appears so you choose the output location.
-# Type check
-pnpm test:types
+### Command Palette
-# Package extension
-pnpm package
-```
+All commands are available via `Cmd+Shift+P`:
+- `Convert JSON to TOON`
+- `Convert TOON to JSON`
+- `Convert to TOON (Save As...)`
+- `Convert to JSON (Save As...)`
-## Project Status & Roadmap
+## Settings
-Following semantic versioning towards 1.0.0:
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `toon.encode.indent` | `2` | Indentation size for TOON output |
+| `toon.encode.delimiter` | `,` | Delimiter for tabular arrays (`,`, `\|`, `\t`) |
+| `toon.encode.keyFolding` | `off` | Dotted key folding (`off` or `safe`) |
+| `toon.decode.expandPaths` | `off` | Expand dotted keys on decode (`off` or `safe`) |
+| `toon.json.indent` | `2` | Indentation size for JSON output |
+| `toon.openAfterConvert` | `true` | Open converted file after saving |
+| `toon.validation.enable` | `true` | Enable real-time validation in Problems panel |
-- **v0.0.x** - Initial project setup, bare minimum structure (current)
-- **v0.1.x** - Basic syntax highlighting and file recognition
-- **v0.2.x** - Format validation and error detection
-- **v0.3.x** - Code formatting and auto-completion
-- **v1.0.0** - First stable release with full TOON format support
+## Development
+
+Requires Node.js 22+ for build tooling.
-See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
+```bash
+git clone https://github.com/akumarpalo/vscode.git
+cd vscode
+pnpm install
+pnpm build
+```
-## Documentation
+Press F5 in VS Code to launch the Extension Development Host.
-- [📜 TOON Specification](https://github.com/toon-format/spec) - Official specification
-- [🐛 Issues](https://github.com/toon-format/vscode/issues) - Bug reports and features
-- [🤝 Contributing](CONTRIBUTING.md) - Contribution guidelines
+```bash
+pnpm dev # Watch mode
+pnpm lint # Lint
+pnpm test:types # Type check
+pnpm package # Build .vsix
+```
-## Related Projects
+## Links
-- [toon](https://github.com/toon-format/toon) - TypeScript implementation
-- [toon-python](https://github.com/toon-format/toon-python) - Python implementation
-- [toon-rust](https://github.com/toon-format/toon-rust) - Rust implementation
+- [TOON Specification](https://github.com/toon-format/spec)
+- [TOON TypeScript Library](https://github.com/toon-format/toon)
+- [Issues](https://github.com/akumarpalo/vscode/issues)
## License
-MIT License – see [LICENSE](LICENSE) for details
+MIT
diff --git a/images/icon.svg b/images/icon.svg
new file mode 100644
index 0000000..42a67a9
--- /dev/null
+++ b/images/icon.svg
@@ -0,0 +1 @@
+
diff --git a/language-configuration.json b/language-configuration.json
new file mode 100644
index 0000000..9c059c9
--- /dev/null
+++ b/language-configuration.json
@@ -0,0 +1,24 @@
+{
+ "brackets": [
+ ["[", "]"],
+ ["{", "}"]
+ ],
+ "autoClosingPairs": [
+ { "open": "{", "close": "}" },
+ { "open": "[", "close": "]" },
+ { "open": "\"", "close": "\"", "notIn": ["string"] }
+ ],
+ "surroundingPairs": [
+ { "open": "{", "close": "}" },
+ { "open": "[", "close": "]" },
+ { "open": "\"", "close": "\"" }
+ ],
+ "folding": {
+ "offSide": true
+ },
+ "indentationRules": {
+ "increaseIndentPattern": "^.+:\\s*$",
+ "decreaseIndentPattern": "^\\s*$"
+ },
+ "wordPattern": "[^\\s,:\\[\\]{}\"']+"
+}
diff --git a/package.json b/package.json
index 1fc2c75..8da8f9c 100644
--- a/package.json
+++ b/package.json
@@ -1,41 +1,191 @@
{
"publisher": "toon-format",
"name": "toon",
- "displayName": "Token-Oriented Object Notation (TOON) Support",
- "type": "module",
- "version": "0.0.1",
+ "displayName": "TOON — JSON ↔ TOON Converter",
+ "version": "0.1.0",
"packageManager": "pnpm@10.23.0",
- "description": "Visual Studio Code extension for TOON format support",
+ "description": "Bidirectional JSON/TOON conversion, syntax highlighting, and real-time validation for the TOON format",
"license": "MIT",
- "homepage": "https://github.com/toon-format/vscode#readme",
+ "homepage": "https://github.com/akumarpalo/vscode#readme",
"repository": {
"type": "git",
- "url": "https://github.com/toon-format/vscode.git"
+ "url": "https://github.com/akumarpalo/vscode.git"
},
"bugs": {
- "url": "https://github.com/toon-format/vscode/issues"
+ "url": "https://github.com/akumarpalo/vscode/issues"
},
"keywords": [
"toon",
+ "json",
+ "converter",
"format",
- "serialization"
+ "serialization",
+ "token-efficient"
],
"categories": [
"Programming Languages",
"Formatters"
],
- "main": "./dist/extension.mjs",
+ "main": "./dist/extension.cjs",
"engines": {
"vscode": "^1.95.0",
- "node": ">=24.0.0"
+ "node": ">=18.0.0"
+ },
+ "capabilities": {
+ "untrustedWorkspaces": {
+ "supported": true
+ }
},
"contributes": {
+ "languages": [
+ {
+ "id": "toon",
+ "aliases": [
+ "TOON",
+ "toon"
+ ],
+ "extensions": [
+ ".toon"
+ ],
+ "configuration": "./language-configuration.json"
+ }
+ ],
+ "grammars": [
+ {
+ "language": "toon",
+ "scopeName": "source.toon",
+ "path": "./syntaxes/toon.tmLanguage.json"
+ }
+ ],
"commands": [
{
- "command": "toon.helloWorld",
- "title": "TOON: Hello World"
+ "command": "toon.jsonToToon",
+ "title": "Convert JSON to TOON"
+ },
+ {
+ "command": "toon.toonToJson",
+ "title": "Convert TOON to JSON"
+ },
+ {
+ "command": "toon.convertToToonFile",
+ "title": "Convert to TOON (Save As…)"
+ },
+ {
+ "command": "toon.convertToJsonFile",
+ "title": "Convert to JSON (Save As…)"
+ }
+ ],
+ "menus": {
+ "commandPalette": [
+ {
+ "command": "toon.jsonToToon",
+ "when": "editorTextFocus"
+ },
+ {
+ "command": "toon.toonToJson",
+ "when": "editorTextFocus"
+ },
+ {
+ "command": "toon.convertToToonFile",
+ "when": "editorLangId == json || editorLangId == jsonc"
+ },
+ {
+ "command": "toon.convertToJsonFile",
+ "when": "editorLangId == toon"
+ }
+ ],
+ "explorer/context": [
+ {
+ "command": "toon.convertToToonFile",
+ "when": "resourceExtname == .json",
+ "group": "7_toon"
+ },
+ {
+ "command": "toon.convertToJsonFile",
+ "when": "resourceExtname == .toon",
+ "group": "7_toon"
+ }
+ ],
+ "editor/context": [
+ {
+ "command": "toon.jsonToToon",
+ "when": "editorLangId == json || editorLangId == jsonc || editorHasSelection",
+ "group": "1_modification"
+ },
+ {
+ "command": "toon.toonToJson",
+ "when": "editorLangId == toon || editorHasSelection",
+ "group": "1_modification"
+ }
+ ]
+ },
+ "keybindings": [
+ {
+ "command": "toon.jsonToToon",
+ "key": "ctrl+alt+t",
+ "mac": "cmd+alt+t",
+ "when": "editorTextFocus"
+ },
+ {
+ "command": "toon.toonToJson",
+ "key": "ctrl+alt+j",
+ "mac": "cmd+alt+j",
+ "when": "editorTextFocus"
+ }
+ ],
+ "configuration": {
+ "title": "TOON",
+ "properties": {
+ "toon.encode.indent": {
+ "type": "number",
+ "default": 2,
+ "description": "Indentation size for TOON output"
+ },
+ "toon.encode.delimiter": {
+ "type": "string",
+ "enum": [
+ ",",
+ "|",
+ "\t"
+ ],
+ "default": ",",
+ "description": "Delimiter for tabular arrays in TOON output"
+ },
+ "toon.encode.keyFolding": {
+ "type": "string",
+ "enum": [
+ "off",
+ "safe"
+ ],
+ "default": "off",
+ "description": "Enable dotted key folding (e.g., a.b.c: value)"
+ },
+ "toon.decode.expandPaths": {
+ "type": "string",
+ "enum": [
+ "off",
+ "safe"
+ ],
+ "default": "off",
+ "description": "Expand dotted keys into nested objects when decoding"
+ },
+ "toon.json.indent": {
+ "type": "number",
+ "default": 2,
+ "description": "Indentation size for JSON output"
+ },
+ "toon.openAfterConvert": {
+ "type": "boolean",
+ "default": true,
+ "description": "Open the converted file after saving"
+ },
+ "toon.validation.enable": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable real-time TOON validation in the Problems panel"
+ }
}
- ]
+ }
},
"scripts": {
"build": "tsdown",
@@ -44,10 +194,14 @@
"lint:fix": "eslint . --fix",
"test": "echo \"No tests yet\" && exit 0",
"test:types": "tsc --noEmit",
- "package": "pnpm build && vsce package",
+ "package": "pnpm build && vsce package --no-dependencies",
"publish": "pnpm build && vsce publish",
"vscode:prepublish": "pnpm build"
},
+ "dependencies": {
+ "@toon-format/toon": "^2.3.0",
+ "jsonc-parser": "^3.2.0"
+ },
"devDependencies": {
"@antfu/eslint-config": "^6.2.0",
"@types/node": "^24.10.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be394e0..73ad60c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,13 @@ settings:
importers:
.:
+ dependencies:
+ '@toon-format/toon':
+ specifier: ^2.3.0
+ version: 2.3.0
+ jsonc-parser:
+ specifier: ^3.2.0
+ version: 3.3.1
devDependencies:
'@antfu/eslint-config':
specifier: ^6.2.0
@@ -479,6 +486,9 @@ packages:
'@textlint/types@15.4.0':
resolution: {integrity: sha512-ZMwJgw/xjxJufOD+IB7I2Enl9Si4Hxo04B76RwUZ5cKBKzOPcmd6WvGe2F7jqdgmTdGnfMU+Bo/joQrjPNIWqg==}
+ '@toon-format/toon@2.3.0':
+ resolution: {integrity: sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -3060,6 +3070,8 @@ snapshots:
dependencies:
'@textlint/ast-node-types': 15.4.0
+ '@toon-format/toon@2.3.0': {}
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
diff --git a/src/commands/convertToJsonFile.ts b/src/commands/convertToJsonFile.ts
new file mode 100644
index 0000000..c565a2d
--- /dev/null
+++ b/src/commands/convertToJsonFile.ts
@@ -0,0 +1,54 @@
+import * as vscode from 'vscode'
+import { toonToJson } from '../converter'
+import { getDecodeOptions } from '../utils'
+
+export async function convertToJsonFileCommand(uri?: vscode.Uri): Promise {
+ const sourceUri = uri ?? vscode.window.activeTextEditor?.document.uri
+ if (!sourceUri)
+ return
+
+ let text: string
+ try {
+ const fileContent = await vscode.workspace.fs.readFile(sourceUri)
+ text = new TextDecoder('utf-8').decode(fileContent)
+ }
+ catch (e) {
+ vscode.window.showErrorMessage(`Failed to read file: ${(e as Error).message}`)
+ return
+ }
+
+ if (!text.trim()) {
+ vscode.window.showInformationMessage('Nothing to convert — file is empty.')
+ return
+ }
+
+ const options = getDecodeOptions()
+ const result = toonToJson(text, options)
+
+ if (!result.success) {
+ vscode.window.showErrorMessage(`Conversion failed: ${result.error}`)
+ return
+ }
+
+ const defaultPath = sourceUri.fsPath.replace(/\.toon$/, '.json')
+ const defaultUri = vscode.Uri.file(defaultPath)
+
+ const targetUri = await vscode.window.showSaveDialog({
+ defaultUri,
+ filters: { 'JSON files': ['json'] },
+ title: 'Save JSON file as…',
+ })
+
+ if (!targetUri)
+ return
+
+ await vscode.workspace.fs.writeFile(targetUri, new TextEncoder().encode(result.output))
+
+ const config = vscode.workspace.getConfiguration('toon')
+ if (config.get('openAfterConvert', true)) {
+ const doc = await vscode.workspace.openTextDocument(targetUri)
+ await vscode.window.showTextDocument(doc)
+ }
+
+ vscode.window.setStatusBarMessage('$(check) Saved JSON file', 3000)
+}
diff --git a/src/commands/convertToToonFile.ts b/src/commands/convertToToonFile.ts
new file mode 100644
index 0000000..f11cbca
--- /dev/null
+++ b/src/commands/convertToToonFile.ts
@@ -0,0 +1,55 @@
+import * as vscode from 'vscode'
+import { jsonToToon } from '../converter'
+import { getEncodeOptions } from '../utils'
+
+export async function convertToToonFileCommand(uri?: vscode.Uri): Promise {
+ const sourceUri = uri ?? vscode.window.activeTextEditor?.document.uri
+ if (!sourceUri)
+ return
+
+ let text: string
+ try {
+ const fileContent = await vscode.workspace.fs.readFile(sourceUri)
+ text = new TextDecoder('utf-8').decode(fileContent)
+ }
+ catch (e) {
+ vscode.window.showErrorMessage(`Failed to read file: ${(e as Error).message}`)
+ return
+ }
+
+ if (!text.trim()) {
+ vscode.window.showInformationMessage('Nothing to convert — file is empty.')
+ return
+ }
+
+ const languageId = sourceUri.fsPath.endsWith('.jsonc') ? 'jsonc' : 'json'
+ const options = getEncodeOptions()
+ const result = jsonToToon(text, languageId, options)
+
+ if (!result.success) {
+ vscode.window.showErrorMessage(`Conversion failed: ${result.error}`)
+ return
+ }
+
+ const defaultPath = sourceUri.fsPath.replace(/\.jsonc?$/, '.toon')
+ const defaultUri = vscode.Uri.file(defaultPath)
+
+ const targetUri = await vscode.window.showSaveDialog({
+ defaultUri,
+ filters: { 'TOON files': ['toon'] },
+ title: 'Save TOON file as…',
+ })
+
+ if (!targetUri)
+ return
+
+ await vscode.workspace.fs.writeFile(targetUri, new TextEncoder().encode(result.output))
+
+ const config = vscode.workspace.getConfiguration('toon')
+ if (config.get('openAfterConvert', true)) {
+ const doc = await vscode.workspace.openTextDocument(targetUri)
+ await vscode.window.showTextDocument(doc)
+ }
+
+ vscode.window.setStatusBarMessage('$(check) Saved TOON file', 3000)
+}
diff --git a/src/commands/index.ts b/src/commands/index.ts
new file mode 100644
index 0000000..00d5bbb
--- /dev/null
+++ b/src/commands/index.ts
@@ -0,0 +1,4 @@
+export { convertToJsonFileCommand } from './convertToJsonFile'
+export { convertToToonFileCommand } from './convertToToonFile'
+export { jsonToToonCommand } from './jsonToToon'
+export { toonToJsonCommand } from './toonToJson'
diff --git a/src/commands/jsonToToon.ts b/src/commands/jsonToToon.ts
new file mode 100644
index 0000000..dd1042d
--- /dev/null
+++ b/src/commands/jsonToToon.ts
@@ -0,0 +1,65 @@
+import * as vscode from 'vscode'
+import { jsonToToon } from '../converter'
+import { getEncodeOptions, getFullDocumentRange } from '../utils'
+
+export async function jsonToToonCommand(): Promise {
+ const editor = vscode.window.activeTextEditor
+ if (!editor)
+ return
+
+ const document = editor.document
+ const languageId = document.languageId
+ const selection = editor.selection
+ const hasSelection = !selection.isEmpty
+
+ if (!hasSelection) {
+ if (languageId === 'toon') {
+ vscode.window.showInformationMessage('This file is already TOON.')
+ return
+ }
+ if (languageId !== 'json' && languageId !== 'jsonc') {
+ vscode.window.showInformationMessage('Select JSON text to convert, or open a .json file.')
+ return
+ }
+ }
+
+ const range = hasSelection
+ ? new vscode.Range(selection.start, selection.end)
+ : getFullDocumentRange(document)
+
+ const text = document.getText(range)
+
+ if (!text.trim()) {
+ vscode.window.showInformationMessage('Nothing to convert — selection is empty.')
+ return
+ }
+
+ const parseLanguageId = (languageId === 'json' || languageId === 'jsonc') ? languageId : 'json'
+ const options = getEncodeOptions()
+ const result = jsonToToon(text, parseLanguageId, options)
+
+ if (!result.success) {
+ vscode.window.showErrorMessage(`Conversion failed: ${result.error}`)
+ if (result.line !== undefined) {
+ const pos = new vscode.Position(range.start.line + result.line - 1, 0)
+ editor.selection = new vscode.Selection(pos, pos)
+ editor.revealRange(new vscode.Range(pos, pos))
+ }
+ return
+ }
+
+ const applied = await editor.edit((editBuilder) => {
+ editBuilder.replace(range, result.output)
+ })
+
+ if (!applied) {
+ vscode.window.showErrorMessage('Failed to apply edit — the document may have changed.')
+ return
+ }
+
+ if (!hasSelection) {
+ await vscode.languages.setTextDocumentLanguage(document, 'toon')
+ }
+
+ vscode.window.setStatusBarMessage('$(check) Converted to TOON', 3000)
+}
diff --git a/src/commands/toonToJson.ts b/src/commands/toonToJson.ts
new file mode 100644
index 0000000..b0b2ac0
--- /dev/null
+++ b/src/commands/toonToJson.ts
@@ -0,0 +1,64 @@
+import * as vscode from 'vscode'
+import { toonToJson } from '../converter'
+import { getDecodeOptions, getFullDocumentRange } from '../utils'
+
+export async function toonToJsonCommand(): Promise {
+ const editor = vscode.window.activeTextEditor
+ if (!editor)
+ return
+
+ const document = editor.document
+ const languageId = document.languageId
+ const selection = editor.selection
+ const hasSelection = !selection.isEmpty
+
+ if (!hasSelection) {
+ if (languageId === 'json' || languageId === 'jsonc') {
+ vscode.window.showInformationMessage('This file is already JSON.')
+ return
+ }
+ if (languageId !== 'toon') {
+ vscode.window.showInformationMessage('Select TOON text to convert, or open a .toon file.')
+ return
+ }
+ }
+
+ const range = hasSelection
+ ? new vscode.Range(selection.start, selection.end)
+ : getFullDocumentRange(document)
+
+ const text = document.getText(range)
+
+ if (!text.trim()) {
+ vscode.window.showInformationMessage('Nothing to convert — selection is empty.')
+ return
+ }
+
+ const options = getDecodeOptions()
+ const result = toonToJson(text, options)
+
+ if (!result.success) {
+ vscode.window.showErrorMessage(`Conversion failed: ${result.error}`)
+ if (result.line !== undefined) {
+ const pos = new vscode.Position(range.start.line + result.line - 1, 0)
+ editor.selection = new vscode.Selection(pos, pos)
+ editor.revealRange(new vscode.Range(pos, pos))
+ }
+ return
+ }
+
+ const applied = await editor.edit((editBuilder) => {
+ editBuilder.replace(range, result.output)
+ })
+
+ if (!applied) {
+ vscode.window.showErrorMessage('Failed to apply edit — the document may have changed.')
+ return
+ }
+
+ if (!hasSelection) {
+ await vscode.languages.setTextDocumentLanguage(document, 'json')
+ }
+
+ vscode.window.setStatusBarMessage('$(check) Converted to JSON', 3000)
+}
diff --git a/src/converter.ts b/src/converter.ts
new file mode 100644
index 0000000..5b532d6
--- /dev/null
+++ b/src/converter.ts
@@ -0,0 +1,104 @@
+import type { DecodeOptions, EncodeOptions } from '@toon-format/toon'
+import type { ParseError } from 'jsonc-parser'
+import { decode, encode } from '@toon-format/toon'
+import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'
+
+export interface ConversionSuccess {
+ success: true
+ output: string
+}
+
+export interface ConversionFailure {
+ success: false
+ error: string
+ line?: number
+}
+
+export type ConversionResult = ConversionSuccess | ConversionFailure
+
+export interface JsonToToonOptions {
+ indent?: number
+ delimiter?: ',' | '|' | '\t'
+ keyFolding?: 'off' | 'safe'
+}
+
+export interface ToonToJsonOptions {
+ indent?: number
+ expandPaths?: 'off' | 'safe'
+}
+
+export function jsonToToon(input: string, languageId: string, options: JsonToToonOptions = {}): ConversionResult {
+ let data: unknown
+
+ try {
+ if (languageId === 'jsonc') {
+ const errors: ParseError[] = []
+ data = parseJsonc(input, errors, { allowTrailingComma: true })
+ if (errors.length > 0) {
+ const firstError = errors[0]!
+ const line = offsetToLine(input, firstError.offset)
+ const errorType = printParseErrorCode(firstError.error)
+ return { success: false, error: `${errorType} at line ${line}`, line }
+ }
+ }
+ else {
+ data = JSON.parse(input)
+ }
+ }
+ catch (e) {
+ const line = extractJsonErrorLine(e as SyntaxError, input)
+ return { success: false, error: (e as Error).message, line }
+ }
+
+ try {
+ const encodeOptions: EncodeOptions = {}
+ if (options.indent !== undefined)
+ encodeOptions.indent = options.indent
+ if (options.delimiter !== undefined)
+ encodeOptions.delimiter = options.delimiter
+ if (options.keyFolding !== undefined)
+ encodeOptions.keyFolding = options.keyFolding
+
+ const output = encode(data, encodeOptions)
+ return { success: true, output }
+ }
+ catch (e) {
+ return { success: false, error: `Encode error: ${(e as Error).message}` }
+ }
+}
+
+export function toonToJson(input: string, options: ToonToJsonOptions = {}): ConversionResult {
+ try {
+ const decodeOptions: DecodeOptions = { strict: true }
+ if (options.expandPaths !== undefined)
+ decodeOptions.expandPaths = options.expandPaths
+
+ const data = decode(input, decodeOptions)
+ const indent = options.indent ?? 2
+ const output = JSON.stringify(data, null, indent)
+ return { success: true, output }
+ }
+ catch (e) {
+ const line = (e as { line?: number }).line
+ return { success: false, error: (e as Error).message, line }
+ }
+}
+
+function offsetToLine(text: string, offset: number): number {
+ let line = 1
+ for (let i = 0; i < offset && i < text.length; i++) {
+ if (text[i] === '\n')
+ line++
+ }
+ return line
+}
+
+// V8-specific: matches "at position N" format. Safe because VS Code always runs on V8.
+function extractJsonErrorLine(error: SyntaxError, input: string): number | undefined {
+ const match = error.message.match(/position\s+(\d+)/)
+ if (match) {
+ const offset = Number.parseInt(match[1]!, 10)
+ return offsetToLine(input, offset)
+ }
+ return undefined
+}
diff --git a/src/diagnostics.ts b/src/diagnostics.ts
new file mode 100644
index 0000000..da2b566
--- /dev/null
+++ b/src/diagnostics.ts
@@ -0,0 +1,80 @@
+import { decode } from '@toon-format/toon'
+import * as vscode from 'vscode'
+
+let diagnosticCollection: vscode.DiagnosticCollection
+const debounceTimers = new Map>()
+
+export function setupDiagnostics(context: vscode.ExtensionContext): void {
+ diagnosticCollection = vscode.languages.createDiagnosticCollection('toon')
+ context.subscriptions.push(diagnosticCollection)
+
+ context.subscriptions.push(
+ vscode.workspace.onDidOpenTextDocument(validateToonDocument),
+ vscode.workspace.onDidChangeTextDocument((e) => {
+ validateDebounced(e.document)
+ }),
+ vscode.workspace.onDidCloseTextDocument((document) => {
+ const key = document.uri.toString()
+ const timer = debounceTimers.get(key)
+ if (timer) {
+ clearTimeout(timer)
+ debounceTimers.delete(key)
+ }
+ diagnosticCollection.delete(document.uri)
+ }),
+ )
+
+ vscode.workspace.textDocuments.forEach(validateToonDocument)
+}
+
+function validateDebounced(document: vscode.TextDocument): void {
+ const key = document.uri.toString()
+ const existing = debounceTimers.get(key)
+ if (existing)
+ clearTimeout(existing)
+
+ debounceTimers.set(
+ key,
+ setTimeout(() => {
+ debounceTimers.delete(key)
+ validateToonDocument(document)
+ }, 300),
+ )
+}
+
+function validateToonDocument(document: vscode.TextDocument): void {
+ if (document.languageId !== 'toon')
+ return
+
+ const config = vscode.workspace.getConfiguration('toon')
+ if (!config.get('validation.enable', true)) {
+ diagnosticCollection.set(document.uri, [])
+ return
+ }
+
+ const text = document.getText()
+ if (!text.trim()) {
+ diagnosticCollection.set(document.uri, [])
+ return
+ }
+
+ try {
+ decode(text, { strict: true })
+ diagnosticCollection.set(document.uri, [])
+ }
+ catch (e) {
+ const err = e as Error & { line?: number }
+ const lineNum = (err.line ?? 1) - 1
+ const lineText = document.lineAt(Math.min(lineNum, document.lineCount - 1))
+ const range = new vscode.Range(lineText.range.start, lineText.range.end)
+
+ const diagnostic = new vscode.Diagnostic(
+ range,
+ err.message,
+ vscode.DiagnosticSeverity.Error,
+ )
+ diagnostic.source = 'TOON'
+
+ diagnosticCollection.set(document.uri, [diagnostic])
+ }
+}
diff --git a/src/extension.ts b/src/extension.ts
index 7c976c7..002ea70 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,14 +1,21 @@
import * as vscode from 'vscode'
+import {
+ convertToJsonFileCommand,
+ convertToToonFileCommand,
+ jsonToToonCommand,
+ toonToJsonCommand,
+} from './commands'
+import { setupDiagnostics } from './diagnostics'
export function activate(context: vscode.ExtensionContext): void {
- // Register the hello world command
- const disposable = vscode.commands.registerCommand('toon.helloWorld', () => {
- vscode.window.showInformationMessage('Hello World from TOON!')
- })
+ context.subscriptions.push(
+ vscode.commands.registerCommand('toon.jsonToToon', jsonToToonCommand),
+ vscode.commands.registerCommand('toon.toonToJson', toonToJsonCommand),
+ vscode.commands.registerCommand('toon.convertToToonFile', convertToToonFileCommand),
+ vscode.commands.registerCommand('toon.convertToJsonFile', convertToJsonFileCommand),
+ )
- context.subscriptions.push(disposable)
+ setupDiagnostics(context)
}
-export function deactivate(): void {
- // Cleanup if needed
-}
+export function deactivate(): void {}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..525a18a
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,26 @@
+import type { JsonToToonOptions, ToonToJsonOptions } from './converter'
+import * as vscode from 'vscode'
+
+export function getEncodeOptions(): JsonToToonOptions {
+ const config = vscode.workspace.getConfiguration('toon')
+ return {
+ indent: config.get('encode.indent', 2),
+ delimiter: config.get<',' | '|' | '\t'>('encode.delimiter', ','),
+ keyFolding: config.get<'off' | 'safe'>('encode.keyFolding', 'off'),
+ }
+}
+
+export function getDecodeOptions(): ToonToJsonOptions {
+ const config = vscode.workspace.getConfiguration('toon')
+ return {
+ indent: config.get('json.indent', 2),
+ expandPaths: config.get<'off' | 'safe'>('decode.expandPaths', 'off'),
+ }
+}
+
+export function getFullDocumentRange(document: vscode.TextDocument): vscode.Range {
+ return new vscode.Range(
+ document.positionAt(0),
+ document.positionAt(document.getText().length),
+ )
+}
diff --git a/syntaxes/toon.tmLanguage.json b/syntaxes/toon.tmLanguage.json
new file mode 100644
index 0000000..b624434
--- /dev/null
+++ b/syntaxes/toon.tmLanguage.json
@@ -0,0 +1,140 @@
+{
+ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
+ "name": "TOON",
+ "scopeName": "source.toon",
+ "patterns": [
+ { "include": "#list-item" },
+ { "include": "#array-header-line" },
+ { "include": "#key-value-line" },
+ { "include": "#key-value-line-simple" },
+ { "include": "#key-nested" },
+ { "include": "#tabular-row" }
+ ],
+ "repository": {
+ "list-item": {
+ "match": "^(\\s*)(-)\\s",
+ "captures": {
+ "2": { "name": "punctuation.definition.list.begin.toon" }
+ }
+ },
+ "array-header-line": {
+ "comment": "Matches: key[N]{fields}: (end of line, nested content below)",
+ "match": "^(\\s*)([\\w][\\w.]*)(\\[)(\\d+)([|\\t])?(\\])(?:(\\{)([^}]*)(\\}))?(:)\\s*$",
+ "captures": {
+ "2": { "name": "support.type.property-name.toon" },
+ "3": { "name": "punctuation.definition.array.begin.toon" },
+ "4": { "name": "constant.numeric.toon" },
+ "5": { "name": "punctuation.separator.delimiter.toon" },
+ "6": { "name": "punctuation.definition.array.end.toon" },
+ "7": { "name": "punctuation.definition.fieldlist.begin.toon" },
+ "8": { "name": "variable.other.field.toon" },
+ "9": { "name": "punctuation.definition.fieldlist.end.toon" },
+ "10": { "name": "punctuation.separator.key-value.toon" }
+ }
+ },
+ "key-value-line": {
+ "comment": "Matches: key[N]{fields}: inline-values (primitive array with header)",
+ "match": "^(\\s*)([\\w][\\w.]*)(\\[)(\\d+)([|\\t])?(\\])(?:(\\{)([^}]*)(\\}))?(:)\\s+(.+)$",
+ "captures": {
+ "2": { "name": "support.type.property-name.toon" },
+ "3": { "name": "punctuation.definition.array.begin.toon" },
+ "4": { "name": "constant.numeric.toon" },
+ "5": { "name": "punctuation.separator.delimiter.toon" },
+ "6": { "name": "punctuation.definition.array.end.toon" },
+ "7": { "name": "punctuation.definition.fieldlist.begin.toon" },
+ "8": { "name": "variable.other.field.toon" },
+ "9": { "name": "punctuation.definition.fieldlist.end.toon" },
+ "10": { "name": "punctuation.separator.key-value.toon" },
+ "11": { "patterns": [{ "include": "#inline-values" }] }
+ }
+ },
+ "key-value-line-simple": {
+ "comment": "Matches: key: value",
+ "match": "^(\\s*)([\\w][\\w.]*)(:)\\s+(.+)$",
+ "captures": {
+ "2": { "name": "support.type.property-name.toon" },
+ "3": { "name": "punctuation.separator.key-value.toon" },
+ "4": { "patterns": [{ "include": "#value" }] }
+ }
+ },
+ "key-nested": {
+ "comment": "Matches: key: (end of line, nested content below)",
+ "match": "^(\\s*)([\\w][\\w.]*)(:)\\s*$",
+ "captures": {
+ "2": { "name": "support.type.property-name.toon" },
+ "3": { "name": "punctuation.separator.key-value.toon" }
+ }
+ },
+ "inline-values": {
+ "patterns": [
+ { "include": "#string-quoted" },
+ { "include": "#constant-boolean" },
+ { "include": "#constant-null" },
+ { "include": "#constant-numeric" },
+ { "include": "#punctuation-delimiter" }
+ ]
+ },
+ "tabular-row": {
+ "comment": "Catch-all for lines that are tabular data rows (values separated by delimiters)",
+ "patterns": [
+ { "include": "#string-quoted" },
+ { "include": "#constant-boolean" },
+ { "include": "#constant-null" },
+ { "include": "#constant-numeric" },
+ { "include": "#punctuation-delimiter" }
+ ]
+ },
+ "value": {
+ "patterns": [
+ { "include": "#string-quoted" },
+ { "include": "#constant-boolean" },
+ { "include": "#constant-null" },
+ { "include": "#constant-numeric" },
+ { "include": "#empty-array" },
+ { "include": "#string-unquoted" }
+ ]
+ },
+ "string-quoted": {
+ "name": "string.quoted.double.toon",
+ "begin": "\"",
+ "end": "\"",
+ "beginCaptures": {
+ "0": { "name": "punctuation.definition.string.begin.toon" }
+ },
+ "endCaptures": {
+ "0": { "name": "punctuation.definition.string.end.toon" }
+ },
+ "patterns": [
+ { "include": "#escape-sequence" }
+ ]
+ },
+ "escape-sequence": {
+ "name": "constant.character.escape.toon",
+ "match": "\\\\(?:[\"\\\\/nrt]|u[0-9a-fA-F]{4})"
+ },
+ "constant-boolean": {
+ "name": "constant.language.boolean.toon",
+ "match": "\\b(true|false)\\b"
+ },
+ "constant-null": {
+ "name": "constant.language.null.toon",
+ "match": "\\bnull\\b"
+ },
+ "constant-numeric": {
+ "name": "constant.numeric.toon",
+ "match": "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?(?=\\s*[,|\\t]|\\s*$)"
+ },
+ "empty-array": {
+ "name": "constant.language.toon",
+ "match": "\\[\\]"
+ },
+ "punctuation-delimiter": {
+ "name": "punctuation.separator.toon",
+ "match": "[,|]"
+ },
+ "string-unquoted": {
+ "name": "string.unquoted.toon",
+ "match": "[^,|\\s\"][^,|]*"
+ }
+ }
+}
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 19a93de..dccc548 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -1,10 +1,15 @@
-import type { UserConfig, UserConfigFn } from 'tsdown/config'
import { defineConfig } from 'tsdown/config'
+import { resolve } from 'node:path'
-const config: UserConfig | UserConfigFn = defineConfig({
+export default defineConfig({
entry: 'src/extension.ts',
+ format: 'cjs',
+ outDir: 'dist',
external: ['vscode'],
- dts: true,
+ noExternal: ['@toon-format/toon', 'jsonc-parser'],
+ dts: false,
+ clean: true,
+ alias: {
+ 'jsonc-parser': resolve('node_modules/jsonc-parser/lib/esm/main.js'),
+ },
})
-
-export default config