aboutsummaryrefslogtreecommitdiff
path: root/plugin
diff options
context:
space:
mode:
Diffstat (limited to 'plugin')
-rw-r--r--plugin/ThirdPartyNotices.md30
-rw-r--r--plugin/package.json32
-rw-r--r--plugin/src/extension.js2
-rw-r--r--plugin/src/runner.js79
-rw-r--r--plugin/test/runner.test.js117
5 files changed, 258 insertions, 2 deletions
diff --git a/plugin/ThirdPartyNotices.md b/plugin/ThirdPartyNotices.md
index ff6611d..d21d67a 100644
--- a/plugin/ThirdPartyNotices.md
+++ b/plugin/ThirdPartyNotices.md
@@ -25,3 +25,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+## Code Runner
+
+Terminal creation, display, reuse, close handling and Windows shell detection
+in `src/runner.js` are adapted from formulahendry Code Runner. The run button
+uses the same native VS Code `$(play)` icon.
+
+https://github.com/formulahendry/vscode-code-runner/blob/97af1080d09046e0129acce592f0033ff2df9f26/src/codeManager.ts
+
+MIT License
+
+Copyright (c) 2017 Jun Han
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugin/package.json b/plugin/package.json
index 4bffadd..677c9d1 100644
--- a/plugin/package.json
+++ b/plugin/package.json
@@ -1,7 +1,7 @@
{
"name": "cie-pseudocode",
"displayName": "CIE Pseudocode",
- "description": "CIE pseudocode highlighting, Tab indentation and bracket checking for .cps files.",
+ "description": "CIE pseudocode highlighting, Tab indentation, bracket checking and terminal execution for .cps files.",
"version": "0.1.0",
"publisher": "ovfor4",
"author": "_ov4",
@@ -20,11 +20,39 @@
"Formatters"
],
"scripts": {
- "check": "node --check src/extension.js && node --check src/indentation.js && node --check src/lexical.js && node --check src/brackets.js",
+ "check": "node --check src/extension.js && node --check src/indentation.js && node --check src/lexical.js && node --check src/brackets.js && node --check src/runner.js",
"test": "node --test test/*.test.js",
"package": "npm run check && npm test && vsce package --no-dependencies"
},
"contributes": {
+ "commands": [
+ {
+ "command": "cie-pseudocode.runFile",
+ "title": "Run Pseudocode File in Terminal",
+ "enablement": "editorLangId == cie-pseudocode",
+ "icon": "$(play)"
+ }
+ ],
+ "menus": {
+ "editor/title": [
+ {
+ "command": "cie-pseudocode.runFile",
+ "when": "editorLangId == cie-pseudocode",
+ "group": "navigation@1"
+ }
+ ]
+ },
+ "configuration": {
+ "title": "CIE Pseudocode",
+ "properties": {
+ "cie-pseudocode.compilerPath": {
+ "type": "string",
+ "default": "cpsc",
+ "scope": "resource",
+ "description": "Compiler executable name on PATH or full path to cpsc (cpsc.exe on Windows)."
+ }
+ }
+ },
"languages": [
{
"id": "cie-pseudocode",
diff --git a/plugin/src/extension.js b/plugin/src/extension.js
index bfc51a1..70c8622 100644
--- a/plugin/src/extension.js
+++ b/plugin/src/extension.js
@@ -4,6 +4,7 @@ const vscode = require('vscode');
const { indentationEdits } = require('./indentation');
const { codeOnly } = require('./lexical');
const { unmatchedBrackets } = require('./brackets');
+const { registerRunner } = require('./runner');
const languageId = 'cie-pseudocode';
function formatLines(document, startLine, endLine, keepBlankIndent = false) {
@@ -13,6 +14,7 @@ function formatLines(document, startLine, endLine, keepBlankIndent = false) {
}
function activate(context) {
+ registerRunner(vscode, context);
const invalidBracket = vscode.window.createTextEditorDecorationType({
dark: { color: '#F44747' }
});
diff --git a/plugin/src/runner.js b/plugin/src/runner.js
new file mode 100644
index 0000000..a327c58
--- /dev/null
+++ b/plugin/src/runner.js
@@ -0,0 +1,79 @@
+'use strict';
+
+const path = require('node:path');
+
+function quote(value, shell) {
+ if (/[\r\n\0]/.test(value)) throw new Error('Run paths must not contain line breaks or NUL characters.');
+ if (shell === 'cmd') return `"${value}"`;
+ return "'" + value.replace(/'/g, shell === 'powershell' ? "''" : "'\\''") + "'";
+}
+
+function buildRunCommand(compiler, file, cwd, shell) {
+ const run = `${shell === 'powershell' ? '& ' : ''}${quote(compiler, shell)} ${quote(file, shell)} -r`;
+ if (shell === 'powershell') return `Set-Location -LiteralPath ${quote(cwd, shell)}; if ($?) { ${run} }`;
+ return `cd ${shell === 'cmd' ? '/d ' : ''}${quote(cwd, shell)} && ${run}`;
+}
+
+// Adapted from Code Runner's isPowershellOnWindows; also recognize pwsh and named profiles.
+function terminalShell(vscode, platform) {
+ if (platform !== 'win32') return 'posix';
+ const config = vscode.workspace.getConfiguration('terminal');
+ const profileName = config.get('integrated.defaultProfile.windows');
+ const profile = config.get('integrated.profiles.windows', {})[profileName];
+ const profilePath = Array.isArray(profile?.path) ? profile.path[0] : profile?.path;
+ const shell = profilePath || profile?.source || profileName || vscode.env.shell;
+ if (/(?:powershell|pwsh)/i.test(shell)) return 'powershell';
+ if (/(?:^|[\\/])cmd(?:\.exe)?$|^Command Prompt$/i.test(shell)) return 'cmd';
+ throw new Error('Choose PowerShell or Command Prompt as the default Windows terminal profile.');
+}
+
+function registerRunner(vscode, context, platform = process.platform) {
+ let terminal = null;
+ let shell;
+ let preparing = false;
+ const command = vscode.commands.registerCommand('cie-pseudocode.runFile', async uri => {
+ if (preparing) return;
+ preparing = true;
+ try {
+ let document = uri ? await vscode.workspace.openTextDocument(uri) : vscode.window.activeTextEditor?.document;
+ if (!document || document.languageId !== 'cie-pseudocode') {
+ vscode.window.showErrorMessage('Open a CIE pseudocode file to run.');
+ return;
+ }
+ const wasUntitled = document.isUntitled;
+ if (wasUntitled) await vscode.window.showTextDocument(document);
+ if (!(await document.save())) return;
+ // Saving an untitled document replaces it with a new file-backed document.
+ if (wasUntitled) document = vscode.window.activeTextEditor?.document;
+ if (!document || document.isUntitled || document.uri.scheme !== 'file') {
+ vscode.window.showErrorMessage('Save the pseudocode to a local file before running.');
+ return;
+ }
+ const cwd = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath || path.dirname(document.uri.fsPath);
+ const compiler = vscode.workspace.getConfiguration('cie-pseudocode', document.uri).get('compilerPath', 'cpsc');
+ if (!compiler.trim()) throw new Error('Set CIE Pseudocode: Compiler Path to cpsc or its full path.');
+ const runShell = terminal ? shell : terminalShell(vscode, platform);
+ const text = buildRunCommand(compiler, document.uri.fsPath, cwd, runShell);
+ // Adapted from Code Runner's executeCommandInTerminal (see ThirdPartyNotices.md).
+ if (terminal === null) {
+ terminal = vscode.window.createTerminal('CIE Pseudocode');
+ shell = runShell;
+ }
+ terminal.show(false);
+ terminal.sendText(text);
+ } catch (error) {
+ vscode.window.showErrorMessage(`Could not run pseudocode: ${error.message}`);
+ } finally {
+ preparing = false;
+ }
+ });
+ context.subscriptions.push(
+ command,
+ vscode.window.onDidCloseTerminal(closed => {
+ if (closed === terminal) terminal = null;
+ }),
+ { dispose() { terminal?.dispose(); } }
+ );
+}
+
+module.exports = { buildRunCommand, registerRunner };
diff --git a/plugin/test/runner.test.js b/plugin/test/runner.test.js
new file mode 100644
index 0000000..a22d103
--- /dev/null
+++ b/plugin/test/runner.test.js
@@ -0,0 +1,117 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { test } = require('node:test');
+const { spawnSync } = require('node:child_process');
+const { buildRunCommand, registerRunner } = require('../src/runner');
+
+test('commands quote paths and only run after changing directory', () => {
+ assert.equal(buildRunCommand('/tools/my cpsc', '/work/my file.cps', '/work/my project', 'posix'),
+ "cd '/work/my project' && '/tools/my cpsc' '/work/my file.cps' -r");
+ assert.equal(buildRunCommand('C:\\My Tools\\cpsc.exe', 'D:\\My Files\\test.cps', 'D:\\My Files', 'cmd'),
+ 'cd /d "D:\\My Files" && "C:\\My Tools\\cpsc.exe" "D:\\My Files\\test.cps" -r');
+ assert.equal(buildRunCommand('C:\\My Tools\\cpsc.exe', "D:\\Pat's Files\\test.cps", "D:\\Pat's Files", 'powershell'),
+ "Set-Location -LiteralPath 'D:\\Pat''s Files'; if ($?) { & 'C:\\My Tools\\cpsc.exe' 'D:\\Pat''s Files\\test.cps' -r }");
+ assert.throws(() => buildRunCommand('cpsc\nother', 'test.cps', '.', 'posix'), /line breaks/);
+ if (process.platform !== 'win32') {
+ const file = "/tmp/Pat's file $(echo unexpected) `echo unexpected`.cps";
+ const result = spawnSync('/bin/sh', ['-c', buildRunCommand('/usr/bin/printf', file, '/tmp', 'posix')], { encoding: 'utf8' });
+ assert.equal(result.status, 0);
+ assert.equal(result.stdout, file);
+ const failed = spawnSync('/bin/sh', ['-c', buildRunCommand('/usr/bin/printf', file, '/missing-cie-run-directory', 'posix')], { encoding: 'utf8' });
+ assert.notEqual(failed.status, 0);
+ assert.equal(failed.stdout, '');
+ }
+});
+
+function harness(platform = 'linux') {
+ let run, close;
+ const calls = { saved: 0, terminals: [], errors: [], settings: [] };
+ const document = {
+ languageId: 'cie-pseudocode', isUntitled: false,
+ uri: { scheme: 'file', fsPath: '/project/sub/my file.cps' },
+ async save() { calls.saved++; return true; }
+ };
+ const vscode = {
+ env: { shell: 'C:\\Windows\\System32\\cmd.exe' },
+ commands: { registerCommand(id, callback) { assert.equal(id, 'cie-pseudocode.runFile'); run = callback; return { dispose() {} }; } },
+ workspace: {
+ async openTextDocument() { return document; },
+ getWorkspaceFolder() { return { uri: { fsPath: '/project' } }; },
+ getConfiguration(section, uri) {
+ calls.settings.push({ section, uri });
+ return { get(key, fallback) { return section === 'cie-pseudocode' ? '/tools/my cpsc' : fallback; } };
+ }
+ },
+ window: {
+ activeTextEditor: { document },
+ async showTextDocument(doc) { this.activeTextEditor = { document: doc }; },
+ showErrorMessage(message) { calls.errors.push(message); },
+ onDidCloseTerminal(callback) { close = callback; return { dispose() {} }; },
+ createTerminal(name) {
+ const terminal = { name, sent: [], show(focus) { this.preserveFocus = focus; }, sendText(text) { this.sent.push(text); }, dispose() {} };
+ calls.terminals.push(terminal);
+ return terminal;
+ }
+ }
+ };
+ registerRunner(vscode, { subscriptions: [] }, platform);
+ return { vscode, document, calls, run: uri => run(uri), close: terminal => close(terminal) };
+}
+
+test('save before running the whole file, scope settings, reuse and recreate the terminal', async () => {
+ const h = harness();
+ await h.run(h.document.uri);
+ assert.equal(h.calls.saved, 1);
+ assert.equal(h.calls.terminals[0].preserveFocus, false);
+ assert.equal(h.calls.terminals[0].sent[0], "cd '/project' && '/tools/my cpsc' '/project/sub/my file.cps' -r");
+ assert.equal(h.calls.settings[0].uri, h.document.uri);
+ h.close({});
+ await h.run();
+ assert.equal(h.calls.terminals.length, 1);
+ h.close(h.calls.terminals[0]);
+ h.vscode.workspace.getWorkspaceFolder = () => undefined;
+ await h.run();
+ assert.equal(h.calls.terminals.length, 2);
+ assert.match(h.calls.terminals[1].sent[0], /^cd '\/project\/sub'/);
+ assert.deepEqual(h.calls.errors, []);
+});
+
+test('save cancellation and failure do not create a terminal; untitled files use the saved URI', async () => {
+ const h = harness();
+ h.document.isUntitled = true;
+ h.document.uri = { scheme: 'untitled', fsPath: 'Untitled-1' };
+ h.document.save = async () => false;
+ await h.run();
+ assert.equal(h.calls.terminals.length, 0);
+ h.document.save = async () => { throw new Error('Save failed'); };
+ await h.run();
+ assert.match(h.calls.errors[0], /Save failed/);
+ assert.equal(h.calls.terminals.length, 0);
+ h.document.save = async () => {
+ h.vscode.window.activeTextEditor = { document: { ...h.document, isUntitled: false,
+ uri: { scheme: 'file', fsPath: '/project/new file.cps' } } };
+ return true;
+ };
+ await h.run();
+ assert.match(h.calls.terminals[0].sent[0], /'\/project\/new file.cps' -r$/);
+});
+
+test('Windows recognizes custom pwsh and cmd profiles and retains the reused terminal shell', async () => {
+ const h = harness('win32');
+ let profile = 'My Shell';
+ h.vscode.workspace.getConfiguration = section => ({ get(key, fallback) {
+ if (section === 'cie-pseudocode') return 'cpsc';
+ if (key === 'integrated.defaultProfile.windows') return profile;
+ if (key === 'integrated.profiles.windows') return { 'My Shell': { path: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' } };
+ return fallback;
+ } });
+ await h.run();
+ assert.match(h.calls.terminals[0].sent[0], /^Set-Location.*& 'cpsc'/);
+ profile = 'Command Prompt';
+ await h.run();
+ assert.match(h.calls.terminals[0].sent[1], /^Set-Location/);
+ h.close(h.calls.terminals[0]);
+ await h.run();
+ assert.match(h.calls.terminals[1].sent[0], /^cd \/d .*&& "cpsc"/);
+});