@@ -67,19 +67,131 @@ export function findImagePathMentions(
6767) : ImagePathMention [ ] {
6868 const mentions : ImagePathMention [ ] = [ ] ;
6969 const seen = new Set < string > ( ) ;
70- const pattern =
71- / f i l e : \/ \/ \S + | (?: [ ~ . / ] | [ A - Z a - z ] : ) [ ^ \n \r ] * ?\. (?: p n g | j p e ? g | w e b p | g i f ) (? = $ | \s | [ ) , . ; : ! ? ] ) / gi;
72- let match : RegExpExecArray | null ;
73- while ( ( match = pattern . exec ( text ) ) !== null ) {
74- const raw = trimTrailingPunctuation ( match [ 0 ] ?? "" ) ;
75- const path = normalizeImagePathCandidate ( raw , cwd ) ;
76- if ( path === undefined || seen . has ( path ) ) continue ;
70+
71+ const push = ( raw : string , path : string | undefined ) : void => {
72+ if ( path === undefined || seen . has ( path ) ) return ;
7773 seen . add ( path ) ;
7874 mentions . push ( { raw, path } ) ;
75+ } ;
76+
77+ let lineStart = 0 ;
78+ while ( lineStart <= text . length ) {
79+ let lineEnd = text . indexOf ( "\n" , lineStart ) ;
80+ if ( lineEnd === - 1 ) lineEnd = text . length ;
81+ // Keep a lone trailing \r on CRLF out of the scan window.
82+ const contentEnd =
83+ lineEnd > lineStart && text [ lineEnd - 1 ] === "\r" ? lineEnd - 1 : lineEnd ;
84+ scanImagePathLine ( text , lineStart , contentEnd , cwd , push ) ;
85+ if ( lineEnd === text . length ) break ;
86+ lineStart = lineEnd + 1 ;
7987 }
8088 return mentions ;
8189}
8290
91+ const WRAPPERS = new Set ( [ "'" , '"' , "`" ] ) ;
92+ const UNQUOTED_AT =
93+ / ^ (?: f i l e : \/ \/ \S + | (?: [ ~ . / ] | [ A - Z a - z ] : ) [ ^ \n \r ] * ?\. (?: p n g | j p e ? g | w e b p | g i f ) (? = $ | \s | [ ) , . ; : ! ? ] ) ) / i;
94+
95+ function scanImagePathLine (
96+ text : string ,
97+ start : number ,
98+ end : number ,
99+ cwd : string ,
100+ push : ( raw : string , path : string | undefined ) => void ,
101+ ) : void {
102+ let i = start ;
103+ while ( i < end ) {
104+ const ch = text [ i ] ;
105+ if ( ch === undefined ) break ;
106+ if ( WRAPPERS . has ( ch ) ) {
107+ // Contractions/possessives (`what's`, `don't`) are not quote openers.
108+ if ( ch === "'" && i > start && isWordChar ( text [ i - 1 ] ) ) {
109+ i += 1 ;
110+ continue ;
111+ }
112+ const close = text . indexOf ( ch , i + 1 ) ;
113+ if ( close !== - 1 && close < end ) {
114+ const inner = text . slice ( i + 1 , close ) ;
115+ if ( looksLikeQuotedImagePath ( inner ) ) {
116+ const raw = text . slice ( i , close + 1 ) ;
117+ push ( raw , normalizeImagePathCandidate ( inner , cwd , true ) ) ;
118+ i = close + 1 ;
119+ continue ;
120+ }
121+ // Balanced quotes around prose are not path wrappers. Search inside
122+ // so an absolute path can still be found, then resume after the closer.
123+ scanImagePathLine ( text , i + 1 , close , cwd , push ) ;
124+ i = close + 1 ;
125+ continue ;
126+ }
127+ // Unmatched opener is not a wrapper; same-line unquoted fallback only.
128+ i += 1 ;
129+ continue ;
130+ }
131+
132+ if ( canStartUnquotedPath ( text , i , end ) ) {
133+ const match = UNQUOTED_AT . exec ( text . slice ( i , end ) ) ;
134+ if ( match ?. [ 0 ] !== undefined ) {
135+ const raw = trimTrailingPunctuation ( match [ 0 ] ) ;
136+ push ( raw , normalizeImagePathCandidate ( raw , cwd , false ) ) ;
137+ i += match [ 0 ] . length ;
138+ continue ;
139+ }
140+ }
141+ i += 1 ;
142+ }
143+ }
144+
145+ function isWordChar ( ch : string | undefined ) : boolean {
146+ if ( ch === undefined || ch . length !== 1 ) return false ;
147+ return (
148+ ( ch >= "0" && ch <= "9" ) ||
149+ ( ch >= "A" && ch <= "Z" ) ||
150+ ( ch >= "a" && ch <= "z" )
151+ ) ;
152+ }
153+
154+ function looksLikeQuotedImagePath ( inner : string ) : boolean {
155+ if ( inner . startsWith ( "file://" ) ) return true ;
156+ if ( inner === "~" || inner . startsWith ( "~/" ) || inner . startsWith ( "~\\" ) )
157+ return true ;
158+ if ( inner . startsWith ( "/" ) || inner . startsWith ( "\\" ) ) return true ;
159+ if (
160+ inner . startsWith ( "./" ) ||
161+ inner . startsWith ( "../" ) ||
162+ inner . startsWith ( ".\\" ) ||
163+ inner . startsWith ( "..\\" )
164+ ) {
165+ return true ;
166+ }
167+ const drive = inner [ 0 ] ;
168+ const sep = inner [ 2 ] ;
169+ return (
170+ inner . length >= 3 &&
171+ drive !== undefined &&
172+ sep !== undefined &&
173+ ( ( drive >= "A" && drive <= "Z" ) || ( drive >= "a" && drive <= "z" ) ) &&
174+ inner [ 1 ] === ":" &&
175+ ( sep === "/" || sep === "\\" )
176+ ) ;
177+ }
178+
179+ function canStartUnquotedPath ( text : string , i : number , end : number ) : boolean {
180+ if ( i >= end ) return false ;
181+ if ( text . startsWith ( "file://" , i ) ) return true ;
182+ const ch = text [ i ] ;
183+ if ( ch === undefined ) return false ;
184+ if ( ch === "~" || ch === "." || ch === "/" ) return true ;
185+ if (
186+ i + 1 < end &&
187+ ( ( ch >= "A" && ch <= "Z" ) || ( ch >= "a" && ch <= "z" ) ) &&
188+ text [ i + 1 ] === ":"
189+ ) {
190+ return true ;
191+ }
192+ return false ;
193+ }
194+
83195export async function imageAttachmentFromPath (
84196 path : string ,
85197) : Promise < AttachImageResult > {
@@ -263,40 +375,44 @@ export function userRowText(
263375function normalizeImagePathCandidate (
264376 input : string ,
265377 cwd : string ,
378+ quoted : boolean ,
266379) : string | undefined {
267- const unquoted = unquoteShellPath ( trimTrailingPunctuation ( input . trim ( ) ) ) ;
268- if ( unquoted === undefined ) return undefined ;
380+ const resolved = quoted
381+ ? resolveQuotedImagePath ( input )
382+ : resolveUnquotedImagePath ( input ) ;
383+ if ( resolved === undefined ) return undefined ;
269384 const expanded =
270- unquoted === "~" || unquoted . startsWith ( "~/" )
271- ? resolve ( homedir ( ) , unquoted . slice ( 2 ) )
272- : unquoted ;
273- if (
274- / \s / . test ( expanded ) &&
275- ! isAbsolute ( expanded ) &&
276- input [ 0 ] !== "'" &&
277- input [ 0 ] !== '"'
278- ) {
385+ resolved === "~" || resolved . startsWith ( "~/" )
386+ ? resolve ( homedir ( ) , resolved . slice ( 2 ) )
387+ : resolved ;
388+ if ( / \s / . test ( expanded ) && ! isAbsolute ( expanded ) && ! quoted ) {
279389 return undefined ;
280390 }
281391 const abs = isAbsolute ( expanded ) ? expanded : resolve ( cwd , expanded ) ;
282392 return imageMimeTypeForPath ( abs ) === undefined ? undefined : abs ;
283393}
284394
285- function unquoteShellPath ( input : string ) : string | undefined {
286- if ( input . startsWith ( "file://" ) ) {
395+ function resolveQuotedImagePath ( inner : string ) : string | undefined {
396+ if ( inner . startsWith ( "file://" ) ) {
287397 try {
288- return decodeURIComponent ( new URL ( input ) . pathname ) ;
398+ return decodeURIComponent ( new URL ( inner ) . pathname ) ;
289399 } catch {
290400 return undefined ;
291401 }
292402 }
293- if (
294- ( input . startsWith ( "'" ) && input . endsWith ( "'" ) ) ||
295- ( input . startsWith ( '"' ) && input . endsWith ( '"' ) )
296- ) {
297- return input . slice ( 1 , - 1 ) ;
403+ return inner ;
404+ }
405+
406+ function resolveUnquotedImagePath ( input : string ) : string | undefined {
407+ const trimmed = trimTrailingPunctuation ( input . trim ( ) ) ;
408+ if ( trimmed . startsWith ( "file://" ) ) {
409+ try {
410+ return decodeURIComponent ( new URL ( trimmed ) . pathname ) ;
411+ } catch {
412+ return undefined ;
413+ }
298414 }
299- return input . replace ( / \\ ( [ \\ \s ' " ( ) ] ) / g, "$1" ) ;
415+ return trimmed . replace ( / \\ ( [ \\ \s ' " ( ) ] ) / g, "$1" ) ;
300416}
301417
302418function trimTrailingPunctuation ( input : string ) : string {
0 commit comments