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 new file mode 100644 index 0000000000..5ac8327260 --- /dev/null +++ b/src/extension/linkify/common/lineAnnotationParser.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// General parser for line annotations following a file path. +// 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 +// +// 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: +// - 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. +// - 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']); + +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; + +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; + } + + const slice = text.slice(0, maxScan); + + // Check for parenthesized form like "(line 42)" or "(lines 10-12)" + const pm = parenRe.exec(slice); + if (pm) { + const line = toLine(pm[2]); + if (line !== undefined) { + 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 = Array.from(slice.matchAll(tokenRe), m => m[0]).slice(0, 40); + + for (let i = 0; i < tokens.length; i++) { + const match = tryParseLineRange(tokens, i); + if (match) { + return { + startLine: match.startLine, + raw: reconstruct(tokens, i, match.tokenSpan) + }; + } + } + + 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 +// 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; +} + +function reconstruct(tokens: string[], start: number, span: number): string { + return tokens.slice(start, start + span).join(' '); +} diff --git a/src/extension/linkify/common/linkifier.ts b/src/extension/linkify/common/linkifier.ts index d8efc323a7..193d233543 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 { parsePrecedingLineNumberAnnotation, parseTrailingLineNumberAnnotation } from './lineAnnotationParser'; +import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText'; import type { IContributedLinkifier, ILinkifier, LinkifierContext } from './linkifyService'; namespace LinkifierState { @@ -67,6 +69,15 @@ 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; precedingText: string } | undefined; + + // 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( private readonly context: LinkifierContext, private readonly productUriScheme: string, @@ -95,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, '`'); @@ -210,12 +236,34 @@ 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; + // 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); + } + } + } + + 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 +353,130 @@ export class Linkifier implements ILinkifier { } return out; } + + // --- buffering helpers --- + + private processCoalescedParts(parts: readonly LinkifiedPart[]): LinkifiedPart[] { + const emit: LinkifiedPart[] = []; + for (const part of parts) { + if (part instanceof LinkifyLocationAnchor) { + const value = part.value; + if (typeof value === 'object' && value !== null && 'range' in value) { // already has line info + emit.push(part); + continue; + } + if (this._delayedAnchorBuffer) { + emit.push(...this.flushAnchorBuffer()); + } + // 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 + } + } + // 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 }; + // 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') { + 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 + || !!parseTrailingLineNumberAnnotation(b.afterText) + || this.tryUpgradeBufferedAnchorFromPreceding(); + } + + private flushAnchorBuffer(): LinkifiedPart[] { + if (!this._delayedAnchorBuffer) { return []; } + const { anchor, afterText } = this._delayedAnchorBuffer; + let resultAnchor: LinkifyLocationAnchor = anchor; + const parsed = parseTrailingLineNumberAnnotation(afterText); + if (parsed) { + resultAnchor = new LinkifyLocationAnchor({ uri: anchor.value, range: Linkifier.createSingleLineRange(parsed.startLine) } as Location); + } + this._delayedAnchorBuffer = undefined; + 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. + 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 normalized = this.normalizePrecedingSnapshot(text, b.anchor); + const parsed = parsePrecedingLineNumberAnnotation(normalized); + if (!parsed) { return false; } + 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/filePathLinkifier.spec.ts b/src/extension/linkify/test/node/filePathLinkifier.spec.ts index 213918d859..932feeab78 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,345 @@ 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 various phrases and dash types`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + // 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( + 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 '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( + 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 '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( + 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), + ' is at lines 76-83.' + ] + ); + }); + + 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 range connectors ('through' and 'to')`, async () => { + const linkifier = createTestLinkifierService( + 'exampleScript.ts' + ); + + // Test 'through' connector + const result1 = await linkify(linkifier, + 'exampleScript.ts is at lines 5 through 9.' + ); + assertPartsEqual( + 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 'to' connector + const result2 = await linkify(linkifier, + 'This section in exampleScript.ts spans lines 3 to 7.' + ); + assertPartsEqual( + result2.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.' + ] + ); + }); + + 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.' + ] + ); + }); + + 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), + '.' + ] + ); + }); }); diff --git a/src/extension/linkify/test/node/util.ts b/src/extension/linkify/test/node/util.ts index fd6950cfe9..cfed5fd70e 100644 --- a/src/extension/linkify/test/node/util.ts +++ b/src/extension/linkify/test/node/util.ts @@ -78,7 +78,19 @@ 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 = 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()); + // 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()); + } } else { assert(actual instanceof LinkifySymbolAnchor); assert(expected instanceof LinkifySymbolAnchor, "Expected LinkifySymbolAnchor");