From e52266d8ce8823866b2dad3e04ffc78438e08397 Mon Sep 17 00:00:00 2001 From: _ov4 Date: Tue, 13 Oct 2026 05:58:39 +0000 Subject: feat(plugin): add keyword Tab indentation and selection formatting Co-Authored-By: Claude Fable 5.1 --- plugin/package-lock.json | 2 +- plugin/package.json | 15 +++++++++-- plugin/src/extension.js | 31 ++++++++++++++++++++-- plugin/src/indentation.js | 58 +++++++++++++++++++++++++++++++++++++++++ plugin/src/lexical.js | 33 +++++++++++++++++++++++ plugin/test/indentation.test.js | 44 +++++++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 plugin/src/indentation.js create mode 100644 plugin/src/lexical.js create mode 100644 plugin/test/indentation.test.js diff --git a/plugin/package-lock.json b/plugin/package-lock.json index 18cd920..6aadf08 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "cie-pseudocode", "version": "0.1.0", - "license": "GPL-3.0-only", + "license": "AGPL-3.0-only", "devDependencies": { "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2" diff --git a/plugin/package.json b/plugin/package.json index c7a31d1..003a866 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -4,16 +4,17 @@ "description": "CIE pseudocode highlighting, Tab indentation and bracket checking for .cps files.", "version": "0.1.0", "publisher": "ovfor4", - "license": "GPL-3.0-only", + "license": "AGPL-3.0-only", "repository": { "type": "git", "url": "https://github.com/ovfor4/cie-pseudocode-compiler.git", "directory": "plugin" }, "engines": { "vscode": "^1.85.0" }, + "main": "./src/extension.js", "categories": ["Programming Languages", "Formatters"], "scripts": { - "check": "node --check src/extension.js", + "check": "node --check src/extension.js && node --check src/indentation.js && node --check src/lexical.js", "test": "node --test test/*.test.js" }, "contributes": { @@ -23,6 +24,16 @@ "extensions": [".cps"], "configuration": "./language-configuration.json" }], + "configurationDefaults": { + "[cie-pseudocode]": { + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false, + "editor.autoIndent": "full", + "editor.formatOnType": true, + "editor.defaultFormatter": "ovfor4.cie-pseudocode" + } + }, "grammars": [{ "language": "cie-pseudocode", "scopeName": "source.cie-pseudocode", diff --git a/plugin/src/extension.js b/plugin/src/extension.js index 1101ed6..a7b4834 100644 --- a/plugin/src/extension.js +++ b/plugin/src/extension.js @@ -1,6 +1,33 @@ 'use strict'; -// Language registration and TextMate highlighting are declarative. -function activate() {} +const vscode = require('vscode'); +const { indentationEdits } = require('./indentation'); +const { codeOnly } = require('./lexical'); +const languageId = 'cie-pseudocode'; + +function formatLines(document, startLine, endLine, keepBlankIndent = false) { + const lines = document.getText().split(/\r?\n/); + return indentationEdits(lines, startLine, endLine, keepBlankIndent).map(edit => + vscode.TextEdit.replace(new vscode.Range(edit.line, 0, edit.line, edit.length), edit.text)); +} + +function activate(context) { + context.subscriptions.push( + vscode.languages.registerDocumentRangeFormattingEditProvider(languageId, { + provideDocumentRangeFormattingEdits(document, range) { + const endLine = range.end.character === 0 && range.end.line > range.start.line + ? range.end.line - 1 : range.end.line; + return formatLines(document, range.start.line, endLine); + } + }), + vscode.languages.registerOnTypeFormattingEditProvider(languageId, { + provideOnTypeFormattingEdits(document, position, char) { + if (char !== '\n' && !/^\s*(?:ELSE|ENDIF|ENDWHILE|UNTIL|NEXT|ENDFUNCTION|ENDPROCEDURE|ENDTYPE)\b/.test( + codeOnly(document.lineAt(position.line).text))) return []; + return formatLines(document, position.line, position.line, char === '\n'); + } + }, '\n', 'E', 'F', 'L', 'T', 'N') + ); +} module.exports = { activate }; diff --git a/plugin/src/indentation.js b/plugin/src/indentation.js new file mode 100644 index 0000000..c8af770 --- /dev/null +++ b/plugin/src/indentation.js @@ -0,0 +1,58 @@ +'use strict'; + +const { codeOnly } = require('./lexical'); +const closers = { + ENDIF: 'IF', ENDWHILE: 'WHILE', UNTIL: 'REPEAT', NEXT: 'FOR', + ENDFUNCTION: 'FUNCTION', ENDPROCEDURE: 'PROCEDURE', ENDTYPE: 'TYPE' +}; + +function lineDepths(lines) { + const stack = []; + const depth = () => stack.filter(block => block.active).length; + return lines.map(line => { + const code = codeOnly(line).trim(); + const keyword = /^([A-Za-z][A-Za-z0-9_]*)\b/.exec(code)?.[1]; + if (closers[keyword] || keyword === 'ELSE') { + const kind = keyword === 'ELSE' ? 'IF' : closers[keyword]; + const index = stack.findLastIndex(block => block.kind === kind); + if (index >= 0) { + stack.length = index; + const current = depth(); + if (keyword === 'ELSE') stack.push({ kind: 'IF', active: true }); + return current; + } + // Incomplete code should still format without negative indentation. + return depth(); + } + + const current = depth(); + if (keyword === 'THEN' || keyword === 'DO') { + const top = stack[stack.length - 1]; + if (top?.kind === (keyword === 'THEN' ? 'IF' : 'WHILE')) top.active = true; + } else if (keyword === 'IF' || keyword === 'WHILE') { + stack.push({ kind: keyword, active: new RegExp( + keyword === 'IF' ? '\\bTHEN\\b' : '\\bDO\\b').test(code) }); + } else if (['FOR', 'REPEAT', 'FUNCTION', 'PROCEDURE'].includes(keyword)) { + stack.push({ kind: keyword, active: true }); + } else if (/^TYPE\s+[A-Za-z][A-Za-z0-9_]*$/.test(code)) { + stack.push({ kind: 'TYPE', active: true }); + } + return current; + }); +} + +function indentationEdits(lines, startLine, endLine, keepBlankIndent = false) { + const depths = lineDepths(lines.slice(0, endLine + 1)); + const edits = []; + for (let line = startLine; line <= endLine; line++) { + const oldIndent = /^[\t ]*/.exec(lines[line])[0]; + const blank = lines[line].trim() === ''; + const newIndent = blank && !keepBlankIndent ? '' : '\t'.repeat(depths[line]); + if (oldIndent !== newIndent) { + edits.push({ line, length: oldIndent.length, text: newIndent }); + } + } + return edits; +} + +module.exports = { lineDepths, indentationEdits }; diff --git a/plugin/src/lexical.js b/plugin/src/lexical.js new file mode 100644 index 0000000..3fc856a --- /dev/null +++ b/plugin/src/lexical.js @@ -0,0 +1,33 @@ +'use strict'; + +// Preserve UTF-16 offsets and newlines, masking CIE quotes and // comments. +// Unlike C++, CIE has no escapes or multiline literals. +function codeOnly(text) { + let quote = null; + let comment = false; + const result = []; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '\n' || char === '\r') { + quote = null; + comment = false; + result.push(char); + } else if (comment) { + result.push(' '); + } else if (quote) { + if (char === quote) quote = null; + result.push(' '); + } else if (char === '"' || char === "'") { + quote = char; + result.push(' '); + } else if (char === '/' && text[i + 1] === '/') { + comment = true; + result.push(' '); + } else { + result.push(char); + } + } + return result.join(''); +} + +module.exports = { codeOnly }; diff --git a/plugin/test/indentation.test.js b/plugin/test/indentation.test.js new file mode 100644 index 0000000..9a50cb3 --- /dev/null +++ b/plugin/test/indentation.test.js @@ -0,0 +1,44 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { lineDepths, indentationEdits } = require('../src/indentation'); + +test('nested branches and each implemented block use one Tab per level', () => { + const lines = [ + 'PROCEDURE Demo()', 'FOR I <- 1 TO 3', 'IF X THEN', + 'WHILE Y DO', 'OUTPUT X', 'ENDWHILE', 'ELSE', + 'REPEAT', 'OUTPUT Y', 'UNTIL Done', 'ENDIF', 'NEXT I', 'ENDPROCEDURE', + 'FUNCTION Value() RETURNS INTEGER', 'RETURN 1', 'ENDFUNCTION', + 'TYPE Record', 'DECLARE X : INTEGER', 'ENDTYPE' + ]; + assert.deepEqual(lineDepths(lines), [0, 1, 2, 3, 4, 3, 2, 3, 4, 3, 2, 1, 0, 0, 1, 0, 0, 1, 0]); +}); + +test('split THEN/DO, single-line TYPE, comments and strings do not add extra levels', () => { + const lines = [ + 'TYPE Season = (Spring, Summer)', 'TYPE Ptr = ^INTEGER', 'IF Ready', + 'THEN // IF DO', 'WHILE Check("DO")', 'DO', 'OUTPUT "ENDIF // IF THEN"', + "OUTPUT 'E' // ENDWHILE", 'ENDWHILE', 'ENDIF', 'IFValue <- 1', 'if X then' + ]; + assert.deepEqual(lineDepths(lines), [0, 0, 0, 0, 1, 1, 2, 2, 1, 0, 0, 0]); +}); + +test('range formatting reads earlier context, preserves text and is idempotent', () => { + const lines = ['IF X THEN', ' WHILE Y DO', ' OUTPUT "x" // keep spaces', ' ENDWHILE', 'ENDIF']; + const edits = indentationEdits(lines, 2, 3); + assert.deepEqual(edits, [ + { line: 2, length: 1, text: '\t\t' }, { line: 3, length: 1, text: '\t' } + ]); + for (const edit of edits) lines[edit.line] = edit.text + lines[edit.line].slice(edit.length); + assert.equal(lines[2], '\t\tOUTPUT "x" // keep spaces'); + assert.equal(lines[1], ' WHILE Y DO'); + assert.deepEqual(indentationEdits(lines, 2, 3), []); + assert.deepEqual(indentationEdits(['ENDIF', 'UNTIL X', 'NEXT I'], 0, 2), []); +}); + +test('blank lines are empty when formatted and indented at the caret after Enter', () => { + const lines = ['IF X THEN', ' ']; + assert.deepEqual(indentationEdits(lines, 1, 1), [{ line: 1, length: 3, text: '' }]); + assert.deepEqual(indentationEdits(lines, 1, 1, true), [{ line: 1, length: 3, text: '\t' }]); +}); -- cgit v1.3.1-4-g156e