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