Skip to content

Commit d5d37bb

Browse files
Implement public facing StatementRangeSyntaxError (#11907)
Addresses #8350 - Requires posit-dev/ark#1040 - Blocks quarto-dev/quarto#919 Builds on: - quarto-dev/quarto#914 - posit-dev/ark#1030 # Summary This PR adds a new throwable `StatementRangeSyntaxError` to `positron.d.ts`. ```ts /** * 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); } ``` This is the only public facing API change from this PR. The idea is that from extension land (`positron-r`, `positron-python`, and `quarto`) you should be able to throw a `StatementRangeSyntaxError` from your `provideStatementRange()` provider, with an optional `line` number of where the syntax error _roughly_ starts. This gets propagated up through the main thread and into our `Cmd+Enter` handling in `positronConsoleActions.ts`, where we now detect this special case and: - Emit an info level notification telling the user that we can't execute code due to a syntax error. If the `line` number is present, we give them a button to jump to that line. - Bail from `Cmd+Enter` handling altogether. We don't even do one-line-at-a-time execution. #8350 has proved that this is just too confusing when you are trying to execute code after a syntax error. Here is what the end result looks like in an R script: https://github.com/user-attachments/assets/23dca240-06af-40d5-8b1b-a8b24af99259 Note how you can execute R code _above_ the first syntax error, thanks to posit-dev/ark#1030. It is only _below_ the first syntax error that we emit this notification and refuse to execute. It even works in roxygen2 comments where we have a little "subdocument": https://github.com/user-attachments/assets/ec920753-400d-4493-b534-188dcf4e2a65 And lastly, it works in Quarto, where we have a "virtual document" for R/Python and map the `line` number from that virtual document back into the original Qmd, thanks to quarto-dev/quarto#919 (note that if you try to run that Quarto PR, you need to remove the version guard on Positron 2026.03.0 manually). Additionally, quarto-dev/quarto#914 ensures that you can use StatementRange in any syntax-error-free cell, even if some other cell in the document has a syntax error. https://github.com/user-attachments/assets/a01b9ed1-22fe-4169-b088-a8393800d9db # Design I considered many different designs along the way, but landed on a model of: - Extension host side _models errors as throwable errors_, i.e. with `StatementRange` and `throw StatementRangeSyntaxError` ```ts export interface StatementRange { readonly range: vscode.Range; readonly code?: string; } export class StatementRangeSyntaxError extends Error { readonly line?: number; constructor(line?: number); } ``` - Main thread side _models errors as data_, i.e. a single `IStatementRange` type made up as: ```ts type IStatementRange = IStatementRangeSuccess | IStatementRangeRejection type IStatementRangeRejection = IStatementRangeSyntaxRejection export interface IStatementRangeSuccess { readonly kind: StatementRangeKind.Success; readonly range: IRange; readonly code?: string; } export interface IStatementRangeSyntaxRejection { readonly kind: StatementRangeKind.Rejection; readonly rejectionKind: StatementRangeRejectionKind.Syntax; readonly line?: number; } ``` Some rationale: - Backwards compatible and public facing `StatementRange` doesn't change - Can add new error/rejection variants as needed - Can't easily model main thread side errors as, well, `Error`s, because the `StatementRangeSyntaxError` doesn't serialize across the extension -> main thread boundary well. You basically have to turn it into some "data" type anyways to do this, and then it doesn't feel worth it to convert back on the main thread side. - Ark's custom `StatementRange` LSP Request also now uses the "success" vs "rejection" model. From Ark's LSP-ish perspective, a "rejection" is an allowed value that can be returned as an LSP Response. We reserve the JSONRPC error path for true "holy crap something horrible happened and I can't complete this request" cases. The end result looks like this: <img width="5127" height="4470" alt="test" src="https://github.com/user-attachments/assets/b341c1dd-edac-45a2-9aed-03a4b05b3c01" /> # Prior art There isn't much prior art for exactly what I'm trying to do here, but there is a little bit: - `FileSystemError` with its various flavors. Not quite the same as us because none of the flavors have any metadata to pass through, like `line`. That metadata is what steered me away from trying to have one overarching `StatementRangeError` class that could contain something like `static SyntaxError(line?: number): StatementRangeError`. I did try this, but the boilerplate didn't feel worth it. https://github.com/posit-dev/positron/blob/31c0111d42eaff76f06e1800836663e5765e1df1/src/vscode-dts/vscode.d.ts#L9474-L9486 - `Rejection`, which is used by the `RenameProvider`. Catches an error from the LSP side of things and converts it into a `Rejection` "data" type, kind of like we do. Strangely does `RenameProvider & Rejection` as the return value type. - Defined here https://github.com/posit-dev/positron/blob/9754ca9e56524315fa6b2d1981fb6cb82ef5cd7b/src/vs/editor/common/languages.ts#L2127-L2138 - Catching the error here https://github.com/posit-dev/positron/blob/9754ca9e56524315fa6b2d1981fb6cb82ef5cd7b/src/vs/workbench/api/common/extHostLanguageFeatures.ts#L882-L903 # QA ### Release Notes #### New Features - Stepping through code with `Cmd + Enter` now works more reliably when there are syntax errors in the document: - For R code: - [Code _above_ the first syntax error](posit-dev/ark#1030) can now be executed reliably with `Cmd + Enter` - [Code _below_ the first syntax error](#11907) causes a notification to pop up that informs you that reliable execution is impossible and provides you with a button to jump to the syntax error - For Quarto documents, a syntax error in one chunk no longer affects the ability to [run code in other chunks](quarto-dev/quarto#914) ### QA Notes QA: It would be nice to have two integration tests ```r 1 + 1 # <put cursor here and execute this, it should work now and send the whole statement> 2 + \ 2 ``` ```r 1 + \ 1 2 + 2 # <put cursor here and execute this, it should not send anything, and a notification should pop up> ``` `@:ark` `@:quarto` `@:console` --------- Signed-off-by: Davis Vaughan <davis@posit.co> Co-authored-by: Lionel Henry <lionel.hry@proton.me>
1 parent cf256d6 commit d5d37bb

15 files changed

Lines changed: 688 additions & 60 deletions

File tree

extensions/positron-r/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,7 @@
10781078
},
10791079
"positron": {
10801080
"binaryDependencies": {
1081-
"ark": "0.1.227"
1081+
"ark": "0.1.229"
10821082
},
10831083
"minimumRVersion": "4.2.0",
10841084
"minimumRenvVersion": "1.0.9"

extensions/positron-r/src/statement-range.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,36 @@
55

66
import * as positron from 'positron';
77
import * as vscode from 'vscode';
8-
import { CancellationToken, LanguageClient, Position, Range, RequestType, VersionedTextDocumentIdentifier } from 'vscode-languageclient/node';
8+
import { LanguageClient, Position, Range, RequestType, VersionedTextDocumentIdentifier } from 'vscode-languageclient/node';
9+
10+
enum StatementRangeKind {
11+
Success = 'success',
12+
Rejection = 'rejection'
13+
}
14+
15+
enum StatementRangeRejectionKind {
16+
Syntax = 'syntax'
17+
}
918

1019
interface StatementRangeParams {
1120
textDocument: VersionedTextDocumentIdentifier;
1221
position: Position;
1322
}
1423

15-
interface StatementRangeResponse {
16-
range: Range;
17-
code?: string;
24+
type StatementRangeResponse = StatementRangeSuccess | StatementRangeRejection;
25+
26+
interface StatementRangeSuccess {
27+
readonly kind: StatementRangeKind.Success;
28+
readonly range: Range;
29+
readonly code?: string;
30+
}
31+
32+
type StatementRangeRejection = StatementRangeSyntaxRejection;
33+
34+
interface StatementRangeSyntaxRejection {
35+
readonly kind: StatementRangeKind.Rejection;
36+
readonly rejectionKind: StatementRangeRejectionKind.Syntax;
37+
readonly line?: number;
1838
}
1939

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

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

48-
const response = this._client.sendRequest(StatementRangeRequest.type, params, token);
69+
const data = await this._client.sendRequest(StatementRangeRequest.type, params, token);
70+
71+
if (!data) {
72+
return undefined;
73+
}
4974

50-
return response.then(data => {
51-
if (!data) {
52-
return undefined;
75+
switch (data.kind) {
76+
case StatementRangeKind.Success: {
77+
return {
78+
range: this._client.protocol2CodeConverter.asRange(data.range),
79+
// Explicitly normalize non-strings to `undefined` (i.e. a possible `null`)
80+
code: typeof data.code === 'string' ? data.code : undefined
81+
} satisfies positron.StatementRange;
82+
}
83+
case StatementRangeKind.Rejection: {
84+
switch (data.rejectionKind) {
85+
case StatementRangeRejectionKind.Syntax: {
86+
throw new positron.StatementRangeSyntaxError(data.line);
87+
}
88+
default: {
89+
throw new Error(`Unrecognized 'StatementRangeRejectionKind': ${data.rejectionKind}`);
90+
}
91+
}
92+
}
93+
default: {
94+
throw new Error(`Unrecognized 'StatementRangeKind': ${data}`);
5395
}
54-
const range = this._client.protocol2CodeConverter.asRange(data.range);
55-
// Explicitly normalize non-strings to `undefined` (i.e. a possible `null`)
56-
const code = typeof data.code === 'string' ? data.code : undefined;
57-
return { range: range, code: code } as positron.StatementRange;
58-
});
96+
}
5997
}
6098
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import './mocha-setup';
7+
8+
import * as assert from 'assert';
9+
import * as positron from 'positron';
10+
import * as vscode from 'vscode';
11+
import * as testKit from './kit';
12+
import { createRandomFile, deleteFile } from './editor-utils';
13+
14+
suite('RStatementRangeProvider', () => {
15+
let sessionDisposable: vscode.Disposable;
16+
17+
suiteSetup(async function () {
18+
const [, disposable] = await testKit.startR();
19+
sessionDisposable = disposable;
20+
});
21+
22+
suiteTeardown(async () => {
23+
await sessionDisposable?.dispose();
24+
});
25+
26+
test('single-line expression', async function () {
27+
const code = `
28+
1 + 1
29+
`.trimStart();
30+
31+
await testKit.withDisposables(async (disposables) => {
32+
const result = await getStatementRange(
33+
disposables,
34+
code,
35+
new vscode.Position(0, 0)
36+
);
37+
assert.ok(result, 'Expected a statement range result');
38+
assert.strictEqual(result.range.start.line, 0);
39+
assert.strictEqual(result.range.start.character, 0);
40+
assert.strictEqual(result.range.end.line, 0);
41+
assert.strictEqual(result.range.end.character, 5);
42+
});
43+
});
44+
45+
test('multi-line block from first line', async function () {
46+
const code = `
47+
for (i in 1:3) {
48+
print(i)
49+
}
50+
`.trimStart();
51+
52+
await testKit.withDisposables(async (disposables) => {
53+
const result = await getStatementRange(
54+
disposables,
55+
code,
56+
new vscode.Position(0, 0)
57+
);
58+
59+
assert.ok(result, 'Expected a statement range result');
60+
assert.strictEqual(result.range.start.line, 0);
61+
assert.strictEqual(result.range.start.character, 0);
62+
assert.strictEqual(result.range.end.line, 2);
63+
assert.strictEqual(result.range.end.character, 1);
64+
});
65+
});
66+
67+
test('in a pipe chain', async function () {
68+
const code = `
69+
1 + 1
70+
71+
df |>
72+
mutate(y = x + 1) |>
73+
mutate(z = x + y)
74+
`.trimStart();
75+
76+
await testKit.withDisposables(async (disposables) => {
77+
const result = await getStatementRange(
78+
disposables,
79+
code,
80+
new vscode.Position(3, 3)
81+
);
82+
83+
assert.ok(result, 'Expected a statement range result');
84+
assert.strictEqual(result.range.start.line, 2);
85+
assert.strictEqual(result.range.start.character, 0);
86+
assert.strictEqual(result.range.end.line, 4);
87+
assert.strictEqual(result.range.end.character, 18);
88+
});
89+
});
90+
91+
test('cursor before syntax error works fine', async function () {
92+
const code = `
93+
df |>
94+
summarise(foo = mean(x))
95+
96+
df |>
97+
mutate(y = x \ 1 |>
98+
mutate(z = x + y)
99+
`.trimStart();
100+
101+
await testKit.withDisposables(async (disposables) => {
102+
const result = await getStatementRange(
103+
disposables,
104+
code,
105+
new vscode.Position(1, 3)
106+
);
107+
108+
assert.ok(result, 'Expected a statement range result');
109+
assert.strictEqual(result.range.start.line, 0);
110+
assert.strictEqual(result.range.start.character, 0);
111+
assert.strictEqual(result.range.end.line, 1);
112+
assert.strictEqual(result.range.end.character, 25);
113+
});
114+
});
115+
116+
test('cursor in syntax error throws StatementRangeSyntaxError', async function () {
117+
const code = `
118+
df |>
119+
summarise(foo = mean(x))
120+
121+
df |>
122+
mutate(y = x \ 1 |>
123+
mutate(z = x + y)
124+
`.trimStart();
125+
126+
await testKit.withDisposables(async (disposables) => {
127+
await assert.rejects(
128+
() => getStatementRange(
129+
disposables,
130+
code,
131+
new vscode.Position(4, 0)
132+
),
133+
(err) => {
134+
assert.ok(err instanceof positron.StatementRangeSyntaxError);
135+
assert.strictEqual(err.line, 3);
136+
return true;
137+
}
138+
);
139+
});
140+
});
141+
});
142+
143+
/**
144+
* Executes the statement range provider for an R file with the given contents
145+
* at the given position.
146+
*/
147+
async function getStatementRange(
148+
disposables: vscode.Disposable[],
149+
contents: string,
150+
position: vscode.Position
151+
): Promise<positron.StatementRange | undefined> {
152+
const fileUri = await createRandomFile(contents, 'R');
153+
disposables.push({ dispose: () => deleteFile(fileUri) });
154+
155+
const doc = await vscode.workspace.openTextDocument(fileUri);
156+
await vscode.window.showTextDocument(doc);
157+
158+
return await vscode.commands.executeCommand<positron.StatementRange | undefined>(
159+
'vscode.executeStatementRangeProvider',
160+
doc.uri,
161+
position
162+
);
163+
}

src/positron-dts/positron.d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,9 @@ declare module 'positron' {
15421542
* cursor is within. If the cursor is not within a statement, return the
15431543
* range of the next statement, if one exists.
15441544
*
1545+
* Throw a {@link StatementRangeSyntaxError} to indicate that a statement range
1546+
* cannot be provided due to a syntax error in the document.
1547+
*
15451548
* @param document The document in which the command was invoked.
15461549
* @param position The position at which the command was invoked.
15471550
* @param token A cancellation token.
@@ -1565,7 +1568,24 @@ declare module 'positron' {
15651568
* The code for this statement range, if different from the document contents at this range.
15661569
*/
15671570
readonly code?: string;
1571+
}
1572+
1573+
/**
1574+
* An error thrown by a {@link StatementRangeProvider} to indicate that a statement range
1575+
* cannot be provided due to a syntax error in the document.
1576+
*/
1577+
export class StatementRangeSyntaxError extends Error {
1578+
/**
1579+
* Zero indexed line number where the syntax error occurred.
1580+
*/
1581+
readonly line?: number;
15681582

1583+
/**
1584+
* Creates a new statement range syntax error.
1585+
*
1586+
* @param line Zero indexed line number where the syntax error occurred.
1587+
*/
1588+
constructor(line?: number);
15691589
}
15701590

15711591
export interface HelpTopicProvider {

src/vs/editor/common/languages.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2040,6 +2040,15 @@ export class FoldingRangeKind {
20402040
}
20412041

20422042
// --- Start Positron ---
2043+
export const enum StatementRangeKind {
2044+
Success = 'success',
2045+
Rejection = 'rejection',
2046+
}
2047+
2048+
export const enum StatementRangeRejectionKind {
2049+
Syntax = 'syntax',
2050+
}
2051+
20432052
export interface StatementRangeProvider {
20442053
/**
20452054
* Provide the statement that contains the given position.
@@ -2048,10 +2057,19 @@ export interface StatementRangeProvider {
20482057
ProviderResult<IStatementRange>;
20492058
}
20502059

2060+
export type IStatementRange = IStatementRangeSuccess | IStatementRangeRejection;
2061+
2062+
export type IStatementRangeRejection = IStatementRangeSyntaxRejection;
2063+
20512064
/**
20522065
* The range of a statement, plus optionally the code for the range.
20532066
*/
2054-
export interface IStatementRange {
2067+
export interface IStatementRangeSuccess {
2068+
/**
2069+
* The kind of statement range result.
2070+
*/
2071+
readonly kind: StatementRangeKind.Success;
2072+
20552073
/**
20562074
* The range of the statement at the given position.
20572075
*/
@@ -2061,7 +2079,23 @@ export interface IStatementRange {
20612079
* The code for this statement range, if different from the document contents at this range.
20622080
*/
20632081
readonly code?: string;
2082+
}
20642083

2084+
export interface IStatementRangeSyntaxRejection {
2085+
/**
2086+
* The kind of statement range result.
2087+
*/
2088+
readonly kind: StatementRangeKind.Rejection;
2089+
2090+
/**
2091+
* The kind of rejection.
2092+
*/
2093+
readonly rejectionKind: StatementRangeRejectionKind.Syntax;
2094+
2095+
/**
2096+
* Zero indexed line number where the syntax error occurred.
2097+
*/
2098+
readonly line?: number;
20652099
}
20662100

20672101
export interface HelpTopicProvider {

src/vs/editor/common/standalone/standaloneEnums.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,15 @@ export enum SignatureHelpTriggerKind {
881881
ContentChange = 3
882882
}
883883

884+
export enum StatementRangeKind {
885+
Success = 'success',
886+
Rejection = 'rejection'
887+
}
888+
889+
export enum StatementRangeRejectionKind {
890+
Syntax = 'syntax'
891+
}
892+
884893
/**
885894
* A symbol kind.
886895
*/

src/vs/editor/contrib/positronStatementRange/browser/provideStatementRange.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { IModelService } from '../../../common/services/model.js';
1616
import { CancellationToken } from '../../../../base/common/cancellation.js';
1717

1818

19-
async function provideStatementRange(
19+
export async function provideStatementRange(
2020
registry: LanguageFeatureRegistry<languages.StatementRangeProvider>,
2121
model: ITextModel,
2222
position: Position,

0 commit comments

Comments
 (0)