| 1 | ! ============================================================================== |
| 2 | ! Module: better_errors |
| 3 | ! Purpose: Enhanced error messages with helpful suggestions |
| 4 | ! ============================================================================== |
| 5 | module better_errors |
| 6 | use iso_fortran_env, only: error_unit |
| 7 | use system_interface, only: get_environment_var, c_isatty |
| 8 | use io_helpers, only: write_stderr |
| 9 | use iso_c_binding, only: c_int |
| 10 | implicit none |
| 11 | private |
| 12 | |
| 13 | ! Public interface |
| 14 | public :: show_command_not_found_error |
| 15 | public :: suggest_similar_commands |
| 16 | public :: levenshtein_distance |
| 17 | |
| 18 | ! ANSI color codes for errors |
| 19 | integer, parameter :: COLOR_RED = 31 |
| 20 | integer, parameter :: COLOR_YELLOW = 33 |
| 21 | integer, parameter :: COLOR_CYAN = 36 |
| 22 | integer, parameter :: COLOR_GREEN = 32 |
| 23 | integer, parameter :: COLOR_RESET = 0 |
| 24 | |
| 25 | ! Maximum suggestions to show |
| 26 | integer, parameter :: MAX_SUGGESTIONS = 3 |
| 27 | integer, parameter :: MAX_EDIT_DISTANCE = 3 |
| 28 | |
| 29 | contains |
| 30 | |
| 31 | ! Show enhanced "command not found" error with suggestions |
| 32 | subroutine show_command_not_found_error(command) |
| 33 | use system_interface, only: file_exists |
| 34 | character(len=*), intent(in) :: command |
| 35 | character(len=256), allocatable :: suggestions(:) |
| 36 | integer :: num_suggestions, i |
| 37 | character(len=10) :: shell_name |
| 38 | character(len=64) :: error_msg |
| 39 | |
| 40 | ! Use "sh" for POSIX compliance in non-interactive mode |
| 41 | if (stderr_is_tty()) then |
| 42 | shell_name = "fortsh" |
| 43 | else |
| 44 | shell_name = "sh" |
| 45 | end if |
| 46 | |
| 47 | ! POSIX: For paths (containing /), use "No such file or directory" if file doesn't exist |
| 48 | ! Use "command not found" only for bare command names searched in PATH |
| 49 | if (index(command, '/') > 0) then |
| 50 | if (.not. file_exists(trim(command))) then |
| 51 | error_msg = "No such file or directory" |
| 52 | else |
| 53 | error_msg = "Permission denied" |
| 54 | end if |
| 55 | else |
| 56 | error_msg = "command not found" |
| 57 | end if |
| 58 | |
| 59 | ! Print main error message in red (POSIX format) |
| 60 | if (stderr_is_tty()) then |
| 61 | call write_stderr(trim(color_code(COLOR_RED)) // & |
| 62 | trim(shell_name) // ': ' // trim(command) // ': ' // trim(error_msg)) |
| 63 | call write_stderr(trim(color_code(COLOR_RESET))) |
| 64 | else |
| 65 | call write_stderr(trim(shell_name) // ': ' // trim(command) // ': ' // trim(error_msg)) |
| 66 | end if |
| 67 | |
| 68 | ! Only show suggestions if stderr is a TTY (interactive mode) |
| 69 | if (.not. stderr_is_tty()) return |
| 70 | |
| 71 | ! Try to find similar commands |
| 72 | call suggest_similar_commands(command, suggestions, num_suggestions) |
| 73 | |
| 74 | if (num_suggestions > 0) then |
| 75 | ! Print suggestions |
| 76 | write(error_unit, '(a)', advance='no') trim(color_code(COLOR_CYAN)) |
| 77 | write(error_unit, '(a)', advance='no') "Did you mean" |
| 78 | |
| 79 | if (num_suggestions == 1) then |
| 80 | write(error_unit, '(a)', advance='no') " '" |
| 81 | write(error_unit, '(a)', advance='no') trim(suggestions(1)) |
| 82 | write(error_unit, '(a)') "'?" |
| 83 | else |
| 84 | write(error_unit, '(a)') ":" |
| 85 | do i = 1, num_suggestions |
| 86 | write(error_unit, '(a)', advance='no') " " |
| 87 | write(error_unit, '(a)', advance='no') trim(color_code(COLOR_GREEN)) |
| 88 | write(error_unit, '(a)', advance='no') trim(suggestions(i)) |
| 89 | write(error_unit, '(a)') trim(color_code(COLOR_CYAN)) |
| 90 | end do |
| 91 | end if |
| 92 | write(error_unit, '(a)') trim(color_code(COLOR_RESET)) |
| 93 | end if |
| 94 | |
| 95 | ! Cleanup |
| 96 | if (allocated(suggestions)) deallocate(suggestions) |
| 97 | end subroutine |
| 98 | |
| 99 | ! Find similar commands in PATH and builtins |
| 100 | subroutine suggest_similar_commands(command, suggestions, num_suggestions) |
| 101 | character(len=*), intent(in) :: command |
| 102 | character(len=256), allocatable, intent(out) :: suggestions(:) |
| 103 | integer, intent(out) :: num_suggestions |
| 104 | |
| 105 | character(len=256), allocatable :: candidates(:) |
| 106 | integer, allocatable :: distances(:) |
| 107 | integer :: num_candidates, i, min_dist |
| 108 | character(len=256) :: temp_suggestions(MAX_SUGGESTIONS) |
| 109 | |
| 110 | ! Get candidate commands |
| 111 | call get_command_candidates(candidates, num_candidates) |
| 112 | |
| 113 | if (num_candidates == 0) then |
| 114 | num_suggestions = 0 |
| 115 | return |
| 116 | end if |
| 117 | |
| 118 | ! Allocate distances array |
| 119 | allocate(distances(num_candidates)) |
| 120 | |
| 121 | ! Calculate edit distance for each candidate |
| 122 | do i = 1, num_candidates |
| 123 | distances(i) = levenshtein_distance(command, candidates(i)) |
| 124 | end do |
| 125 | |
| 126 | ! Find commands within acceptable edit distance |
| 127 | min_dist = minval(distances) |
| 128 | num_suggestions = 0 |
| 129 | |
| 130 | ! Only suggest if distance is reasonable |
| 131 | if (min_dist > MAX_EDIT_DISTANCE) then |
| 132 | deallocate(candidates, distances) |
| 133 | return |
| 134 | end if |
| 135 | |
| 136 | ! Collect suggestions (up to MAX_SUGGESTIONS) |
| 137 | do i = 1, num_candidates |
| 138 | if (num_suggestions >= MAX_SUGGESTIONS) exit |
| 139 | |
| 140 | ! Include commands with distance <= min_dist + 1 |
| 141 | if (distances(i) <= min(min_dist + 1, MAX_EDIT_DISTANCE)) then |
| 142 | num_suggestions = num_suggestions + 1 |
| 143 | temp_suggestions(num_suggestions) = trim(candidates(i)) |
| 144 | end if |
| 145 | end do |
| 146 | |
| 147 | ! Copy to output |
| 148 | if (num_suggestions > 0) then |
| 149 | allocate(suggestions(num_suggestions)) |
| 150 | do i = 1, num_suggestions |
| 151 | suggestions(i) = temp_suggestions(i) |
| 152 | end do |
| 153 | end if |
| 154 | |
| 155 | ! Cleanup |
| 156 | deallocate(candidates, distances) |
| 157 | end subroutine |
| 158 | |
| 159 | ! Get list of candidate commands (builtins + PATH) |
| 160 | subroutine get_command_candidates(candidates, num_candidates) |
| 161 | character(len=256), allocatable, intent(out) :: candidates(:) |
| 162 | integer, intent(out) :: num_candidates |
| 163 | |
| 164 | character(len=256), allocatable :: temp_candidates(:) |
| 165 | character(len=:), allocatable :: path_env |
| 166 | character(len=1024) :: dir |
| 167 | integer :: max_candidates, path_start, path_end, colon_pos |
| 168 | logical :: dir_exists |
| 169 | |
| 170 | max_candidates = 1000 |
| 171 | allocate(temp_candidates(max_candidates)) |
| 172 | num_candidates = 0 |
| 173 | |
| 174 | ! Add common builtins |
| 175 | call add_builtins(temp_candidates, num_candidates, max_candidates) |
| 176 | |
| 177 | ! Get PATH |
| 178 | path_env = get_environment_var('PATH') |
| 179 | if (.not. allocated(path_env) .or. len_trim(path_env) == 0) then |
| 180 | ! Just return builtins |
| 181 | allocate(candidates(num_candidates)) |
| 182 | candidates(1:num_candidates) = temp_candidates(1:num_candidates) |
| 183 | deallocate(temp_candidates) |
| 184 | return |
| 185 | end if |
| 186 | |
| 187 | ! Search PATH directories for executables |
| 188 | path_start = 1 |
| 189 | do while (path_start <= len_trim(path_env) .and. num_candidates < max_candidates) |
| 190 | ! Find next colon |
| 191 | colon_pos = index(path_env(path_start:), ':') |
| 192 | if (colon_pos > 0) then |
| 193 | path_end = path_start + colon_pos - 2 |
| 194 | else |
| 195 | path_end = len_trim(path_env) |
| 196 | end if |
| 197 | |
| 198 | ! Extract directory |
| 199 | dir = path_env(path_start:path_end) |
| 200 | |
| 201 | ! Check if directory exists (simple check) |
| 202 | inquire(file=trim(dir), exist=dir_exists) |
| 203 | if (dir_exists) then |
| 204 | ! Try to list files in directory using ls |
| 205 | ! This is a simplified version - in production, use directory listing |
| 206 | ! For now, just add a few common commands |
| 207 | if (num_candidates < max_candidates) then |
| 208 | ! Just add some known commands for demonstration |
| 209 | ! In full implementation, would scan directory |
| 210 | end if |
| 211 | end if |
| 212 | |
| 213 | ! Move to next directory |
| 214 | if (colon_pos > 0) then |
| 215 | path_start = path_start + colon_pos |
| 216 | else |
| 217 | exit |
| 218 | end if |
| 219 | end do |
| 220 | |
| 221 | ! Copy to output |
| 222 | allocate(candidates(num_candidates)) |
| 223 | candidates(1:num_candidates) = temp_candidates(1:num_candidates) |
| 224 | deallocate(temp_candidates) |
| 225 | end subroutine |
| 226 | |
| 227 | ! Add builtin commands to candidate list |
| 228 | subroutine add_builtins(candidates, num, max_count) |
| 229 | character(len=*), intent(inout) :: candidates(:) |
| 230 | integer, intent(inout) :: num |
| 231 | integer, intent(in) :: max_count |
| 232 | |
| 233 | character(len=20) :: builtins(50) |
| 234 | integer :: i, n_builtins |
| 235 | |
| 236 | ! Common builtins that users might typo |
| 237 | builtins = [ & |
| 238 | 'cd ', 'ls ', 'echo ', 'pwd ', 'exit ', & |
| 239 | 'export ', 'set ', 'unset ', 'alias ', 'unalias ', & |
| 240 | 'source ', 'history ', 'jobs ', 'fg ', 'bg ', & |
| 241 | 'kill ', 'wait ', 'read ', 'printf ', 'test ', & |
| 242 | 'type ', 'command ', 'builtin ', 'declare ', 'local ', & |
| 243 | 'return ', 'shift ', 'break ', 'continue', 'eval ', & |
| 244 | 'exec ', 'trap ', 'ulimit ', 'umask ', 'getopts ', & |
| 245 | 'hash ', 'help ', 'fc ', 'complete', 'compgen ', & |
| 246 | 'git ', 'grep ', 'find ', 'sed ', 'awk ', & |
| 247 | 'cat ', 'less ', 'more ', 'vim ', 'nano ' & |
| 248 | ] |
| 249 | n_builtins = 50 |
| 250 | |
| 251 | do i = 1, n_builtins |
| 252 | if (num >= max_count) exit |
| 253 | num = num + 1 |
| 254 | candidates(num) = trim(builtins(i)) |
| 255 | end do |
| 256 | end subroutine |
| 257 | |
| 258 | ! Calculate Levenshtein distance (edit distance) between two strings |
| 259 | function levenshtein_distance(s1, s2) result(distance) |
| 260 | character(len=*), intent(in) :: s1, s2 |
| 261 | integer :: distance |
| 262 | |
| 263 | integer :: len1, len2, i, j, cost |
| 264 | integer, allocatable :: matrix(:,:) |
| 265 | |
| 266 | len1 = len_trim(s1) |
| 267 | len2 = len_trim(s2) |
| 268 | |
| 269 | ! Handle empty strings |
| 270 | if (len1 == 0) then |
| 271 | distance = len2 |
| 272 | return |
| 273 | end if |
| 274 | if (len2 == 0) then |
| 275 | distance = len1 |
| 276 | return |
| 277 | end if |
| 278 | |
| 279 | ! Allocate matrix (0:len1, 0:len2) |
| 280 | allocate(matrix(0:len1, 0:len2)) |
| 281 | |
| 282 | ! Initialize first row and column |
| 283 | do i = 0, len1 |
| 284 | matrix(i, 0) = i |
| 285 | end do |
| 286 | do j = 0, len2 |
| 287 | matrix(0, j) = j |
| 288 | end do |
| 289 | |
| 290 | ! Fill matrix using dynamic programming |
| 291 | do j = 1, len2 |
| 292 | do i = 1, len1 |
| 293 | if (s1(i:i) == s2(j:j)) then |
| 294 | cost = 0 |
| 295 | else |
| 296 | cost = 1 |
| 297 | end if |
| 298 | |
| 299 | matrix(i, j) = min( & |
| 300 | matrix(i-1, j) + 1, & ! Deletion |
| 301 | matrix(i, j-1) + 1, & ! Insertion |
| 302 | matrix(i-1, j-1) + cost & ! Substitution |
| 303 | ) |
| 304 | end do |
| 305 | end do |
| 306 | |
| 307 | distance = matrix(len1, len2) |
| 308 | deallocate(matrix) |
| 309 | end function |
| 310 | |
| 311 | ! Check if stderr is a TTY (terminal) |
| 312 | function stderr_is_tty() result(is_tty) |
| 313 | logical :: is_tty |
| 314 | integer(c_int) :: result |
| 315 | |
| 316 | ! error_unit is typically 0 or 2 depending on implementation |
| 317 | ! Standard error is file descriptor 2 |
| 318 | result = c_isatty(int(2, c_int)) |
| 319 | is_tty = (result /= 0) |
| 320 | end function |
| 321 | |
| 322 | ! Generate ANSI color code |
| 323 | function color_code(color) result(code) |
| 324 | integer, intent(in) :: color |
| 325 | character(len=20) :: code ! Increased to handle i15 format + escape sequences |
| 326 | |
| 327 | ! Only use colors if stderr is a TTY |
| 328 | if (.not. stderr_is_tty()) then |
| 329 | code = '' |
| 330 | return |
| 331 | end if |
| 332 | |
| 333 | if (color == COLOR_RESET) then |
| 334 | code = char(27) // '[0m' |
| 335 | else |
| 336 | write(code, '(a,i15,a)') char(27) // '[', color, 'm' |
| 337 | end if |
| 338 | end function |
| 339 | |
| 340 | end module better_errors |
| 341 |