Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -557,4 +557,202 @@ describe('buildLinkPattern', () => {
expect(matches[0][1]).toBe(longPath);
});
});

describe('wrapper character handling', () => {
const pattern = buildLinkPattern(DEFAULT_DELIMITERS);

describe('backtick wrapping', () => {
it('should match link wrapped in backticks without capturing backticks', () => {
const line = '`file.ts#L10`';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match range link wrapped in backticks', () => {
const line = '`path/to/file.ts#L10-L20`';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('path/to/file.ts#L10-L20');
expect(matches[0][1]).toBe('path/to/file.ts');
});

it('should match inline code in prose', () => {
const line = 'Check `file.ts#L10` for details';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match multiple backtick-wrapped links', () => {
const line = 'Compare `file1.ts#L10` with `file2.ts#L20`';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(2);
expect(matches[0][0]).toBe('file1.ts#L10');
expect(matches[1][0]).toBe('file2.ts#L20');
});

it('should exclude leading backtick when only on one side', () => {
const line = '`file.ts#L10 is broken';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should exclude trailing backtick when only on one side', () => {
const line = 'see file.ts#L10`';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match link inside triple backtick fencing', () => {
const line = '```file.ts#L10```';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match backtick-wrapped link with columns', () => {
const line = '`src/auth.ts#L10C5-L20C10`';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('src/auth.ts#L10C5-L20C10');
expect(matches[0][1]).toBe('src/auth.ts');
});

it('should match backtick-wrapped link with multi-char hash delimiter', () => {
const delimiters: DelimiterConfig = {
hash: '>>',
line: 'L',
position: 'C',
range: '-',
};
const multiCharPattern = buildLinkPattern(delimiters);
const line = '`file.ts>>L10`';
const matches = [...line.matchAll(multiCharPattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts>>L10');
expect(matches[0][1]).toBe('file.ts');
});
});

describe('single quote wrapping', () => {
it('should match link wrapped in single quotes', () => {
const line = "'file.ts#L10'";
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match single-quoted link in prose', () => {
const line = "Check 'file.ts#L10' for details";
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});
});

describe('double quote wrapping', () => {
it('should match link wrapped in double quotes', () => {
const line = '"file.ts#L10"';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match double-quoted link in prose', () => {
const line = 'Check "file.ts#L10" for details';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});
});

describe('angle bracket wrapping', () => {
it('should match link wrapped in angle brackets', () => {
const line = '<file.ts#L10>';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});

it('should match angle-bracketed link in prose', () => {
const line = 'See <file.ts#L10> for the implementation';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][0]).toBe('file.ts#L10');
expect(matches[0][1]).toBe('file.ts');
});
});

describe('parentheses and brackets are NOT excluded (appear in real paths)', () => {
it('should capture leading parenthesis as part of path', () => {
const line = '(file.ts#L10)';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][1]).toBe('(file.ts');
});

it('should capture leading bracket as part of path', () => {
const line = '[file.ts#L10]';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(1);
expect(matches[0][1]).toBe('[file.ts');
});
});

describe('mixed wrapper scenarios', () => {
it('should handle different wrapper types in same line', () => {
const line = 'Compare `file1.ts#L10` with "file2.ts#L20"';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(2);
expect(matches[0][0]).toBe('file1.ts#L10');
expect(matches[1][0]).toBe('file2.ts#L20');
});

it('should handle all excluded wrapper types in one line', () => {
const line = '`a.ts#L1` \'b.ts#L2\' "c.ts#L3" <d.ts#L4>';
const matches = [...line.matchAll(pattern)];

expect(matches).toHaveLength(4);
expect(matches[0][0]).toBe('a.ts#L1');
expect(matches[0][1]).toBe('a.ts');
expect(matches[1][0]).toBe('b.ts#L2');
expect(matches[1][1]).toBe('b.ts');
expect(matches[2][0]).toBe('c.ts#L3');
expect(matches[2][1]).toBe('c.ts');
expect(matches[3][0]).toBe('d.ts#L4');
expect(matches[3][1]).toBe('d.ts');
});
});
});
});
19 changes: 16 additions & 3 deletions packages/rangelink-core-ts/src/utils/buildLinkPattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ import { escapeRegex } from './escapeRegex';
const NOT_AFTER_URL_CHAR = '(?<![a-zA-Z0-9:/._?&=%~-])';
const NO_WEB_URL_SCHEME = '(?![hH][tT][tT][pP][sS]?://|[fF][tT][pP]://)';

/**
* Path character class for RangeLink detection.
*
* Matches any non-whitespace character EXCEPT common text wrapper characters.
* These are excluded because they frequently surround links in prose and markdown
* but are never (or practically never) part of real file paths.
*
* Excluded: \x60 (backtick), \x27 (single quote), \x22 (double quote), < >
*
* NOT excluded: ( ) [ ] { } — these appear in real directory/file names.
*/
const PATH_CHAR = '[^\\s\\x60\\x27\\x22<>]';

/**
* Builds a RegExp pattern for detecting RangeLinks in terminal output.
*
Expand Down Expand Up @@ -67,13 +80,13 @@ export const buildLinkPattern = (delimiters: DelimiterConfig): RegExp => {
// Allowed: file://, domain-like paths (github.com/...), Windows paths (C:\...)
const pathPattern =
delimiters.hash.length === 1
? `(${NOT_AFTER_URL_CHAR}${NO_WEB_URL_SCHEME}\\S+?)` // Single-char: URL exclusion + non-greedy
: `(${NOT_AFTER_URL_CHAR}${NO_WEB_URL_SCHEME}(?:(?!${escapedHash})\\S)+)`; // Multi-char: URL exclusion + no hash
? `(${NOT_AFTER_URL_CHAR}${NO_WEB_URL_SCHEME}${PATH_CHAR}+?)` // Single-char: URL exclusion + non-greedy
: `(${NOT_AFTER_URL_CHAR}${NO_WEB_URL_SCHEME}(?:(?!${escapedHash})${PATH_CHAR})+)`; // Multi-char: URL exclusion + no hash

// Build complete pattern
// Pattern: (path)(hash{1,2})(line)(digits)(optional: position)(optional: range)
//
// Note: Using \\S+ (non-whitespace) for path instead of .+ because:
// Note: Using PATH_CHAR+ (non-whitespace, non-backtick) for path instead of .+ because:
// - Terminal lines may have multiple links separated by spaces
// - We want to match individual links, not everything between them
// - Example: "file1.ts#L10 file2.ts#L20" should match 2 links, not 1
Expand Down
1 change: 1 addition & 0 deletions packages/rangelink-vscode-extension/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Smart padding preserves whitespace-only text** - Fixed `applySmartPadding()` incorrectly trimming whitespace-only strings to empty. Now whitespace-only content is preserved when using paste destinations.
- **Web URLs no longer hijacked as RangeLinks** - Fixed clickable links incorrectly capturing URLs like `https://example.com/path/file.ts#L10`, which caused "file not found" errors when clicked. HTTP, HTTPS, and FTP URLs are now properly ignored while local file paths continue to work. (#288)
- **Full-line selection validation error** - Fixed `SELECTION_ZERO_WIDTH` error when using Ctrl+L or triple-click to select full lines. The selection normalization now correctly sets the end character to the line length instead of 0. (#306)
- **Wrapped link navigation** - Fixed navigation failing when RangeLinks are wrapped in backticks, quotes, or angle brackets. Wrapper characters (`` ` ``, `'`, `"`, `<`, `>`) were captured as part of the path, producing unresolvable paths like `` `file.ts ``. The link detection pattern now excludes these characters from path matching. (#310)

## [1.0.0]

Expand Down