From 592984e51aa5d17a215b1b1b48ed4d4adbca0cc2 Mon Sep 17 00:00:00 2001 From: uhyeon <98dngus@gmail.com> Date: Tue, 25 Aug 2026 13:35:04 +0900 Subject: [PATCH 1/5] fix(file-links): trim CJK particles after ASCII extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space-less languages glue particles onto paths (README.mdへ). Widen path classes with \p{L} for mid-path CJK (#13396) and trim non-ASCII letters after an ASCII extension so the particle is not part of the link (#15240 symmetry). --- mobile/src/components/MobileMarkdown.tsx | 5 +- .../markdown-file-path-detection.test.ts | 26 +++++++ .../markdown-file-path-detection.ts | 19 +++-- .../terminal/terminal-path-tap-injected.ts | 24 +++++-- mobile/src/terminal/terminal-path-tap.test.ts | 21 ++++++ mobile/src/terminal/terminal-path-tap.ts | 23 ++++-- .../lib/terminal-bare-file-link-detection.ts | 6 +- .../src/lib/terminal-links-redos.test.ts | 5 +- src/renderer/src/lib/terminal-links.test.ts | 72 +++++++++++++++++++ src/renderer/src/lib/terminal-links.ts | 57 +++------------ .../terminal-spaced-path-trailing-prose.ts | 50 +++++++++++++ src/shared/file-link-trailing-prose.test.ts | 46 ++++++++++++ src/shared/file-link-trailing-prose.ts | 37 ++++++++++ 13 files changed, 320 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts create mode 100644 src/shared/file-link-trailing-prose.test.ts create mode 100644 src/shared/file-link-trailing-prose.ts diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index cc88c01e5644..85059edbe0b3 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 { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose' import { routeMarkdownHref } from './markdown-href-routing' import { isIntrawordUnderscoreToken, @@ -132,7 +133,9 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re onOpenFile(normalizeFilePath(code.trim()))} + onPress={() => + onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiLetters(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..c62512464a8d 100644 --- a/mobile/src/components/markdown-file-path-detection.test.ts +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -210,6 +210,26 @@ 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 +276,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..82c8cba7357e 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 { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose' export type FilePathSegment = | { type: 'text'; value: string } @@ -88,8 +89,11 @@ 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. Keep +// the extension ASCII so particles after `.md` stay outside the match; still +// run trimFileLinkTrailingNonAsciiLetters for code-span / line:col edges. 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 +128,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(trimFileLinkTrailingNonAsciiLetters(pathText)) // Reject anything URL-ish or scheme-bearing — those are handled as web links. if (candidate.includes('://') || hasMidTokenAt(candidate)) { return false @@ -178,12 +183,12 @@ export function detectFilePathSegments(text: string): FilePathSegment[] { CANDIDATE_PATTERN.lastIndex = 0 while ((match = CANDIDATE_PATTERN.exec(text))) { - const candidate = match[0] + const candidate = trimFileLinkTrailingNonAsciiLetters(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 +217,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 = trimFileLinkTrailingNonAsciiLetters(code.trim()) if (!trimmed || /\s/.test(trimmed)) { return false } @@ -234,7 +239,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..1529aa6ef772 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -14,10 +14,11 @@ // 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_LETTERS = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)((?:(?![A-Za-z])\p{L})+)$/u; function parsePathLineCol(value) { var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); @@ -39,6 +40,12 @@ export const TERMINAL_PATH_TAP_JS = String.raw` return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; } + function trimTrailingNonAsciiLetters(range) { + var match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(range.text); + if (!match) return range; + return { text: match[1], startIndex: range.startIndex, endIndex: range.startIndex + match[1].length }; + } + function hasSeparatorAfterWhitespace(text) { var sawWhitespace = false; for (var i = 0; i < text.length; i++) { @@ -53,7 +60,8 @@ 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 non-ASCII letters (CJK particles glued to the ext). + var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu; var match; while ((match = extensionPrefixPattern.exec(range.text)) !== null) { var end = match.index + match[0].length; @@ -91,8 +99,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 = trimTrailingNonAsciiLetters(proseTrimmed); if (col < candidate.startIndex || col >= candidate.endIndex) continue; var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; @@ -110,8 +119,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 = trimTrailingNonAsciiLetters(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..6025820f44dc 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..fccbf323f060 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 { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose' 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 trimFileLinkRangeTrailingNonAsciiLetters 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,10 @@ 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 + // Why: also stop before non-ASCII letters so `…/파일.md로 열었습니다` keeps + // only the path after `\p{L}` widening (space-only trim leaves the particle). + const extensionPrefixPattern = + /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu let match: RegExpExecArray | null while ((match = extensionPrefixPattern.exec(range.text)) !== null) { const end = match.index + match[0].length @@ -148,10 +155,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 = trimFileLinkRangeTrailingNonAsciiLetters(proseTrimmed) if (col < candidate.startIndex || col >= candidate.endIndex) { continue } @@ -181,10 +189,11 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile if (!trimmed) { continue } - if (col < trimmed.startIndex || col >= trimmed.endIndex) { + const candidate = trimFileLinkRangeTrailingNonAsciiLetters(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/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts index 364ebaffd6cd..31a4d351634c 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 { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose' import { detectTerminalFileLinkRanges, terminalFileLinkRangesOverlap, @@ -58,7 +59,10 @@ export function detectBareFilenameLinks( if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { continue } - const link = toParsedTerminalFileLink(range) + // Why: space-less languages glue particles onto bare filenames + // (`README.md로`); trim before the ASCII filename check or the token + // fails looksLikeFilename and never becomes a link. + const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiLetters(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..9f3312e104ba 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..d2bc8218fb06 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -112,6 +112,17 @@ describe('terminal path helpers', () => { expect(performance.now() - startedAt).toBeLessThan(100) expect(links.map((link) => link.displayText)).toEqual(['package.json']) }) + + 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 +226,67 @@ 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([ + ['Korean', '/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, while terminal output is composed. + it('detects NFD-decomposed non-Latin paths', () => { + const path = `/Users/me/docs/${'한글폴더'.normalize('NFD')}/${'파일.md'.normalize('NFD')}` + const links = extractTerminalFileLinks(path) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ pathText: path }) + }) + + 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..4120697130ce 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,3 +1,4 @@ +import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose' 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,11 @@ 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 +178,7 @@ function detectLocalPathLinks( if (!/[\\/]/.test(range.text)) { continue } - const link = toParsedTerminalFileLink(range) + const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiLetters(range)) if (link) { links.push(link) } @@ -259,7 +220,9 @@ function detectSpacedLocalPathLinks( const candidateLinks = candidateRanges .map((candidateRange) => toParsedTerminalFileLink( - trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) + trimFileLinkRangeTrailingNonAsciiLetters( + trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) + ) ) ) .filter((link): link is ParsedTerminalFileLink => link !== null) 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..e79127d18cde --- /dev/null +++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts @@ -0,0 +1,50 @@ +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 before non-ASCII letters so `…/파일.md로 열었습니다` keeps only +// the path after `\p{L}` widening (space-only trim leaves the particle). +const EXTENSION_PREFIX_PATTERN = + /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu + +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 + } + 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/file-link-trailing-prose.test.ts b/src/shared/file-link-trailing-prose.test.ts new file mode 100644 index 000000000000..0928a997d830 --- /dev/null +++ b/src/shared/file-link-trailing-prose.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + trimFileLinkRangeTrailingNonAsciiLetters, + trimFileLinkTrailingNonAsciiLetters +} from './file-link-trailing-prose' + +describe('trimFileLinkTrailingNonAsciiLetters', () => { + 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'] + ])('trims non-ASCII letters after an ASCII extension: %s', (input, expected) => { + expect(trimFileLinkTrailingNonAsciiLetters(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(trimFileLinkTrailingNonAsciiLetters(input)).toBe(input) + }) + + it('adjusts endIndex when trimming a range', () => { + const range = { + text: 'README.md로', + startIndex: 10, + endIndex: 10 + 'README.md로'.length + } + expect(trimFileLinkRangeTrailingNonAsciiLetters(range)).toEqual({ + text: 'README.md', + startIndex: 10, + endIndex: 19 + }) + }) +}) diff --git a/src/shared/file-link-trailing-prose.ts b/src/shared/file-link-trailing-prose.ts new file mode 100644 index 000000000000..99eb078f3243 --- /dev/null +++ b/src/shared/file-link-trailing-prose.ts @@ -0,0 +1,37 @@ +/** + * Strip CJK (and other non-ASCII) letter runs that glue onto a file extension + * in space-less languages. Agents and comments often write: + * + * plans/foo.md로 확정했습니다. + * See `README.md에` + * + * English splits on spaces (`…md for`), but Korean/Japanese/Chinese particles + * stay in the same token. ASCII Latin tails like `file.mdbackup` are kept — + * only non-ASCII letters after an ASCII extension (and optional :line[:col]) + * count as trailing prose. + * + * Pair this with any `\p{L}` widening of path regexes: without the trim, + * `…/파일.md로` would be swallowed whole (#13396 / #15240). + */ + +const ASCII_EXT_THEN_NON_ASCII_LETTERS = + /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u + +export function trimFileLinkTrailingNonAsciiLetters(text: string): string { + const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text) + return match?.groups?.path ?? text +} + +export function trimFileLinkRangeTrailingNonAsciiLetters< + T extends { text: string; startIndex: number; endIndex: number } +>(range: T): T { + const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text) + if (trimmed === range.text) { + return range + } + return { + ...range, + text: trimmed, + endIndex: range.startIndex + trimmed.length + } +} From f890b3ffbfc84b6c17c1c8ff89232577f7405da7 Mon Sep 17 00:00:00 2001 From: uhyeon <98dngus@gmail.com> Date: Tue, 25 Aug 2026 14:26:02 +0900 Subject: [PATCH 2/5] refactor(file-links): fold trim into #15240 shared text-boundary module Move non-ASCII prose boundary from terminal-http-url-extraction into src/shared/non-ascii-terminal-text-boundary.ts and wire file-link trim through the same module (#15240 mirror). --- mobile/src/components/MobileMarkdown.tsx | 2 +- .../markdown-file-path-detection.ts | 2 +- mobile/src/terminal/terminal-path-tap.ts | 2 +- .../terminal-http-url-extraction.ts | 11 +----- .../lib/terminal-bare-file-link-detection.ts | 2 +- src/renderer/src/lib/terminal-links.ts | 2 +- .../terminal-spaced-path-trailing-prose.ts | 2 +- src/shared/file-link-trailing-prose.ts | 37 ------------------ ... non-ascii-terminal-text-boundary.test.ts} | 20 +++++++++- .../non-ascii-terminal-text-boundary.ts | 38 +++++++++++++++++++ 10 files changed, 64 insertions(+), 54 deletions(-) delete mode 100644 src/shared/file-link-trailing-prose.ts rename src/shared/{file-link-trailing-prose.test.ts => non-ascii-terminal-text-boundary.test.ts} (60%) create mode 100644 src/shared/non-ascii-terminal-text-boundary.ts diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 85059edbe0b3..fbccbb681f3f 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -7,7 +7,7 @@ import { isFilePathCodeSpan, normalizeFilePath } from './markdown-file-path-detection' -import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose' +import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary' import { routeMarkdownHref } from './markdown-href-routing' import { isIntrawordUnderscoreToken, diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index 82c8cba7357e..9cd2aeb0c796 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -4,7 +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 { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose' +import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary' export type FilePathSegment = | { type: 'text'; value: string } diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index fccbf323f060..60a95806874c 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -7,7 +7,7 @@ import { parseFileLinkLocation, type ParsedFileLinkLocation } from '../../../src/shared/file-link-location' -import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/file-link-trailing-prose' +import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary' export type TappedFilePath = ParsedFileLinkLocation 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 31a4d351634c..fe37b96caf57 100644 --- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts +++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts @@ -1,4 +1,4 @@ -import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose' +import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary' import { detectTerminalFileLinkRanges, terminalFileLinkRangesOverlap, diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 4120697130ce..2a5a59e31914 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,4 +1,4 @@ -import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/file-link-trailing-prose' +import { trimFileLinkRangeTrailingNonAsciiLetters } 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' diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts index e79127d18cde..a478859765dc 100644 --- a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts +++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts @@ -5,7 +5,7 @@ import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detecti // 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 before non-ASCII letters so `…/파일.md로 열었습니다` keeps only +// Also stop before non-ASCII letters (#15240 mirror) so `…/파일.md로` keeps only // the path after `\p{L}` widening (space-only trim leaves the particle). const EXTENSION_PREFIX_PATTERN = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu diff --git a/src/shared/file-link-trailing-prose.ts b/src/shared/file-link-trailing-prose.ts deleted file mode 100644 index 99eb078f3243..000000000000 --- a/src/shared/file-link-trailing-prose.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Strip CJK (and other non-ASCII) letter runs that glue onto a file extension - * in space-less languages. Agents and comments often write: - * - * plans/foo.md로 확정했습니다. - * See `README.md에` - * - * English splits on spaces (`…md for`), but Korean/Japanese/Chinese particles - * stay in the same token. ASCII Latin tails like `file.mdbackup` are kept — - * only non-ASCII letters after an ASCII extension (and optional :line[:col]) - * count as trailing prose. - * - * Pair this with any `\p{L}` widening of path regexes: without the trim, - * `…/파일.md로` would be swallowed whole (#13396 / #15240). - */ - -const ASCII_EXT_THEN_NON_ASCII_LETTERS = - /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u - -export function trimFileLinkTrailingNonAsciiLetters(text: string): string { - const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text) - return match?.groups?.path ?? text -} - -export function trimFileLinkRangeTrailingNonAsciiLetters< - T extends { text: string; startIndex: number; endIndex: number } ->(range: T): T { - const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text) - if (trimmed === range.text) { - return range - } - return { - ...range, - text: trimmed, - endIndex: range.startIndex + trimmed.length - } -} diff --git a/src/shared/file-link-trailing-prose.test.ts b/src/shared/non-ascii-terminal-text-boundary.test.ts similarity index 60% rename from src/shared/file-link-trailing-prose.test.ts rename to src/shared/non-ascii-terminal-text-boundary.test.ts index 0928a997d830..a106b6850695 100644 --- a/src/shared/file-link-trailing-prose.test.ts +++ b/src/shared/non-ascii-terminal-text-boundary.test.ts @@ -1,10 +1,26 @@ import { describe, expect, it } from 'vitest' import { + isNonAsciiProseBoundary, trimFileLinkRangeTrailingNonAsciiLetters, trimFileLinkTrailingNonAsciiLetters -} from './file-link-trailing-prose' +} from './non-ascii-terminal-text-boundary' -describe('trimFileLinkTrailingNonAsciiLetters', () => { +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('trimFileLinkTrailingNonAsciiLetters (file-link mirror of #15240)', () => { it.each([ ['README.md로', 'README.md'], ['AGENTS.md에', 'AGENTS.md'], 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..24992dbc1ad7 --- /dev/null +++ b/src/shared/non-ascii-terminal-text-boundary.ts @@ -0,0 +1,38 @@ +/** + * Non-ASCII boundary rules for terminal autolink spans (#15240). + * + * URL extraction ends at non-ASCII punctuation/symbols/separators (`\p{P}\p{S}\p{Z}`) + * but keeps CJK letters inside the path. File-link extraction is the mirror: letters + * inside a path stay, but a non-ASCII letter run glued after an ASCII extension + * (`README.md로`) is trailing prose. + */ + +// Why not all non-ascii: a url path may legitimately carry unencoded CJK (see the +// wrapped-url tests in terminal-http-url-extraction.test.ts). +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)) +} + +const ASCII_EXT_THEN_NON_ASCII_LETTERS = + /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u + +export function trimFileLinkTrailingNonAsciiLetters(text: string): string { + const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text) + return match?.groups?.path ?? text +} + +export function trimFileLinkRangeTrailingNonAsciiLetters< + T extends { text: string; startIndex: number; endIndex: number } +>(range: T): T { + const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text) + if (trimmed === range.text) { + return range + } + return { + ...range, + text: trimmed, + endIndex: range.startIndex + trimmed.length + } +} From 37c3b5f57b48be747ae62f1336ba9b62d96d680c Mon Sep 17 00:00:00 2001 From: lazybuttrying Date: Mon, 31 Aug 2026 16:15:58 +0900 Subject: [PATCH 3/5] fix(file-links): end the trim at any non-ASCII, not a letter run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #10573 review showed a trailing trim only fires when the tail is exactly what it strips. The letters-only form had the same hole: a particle followed by punctuation or ASCII left the whole span linked. plans/foo.mdを(参照) -> plans/foo.mdを(参照 plans/foo.mdへabc -> plans/foo.mdへabc docs/日本語.md」を 参照 -> docs/日本語.md」を 参照 The first two were regressions against main, where the ASCII-only class truncated at the first non-Latin byte by accident. Widening to \p{L} removes that accident, so the trim has to carry the boundary itself. Once an ASCII extension closes the name a real path only continues with ASCII (`/`, `\`, `:line`), so cut at the first \P{ASCII}. Same lookahead in all three copies of the spaced-path scan: renderer, mobile tap, and the injected webview mirror that carries its own regexes. Also pin \p{M} in the NFD test: the fixture decomposes to a combining mark, so dropping \p{M} now fails (flagged by coderabbit on #13399). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QDkPfyEHWNUW1JQZRe6tKb --- mobile/src/components/MobileMarkdown.tsx | 4 +- .../markdown-file-path-detection.test.ts | 30 +++++++------- .../markdown-file-path-detection.ts | 13 +++--- .../terminal/terminal-path-tap-injected.ts | 14 +++---- mobile/src/terminal/terminal-path-tap.test.ts | 12 +++--- mobile/src/terminal/terminal-path-tap.ts | 14 +++---- .../lib/terminal-bare-file-link-detection.ts | 8 ++-- .../src/lib/terminal-links-redos.test.ts | 4 +- src/renderer/src/lib/terminal-links.test.ts | 39 ++++++++++++------ src/renderer/src/lib/terminal-links.ts | 9 ++--- .../terminal-spaced-path-trailing-prose.ts | 6 +-- .../non-ascii-terminal-text-boundary.test.ts | 40 +++++++++++-------- .../non-ascii-terminal-text-boundary.ts | 23 +++++------ src/shared/terminal-file-link-conformance.ts | 20 ++++++++++ 14 files changed, 135 insertions(+), 101 deletions(-) diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index fbccbb681f3f..4928b0734fee 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -7,7 +7,7 @@ import { isFilePathCodeSpan, normalizeFilePath } from './markdown-file-path-detection' -import { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary' +import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' import { routeMarkdownHref } from './markdown-href-routing' import { isIntrawordUnderscoreToken, @@ -134,7 +134,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re key={key} style={[styles.inlineCode, styles.inlineCodeLink]} onPress={() => - onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiLetters(code.trim()))) + onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiProse(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 c62512464a8d..bb6ec43c5eb7 100644 --- a/mobile/src/components/markdown-file-path-detection.test.ts +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -212,22 +212,24 @@ describe('detectFilePathSegments', () => { }) 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: '로 완료' } - ]) + 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: '저장 경로는 ' }, + expect(detectFilePathSegments('保存先は plans/foo.mdへ 確定')).toEqual([ + { type: 'text', value: '保存先は ' }, { type: 'file', value: 'plans/foo.md', path: 'plans/foo.md' }, - { type: 'text', value: '로 확정' } + { type: 'text', value: 'へ 確定' } ]) }) }) @@ -278,8 +280,8 @@ describe('isFilePathCodeSpan', () => { }) 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('src/foo.tsへ')).toBe(true) + expect(isFilePathCodeSpan('plans/foo.mdに')).toBe(true) expect(isFilePathCodeSpan('package.jsonです')).toBe(true) }) }) diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index 9cd2aeb0c796..4317d1015f3d 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -4,7 +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 { trimFileLinkTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary' +import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' export type FilePathSegment = | { type: 'text'; value: string } @@ -89,9 +89,8 @@ 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. Keep -// the extension ASCII so particles after `.md` stay outside the match; still -// run trimFileLinkTrailingNonAsciiLetters for code-span / line:col edges. +// 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}[\\/])(?:[\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 @@ -130,7 +129,7 @@ export function splitFilePathLineSuffix(pathText: string): { function isOpenablePath(pathText: string): boolean { // 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(trimFileLinkTrailingNonAsciiLetters(pathText)) + const { path: candidate } = splitFilePathLineSuffix(trimFileLinkTrailingNonAsciiProse(pathText)) // Reject anything URL-ish or scheme-bearing — those are handled as web links. if (candidate.includes('://') || hasMidTokenAt(candidate)) { return false @@ -183,7 +182,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] { CANDIDATE_PATTERN.lastIndex = 0 while ((match = CANDIDATE_PATTERN.exec(text))) { - const candidate = trimFileLinkTrailingNonAsciiLetters(match[0]) + const candidate = trimFileLinkTrailingNonAsciiProse(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). @@ -217,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 = trimFileLinkTrailingNonAsciiLetters(code.trim()) + const trimmed = trimFileLinkTrailingNonAsciiProse(code.trim()) if (!trimmed || /\s/.test(trimmed)) { return false } diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts index 1529aa6ef772..c6f2706a5924 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -18,7 +18,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw` 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_LETTERS = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)((?:(?![A-Za-z])\p{L})+)$/u; + var ASCII_EXT_THEN_NON_ASCII = /^(.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(\P{ASCII}[\s\S]*)$/u; function parsePathLineCol(value) { var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); @@ -40,8 +40,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw` return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; } - function trimTrailingNonAsciiLetters(range) { - var match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(range.text); + 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 }; } @@ -60,8 +60,8 @@ 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; - // Also stop before non-ASCII letters (CJK particles glued to the ext). - var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu; + // 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; @@ -101,7 +101,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw` if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col); if (!proseTrimmed) continue; - var candidate = trimTrailingNonAsciiLetters(proseTrimmed); + var candidate = trimTrailingNonAsciiProse(proseTrimmed); if (col < candidate.startIndex || col >= candidate.endIndex) continue; var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; @@ -119,7 +119,7 @@ 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; - var candidate = trimTrailingNonAsciiLetters(trimmed); + var candidate = trimTrailingNonAsciiProse(trimmed); if (col < candidate.startIndex || col >= candidate.endIndex) continue; var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index 6025820f44dc..9d648dadfd0e 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -166,24 +166,24 @@ describe('matchFilePathAtColumn', () => { }) it('trims CJK particles from bare filenames under the tap', () => { - const line = 'See README.md로 for details' + 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() + 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({ + 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() + expect(matchFilePathAtColumn(line, colOf(line, 'へ'))).toBeNull() }) }) diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index 60a95806874c..fea62cb2b6f5 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -7,7 +7,7 @@ import { parseFileLinkLocation, type ParsedFileLinkLocation } from '../../../src/shared/file-link-location' -import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../src/shared/non-ascii-terminal-text-boundary' +import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' export type TappedFilePath = ParsedFileLinkLocation @@ -17,7 +17,7 @@ export type TappedFilePath = ParsedFileLinkLocation // 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 trimFileLinkRangeTrailingNonAsciiLetters so glued particles after +// Pair with trimFileLinkRangeTrailingNonAsciiProse so glued particles after // an extension are not part of the tap target. const LOCAL_PATH_REGEX = /(?:~[\\/]|[\\/]|\.{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 @@ -86,10 +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 - // Why: also stop before non-ASCII letters so `…/파일.md로 열었습니다` keeps - // only the path after `\p{L}` widening (space-only trim leaves the particle). - const extensionPrefixPattern = - /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu + // 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 @@ -159,7 +157,7 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP if (!proseTrimmed) { continue } - const candidate = trimFileLinkRangeTrailingNonAsciiLetters(proseTrimmed) + const candidate = trimFileLinkRangeTrailingNonAsciiProse(proseTrimmed) if (col < candidate.startIndex || col >= candidate.endIndex) { continue } @@ -189,7 +187,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile if (!trimmed) { continue } - const candidate = trimFileLinkRangeTrailingNonAsciiLetters(trimmed) + const candidate = trimFileLinkRangeTrailingNonAsciiProse(trimmed) if (col < candidate.startIndex || col >= candidate.endIndex) { continue } 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 fe37b96caf57..7afc1e131b7a 100644 --- a/src/renderer/src/lib/terminal-bare-file-link-detection.ts +++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts @@ -1,4 +1,4 @@ -import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary' +import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary' import { detectTerminalFileLinkRanges, terminalFileLinkRangesOverlap, @@ -59,10 +59,8 @@ export function detectBareFilenameLinks( if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { continue } - // Why: space-less languages glue particles onto bare filenames - // (`README.md로`); trim before the ASCII filename check or the token - // fails looksLikeFilename and never becomes a link. - const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiLetters(range)) + // Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link. + const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiProse(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 9f3312e104ba..34fd7240f672 100644 --- a/src/renderer/src/lib/terminal-links-redos.test.ts +++ b/src/renderer/src/lib/terminal-links-redos.test.ts @@ -11,8 +11,8 @@ describe('terminal-links ReDoS guard (#5970)', () => { ['separator + space padding', `a/${' '.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)}`] + ['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 d2bc8218fb06..85c4dd7344fc 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -114,8 +114,8 @@ describe('terminal path helpers', () => { }) it.each([ - ['README.md로', 'README.md'], - ['AGENTS.md에', 'AGENTS.md'], + ['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) @@ -231,7 +231,7 @@ describe('terminal path helpers', () => { // character, so only the ASCII prefix was linkified and the filename sat // outside every link range (#13396). it.each([ - ['Korean', '/Users/me/docs/한글폴더/파일.md'], + ['Japanese', '/Users/me/docs/日本語フォルダ/ファイル.md'], ['Japanese', '/Users/me/docs/日本語/ファイル.md'], ['Chinese', '/Users/me/docs/中文目录/文件.md'], ['Cyrillic', '/Users/me/docs/Документы/файл.md'], @@ -243,31 +243,46 @@ describe('terminal path helpers', () => { }) it('detects relative paths that start with a non-Latin segment', () => { - const links = extractTerminalFileLinks('한글폴더/파일.md') + const links = extractTerminalFileLinks('日本語フォルダ/ファイル.md') expect(links).toHaveLength(1) - expect(links[0]).toMatchObject({ pathText: '한글폴더/파일.md' }) + 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') + const links = extractTerminalFileLinks('/Users/me/docs/日本語フォルダ/ファイル.ts:42:7') expect(links).toHaveLength(1) expect(links[0]).toMatchObject({ - pathText: '/Users/me/docs/한글폴더/파일.ts', + pathText: '/Users/me/docs/日本語フォルダ/ファイル.ts', line: 42, column: 7 }) }) - // macOS stores many names decomposed, while terminal output is composed. + // 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')}` + 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 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') @@ -276,8 +291,8 @@ describe('terminal path helpers', () => { }) it('trims particles after a non-Latin path while keeping the CJK segments', () => { - const path = '/Users/me/docs/한글폴더/파일.md' - const line = `${path}로 열었습니다` + 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 }) diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 2a5a59e31914..408e29d8d4f1 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,4 +1,4 @@ -import { trimFileLinkRangeTrailingNonAsciiLetters } from '../../../shared/non-ascii-terminal-text-boundary' +import { trimFileLinkRangeTrailingNonAsciiProse } 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' @@ -36,8 +36,7 @@ export type ResolvedTerminalFileLink = Pick toParsedTerminalFileLink( - trimFileLinkRangeTrailingNonAsciiLetters( + trimFileLinkRangeTrailingNonAsciiProse( trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) ) ) diff --git a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts index a478859765dc..0081e37faec8 100644 --- a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts +++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts @@ -5,10 +5,8 @@ import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detecti // 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 before non-ASCII letters (#15240 mirror) so `…/파일.md로` keeps only -// the path after `\p{L}` widening (space-only trim leaves the particle). -const EXTENSION_PREFIX_PATTERN = - /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$|(?:(?![A-Za-z])\p{L}))/gu +// 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 function countPathStarts(text: string): number { let count = 0 diff --git a/src/shared/non-ascii-terminal-text-boundary.test.ts b/src/shared/non-ascii-terminal-text-boundary.test.ts index a106b6850695..27ae107f7309 100644 --- a/src/shared/non-ascii-terminal-text-boundary.test.ts +++ b/src/shared/non-ascii-terminal-text-boundary.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { isNonAsciiProseBoundary, - trimFileLinkRangeTrailingNonAsciiLetters, - trimFileLinkTrailingNonAsciiLetters + trimFileLinkRangeTrailingNonAsciiProse, + trimFileLinkTrailingNonAsciiProse } from './non-ascii-terminal-text-boundary' describe('isNonAsciiProseBoundary (#15240)', () => { @@ -16,22 +16,28 @@ describe('isNonAsciiProseBoundary (#15240)', () => { 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) + expect(isNonAsciiProseBoundary('へ'.charCodeAt(0))).toBe(false) }) }) -describe('trimFileLinkTrailingNonAsciiLetters (file-link mirror of #15240)', () => { +describe('trimFileLinkTrailingNonAsciiProse (file-link mirror of #15240)', () => { it.each([ - ['README.md로', 'README.md'], - ['AGENTS.md에', 'AGENTS.md'], + ['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'] + ['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(trimFileLinkTrailingNonAsciiLetters(input)).toBe(expected) + expect(trimFileLinkTrailingNonAsciiProse(input)).toBe(expected) }) it.each([ @@ -40,20 +46,20 @@ describe('trimFileLinkTrailingNonAsciiLetters (file-link mirror of #15240)', () 'file.mdbackup', 'file.mdBackup', 'plans/foo.md', - '/Users/me/docs/한글폴더/파일.md', + '/Users/me/docs/日本語フォルダ/ファイル.md', 'Makefile', 'src/foo.ts:12:3' ])('leaves non-particle text unchanged: %s', (input) => { - expect(trimFileLinkTrailingNonAsciiLetters(input)).toBe(input) + expect(trimFileLinkTrailingNonAsciiProse(input)).toBe(input) }) it('adjusts endIndex when trimming a range', () => { const range = { - text: 'README.md로', + text: 'README.mdへ', startIndex: 10, - endIndex: 10 + 'README.md로'.length + endIndex: 10 + 'README.mdへ'.length } - expect(trimFileLinkRangeTrailingNonAsciiLetters(range)).toEqual({ + 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 index 24992dbc1ad7..4ea78b7eb56d 100644 --- a/src/shared/non-ascii-terminal-text-boundary.ts +++ b/src/shared/non-ascii-terminal-text-boundary.ts @@ -1,32 +1,31 @@ /** * Non-ASCII boundary rules for terminal autolink spans (#15240). * - * URL extraction ends at non-ASCII punctuation/symbols/separators (`\p{P}\p{S}\p{Z}`) - * but keeps CJK letters inside the path. File-link extraction is the mirror: letters - * inside a path stay, but a non-ASCII letter run glued after an ASCII extension - * (`README.md로`) is trailing prose. + * 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 (see the -// wrapped-url tests in terminal-http-url-extraction.test.ts). +// 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)) } -const ASCII_EXT_THEN_NON_ASCII_LETTERS = - /^(?.*\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?(?:(?![A-Za-z])\p{L})+)$/u +// 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 trimFileLinkTrailingNonAsciiLetters(text: string): string { - const match = ASCII_EXT_THEN_NON_ASCII_LETTERS.exec(text) +export function trimFileLinkTrailingNonAsciiProse(text: string): string { + const match = ASCII_EXT_THEN_NON_ASCII.exec(text) return match?.groups?.path ?? text } -export function trimFileLinkRangeTrailingNonAsciiLetters< +export function trimFileLinkRangeTrailingNonAsciiProse< T extends { text: string; startIndex: number; endIndex: number } >(range: T): T { - const trimmed = trimFileLinkTrailingNonAsciiLetters(range.text) + const trimmed = trimFileLinkTrailingNonAsciiProse(range.text) if (trimmed === range.text) { return range } diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts index d74af87eab26..38f0160a60e7 100644 --- a/src/shared/terminal-file-link-conformance.ts +++ b/src/shared/terminal-file-link-conformance.ts @@ -137,5 +137,25 @@ 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 } } ] From dd71bb9844e37b97fdfe079c09b424c2c5925ef0 Mon Sep 17 00:00:00 2001 From: lazybuttrying Date: Tue, 1 Sep 2026 13:11:55 +0900 Subject: [PATCH 4/5] fix(file-links): link non-Latin bare names and prose glued on the left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps remained after the trailing trim. `파일.md` never linked at all: BARE_FILENAME_PATTERN was ASCII-only, so a bare filename whose *name* is non-Latin failed looksLikeFilename with or without a particle. Widen it to \p{L}\p{M}\p{N} and require an ASCII extension, which keeps the prose rejection the function exists for - otherwise every sentence ending in `.` reaches the filesystem probe. Prose also glues onto the left (`문서는README.md`), and the span swallowed it. Trim a leading non-ASCII run, but only when the path resumes in ASCII: `참고파일.md` and `한글폴더/파일.md` are plausible real names and must stay whole. A file genuinely named `파일은README.md` is the accepted cost. Applied in all five implementations - renderer links, bare detection, mobile tap, its injected WebView mirror, and mobile markdown - with the new cases in the shared conformance list, so each asserts twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QDkPfyEHWNUW1JQZRe6tKb --- mobile/src/components/MobileMarkdown.tsx | 6 +-- .../markdown-file-path-detection.ts | 8 ++-- .../terminal/terminal-path-tap-injected.ts | 16 ++++++- mobile/src/terminal/terminal-path-tap.ts | 8 ++-- .../lib/terminal-bare-file-link-detection.ts | 13 ++++-- src/renderer/src/lib/terminal-links.test.ts | 42 +++++++++++++++++++ src/renderer/src/lib/terminal-links.ts | 6 +-- .../non-ascii-terminal-text-boundary.ts | 28 +++++++++++++ src/shared/terminal-file-link-conformance.ts | 20 +++++++++ 9 files changed, 126 insertions(+), 21 deletions(-) diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 4928b0734fee..63e2a7dc2093 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -7,7 +7,7 @@ import { isFilePathCodeSpan, normalizeFilePath } from './markdown-file-path-detection' -import { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' +import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' import { routeMarkdownHref } from './markdown-href-routing' import { isIntrawordUnderscoreToken, @@ -133,9 +133,7 @@ function renderInline(text: string, onOpenFile?: (pathText: string) => void): Re - onOpenFile(normalizeFilePath(trimFileLinkTrailingNonAsciiProse(code.trim()))) - } + onPress={() => onOpenFile(normalizeFilePath(trimFileLinkNonAsciiProse(code.trim())))} > {code} diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index 4317d1015f3d..0513b4dec69b 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -4,7 +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 { trimFileLinkTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' +import { trimFileLinkNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' export type FilePathSegment = | { type: 'text'; value: string } @@ -129,7 +129,7 @@ export function splitFilePathLineSuffix(pathText: string): { function isOpenablePath(pathText: string): boolean { // 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(trimFileLinkTrailingNonAsciiProse(pathText)) + 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 @@ -182,7 +182,7 @@ export function detectFilePathSegments(text: string): FilePathSegment[] { CANDIDATE_PATTERN.lastIndex = 0 while ((match = CANDIDATE_PATTERN.exec(text))) { - const candidate = trimFileLinkTrailingNonAsciiProse(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). @@ -216,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 = trimFileLinkTrailingNonAsciiProse(code.trim()) + const trimmed = trimFileLinkNonAsciiProse(code.trim()) if (!trimmed || /\s/.test(trimmed)) { return false } diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts index c6f2706a5924..fab495bebee7 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -19,6 +19,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw` 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); @@ -46,6 +48,16 @@ export const TERMINAL_PATH_TAP_JS = String.raw` 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++) { @@ -101,7 +113,7 @@ export const TERMINAL_PATH_TAP_JS = String.raw` if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; var proseTrimmed = trimSpacedPathTrailingProse(trimmed, col); if (!proseTrimmed) continue; - var candidate = trimTrailingNonAsciiProse(proseTrimmed); + var candidate = trimNonAsciiProse(proseTrimmed); if (col < candidate.startIndex || col >= candidate.endIndex) continue; var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; @@ -119,7 +131,7 @@ 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; - var candidate = trimTrailingNonAsciiProse(trimmed); + var candidate = trimNonAsciiProse(trimmed); if (col < candidate.startIndex || col >= candidate.endIndex) continue; var parsed = parsePathLineCol(candidate.text); if (parsed) return parsed; diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index fea62cb2b6f5..c226389ab438 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -7,7 +7,7 @@ import { parseFileLinkLocation, type ParsedFileLinkLocation } from '../../../src/shared/file-link-location' -import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' +import { trimFileLinkRangeNonAsciiProse } from '../../../src/shared/non-ascii-terminal-text-boundary' export type TappedFilePath = ParsedFileLinkLocation @@ -17,7 +17,7 @@ export type TappedFilePath = ParsedFileLinkLocation // 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 trimFileLinkRangeTrailingNonAsciiProse so glued particles after +// 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]:[\\/]|[\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 @@ -157,7 +157,7 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP if (!proseTrimmed) { continue } - const candidate = trimFileLinkRangeTrailingNonAsciiProse(proseTrimmed) + const candidate = trimFileLinkRangeNonAsciiProse(proseTrimmed) if (col < candidate.startIndex || col >= candidate.endIndex) { continue } @@ -187,7 +187,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile if (!trimmed) { continue } - const candidate = trimFileLinkRangeTrailingNonAsciiProse(trimmed) + const candidate = trimFileLinkRangeNonAsciiProse(trimmed) if (col < candidate.startIndex || col >= candidate.endIndex) { continue } 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 7afc1e131b7a..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,4 +1,4 @@ -import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary' +import { trimFileLinkRangeNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary' import { detectTerminalFileLinkRanges, terminalFileLinkRangesOverlap, @@ -24,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 { @@ -38,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) } @@ -60,7 +65,7 @@ export function detectBareFilenameLinks( continue } // Trim first: `README.mdへ` fails looksLikeFilename and never becomes a link. - const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiProse(range)) + const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range)) if (!link || !looksLikeFilename(link.pathText)) { continue } diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 85c4dd7344fc..6b2dfacc35a2 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -113,6 +113,48 @@ describe('terminal path helpers', () => { 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'], diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 408e29d8d4f1..fcbc1a7688a5 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,4 +1,4 @@ -import { trimFileLinkRangeTrailingNonAsciiProse } from '../../../shared/non-ascii-terminal-text-boundary' +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' @@ -177,7 +177,7 @@ function detectLocalPathLinks( if (!/[\\/]/.test(range.text)) { continue } - const link = toParsedTerminalFileLink(trimFileLinkRangeTrailingNonAsciiProse(range)) + const link = toParsedTerminalFileLink(trimFileLinkRangeNonAsciiProse(range)) if (link) { links.push(link) } @@ -219,7 +219,7 @@ function detectSpacedLocalPathLinks( const candidateLinks = candidateRanges .map((candidateRange) => toParsedTerminalFileLink( - trimFileLinkRangeTrailingNonAsciiProse( + trimFileLinkRangeNonAsciiProse( trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) ) ) diff --git a/src/shared/non-ascii-terminal-text-boundary.ts b/src/shared/non-ascii-terminal-text-boundary.ts index 4ea78b7eb56d..8465e15de190 100644 --- a/src/shared/non-ascii-terminal-text-boundary.ts +++ b/src/shared/non-ascii-terminal-text-boundary.ts @@ -22,6 +22,34 @@ export function trimFileLinkTrailingNonAsciiProse(text: string): string { 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 { diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts index 38f0160a60e7..03056083b477 100644 --- a/src/shared/terminal-file-link-conformance.ts +++ b/src/shared/terminal-file-link-conformance.ts @@ -157,5 +157,25 @@ export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConfor 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 } + }, + // 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 } } ] From ec7d46397a10ba95b4b9e76166417aa7528f3c9c Mon Sep 17 00:00:00 2001 From: lazybuttrying Date: Tue, 1 Sep 2026 13:56:17 +0900 Subject: [PATCH 5/5] fix(file-links): stop two relative paths on one line from merging `docs/a.md see docs/b.md` linked neither file. The spaced-path scan walks every extension on the line and extends the span when the added segment contains a separator, guarded by countPathStarts > 1. That guard only recognises `/`, `./`, `~/` and `C:\` starts, so two *relative* paths never tripped it: the span ran to the last extension, produced a string that is not a file, and both real paths went dead. Absolute pairs were unaffected, which is why this stayed hidden. Stop extending once the selection is already a finished path - a separator plus an alphabetic extension. The alphabetic part matters: `/tmp/v1.2` ends in a numeric `.2`, so a spaced directory name still extends correctly into `/tmp/v1.2 reports/result.json`. Early-exiting made several line-ending prefix candidates trim to the same span, so dedupe emitted spans - `/var/log/app.log` was returned four times. Mirrored into the mobile tap matcher and its injected WebView copy, with both halves of `docs/a.md see docs/b.md` in the shared conformance list. Pre-existing on main and not CJK-specific; found while testing this branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QDkPfyEHWNUW1JQZRe6tKb --- mobile/src/terminal/terminal-path-tap-injected.ts | 4 ++++ mobile/src/terminal/terminal-path-tap.ts | 7 +++++++ src/renderer/src/lib/terminal-links.ts | 11 +++++++++-- .../src/lib/terminal-spaced-path-trailing-prose.ts | 9 +++++++++ src/shared/terminal-file-link-conformance.ts | 14 ++++++++++++++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts index fab495bebee7..1194dbbd633b 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -79,6 +79,8 @@ export const TERMINAL_PATH_TAP_JS = String.raw` 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; } @@ -91,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; diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index c226389ab438..d3e0d4c47884 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -95,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 || @@ -121,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)) { diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index fcbc1a7688a5..775ae07b54ca 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -225,9 +225,16 @@ function detectSpacedLocalPathLinks( ) ) .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 index 0081e37faec8..028884266148 100644 --- a/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts +++ b/src/renderer/src/lib/terminal-spaced-path-trailing-prose.ts @@ -8,6 +8,10 @@ import type { DetectedTerminalFileLinkRange } from './terminal-file-link-detecti // 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)) { @@ -29,6 +33,11 @@ export function trimSpacedPathTrailingProse( 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 || diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts index 03056083b477..3ccc1f28a16c 100644 --- a/src/shared/terminal-file-link-conformance.ts +++ b/src/shared/terminal-file-link-conformance.ts @@ -171,6 +171,20 @@ export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConfor 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',