refactor(terminal): extract common link related code to terminal-link.ts (#4549)

1. Move regex patterns and constants to terminal-link.ts
2. Create ILinkInfo interface for link information
3. Extract extractLineInfoFromMatch function to common module
4. Update imports and usages in related files
5. Remove duplicate code in link providers
This commit is contained in:
大表哥 2025-05-20 16:37:42 +08:00 committed by GitHub
parent 473f45dbc3
commit d6d172bca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 339 additions and 101 deletions

View File

@ -286,4 +286,79 @@ describe('Workbench - TerminalValidatedLocalLinkProvider', () => {
},
]);
});
describe('Special formats', () => {
const isWindows = false;
test('Python error output format', async () => {
await assertLink(' File "/path/to/file.py", line 3, in test_func', isWindows, [
{
text: '/path/to/file.py", line 3',
range: [
[9, 1],
[33, 1],
],
},
]);
await assertLink(' File "/absolute/path/script.py", line 123, column 45', isWindows, [
{
text: '/absolute/path/script.py", line 123, column 45',
range: [
[11, 1],
[56, 1],
],
},
]);
});
test('File paths with line and column numbers', async () => {
await assertLink('/path/to/file.py:2, column 2', isWindows, [
{
text: '/path/to/file.py:2',
range: [
[1, 1],
[18, 1],
],
},
]);
await assertLink('/path/to/file.py:2, col 2', isWindows, [
{
text: '/path/to/file.py:2',
range: [
[1, 1],
[18, 1],
],
},
]);
await assertLink('/path/to/file.py:2:3', isWindows, [
{
text: '/path/to/file.py:2:3',
range: [
[1, 1],
[20, 1],
],
},
]);
});
test('Multiple formats in one line', async () => {
await assertLink('Error in /path/to/file.py:2, column 2 and /another/file.js:5:6', isWindows, [
{
text: '/path/to/file.py:2',
range: [
[10, 1],
[27, 1],
],
},
{
text: '/another/file.js:5:6',
range: [
[43, 1],
[62, 1],
],
},
]);
});
});
});

View File

