Skip to content

Commit 400db01

Browse files
asukaminato0721meta-codesync[bot]
authored andcommitted
add code lens run command for test and main #2361 (#2397)
Summary: Fixes #2361 Implemented LSP CodeLens generation for pytest/unittest-style tests and `if __name__ == "__main__"` with Run/Test commands Added VS Code extension commands (pyrefly.runMain, pyrefly.runTest) and wiring to execute main files or trigger the VS Code Testing API at the cursor. Pull Request resolved: #2397 Test Plan: Added LSP interaction coverage and fixtures for CodeLens behavior. ``` > '/usr/bin/python' -m unittest 'main.TestT.test_' . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK > '/usr/bin/python' -m unittest 'main.TestT' . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK > '/usr/bin/python' '/tmp/tmp/main.py' Hello from tmp! ``` Reviewed By: yangdanny97 Differential Revision: D99104252 Pulled By: kinto0 fbshipit-source-id: a40d03c4c354d7c4a7d22fd9c724030065c38bb1
1 parent e853b46 commit 400db01

14 files changed

Lines changed: 975 additions & 1 deletion

File tree

lsp/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@
6464
"title": "Unfold All Docstrings",
6565
"category": "pyrefly",
6666
"command": "pyrefly.unfoldAllDocstrings"
67+
},
68+
{
69+
"title": "Run File",
70+
"category": "pyrefly",
71+
"command": "pyrefly.runMain"
72+
},
73+
{
74+
"title": "Run Test",
75+
"category": "pyrefly",
76+
"command": "pyrefly.runTest"
6777
}
6878
],
6979
"semanticTokenScopes": [
@@ -221,6 +231,12 @@
221231
"default": true,
222232
"description": "If true, Pyrefly will sync notebook documents with the language server. Set to false to disable notebook support."
223233
},
234+
"python.pyrefly.runnableCodeLens": {
235+
"type": "boolean",
236+
"default": false,
237+
"description": "Enable Pyrefly's Run/Test CodeLens actions for Python files.",
238+
"scope": "resource"
239+
},
224240
"python.pyrefly.streamDiagnostics": {
225241
"type": "boolean",
226242
"default": true,

lsp/src/codeLens.ts

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
*/
9+
10+
import {execFile} from 'child_process';
11+
import * as path from 'path';
12+
import * as vscode from 'vscode';
13+
import {ExtensionContext} from 'vscode';
14+
import {PythonExtension} from '@vscode/python-extension';
15+
16+
type CodeLensPosition = {
17+
line: number;
18+
character: number;
19+
};
20+
21+
type RunMainArgs = {
22+
uri?: string;
23+
cwd?: string;
24+
};
25+
26+
type RunTestArgs = {
27+
uri?: string;
28+
cwd?: string;
29+
position?: CodeLensPosition;
30+
testName?: string;
31+
className?: string;
32+
isUnittest?: boolean;
33+
};
34+
35+
const TASK_SOURCE = 'pyrefly';
36+
const OPEN_RUNNABLE_CODE_LENS_SETTING = 'Open Runnable CodeLens Setting';
37+
const DISABLE_RUNNABLE_CODE_LENS = 'Disable Runnable CodeLens';
38+
const shownRunnableCodeLensErrors = new Set<string>();
39+
40+
function asObject(value: unknown): Record<string, unknown> | undefined {
41+
return value != null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
42+
}
43+
44+
function parsePosition(value: unknown): CodeLensPosition | undefined {
45+
const position = asObject(value);
46+
if (!position) {
47+
return undefined;
48+
}
49+
const line = position.line;
50+
const character = position.character;
51+
return typeof line === 'number' && typeof character === 'number'
52+
? {line, character}
53+
: undefined;
54+
}
55+
56+
function parseRunMainArgs(args: unknown): RunMainArgs | undefined {
57+
const parsed = asObject(args);
58+
if (!parsed) {
59+
return undefined;
60+
}
61+
return {
62+
uri: typeof parsed.uri === 'string' ? parsed.uri : undefined,
63+
cwd: typeof parsed.cwd === 'string' ? parsed.cwd : undefined,
64+
};
65+
}
66+
67+
function parseRunTestArgs(args: unknown): RunTestArgs | undefined {
68+
const parsed = asObject(args);
69+
if (!parsed) {
70+
return undefined;
71+
}
72+
return {
73+
uri: typeof parsed.uri === 'string' ? parsed.uri : undefined,
74+
cwd: typeof parsed.cwd === 'string' ? parsed.cwd : undefined,
75+
position: parsePosition(parsed.position),
76+
testName: typeof parsed.testName === 'string' ? parsed.testName : undefined,
77+
className: typeof parsed.className === 'string' ? parsed.className : undefined,
78+
isUnittest:
79+
typeof parsed.isUnittest === 'boolean' ? parsed.isUnittest : undefined,
80+
};
81+
}
82+
83+
function parseUri(rawUri: string | undefined): vscode.Uri | undefined {
84+
if (!rawUri) {
85+
return undefined;
86+
}
87+
try {
88+
return vscode.Uri.parse(rawUri);
89+
} catch {
90+
return undefined;
91+
}
92+
}
93+
94+
async function interpreterForUri(
95+
uri: vscode.Uri,
96+
pythonExtension: PythonExtension,
97+
): Promise<string | undefined> {
98+
const envPath = await pythonExtension.environments.getActiveEnvironmentPath(
99+
uri,
100+
);
101+
return envPath.path.length > 0 ? envPath.path : undefined;
102+
}
103+
104+
function configurationTargetForUri(uri: vscode.Uri): vscode.ConfigurationTarget {
105+
return vscode.workspace.getWorkspaceFolder(uri) != null
106+
? vscode.ConfigurationTarget.WorkspaceFolder
107+
: vscode.ConfigurationTarget.Workspace;
108+
}
109+
110+
async function disableRunnableCodeLens(uri: vscode.Uri): Promise<void> {
111+
await vscode.workspace
112+
.getConfiguration('python.pyrefly', uri)
113+
.update(
114+
'runnableCodeLens',
115+
false,
116+
configurationTargetForUri(uri),
117+
);
118+
}
119+
120+
async function showRunnableCodeLensError(
121+
uri: vscode.Uri,
122+
kind: string,
123+
message: string,
124+
): Promise<void> {
125+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
126+
const errorKey = `${kind}:${workspaceFolder?.uri.toString() ?? 'workspace'}`;
127+
if (shownRunnableCodeLensErrors.has(errorKey)) {
128+
return;
129+
}
130+
shownRunnableCodeLensErrors.add(errorKey);
131+
const action = await vscode.window.showErrorMessage(
132+
message,
133+
OPEN_RUNNABLE_CODE_LENS_SETTING,
134+
DISABLE_RUNNABLE_CODE_LENS,
135+
);
136+
if (action === OPEN_RUNNABLE_CODE_LENS_SETTING) {
137+
await vscode.commands.executeCommand(
138+
'workbench.action.openSettings',
139+
'python.pyrefly.runnableCodeLens',
140+
);
141+
} else if (action === DISABLE_RUNNABLE_CODE_LENS) {
142+
await disableRunnableCodeLens(uri);
143+
}
144+
}
145+
146+
async function canImportModule(
147+
interpreter: string,
148+
moduleName: string,
149+
cwd: string | undefined,
150+
): Promise<boolean> {
151+
return await new Promise(resolve => {
152+
execFile(
153+
interpreter,
154+
['-c', `import ${moduleName}`],
155+
{cwd},
156+
error => resolve(error == null),
157+
);
158+
});
159+
}
160+
161+
function scopeForUri(uri: vscode.Uri): vscode.WorkspaceFolder | vscode.TaskScope {
162+
return vscode.workspace.getWorkspaceFolder(uri) ?? vscode.TaskScope.Workspace;
163+
}
164+
165+
function moduleNameFromPath(uri: vscode.Uri): string | undefined {
166+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
167+
if (!workspaceFolder) {
168+
return undefined;
169+
}
170+
let relativePath = path.relative(workspaceFolder.uri.fsPath, uri.fsPath);
171+
if (relativePath.startsWith('..')) {
172+
return undefined;
173+
}
174+
if (relativePath.endsWith('.py')) {
175+
relativePath = relativePath.slice(0, -3);
176+
} else {
177+
return undefined;
178+
}
179+
return relativePath
180+
.split(path.sep)
181+
.filter(part => part.length > 0)
182+
.join('.');
183+
}
184+
185+
async function runAtCursor(
186+
uri: vscode.Uri,
187+
position: CodeLensPosition,
188+
): Promise<void> {
189+
const document = await vscode.workspace.openTextDocument(uri);
190+
const editor = await vscode.window.showTextDocument(document, {
191+
preview: false,
192+
});
193+
const cursor = new vscode.Position(position.line, position.character);
194+
editor.selection = new vscode.Selection(cursor, cursor);
195+
editor.revealRange(new vscode.Range(cursor, cursor));
196+
await vscode.commands.executeCommand('testing.runAtCursor');
197+
}
198+
199+
async function executeProcessTask(
200+
uri: vscode.Uri,
201+
cwd: string | undefined,
202+
definition: vscode.TaskDefinition,
203+
label: string,
204+
command: string,
205+
args: string[],
206+
): Promise<void> {
207+
// Use ProcessExecution so VS Code passes argv directly instead of relying on
208+
// shell-specific quoting rules.
209+
const task = new vscode.Task(
210+
definition,
211+
scopeForUri(uri),
212+
label,
213+
TASK_SOURCE,
214+
new vscode.ProcessExecution(command, args, {
215+
cwd,
216+
}),
217+
);
218+
task.presentationOptions = {
219+
reveal: vscode.TaskRevealKind.Always,
220+
panel: vscode.TaskPanelKind.Dedicated,
221+
focus: true,
222+
clear: false,
223+
showReuseMessage: false,
224+
};
225+
await vscode.tasks.executeTask(task);
226+
}
227+
228+
async function runMainFile(
229+
args: RunMainArgs,
230+
pythonExtension: PythonExtension,
231+
): Promise<void> {
232+
const uri = parseUri(args.uri);
233+
if (!uri) {
234+
return;
235+
}
236+
const interpreter = await interpreterForUri(uri, pythonExtension);
237+
if (!interpreter) {
238+
void showRunnableCodeLensError(
239+
uri,
240+
'missing-interpreter',
241+
'Pyrefly could not determine a Python interpreter for this file. Ensure the correct interpreter is selected in your IDE before using runnable CodeLens.',
242+
);
243+
return;
244+
}
245+
await executeProcessTask(
246+
uri,
247+
args.cwd,
248+
{type: TASK_SOURCE, action: 'runMain'},
249+
'Pyrefly: Run File',
250+
interpreter,
251+
[uri.fsPath],
252+
);
253+
}
254+
255+
async function runTestAtLocation(
256+
args: RunTestArgs,
257+
pythonExtension: PythonExtension,
258+
): Promise<void> {
259+
const uri = parseUri(args.uri);
260+
if (!uri) {
261+
return;
262+
}
263+
const cwd = args.cwd;
264+
const className = args.className;
265+
const testName = args.testName;
266+
267+
if (args.position && !testName && !className) {
268+
await runAtCursor(uri, args.position);
269+
return;
270+
}
271+
272+
const interpreter = await interpreterForUri(uri, pythonExtension);
273+
if (!interpreter) {
274+
void showRunnableCodeLensError(
275+
uri,
276+
'missing-interpreter',
277+
'Pyrefly could not determine a Python interpreter for this file. Ensure the correct interpreter is selected in your IDE before using runnable CodeLens.',
278+
);
279+
return;
280+
}
281+
if (args.isUnittest === true) {
282+
const moduleName = moduleNameFromPath(uri);
283+
if (!moduleName) {
284+
if (args.position) {
285+
await runAtCursor(uri, args.position);
286+
}
287+
return;
288+
}
289+
let target = moduleName;
290+
if (className) {
291+
target = `${target}.${className}`;
292+
}
293+
if (testName) {
294+
target = `${target}.${testName}`;
295+
}
296+
await executeProcessTask(
297+
uri,
298+
cwd,
299+
{type: TASK_SOURCE, action: 'runUnittest'},
300+
'Pyrefly: Run Test',
301+
interpreter,
302+
['-m', 'unittest', target],
303+
);
304+
return;
305+
}
306+
307+
let nodeId = uri.fsPath;
308+
if (className) {
309+
nodeId = `${nodeId}::${className}`;
310+
}
311+
if (testName) {
312+
nodeId = `${nodeId}::${testName}`;
313+
}
314+
if (!(await canImportModule(interpreter, 'pytest', cwd))) {
315+
void showRunnableCodeLensError(
316+
uri,
317+
'missing-pytest',
318+
'Pyrefly could not import pytest from the selected interpreter. Select the correct interpreter or install pytest in that environment before using runnable CodeLens.',
319+
);
320+
return;
321+
}
322+
await executeProcessTask(
323+
uri,
324+
cwd,
325+
{type: TASK_SOURCE, action: 'runPytest'},
326+
'Pyrefly: Run Test',
327+
interpreter,
328+
['-m', 'pytest', nodeId],
329+
);
330+
}
331+
332+
export function registerCodeLensCommands(
333+
context: ExtensionContext,
334+
pythonExtension: PythonExtension,
335+
): void {
336+
context.subscriptions.push(
337+
vscode.commands.registerCommand('pyrefly.runTest', async args => {
338+
const parsedArgs = parseRunTestArgs(args);
339+
if (!parsedArgs) {
340+
return;
341+
}
342+
await runTestAtLocation(parsedArgs, pythonExtension);
343+
}),
344+
);
345+
346+
context.subscriptions.push(
347+
vscode.commands.registerCommand('pyrefly.runMain', async args => {
348+
const parsedArgs = parseRunMainArgs(args);
349+
if (!parsedArgs) {
350+
return;
351+
}
352+
await runMainFile(parsedArgs, pythonExtension);
353+
}),
354+
);
355+
}

0 commit comments

Comments
 (0)