Find and remove dead code and unused APIs in OCaml projects
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 *)