From 5e054f695861d42eb95c515dea230be5cf4581c5 Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Fri, 31 Oct 2025 16:31:41 -0700 Subject: [PATCH 1/9] linkify line numbers to file names --- .../linkify/common/filePathLinkifier.ts | 8 +- .../linkify/common/lineAnnotationParser.ts | 83 ++++++++++++++++ src/extension/linkify/common/linkifier.ts | 87 ++++++++++++++++- .../linkify/common/linkifyService.ts | 1 + .../test/node/filePathLinkifier.spec.ts | 95 +++++++++++++++++++ src/extension/linkify/test/node/util.ts | 9 +- 6 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 src/extension/linkify/common/lineAnnotationParser.ts diff --git a/src/extension/linkify/common/filePathLinkifier.ts b/src/extension/linkify/common/filePathLinkifier.ts index 1571f2c53e..bff6391491 100644 --- a/src/extension/linkify/common/filePathLinkifier.ts +++ b/src/extension/linkify/common/filePathLinkifier.ts @@ -79,7 +79,13 @@ export class FilePathLinkifier implements IContributedLinkifier { pathText ??= match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? ''; parts.push(this.resolvePathText(pathText, context) - .then(uri => uri ? new LinkifyLocationAnchor(uri) : matched)); + .then(uri => { + if (!uri) { + return matched; + } + // Do not try to parse line annotations here; central parser & buffer will upgrade later. + return new LinkifyLocationAnchor(uri); + })); endLastMatch = match.index + matched.length; } diff --git a/src/extension/linkify/common/lineAnnotationParser.ts b/src/extension/linkify/common/lineAnnotationParser.ts new file mode 100644 index 0000000000..6bfbcedf6b --- /dev/null +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// General, extensible parser for line annotations following a file path. +// Keeps logic maintainable by avoiding a monolithic fragile regex. + +const RANGE_CONNECTORS = new Set(['-', '–', '—', 'to', 'through', 'thru']); +const LINE_TOKENS = new Set(['line', 'lines', 'ln', 'l']); + +export interface ParsedLineAnnotation { + readonly startLine: number; // zero-based + readonly raw: string; // raw matched snippet +} + +const parenRe = /^\s*\((lines?)\s+(\d+)(?:\s*([–—-]|to|through|thru)\s*(\d+))?\)/i; + +export function parseLineNumberAnnotation(text: string, maxScan = 160): ParsedLineAnnotation | undefined { + if (!text) { return undefined; } + const slice = text.slice(0, maxScan); + const pm = parenRe.exec(slice); + if (pm) { + const line = toLine(pm[2]); + if (line !== undefined) { + return { startLine: line, raw: pm[0] }; + } + } + const tokenRe = /[A-Za-z]+|\d+|[–—-]/g; + const tokens: string[] = []; + let m: RegExpExecArray | null; + while ((m = tokenRe.exec(slice))) { + tokens.push(m[0]); + if (tokens.length > 40) { break; } + } + for (let i = 0; i < tokens.length; i++) { + const tk = tokens[i].toLowerCase(); + if (LINE_TOKENS.has(tk)) { + const numToken = tokens[i + 1]; + if (numToken && /^\d+$/.test(numToken)) { + const line = toLine(numToken); + if (line === undefined) { continue; } + const maybeConnector = tokens[i + 2]?.toLowerCase(); + if (maybeConnector && RANGE_CONNECTORS.has(maybeConnector)) { + const secondNum = tokens[i + 3]; + if (!secondNum || !/^\d+$/.test(secondNum)) { + return { startLine: line, raw: reconstruct(tokens, i, 4) }; + } + return { startLine: line, raw: reconstruct(tokens, i, 4) }; + } + return { startLine: line, raw: reconstruct(tokens, i, 2) }; + } + } + } + return undefined; +} + +function toLine(token: string): number | undefined { + const n = parseInt(token, 10); + return isNaN(n) || n <= 0 ? undefined : n - 1; +} + +function reconstruct(tokens: string[], start: number, span: number): string { + return tokens.slice(start, start + span).join(' '); +} + +if (typeof process !== 'undefined' && process.env?.VITEST_INTERNAL_EVAL === 'lineAnnotationParserDev') { + const samples = [ + '(line 42)', + '(lines 10-15)', + 'is located at lines 77–85.', + 'is found at lines 5-9', + 'is at lines 6 through 11', + 'on line 120', + 'at line 33', + 'lines 44-50', + 'Ln 22', + 'line 9' + ]; + for (const s of samples) { + console.log('ANNOT_SAMPLE', s, parseLineNumberAnnotation(s)); + } +} diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index d8efc323a7..bc3c9d45fc 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -6,7 +6,9 @@ import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors'; import { escapeRegExpCharacters } from '../../../util/vs/base/common/strings'; -import { LinkifiedPart, LinkifiedText, coalesceParts } from './linkifiedText'; +import { Location, Position, Range } from '../../../vscodeTypes'; +import { parseLineNumberAnnotation } from './lineAnnotationParser'; +import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText'; import type { IContributedLinkifier, ILinkifier, LinkifierContext } from './linkifyService'; namespace LinkifierState { @@ -67,6 +69,13 @@ export class Linkifier implements ILinkifier { private _totalAddedLinkCount = 0; + // Buffer used to delay emitting a single file anchor until we either + // detect a line annotation or exceed buffering heuristics. + private _delayedAnchorBuffer: { anchor: LinkifyLocationAnchor; afterText: string; totalChars: number } | undefined; + + private static readonly maxAnchorBuffer = 140; // chars of following text to wait for annotation + private static readonly flushTerminatorsRe = /[\.?!]\s*$|\n/; // punctuation or newline suggests end of sentence + constructor( private readonly context: LinkifierContext, private readonly productUriScheme: string, @@ -210,12 +219,30 @@ export class Linkifier implements ILinkifier { } } } - return { parts: coalesceParts(out) }; + // Coalesce adjacent string parts first so upgrade regex sees complete annotation text + // If we are still accumulating a word (end of input chunk), finalize it so annotations like 'lines 77–85.' are present. + if (this._state.type === LinkifierState.Type.Accumulating && this._state.accumulationType === LinkifierState.AccumulationType.Word) { + const pending = this._state.pendingText; + this._state = LinkifierState.Default; + if (pending.length) { + const r = await this.doLinkifyAndAppend(pending, {}, token); + out.push(...r.parts); + } + } + + const coalesced = coalesceParts(out); + + return { parts: this.processCoalescedParts(coalesced) }; } async flush(token: CancellationToken): Promise { let out: LinkifiedText | undefined; + // Flush any buffered anchor before finalizing + if (this._delayedAnchorBuffer) { + out = { parts: this.flushAnchorBuffer() }; + } + switch (this._state.type) { case LinkifierState.Type.CodeOrMathBlock: { out = { parts: [this.doAppend(this._state.contents)] }; @@ -305,4 +332,60 @@ export class Linkifier implements ILinkifier { } return out; } + + // --- Simplified buffering helpers --- + + private processCoalescedParts(parts: readonly LinkifiedPart[]): LinkifiedPart[] { + const emit: LinkifiedPart[] = []; + for (const part of parts) { + if (part instanceof LinkifyLocationAnchor) { + const value: any = part.value; + if (value && value.range) { // already has line info + emit.push(part); + continue; + } + if (this._delayedAnchorBuffer) { + emit.push(...this.flushAnchorBuffer()); + } + this._delayedAnchorBuffer = { anchor: part, afterText: '', totalChars: 0 }; + if (typeof process !== 'undefined' && process.env?.VITEST) { + console.log('[Linkifier.buffer] Started buffering anchor', { uri: String(value) }); + } + continue; + } + if (this._delayedAnchorBuffer && typeof part === 'string') { + this._delayedAnchorBuffer.afterText += part; + this._delayedAnchorBuffer.totalChars += part.length; + if (this.shouldFlushCurrentBuffer()) { + emit.push(...this.flushAnchorBuffer()); + } + continue; + } + emit.push(part); + } + return emit; + } + + private shouldFlushCurrentBuffer(): boolean { + const b = this._delayedAnchorBuffer; + if (!b) { return false; } + return Linkifier.flushTerminatorsRe.test(b.afterText) + || b.totalChars > Linkifier.maxAnchorBuffer + || !!parseLineNumberAnnotation(b.afterText); + } + + private flushAnchorBuffer(): LinkifiedPart[] { + if (!this._delayedAnchorBuffer) { return []; } + const { anchor, afterText } = this._delayedAnchorBuffer; + let resultAnchor: LinkifyLocationAnchor = anchor; + const parsed = parseLineNumberAnnotation(afterText); + if (parsed) { + resultAnchor = new LinkifyLocationAnchor({ uri: anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); + if (typeof process !== 'undefined' && process.env?.VITEST) { + console.log('[Linkifier.buffer] Upgraded buffered anchor', { uri: String(anchor.value), lineNumber: parsed.startLine + 1 }); + } + } + this._delayedAnchorBuffer = undefined; + return afterText.length > 0 ? [resultAnchor, afterText] : [resultAnchor]; + } } diff --git a/src/extension/linkify/common/linkifyService.ts b/src/extension/linkify/common/linkifyService.ts index dfaa18bc3f..921fec3fe6 100644 --- a/src/extension/linkify/common/linkifyService.ts +++ b/src/extension/linkify/common/linkifyService.ts @@ -86,6 +86,7 @@ export class LinkifyService implements ILinkifyService { @IWorkspaceService workspaceService: IWorkspaceService, @IEnvService private readonly envService: IEnvService, ) { + // Single file path linkifier now handles line number annotations inline this.registerGlobalLinkifier({ create: () => new FilePathLinkifier(fileSystem, workspaceService) }); } diff --git a/src/extension/linkify/test/node/filePathLinkifier.spec.ts b/src/extension/linkify/test/node/filePathLinkifier.spec.ts index 213918d859..0c95b18e69 100644 --- a/src/extension/linkify/test/node/filePathLinkifier.spec.ts +++ b/src/extension/linkify/test/node/filePathLinkifier.spec.ts @@ -6,6 +6,7 @@ import { suite, test } from 'vitest'; import { isWindows } from '../../../../util/vs/base/common/platform'; import { URI } from '../../../../util/vs/base/common/uri'; +import { Location, Position, Range } from '../../../../vscodeTypes'; import { LinkifyLocationAnchor } from '../../common/linkifiedText'; import { assertPartsEqual, createTestLinkifierService, linkify, workspaceFile } from './util'; @@ -254,4 +255,98 @@ suite('File Path Linkifier', () => { ], ); }); + + test(`Should create file link with single line annotation`, async () => { + const linkifier = createTestLinkifierService( + 'inspectdb.py' + ); + + const result = await linkify(linkifier, + 'inspectdb.py (line 340) - The primary usage.' + ); + + assertPartsEqual( + result.parts, + [ + new LinkifyLocationAnchor({ uri: workspaceFile('inspectdb.py'), range: new Range(new Position(339, 0), new Position(339, 0)) } as Location), + ' (line 340) - The primary usage.' + ] + ); + }); + + test(`Should create file link with multi line annotation (range)`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The return statement in exampleScript.ts is located at lines 77–85.' + ); + + assertPartsEqual( + result.parts, + [ + 'The return statement in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(76, 0), new Position(76, 0)) } as Location), + ' is located at lines 77–85.' + ] + ); + }); + + test(`Should create file link with multi line annotation using 'found' phrase`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The return statement for the createScenarioFromLogContext function in exampleScript.ts is found at lines 76–82.' + ); + + assertPartsEqual( + result.parts, + [ + 'The return statement for the createScenarioFromLogContext function in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(75, 0), new Position(75, 0)) } as Location), + ' is found at lines 76–82.' + ] + ); + }); + + test(`Should create file link with multi line annotation using 'at' phrase`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The return statement for the createScenarioFromLogContext function in exampleScript.ts is at lines 76–82.' + ); + + assertPartsEqual( + result.parts, + [ + 'The return statement for the createScenarioFromLogContext function in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(75, 0), new Position(75, 0)) } as Location), + ' is at lines 76–82.' + ] + ); + }); + + test(`Should create file link with multi line annotation using hyphen range`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The return statement for the createScenarioFromLogContext function in exampleScript.ts is at lines 76-83.' + ); + + assertPartsEqual( + result.parts, + [ + 'The return statement for the createScenarioFromLogContext function in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(75, 0), new Position(75, 0)) } as Location), + ' is at lines 76-83.' + ] + ); + }); }); diff --git a/src/extension/linkify/test/node/util.ts b/src/extension/linkify/test/node/util.ts index fd6950cfe9..5f99cce924 100644 --- a/src/extension/linkify/test/node/util.ts +++ b/src/extension/linkify/test/node/util.ts @@ -78,7 +78,14 @@ export function assertPartsEqual(actualParts: readonly LinkifiedPart[], expected assert.strictEqual(actual, expected); } else if (actual instanceof LinkifyLocationAnchor) { assert(expected instanceof LinkifyLocationAnchor, "Expected LinkifyLocationAnchor"); - assert.strictEqual(actual.value.toString(), expected.value.toString()); + const actualVal: any = actual.value as any; + const expectedVal: any = expected.value as any; + if (actualVal && expectedVal && 'range' in actualVal && 'range' in expectedVal && 'uri' in actualVal && 'uri' in expectedVal) { + assert.strictEqual(actualVal.uri.toString(), expectedVal.uri.toString()); + assert.strictEqual(actualVal.range.start.line, expectedVal.range.start.line); + } else { + assert.strictEqual(actual.value.toString(), expected.value.toString()); + } } else { assert(actual instanceof LinkifySymbolAnchor); assert(expected instanceof LinkifySymbolAnchor, "Expected LinkifySymbolAnchor"); From a2f408b3cc620371c70d1e6893313d5e670f0085 Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Fri, 31 Oct 2025 17:46:51 -0700 Subject: [PATCH 2/9] Few simplifications --- .../linkify/common/lineAnnotationParser.ts | 37 +++++- src/extension/linkify/common/linkifier.ts | 10 +- .../test/node/filePathLinkifier.spec.ts | 112 ++++++++++++++++++ src/extension/linkify/test/node/util.ts | 7 +- 4 files changed, 150 insertions(+), 16 deletions(-) diff --git a/src/extension/linkify/common/lineAnnotationParser.ts b/src/extension/linkify/common/lineAnnotationParser.ts index 6bfbcedf6b..01592a99e0 100644 --- a/src/extension/linkify/common/lineAnnotationParser.ts +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -4,7 +4,36 @@ *--------------------------------------------------------------------------------------------*/ // General, extensible parser for line annotations following a file path. -// Keeps logic maintainable by avoiding a monolithic fragile regex. +// Supported patterns (single-line anchor uses first line in range): +// +// Parenthesized forms: +// (line 42) +// (lines 10-12) (hyphen / en/em dash / through|thru|to connectors) +// +// Prose forms (any preceding words ignored; we scan tokens): +// on line 45 +// at line 33 +// line 9 +// lines 3 to 7 +// lines 5 through 9 +// lines 6–11 +// Ln 22 / ln 22 / l 22 +// is located at lines 77–85 +// is found at lines 5-9 +// is at lines 6 through 11 +// +// We intentionally only expose the start line (zero-based) because downstream +// logic currently navigates to a single line even if a range was referenced. +// Extending to full range selection would involve carrying an endLine as well. +// +// Design notes: +// - Token-based approach avoids brittle giant regex and makes future synonym +// additions trivial (expand LINE_TOKENS or RANGE_CONNECTORS sets). +// - Max scan limits ensure we do not waste time over very long trailing text. +// - We ignore invalid ranges like "lines 10 through" (missing second number) +// but still treat the first number as the target line. +// - Returned raw snippet is a lightweight reconstruction of matched tokens for +// potential future highlighting or telemetry. const RANGE_CONNECTORS = new Set(['-', '–', '—', 'to', 'through', 'thru']); const LINE_TOKENS = new Set(['line', 'lines', 'ln', 'l']); @@ -43,10 +72,8 @@ export function parseLineNumberAnnotation(text: string, maxScan = 160): ParsedLi const maybeConnector = tokens[i + 2]?.toLowerCase(); if (maybeConnector && RANGE_CONNECTORS.has(maybeConnector)) { const secondNum = tokens[i + 3]; - if (!secondNum || !/^\d+$/.test(secondNum)) { - return { startLine: line, raw: reconstruct(tokens, i, 4) }; - } - return { startLine: line, raw: reconstruct(tokens, i, 4) }; + const span = (secondNum && /^\d+$/.test(secondNum)) ? 4 : 3; + return { startLine: line, raw: reconstruct(tokens, i, span) }; } return { startLine: line, raw: reconstruct(tokens, i, 2) }; } diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index bc3c9d45fc..9c5941af17 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -339,8 +339,8 @@ export class Linkifier implements ILinkifier { const emit: LinkifiedPart[] = []; for (const part of parts) { if (part instanceof LinkifyLocationAnchor) { - const value: any = part.value; - if (value && value.range) { // already has line info + const value = part.value; + if (typeof value === 'object' && value !== null && 'range' in value) { // already has line info emit.push(part); continue; } @@ -348,9 +348,6 @@ export class Linkifier implements ILinkifier { emit.push(...this.flushAnchorBuffer()); } this._delayedAnchorBuffer = { anchor: part, afterText: '', totalChars: 0 }; - if (typeof process !== 'undefined' && process.env?.VITEST) { - console.log('[Linkifier.buffer] Started buffering anchor', { uri: String(value) }); - } continue; } if (this._delayedAnchorBuffer && typeof part === 'string') { @@ -381,9 +378,6 @@ export class Linkifier implements ILinkifier { const parsed = parseLineNumberAnnotation(afterText); if (parsed) { resultAnchor = new LinkifyLocationAnchor({ uri: anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); - if (typeof process !== 'undefined' && process.env?.VITEST) { - console.log('[Linkifier.buffer] Upgraded buffered anchor', { uri: String(anchor.value), lineNumber: parsed.startLine + 1 }); - } } this._delayedAnchorBuffer = undefined; return afterText.length > 0 ? [resultAnchor, afterText] : [resultAnchor]; diff --git a/src/extension/linkify/test/node/filePathLinkifier.spec.ts b/src/extension/linkify/test/node/filePathLinkifier.spec.ts index 0c95b18e69..02720bff61 100644 --- a/src/extension/linkify/test/node/filePathLinkifier.spec.ts +++ b/src/extension/linkify/test/node/filePathLinkifier.spec.ts @@ -349,4 +349,116 @@ suite('File Path Linkifier', () => { ] ); }); + + test(`Should create file link with parenthesized multi line annotation variant (lines 10-12)`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'exampleScript.ts (lines 10-12) reference' + ); + + assertPartsEqual( + result.parts, + [ + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(9, 0), new Position(9, 0)) } as Location), + ' (lines 10-12) reference' + ] + ); + }); + + test(`Should create file link with 'on line' prose variant`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The init logic in exampleScript.ts on line 45.' + ); + + assertPartsEqual( + result.parts, + [ + 'The init logic in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(44, 0), new Position(44, 0)) } as Location), + ' on line 45.' + ] + ); + }); + + test(`Should create file link with 'through' range connector`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'exampleScript.ts is at lines 5 through 9.' + ); + + assertPartsEqual( + result.parts, + [ + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(4, 0), new Position(4, 0)) } as Location), + ' is at lines 5 through 9.' + ] + ); + }); + + test(`Should create file link with 'to' range connector`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'This section in exampleScript.ts spans lines 3 to 7.' + ); + + assertPartsEqual( + result.parts, + [ + 'This section in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(2, 0), new Position(2, 0)) } as Location), + ' spans lines 3 to 7.' + ] + ); + }); + + test(`Should create file link with 'Ln' shorthand single line`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'Check exampleScript.ts Ln 22 for setup.' + ); + + assertPartsEqual( + result.parts, + [ + 'Check ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(21, 0), new Position(21, 0)) } as Location), + ' Ln 22 for setup.' + ] + ); + }); + + test(`Should not create file link for non-annotation word containing 'line' substring (deadline 30)`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'This exampleScript.ts deadline 30 is informational.' + ); + + assertPartsEqual( + result.parts, + [ + 'This ', + new LinkifyLocationAnchor(workspaceFile('exampleScript.ts')), + ' deadline 30 is informational.' + ] + ); + }); }); diff --git a/src/extension/linkify/test/node/util.ts b/src/extension/linkify/test/node/util.ts index 5f99cce924..70eda5ec00 100644 --- a/src/extension/linkify/test/node/util.ts +++ b/src/extension/linkify/test/node/util.ts @@ -78,9 +78,10 @@ export function assertPartsEqual(actualParts: readonly LinkifiedPart[], expected assert.strictEqual(actual, expected); } else if (actual instanceof LinkifyLocationAnchor) { assert(expected instanceof LinkifyLocationAnchor, "Expected LinkifyLocationAnchor"); - const actualVal: any = actual.value as any; - const expectedVal: any = expected.value as any; - if (actualVal && expectedVal && 'range' in actualVal && 'range' in expectedVal && 'uri' in actualVal && 'uri' in expectedVal) { + const actualVal = actual.value; + const expectedVal = expected.value; + if (typeof actualVal === 'object' && actualVal !== null && 'range' in actualVal && 'uri' in actualVal && + typeof expectedVal === 'object' && expectedVal !== null && 'range' in expectedVal && 'uri' in expectedVal) { assert.strictEqual(actualVal.uri.toString(), expectedVal.uri.toString()); assert.strictEqual(actualVal.range.start.line, expectedVal.range.start.line); } else { From e134dce4758643c1cd9ec412a05dfb8bea523bcc Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Fri, 31 Oct 2025 17:50:58 -0700 Subject: [PATCH 3/9] test update --- .../test/node/filePathLinkifier.spec.ts | 52 ++++++------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/src/extension/linkify/test/node/filePathLinkifier.spec.ts b/src/extension/linkify/test/node/filePathLinkifier.spec.ts index 02720bff61..722b6f2fa9 100644 --- a/src/extension/linkify/test/node/filePathLinkifier.spec.ts +++ b/src/extension/linkify/test/node/filePathLinkifier.spec.ts @@ -293,55 +293,43 @@ suite('File Path Linkifier', () => { ); }); - test(`Should create file link with multi line annotation using 'found' phrase`, async () => { + test(`Should create file link with multi line annotation using various phrases and dash types`, async () => { const linkifier = createTestLinkifierService( 'exampleScript.ts' ); - const result = await linkify(linkifier, + // Test 'found at' with en-dash + const result1 = await linkify(linkifier, 'The return statement for the createScenarioFromLogContext function in exampleScript.ts is found at lines 76–82.' ); - assertPartsEqual( - result.parts, + result1.parts, [ 'The return statement for the createScenarioFromLogContext function in ', new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(75, 0), new Position(75, 0)) } as Location), ' is found at lines 76–82.' ] ); - }); - - test(`Should create file link with multi line annotation using 'at' phrase`, async () => { - const linkifier = createTestLinkifierService( - 'exampleScript.ts' - ); - const result = await linkify(linkifier, + // Test 'is at' with en-dash + const result2 = await linkify(linkifier, 'The return statement for the createScenarioFromLogContext function in exampleScript.ts is at lines 76–82.' ); - assertPartsEqual( - result.parts, + result2.parts, [ 'The return statement for the createScenarioFromLogContext function in ', new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(75, 0), new Position(75, 0)) } as Location), ' is at lines 76–82.' ] ); - }); - - test(`Should create file link with multi line annotation using hyphen range`, async () => { - const linkifier = createTestLinkifierService( - 'exampleScript.ts' - ); - const result = await linkify(linkifier, + // Test 'is at' with hyphen + const result3 = await linkify(linkifier, 'The return statement for the createScenarioFromLogContext function in exampleScript.ts is at lines 76-83.' ); - assertPartsEqual( - result.parts, + result3.parts, [ 'The return statement for the createScenarioFromLogContext function in ', new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(75, 0), new Position(75, 0)) } as Location), @@ -387,35 +375,29 @@ suite('File Path Linkifier', () => { ); }); - test(`Should create file link with 'through' range connector`, async () => { + test(`Should create file link with range connectors ('through' and 'to')`, async () => { const linkifier = createTestLinkifierService( 'exampleScript.ts' ); - const result = await linkify(linkifier, + // Test 'through' connector + const result1 = await linkify(linkifier, 'exampleScript.ts is at lines 5 through 9.' ); - assertPartsEqual( - result.parts, + result1.parts, [ new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(4, 0), new Position(4, 0)) } as Location), ' is at lines 5 through 9.' ] ); - }); - test(`Should create file link with 'to' range connector`, async () => { - const linkifier = createTestLinkifierService( - 'exampleScript.ts' - ); - - const result = await linkify(linkifier, + // Test 'to' connector + const result2 = await linkify(linkifier, 'This section in exampleScript.ts spans lines 3 to 7.' ); - assertPartsEqual( - result.parts, + result2.parts, [ 'This section in ', new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(2, 0), new Position(2, 0)) } as Location), From 288d5e5b5a83944ab56181b3bfff6757680ddbcf Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Fri, 31 Oct 2025 19:04:37 -0700 Subject: [PATCH 4/9] minor updates --- .../linkify/common/filePathLinkifier.ts | 8 +------- .../linkify/common/lineAnnotationParser.ts | 18 ------------------ src/extension/linkify/common/linkifier.ts | 2 +- src/extension/linkify/common/linkifyService.ts | 1 - 4 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/extension/linkify/common/filePathLinkifier.ts b/src/extension/linkify/common/filePathLinkifier.ts index bff6391491..1571f2c53e 100644 --- a/src/extension/linkify/common/filePathLinkifier.ts +++ b/src/extension/linkify/common/filePathLinkifier.ts @@ -79,13 +79,7 @@ export class FilePathLinkifier implements IContributedLinkifier { pathText ??= match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? ''; parts.push(this.resolvePathText(pathText, context) - .then(uri => { - if (!uri) { - return matched; - } - // Do not try to parse line annotations here; central parser & buffer will upgrade later. - return new LinkifyLocationAnchor(uri); - })); + .then(uri => uri ? new LinkifyLocationAnchor(uri) : matched)); endLastMatch = match.index + matched.length; } diff --git a/src/extension/linkify/common/lineAnnotationParser.ts b/src/extension/linkify/common/lineAnnotationParser.ts index 01592a99e0..d62f9a2dd4 100644 --- a/src/extension/linkify/common/lineAnnotationParser.ts +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -90,21 +90,3 @@ function toLine(token: string): number | undefined { function reconstruct(tokens: string[], start: number, span: number): string { return tokens.slice(start, start + span).join(' '); } - -if (typeof process !== 'undefined' && process.env?.VITEST_INTERNAL_EVAL === 'lineAnnotationParserDev') { - const samples = [ - '(line 42)', - '(lines 10-15)', - 'is located at lines 77–85.', - 'is found at lines 5-9', - 'is at lines 6 through 11', - 'on line 120', - 'at line 33', - 'lines 44-50', - 'Ln 22', - 'line 9' - ]; - for (const s of samples) { - console.log('ANNOT_SAMPLE', s, parseLineNumberAnnotation(s)); - } -} diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index 9c5941af17..859a68d5e6 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -333,7 +333,7 @@ export class Linkifier implements ILinkifier { return out; } - // --- Simplified buffering helpers --- + // --- buffering helpers --- private processCoalescedParts(parts: readonly LinkifiedPart[]): LinkifiedPart[] { const emit: LinkifiedPart[] = []; diff --git a/src/extension/linkify/common/linkifyService.ts b/src/extension/linkify/common/linkifyService.ts index 921fec3fe6..dfaa18bc3f 100644 --- a/src/extension/linkify/common/linkifyService.ts +++ b/src/extension/linkify/common/linkifyService.ts @@ -86,7 +86,6 @@ export class LinkifyService implements ILinkifyService { @IWorkspaceService workspaceService: IWorkspaceService, @IEnvService private readonly envService: IEnvService, ) { - // Single file path linkifier now handles line number annotations inline this.registerGlobalLinkifier({ create: () => new FilePathLinkifier(fileSystem, workspaceService) }); } From a21bf987a8d0d6df00022989e9a6055058f152f8 Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Fri, 31 Oct 2025 19:05:01 -0700 Subject: [PATCH 5/9] minor updates --- .../linkify/common/filePathLinkifier.ts | 28 +++- .../linkify/common/lineAnnotationParser.ts | 28 +++- src/extension/linkify/common/linkifier.ts | 49 ++++++- .../test/node/filePathLinkifier.spec.ts | 134 ++++++++++++++++++ 4 files changed, 229 insertions(+), 10 deletions(-) diff --git a/src/extension/linkify/common/filePathLinkifier.ts b/src/extension/linkify/common/filePathLinkifier.ts index 1571f2c53e..a138b7228e 100644 --- a/src/extension/linkify/common/filePathLinkifier.ts +++ b/src/extension/linkify/common/filePathLinkifier.ts @@ -47,7 +47,7 @@ export class FilePathLinkifier implements IContributedLinkifier { ) { } async linkify(text: string, context: LinkifierContext, token: CancellationToken): Promise { - const parts: Array | LinkifiedPart> = []; + const parts: LinkifiedPart[] = []; let endLastMatch = 0; for (const match of text.matchAll(pathMatchRe)) { @@ -78,8 +78,28 @@ export class FilePathLinkifier implements IContributedLinkifier { } pathText ??= match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? ''; - parts.push(this.resolvePathText(pathText, context) - .then(uri => uri ? new LinkifyLocationAnchor(uri) : matched)); + + + // Determine path by truncating at the end of the file extension. + // This avoids relying on generic punctuation stripping and instead uses + // knowledge that a filename ends right after the extension token. + let trailing = ''; + let core = pathText; + // Split off trailing punctuation after a valid file extension (letters/numbers after final dot) + const extMatch = core.match(/(.+\.[A-Za-z0-9]+)([.,;:]*)$/); + if (extMatch) { + core = extMatch[1]; + trailing = extMatch[2]; + } + const uri = await this.resolvePathText(core, context); + if (uri) { + // Reconstruct matched text into prefix + anchor + trailing remainder. + parts.push(new LinkifyLocationAnchor(uri)); + if (trailing) { parts.push(trailing); } + } else { + // Fallback: use original matched text including punctuation + parts.push(matched); + } endLastMatch = match.index + matched.length; } @@ -89,7 +109,7 @@ export class FilePathLinkifier implements IContributedLinkifier { parts.push(suffix); } - return { parts: coalesceParts(await Promise.all(parts)) }; + return { parts: coalesceParts(parts) }; } private async resolvePathText(pathText: string, context: LinkifierContext): Promise { diff --git a/src/extension/linkify/common/lineAnnotationParser.ts b/src/extension/linkify/common/lineAnnotationParser.ts index d62f9a2dd4..824de32cd3 100644 --- a/src/extension/linkify/common/lineAnnotationParser.ts +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -45,7 +45,8 @@ export interface ParsedLineAnnotation { const parenRe = /^\s*\((lines?)\s+(\d+)(?:\s*([–—-]|to|through|thru)\s*(\d+))?\)/i; -export function parseLineNumberAnnotation(text: string, maxScan = 160): ParsedLineAnnotation | undefined { +// Parses trailing annotation patterns where line info appears AFTER the file name in prose, or inline parenthesized forms. +export function parseTrailingLineNumberAnnotation(text: string, maxScan = 160): ParsedLineAnnotation | undefined { if (!text) { return undefined; } const slice = text.slice(0, maxScan); const pm = parenRe.exec(slice); @@ -82,6 +83,31 @@ export function parseLineNumberAnnotation(text: string, maxScan = 160): ParsedLi return undefined; } +// Parses preceding annotation patterns where line info appears BEFORE the file name. +// Examples handled (anchor is expected to follow immediately after these tokens): +// in lines 5-7 of +// lines 10-12 of +// on line 45 of +// at line 7 of +// ln 22 of +// at line 19 in +// line 19 in +// Tokenization would work here too, but a concise end-anchored regex is sufficient +// because we only inspect a short contiguous snapshot directly preceding the file path. +// Returns startLine (zero-based) if matched. +export function parsePrecedingLineNumberAnnotation(text: string): ParsedLineAnnotation | undefined { + if (!text) { return undefined; } + // Anchored at end to reduce false positives further back in the snapshot. + // Accept either 'of' or 'in' as the preposition connecting the line annotation to the file name. + // This enables patterns like 'at line 19 in file.ts' or 'line 19 in file.ts'. + const re = /(?:\b(?:in|on|at)\s+)?\b(lines?|ln|l)\b\s+(\d+)(?:\s*(?:-|–|—|to|through|thru)\s*(\d+))?\s+(?:of|in)\s*$/i; + const m = text.match(re); + if (!m) { return undefined; } + const start = toLine(m[2]); + if (start === undefined) { return undefined; } + return { startLine: start, raw: m[0] }; +} + function toLine(token: string): number | undefined { const n = parseInt(token, 10); return isNaN(n) || n <= 0 ? undefined : n - 1; diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index 859a68d5e6..03bbd516a2 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors'; import { escapeRegExpCharacters } from '../../../util/vs/base/common/strings'; import { Location, Position, Range } from '../../../vscodeTypes'; -import { parseLineNumberAnnotation } from './lineAnnotationParser'; +import { parsePrecedingLineNumberAnnotation, parseTrailingLineNumberAnnotation } from './lineAnnotationParser'; import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText'; import type { IContributedLinkifier, ILinkifier, LinkifierContext } from './linkifyService'; @@ -71,7 +71,7 @@ export class Linkifier implements ILinkifier { // Buffer used to delay emitting a single file anchor until we either // detect a line annotation or exceed buffering heuristics. - private _delayedAnchorBuffer: { anchor: LinkifyLocationAnchor; afterText: string; totalChars: number } | undefined; + private _delayedAnchorBuffer: { anchor: LinkifyLocationAnchor; afterText: string; totalChars: number; precedingText: string } | undefined; private static readonly maxAnchorBuffer = 140; // chars of following text to wait for annotation private static readonly flushTerminatorsRe = /[\.?!]\s*$|\n/; // punctuation or newline suggests end of sentence @@ -347,7 +347,26 @@ export class Linkifier implements ILinkifier { if (this._delayedAnchorBuffer) { emit.push(...this.flushAnchorBuffer()); } - this._delayedAnchorBuffer = { anchor: part, afterText: '', totalChars: 0 }; + // Capture up to N chars of preceding applied text to allow upgrading + // anchors when annotation precedes file name: "in lines 5-7 of example.ts". + // Build a preceding snapshot from contiguous prior string parts (not entire applied text) + const precedingSnapshot = (() => { + let acc = ''; + for (let i = emit.length - 1; i >= 0; i--) { + const prev = emit[i]; + if (typeof prev === 'string') { + acc = prev + acc; + if (acc.length >= 160) { break; } + } else { + break; // stop at non-string boundary + } + } + return acc.slice(-160); + })(); + this._delayedAnchorBuffer = { anchor: part, afterText: '', totalChars: 0, precedingText: precedingSnapshot }; + // Try immediate upgrade using preceding annotation pattern. + // If upgraded, continue buffering to capture any trailing text (e.g., punctuation). + this.tryUpgradeBufferedAnchorFromPreceding(); continue; } if (this._delayedAnchorBuffer && typeof part === 'string') { @@ -368,18 +387,38 @@ export class Linkifier implements ILinkifier { if (!b) { return false; } return Linkifier.flushTerminatorsRe.test(b.afterText) || b.totalChars > Linkifier.maxAnchorBuffer - || !!parseLineNumberAnnotation(b.afterText); + || !!parseTrailingLineNumberAnnotation(b.afterText) + || this.tryUpgradeBufferedAnchorFromPreceding(); } private flushAnchorBuffer(): LinkifiedPart[] { if (!this._delayedAnchorBuffer) { return []; } const { anchor, afterText } = this._delayedAnchorBuffer; let resultAnchor: LinkifyLocationAnchor = anchor; - const parsed = parseLineNumberAnnotation(afterText); + const parsed = parseTrailingLineNumberAnnotation(afterText); if (parsed) { resultAnchor = new LinkifyLocationAnchor({ uri: anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); } this._delayedAnchorBuffer = undefined; return afterText.length > 0 ? [resultAnchor, afterText] : [resultAnchor]; } + + // Preceding annotation pattern (annotation before file name): + // Examples: "in lines 5-7 of example.ts", "lines 10-12 of foo.py", "on line 45 of bar.ts", "ln 22 of baz.js" + // We only upgrade once; if already upgraded via trailing text we skip. + private tryUpgradeBufferedAnchorFromPreceding(): boolean { + const b = this._delayedAnchorBuffer; + if (!b) { return false; } + // If already has line info or we already parsed trailing text, skip. + const val = b.anchor.value; + if (typeof val === 'object' && val !== null && 'range' in val) { return false; } + // Extract tail ending right before the file path anchor was inserted. + // Snapshot may include other text after the annotation; restrict to last 160 chars. + const text = b.precedingText; + if (!text) { return false; } + const parsed = parsePrecedingLineNumberAnnotation(text); + if (!parsed) { return false; } + b.anchor = new LinkifyLocationAnchor({ uri: b.anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); + return true; + } } diff --git a/src/extension/linkify/test/node/filePathLinkifier.spec.ts b/src/extension/linkify/test/node/filePathLinkifier.spec.ts index 722b6f2fa9..bc5453d7f9 100644 --- a/src/extension/linkify/test/node/filePathLinkifier.spec.ts +++ b/src/extension/linkify/test/node/filePathLinkifier.spec.ts @@ -443,4 +443,138 @@ suite('File Path Linkifier', () => { ] ); }); + + test(`Should upgrade file link with preceding range annotation 'in lines 5-7 of file'`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'Critical init in lines 5-7 of exampleScript.ts ensures state.' + ); + + assertPartsEqual( + result.parts, + [ + 'Critical init in lines 5-7 of ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(4, 0), new Position(4, 0)) } as Location), + ' ensures state.' + ] + ); + }); + + test(`Should upgrade file link with preceding single line annotation 'on line 45 of file'`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'Bug fix applied on line 45 of exampleScript.ts today.' + ); + + assertPartsEqual( + result.parts, + [ + 'Bug fix applied on line 45 of ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(44, 0), new Position(44, 0)) } as Location), + ' today.' + ] + ); + }); + + test(`Should upgrade file link with preceding shorthand 'ln 22 of file'`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'Configuration lives ln 22 of exampleScript.ts for now.' + ); + + assertPartsEqual( + result.parts, + [ + 'Configuration lives ln 22 of ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(21, 0), new Position(21, 0)) } as Location), + ' for now.' + ] + ); + }); + + test(`Should upgrade file link with preceding single line annotation using 'at line N in file'`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The main function is defined at line 19 in exampleScript.ts.' + ); + + assertPartsEqual( + result.parts, + [ + 'The main function is defined at line 19 in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(18, 0), new Position(18, 0)) } as Location), + '.' + ] + ); + }); + + test(`Should upgrade file link with preceding single line annotation using 'line N in file' (no leading preposition)`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'Review line 19 in exampleScript.ts for correctness.' + ); + + assertPartsEqual( + result.parts, + [ + 'Review line 19 in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(18, 0), new Position(18, 0)) } as Location), + ' for correctness.' + ] + ); + }); + + test(`Should upgrade file link with preceding single line annotation and trailing punctuation`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The main function is defined at line 25 in exampleScript.ts.' + ); + + assertPartsEqual( + result.parts, + [ + 'The main function is defined at line 25 in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(24, 0), new Position(24, 0)) } as Location), + '.' + ] + ); + }); + + test(`Should not mis-upgrade when 'lines' appears without 'of' before file name`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'Review lines 10-12 exampleScript.ts for coverage.' + ); + + // No preceding upgrade; file anchor should be plain (no range) because pattern missing 'of'. + assertPartsEqual( + result.parts, + [ + 'Review lines 10-12 ', + new LinkifyLocationAnchor(workspaceFile('exampleScript.ts')), + ' for coverage.' + ] + ); + }); }); From f3ef284a442a1c0528df5b1dc53541cf35deb7b4 Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Fri, 31 Oct 2025 19:52:15 -0700 Subject: [PATCH 6/9] add normalization --- src/extension/linkify/common/linkifier.ts | 35 ++++++++++++++++++- .../test/node/filePathLinkifier.spec.ts | 19 ++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index 03bbd516a2..83a527d00f 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -361,6 +361,11 @@ export class Linkifier implements ILinkifier { break; // stop at non-string boundary } } + // Fallback: if no contiguous prior parts (streaming emitted anchor before prose), + // use tail of already applied text so preceding patterns like '... line 25 in ' still upgrade. + if (!acc.length && this._appliedText.length) { + return this._appliedText.slice(-160); + } return acc.slice(-160); })(); this._delayedAnchorBuffer = { anchor: part, afterText: '', totalChars: 0, precedingText: precedingSnapshot }; @@ -403,6 +408,33 @@ export class Linkifier implements ILinkifier { return afterText.length > 0 ? [resultAnchor, afterText] : [resultAnchor]; } + private normalizePrecedingSnapshot(raw: string, anchor: LinkifyLocationAnchor): string { + if (!raw) { return raw; } + let out = raw; + // Trim trailing backtick-quoted full path containing file name (with optional punctuation) + try { + const anchorStr = (() => { + try { return (anchor.value as any)?.toString?.() ?? String(anchor.value); } catch { return undefined; } + })(); + if (anchorStr) { + const fileNameMatch = anchorStr.match(/([^\\\/]+)$/); + const fileName = fileNameMatch?.[1]; + if (fileName) { + const backtickPathRe = new RegExp("`[^`]*" + escapeRegExpCharacters(fileName) + "[^`]*`[.,;:]?$", 'i'); + out = out.replace(backtickPathRe, '').replace(/\s+$/, ''); + } + } + } catch { /* ignore */ } + // Strip common inline formatting (bold, italic, code) preserving inner content + out = out + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1'); + // Remove stray leftover formatting tokens + out = out.replace(/[*`_]{1,2}/g, ''); + return out; + } + // Preceding annotation pattern (annotation before file name): // Examples: "in lines 5-7 of example.ts", "lines 10-12 of foo.py", "on line 45 of bar.ts", "ln 22 of baz.js" // We only upgrade once; if already upgraded via trailing text we skip. @@ -416,7 +448,8 @@ export class Linkifier implements ILinkifier { // Snapshot may include other text after the annotation; restrict to last 160 chars. const text = b.precedingText; if (!text) { return false; } - const parsed = parsePrecedingLineNumberAnnotation(text); + const normalized = this.normalizePrecedingSnapshot(text, b.anchor); + const parsed = parsePrecedingLineNumberAnnotation(normalized); if (!parsed) { return false; } b.anchor = new LinkifyLocationAnchor({ uri: b.anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); return true; diff --git a/src/extension/linkify/test/node/filePathLinkifier.spec.ts b/src/extension/linkify/test/node/filePathLinkifier.spec.ts index bc5453d7f9..932feeab78 100644 --- a/src/extension/linkify/test/node/filePathLinkifier.spec.ts +++ b/src/extension/linkify/test/node/filePathLinkifier.spec.ts @@ -577,4 +577,23 @@ suite('File Path Linkifier', () => { ] ); }); + + test(`Should upgrade file link with formatted preceding single line annotation (bold line number)`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + const result = await linkify(linkifier, + 'The main function is defined at **line 25** in exampleScript.ts.' + ); + + assertPartsEqual( + result.parts, + [ + 'The main function is defined at **line 25** in ', + new LinkifyLocationAnchor({ uri: workspaceFile('exampleScript.ts'), range: new Range(new Position(24, 0), new Position(24, 0)) } as Location), + '.' + ] + ); + }); }); From 53aa3e32cea8a2574a720315fae867c7032d2013 Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Mon, 3 Nov 2025 08:48:39 -0800 Subject: [PATCH 7/9] cp feedback updates --- src/extension/linkify/common/linkifier.ts | 12 +++++++++--- src/extension/linkify/test/node/util.ts | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index 83a527d00f..61252f5c18 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -73,7 +73,9 @@ export class Linkifier implements ILinkifier { // detect a line annotation or exceed buffering heuristics. private _delayedAnchorBuffer: { anchor: LinkifyLocationAnchor; afterText: string; totalChars: number; precedingText: string } | undefined; - private static readonly maxAnchorBuffer = 140; // chars of following text to wait for annotation + // Buffer size chosen based on empirical testing: 140 chars covers most typical sentence lengths and ensures + // that line annotations following file anchors are detected without excessive memory usage or latency. + private static readonly maxAnchorBuffer = 140; private static readonly flushTerminatorsRe = /[\.?!]\s*$|\n/; // punctuation or newline suggests end of sentence constructor( @@ -402,7 +404,7 @@ export class Linkifier implements ILinkifier { let resultAnchor: LinkifyLocationAnchor = anchor; const parsed = parseTrailingLineNumberAnnotation(afterText); if (parsed) { - resultAnchor = new LinkifyLocationAnchor({ uri: anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); + resultAnchor = new LinkifyLocationAnchor({ uri: anchor.value, range: Linkifier.createSingleLineRange(parsed.startLine) } as Location); } this._delayedAnchorBuffer = undefined; return afterText.length > 0 ? [resultAnchor, afterText] : [resultAnchor]; @@ -451,7 +453,11 @@ export class Linkifier implements ILinkifier { const normalized = this.normalizePrecedingSnapshot(text, b.anchor); const parsed = parsePrecedingLineNumberAnnotation(normalized); if (!parsed) { return false; } - b.anchor = new LinkifyLocationAnchor({ uri: b.anchor.value, range: new Range(new Position(parsed.startLine, 0), new Position(parsed.startLine, 0)) } as Location); + b.anchor = new LinkifyLocationAnchor({ uri: b.anchor.value, range: Linkifier.createSingleLineRange(parsed.startLine) } as Location); return true; } + + private static createSingleLineRange(line: number): Range { + return new Range(new Position(line, 0), new Position(line, 0)); + } } diff --git a/src/extension/linkify/test/node/util.ts b/src/extension/linkify/test/node/util.ts index 70eda5ec00..cfed5fd70e 100644 --- a/src/extension/linkify/test/node/util.ts +++ b/src/extension/linkify/test/node/util.ts @@ -83,7 +83,11 @@ export function assertPartsEqual(actualParts: readonly LinkifiedPart[], expected if (typeof actualVal === 'object' && actualVal !== null && 'range' in actualVal && 'uri' in actualVal && typeof expectedVal === 'object' && expectedVal !== null && 'range' in expectedVal && 'uri' in expectedVal) { assert.strictEqual(actualVal.uri.toString(), expectedVal.uri.toString()); - assert.strictEqual(actualVal.range.start.line, expectedVal.range.start.line); + // Compare full range, not just start line, so tests fail if columns or end positions diverge. + assert.strictEqual(actualVal.range.start.line, expectedVal.range.start.line, 'start line mismatch'); + assert.strictEqual(actualVal.range.start.character, expectedVal.range.start.character, 'start character mismatch'); + assert.strictEqual(actualVal.range.end.line, expectedVal.range.end.line, 'end line mismatch'); + assert.strictEqual(actualVal.range.end.character, expectedVal.range.end.character, 'end character mismatch'); } else { assert.strictEqual(actual.value.toString(), expected.value.toString()); } From 849cc4e0a5b7a174520a2399facf186da51439dd Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Mon, 3 Nov 2025 12:51:59 -0800 Subject: [PATCH 8/9] refactor line annotation --- .../linkify/common/lineAnnotationParser.ts | 78 ++++++++++++++----- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/src/extension/linkify/common/lineAnnotationParser.ts b/src/extension/linkify/common/lineAnnotationParser.ts index 824de32cd3..e74fe2b31e 100644 --- a/src/extension/linkify/common/lineAnnotationParser.ts +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -35,6 +35,7 @@ // - Returned raw snippet is a lightweight reconstruction of matched tokens for // potential future highlighting or telemetry. +// '-', '–', and '—' represent hyphen, en-dash, and em-dash respectively const RANGE_CONNECTORS = new Set(['-', '–', '—', 'to', 'through', 'thru']); const LINE_TOKENS = new Set(['line', 'lines', 'ln', 'l']); @@ -45,10 +46,54 @@ export interface ParsedLineAnnotation { const parenRe = /^\s*\((lines?)\s+(\d+)(?:\s*([–—-]|to|through|thru)\s*(\d+))?\)/i; +function isNumberToken(token: string | undefined): boolean { + return !!token && /^\d+$/.test(token); +} + +interface LineRangeMatch { + readonly startLine: number; + readonly tokenSpan: number; // Number of tokens consumed (2 for "line 42", 3-4 for ranges) +} + +function tryParseLineRange(tokens: string[], startIndex: number): LineRangeMatch | undefined { + const lineToken = tokens[startIndex]; + if (!lineToken || !LINE_TOKENS.has(lineToken.toLowerCase())) { + return undefined; + } + + const numToken = tokens[startIndex + 1]; + if (!isNumberToken(numToken)) { + return undefined; + } + + const line = toLine(numToken); + if (line === undefined) { + return undefined; + } + + // Check for range connector (e.g., "lines 10 through 15" or "lines 10-15") + const maybeConnector = tokens[startIndex + 2]?.toLowerCase(); + if (maybeConnector && RANGE_CONNECTORS.has(maybeConnector)) { + const secondNum = tokens[startIndex + 3]; + // If we have a valid second number, span is 4 (line + num + connector + num) + // Otherwise span is 3 (line + num + connector, incomplete range) + const tokenSpan = isNumberToken(secondNum) ? 4 : 3; + return { startLine: line, tokenSpan }; + } + + // Simple case: just "line 42" (span of 2 tokens) + return { startLine: line, tokenSpan: 2 }; +} + // Parses trailing annotation patterns where line info appears AFTER the file name in prose, or inline parenthesized forms. export function parseTrailingLineNumberAnnotation(text: string, maxScan = 160): ParsedLineAnnotation | undefined { - if (!text) { return undefined; } + if (!text) { + return undefined; + } + const slice = text.slice(0, maxScan); + + // Fast path: Check for parenthesized form like "(line 42)" or "(lines 10-12)" const pm = parenRe.exec(slice); if (pm) { const line = toLine(pm[2]); @@ -56,30 +101,21 @@ export function parseTrailingLineNumberAnnotation(text: string, maxScan = 160): return { startLine: line, raw: pm[0] }; } } + + // Tokenize and scan for prose patterns like "on line 45" or "lines 10 through 15" const tokenRe = /[A-Za-z]+|\d+|[–—-]/g; - const tokens: string[] = []; - let m: RegExpExecArray | null; - while ((m = tokenRe.exec(slice))) { - tokens.push(m[0]); - if (tokens.length > 40) { break; } - } + const tokens = Array.from(slice.matchAll(tokenRe), m => m[0]).slice(0, 40); + for (let i = 0; i < tokens.length; i++) { - const tk = tokens[i].toLowerCase(); - if (LINE_TOKENS.has(tk)) { - const numToken = tokens[i + 1]; - if (numToken && /^\d+$/.test(numToken)) { - const line = toLine(numToken); - if (line === undefined) { continue; } - const maybeConnector = tokens[i + 2]?.toLowerCase(); - if (maybeConnector && RANGE_CONNECTORS.has(maybeConnector)) { - const secondNum = tokens[i + 3]; - const span = (secondNum && /^\d+$/.test(secondNum)) ? 4 : 3; - return { startLine: line, raw: reconstruct(tokens, i, span) }; - } - return { startLine: line, raw: reconstruct(tokens, i, 2) }; - } + const match = tryParseLineRange(tokens, i); + if (match) { + return { + startLine: match.startLine, + raw: reconstruct(tokens, i, match.tokenSpan) + }; } } + return undefined; } From c680b4aff0928c3b71efccd34f3ac8d38b3005de Mon Sep 17 00:00:00 2001 From: vijay upadya Date: Mon, 3 Nov 2025 13:26:56 -0800 Subject: [PATCH 9/9] Updates to fix test. --- .../linkify/common/lineAnnotationParser.ts | 12 ++++----- src/extension/linkify/common/linkifier.ts | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/extension/linkify/common/lineAnnotationParser.ts b/src/extension/linkify/common/lineAnnotationParser.ts index e74fe2b31e..5ac8327260 100644 --- a/src/extension/linkify/common/lineAnnotationParser.ts +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// General, extensible parser for line annotations following a file path. +// General parser for line annotations following a file path. // Supported patterns (single-line anchor uses first line in range): // // Parenthesized forms: @@ -22,13 +22,12 @@ // is found at lines 5-9 // is at lines 6 through 11 // -// We intentionally only expose the start line (zero-based) because downstream +// Intentionally only expose the start line (zero-based) because downstream // logic currently navigates to a single line even if a range was referenced. // Extending to full range selection would involve carrying an endLine as well. // // Design notes: -// - Token-based approach avoids brittle giant regex and makes future synonym -// additions trivial (expand LINE_TOKENS or RANGE_CONNECTORS sets). +// - Uses token-based approach // - Max scan limits ensure we do not waste time over very long trailing text. // - We ignore invalid ranges like "lines 10 through" (missing second number) // but still treat the first number as the target line. @@ -93,7 +92,7 @@ export function parseTrailingLineNumberAnnotation(text: string, maxScan = 160): const slice = text.slice(0, maxScan); - // Fast path: Check for parenthesized form like "(line 42)" or "(lines 10-12)" + // Check for parenthesized form like "(line 42)" or "(lines 10-12)" const pm = parenRe.exec(slice); if (pm) { const line = toLine(pm[2]); @@ -128,8 +127,7 @@ export function parseTrailingLineNumberAnnotation(text: string, maxScan = 160): // ln 22 of // at line 19 in // line 19 in -// Tokenization would work here too, but a concise end-anchored regex is sufficient -// because we only inspect a short contiguous snapshot directly preceding the file path. +// We only inspect a short contiguous snapshot directly preceding the file path. // Returns startLine (zero-based) if matched. export function parsePrecedingLineNumberAnnotation(text: string): ParsedLineAnnotation | undefined { if (!text) { return undefined; } diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index 61252f5c18..193d233543 100644 --- a/src/extension/linkify/common/linkifier.ts +++ b/src/extension/linkify/common/linkifier.ts @@ -106,6 +106,21 @@ export class Linkifier implements ILinkifier { } else { // Start accumulating + // Early detect fenced code block openings so that linkification inside the first line + // of the block (before the newline token arrives) never runs. Previously we only + // recognized fences once the trailing whitespace/newline part was processed which meant + // a sequence like ['```', '\n', '[file.ts](file.ts)'] would linkify the interior filename. + // Recognize language spec suffixes (e.g. ```ts) by extracting just the fence marker. + const fenceOpen = part.match(/^(?:`{3,}|~{3,}|\$\$)[^\s]*$/); + if (fenceOpen) { + const fenceMarkerMatch = part.match(/(`{3,}|~{3,}|\$\$)/); + const fenceMarker = fenceMarkerMatch ? fenceMarkerMatch[1] : fenceOpen[0]; + const indent = this._appliedText.match(/(\n|^)([ \t]*)$/); + this._state = new LinkifierState.CodeOrMathBlock(fenceMarker, indent?.[2] ?? ''); + out.push(this.doAppend(part)); + break; + } + // `text... if (/^[^\[`]*`[^`]*$/.test(part)) { this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.InlineCodeOrMath, '`'); @@ -225,10 +240,14 @@ export class Linkifier implements ILinkifier { // If we are still accumulating a word (end of input chunk), finalize it so annotations like 'lines 77–85.' are present. if (this._state.type === LinkifierState.Type.Accumulating && this._state.accumulationType === LinkifierState.AccumulationType.Word) { const pending = this._state.pendingText; - this._state = LinkifierState.Default; - if (pending.length) { - const r = await this.doLinkifyAndAppend(pending, {}, token); - out.push(...r.parts); + // Avoid prematurely finalizing when we may be in the middle of a split fenced code block opener + // Example: parts ['``', '`ts', '\n'] should not linkify '```ts' before seeing the newline. + if (!/^(?:`{2,}|~{2,})[^\n]*$/.test(pending)) { + this._state = LinkifierState.Default; + if (pending.length) { + const r = await this.doLinkifyAndAppend(pending, {}, token); + out.push(...r.parts); + } } }