Skip to content

Commit 7c40f2c

Browse files
authored
Make error lines match the original file (#9984)
* Working line rewrite * Add href and tests * Support unix too * Add news entry * Fix linter problems
1 parent a8d965b commit 7c40f2c

File tree

7 files changed

+123
-8
lines changed

7 files changed

+123
-8
lines changed

news/2 Fixes/6370.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make line numbers in errors for the Interactive window match the original file and make them clickable for jumping back to an error location.

src/client/common/platform/fileSystem.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export class RawFileSystem implements IRawFileSystem {
129129
paths?: IRawPath,
130130
vscfs?: IVSCodeFileSystemAPI,
131131
fsExtra?: IRawFSExtra
132-
): RawFileSystem{
132+
): RawFileSystem {
133133
// prettier-ignore
134134
return new RawFileSystem(
135135
paths || FileSystemPaths.withDefaults(),
@@ -472,6 +472,9 @@ export class FileSystem implements IFileSystem {
472472
public arePathsSame(path1: string, path2: string): boolean {
473473
return this.utils.pathUtils.arePathsSame(path1, path2);
474474
}
475+
public getDisplayName(path: string): string {
476+
return this.utils.pathUtils.getDisplayName(path);
477+
}
475478
public async stat(filename: string): Promise<FileStat> {
476479
return this.utils.raw.stat(filename);
477480
}

src/client/common/platform/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export interface IFileSystem {
192192
// path-related
193193
directorySeparatorChar: string;
194194
arePathsSame(path1: string, path2: string): boolean;
195+
getDisplayName(path: string): string;
195196

196197
// "raw" operations
197198
stat(filePath: string): Promise<FileStat>;

src/client/datascience/interactive-common/linkProvider.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,26 @@
44
import '../../common/extensions';
55

66
import { inject, injectable } from 'inversify';
7-
import { Event, EventEmitter } from 'vscode';
7+
import { Event, EventEmitter, Position, Range, TextEditorRevealType, Uri } from 'vscode';
88

9-
import { IApplicationShell } from '../../common/application/types';
9+
import { IApplicationShell, IDocumentManager } from '../../common/application/types';
1010
import { IFileSystem } from '../../common/platform/types';
1111
import * as localize from '../../common/utils/localize';
1212
import { noop } from '../../common/utils/misc';
1313
import { IInteractiveWindowListener } from '../types';
1414
import { InteractiveWindowMessages } from './interactiveWindowTypes';
1515

16+
const LineQueryRegex = /line=(\d+)/;
17+
1618
// tslint:disable: no-any
1719
@injectable()
1820
export class LinkProvider implements IInteractiveWindowListener {
1921
private postEmitter: EventEmitter<{ message: string; payload: any }> = new EventEmitter<{ message: string; payload: any }>();
20-
constructor(@inject(IApplicationShell) private applicationShell: IApplicationShell, @inject(IFileSystem) private fileSystem: IFileSystem) {
22+
constructor(
23+
@inject(IApplicationShell) private applicationShell: IApplicationShell,
24+
@inject(IFileSystem) private fileSystem: IFileSystem,
25+
@inject(IDocumentManager) private documentManager: IDocumentManager
26+
) {
2127
noop();
2228
}
2329

@@ -29,7 +35,13 @@ export class LinkProvider implements IInteractiveWindowListener {
2935
switch (message) {
3036
case InteractiveWindowMessages.OpenLink:
3137
if (payload) {
32-
this.applicationShell.openUrl(payload.toString());
38+
// Special case file URIs
39+
const href = payload.toString();
40+
if (href.startsWith('file')) {
41+
this.openFile(href);
42+
} else {
43+
this.applicationShell.openUrl(href);
44+
}
3345
}
3446
break;
3547
case InteractiveWindowMessages.SavePng:
@@ -59,4 +71,27 @@ export class LinkProvider implements IInteractiveWindowListener {
5971
public dispose(): void | undefined {
6072
noop();
6173
}
74+
75+
private openFile(fileUri: string) {
76+
const uri = Uri.parse(fileUri);
77+
let selection: Range = new Range(new Position(0, 0), new Position(0, 0));
78+
if (uri.query) {
79+
// Might have a line number query on the file name
80+
const lineMatch = LineQueryRegex.exec(uri.query);
81+
if (lineMatch) {
82+
const lineNumber = parseInt(lineMatch[1], 10);
83+
selection = new Range(new Position(lineNumber, 0), new Position(lineNumber, 0));
84+
}
85+
}
86+
87+
// Show the matching editor if there is one
88+
const editor = this.documentManager.visibleTextEditors.find(e => this.fileSystem.arePathsSame(e.document.fileName, uri.fsPath));
89+
if (editor) {
90+
this.documentManager.showTextDocument(editor.document, { selection, viewColumn: editor.viewColumn }).then(() => {
91+
editor.revealRange(selection, TextEditorRevealType.InCenter);
92+
});
93+
} else {
94+
this.documentManager.showTextDocument(uri, { selection });
95+
}
96+
}
6297
}

src/client/datascience/jupyter/jupyterNotebook.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
INotebookServerLaunchInfo,
3737
InterruptResult
3838
} from '../types';
39-
import { expandWorkingDir } from './jupyterUtils';
39+
import { expandWorkingDir, modifyTraceback } from './jupyterUtils';
4040
import { LiveKernelModel } from './kernels/types';
4141

4242
// tslint:disable-next-line: no-require-imports
@@ -1079,7 +1079,7 @@ export class JupyterNotebookBase implements INotebook {
10791079
output_type: 'error',
10801080
ename: msg.content.ename,
10811081
evalue: msg.content.evalue,
1082-
traceback: msg.content.traceback
1082+
traceback: modifyTraceback(cell.file, this.fs.getDisplayName(cell.file), cell.line, msg.content.traceback)
10831083
};
10841084
this.addToCellData(cell, output, clearState);
10851085
cell.state = CellState.error;

src/client/datascience/jupyter/jupyterUtils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ import { IWorkspaceService } from '../../common/application/types';
1010
import { IDataScienceSettings } from '../../common/types';
1111
import { noop } from '../../common/utils/misc';
1212
import { SystemVariables } from '../../common/variables/systemVariables';
13+
import { Identifiers } from '../constants';
1314
import { IConnection } from '../types';
1415

16+
// tslint:disable-next-line:no-require-imports no-var-requires
17+
const _escapeRegExp = require('lodash/escapeRegExp') as typeof import('lodash/escapeRegExp');
18+
1519
export function expandWorkingDir(workingDir: string | undefined, launchingFile: string, workspace: IWorkspaceService): string {
1620
if (workingDir) {
1721
const variables = new SystemVariables(Uri.file(launchingFile), undefined, workspace);
@@ -45,3 +49,33 @@ export function createRemoteConnectionInfo(uri: string, settings: IDataScienceSe
4549
dispose: noop
4650
};
4751
}
52+
53+
const LineMatchRegex = /(;32m[ ->]*?)(\d+)/g;
54+
const IPythonMatchRegex = /(<ipython-input.*?>)/g;
55+
56+
function modifyLineNumbers(entry: string, file: string, startLine: number): string {
57+
return entry.replace(LineMatchRegex, (_s, prefix, num) => {
58+
const n = parseInt(num, 10);
59+
const newLine = startLine + n;
60+
return `${prefix}<a href='file://${file}?line=${newLine}'>${newLine + 1}</a>`;
61+
});
62+
}
63+
64+
function modifyTracebackEntry(fileMatchRegex: RegExp, file: string, fileDisplayName: string, startLine: number, entry: string): string {
65+
if (fileMatchRegex.test(entry)) {
66+
return modifyLineNumbers(entry, file, startLine);
67+
} else if (IPythonMatchRegex.test(entry)) {
68+
const ipythonReplaced = entry.replace(IPythonMatchRegex, fileDisplayName);
69+
return modifyLineNumbers(ipythonReplaced, file, startLine);
70+
}
71+
return entry;
72+
}
73+
74+
export function modifyTraceback(file: string, fileDisplayName: string, startLine: number, traceback: string[]): string[] {
75+
if (file && file !== Identifiers.EmptyFileName) {
76+
const escaped = _escapeRegExp(fileDisplayName);
77+
const fileMatchRegex = new RegExp(`\\[.*?;32m${escaped}`);
78+
return traceback.map(modifyTracebackEntry.bind(undefined, fileMatchRegex, file, fileDisplayName, startLine));
79+
}
80+
return traceback;
81+
}

src/test/datascience/jupyterUtils.unit.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { anything, instance, mock, when } from 'ts-mockito';
66
import { Uri } from 'vscode';
77
import { WorkspaceService } from '../../client/common/application/workspace';
88
import { IS_WINDOWS } from '../../client/common/platform/constants';
9-
import { expandWorkingDir } from '../../client/datascience/jupyter/jupyterUtils';
9+
import { expandWorkingDir, modifyTraceback } from '../../client/datascience/jupyter/jupyterUtils';
1010

1111
suite('Data Science JupyterUtils', () => {
1212
const workspaceService = mock(WorkspaceService);
@@ -33,4 +33,45 @@ suite('Data Science JupyterUtils', () => {
3333
assert.equal(expandWorkingDir('${workspaceFolder}', 'test/xyz/bip/foo.baz', inst), Uri.file('test/bar').fsPath);
3434
assert.equal(expandWorkingDir('${cwd}-${file}', 'bar/bip/foo.baz', inst), `${Uri.file('test/bar').fsPath}-${Uri.file('bar/bip/foo.baz').fsPath}`);
3535
});
36+
37+
test('modifying traceback', () => {
38+
const trace1 = [
39+
'"\u001b[1;36m File \u001b[1;32m"<ipython-input-2-940d61ce6e42>"\u001b[1;36m, line \u001b[1;32m599999\u001b[0m\n\u001b[1;33m sys.\u001b[0m\n\u001b[1;37m ^\u001b[0m\n\u001b[1;31mSyntaxError\u001b[0m\u001b[1;31m:\u001b[0m invalid syntax\n"'
40+
];
41+
const after1 = [
42+
`"\u001b[1;36m File \u001b[1;32m"footastic.py"\u001b[1;36m, line \u001b[1;32m<a href='file://foo.py?line=600001'>600002</a>\u001b[0m\n\u001b[1;33m sys.\u001b[0m\n\u001b[1;37m ^\u001b[0m\n\u001b[1;31mSyntaxError\u001b[0m\u001b[1;31m:\u001b[0m invalid syntax\n"`
43+
];
44+
const file1 = 'foo.py';
45+
// Use a join after to make the assert show the results
46+
assert.equal(after1.join('\n'), modifyTraceback(file1, 'footastic.py', 2, trace1).join('\n'), 'Syntax error failure');
47+
const trace2 = [
48+
'\u001b[1;31m---------------------------------------------------------------------------\u001b[0m',
49+
'\u001b[1;31mException\u001b[0m Traceback (most recent call last)',
50+
"\u001b[1;32md:\\Training\\SnakePython\\manualTestFile.py\u001b[0m in \u001b[0;36m<module>\u001b[1;34m\u001b[0m\n\u001b[0;32m 3\u001b[0m \u001b[1;32mfor\u001b[0m \u001b[0mi\u001b[0m \u001b[1;32min\u001b[0m \u001b[0mtrange\u001b[0m\u001b[1;33m(\u001b[0m\u001b[1;36m100\u001b[0m\u001b[1;33m)\u001b[0m\u001b[1;33m:\u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33m\u001b[0m\u001b[0m\n\u001b[0;32m 4\u001b[0m \u001b[0mtime\u001b[0m\u001b[1;33m.\u001b[0m\u001b[0msleep\u001b[0m\u001b[1;33m(\u001b[0m\u001b[1;36m0.01\u001b[0m\u001b[1;33m)\u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33m\u001b[0m\u001b[0m\n\u001b[1;32m----> 5\u001b[1;33m \u001b[1;32mraise\u001b[0m \u001b[0mException\u001b[0m\u001b[1;33m(\u001b[0m\u001b[1;34m'spam'\u001b[0m\u001b[1;33m)\u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33m\u001b[0m\u001b[0m\n\u001b[0m",
51+
'\u001b[1;31mException\u001b[0m: spam'
52+
];
53+
const after2 = [
54+
'\u001b[1;31m---------------------------------------------------------------------------\u001b[0m',
55+
'\u001b[1;31mException\u001b[0m Traceback (most recent call last)',
56+
`\u001b[1;32md:\\Training\\SnakePython\\manualTestFile.py\u001b[0m in \u001b[0;36m<module>\u001b[1;34m\u001b[0m\n\u001b[0;32m <a href='file://d:\\Training\\SnakePython\\manualTestFile.py?line=23'>24</a>\u001b[0m \u001b[1;32mfor\u001b[0m \u001b[0mi\u001b[0m \u001b[1;32min\u001b[0m \u001b[0mtrange\u001b[0m\u001b[1;33m(\u001b[0m\u001b[1;36m100\u001b[0m\u001b[1;33m)\u001b[0m\u001b[1;33m:\u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33m\u001b[0m\u001b[0m\n\u001b[0;32m <a href='file://d:\\Training\\SnakePython\\manualTestFile.py?line=24'>25</a>\u001b[0m \u001b[0mtime\u001b[0m\u001b[1;33m.\u001b[0m\u001b[0msleep\u001b[0m\u001b[1;33m(\u001b[0m\u001b[1;36m0.01\u001b[0m\u001b[1;33m)\u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33m\u001b[0m\u001b[0m\n\u001b[1;32m----> <a href='file://d:\\Training\\SnakePython\\manualTestFile.py?line=25'>26</a>\u001b[1;33m \u001b[1;32mraise\u001b[0m \u001b[0mException\u001b[0m\u001b[1;33m(\u001b[0m\u001b[1;34m'spam'\u001b[0m\u001b[1;33m)\u001b[0m\u001b[1;33m\u001b[0m\u001b[1;33m\u001b[0m\u001b[0m\n\u001b[0m`,
57+
'\u001b[1;31mException\u001b[0m: spam'
58+
];
59+
const file2 = 'd:\\Training\\SnakePython\\manualTestFile.py';
60+
assert.equal(after2.join('\n'), modifyTraceback(file2, file2, 20, trace2).join('\n'), 'Exception failure');
61+
const trace3 = [
62+
'\u001b[0;31m---------------------------------------------------------------------------\u001b[0m',
63+
'\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)',
64+
'\u001b[0;32m~/Test/manualTestFile.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 4\u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mnumpy\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 5\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mpandas\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mpd\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 6\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mmatplotlib\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpyplot\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mplt\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n',
65+
"\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'numpy'"
66+
];
67+
const after3 = [
68+
'\u001b[0;31m---------------------------------------------------------------------------\u001b[0m',
69+
'\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)',
70+
"\u001b[0;32m~/Test/manualTestFile.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> <a href='file:///home/rich/Test/manualTestFile.py?line=24'>25</a>\u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mnumpy\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m <a href='file:///home/rich/Test/manualTestFile.py?line=25'>26</a>\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mpandas\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mpd\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m <a href='file:///home/rich/Test/manualTestFile.py?line=26'>27</a>\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mmatplotlib\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpyplot\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mplt\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
71+
"\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'numpy'"
72+
];
73+
const file3 = '/home/rich/Test/manualTestFile.py';
74+
const display3 = '~/Test/manualTestFile.py';
75+
assert.equal(after3.join('\n'), modifyTraceback(file3, display3, 20, trace3).join('\n'), 'Exception unix failure');
76+
});
3677
});

0 commit comments

Comments
 (0)