@@ -100,60 +100,10 @@ local function file_exists(file_path)
100100 return vim .fn .filereadable (path ) == 1
101101end
102102
103- --- Extract surrounding context for display
104- --- @param text string Full text
105- --- @param match_start number Start position of match
106- --- @param match_end number End position of match
107- --- @param max_len number Maximum context length
108- --- @return string
109- local function extract_context (text , match_start , match_end , max_len )
110- max_len = max_len or 60
111- local half = math.floor (max_len / 2 )
112-
113- -- Find line boundaries for better context
114- local line_start = match_start
115- local line_end = match_end
116-
117- -- Go back to find start of line or limit
118- while line_start > 1 and text :sub (line_start - 1 , line_start - 1 ) ~= ' \n ' and (match_start - line_start ) < half do
119- line_start = line_start - 1
120- end
121-
122- -- Go forward to find end of line or limit
123- while line_end < # text and text :sub (line_end + 1 , line_end + 1 ) ~= ' \n ' and (line_end - match_end ) < half do
124- line_end = line_end + 1
125- end
126-
127- local context = text :sub (line_start , line_end )
128-
129- -- Clean up: remove extra whitespace
130- context = context :gsub (' %s+' , ' ' )
131- context = vim .trim (context )
132-
133- -- Truncate if still too long
134- if # context > max_len then
135- -- Try to center around the match
136- local match_in_context = match_start - line_start + 1
137- local ctx_start = math.max (1 , match_in_context - half )
138- local ctx_end = math.min (# context , match_in_context + half )
139- context = context :sub (ctx_start , ctx_end )
140-
141- if ctx_start > 1 then
142- context = ' ...' .. context
143- end
144- if ctx_end < # context then
145- context = context .. ' ...'
146- end
147- end
148-
149- return context
150- end
151-
152103--- @class CodeReference
153104--- @field file_path string Relative or absolute file path
154105--- @field line number | nil Line number (1-indexed )
155106--- @field column number | nil Column number (optional )
156- --- @field context string Surrounding text for display
157107--- @field message_id string ID of the message containing this reference
158108--- @field match_start number Start position in original text
159109--- @field match_end number End position in original text
@@ -166,60 +116,115 @@ end
166116--- @return CodeReference[]
167117function M .parse_references (text , message_id )
168118 local references = {}
169- local seen = {} -- For deduplication: file_path:line
119+ local covered_ranges = {} -- Track which character ranges we've already matched
120+
121+ -- Helper to check if a range overlaps with any covered range
122+ local function is_covered (start_pos , end_pos )
123+ for _ , range in ipairs (covered_ranges ) do
124+ -- Check if ranges overlap
125+ if not (end_pos < range [1 ] or start_pos > range [2 ]) then
126+ return true
127+ end
128+ end
129+ return false
130+ end
170131
171- -- Pattern: path/to/file.ext:line or path/to/file.ext:line:column
172- -- Must have a valid file extension before the colon
173- -- The path can contain: alphanumeric, underscore, dot, slash, hyphen
174- local pattern = ' ([%w_./%-]+%.([%w]+)):(%d+):?(%d*)'
132+ -- Helper to add a reference
133+ local function add_reference (path , ext , match_start , match_end , line , column )
134+ if not is_valid_extension (ext ) then
135+ return false
136+ end
137+ if not file_exists (path ) then
138+ return false
139+ end
140+ if is_covered (match_start , match_end ) then
141+ return false
142+ end
143+
144+ -- Mark this range as covered
145+ table.insert (covered_ranges , { match_start , match_end })
146+
147+ -- Create absolute path for Snacks preview
148+ local abs_path = path
149+ if not vim .startswith (path , ' /' ) then
150+ abs_path = vim .fn .getcwd () .. ' /' .. path
151+ end
175152
153+ table.insert (references , {
154+ file_path = path ,
155+ line = line ,
156+ column = column ,
157+ message_id = message_id ,
158+ match_start = match_start ,
159+ match_end = match_end ,
160+ file = abs_path ,
161+ pos = line and { line , (column or 1 ) - 1 } or nil ,
162+ })
163+ return true
164+ end
165+
166+ -- First pass: find file:// URI references (preferred format)
167+ -- Matches: file://path/to/file.ext or file://path/to/file.ext:line or file://path/to/file.ext:line:column
168+ local pattern_file_uri = ' file://([%w_./%-]+%.([%w]+)):?(%d*):?(%d*)'
176169 local search_start = 1
177170 while search_start <= # text do
178- local match_start , match_end , path , ext , line_str , col_str = text :find (pattern , search_start )
179-
171+ local match_start , match_end , path , ext , line_str , col_str = text :find (pattern_file_uri , search_start )
180172 if not match_start then
181173 break
182174 end
183175
184- -- Validate extension
185- if is_valid_extension (ext ) and not is_url_context (text , match_start ) then
186- -- Check if file exists
187- if file_exists (path ) then
188- local line = tonumber (line_str )
189- local column = col_str ~= ' ' and tonumber (col_str ) or nil
190-
191- -- Deduplication key
192- local dedup_key = path .. ' :' .. (line or 0 )
193- if not seen [dedup_key ] then
194- seen [dedup_key ] = true
176+ local line = line_str ~= ' ' and tonumber (line_str ) or nil
177+ local column = col_str ~= ' ' and tonumber (col_str ) or nil
178+ add_reference (path , ext , match_start , match_end , line , column )
179+ search_start = match_end + 1
180+ end
195181
196- local context = extract_context (text , match_start , match_end --[[ @as number]] , 60 )
182+ -- Second pass: find path:line[:column] references (legacy format, more specific)
183+ local pattern_with_line = ' ([%w_./%-]+%.([%w]+)):(%d+):?(%d*)'
184+ search_start = 1
185+ while search_start <= # text do
186+ local match_start , match_end , path , ext , line_str , col_str = text :find (pattern_with_line , search_start )
187+ if not match_start then
188+ break
189+ end
197190
198- -- Create absolute path for Snacks preview
199- local abs_path = path
200- if not vim .startswith (path , ' /' ) then
201- abs_path = vim .fn .getcwd () .. ' /' .. path
202- end
191+ -- Skip if this looks like a URL (http://, https://, file://, etc.)
192+ if is_url_context (text , match_start ) then
193+ search_start = match_end + 1
194+ else
195+ local line = tonumber (line_str )
196+ local column = col_str ~= ' ' and tonumber (col_str ) or nil
197+ add_reference (path , ext , match_start , match_end , line , column )
198+ search_start = match_end + 1
199+ end
200+ end
203201
204- table.insert (references , {
205- file_path = path ,
206- line = line ,
207- column = column ,
208- context = context ,
209- message_id = message_id ,
210- match_start = match_start ,
211- match_end = match_end ,
212- -- Fields for Snacks picker file preview
213- file = abs_path ,
214- pos = line and { line , (column or 1 ) - 1 } or nil ,
215- })
216- end
217- end
202+ -- Third pass: find path-only references (must contain a slash to be a path)
203+ local pattern_no_line = ' ([%w_%-]+/[%w_./%-]+%.([%w]+))'
204+ search_start = 1
205+ while search_start <= # text do
206+ local match_start , match_end , path , ext = text :find (pattern_no_line , search_start )
207+ if not match_start then
208+ break
218209 end
219210
220- search_start = match_end + 1
211+ -- Skip if preceded by file:// or other URL scheme
212+ if is_url_context (text , match_start ) then
213+ search_start = match_end + 1
214+ -- Only add if not followed by a colon and digit (which would be caught by second pattern)
215+ elseif text :sub (match_end + 1 , match_end + 1 ) ~= ' :' then
216+ add_reference (path , ext , match_start , match_end , nil , nil )
217+ search_start = match_end + 1
218+ else
219+ search_start = match_end + 1
220+ end
221221 end
222222
223+ -- Sort by match position for consistent ordering
224+ table.sort (references , function (a , b )
225+ return a .match_start < b .match_start
226+ end )
227+
223228 return references
224229end
225230
0 commit comments