Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/positron-r/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@
},
"positron": {
"binaryDependencies": {
"ark": "0.1.227"
"ark": "0.1.229"
},
"minimumRVersion": "4.2.0",
"minimumRenvVersion": "1.0.9"
Expand Down
66 changes: 52 additions & 14 deletions extensions/positron-r/src/statement-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,36 @@

import * as positron from 'positron';
import * as vscode from 'vscode';
import { CancellationToken, LanguageClient, Position, Range, RequestType, VersionedTextDocumentIdentifier } from 'vscode-languageclient/node';
import { LanguageClient, Position, Range, RequestType, VersionedTextDocumentIdentifier } from 'vscode-languageclient/node';

enum StatementRangeKind {
Success = 'success',
Rejection = 'rejection'
}

enum StatementRangeRejectionKind {
Syntax = 'syntax'
}

interface StatementRangeParams {
textDocument: VersionedTextDocumentIdentifier;
position: Position;
}

interface StatementRangeResponse {
range: Range;
code?: string;
type StatementRangeResponse = StatementRangeSuccess | StatementRangeRejection;

interface StatementRangeSuccess {
readonly kind: StatementRangeKind.Success;
readonly range: Range;
readonly code?: string;
}

type StatementRangeRejection = StatementRangeSyntaxRejection;

interface StatementRangeSyntaxRejection {
readonly kind: StatementRangeKind.Rejection;
readonly rejectionKind: StatementRangeRejectionKind.Syntax;
readonly line?: number;
}
Comment on lines +24 to 38
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ark's custom LSP Request types for StatementRange

Mirrors the "error as data" model used by the main thread, and feels more correct from an LSP perspective compared to going through a jsonrpc error.


export namespace StatementRangeRequest {
Expand All @@ -38,23 +58,41 @@ export class RStatementRangeProvider implements positron.StatementRangeProvider
async provideStatementRange(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken): Promise<positron.StatementRange | undefined> {
token: vscode.CancellationToken
): Promise<positron.StatementRange | undefined> {

const params: StatementRangeParams = {
textDocument: this._client.code2ProtocolConverter.asVersionedTextDocumentIdentifier(document),
position: this._client.code2ProtocolConverter.asPosition(position)
};

const response = this._client.sendRequest(StatementRangeRequest.type, params, token);
const data = await this._client.sendRequest(StatementRangeRequest.type, params, token);

if (!data) {
return undefined;
}

return response.then(data => {
if (!data) {
return undefined;
switch (data.kind) {
case StatementRangeKind.Success: {
return {
range: this._client.protocol2CodeConverter.asRange(data.range),
// Explicitly normalize non-strings to `undefined` (i.e. a possible `null`)
code: typeof data.code === 'string' ? data.code : undefined
} satisfies positron.StatementRange;
}
case StatementRangeKind.Rejection: {
switch (data.rejectionKind) {
case StatementRangeRejectionKind.Syntax: {
throw new positron.StatementRangeSyntaxError(data.line);
}
default: {
throw new Error(`Unrecognized 'StatementRangeRejectionKind': ${data.rejectionKind}`);
}
}
}
default: {
throw new Error(`Unrecognized 'StatementRangeKind': ${data}`);
}
const range = this._client.protocol2CodeConverter.asRange(data.range);
// Explicitly normalize non-strings to `undefined` (i.e. a possible `null`)
const code = typeof data.code === 'string' ? data.code : undefined;
return { range: range, code: code } as positron.StatementRange;
});
}
}
}
163 changes: 163 additions & 0 deletions extensions/positron-r/src/test/statement-range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import './mocha-setup';

import * as assert from 'assert';
import * as positron from 'positron';
import * as vscode from 'vscode';
import * as testKit from './kit';
import { createRandomFile, deleteFile } from './editor-utils';

suite('RStatementRangeProvider', () => {
let sessionDisposable: vscode.Disposable;

suiteSetup(async function () {
const [, disposable] = await testKit.startR();
sessionDisposable = disposable;
});

suiteTeardown(async () => {
await sessionDisposable?.dispose();
});

test('single-line expression', async function () {
const code = `
1 + 1
`.trimStart();

await testKit.withDisposables(async (disposables) => {
const result = await getStatementRange(
disposables,
code,
new vscode.Position(0, 0)
);
assert.ok(result, 'Expected a statement range result');
assert.strictEqual(result.range.start.line, 0);
assert.strictEqual(result.range.start.character, 0);
assert.strictEqual(result.range.end.line, 0);
assert.strictEqual(result.range.end.character, 5);
});
});

test('multi-line block from first line', async function () {
const code = `
for (i in 1:3) {
print(i)
}
`.trimStart();

await testKit.withDisposables(async (disposables) => {
const result = await getStatementRange(
disposables,
code,
new vscode.Position(0, 0)
);

assert.ok(result, 'Expected a statement range result');
assert.strictEqual(result.range.start.line, 0);
assert.strictEqual(result.range.start.character, 0);
assert.strictEqual(result.range.end.line, 2);
assert.strictEqual(result.range.end.character, 1);
});
});

test('in a pipe chain', async function () {
const code = `
1 + 1

df |>
mutate(y = x + 1) |>
mutate(z = x + y)
`.trimStart();

await testKit.withDisposables(async (disposables) => {
const result = await getStatementRange(
disposables,
code,
new vscode.Position(3, 3)
);

assert.ok(result, 'Expected a statement range result');
assert.strictEqual(result.range.start.line, 2);
assert.strictEqual(result.range.start.character, 0);
assert.strictEqual(result.range.end.line, 4);
assert.strictEqual(result.range.end.character, 18);
});
});

test('cursor before syntax error works fine', async function () {
const code = `
df |>
summarise(foo = mean(x))

df |>
mutate(y = x \ 1 |>
mutate(z = x + y)
`.trimStart();

await testKit.withDisposables(async (disposables) => {
const result = await getStatementRange(
disposables,
code,
new vscode.Position(1, 3)
);

assert.ok(result, 'Expected a statement range result');
assert.strictEqual(result.range.start.line, 0);
assert.strictEqual(result.range.start.character, 0);
assert.strictEqual(result.range.end.line, 1);
assert.strictEqual(result.range.end.character, 25);
});
});

test('cursor in syntax error throws StatementRangeSyntaxError', async function () {
const code = `
df |>
summarise(foo = mean(x))

df |>
mutate(y = x \ 1 |>
mutate(z = x + y)
`.trimStart();

await testKit.withDisposables(async (disposables) => {
await assert.rejects(
() => getStatementRange(
disposables,
code,
new vscode.Position(4, 0)
),
(err) => {
assert.ok(err instanceof positron.StatementRangeSyntaxError);
assert.strictEqual(err.line, 3);
return true;
}
);
});
});
});

/**
* Executes the statement range provider for an R file with the given contents
* at the given position.
*/
async function getStatementRange(
disposables: vscode.Disposable[],
contents: string,
position: vscode.Position
): Promise<positron.StatementRange | undefined> {
const fileUri = await createRandomFile(contents, 'R');
disposables.push({ dispose: () => deleteFile(fileUri) });

const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc);

return await vscode.commands.executeCommand<positron.StatementRange | undefined>(
'vscode.executeStatementRangeProvider',
doc.uri,
position
);
}
20 changes: 20 additions & 0 deletions src/positron-dts/positron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,9 @@ declare module 'positron' {
* cursor is within. If the cursor is not within a statement, return the
* range of the next statement, if one exists.
*
* Throw a {@link StatementRangeSyntaxError} to indicate that a statement range
* cannot be provided due to a syntax error in the document.
*
* @param document The document in which the command was invoked.
* @param position The position at which the command was invoked.
* @param token A cancellation token.
Expand All @@ -1553,7 +1556,24 @@ declare module 'positron' {
* The code for this statement range, if different from the document contents at this range.
*/
readonly code?: string;
}

/**
* An error thrown by a {@link StatementRangeProvider} to indicate that a statement range
* cannot be provided due to a syntax error in the document.
*/
export class StatementRangeSyntaxError extends Error {
/**
* Zero indexed line number where the syntax error occurred.
*/
readonly line?: number;

/**
* Creates a new statement range syntax error.
*
* @param line Zero indexed line number where the syntax error occurred.
*/
constructor(line?: number);
}

export interface HelpTopicProvider {
Expand Down
36 changes: 35 additions & 1 deletion src/vs/editor/common/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2040,6 +2040,15 @@ export class FoldingRangeKind {
}

// --- Start Positron ---
export const enum StatementRangeKind {
Success = 'success',
Rejection = 'rejection',
}

export const enum StatementRangeRejectionKind {
Syntax = 'syntax',
}

export interface StatementRangeProvider {
/**
* Provide the statement that contains the given position.
Expand All @@ -2048,10 +2057,19 @@ export interface StatementRangeProvider {
ProviderResult<IStatementRange>;
}

export type IStatementRange = IStatementRangeSuccess | IStatementRangeRejection;

export type IStatementRangeRejection = IStatementRangeSyntaxRejection;

/**
* The range of a statement, plus optionally the code for the range.
*/
export interface IStatementRange {
export interface IStatementRangeSuccess {
/**
* The kind of statement range result.
*/
readonly kind: StatementRangeKind.Success;

/**
* The range of the statement at the given position.
*/
Expand All @@ -2061,7 +2079,23 @@ export interface IStatementRange {
* The code for this statement range, if different from the document contents at this range.
*/
readonly code?: string;
}

export interface IStatementRangeSyntaxRejection {
/**
* The kind of statement range result.
*/
readonly kind: StatementRangeKind.Rejection;

/**
* The kind of rejection.
*/
readonly rejectionKind: StatementRangeRejectionKind.Syntax;

/**
* Zero indexed line number where the syntax error occurred.
*/
readonly line?: number;
}

export interface HelpTopicProvider {
Expand Down
9 changes: 9 additions & 0 deletions src/vs/editor/common/standalone/standaloneEnums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,15 @@ export enum SignatureHelpTriggerKind {
ContentChange = 3
}

export enum StatementRangeKind {
Success = 'success',
Rejection = 'rejection'
}

export enum StatementRangeRejectionKind {
Syntax = 'syntax'
}

/**
* A symbol kind.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { IModelService } from '../../../common/services/model.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';


async function provideStatementRange(
export async function provideStatementRange(
registry: LanguageFeatureRegistry<languages.StatementRangeProvider>,
model: ITextModel,
position: Position,
Expand Down
Loading