diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index cc88c01e5644..63e2a7dc2093 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -7,6 +7,7 @@ import { isFilePathCodeSpan, normalizeFilePath } from './markdown-file-path-detection' +import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' import { routeMarkdownHref } from './markdown-href-routing' import { isIntrawordUnderscoreToken, @@ -132,7 +133,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re onOpenFile(normalizeFilePath(code.trim()))} + onPress={() => onOpenFile(normalizeFilePath(trimFileLinkNonAsciiProse(code.trim())))} > {code} diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts index 19e608ec413c..bb6ec43c5eb7 100644 --- a/mobile/src/components/markdown-file-path-detection.test.ts +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -210,6 +210,28 @@ describe('detectFilePathSegments', () => { { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' } ]) }) + + it('detects CJK directory segments and trims trailing particles', () => { + expect(detectFilePathSegments('開く /Users/me/docs/日本語フォルダ/ファイル.mdへ 完了')).toEqual( + [ + { type: 'text', value: '開く ' }, + { + type: 'file', + value: '/Users/me/docs/日本語フォルダ/ファイル.md', + path: '/Users/me/docs/日本語フォルダ/ファイル.md' + }, + { type: 'text', value: 'へ 完了' } + ] + ) + }) + + it('detects ASCII separator paths with glued CJK particles in prose', () => { + expect(detectFilePathSegments('保存先は plans/foo.mdへ 確定')).toEqual([ + { type: 'text', value: '保存先は ' }, + { type: 'file', value: 'plans/foo.md', path: 'plans/foo.md' }, + { type: 'text', value: 'へ 確定' } + ]) + }) }) describe('isFilePathCodeSpan', () => { @@ -256,6 +278,12 @@ describe('isFilePathCodeSpan', () => { expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) }) + + it('accepts code spans with CJK particles glued after the extension', () => { + expect(isFilePathCodeSpan('src/foo.tsへ')).toBe(true) + expect(isFilePathCodeSpan('plans/foo.mdに')).toBe(true) + expect(isFilePathCodeSpan('package.jsonです')).toBe(true) + }) }) describe('splitFilePathLineSuffix', () => { diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index c51151b9ebf1..0513b4dec69b 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -4,6 +4,7 @@ // annoyance, but a false positive on prose or a version number is a broken tap. import { parseFileLinkLocation } from '../../../src/shared/file-link-location' +import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' export type FilePathSegment = | { type: 'text'; value: string } @@ -88,8 +89,10 @@ const EXTENSION_SET = new Set(FILE_EXTENSIONS) // connected runtime, which may be Windows even when the phone is not. Leading // alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional // tail captures only bounded agent-style :line(:col) citations. +// Why \p{L}\p{M}\p{N}: ASCII-only segments truncated CJK directory names; the +// extension stays ASCII so particles after `.md` fall outside the match. const CANDIDATE_PATTERN = - /(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g + /(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])*|(?:[\p{L}\p{M}\p{N}_.@~+-]+[\\/])+)[\p{L}\p{M}\p{N}_.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![A-Za-z0-9_@%]))?/gu // A path candidate in chat prose is short; a much longer run can't hold one worth // linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we @@ -124,8 +127,9 @@ export function splitFilePathLineSuffix(pathText: string): { } function isOpenablePath(pathText: string): boolean { - // A :line(:col) tail is part of the citation, not the file name. - const { path: candidate } = splitFilePathLineSuffix(pathText) + // A :line(:col) tail is part of the citation, not the file name. Strip CJK + // particles glued after an ASCII extension before extension lookup. + const { path: candidate } = splitFilePathLineSuffix(trimFileLinkNonAsciiProse(pathText)) // Reject anything URL-ish or scheme-bearing — those are handled as web links. if (candidate.includes('://') || hasMidTokenAt(candidate)) { return false @@ -178,12 +182,12 @@ export function detectFilePathSegments(text: string): FilePathSegment[] { CANDIDATE_PATTERN.lastIndex = 0 while ((match = CANDIDATE_PATTERN.exec(text))) { - const candidate = match[0] + const candidate = trimFileLinkNonAsciiProse(match[0]) // Skip candidates that are part of a URL: a scheme colon, a domain tail, or // a preceding slash (the leading slash of an absolute path is part of the // match itself, so prev '/' means a '://' or '//' remainder, not a path). const prev = match.index > 0 ? text[match.index - 1]! : '' - if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) { + if (prev === ':' || prev === '/' || prev === '\\' || /[A-Za-z0-9_.@]/.test(prev)) { continue } if (!isOpenablePath(candidate)) { @@ -212,7 +216,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] { * bare `file.ts` here even without a slash. */ export function isFilePathCodeSpan(code: string): boolean { - const trimmed = code.trim() + const trimmed = trimFileLinkNonAsciiProse(code.trim()) if (!trimmed || /\s/.test(trimmed)) { return false } @@ -234,7 +238,7 @@ export function isFilePathCodeSpan(code: string): boolean { } const name = path.slice(0, dot) const ext = path.slice(dot + 1).toLowerCase() - if (/[^\w.@+-]/.test(name)) { + if (/[^\p{L}\p{M}\p{N}_.@+-]/u.test(name)) { return false } return EXTENSION_SET.has(ext) diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts index 766c381ea22b..1194dbbd633b 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -14,10 +14,13 @@ // often print a bare filename (the markdown link target is consumed, leaving // only the label text), so requiring a slash would miss the common case. export const TERMINAL_PATH_TAP_JS = String.raw` - var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; - var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; + var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu; + var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|\x60\r\n]+(?::\d+)?(?::\d+)?/gu; var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; + var ASCII_EXT_THEN_NON_ASCII = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(\P{ASCII}[\s\S]*)$/u; + // Leading prose only when the path resumes in ASCII, so a wholly non-Latin name survives. + var LEADING_NON_ASCII_THEN_ASCII = /^(\P{ASCII}+)([A-Za-z0-9_][\s\S]*)$/u; function parsePathLineCol(value) { var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); @@ -39,6 +42,22 @@ export const TERMINAL_PATH_TAP_JS = String.raw` return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; } + function trimTrailingNonAsciiProse(range) { + var match = ASCII_EXT_THEN_NON_ASCII.exec(range.text); + if (!match) return range; + return { text: match[1], startIndex: range.startIndex, endIndex: range.startIndex + match[1].length }; + } + + function trimLeadingNonAsciiProse(range) { + var match = LEADING_NON_ASCII_THEN_ASCII.exec(range.text); + if (!match) return range; + return { text: match[2], startIndex: range.endIndex - match[2].length, endIndex: range.endIndex }; + } + + function trimNonAsciiProse(range) { + return trimTrailingNonAsciiProse(trimLeadingNonAsciiProse(range)); + } + function hasSeparatorAfterWhitespace(text) { var sawWhitespace = false; for (var i = 0; i < text.length; i++) { @@ -53,12 +72,15 @@ export const TERMINAL_PATH_TAP_JS = String.raw` // A line-end extension token only extends the span when the added segment // is path-like (contains a separator) — prose must not be swallowed. var selected = null; - var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; + // Also stop before any non-ASCII char (particles and CJK brackets glued to the ext). + var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu; var match; while ((match = extensionPrefixPattern.exec(range.text)) !== null) { var end = match.index + match[0].length; var text = range.text.slice(0, end); if (countPathStarts(text) > 1) continue; + // A finished path plus another hop across whitespace is a second path. + if (selected !== null && COMPLETE_PATH.test(selected)) break; if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { selected = text; } @@ -71,6 +93,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw` return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; } + var COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/; + function countPathStarts(text) { var count = 0; var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; @@ -91,8 +115,9 @@ export const TERMINAL_PATH_TAP_JS = String.raw` while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { var trimmed = trimPathBoundaryPunctuation(match[0], match.index); if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; - var candidate = trimSpacedPathTrailingProse(trimmed, col); - if (!candidate) continue; + var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col); + if (!proseTrimmed) continue; + var candidate = trimNonAsciiProse(proseTrimmed); if (col < candidate.startIndex || col >= candidate.endIndex) continue; var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; @@ -110,8 +135,9 @@ export const TERMINAL_PATH_TAP_JS = String.raw` if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } var trimmed = trimPathBoundaryPunctuation(raw, match.index); if (!trimmed) continue; - if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; - var parsed = parsePathLineCol(trimmed.text); + var candidate = trimNonAsciiProse(trimmed); + if (col < candidate.startIndex || col >= candidate.endIndex) continue; + var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; } return null; diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index ebb8e0b9ec3b..9d648dadfd0e 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -164,6 +164,27 @@ describe('matchFilePathAtColumn', () => { const line = '• Here you go: README.md' expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull() }) + + it('trims CJK particles from bare filenames under the tap', () => { + const line = 'See README.mdへ for details' + expect(matchFilePathAtColumn(line, colOf(line, 'README'))).toEqual({ + pathText: 'README.md', + line: null, + column: null + }) + expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull() + }) + + it('matches non-Latin separator paths and trims trailing particles', () => { + const path = '/Users/me/docs/日本語フォルダ/ファイル.md' + const line = `${path}へ 開きました` + expect(matchFilePathAtColumn(line, colOf(line, 'ファイル'))).toEqual({ + pathText: path, + line: null, + column: null + }) + expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull() + }) }) describe('injected matchFilePathAtColumn', () => { diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index 8e7545a4cfb4..d3e0d4c47884 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -7,6 +7,7 @@ import { parseFileLinkLocation, type ParsedFileLinkLocation } from '../../../src/shared/file-link-location' +import { trimFileLinkRangeNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' export type TappedFilePath = ParsedFileLinkLocation @@ -15,10 +16,13 @@ export type TappedFilePath = ParsedFileLinkLocation // with :line or :line:col. Like desktop, we propose candidates and let the host // existence-check reject non-files — agents often print a bare filename, so // requiring a slash would miss the common case. +// Why \p{L}\p{M}\p{N}: ASCII-only classes truncated CJK path segments (#13396). +// Pair with trimFileLinkRangeNonAsciiProse so glued particles after +// an extension are not part of the tap target. const LOCAL_PATH_REGEX = - /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/]|(?=[\p{L}\p{M}\p{N}._-]*\.[A-Za-z0-9]))[\p{L}\p{M}\p{N}._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/gu const SPACED_PATH_REGEX = - /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{M}\p{N}._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/gu const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"]) const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.']) @@ -82,7 +86,8 @@ function trimSpacedPathTrailingProse( // segment is path-like (contains a separator) — "v1.2 reports/result.json" // extends, prose like "failed to start app.py" must not be swallowed. let selected: string | null = null - const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g + // Also stop at non-ASCII: a CJK bracket closes a citation as often as a particle. + const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu let match: RegExpExecArray | null while ((match = extensionPrefixPattern.exec(range.text)) !== null) { const end = match.index + match[0].length @@ -90,6 +95,9 @@ function trimSpacedPathTrailingProse( if (countPathStarts(text) > 1) { continue } + if (selected !== null && COMPLETE_PATH.test(selected)) { + break + } if ( end < range.text.length || selected === null || @@ -116,6 +124,10 @@ function trimSpacedPathTrailingProse( } } +// Mirror of the renderer rule: a separator plus an alphabetic extension means the +// span is already a finished path, so a further hop across whitespace is a second one. +const COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/ + function countPathStarts(text: string): number { let count = 0 for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { @@ -148,10 +160,11 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP ) { continue } - const candidate = trimSpacedPathTrailingProse(trimmed, col) - if (!candidate) { + const proseTrimmed = trimSpacedPathTrailingProse(trimmed, col) + if (!proseTrimmed) { continue } + const candidate = trimFileLinkRangeNonAsciiProse(proseTrimmed) if (col < candidate.startIndex || col >= candidate.endIndex) { continue } @@ -181,10 +194,11 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile if (!trimmed) { continue } - if (col < trimmed.startIndex || col >= trimmed.endIndex) { + const candidate = trimFileLinkRangeNonAsciiProse(trimmed) + if (col < candidate.startIndex || col >= candidate.endIndex) { continue } - const parsed = parsePathWithOptionalLineColumn(trimmed.text) + const parsed = parsePathWithOptionalLineColumn(candidate.text) if (parsed) { return parsed } diff --git a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts index eeaa463d818c..e2dcb74e96be 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-url-extraction.ts @@ -1,4 +1,5 @@ import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { isNonAsciiProseBoundary } from '../../../../shared/non-ascii-terminal-text-boundary' type ParsedTerminalHttpLink = { url: string @@ -98,7 +99,7 @@ function trimHttpUrlTrailingPunctuation( function isHttpUrlBodyTerminator(code: number): boolean { return ( - isNonAsciiUrlTerminator(code) || + isNonAsciiProseBoundary(code) || isAsciiWhitespace(code) || code === 0x22 || code === 0x27 || @@ -143,14 +144,6 @@ function isHttpUrlTrailingPunctuation(code: number): boolean { ) } -// Why not all non-ascii: a url path may legitimately carry unencoded CJK (see the wrapped-url -// tests), so only punctuation and spaces end it - full-width brackets, an ideographic space, `\u30fb`. -const NON_ASCII_URL_TERMINATOR = /[\p{P}\p{S}\p{Z}]/u - -function isNonAsciiUrlTerminator(code: number): boolean { - return code > 0x7e && NON_ASCII_URL_TERMINATOR.test(String.fromCharCode(code)) -} - function isAsciiWhitespace(code: number): boolean { return code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32 } diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts index 364ebaffd6cd..f800f480897a 100644 --- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts +++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts @@ -1,3 +1,4 @@ +import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary' import { detectTerminalFileLinkRanges, terminalFileLinkRangesOverlap, @@ -23,7 +24,12 @@ const EXTENSIONLESS_FILENAMES = new Set([ 'CONTRIBUTING' ]) -const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/ +// Why \p{L}\p{M}\p{N}: an ASCII-only name never matched `文書.md`, so a bare +// non-Latin filename was not a link at all (#13396). +const BARE_FILENAME_PATTERN = /^[\p{L}\p{M}\p{N}_][\p{L}\p{M}\p{N}._+-]*$/u +// Why an ASCII extension: with the wider name class, every sentence ending in `.` +// would otherwise reach the filesystem probe. +const BARE_FILENAME_EXTENSION = /\.[A-Za-z0-9_+-]+$/ const MAX_BARE_FILENAME_TOKEN_LENGTH = 120 function looksLikeFilename(token: string): boolean { @@ -37,7 +43,7 @@ function looksLikeFilename(token: string): boolean { return false } if (token.includes('.')) { - return !/^\.+$/.test(token) + return BARE_FILENAME_EXTENSION.test(token) } return EXTENSIONLESS_FILENAMES.has(token) } @@ -58,7 +64,8 @@ export function detectBareFilenameLinks( if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { continue } - const link = toParsedTerminalFileLink(range) + // Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link. + const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range)) if (!link || !looksLikeFilename(link.pathText)) { continue } diff --git a/src/renderer/src/lib/terminal-links-redos.test.ts b/src/renderer/src/lib/terminal-links-redos.test.ts index 2de66b55d871..34fd7240f672 100644 --- a/src/renderer/src/lib/terminal-links-redos.test.ts +++ b/src/renderer/src/lib/terminal-links-redos.test.ts @@ -9,7 +9,10 @@ import { extractTerminalFileLinks, extractTerminalFileLinkCandidates } from './t describe('terminal-links ReDoS guard (#5970)', () => { const cases: [string, string][] = [ ['separator + space padding', `a/${' '.repeat(30_000)}`], - ['advertised url + space padding', `Web Interface http://127.0.0.1:4040${' '.repeat(30_000)}`] + ['advertised url + space padding', `Web Interface http://127.0.0.1:4040${' '.repeat(30_000)}`], + // The local-path class accepts Unicode letters/marks; keep that scan linear too. + ['non-latin separator + space padding', `あ/${' '.repeat(30_000)}`], + ['non-latin run + space padding', `${'日本語'.repeat(15_000)}/${' '.repeat(30_000)}`] ] for (const [name, line] of cases) { diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 66ff8d870a8b..6b2dfacc35a2 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -112,6 +112,59 @@ describe('terminal path helpers', () => { expect(performance.now() - startedAt).toBeLessThan(100) expect(links.map((link) => link.displayText)).toEqual(['package.json']) }) + + // Prose glues onto the left as readily as the right; the span must exclude it, + // and startIndex must move with it. + it.each([ + ['文書はREADME.mdだ', 'README.md'], + ['参照docs/文書.md', 'docs/文書.md'], + ['経路はdocs/設計.mdへ', 'docs/設計.md'] + ])('trims non-Latin prose glued before a path: %s', (line, expected) => { + const links = extractTerminalFileLinks(line) + expect(links.map((link) => link.pathText)).toContain(expected) + const link = links.find((entry) => entry.pathText === expected)! + expect(line.slice(link.startIndex, link.endIndex)).toBe(expected) + }) + + // A leading non-Latin run is only prose when the path resumes in ASCII — + // otherwise `参照文書.md` and `日本語フォルダ/x.md` would lose their real names. + it.each(['参照文書.md', '日本語フォルダ/文書.md'])( + 'keeps a wholly non-Latin name intact: %s', + (line) => { + expect(extractTerminalFileLinks(line).map((l) => l.pathText)).toEqual([line]) + } + ) + + // A non-Latin *name* has to link too, not just a non-Latin tail (#13396). + it.each([ + ['文書.md', '文書.md'], + ['文書.mdへ', '文書.md'], + ['設計.txtに', '設計.txt'] + ])('detects bare filenames whose name is non-Latin: %s', (token, pathText) => { + const links = extractTerminalFileLinks(token) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText }) + }) + + // Widening the name class must not send every sentence ending in `.` to the + // filesystem probe, so an ASCII extension is still required. + it.each(['確定しました。', '確定しました.', 'よろしくお願いします.'])( + 'does not treat trailing-dot prose as a bare filename: %s', + (token) => { + expect(extractTerminalFileLinks(token)).toEqual([]) + } + ) + + it.each([ + ['README.mdへ', 'README.md'], + ['AGENTS.mdに', 'AGENTS.md'], + ['file.tsです', 'file.ts'] + ])('trims CJK/JP particles from bare filenames: %s', (token, pathText) => { + const links = extractTerminalFileLinks(token) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText, displayText: pathText }) + expect(token.slice(links[0].startIndex, links[0].endIndex)).toBe(pathText) + }) }) describe('extractTerminalFileLinks local path tokens', () => { @@ -215,6 +268,82 @@ describe('terminal path helpers', () => { expect(links).toHaveLength(20_000) expect(links[0].pathText).toBe('/tmp/Foo Bar/file') }, 5_000) + + // An ASCII-only character class truncated these at the first non-Latin + // character, so only the ASCII prefix was linkified and the filename sat + // outside every link range (#13396). + it.each([ + ['Japanese', '/Users/me/docs/日本語フォルダ/ファイル.md'], + ['Japanese', '/Users/me/docs/日本語/ファイル.md'], + ['Chinese', '/Users/me/docs/中文目录/文件.md'], + ['Cyrillic', '/Users/me/docs/Документы/файл.md'], + ['Greek', '/Users/me/docs/έγγραφα/αρχείο.md'] + ])('detects unspaced %s paths end to end', (_script, path) => { + const links = extractTerminalFileLinks(path) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText: path, displayText: path }) + }) + + it('detects relative paths that start with a non-Latin segment', () => { + const links = extractTerminalFileLinks('日本語フォルダ/ファイル.md') + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText: '日本語フォルダ/ファイル.md' }) + }) + + it('keeps line and column suffixes on non-Latin paths', () => { + const links = extractTerminalFileLinks('/Users/me/docs/日本語フォルダ/ファイル.ts:42:7') + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + pathText: '/Users/me/docs/日本語フォルダ/ファイル.ts', + line: 42, + column: 7 + }) + }) + + // macOS stores many names decomposed; the assertion pins \p{M}, which a Jamo-only + // fixture would leave untested. + it('detects NFD-decomposed non-Latin paths', () => { + const path = `/Users/me/docs/${'日本語フォルダ'.normalize('NFD')}/${'ファイル.md'.normalize('NFD')}` + expect(/\p{M}/u.test(path)).toBe(true) + const links = extractTerminalFileLinks(path) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText: path }) + }) + + // \p{L} removes the accidental truncation at the first non-Latin byte, so the + // trim must cover tails that are not a bare letter run (#10573 review). + it.each([ + ['plans/foo.mdを(参照)', 'plans/foo.md'], + ['plans/foo.mdへabc', 'plans/foo.md'], + ['docs/日本語.md」を 参照', 'docs/日本語.md'] + ])('stops at prose punctuation glued after an extension: %s', (line, expected) => { + const links = extractTerminalFileLinks(line) + expect(links.map((link) => link.pathText)).toContain(expected) + const link = links.find((entry) => entry.pathText === expected) + expect(line.slice(link!.startIndex, link!.endIndex)).toBe(expected) + }) + + it('trims particles glued after a separator path extension', () => { + const line = '保存先は plans/foo.mdへ 確定しました.' + const links = extractTerminalFileLinks(line) + expect(links.map((link) => link.pathText)).toContain('plans/foo.md') + const link = links.find((entry) => entry.pathText === 'plans/foo.md') + expect(link).toBeDefined() + expect(line.slice(link!.startIndex, link!.endIndex)).toBe('plans/foo.md') + }) + + it('trims particles after a non-Latin path while keeping the CJK segments', () => { + const path = '/Users/me/docs/日本語フォルダ/ファイル.md' + const line = `${path}へ 開きました` + const links = extractTerminalFileLinks(line) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText: path, displayText: path }) + }) + + it('does not trim Latin tails after an extension', () => { + const links = extractTerminalFileLinks('file.mdbackup') + expect(links.map((link) => link.pathText)).toEqual(['file.mdbackup']) + }) }) it('supports Windows cwd resolution for terminal file links', () => { diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 729f2d839e70..775ae07b54ca 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,3 +1,4 @@ +import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary' import { normalizeAbsolutePath } from './terminal-path-normalization' import { resolveExplicitFileLinkTarget } from './explicit-file-link-target' import { detectBareFilenameLinks } from './terminal-bare-file-link-detection' @@ -10,6 +11,7 @@ import { type DetectedTerminalFileLinkRange } from './terminal-file-link-detection-ranges' import { detectTerminalFileUriLinks } from './terminal-file-uri-link' +import { trimSpacedPathTrailingProse } from './terminal-spaced-path-trailing-prose' export type ParsedTerminalFileLink = { pathText: string @@ -33,8 +35,10 @@ export type ResolvedTerminalFileLink = Pick 1) { - continue - } - if ( - end < range.text.length || - selected === null || - /[\\/]/.test(range.text.slice(selected.length, end)) - ) { - selected = text - } - } - if (!selected) { - return range - } - return { - text: selected, - startIndex: range.startIndex, - endIndex: range.startIndex + selected.length - } -} - -function countPathStarts(text: string): number { - let count = 0 - for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { - void match - count += 1 - } - return count -} - function trimTrailingWhitespace( range: DetectedTerminalFileLinkRange ): DetectedTerminalFileLinkRange { @@ -217,7 +177,7 @@ function detectLocalPathLinks( if (!/[\\/]/.test(range.text)) { continue } - const link = toParsedTerminalFileLink(range) + const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range)) if (link) { links.push(link) } @@ -259,13 +219,22 @@ function detectSpacedLocalPathLinks( const candidateLinks = candidateRanges .map((candidateRange) => toParsedTerminalFileLink( - trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) + trimFileLinkRangeNonAsciiProse( + trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) + ) ) ) .filter((link): link is ParsedTerminalFileLink => link !== null) - const link = candidateLinks[0] + // Prefix candidates can now trim to the same span, so emit each span once. + const seenSpans = new Set() + const uniqueLinks = candidateLinks.filter((candidateLink) => + seenSpans.has(`${candidateLink.startIndex}:${candidateLink.endIndex}`) + ? false + : (seenSpans.add(`${candidateLink.startIndex}:${candidateLink.endIndex}`), true) + ) + const link = uniqueLinks[0] if (link) { - for (const candidateLink of candidateLinks) { + for (const candidateLink of uniqueLinks) { links.push(candidateLink) } insertTerminalFileLinkClaimedRange(claimedRanges, [link.startIndex, link.endIndex]) diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts new file mode 100644 index 000000000000..028884266148 --- /dev/null +++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts @@ -0,0 +1,57 @@ +import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detection-ranges' + +// Why: keep one extension-terminated path, but drop trailing prose or a second +// unrelated path that the broad spaced-path scan also captured. A line-end +// extension token only extends the span when the added segment is path-like +// (contains a separator) — "v1.2 reports/result.json" extends, prose like +// "failed to start app.py" must not be swallowed. +// Also stop at non-ASCII: a CJK bracket closes a citation as often as a particle. +const EXTENSION_PREFIX_PATTERN = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|\P{ASCII})/gu + +// A separator plus an alphabetic extension means the span is already a finished +// path; `v1.2` is not, so a numeric tail still extends into `v1.2 reports/x.json`. +const COMPLETE_PATH = /[\\/].*\.[A-Za-z][A-Za-z0-9_+-]*$/ + +function countPathStarts(text: string): number { + let count = 0 + for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { + void match + count += 1 + } + return count +} + +export function trimSpacedPathTrailingProse( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange { + let selected: string | null = null + EXTENSION_PREFIX_PATTERN.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = EXTENSION_PREFIX_PATTERN.exec(range.text)) !== null) { + const end = match.index + match[0].length + const text = range.text.slice(0, end) + if (countPathStarts(text) > 1) { + continue + } + // Why: countPathStarts only sees absolute starts, so two relative paths on one + // line used to merge into a span that resolves to nothing and killed both. + if (selected !== null && COMPLETE_PATH.test(selected)) { + break + } + if ( + end < range.text.length || + selected === null || + /[\\/]/.test(range.text.slice(selected.length, end)) + ) { + selected = text + } + } + if (!selected) { + return range + } + return { + text: selected, + startIndex: range.startIndex, + endIndex: range.startIndex + selected.length + } +} diff --git a/src/shared/non-ascii-terminal-text-boundary.test.ts b/src/shared/non-ascii-terminal-text-boundary.test.ts new file mode 100644 index 000000000000..27ae107f7309 --- /dev/null +++ b/src/shared/non-ascii-terminal-text-boundary.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + isNonAsciiProseBoundary, + trimFileLinkRangeTrailingNonAsciiProse, + trimFileLinkTrailingNonAsciiProse +} from './non-ascii-terminal-text-boundary' + +describe('isNonAsciiProseBoundary (#15240)', () => { + it('treats full-width punctuation and ideographic space as prose boundaries', () => { + expect(isNonAsciiProseBoundary('('.charCodeAt(0))).toBe(true) + expect(isNonAsciiProseBoundary('、'.charCodeAt(0))).toBe(true) + expect(isNonAsciiProseBoundary('。'.charCodeAt(0))).toBe(true) + expect(isNonAsciiProseBoundary('\u3000'.charCodeAt(0))).toBe(true) + }) + + it('does not treat CJK letters in a path as prose boundaries', () => { + expect(isNonAsciiProseBoundary('文'.charCodeAt(0))).toBe(false) + expect(isNonAsciiProseBoundary('档'.charCodeAt(0))).toBe(false) + expect(isNonAsciiProseBoundary('へ'.charCodeAt(0))).toBe(false) + }) +}) + +describe('trimFileLinkTrailingNonAsciiProse (file-link mirror of #15240)', () => { + it.each([ + ['README.mdへ', 'README.md'], + ['AGENTS.mdに', 'AGENTS.md'], + ['file.tsです', 'file.ts'], + ['plans/foo.mdへ', 'plans/foo.md'], + ['src/foo.ts:12へ', 'src/foo.ts:12'], + ['src/foo.ts:12:3に', 'src/foo.ts:12:3'], + ['/Users/me/docs/日本語フォルダ/ファイル.mdへ', '/Users/me/docs/日本語フォルダ/ファイル.md'], + ['ファイル.md了', 'ファイル.md'], + // Trailing prose is not always a bare letter run: a particle may be followed by + // punctuation or ASCII, and CJK brackets close a citation (#15240 boundary set). + ['plans/foo.mdを(参照)', 'plans/foo.md'], + ['plans/foo.mdへabc', 'plans/foo.md'], + ['docs/日本語.md」を', 'docs/日本語.md'], + ['README.md、次', 'README.md'] + ])('trims non-ASCII letters after an ASCII extension: %s', (input, expected) => { + expect(trimFileLinkTrailingNonAsciiProse(input)).toBe(expected) + }) + + it.each([ + 'README.md', + 'README.md for', + 'file.mdbackup', + 'file.mdBackup', + 'plans/foo.md', + '/Users/me/docs/日本語フォルダ/ファイル.md', + 'Makefile', + 'src/foo.ts:12:3' + ])('leaves non-particle text unchanged: %s', (input) => { + expect(trimFileLinkTrailingNonAsciiProse(input)).toBe(input) + }) + + it('adjusts endIndex when trimming a range', () => { + const range = { + text: 'README.mdへ', + startIndex: 10, + endIndex: 10 + 'README.mdへ'.length + } + expect(trimFileLinkRangeTrailingNonAsciiProse(range)).toEqual({ + text: 'README.md', + startIndex: 10, + endIndex: 19 + }) + }) +}) diff --git a/src/shared/non-ascii-terminal-text-boundary.ts b/src/shared/non-ascii-terminal-text-boundary.ts new file mode 100644 index 000000000000..8465e15de190 --- /dev/null +++ b/src/shared/non-ascii-terminal-text-boundary.ts @@ -0,0 +1,65 @@ +/** + * Non-ASCII boundary rules for terminal autolink spans (#15240). + * + * URLs stop at non-ASCII punctuation but keep CJK letters inside the path. File + * links mirror that: anything glued after an ASCII extension is trailing prose. + */ + +// Why not all non-ascii: a url path may legitimately carry unencoded CJK. +export const NON_ASCII_PROSE_BOUNDARY = /[\p{P}\p{S}\p{Z}]/u + +export function isNonAsciiProseBoundary(code: number): boolean { + return code > 0x7e && NON_ASCII_PROSE_BOUNDARY.test(String.fromCharCode(code)) +} + +// Why \P{ASCII} and not a letter run: a real path continues in ASCII after the +// extension, and a letters-only trim never fires on `foo.mdを(参照)` (#10573 review). +const ASCII_EXT_THEN_NON_ASCII = + /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?\P{ASCII}[\s\S]*)$/u + +export function trimFileLinkTrailingNonAsciiProse(text: string): string { + const match = ASCII_EXT_THEN_NON_ASCII.exec(text) + return match?.groups?.path ?? text +} + +// Why only before an ASCII start: `참고파일.md` may genuinely be a file name, so a +// leading non-ASCII run is prose only when the path resumes in ASCII (`참고docs/x.md`). +const LEADING_NON_ASCII_THEN_ASCII = /^(?\P{ASCII}+)(?[A-Za-z0-9_][\s\S]*)$/u + +export function trimFileLinkLeadingNonAsciiProse(text: string): string { + return LEADING_NON_ASCII_THEN_ASCII.exec(text)?.groups?.path ?? text +} + +export function trimFileLinkNonAsciiProse(text: string): string { + return trimFileLinkTrailingNonAsciiProse(trimFileLinkLeadingNonAsciiProse(text)) +} + +export function trimFileLinkRangeLeadingNonAsciiProse< + T extends { text: string; startIndex: number; endIndex: number } +>(range: T): T { + const trimmed = trimFileLinkLeadingNonAsciiProse(range.text) + if (trimmed === range.text) { + return range + } + return { ...range, text: trimmed, startIndex: range.endIndex - trimmed.length } +} + +export function trimFileLinkRangeNonAsciiProse< + T extends { text: string; startIndex: number; endIndex: number } +>(range: T): T { + return trimFileLinkRangeTrailingNonAsciiProse(trimFileLinkRangeLeadingNonAsciiProse(range)) +} + +export function trimFileLinkRangeTrailingNonAsciiProse< + T extends { text: string; startIndex: number; endIndex: number } +>(range: T): T { + const trimmed = trimFileLinkTrailingNonAsciiProse(range.text) + if (trimmed === range.text) { + return range + } + return { + ...range, + text: trimmed, + endIndex: range.startIndex + trimmed.length + } +} diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts index d74af87eab26..3ccc1f28a16c 100644 --- a/src/shared/terminal-file-link-conformance.ts +++ b/src/shared/terminal-file-link-conformance.ts @@ -137,5 +137,59 @@ export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConfor lineText: 'just some prose with no path here', tapText: 'prose', expected: null + }, + // A glued particle is not always followed by a space: punctuation, ASCII, and CJK + // brackets all close the citation, and a letters-only trim never fires there. + { + name: 'particle then full-width parenthesis', + lineText: 'plans/foo.mdを(参照) 確認', + tapText: 'foo', + expected: { pathText: 'plans/foo.md', line: null, column: null } + }, + { + name: 'particle then ascii tail', + lineText: 'plans/foo.mdへabc', + tapText: 'foo', + expected: { pathText: 'plans/foo.md', line: null, column: null } + }, + { + name: 'cjk closing bracket after extension', + lineText: 'docs/日本語.md」を 参照', + tapText: '日本語', + expected: { pathText: 'docs/日本語.md', line: null, column: null } + }, + // Prose glues onto the left as readily as the right. + { + name: 'prose glued before a bare filename', + lineText: '文書はREADME.mdだ', + tapText: 'README', + expected: { pathText: 'README.md', line: null, column: null } + }, + { + name: 'prose glued before a separator path', + lineText: '参照docs/文書.md を見て', + tapText: 'docs/', + expected: { pathText: 'docs/文書.md', line: null, column: null } + }, + // Two relative paths on one line must stay separate; they used to merge into a + // span that resolved to nothing and killed both. + { + name: 'two relative paths, first', + lineText: 'docs/a.md see docs/b.md', + tapText: 'a.md', + expected: { pathText: 'docs/a.md', line: null, column: null } + }, + { + name: 'two relative paths, second', + lineText: 'docs/a.md see docs/b.md', + tapText: 'b.md', + expected: { pathText: 'docs/b.md', line: null, column: null } + }, + // Only at a non-ASCII -> ASCII boundary: a wholly non-Latin name is a real name. + { + name: 'wholly non-latin name stays whole', + lineText: '参照文書.md を見て', + tapText: '参照', + expected: { pathText: '参照文書.md', line: null, column: null } } ]