From 9cdb977c25144fd37a20234251a5b8d5b1d0def6 Mon Sep 17 00:00:00 2001 From: oskarc35 Date: Wed, 8 Apr 2026 22:21:20 +0200 Subject: [PATCH 1/3] Adds support for mtmacro, document formatting and embedded HTML support Migrates the extension to TypeScript and integrates a new formatting feature for MapTool Script files. The formatter intelligently segments documents to apply proper indentation for MTS code and leverage the VS Code HTML language service for embedded HTML fragments, both raw and within string literals. This change also updates the build process, introduces new developer tasks, and enhances the TextMate grammar for improved HTML syntax highlighting. User-configurable options are added to control HTML formatting behavior within strings, and `.mtmacro` files are now also supported. --- .github/workflows/prerelease_build.yml | 1 + .github/workflows/release_deploy.yml | 1 + .gitignore | 1 + .vscode/launch.json | 2 +- .vscode/tasks.json | 28 ++++- .vscodeignore | 16 ++- package-lock.json | 87 +++++++++++++- package.json | 49 +++++++- src/extension.ts | 41 +++++++ src/formatDocument.ts | 30 +++++ src/formatHtml.ts | 41 +++++++ src/formatMts.ts | 152 ++++++++++++++++++++++++ src/segment.ts | 155 +++++++++++++++++++++++++ syntaxes/mts.tmLanguage.json | 51 +++++++- syntaxes/mts.tmLanguage.yaml | 32 ++++- tsconfig.json | 13 +++ 16 files changed, 680 insertions(+), 20 deletions(-) create mode 100644 src/extension.ts create mode 100644 src/formatDocument.ts create mode 100644 src/formatHtml.ts create mode 100644 src/formatMts.ts create mode 100644 src/segment.ts create mode 100644 tsconfig.json 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/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..e4b3e8f 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.5", "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('