aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author_ov4 <ov4@ov4.org>2026-10-13 05:58:39 +0000
committer_ov4 <ov4@ov4.org>2026-10-13 05:58:39 +0000
commite52266d8ce8823866b2dad3e04ffc78438e08397 (patch)
tree6fbda74ac31a1d5db2e765d5ca086ae45df861ff
parentbe8154fac8a0ea5eb3dddef4053f12d67102009d (diff)
feat(plugin): add keyword Tab indentation and selection formatting
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
-rw-r--r--plugin/package-lock.json2
-rw-r--r--plugin/package.json15
-rw-r--r--plugin/src/extension.js31
-rw-r--r--plugin/src/indentation.js58
-rw-r--r--plugin/src/lexical.js33
-rw-r--r--plugin/test/indentation.test.js44
6 files changed, 178 insertions, 5 deletions
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' }]);
+});