Skip to content

Commit

Permalink
Merge pull request #2007 from posit-dev/feature/x-r-run
Browse files Browse the repository at this point in the history
Implement `x-r-run` handler
  • Loading branch information
DavisVaughan authored Jan 4, 2024
2 parents 0a9f0b2 + 7f49a72 commit 74f52ba
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .vscode-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ const extensions = [
label: 'positron-code-cells',
workspaceFolder: path.join(os.tmpdir(), `positron-code-cells-${Math.floor(Math.random() * 100000)}`),
mocha: { timeout: 60_000 }
},
{
label: 'positron-r',
workspaceFolder: path.join(os.tmpdir(), `positron-r-${Math.floor(Math.random() * 100000)}`),
mocha: { timeout: 60_000 }
}
// --- End Positron ---
];
Expand Down
2 changes: 1 addition & 1 deletion extensions/positron-r/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@
},
"positron": {
"binaryDependencies": {
"ark": "0.1.40"
"ark": "0.1.41"
}
}
}
85 changes: 85 additions & 0 deletions extensions/positron-r/src/hyperlink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2024 Posit Software, PBC. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import * as positron from 'positron';
import * as vscode from 'vscode';
import { randomUUID } from 'crypto';
import { RRuntime } from './runtime';

export async function handleRCode(runtime: RRuntime, code: string): Promise<void> {
const match = matchRunnable(code);

if (!match) {
// Didn't match our regex. Not safe to run, or not recognizable as R code.
return handleNotRunnable(code);
}
if (!match.groups) {
return handleNotRunnable(code);
}

const packageName = match.groups.package;
// Not currently used, but could be useful for showing help documentation on hover
// const functionName = match.groups.function;

if (isCorePackage(packageName)) {
// We never run code prefixed with a core package name, as this is suspicious
return handleNotRunnable(code);
}

if (isBlessedPackage(packageName)) {
// Attached or not, if it is a blessed package then we automatically run it
return handleAutomaticallyRunnable(runtime, code);
}
if (await runtime.isPackageAttached(packageName)) {
// Only automatically run unknown package code if the package is already attached
return handleAutomaticallyRunnable(runtime, code);
}

// Otherwise, it looks like runnable code but isn't safe enough to automatically run
return handleManuallyRunnable(runtime, code);
}

function handleNotRunnable(code: string) {
vscode.window.showInformationMessage(vscode.l10n.t(
`Code hyperlink not recognized. Manually run the following if you trust the hyperlink source: \`${code}\`.`
));
}

function handleManuallyRunnable(_runtime: RRuntime, code: string) {
// TODO: Put `code` in the Console for the user, overwriting anything that was there.
// Seems like we will need a new API for that.
vscode.window.showInformationMessage(vscode.l10n.t(
`Code hyperlink written to clipboard: \`${code}\`.`
));
vscode.env.clipboard.writeText(code);
}

function handleAutomaticallyRunnable(runtime: RRuntime, code: string) {
const id = randomUUID();

// Fire and forget style
runtime.execute(
code,
id,
positron.RuntimeCodeExecutionMode.Interactive,
positron.RuntimeErrorBehavior.Continue
);
}

export function matchRunnable(code: string): RegExpMatchArray | null {
// Of the form `package::function(args)` where `args` can't contain `(`, `)`, or `;`.
// See https://cli.r-lib.org/reference/links.html#security-considerations.
const runnableRegExp = /^(?<package>\w+)::(?<function>\w+)[(][^();]*[)]$/;
return code.match(runnableRegExp);
}

function isCorePackage(packageName: string): boolean {
const corePackages = ['utils', 'base', 'stats'];
return corePackages.includes(packageName);
}

function isBlessedPackage(packageName: string): boolean {
const blessedPackages = ['testthat', 'rlang', 'devtools', 'usethis', 'pkgload', 'pkgdown'];
return blessedPackages.includes(packageName);
}
21 changes: 18 additions & 3 deletions extensions/positron-r/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { delay, timeout } from './util';
import { ArkAttachOnStartup, ArkDelayStartup } from './startup';
import { RHtmlWidget, getResourceRoots } from './htmlwidgets';
import { randomUUID } from 'crypto';
import { handleRCode } from './hyperlink';

class RRuntimeManager {
private runtimes: Map<string, RRuntime> = new Map();
Expand Down Expand Up @@ -130,9 +131,7 @@ export class RRuntime implements positron.LanguageRuntime, vscode.Disposable {

// Run code.
case 'x-r-run':
console.log('********************************************************************');
console.log(`R runtime should run resource "${resource.path}"`);
console.log('********************************************************************');
handleRCode(this, resource.path);
return Promise.resolve(true);

// Unhandled.
Expand Down Expand Up @@ -388,6 +387,22 @@ export class RRuntime implements positron.LanguageRuntime, vscode.Disposable {
return true;
}

async isPackageAttached(packageName: string): Promise<boolean> {
let attached = false;

try {
attached = await this.callMethod('isPackageAttached', packageName);
} catch (err) {
const runtimeError = err as positron.RuntimeMethodError;
vscode.window.showErrorMessage(vscode.l10n.t(
`Error checking if '${packageName}' is attached: ${runtimeError.message} ` +
`(${runtimeError.code})`
));
}

return attached;
}

private async createKernel(): Promise<JupyterLanguageRuntime> {
const ext = vscode.extensions.getExtension('vscode.jupyter-adapter');
if (!ext) {
Expand Down
27 changes: 27 additions & 0 deletions extensions/positron-r/src/test/hyperlink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2024 Posit Software, PBC. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { matchRunnable } from '../hyperlink';

suite('Hyperlink', () => {
test('Runnable R code regex enforces safety rules', async () => {
const matchSimple = matchRunnable("pkg::fun()");
assert.strictEqual(matchSimple?.groups?.package, "pkg");
assert.strictEqual(matchSimple?.groups?.function, "fun");

const matchArgs = matchRunnable("pkg::fun(1 + 1, 2:5)");
assert.strictEqual(matchArgs?.groups?.package, "pkg");
assert.strictEqual(matchArgs?.groups?.function, "fun");

const matchUnsafeInnerFunction = matchRunnable("pkg::fun(fun())");
assert.strictEqual(matchUnsafeInnerFunction, null);

const matchUnsafeSemicolon = matchRunnable("pkg::fun({1 + 2; 3 + 4})");
assert.strictEqual(matchUnsafeSemicolon, null);

const matchUnsafeTripleColon = matchRunnable("pkg:::fun()");
assert.strictEqual(matchUnsafeTripleColon, null);
});
});
6 changes: 6 additions & 0 deletions scripts/test-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ echo
yarn test-extension -l positron-code-cells
kill_app

echo
echo "### Positron R tests"
echo
yarn test-extension -l positron-r
kill_app

# --- End Positron ---

# Tests standalone (CommonJS)
Expand Down

0 comments on commit 74f52ba

Please sign in to comment.