@ -25,22 +25,21 @@ import {
ITerminalHoverManagerService,
ITerminalService,
} from '../../common';
import {
ILinkInfo,
extractLineInfoFromMatch,
getLineAndColumnClause,
unixLocalLinkClause,
winDrivePrefix,
winLocalLinkClause,
} from '../../common/terminal-link';
import { XTermCore } from '../../common/xterm-private';
import { TerminalClient } from '../terminal.client';
import { TerminalExternalLinkProviderAdapter } from './external-link-provider-adapter';
import { TerminalLink } from './link';
import { TerminalProtocolLinkProvider } from './protocol-link-provider';
import {
TerminalValidatedLocalLinkProvider,
lineAndColumnClause,
lineAndColumnClauseGroupCount,
unixLineAndColumnMatchIndex,
unixLocalLinkClause,
winDrivePrefix,
winLineAndColumnMatchIndex,
winLocalLinkClause,
} from './validated-local-link-provider';
import { TerminalValidatedLocalLinkProvider } from './validated-local-link-provider';
import { TerminalWordLinkProvider } from './word-link-provider';
const { posix, win32 } = path;
@ -249,23 +248,22 @@ export class TerminalLinkManager extends Disposable {
protected get _localLinkRegex(): RegExp {
const baseLocalLinkClause = this._client.os === OperatingSystem.Windows ? winLocalLinkClause : unixLocalLinkClause;
// Append line and column number regex
return new RegExp(`${baseLocalLinkClause}(${lineAndColumnClause})`);
return new RegExp(`${baseLocalLinkClause}(${getLineAndColumnClause()})`);
}
private async _handleLocalLink(link: string): Promise<void> {
// TODO: This gets resolved again but doesn't need to as it's already validated
const resolvedLink = await this._resolvePath(link);
const lineColumnInfo: LineColumnInfo = this.extractLineColumnInfo(link);
const resolvedLink = await this._resolvePath(lineColumnInfo.filePath);
if (!resolvedLink) {
return;
}
const lineColumnInfo: LineColumnInfo = this.extractLineColumnInfo(link);
const range: ITextEditorSelection = {
startLineNumber: lineColumnInfo.lineNumber,
endLineNumber: lineColumnInfo.lineNumber,
startColumn: lineColumnInfo.columnNumber,
endColumn: lineColumnInfo.columnNumber,
};
await this._editorService.open(resolvedLink.uri, { range });
await this._editorService.open(resolvedLink.uri, { range, focus: true });
}
private _handleHypertextLink(url: string): void {
@ -399,39 +397,31 @@ export class TerminalLinkManager extends Disposable {
}
}
private _extractLineInfoFromMatch(match: RegExpExecArray): ILinkInfo {
return extractLineInfoFromMatch(match);
}
/**
* Returns line and column number of URl if that is present.
*
* @param link Url link which may contain line and column number.
*/
public extractLineColumnInfo(link: string): LineColumnInfo {
const matches: string[] | null = this._localLinkRegex.exec(link);
const matches: RegExpExecArray | null = this._localLinkRegex.exec(link);
const lineColumnInfo: LineColumnInfo = {
lineNumber: 1,
columnNumber: 1,
filePath: link,
};
if (!matches) {
return lineColumnInfo;
}
const lineAndColumnMatchIndex =
this._client.os === OperatingSystem.Windows ? winLineAndColumnMatchIndex : unixLineAndColumnMatchIndex;
for (let i = 0; i < lineAndColumnClause.length; i++) {
const lineMatchIndex = lineAndColumnMatchIndex + lineAndColumnClauseGroupCount * i;
const rowNumber = matches[lineMatchIndex];
if (rowNumber) {
lineColumnInfo['lineNumber'] = parseInt(rowNumber, 10);
// Check if column number exists
const columnNumber = matches[lineMatchIndex + 2];
if (columnNumber) {
lineColumnInfo['columnNumber'] = parseInt(columnNumber, 10);
}
break;
}
}
return lineColumnInfo;
const lineInfo = this._extractLineInfoFromMatch(matches);
return {
filePath: lineInfo.filePath ?? link,
lineNumber: lineInfo.line ?? 1,
columnNumber: lineInfo.column ?? 1,
};
}
/**
@ -449,6 +439,7 @@ export class TerminalLinkManager extends Disposable {
}
export interface LineColumnInfo {
filePath: string;
lineNumber: number;
columnNumber: number;
}

View File

@ -43,7 +43,17 @@ export class TerminalLink extends Disposable implements ILink {
public readonly range: IBufferRange,
public readonly text: string,
private readonly _viewportY: number,
private readonly _activateCallback: (event: MouseEvent | undefined, uri: string) => void,
private readonly _activateCallback: (
event: MouseEvent | undefined,
uri: string,
lineInfo?: {
filePath?: string;
line?: number;
column?: number;
endLine?: number;
endColumn?: number;
},
) => void,
private readonly _tooltipCallback: (
link: TerminalLink,
viewportRange: IViewportRange,
@ -52,6 +62,13 @@ export class TerminalLink extends Disposable implements ILink {
) => IDisposable | undefined,
private readonly _isHighConfidenceLink: boolean,
readonly label: string | undefined,
private readonly _lineInfo?: {
filePath?: string;
line?: number;
column?: number;
endLine?: number;
endColumn?: number;
},
) {
super();
this.decorations = {
@ -71,7 +88,7 @@ export class TerminalLink extends Disposable implements ILink {
}
activate(event: MouseEvent | undefined, text: string): void {
this._activateCallback(event, text);
this._activateCallback(event, text, this._lineInfo);
}
hover(event: MouseEvent, text: string): void {

View File

@ -7,9 +7,18 @@
import { Autowired, INJECTOR_TOKEN, Injectable, Injector } from '@opensumi/di';
import { AppConfig } from '@opensumi/ide-core-browser/lib/react-providers/config-provider';
import { IWindowService } from '@opensumi/ide-core-browser/lib/window';
import { CommandService, IDisposable, OperatingSystem, URI } from '@opensumi/ide-core-common';
import { CommandService, FileUri, IDisposable, OperatingSystem, URI } from '@opensumi/ide-core-common';
import { IWorkspaceService } from '@opensumi/ide-workspace/lib/common/workspace.interface';
import {
ILinkInfo,
MAX_LENGTH,
extractLineInfoFromMatch,
getLineAndColumnClause,
unixLocalLinkClause,
winLocalLinkClause,
} from '../../common/terminal-link';
import { TerminalBaseLinkProvider } from './base';
import { convertLinkRangeToBuffer, getXtermLineContent } from './helpers';
import { FOLDER_IN_WORKSPACE_LABEL, FOLDER_NOT_IN_WORKSPACE_LABEL, OPEN_FILE_LABEL, TerminalLink } from './link';
@ -18,61 +27,6 @@ import { XtermLinkMatcherHandler } from './link-manager';
import type { TerminalClient } from '../terminal.client';
import type { IBufferLine, IViewportRange, Terminal } from '@xterm/xterm';
const pathPrefix = '(\\.\\.?|\\~)';
const pathSeparatorClause = '\\/';
// '":; are allowed in paths but they are often separators so ignore them
// Also disallow \\ to prevent a catastropic backtracking case #24795
const excludedPathCharactersClause = '[^\\0\\s!`&*()\\[\\]\'":;\\\\]';
/** A regex that matches paths in the form /foo, ~/foo, ./foo, ../foo, foo/bar */
export const unixLocalLinkClause =
'((' +
pathPrefix +
'|(' +
excludedPathCharactersClause +
')+)?(' +
pathSeparatorClause +
'(' +
excludedPathCharactersClause +
')+)+)';
export const winDrivePrefix = '(?:\\\\\\\\\\?\\\\)?[a-zA-Z]:';
const winPathPrefix = '(' + winDrivePrefix + '|\\.\\.?|\\~)';
const winPathSeparatorClause = '(\\\\|\\/)';
const winExcludedPathCharactersClause = '[^\\0<>\\?\\|\\/\\s!`&*()\\[\\]\'":;]';
/** A regex that matches paths in the form \\?\c:\foo c:\foo, ~\foo, .\foo, ..\foo, foo\bar */
export const winLocalLinkClause =
'((' +
winPathPrefix +
'|(' +
winExcludedPathCharactersClause +
')+)?(' +
winPathSeparatorClause +
'(' +
winExcludedPathCharactersClause +
')+)+)';
/** As xterm reads from DOM, space in that case is nonbreaking char ASCII code - 160,
replacing space with nonBreakningSpace or space ASCII code - 32. */
export const lineAndColumnClause = [
'((\\S*)[\'"], line ((\\d+)( column (\\d+))?))', // "(file path)", line 45 [see #40468]
'((\\S*)[\'"],((\\d+)(:(\\d+))?))', // "(file path)",45 [see #78205]
'((\\S*) on line ((\\d+)(, column (\\d+))?))', // (file path) on line 8, column 13
'((\\S*):line ((\\d+)(, column (\\d+))?))', // (file path):line 8, column 13
'(([^\\s\\(\\)]*)(\\s?[\\(\\[](\\d+)(,\\s?(\\d+))?)[\\)\\]])', // (file path)(45), (file path) (45), (file path)(45,18), (file path) (45,18), (file path)(45, 18), (file path) (45, 18), also with []
'(([^:\\s\\(\\)<>\'"\\[\\]]*)(:(\\d+))?(:(\\d+))?)', // (file path):336, (file path):336:9
]
.join('|')
.replace(/ /g, `[${'\u00A0'} ]`);
// Changing any regex may effect this value, hence changes this as well if required.
export const winLineAndColumnMatchIndex = 12;
export const unixLineAndColumnMatchIndex = 11;
// Each line and column clause have 6 groups (ie no. of expressions in round brackets)
export const lineAndColumnClauseGroupCount = 6;
const MAX_LENGTH = 2000;
@Injectable({ multiple: true })
export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider {
@Autowired(INJECTOR_TOKEN)
@ -168,7 +122,9 @@ export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider
link = link.substring(2);
stringIndex += 2;
}
const validatedLinks = await this.detectLocalLink(link, lines, startLine, stringIndex, 1);
// 从匹配结果中提取行号信息
const lineInfo = this._extractLineInfoFromMatch(match);
const validatedLinks = await this.detectLocalLink(link, lines, startLine, stringIndex, 1, lineInfo);
if (validatedLinks.length > 0) {
result.push(...validatedLinks);
@ -178,16 +134,33 @@ export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider
return result;
}
private _extractLineInfoFromMatch(match: RegExpExecArray): ILinkInfo {
return extractLineInfoFromMatch(match);
}
protected get _localLinkRegex(): RegExp {
const baseLocalLinkClause = this._client.os === OperatingSystem.Windows ? winLocalLinkClause : unixLocalLinkClause;
// Append line and column number regex
return new RegExp(`(${baseLocalLinkClause})(${getLineAndColumnClause()})?`);
}
private async detectLocalLink(
text: string,
bufferLines: IBufferLine[],
startLine: number,
stringIndex: number,
offset,
lineInfo?: ILinkInfo,
) {
const result: TerminalLink[] = [];
const validatedLink = await new Promise<TerminalLink | undefined>((r) => {
this._validationCallback(text, async (result) => {
// 使用匹配到的文件路径
const filePath = text.match(this._localLinkRegex)?.[1] || text;
// 如果是 file:/// 协议,转换为本地路径
const localPath = filePath.startsWith('file://') ? FileUri.fsPath(URI.parse(filePath)) : filePath;
this._validationCallback(localPath, async (result) => {
if (result) {
const label = result.isDirectory
? (await this._isDirectoryInsideWorkspace(result.uri))
@ -201,6 +174,7 @@ export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider
this._activateFileCallback(event, text);
}
});
// Convert the link text's string index into a wrapped buffer range
const bufferRange = convertLinkRangeToBuffer(
bufferLines,
@ -213,12 +187,14 @@ export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider
},
startLine,
);
const tooltipCallback = (
link: TerminalLink,
viewportRange: IViewportRange,
modifierDownCallback?: () => void,
modifierUpCallback?: () => void,
) => this._tooltipCallback(link, viewportRange, modifierDownCallback, modifierUpCallback);
r(
this.injector.get(TerminalLink, [
this._xterm,
@ -229,6 +205,7 @@ export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider
tooltipCallback,
true,
label,
lineInfo,
]),
);
} else {
@ -236,18 +213,13 @@ export class TerminalValidatedLocalLinkProvider extends TerminalBaseLinkProvider
}
});
});
if (validatedLink) {
result.push(validatedLink);
}
return result;
}
protected get _localLinkRegex(): RegExp {
const baseLocalLinkClause = this._client.os === OperatingSystem.Windows ? winLocalLinkClause : unixLocalLinkClause;
// Append line and column number regex
return new RegExp(`${baseLocalLinkClause}(${lineAndColumnClause})`);
}
private async _handleLocalFolderLink(uri: URI): Promise<void> {
// If the folder is within one of the window's workspaces, focus it in the explorer
if (await this._isDirectoryInsideWorkspace(uri)) {

View File

@ -0,0 +1,183 @@
/* ---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// 行号和列号的正则生成函数
class LinkMatchCounters {
private ri = 0;
private ci = 0;
private rei = 0;
private cei = 0;
r(): string {
return `(?<row${this.ri++}>\\d+)`;
}
c(): string {
return `(?<col${this.ci++}>\\d+)`;
}
re(): string {
return `(?<rowEnd${this.rei++}>\\d+)`;
}
ce(): string {
return `(?<colEnd${this.cei++}>\\d+)`;
}
}
// 路径相关的正则表达式
export const pathPrefix = '(\\.\\.?|\\~)';
export const pathSeparatorClause = '\\/';
export const excludedPathCharactersClause = '[^\\0\\s!`&*()\\[\\]\'":;\\\\]';
export const winDrivePrefix = '(?:\\\\\\\\\\?\\\\)?[a-zA-Z]:';
export const winPathPrefix = '(' + winDrivePrefix + '|\\.\\.?|\\~)';
export const winPathSeparatorClause = '(\\\\|\\/)';
export const winExcludedPathCharactersClause = '[^\\0<>\\?\\|\\/\\s!`&*()\\[\\]\'":;]';
// Unix 和 Windows 的本地链接正则表达式
export const unixLocalLinkClause =
'((' +
pathPrefix +
'|(' +
excludedPathCharactersClause +
')+)?(' +
pathSeparatorClause +
'(' +
excludedPathCharactersClause +
')+)+)';
export const winLocalLinkClause =
'((' +
winPathPrefix +
'|(' +
winExcludedPathCharactersClause +
')+)?(' +
winPathSeparatorClause +
'(' +
winExcludedPathCharactersClause +
')+)+)';
// 行号和列号的匹配正则表达式
const eolSuffix = '';
/** As xterm reads from DOM, space in that case is nonbreaking char ASCII code - 160,
replacing space with nonBreakningSpace or space ASCII code - 32. */
export function getLineAndColumnClause(): string {
const counters = new LinkMatchCounters();
return [
// foo:339
// foo:339:12
// foo:339:12-789
// foo:339:12-341.789
// foo:339.12
// foo 339
// foo 339:12 [#140780]
// foo 339.12
// foo#339
// foo#339:12 [#190288]
// foo#339.12
// foo, 339 [#217927]
// "foo",339
// "foo",339:12
// "foo",339.12
// "foo",339.12-789
// "foo",339.12-341.789
`(?::|#| |['"],|, )${counters.r()}([:.]${counters.c()}(?:-(?:${counters.re()}\\.)?${counters.ce()})?)?` + eolSuffix,
// The quotes below are optional [#171652]
// "foo", line 339 [#40468]
// "foo", line 339, col 12
// "foo", line 339, column 12
// "foo":line 339
// "foo":line 339, col 12
// "foo":line 339, column 12
// "foo": line 339
// "foo": line 339, col 12
// "foo": line 339, column 12
// "foo" on line 339
// "foo" on line 339, col 12
// "foo" on line 339, column 12
// "foo" line 339 column 12
// "foo", line 339, character 12 [#171880]
// "foo", line 339, characters 12-789 [#171880]
// "foo", lines 339-341 [#171880]
// "foo", lines 339-341, characters 12-789 [#178287]
`['"]?(?:,? |: ?| on )lines? ${counters.r()}(?:-${counters.re()})?(?:,? (?:col(?:umn)?|characters?) ${counters.c()}(?:-${counters.ce()})?)?` +
eolSuffix,
// () and [] are interchangeable
// foo(339)
// foo(339,12)
// foo(339, 12)
// foo (339)
// foo (339,12)
// foo (339, 12)
// foo: (339)
// foo: (339,12)
// foo: (339, 12)
// foo(339:12) [#229842]
// foo (339:12) [#229842]
`:? ?[\\[\\(]${counters.r()}(?:(?:, ?|:)${counters.c()})?[\\]\\)]` + eolSuffix,
]
.join('|')
.replace(/ /g, `[${'\u00A0'} ]`);
}
// 行号和列号匹配的索引
export const winLineAndColumnMatchIndex = 12;
export const unixLineAndColumnMatchIndex = 11;
// 行号和列号子句的组数
export const lineAndColumnClauseGroupCount = 6;
// 链接信息接口
export interface ILinkInfo {
filePath?: string;
line?: number;
column?: number;
endLine?: number;
endColumn?: number;
}
// 最大长度限制
export const MAX_LENGTH = 2000;
/**
*
*/
export function extractLineInfoFromMatch(match: RegExpExecArray): ILinkInfo {
const result: any = {};
// 提取文件路径(第一个捕获组)
if (match[1]) {
result.filePath = match[1];
}
// 提取命名捕获组中的行号信息
if (match.groups) {
for (const [key, value] of Object.entries(match.groups)) {
if (!value) {
continue;
}
if (key.startsWith('row')) {
const index = key.replace('row', '');
if (index === 'End') {
result.endLine = parseInt(value, 10);
} else {
result.line = parseInt(value, 10);
}
} else if (key.startsWith('col')) {
const index = key.replace('col', '');
if (index === 'End') {
result.endColumn = parseInt(value, 10);
} else {
result.column = parseInt(value, 10);
}
}
}
}
return result;
}