diff --git a/.github/workflows/prerelease_build.yml b/.github/workflows/prerelease_build.yml index 4140f3d..117a34e 100644 --- a/.github/workflows/prerelease_build.yml +++ b/.github/workflows/prerelease_build.yml @@ -18,6 +18,7 @@ jobs: with: python-version: '3.12' - run: pip install PyYAML + - run: npm ci - name: Publish to Visual Studio Marketplace id: publishToVSCMarketplace uses: HaaLeo/publish-vscode-extension@v1.6.2 diff --git a/.github/workflows/release_deploy.yml b/.github/workflows/release_deploy.yml index e095d20..ccff7b4 100644 --- a/.github/workflows/release_deploy.yml +++ b/.github/workflows/release_deploy.yml @@ -18,6 +18,7 @@ jobs: with: python-version: '3.12' - run: pip install PyYAML + - run: npm ci - name: Publish to Visual Studio Marketplace id: publishToVSCMarketplace uses: HaaLeo/publish-vscode-extension@v1.6.2 diff --git a/.gitignore b/.gitignore index 67dfeb3..1581edf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules +out *.vsix \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index be5647f..82bac52 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}" ], - "preLaunchTask": "parseyaml" + "preLaunchTask": "build-extension" } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f3ab7ec..52e3520 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,12 +1,32 @@ { - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "parseyaml", "type": "shell", - "command": "py build-grammar.py syntaxes\\mts.tmLanguage.yaml syntaxes\\mts.tmLanguage.json" + "command": "python build-grammar.py syntaxes/mts.tmLanguage.yaml syntaxes/mts.tmLanguage.json", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "compile", + "type": "npm", + "script": "compile", + "problemMatcher": "$tsc", + "options": { + "cwd": "${workspaceFolder}" + } + }, + { + "label": "build-extension", + "dependsOn": [ + "parseyaml", + "compile" + ], + "dependsOrder": "sequence", + "problemMatcher": [] } ] -} \ No newline at end of file +} diff --git a/.vscodeignore b/.vscodeignore index 95d8854..f9bd660 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,9 +1,15 @@ -# Ignore +# Ignore everything by default; whitelist packaged files. ** -# Keep +!package.json +!language-configuration.json !CHANGELOG.md !LICENSE -!images/ -!syntaxes/ -!language-configuration.json \ No newline at end of file +!images/** +!syntaxes/** +!out/**/*.js +!node_modules/vscode-html-languageservice/** +!node_modules/vscode-languageserver-textdocument/** +!node_modules/vscode-languageserver-types/** +!node_modules/vscode-uri/** +!node_modules/@vscode/l10n/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8b672..f5def31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Variable usage before assignment waring. - For-loop argument validation. +## [0.1.4] + +### Added + +- `.mtmacro` file extension registered for MapTool Script (same grammar and language configuration as `.mts`). +- **Format Document** for `.mts` / `.mtmacro`: indents macro source by brace nesting. +- Optional formatting of HTML fragments inside string literals, using the workspace HTML formatter rules for those segments. +- Settings under **MapTool Script**: + - `maptoolScript.format.enable` — toggle Format Document (default: on). + - `maptoolScript.format.htmlInSingleQuotedStrings` — format HTML inside single-quoted strings (default: on). + - `maptoolScript.format.htmlInDoubleQuotedStrings` — format HTML inside double-quoted strings (default: off; turning on may insert `"` and break the macro string). + ## [0.1.3] ### Fixed diff --git a/README.md b/README.md index cccaf63..6f4459c 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,28 @@ Visual Studio Code extension for MapTool macro script language support. ## Features -Basic syntax highlighting for MapTool script. +Syntax highlighting and formatting for MapTool macro script (`.mts` and `.mtmacro`). * Roll Options. * *Most* nested macros. * Special variables. -* Separate scopes for built-in vs user-defined fuctions. +* Separate scopes for built-in vs user-defined functions. * By default built-in functions are bold, but that can be customized by theme. * RPEdit formatting support. +* **Format Document** indents macro source by brace nesting. +* Optional formatting of HTML fragments inside string literals (see settings below). ![Multi-part example](images/multi-part-macro.png) ## Extension Settings -None yet. :/ +Open **Settings** and search for **MapTool Script**, or edit `settings.json`: + +| Setting | Default | Description | +| --- | --- | --- | +| `maptoolScript.format.enable` | `true` | Enable **Format Document** for `.mts` / `.mtmacro`. | +| `maptoolScript.format.htmlInSingleQuotedStrings` | `true` | Format HTML inside single-quoted strings. | +| `maptoolScript.format.htmlInDoubleQuotedStrings` | `false` | Format HTML inside double-quoted strings. Off by default because beautified HTML may insert double quotes and break the macro string. | ## Known Issues @@ -26,6 +34,12 @@ This is very basic. So depending on your coding style it may not catch everythin ## Release Notes +### 0.1.4 + +* `.mtmacro` files use the same MapTool Script language as `.mts`. +* **Format Document** for `.mts` / `.mtmacro` (brace-based indentation). +* Optional formatting of embedded HTML in strings, with settings to control single- vs double-quoted strings. + ### 0.1.3 * Fixed colon used for identifying library in macro roll-option breaks highlighting. diff --git a/package-lock.json b/package-lock.json index 08d0417..8acda07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,98 @@ { "name": "maptool-script", - "version": "0.0.1", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maptool-script", - "version": "0.0.1", + "version": "0.1.4", + "dependencies": { + "vscode-html-languageservice": "^5.6.2", + "vscode-languageserver-textdocument": "^1.0.12" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "@types/vscode": "^1.75.0", + "typescript": "^6.0.2" + }, "engines": { "vscode": "^1.75.0" } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.75.0.tgz", + "integrity": "sha512-SAr0PoOhJS6FUq5LjNr8C/StBKALZwDVm3+U4pjF/3iYkt3GioJOPV/oB1Sf1l7lROe4TgrMyL5N1yaEgTWycw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index c5291f0..6b28317 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,21 @@ "displayName": "MapTool-Script Support", "description": "Syntax highlighting support for the old MapTool scripting language", "icon": "images/logo.png", - "version": "0.1.3", + "version": "0.1.4", "publisher": "bryan-c-jones", "repository": { - "url" : "https://github.com/Daedeross/maptool-script.vscode" + "url": "https://github.com/Daedeross/maptool-script.vscode" }, + "main": "./out/extension.js", "engines": { "vscode": "^1.75.0" }, "categories": [ "Programming Languages" ], + "activationEvents": [ + "onLanguage:mts" + ], "contributes": { "languages": [ { @@ -23,7 +27,8 @@ "mts" ], "extensions": [ - ".mts" + ".mts", + ".mtmacro" ], "configuration": "./language-configuration.json" } @@ -32,7 +37,10 @@ { "language": "mts", "scopeName": "source.mts", - "path": "./syntaxes/mts.tmLanguage.json" + "path": "./syntaxes/mts.tmLanguage.json", + "embeddedLanguages": { + "meta.embedded.block.html": "html" + } } ], "configurationDefaults": { @@ -49,9 +57,40 @@ } ] } + }, + "configuration": { + "title": "MapTool Script", + "properties": { + "maptoolScript.format.enable": { + "type": "boolean", + "default": true, + "description": "Enable Format Document for MapTool Script (.mts / .mtmacro)." + }, + "maptoolScript.format.htmlInSingleQuotedStrings": { + "type": "boolean", + "default": true, + "description": "Format HTML fragments inside single-quoted string literals." + }, + "maptoolScript.format.htmlInDoubleQuotedStrings": { + "type": "boolean", + "default": false, + "description": "Format HTML inside double-quoted strings. Disabled by default because beautified HTML often inserts double quotes and breaks the macro string." + } + } } }, "scripts": { - "vscode:prepublish": "python build-grammar.py syntaxes/mts.tmLanguage.yaml syntaxes/mts.tmLanguage.json" + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "vscode:prepublish": "python build-grammar.py syntaxes/mts.tmLanguage.yaml syntaxes/mts.tmLanguage.json && npm run compile" + }, + "dependencies": { + "vscode-html-languageservice": "^5.6.2", + "vscode-languageserver-textdocument": "^1.0.12" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "@types/vscode": "^1.75.0", + "typescript": "^6.0.2" } } diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..db665b2 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +import { formatWholeDocument } from './formatDocument'; + +const MTS_SELECTOR: vscode.DocumentSelector = { language: 'mts' }; + +function readSegmentOptions(doc: vscode.TextDocument) { + const cfg = vscode.workspace.getConfiguration('maptoolScript', doc.uri); + return { + htmlInSingleQuotedStrings: cfg.get('format.htmlInSingleQuotedStrings', true), + htmlInDoubleQuotedStrings: cfg.get('format.htmlInDoubleQuotedStrings', false), + }; +} + +export function activate(context: vscode.ExtensionContext): void { + const provider: vscode.DocumentFormattingEditProvider = { + provideDocumentFormattingEdits(document, options) { + const cfg = vscode.workspace.getConfiguration('maptoolScript', document.uri); + if (!cfg.get('format.enable', true)) { + return []; + } + + const segmentOptions = readSegmentOptions(document); + const tabSize = options.tabSize ?? 4; + const insertSpaces = options.insertSpaces ?? true; + const next = formatWholeDocument(document, segmentOptions, tabSize, insertSpaces); + const fullRange = document.validateRange( + new vscode.Range(0, 0, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), + ); + if (next === document.getText()) { + return []; + } + return [vscode.TextEdit.replace(fullRange, next)]; + }, + }; + + context.subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider(MTS_SELECTOR, provider), + ); +} + +export function deactivate(): void {} diff --git a/src/formatDocument.ts b/src/formatDocument.ts new file mode 100644 index 0000000..118bfe4 --- /dev/null +++ b/src/formatDocument.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; +import { formatHtmlFragment } from './formatHtml'; +import { formatMtsSegment } from './formatMts'; +import { segmentDocument, SegmentOptions } from './segment'; + +export function formatWholeDocument( + doc: vscode.TextDocument, + segmentOptions: SegmentOptions, + tabSize: number, + insertSpaces: boolean, +): string { + const text = doc.getText(); + const eol = doc.eol === vscode.EndOfLine.CRLF ? '\r\n' : '\n'; + const segments = segmentDocument(text, segmentOptions); + let braceLevel = 0; + const parts: string[] = []; + + for (const seg of segments) { + const chunk = text.slice(seg.start, seg.end); + if (seg.kind === 'mts') { + const [fmt, nextLevel] = formatMtsSegment(chunk, braceLevel, tabSize, eol); + braceLevel = nextLevel; + parts.push(fmt); + } else { + parts.push(formatHtmlFragment(chunk, tabSize, insertSpaces, eol)); + } + } + + return parts.join(''); +} diff --git a/src/formatHtml.ts b/src/formatHtml.ts new file mode 100644 index 0000000..5ac0ef9 --- /dev/null +++ b/src/formatHtml.ts @@ -0,0 +1,41 @@ +import { getLanguageService } from 'vscode-html-languageservice'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +const htmlService = getLanguageService(); + +const formatUri = 'file:///embedded/mapToolFragment.html'; + +export function formatHtmlFragment( + html: string, + tabSize: number, + insertSpaces: boolean, + eol: '\n' | '\r\n', +): string { + const doc = TextDocument.create(formatUri, 'html', 1, html); + const edits = htmlService.format(doc, undefined, { + tabSize, + insertSpaces, + endWithNewline: false, + preserveNewLines: true, + }); + if (!edits.length) { + return normalizeEol(html, eol); + } + const sorted = [...edits].sort( + (a, b) => doc.offsetAt(b.range.start) - doc.offsetAt(a.range.start), + ); + let out = doc.getText(); + for (const e of sorted) { + const start = doc.offsetAt(e.range.start); + const end = doc.offsetAt(e.range.end); + out = out.slice(0, start) + e.newText + out.slice(end); + } + return normalizeEol(out, eol); +} + +function normalizeEol(s: string, eol: '\n' | '\r\n'): string { + if (eol === '\n') { + return s.replace(/\r\n/g, '\n'); + } + return s.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); +} diff --git a/src/formatMts.ts b/src/formatMts.ts new file mode 100644 index 0000000..62456b2 --- /dev/null +++ b/src/formatMts.ts @@ -0,0 +1,152 @@ +function countLeadingCloseBraces(trimmedLine: string): number { + let inDouble = false; + let inSingle = false; + let escape = false; + let count = 0; + let i = 0; + while (i < trimmedLine.length) { + const c = trimmedLine[i]; + if (inDouble) { + if (escape) { + escape = false; + i++; + continue; + } + if (c === '\\') { + escape = true; + i++; + continue; + } + if (c === '"') { + inDouble = false; + i++; + continue; + } + i++; + continue; + } + if (inSingle) { + if (escape) { + escape = false; + i++; + continue; + } + if (c === '\\') { + escape = true; + i++; + continue; + } + if (c === "'") { + inSingle = false; + i++; + continue; + } + i++; + continue; + } + if (c === '"') { + inDouble = true; + i++; + continue; + } + if (c === "'") { + inSingle = true; + i++; + continue; + } + if (c === '}') { + count++; + i++; + while (i < trimmedLine.length && /\s/.test(trimmedLine[i])) { + i++; + } + continue; + } + break; + } + return count; +} + +function lineEndsWithOpenBrace(line: string): boolean { + let inDouble = false; + let inSingle = false; + let escape = false; + let lastSig: string | null = null; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inDouble) { + if (escape) { + escape = false; + continue; + } + if (c === '\\') { + escape = true; + continue; + } + if (c === '"') { + inDouble = false; + continue; + } + continue; + } + if (inSingle) { + if (escape) { + escape = false; + continue; + } + if (c === '\\') { + escape = true; + continue; + } + if (c === "'") { + inSingle = false; + continue; + } + continue; + } + if (c === '"') { + inDouble = true; + continue; + } + if (c === "'") { + inSingle = true; + continue; + } + if (!/\s/.test(c)) { + lastSig = c; + } + } + return lastSig === '{'; +} + +export function formatMtsSegment( + text: string, + baseLevel: number, + tabSize: number, + eol: '\n' | '\r\n', +): [formatted: string, endBraceLevel: number] { + const lines = text.split(/\r?\n/); + let level = Math.max(0, baseLevel); + const out: string[] = []; + const indentUnit = ' '.repeat(tabSize); + + for (const line of lines) { + const trimmedRight = line.replace(/\s+$/u, ''); + const trimmed = trimmedRight.trim(); + if (trimmed === '') { + out.push(''); + continue; + } + + const leadingClosings = countLeadingCloseBraces(trimmed); + level = Math.max(0, level - leadingClosings); + const indent = indentUnit.repeat(level); + out.push(indent + trimmed); + if (lineEndsWithOpenBrace(trimmed)) { + level++; + } + } + + const join = eol === '\r\n' ? '\r\n' : '\n'; + return [out.join(join), level]; +} diff --git a/src/segment.ts b/src/segment.ts new file mode 100644 index 0000000..f116f40 --- /dev/null +++ b/src/segment.ts @@ -0,0 +1,155 @@ +export type SegmentKind = 'mts' | 'html_raw' | 'html_in_string'; + +export interface Segment { + start: number; + end: number; + kind: SegmentKind; +} + +export interface SegmentOptions { + htmlInSingleQuotedStrings: boolean; + htmlInDoubleQuotedStrings: boolean; +} + +function isHtmlTagContentStart(text: string, i: number): boolean { + if (text[i] !== '<') return false; + if (text.startsWith('