Find and remove dead code and unused APIs in OCaml projects
at main 472 lines 17 kB view raw
1(* Main prune library - public interface and orchestration *) 2 3open Removal 4open Result.Syntax 5module Log = (val Logs.src_log (Logs.Src.create "prune") : Logs.LOG) 6include Types 7(* Re-export core types *) 8 9module Doctor = Doctor 10module Show = Show 11module Output = Output 12 13(* Error helper functions *) 14let err fmt = Fmt.kstr (fun e -> Error (`Msg e)) fmt 15 16let pp_build_error ppf ctx = 17 match last_build_result ctx with 18 | None -> Fmt.pf ppf "No build output available" 19 | Some result -> Fmt.pf ppf "%s" result.output 20 21let err_build_failed ctx = err "Build failed:@.%a" pp_build_error ctx 22let err_build_no_info () = err "Build failed with no error information" 23let err_build_error ctx = Error (`Build_error ctx) 24 25(* {2 User interaction} *) 26 27(* Ask user for confirmation, defaulting to 'no' if not in a TTY *) 28let ask_confirmation prompt = 29 if System.is_tty () then ( 30 Fmt.pr "%s [y/N]: %!" prompt; 31 try 32 let response = read_line () in 33 String.lowercase_ascii (String.trim response) = "y" 34 with End_of_file -> false (* Handle Ctrl+D *)) 35 else ( 36 (* Not in TTY - default to 'no' *) 37 Fmt.pr "%s [y/N]: n (not a tty)@." prompt; 38 false) 39 40(* Ask user for confirmation to remove exports *) 41let confirm_removal () = 42 ask_confirmation "@.Do you want to remove these unused exports?" 43 44(* {2 Reporting functions} *) 45 46(* Get relative path for display *) 47let relative_path root_dir file = 48 let root_path = Fpath.v root_dir in 49 let file_path = Fpath.v file in 50 match Fpath.relativize ~root:root_path file_path with 51 | Some rel -> Fpath.to_string rel 52 | None -> file 53 54(* Count total symbols in unused_by_file list *) 55let count_total_symbols unused_by_file = 56 List.fold_left 57 (fun acc (_, symbols) -> acc + List.length symbols) 58 0 unused_by_file 59 60(* Compare symbol_info by line number *) 61let compare_symbol_info (a : symbol_info) (b : symbol_info) = 62 compare a.location.start_line b.location.start_line 63 64(* Display unused or test-only exports in a formatted report *) 65let display_exports ?(label = "unused") ?(no_exports_msg = "") 66 ?(show_count = true) occurrences_by_file = 67 Log.debug (fun m -> 68 m "display_exports (%s): %d files" label (List.length occurrences_by_file)); 69 70 match occurrences_by_file with 71 | [] -> if no_exports_msg <> "" then Fmt.pr "%s" no_exports_msg 72 | _ -> 73 (* Sort files and print each export *) 74 let sorted_files = 75 List.sort (fun (f1, _) (f2, _) -> compare f1 f2) occurrences_by_file 76 in 77 let total_count = 78 List.fold_left 79 (fun count (_file, occs) -> 80 let sorted_occs = 81 List.sort (fun a b -> compare_symbol_info a.symbol b.symbol) occs 82 in 83 List.iter 84 (fun (occ : occurrence_info) -> 85 Fmt.pr "%a: %s %s %s@." pp_location occ.symbol.location label 86 (string_of_symbol_kind occ.symbol.kind) 87 occ.symbol.name) 88 sorted_occs; 89 count + List.length occs) 90 0 sorted_files 91 in 92 if show_count then Fmt.pr "Found %d %s exports@." total_count label 93 94(* Perform actual removal of unused exports *) 95let perform_unused_exports_removal ~cache root_dir unused_by_file = 96 let total = count_total_symbols unused_by_file in 97 Fmt.pr "Removing %d unused exports...@." total; 98 99 let results = 100 List.map 101 (fun (file, symbols) -> 102 let relative_file = relative_path root_dir file in 103 match remove_unused_exports ~cache root_dir file symbols with 104 | Ok () -> 105 Fmt.pr "✓ %s@." relative_file; 106 Ok () 107 | Error e -> 108 Fmt.pr "✗ %s: %a@." relative_file pp_error e; 109 Error e) 110 unused_by_file 111 in 112 113 (* Return the first error if any *) 114 let errors = 115 List.filter_map (function Error e -> Some e | Ok () -> None) results 116 in 117 match errors with [] -> Ok () | e :: _ -> Error e 118 119(* {2 Public interface functions} *) 120 121(* Helper function to build project and handle errors *) 122let with_built_project ?(ctx = empty_context) root_dir f = 123 match System.build_project_and_index root_dir ctx with 124 | Ok () -> f ctx 125 | Error (`Build_failed ctx) -> ( 126 match System.classify_build_error ctx with 127 | No_error -> err_build_no_info () 128 | Fixable_errors _ -> err_build_failed ctx 129 | Other_errors _output -> 130 (* Return error with context for main.ml to handle *) 131 err_build_error ctx) 132 133(* Helper to print summary *) 134let print_iteration_summary ~cache iteration total_mli total_ml = 135 let lines_removed = Cache.count_lines_removed cache in 136 let stats = 137 { 138 mli_exports_removed = total_mli; 139 ml_implementations_removed = total_ml; 140 iterations = (if total_mli = 0 && total_ml = 0 then 0 else iteration - 1); 141 lines_removed; 142 } 143 in 144 if iteration = 1 && total_mli = 0 && total_ml = 0 then ( 145 Log.info (fun m -> m "Analysis complete: no unused code found"); 146 Fmt.pr " "; 147 Output.success "✓ No unused code found") 148 else ( 149 Log.info (fun m -> 150 m "Iterative analysis complete after %d iterations" (iteration - 1)); 151 Output.success "✓ No more unused code found"; 152 Fmt.pr "@.%a@." pp_stats stats); 153 stats 154 155(* Convert occurrence info to symbol info for removal *) 156let extract_symbols occurrences = 157 List.map 158 (fun (file, occs) -> (file, List.map (fun occ -> occ.symbol) occs)) 159 occurrences 160 161(* Process and remove unused exports, returning the count of removed items *) 162let process_unused_exports ~cache ~yes ~iteration root_dir all_removable = 163 let count = count_total_symbols (extract_symbols all_removable) in 164 165 (* First iteration with confirmation prompt *) 166 if (not yes) && iteration = 1 then ( 167 Fmt.pr "@.Found %d unused exports:@." count; 168 display_exports all_removable; 169 if not (ask_confirmation "Remove unused exports?") then ( 170 Fmt.pr "Cancelled - no changes made@."; 171 Error (`Msg "Cancelled by user")) 172 else 173 match 174 perform_unused_exports_removal ~cache root_dir 175 (extract_symbols all_removable) 176 with 177 | Error e -> Error e 178 | Ok () -> Ok count) 179 (* Subsequent iterations or --force mode: no prompt *) 180 else 181 match 182 perform_unused_exports_removal ~cache root_dir 183 (extract_symbols all_removable) 184 with 185 | Error e -> Error e 186 | Ok () -> Ok count 187 188(* Find and remove unused exports from .mli files *) 189let and_remove_exports ~cache ~yes ~exclude_dirs ~public_files ~iteration 190 root_dir mli_files = 191 (* Build first to ensure accurate usage information *) 192 match System.build_project_and_index root_dir empty_context with 193 | Error (`Build_failed _) -> 194 Ok 0 (* Continue if build fails - we may be able to fix it *) 195 | Ok () -> ( 196 match Analysis.unused_exports ~cache ~exclude_dirs root_dir mli_files with 197 | Error e -> Error e 198 | Ok (unused_by_file, excluded_only_by_file) -> 199 (* Filter out public files from removal *) 200 let unused_by_file = 201 List.filter 202 (fun (f, _) -> not (List.mem f public_files)) 203 unused_by_file 204 in 205 let excluded_only_by_file = 206 List.filter 207 (fun (f, _) -> not (List.mem f public_files)) 208 excluded_only_by_file 209 in 210 let all_removable = unused_by_file @ excluded_only_by_file in 211 if all_removable = [] then Ok 0 212 else 213 process_unused_exports ~cache ~yes ~iteration root_dir all_removable 214 ) 215 216(* Fix warnings in both .ml and .mli files *) 217let fix_all_warnings ~cache root_dir warnings = 218 let ml_warnings = 219 List.filter 220 (fun (w : warning_info) -> String.ends_with ~suffix:".ml" w.location.file) 221 warnings 222 in 223 224 let mli_warnings = 225 List.filter 226 (fun (w : warning_info) -> 227 String.ends_with ~suffix:".mli" w.location.file) 228 warnings 229 in 230 231 (* Fix .ml warnings first *) 232 match 233 if ml_warnings = [] then Ok 0 234 else remove_warnings ~cache root_dir ml_warnings 235 with 236 | Error e -> Error e 237 | Ok ml_count -> ( 238 (* Then fix .mli warnings *) 239 match 240 if mli_warnings = [] then Ok 0 241 else remove_warnings ~cache root_dir mli_warnings 242 with 243 | Error e -> Error e 244 | Ok mli_count -> 245 let total = ml_count + mli_count in 246 if total > 0 then 247 Fmt.pr " Fixed %d error%s@." total (if total = 1 then "" else "s"); 248 Ok total) 249 250(* Handle clean build after removing exports *) 251let handle_clean_build ~cache iteration total_mli total_ml mli_changes loop = 252 if mli_changes = 0 then 253 let stats = print_iteration_summary ~cache iteration total_mli total_ml in 254 Ok stats 255 else 256 (* Continue with next iteration *) 257 loop (iteration + 1) (total_mli + mli_changes) total_ml 258 259(* Handle build failure and try to fix warnings *) 260let handle_build_failure ~cache root_dir iteration total_mli total_ml 261 mli_changes loop ctx = 262 match System.classify_build_error ctx with 263 | No_error -> err_build_no_info () 264 | Other_errors _ -> err_build_error ctx 265 | Fixable_errors warnings -> ( 266 (* Fix warnings and continue *) 267 match fix_all_warnings ~cache root_dir warnings with 268 | Error e -> Error e 269 | Ok warning_count -> 270 if warning_count = 0 && mli_changes = 0 then 271 (* No progress made - we're done *) 272 let stats = 273 print_iteration_summary ~cache iteration total_mli total_ml 274 in 275 Ok stats 276 else 277 (* Made progress - continue *) 278 loop (iteration + 1) (total_mli + mli_changes) 279 (total_ml + warning_count)) 280 281(* Main iterative analysis loop *) 282let iterative_analysis ~cache ~yes ~exclude_dirs ~public_files root_dir 283 mli_files = 284 Fmt.pr "@."; 285 286 let rec loop iteration total_mli total_ml : (stats, error) result = 287 (* Show progress *) 288 if iteration > 1 then Fmt.pr "@."; 289 Output.section "Iteration %d:" iteration; 290 291 (* Remove unused exports *) 292 match 293 and_remove_exports ~cache ~yes ~exclude_dirs ~public_files ~iteration 294 root_dir mli_files 295 with 296 | Error (`Msg "Cancelled by user") -> Error (`Msg "Cancelled by user") 297 | Error e -> Error e 298 | Ok mli_changes -> ( 299 (* Build and check for warnings *) 300 match System.build_project_and_index root_dir empty_context with 301 | Ok () -> 302 handle_clean_build ~cache iteration total_mli total_ml mli_changes 303 loop 304 | Error (`Build_failed ctx) -> 305 handle_build_failure ~cache root_dir iteration total_mli total_ml 306 mli_changes loop ctx) 307 in 308 309 loop 1 0 0 310 311type mode = [ `Dry_run | `Single_pass | `Iterative ] 312 313(* Unified analyze function that handles all modes *) 314(* Display dry run results *) 315let display_dry_run_results ~unused_in_regular ~excluded_in_regular 316 ~unused_in_public ~excluded_in_public = 317 (* Display unused exports in regular files *) 318 if List.length unused_in_regular > 0 then display_exports unused_in_regular; 319 320 (* Display excluded-only exports in regular files *) 321 if List.length excluded_in_regular > 0 then ( 322 Output.warning "Some exports are only used in excluded directories"; 323 display_exports ~label:"used only in excluded dirs" excluded_in_regular); 324 325 (* Display info about public files if they have unused exports *) 326 let public_unused = unused_in_public @ excluded_in_public in 327 if List.length public_unused > 0 then ( 328 Fmt.pr "@."; 329 Output.section "Unused exports in public files (will not be removed):"; 330 display_exports ~label:"unused (public)" ~show_count:false public_unused); 331 332 let removable = unused_in_regular @ excluded_in_regular in 333 let total = count_total_symbols removable in 334 if total = 0 && List.length public_unused > 0 then 335 Fmt.pr "No removable exports (only public files have unused exports)@."; 336 total 337 338(* Handle dry run mode *) 339let analyze_dry_run ~cache ~exclude_dirs ~public_files root_dir mli_files = 340 with_built_project root_dir (fun _ctx -> 341 let* unused_by_file, excluded_only_by_file = 342 Analysis.unused_exports ~cache ~exclude_dirs root_dir mli_files 343 in 344 (* Separate public and non-public files *) 345 let unused_in_public, unused_in_regular = 346 List.partition (fun (f, _) -> List.mem f public_files) unused_by_file 347 in 348 let excluded_in_public, excluded_in_regular = 349 List.partition 350 (fun (f, _) -> List.mem f public_files) 351 excluded_only_by_file 352 in 353 354 match 355 ( unused_in_regular, 356 excluded_in_regular, 357 unused_in_public, 358 excluded_in_public ) 359 with 360 | [], [], [], [] -> 361 Fmt.pr " "; 362 Output.success "No unused exports found!"; 363 Ok empty_stats 364 | _ -> 365 let total = 366 display_dry_run_results ~unused_in_regular ~excluded_in_regular 367 ~unused_in_public ~excluded_in_public 368 in 369 Ok { empty_stats with mli_exports_removed = total }) 370 371(* Handle single pass mode *) 372let analyze_single_pass ~cache ~yes ~exclude_dirs ~public_files root_dir 373 mli_files = 374 with_built_project root_dir (fun _ctx -> 375 let* unused_by_file, excluded_only_by_file = 376 Analysis.unused_exports ~cache ~exclude_dirs root_dir mli_files 377 in 378 (* Filter out public files from removal *) 379 let unused_by_file = 380 List.filter (fun (f, _) -> not (List.mem f public_files)) unused_by_file 381 in 382 let excluded_only_by_file = 383 List.filter 384 (fun (f, _) -> not (List.mem f public_files)) 385 excluded_only_by_file 386 in 387 (* Combine unused and excluded-only exports for removal *) 388 let all_removable = unused_by_file @ excluded_only_by_file in 389 match all_removable with 390 | [] -> 391 Fmt.pr " "; 392 Output.success "No unused exports found!"; 393 Ok empty_stats 394 | _ -> 395 (* Display both types of exports *) 396 if List.length unused_by_file > 0 then 397 display_exports ~no_exports_msg:"" unused_by_file; 398 if List.length excluded_only_by_file > 0 then 399 display_exports ~label:"used only in excluded dirs" 400 excluded_only_by_file; 401 if yes || confirm_removal () then 402 (* Convert to symbol_info for removal *) 403 let symbol_by_file = 404 List.map 405 (fun (file, occs) -> 406 (file, List.map (fun occ -> occ.symbol) occs)) 407 all_removable 408 in 409 let* () = 410 perform_unused_exports_removal ~cache root_dir symbol_by_file 411 in 412 let total = count_total_symbols symbol_by_file in 413 let lines_removed = Cache.count_lines_removed cache in 414 Ok 415 { 416 empty_stats with 417 mli_exports_removed = total; 418 lines_removed; 419 iterations = 1; 420 } 421 else ( 422 Fmt.pr "Aborted - no files were modified.@."; 423 Ok empty_stats)) 424 425let analyze ?(yes = false) ?(exclude_dirs = []) ?(public_files = []) mode 426 root_dir mli_files = 427 (* Report public files if any *) 428 (if public_files <> [] then 429 let public_in_analysis = 430 List.filter (fun f -> List.mem f mli_files) public_files 431 in 432 if public_in_analysis <> [] then ( 433 Output.section 434 "Marking %d file(s) as public APIs (will not be modified):" 435 (List.length public_in_analysis); 436 List.iter (fun f -> Fmt.pr " - %s@." f) public_in_analysis; 437 Fmt.pr "@.")); 438 439 let cache = Cache.v () in 440 let result = 441 match mode with 442 | `Dry_run -> 443 analyze_dry_run ~cache ~exclude_dirs ~public_files root_dir mli_files 444 | `Single_pass -> 445 analyze_single_pass ~cache ~yes ~exclude_dirs ~public_files root_dir 446 mli_files 447 | `Iterative -> 448 iterative_analysis ~cache ~yes ~exclude_dirs ~public_files root_dir 449 mli_files 450 in 451 (* Clear the file cache after processing *) 452 Cache.clear cache; 453 result 454 455module Removal = Removal 456module Cache = Cache 457(* Internal modules exposed for testing *) 458 459module System = System 460(* Internal system module exposed for main.ml *) 461 462module Analysis = Analysis 463(* Internal analysis module exposed for testing *) 464 465module Module_alias = Module_alias 466(* Internal module_alias module exposed for testing *) 467 468module Warning = Warning 469(* Internal warning module exposed for testing *) 470 471module Locate = Locate 472(* Internal locate module exposed for testing *)