Fortran · 24850 bytes Raw Blame History
1 module diagnostics_module
2 use iso_fortran_env, only: int32
3 use json_module, only: json_value_t, json_get_string, &
4 json_get_number, json_get_object, &
5 json_get_array, json_array_size, &
6 json_get_array_element, json_has_key, &
7 json_stringify, json_get_value
8 implicit none
9 private
10
11 public :: diagnostic_t, diagnostics_store_t, diagnostic_range_t
12 public :: init_diagnostics_store, cleanup_diagnostics_store
13 public :: parse_diagnostics, parse_diagnostics_from_params, clear_diagnostics
14 public :: parse_diagnostics_from_params_with_server ! NEW: with server attribution
15 public :: get_diagnostics_for_line, get_diagnostic_at_cursor
16 public :: get_diagnostics_for_line_by_server ! NEW: filter by server
17 public :: has_diagnostics_for_file, get_diagnostics_for_file
18 public :: diagnostics_to_json
19 public :: SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO, SEVERITY_HINT
20
21 ! Diagnostic severity levels (LSP standard)
22 integer, parameter :: SEVERITY_ERROR = 1
23 integer, parameter :: SEVERITY_WARNING = 2
24 integer, parameter :: SEVERITY_INFO = 3
25 integer, parameter :: SEVERITY_HINT = 4
26
27 type :: diagnostic_range_t
28 integer :: start_line = 0 ! 0-based
29 integer :: start_col = 0 ! 0-based
30 integer :: end_line = 0 ! 0-based
31 integer :: end_col = 0 ! 0-based
32 end type diagnostic_range_t
33
34 type :: diagnostic_t
35 type(diagnostic_range_t) :: range
36 integer :: severity = SEVERITY_ERROR
37 character(len=:), allocatable :: message
38 character(len=:), allocatable :: source ! e.g., "eslint", "clangd"
39 character(len=:), allocatable :: code ! Error code
40 character(len=:), allocatable :: data ! Raw JSON data field (for Ruff quickfixes)
41 integer :: server_index = 0 ! Which LSP server sent this diagnostic
42 end type diagnostic_t
43
44 type :: file_diagnostics_t
45 character(len=:), allocatable :: uri
46 type(diagnostic_t), allocatable :: items(:)
47 integer :: count = 0
48 end type file_diagnostics_t
49
50 type :: diagnostics_store_t
51 type(file_diagnostics_t), allocatable :: files(:)
52 integer :: file_count = 0
53 end type diagnostics_store_t
54
55 contains
56
57 subroutine init_diagnostics_store(store)
58 type(diagnostics_store_t), intent(out) :: store
59 allocate(store%files(0))
60 store%file_count = 0
61 end subroutine init_diagnostics_store
62
63 subroutine cleanup_diagnostics_store(store)
64 type(diagnostics_store_t), intent(inout) :: store
65 integer :: i, j
66
67 if (allocated(store%files)) then
68 do i = 1, store%file_count
69 if (allocated(store%files(i)%uri)) deallocate(store%files(i)%uri)
70 if (allocated(store%files(i)%items)) then
71 do j = 1, store%files(i)%count
72 if (allocated(store%files(i)%items(j)%message)) &
73 deallocate(store%files(i)%items(j)%message)
74 if (allocated(store%files(i)%items(j)%source)) &
75 deallocate(store%files(i)%items(j)%source)
76 if (allocated(store%files(i)%items(j)%code)) &
77 deallocate(store%files(i)%items(j)%code)
78 end do
79 deallocate(store%files(i)%items)
80 end if
81 end do
82 deallocate(store%files)
83 end if
84 store%file_count = 0
85 end subroutine cleanup_diagnostics_store
86
87 ! Parse diagnostics from LSP notification
88 subroutine parse_diagnostics(store, notification)
89 type(diagnostics_store_t), intent(inout) :: store
90 type(json_value_t), intent(in) :: notification
91 type(json_value_t) :: params
92
93 ! Get params from notification
94 if (.not. json_has_key(notification, "params")) return
95 params = json_get_object(notification, "params")
96
97 ! Delegate to params parser
98 call parse_diagnostics_from_params(store, params)
99 end subroutine parse_diagnostics
100
101 ! Parse diagnostics from just the params object
102 subroutine parse_diagnostics_from_params(store, params)
103 use terminal_io_module, only: terminal_write
104 type(diagnostics_store_t), intent(inout) :: store
105 type(json_value_t), intent(in) :: params
106 type(json_value_t) :: diagnostics_array, diag_obj, range_obj
107 type(json_value_t) :: start_obj, end_obj
108 character(len=:), allocatable :: uri
109 integer :: i, n_diagnostics, file_idx
110 type(diagnostic_t) :: diag
111
112 ! Get URI
113 if (.not. json_has_key(params, "uri")) return
114 uri = json_get_string(params, "uri")
115
116 ! Find or create file entry
117 file_idx = find_or_create_file(store, uri)
118
119 ! Clear existing diagnostics for this file
120 if (allocated(store%files(file_idx)%items)) then
121 deallocate(store%files(file_idx)%items)
122 end if
123 store%files(file_idx)%count = 0
124
125 ! Parse diagnostics array
126 if (json_has_key(params, "diagnostics")) then
127 diagnostics_array = json_get_array(params, "diagnostics")
128 n_diagnostics = json_array_size(diagnostics_array)
129
130 if (n_diagnostics > 0) then
131 allocate(store%files(file_idx)%items(n_diagnostics))
132 store%files(file_idx)%count = n_diagnostics
133
134 do i = 1, n_diagnostics
135 diag_obj = json_get_array_element(diagnostics_array, i-1)
136
137 ! Parse range
138 if (json_has_key(diag_obj, "range")) then
139 range_obj = json_get_object(diag_obj, "range")
140
141 if (json_has_key(range_obj, "start")) then
142 start_obj = json_get_object(range_obj, "start")
143 diag%range%start_line = int(json_get_number(start_obj, "line"))
144 diag%range%start_col = int(json_get_number(start_obj, "character"))
145 end if
146
147 if (json_has_key(range_obj, "end")) then
148 end_obj = json_get_object(range_obj, "end")
149 diag%range%end_line = int(json_get_number(end_obj, "line"))
150 diag%range%end_col = int(json_get_number(end_obj, "character"))
151 end if
152 end if
153
154 ! Parse severity
155 if (json_has_key(diag_obj, "severity")) then
156 diag%severity = int(json_get_number(diag_obj, "severity"))
157 else
158 diag%severity = SEVERITY_ERROR
159 end if
160
161 ! Parse message
162 if (json_has_key(diag_obj, "message")) then
163 diag%message = json_get_string(diag_obj, "message")
164 else
165 diag%message = "Unknown error"
166 end if
167
168 ! Parse source
169 if (json_has_key(diag_obj, "source")) then
170 diag%source = json_get_string(diag_obj, "source")
171 else
172 diag%source = ""
173 end if
174
175 ! Parse code
176 if (json_has_key(diag_obj, "code")) then
177 ! Code can be string or number
178 diag%code = json_get_string(diag_obj, "code")
179 else
180 diag%code = ""
181 end if
182
183 store%files(file_idx)%items(i) = diag
184 end do
185 end if
186 end if
187 end subroutine parse_diagnostics_from_params
188
189 ! Parse diagnostics with server attribution (for multi-LSP support)
190 ! Instead of clearing all diagnostics, this ADDS to existing diagnostics
191 ! and tags them with the server_index so they can be filtered later
192 subroutine parse_diagnostics_from_params_with_server(store, params, server_index)
193 use terminal_io_module, only: terminal_write
194 type(diagnostics_store_t), intent(inout) :: store
195 type(json_value_t), intent(in) :: params
196 integer, intent(in) :: server_index
197 type(json_value_t) :: diagnostics_array, diag_obj, range_obj
198 type(json_value_t) :: start_obj, end_obj
199 character(len=:), allocatable :: uri
200 integer :: i, n_diagnostics, file_idx, new_count, j
201 integer :: old_count = 0
202 type(diagnostic_t) :: diag
203 type(diagnostic_t), allocatable :: old_items(:)
204
205 ! Initialize old_items to prevent uninitialized warning
206 allocate(old_items(0))
207
208 ! Get URI
209 if (.not. json_has_key(params, "uri")) return
210 uri = json_get_string(params, "uri")
211
212 ! Find or create file entry
213 file_idx = find_or_create_file(store, uri)
214
215 ! First, remove any existing diagnostics from this server
216 if (allocated(store%files(file_idx)%items) .and. store%files(file_idx)%count > 0) then
217 ! Count diagnostics NOT from this server
218 do i = 1, store%files(file_idx)%count
219 if (store%files(file_idx)%items(i)%server_index /= server_index) then
220 old_count = old_count + 1
221 end if
222 end do
223
224 ! Copy diagnostics NOT from this server
225 if (old_count > 0) then
226 allocate(old_items(old_count))
227 j = 0
228 do i = 1, store%files(file_idx)%count
229 if (store%files(file_idx)%items(i)%server_index /= server_index) then
230 j = j + 1
231 old_items(j) = store%files(file_idx)%items(i)
232 end if
233 end do
234 end if
235 deallocate(store%files(file_idx)%items)
236 end if
237
238 ! Parse new diagnostics array
239 n_diagnostics = 0
240 if (json_has_key(params, "diagnostics")) then
241 diagnostics_array = json_get_array(params, "diagnostics")
242 n_diagnostics = json_array_size(diagnostics_array)
243 end if
244
245 ! Allocate combined array
246 new_count = old_count + n_diagnostics
247 if (new_count > 0) then
248 if (allocated(store%files(file_idx)%items)) deallocate(store%files(file_idx)%items)
249 allocate(store%files(file_idx)%items(new_count))
250 store%files(file_idx)%count = new_count
251
252 ! Copy old diagnostics first
253 if (old_count > 0 .and. allocated(old_items)) then
254 store%files(file_idx)%items(1:old_count) = old_items(1:old_count)
255 deallocate(old_items)
256 end if
257
258 ! Parse and add new diagnostics
259 do i = 1, n_diagnostics
260 diag_obj = json_get_array_element(diagnostics_array, i-1)
261
262 ! Parse range
263 if (json_has_key(diag_obj, "range")) then
264 range_obj = json_get_object(diag_obj, "range")
265
266 if (json_has_key(range_obj, "start")) then
267 start_obj = json_get_object(range_obj, "start")
268 diag%range%start_line = int(json_get_number(start_obj, "line"))
269 diag%range%start_col = int(json_get_number(start_obj, "character"))
270 end if
271
272 if (json_has_key(range_obj, "end")) then
273 end_obj = json_get_object(range_obj, "end")
274 diag%range%end_line = int(json_get_number(end_obj, "line"))
275 diag%range%end_col = int(json_get_number(end_obj, "character"))
276 end if
277 end if
278
279 ! Parse severity
280 if (json_has_key(diag_obj, "severity")) then
281 diag%severity = int(json_get_number(diag_obj, "severity"))
282 else
283 diag%severity = SEVERITY_ERROR
284 end if
285
286 ! Parse message
287 if (json_has_key(diag_obj, "message")) then
288 diag%message = json_get_string(diag_obj, "message")
289 else
290 diag%message = "Unknown error"
291 end if
292
293 ! Parse source
294 if (json_has_key(diag_obj, "source")) then
295 diag%source = json_get_string(diag_obj, "source")
296 else
297 diag%source = ""
298 end if
299
300 ! Parse code
301 if (json_has_key(diag_obj, "code")) then
302 diag%code = json_get_string(diag_obj, "code")
303 else
304 diag%code = ""
305 end if
306
307 ! Parse data field (stores raw JSON for quickfixes like Ruff)
308 if (json_has_key(diag_obj, "data")) then
309 diag%data = json_stringify(json_get_value(diag_obj, "data"))
310 else
311 diag%data = ""
312 end if
313
314 ! Set server index
315 diag%server_index = server_index
316
317 store%files(file_idx)%items(old_count + i) = diag
318 end do
319 else
320 ! No diagnostics - ensure items is allocated as empty array
321 if (.not. allocated(store%files(file_idx)%items)) then
322 allocate(store%files(file_idx)%items(0))
323 end if
324 store%files(file_idx)%count = 0
325 end if
326 end subroutine parse_diagnostics_from_params_with_server
327
328 function find_or_create_file(store, uri) result(idx)
329 type(diagnostics_store_t), intent(inout) :: store
330 character(len=*), intent(in) :: uri
331 integer :: idx, i
332 type(file_diagnostics_t), allocatable :: new_files(:)
333
334 ! Search for existing file
335 do i = 1, store%file_count
336 if (store%files(i)%uri == uri) then
337 idx = i
338 return
339 end if
340 end do
341
342 ! Create new file entry
343 allocate(new_files(store%file_count + 1))
344 if (store%file_count > 0) then
345 new_files(1:store%file_count) = store%files(1:store%file_count)
346 end if
347
348 idx = store%file_count + 1
349 new_files(idx)%uri = uri
350 new_files(idx)%count = 0
351 allocate(new_files(idx)%items(0))
352
353 deallocate(store%files)
354 store%files = new_files
355 store%file_count = idx
356 end function find_or_create_file
357
358 subroutine clear_diagnostics(store, uri)
359 type(diagnostics_store_t), intent(inout) :: store
360 character(len=*), intent(in) :: uri
361 integer :: i
362
363 do i = 1, store%file_count
364 if (store%files(i)%uri == uri) then
365 if (allocated(store%files(i)%items)) then
366 deallocate(store%files(i)%items)
367 end if
368 store%files(i)%count = 0
369 allocate(store%files(i)%items(0))
370 exit
371 end if
372 end do
373 end subroutine clear_diagnostics
374
375 function get_diagnostics_for_line(store, uri, line) result(diagnostics)
376 type(diagnostics_store_t), intent(in) :: store
377 character(len=*), intent(in) :: uri
378 integer, intent(in) :: line ! 1-based editor line
379 type(diagnostic_t), allocatable :: diagnostics(:)
380 integer :: i, j, count, lsp_line
381
382 lsp_line = line - 1 ! Convert to 0-based
383
384 allocate(diagnostics(0))
385
386 do i = 1, store%file_count
387 if (store%files(i)%uri == uri) then
388 count = 0
389 ! Count diagnostics on this line
390 do j = 1, store%files(i)%count
391 if (store%files(i)%items(j)%range%start_line <= lsp_line .and. &
392 store%files(i)%items(j)%range%end_line >= lsp_line) then
393 count = count + 1
394 end if
395 end do
396
397 if (count > 0) then
398 deallocate(diagnostics)
399 allocate(diagnostics(count))
400 count = 0
401 do j = 1, store%files(i)%count
402 if (store%files(i)%items(j)%range%start_line <= lsp_line .and. &
403 store%files(i)%items(j)%range%end_line >= lsp_line) then
404 count = count + 1
405 diagnostics(count) = store%files(i)%items(j)
406 end if
407 end do
408 end if
409 exit
410 end if
411 end do
412 end function get_diagnostics_for_line
413
414 ! Get diagnostics for a specific line, filtered by server index
415 ! Used for code actions to only send diagnostics from the server being queried
416 function get_diagnostics_for_line_by_server(store, uri, line, server_index) result(diagnostics)
417 type(diagnostics_store_t), intent(in) :: store
418 character(len=*), intent(in) :: uri
419 integer, intent(in) :: line ! 1-based editor line
420 integer, intent(in) :: server_index
421 type(diagnostic_t), allocatable :: diagnostics(:)
422 integer :: i, j, count, lsp_line
423
424 lsp_line = line - 1 ! Convert to 0-based
425
426 allocate(diagnostics(0))
427
428 do i = 1, store%file_count
429 if (store%files(i)%uri == uri) then
430 count = 0
431 ! Count diagnostics on this line from this server
432 do j = 1, store%files(i)%count
433 if (store%files(i)%items(j)%server_index == server_index .and. &
434 store%files(i)%items(j)%range%start_line <= lsp_line .and. &
435 store%files(i)%items(j)%range%end_line >= lsp_line) then
436 count = count + 1
437 end if
438 end do
439
440 if (count > 0) then
441 deallocate(diagnostics)
442 allocate(diagnostics(count))
443 count = 0
444 do j = 1, store%files(i)%count
445 if (store%files(i)%items(j)%server_index == server_index .and. &
446 store%files(i)%items(j)%range%start_line <= lsp_line .and. &
447 store%files(i)%items(j)%range%end_line >= lsp_line) then
448 count = count + 1
449 diagnostics(count) = store%files(i)%items(j)
450 end if
451 end do
452 end if
453 exit
454 end if
455 end do
456 end function get_diagnostics_for_line_by_server
457
458 function get_diagnostic_at_cursor(store, uri, line, col) result(diagnostic)
459 type(diagnostics_store_t), intent(in) :: store
460 character(len=*), intent(in) :: uri
461 integer, intent(in) :: line, col ! 1-based editor position
462 type(diagnostic_t) :: diagnostic
463 integer :: i, j, lsp_line, lsp_col
464 logical :: found
465
466 lsp_line = line - 1 ! Convert to 0-based
467 lsp_col = col - 1
468
469 found = .false.
470 diagnostic%severity = SEVERITY_INFO
471 diagnostic%message = ""
472
473 do i = 1, store%file_count
474 if (store%files(i)%uri == uri) then
475 do j = 1, store%files(i)%count
476 if (lsp_line >= store%files(i)%items(j)%range%start_line .and. &
477 lsp_line <= store%files(i)%items(j)%range%end_line) then
478
479 ! Check column range if on same line
480 if ((lsp_line == store%files(i)%items(j)%range%start_line .and. &
481 lsp_col >= store%files(i)%items(j)%range%start_col) .or. &
482 (lsp_line == store%files(i)%items(j)%range%end_line .and. &
483 lsp_col <= store%files(i)%items(j)%range%end_col) .or. &
484 (lsp_line > store%files(i)%items(j)%range%start_line .and. &
485 lsp_line < store%files(i)%items(j)%range%end_line)) then
486
487 diagnostic = store%files(i)%items(j)
488 found = .true.
489 exit
490 end if
491 end if
492 end do
493 exit
494 end if
495 end do
496 end function get_diagnostic_at_cursor
497
498 function has_diagnostics_for_file(store, uri) result(has_diags)
499 type(diagnostics_store_t), intent(in) :: store
500 character(len=*), intent(in) :: uri
501 logical :: has_diags
502 integer :: i
503
504 has_diags = .false.
505 do i = 1, store%file_count
506 if (store%files(i)%uri == uri) then
507 has_diags = store%files(i)%count > 0
508 exit
509 end if
510 end do
511 end function has_diagnostics_for_file
512
513 function get_diagnostics_for_file(store, uri) result(diagnostics)
514 type(diagnostics_store_t), intent(in) :: store
515 character(len=*), intent(in) :: uri
516 type(diagnostic_t), allocatable :: diagnostics(:)
517 integer :: i
518
519 ! Find file and return all its diagnostics
520 do i = 1, store%file_count
521 if (store%files(i)%uri == uri) then
522 if (store%files(i)%count > 0) then
523 allocate(diagnostics(store%files(i)%count))
524 diagnostics = store%files(i)%items(1:store%files(i)%count)
525 else
526 allocate(diagnostics(0))
527 end if
528 return
529 end if
530 end do
531
532 ! File not found, return empty array
533 allocate(diagnostics(0))
534 end function get_diagnostics_for_file
535
536 ! Convert diagnostics array to JSON array for LSP codeAction context
537 function diagnostics_to_json(diagnostics) result(json_array)
538 use json_module, only: json_value_t, json_create_array, json_create_object, &
539 json_add_string, json_add_number, json_add_object, &
540 json_array_add_element, json_parse
541 use iso_fortran_env, only: real64
542 type(diagnostic_t), intent(in) :: diagnostics(:)
543 type(json_value_t) :: json_array
544 type(json_value_t) :: diag_obj, range_obj, start_pos, end_pos, data_obj
545 integer :: i
546
547 json_array = json_create_array()
548
549 do i = 1, size(diagnostics)
550 diag_obj = json_create_object()
551
552 ! Build range object
553 range_obj = json_create_object()
554 start_pos = json_create_object()
555 call json_add_number(start_pos, "line", real(diagnostics(i)%range%start_line, real64))
556 call json_add_number(start_pos, "character", real(diagnostics(i)%range%start_col, real64))
557 call json_add_object(range_obj, "start", start_pos)
558
559 end_pos = json_create_object()
560 call json_add_number(end_pos, "line", real(diagnostics(i)%range%end_line, real64))
561 call json_add_number(end_pos, "character", real(diagnostics(i)%range%end_col, real64))
562 call json_add_object(range_obj, "end", end_pos)
563
564 call json_add_object(diag_obj, "range", range_obj)
565
566 ! Add severity
567 call json_add_number(diag_obj, "severity", real(diagnostics(i)%severity, real64))
568
569 ! Add message
570 if (allocated(diagnostics(i)%message)) then
571 call json_add_string(diag_obj, "message", diagnostics(i)%message)
572 else
573 call json_add_string(diag_obj, "message", "")
574 end if
575
576 ! Add source if present (use nested if to ensure short-circuit evaluation)
577 if (allocated(diagnostics(i)%source)) then
578 if (len_trim(diagnostics(i)%source) > 0) then
579 call json_add_string(diag_obj, "source", diagnostics(i)%source)
580 end if
581 end if
582
583 ! Add code if present (use nested if to ensure short-circuit evaluation)
584 if (allocated(diagnostics(i)%code)) then
585 if (len_trim(diagnostics(i)%code) > 0) then
586 call json_add_string(diag_obj, "code", diagnostics(i)%code)
587 end if
588 end if
589
590 ! Add data field if present (required for Ruff quickfixes, LSP-agnostic)
591 if (allocated(diagnostics(i)%data)) then
592 if (len_trim(diagnostics(i)%data) > 0) then
593 data_obj = json_parse(diagnostics(i)%data)
594 call json_add_object(diag_obj, "data", data_obj)
595 end if
596 end if
597
598 call json_array_add_element(json_array, diag_obj)
599 end do
600 end function diagnostics_to_json
601
602 end module diagnostics_module