From 6ec30c43c39674d4cd91f396f1ad2343cc880554 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 6 May 2026 13:11:47 -0700 Subject: [PATCH 1/5] WIP add JSON check output --- CHANGES.md | 4 +++ README.md | 22 ++++++++++++++- bin/main.ml | 22 ++++++++++++--- lib/report.ml | 65 +++++++++++++++++++++++++++++++++++++++++++++ test/test_report.ml | 48 ++++++++++++++++++++++++++++++++- 5 files changed, 156 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 63fd8dd..4eb0b17 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Changelog +## 0.2.0 - unreleased + +- Add `doctor check --format json` for tools that need structured output. + ## 0.1.0 - unreleased - Initial public release. diff --git a/README.md b/README.md index 8db24bd..3a5b019 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ doctor check ```console doctor check +doctor check --format json doctor version doctor --help ``` @@ -99,6 +100,25 @@ OCaml Doctor Summary: 6 OK, 2 WARN, 0 ERROR ``` +For tools that need structured output, use JSON: + +```console +$ doctor check --format json +{ + "diagnostics": [ + { + "id": "platform.os", + "severity": "ok", + "title": "platform detected: macOS", + "detail": null, + "suggestion": null + } + ], + "summary": { "ok": 1, "warn": 0, "error": 0 }, + "exit_code": 0 +} +``` + ## Exit Codes - `0`: no warnings or errors @@ -136,7 +156,7 @@ specific shell setup. ## Contribution Ideas - Improve shell detection for PowerShell, cmd.exe, MSYS2, and Cygwin. -- Add JSON output for editor integrations and issue templates. +- Add more structured diagnostics for editor integrations and issue templates. - Add more editor checks without making VS Code mandatory. - Improve opam switch environment explanations on Windows. - Add targeted diagnostics for common dune and LSP project-layout problems. diff --git a/bin/main.ml b/bin/main.ml index 18c6877..1b8d85c 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -1,6 +1,15 @@ let version = Doctor.Version.current -let run_checks () = +type output_format = + | Text + | Json + +let render_diagnostics format diagnostics = + match format with + | Text -> Doctor.Report.render diagnostics + | Json -> Doctor.Report.render_json diagnostics + +let run_checks output_format = try let run = Doctor.Process.run in let os = Doctor.Platform.detect ~run () in @@ -10,7 +19,7 @@ let run_checks () = @ Doctor.Opam.diagnostics ~run os @ Doctor.Editor.diagnostics ~run in - print_string (Doctor.Report.render diagnostics); + print_string (render_diagnostics output_format diagnostics); Doctor.Report.exit_code diagnostics with | exn -> @@ -23,6 +32,13 @@ let print_version () = open Cmdliner +let output_format = + let formats = [ ("text", Text); ("json", Json) ] in + let doc = + "Choose the output format. $(docv) must be $(b,text) or $(b,json)." + in + Arg.(value & opt (enum formats) Text & info [ "format" ] ~docv:"FORMAT" ~doc) + let exit_infos = [ Cmd.Exit.info ~doc:"no warnings or errors." 0; @@ -37,7 +53,7 @@ let exit_infos = let check_cmd = let doc = "Run OCaml development environment diagnostics." in Cmd.v (Cmd.info "check" ~doc ~exits:exit_infos) - Term.(const run_checks $ const ()) + Term.(const run_checks $ output_format) let version_cmd = let doc = "Print the doctor version." in diff --git a/lib/report.ml b/lib/report.ml index 8bc222e..8cea311 100644 --- a/lib/report.ml +++ b/lib/report.ml @@ -45,6 +45,71 @@ let format_summary diagnostics = let ok, warn, error = counts diagnostics in Printf.sprintf "Summary: %d OK, %d WARN, %d ERROR" ok warn error +let json_escape text = + let buffer = Buffer.create (String.length text) in + String.iter + (function + | '"' -> Buffer.add_string buffer "\\\"" + | '\\' -> Buffer.add_string buffer "\\\\" + | '\b' -> Buffer.add_string buffer "\\b" + | '\012' -> Buffer.add_string buffer "\\f" + | '\n' -> Buffer.add_string buffer "\\n" + | '\r' -> Buffer.add_string buffer "\\r" + | '\t' -> Buffer.add_string buffer "\\t" + | char when Char.code char < 0x20 -> + Buffer.add_string buffer (Printf.sprintf "\\u%04x" (Char.code char)) + | char -> Buffer.add_char buffer char) + text; + Buffer.contents buffer + +let json_string text = + Printf.sprintf "\"%s\"" (json_escape text) + +let json_option = function + | Some value -> json_string value + | None -> "null" + +let json_severity = function + | Check.Ok -> "ok" + | Check.Warn -> "warn" + | Check.Error -> "error" + +let render_json_diagnostic diagnostic = + String.concat "\n" + [ + " {"; + Printf.sprintf " \"id\": %s," (json_string diagnostic.Check.id); + Printf.sprintf " \"severity\": %s," + (json_string (json_severity diagnostic.severity)); + Printf.sprintf " \"title\": %s," (json_string diagnostic.title); + Printf.sprintf " \"detail\": %s," (json_option diagnostic.detail); + Printf.sprintf " \"suggestion\": %s" + (json_option diagnostic.suggestion); + " }"; + ] + +let render_json diagnostics = + let ok, warn, error = counts diagnostics in + let diagnostic_lines = + diagnostics |> List.map render_json_diagnostic |> String.concat ",\n" + in + let diagnostics_json = + match diagnostic_lines with + | "" -> "[]" + | _ -> "[\n" ^ diagnostic_lines ^ "\n ]" + in + String.concat "\n" + [ + "{"; + Printf.sprintf " \"diagnostics\": %s," diagnostics_json; + Printf.sprintf + " \"summary\": { \"ok\": %d, \"warn\": %d, \"error\": %d }," ok warn + error; + Printf.sprintf " \"exit_code\": %d" (Check.exit_code diagnostics); + "}"; + ] + ^ "\n" + let render diagnostics = let body = match diagnostics with diff --git a/test/test_report.ml b/test/test_report.ml index 975874b..beae388 100644 --- a/test/test_report.ml +++ b/test/test_report.ml @@ -7,6 +7,11 @@ let expect_equal label expected actual = failwith (Printf.sprintf "%s: expected %d, got %d" label expected actual) +let expect_string label expected actual = + if not (String.equal expected actual) then + failwith + (Printf.sprintf "%s: expected %S, got %S" label expected actual) + let expect_line label needle haystack = if not @@ -14,6 +19,21 @@ let expect_line label needle haystack = (String.split_on_char '\n' haystack)) then failwith (Printf.sprintf "%s: missing line %S" label needle) +let contains_substring haystack needle = + let haystack_length = String.length haystack in + let needle_length = String.length needle in + let rec loop index = + needle_length = 0 + || (index + needle_length <= haystack_length + && (String.sub haystack index needle_length = needle + || loop (index + 1))) + in + loop 0 + +let expect_contains label needle haystack = + if not (contains_substring haystack needle) then + failwith (Printf.sprintf "%s: missing substring %S" label needle) + let () = let ok = diagnostic Doctor.Check.Ok "opam found: 2.2.1" in let warn = diagnostic Doctor.Check.Warn "ocamlformat not installed" in @@ -41,4 +61,30 @@ let () = expect_line "multiline detail second" " second line" rendered; expect_line "multiline suggestion first" " Suggested fix: fix it" rendered; - expect_line "multiline suggestion second" " try again" rendered + expect_line "multiline suggestion second" " try again" rendered; + let json = Doctor.Report.render_json [ ok; warn; error ] in + expect_contains "json diagnostics" "\"diagnostics\": [" json; + expect_contains "json severity" "\"severity\": \"warn\"" json; + expect_contains "json summary" + "\"summary\": { \"ok\": 1, \"warn\": 1, \"error\": 1 }" json; + expect_contains "json exit code" "\"exit_code\": 2" json; + let escaped = + Doctor.Check.make ~id:"escape" ~title:"quoted \"title\"" + ~detail:"first line\nsecond line" Doctor.Check.Warn + in + let json = Doctor.Report.render_json [ escaped ] in + expect_contains "json quotes" "\"title\": \"quoted \\\"title\\\"\"" json; + expect_contains "json newline" "\"detail\": \"first line\\nsecond line\"" + json; + let empty_json = + String.concat "\n" + [ + "{"; + " \"diagnostics\": [],"; + " \"summary\": { \"ok\": 0, \"warn\": 0, \"error\": 0 },"; + " \"exit_code\": 0"; + "}"; + ] + ^ "\n" + in + expect_string "empty json" empty_json (Doctor.Report.render_json []) From a810a3e2e398b42d43250225c944f44113509062 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 6 May 2026 14:52:38 -0700 Subject: [PATCH 2/5] Tighten review cleanup --- CHANGES.md | 2 +- README.md | 13 +++++----- lib/report.ml | 19 +++++++------- test/test_diagnostics.ml | 53 ++++++++++++++++++---------------------- 4 files changed, 40 insertions(+), 47 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a38ec9f..b14f1e5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,7 @@ ## 0.2.0 - unreleased -- Add `doctor check --format json` for tools that need structured output. +- Add `doctor check --format json` for machine-readable diagnostics. ## 0.1.0 - 2026-05-06 diff --git a/README.md b/README.md index 923950c..77dbdb1 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![opam](https://badgen.net/opam/v/doctor)](https://opam.ocaml.org/packages/doctor/) [![license](https://img.shields.io/github/license/funwithcthulhu/doctor.svg)](LICENSE) -`doctor` checks a local OCaml development environment and reports common setup -problems. It is read-only: it prints diagnostics and suggested commands, but it -does not modify opam switches, shell files, or editor settings. +`doctor` checks a local OCaml development environment. It reports missing tools, +suspicious opam state, and editor setup issues; it does not modify switches, +shell files, or editor settings. It currently checks platform details, core tool versions, opam initialization state, active and available switches, whether the resolved `ocaml` appears to @@ -64,7 +64,7 @@ OCaml Doctor Summary: 6 OK, 2 WARN, 0 ERROR ``` -For tools that need structured output: +Use JSON when another program needs to read the report: ```console $ doctor check --format json @@ -105,8 +105,7 @@ opam exec -- dune runtest opam exec -- dune exec doctor -- check ``` -Tests use injected process runners and deterministic fixtures. They do not -require opam to be initialized on the host machine, and they do not require VS -Code or a particular shell setup. +Tests fake process execution, so they do not depend on the host opam setup, VS +Code, or a particular shell. Maintainer release notes are in [RELEASE.md](RELEASE.md). diff --git a/lib/report.ml b/lib/report.ml index f8b6201..b5a8d72 100644 --- a/lib/report.ml +++ b/lib/report.ml @@ -75,20 +75,19 @@ let json_severity = function | Check.Warn -> "warn" | Check.Error -> "error" -let json_field ?(comma = true) name value = - Printf.sprintf " \"%s\": %s%s" name value - (if comma then "," else "") - let render_json_diagnostic diagnostic = + let field ?(comma = true) name value = + Printf.sprintf " \"%s\": %s%s" name value + (if comma then "," else "") + in String.concat "\n" [ " {"; - json_field "id" (json_string diagnostic.Check.id); - json_field "severity" (json_string (json_severity diagnostic.severity)); - json_field "title" (json_string diagnostic.title); - json_field "detail" (json_option diagnostic.detail); - json_field ~comma:false "suggestion" - (json_option diagnostic.suggestion); + field "id" (json_string diagnostic.Check.id); + field "severity" (json_string (json_severity diagnostic.severity)); + field "title" (json_string diagnostic.title); + field "detail" (json_option diagnostic.detail); + field ~comma:false "suggestion" (json_option diagnostic.suggestion); " }"; ] diff --git a/test/test_diagnostics.ml b/test/test_diagnostics.ml index b5bd9be..3ef22ea 100644 --- a/test/test_diagnostics.ml +++ b/test/test_diagnostics.ml @@ -30,16 +30,6 @@ let find_diagnostic id diagnostics = |> List.find_opt (fun diagnostic -> String.equal diagnostic.Check.id id) |> expect_some ("diagnostic " ^ id) -let ocamlformat_spec = - { - Check.command = "ocamlformat"; - args = [ "--version" ]; - label = "ocamlformat"; - missing_severity = Check.Warn; - missing_suggestion = "opam install ocamlformat"; - version_parser = String.trim; - } - let test_command_checks_use_ocamllsp_fallback () = let responses = [ @@ -61,34 +51,39 @@ let test_command_checks_use_ocamllsp_fallback () = expect_string "lsp fallback title" "OCaml LSP found: 1.26.0 (ocamllsp)" lsp.title -let test_missing_configured_command_uses_its_severity_and_suggestion () = - let diagnostic = Check.command_diagnostic ~run:(fake_runner []) ocamlformat_spec in +let test_missing_ocamlformat_is_a_warning () = + let responses = + [ + (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); + ( ( "ocaml", + [ "-version" ] ), + (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); + (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); + (("ocaml-lsp-server", [ "--version" ]), (Process.Exited 0, "1.26.0\n", "")); + ] + in + let diagnostics = Check.command_diagnostics ~run:(fake_runner responses) in + let diagnostic = find_diagnostic "command.ocamlformat" diagnostics in expect_severity "missing command is warning" Check.Warn diagnostic.severity; expect_string "missing command suggestion" "opam install ocamlformat" (expect_some "missing command suggestion" diagnostic.suggestion) -let test_failed_required_command_is_an_error () = +let test_failed_opam_version_check_is_an_error () = let responses = [ ( ( "opam", [ "--version" ] ), (Process.Exited 2, "", "opam failed\n") ); + ( ( "ocaml", + [ "-version" ] ), + (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); + (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); + (("ocaml-lsp-server", [ "--version" ]), (Process.Exited 0, "1.26.0\n", "")); + (("ocamlformat", [ "--version" ]), (Process.Exited 0, "0.27.0\n", "")); ] in - let opam_spec = - { - Check.command = "opam"; - args = [ "--version" ]; - label = "opam"; - missing_severity = Check.Error; - missing_suggestion = - "Install opam from https://opam.ocaml.org/doc/Install.html"; - version_parser = String.trim; - } - in - let diagnostic = - Check.command_diagnostic ~run:(fake_runner responses) opam_spec - in + let diagnostics = Check.command_diagnostics ~run:(fake_runner responses) in + let diagnostic = find_diagnostic "command.opam" diagnostics in expect_severity "nonzero command is diagnostic" Check.Error diagnostic.severity @@ -134,8 +129,8 @@ let () = (fun test -> test ()) [ test_command_checks_use_ocamllsp_fallback; - test_missing_configured_command_uses_its_severity_and_suggestion; - test_failed_required_command_is_an_error; + test_missing_ocamlformat_is_a_warning; + test_failed_opam_version_check_is_an_error; test_opam_env_warns_when_ocaml_resolves_outside_active_switch; test_missing_code_command_skips_vscode_extension_check; ] From 879d16d186412a5059215c409fdce3d1aa977d32 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 6 May 2026 14:58:23 -0700 Subject: [PATCH 3/5] Enable ocamlformat --- .ocamlformat | 2 ++ bin/main.ml | 25 ++++++++++------------- lib/check.ml | 33 +++++++++++------------------- lib/editor.ml | 20 ++++++++---------- lib/opam.ml | 44 ++++++++++++++++------------------------ lib/platform.ml | 11 ++-------- lib/process.ml | 28 +++++++++++-------------- lib/report.ml | 25 +++++++---------------- lib/version.ml | 1 - test/test_cli.ml | 6 ++---- test/test_diagnostics.ml | 35 ++++++++++---------------------- test/test_process.ml | 21 ++++++++----------- test/test_report.ml | 25 ++++++++--------------- 13 files changed, 103 insertions(+), 173 deletions(-) create mode 100644 .ocamlformat diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..aa6f76e --- /dev/null +++ b/.ocamlformat @@ -0,0 +1,2 @@ +profile = conventional +version = 0.29.0 diff --git a/bin/main.ml b/bin/main.ml index 1b8d85c..bea2a3b 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -1,8 +1,6 @@ let version = Doctor.Version.current -type output_format = - | Text - | Json +type output_format = Text | Json let render_diagnostics format diagnostics = match format with @@ -21,10 +19,9 @@ let run_checks output_format = in print_string (render_diagnostics output_format diagnostics); Doctor.Report.exit_code diagnostics - with - | exn -> - prerr_endline ("doctor internal failure: " ^ Printexc.to_string exn); - 3 + with exn -> + prerr_endline ("doctor internal failure: " ^ Printexc.to_string exn); + 3 let print_version () = print_endline Doctor.Version.display; @@ -46,13 +43,12 @@ let exit_infos = Cmd.Exit.info ~doc:"one or more errors." 2; Cmd.Exit.info ~doc:"unexpected internal failure." 3; ] - @ List.filter - (fun info -> Cmd.Exit.info_code info <> 0) - Cmd.Exit.defaults + @ List.filter (fun info -> Cmd.Exit.info_code info <> 0) Cmd.Exit.defaults let check_cmd = let doc = "Run OCaml development environment diagnostics." in - Cmd.v (Cmd.info "check" ~doc ~exits:exit_infos) + Cmd.v + (Cmd.info "check" ~doc ~exits:exit_infos) Term.(const run_checks $ output_format) let version_cmd = @@ -67,12 +63,13 @@ let default_cmd = [ `S Manpage.s_description; `P - "doctor checks for common OCaml, opam, dune, LSP, formatter, \ - shell environment, and VS Code setup issues. It does not modify your \ + "doctor checks for common OCaml, opam, dune, LSP, formatter, shell \ + environment, and VS Code setup issues. It does not modify your \ machine."; ] in - Cmd.group (Cmd.info "doctor" ~version ~doc ~man ~exits:exit_infos) + Cmd.group + (Cmd.info "doctor" ~version ~doc ~man ~exits:exit_infos) [ check_cmd; version_cmd ] let () = diff --git a/lib/check.ml b/lib/check.ml index 1071779..5064cef 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -1,7 +1,4 @@ -type severity = - | Ok - | Warn - | Error +type severity = Ok | Warn | Error type diagnostic = { id : string; @@ -31,15 +28,10 @@ let aggregate diagnostics = Ok diagnostics let exit_code diagnostics = - match aggregate diagnostics with - | Ok -> 0 - | Warn -> 1 - | Error -> 2 + match aggregate diagnostics with Ok -> 0 | Warn -> 1 | Error -> 2 let clean_output output = - output - |> String.split_on_char '\n' - |> List.map String.trim + output |> String.split_on_char '\n' |> List.map String.trim |> List.filter (fun line -> line <> "") let first_output_line (result : Process.result) = @@ -84,14 +76,16 @@ let command_diagnostic ~(run : Process.runner) spec = let title = title_with_version spec.label version in make ~id:("command." ^ spec.command) ~title Ok | Process.Spawn_error _ -> - make ~id:("command." ^ spec.command) + make + ~id:("command." ^ spec.command) ~title:(Printf.sprintf "%s not found" spec.label) ~detail: - (Printf.sprintf - "The `%s` command is not available on PATH." spec.command) + (Printf.sprintf "The `%s` command is not available on PATH." + spec.command) ~suggestion:spec.missing_suggestion spec.missing_severity | Exited _ | Signaled _ | Stopped _ -> - make ~id:("command." ^ spec.command) + make + ~id:("command." ^ spec.command) ~title:(Printf.sprintf "%s command failed" spec.label) ~detail:(Process.summary result) ~suggestion:spec.missing_suggestion spec.missing_severity @@ -127,8 +121,8 @@ let lsp_command_diagnostic ~(run : Process.runner) = ~title:"OCaml LSP found, but its version could not be read" ~detail:(Process.summary fallback) ~suggestion: - "Try running `ocamllsp --version` directly, or reinstall it \ - with opam." + "Try running `ocamllsp --version` directly, or reinstall it with \ + opam." Warn) | _ -> make ~id:"command.ocaml-lsp-server" @@ -181,7 +175,4 @@ let ocamlformat_spec = let command_diagnostics ~run = List.map (command_diagnostic ~run) core_command_specs - @ [ - lsp_command_diagnostic ~run; - command_diagnostic ~run ocamlformat_spec; - ] + @ [ lsp_command_diagnostic ~run; command_diagnostic ~run ocamlformat_spec ] diff --git a/lib/editor.ml b/lib/editor.ml index 73c6cfd..0b7bbe3 100644 --- a/lib/editor.ml +++ b/lib/editor.ml @@ -1,7 +1,5 @@ let has_extension extensions extension = - extensions - |> String.split_on_char '\n' - |> List.map String.trim + extensions |> String.split_on_char '\n' |> List.map String.trim |> List.exists (String.equal extension) let diagnostics ~(run : Process.runner) = @@ -10,18 +8,16 @@ let diagnostics ~(run : Process.runner) = | Process.Spawn_error _ -> [ Check.make ~id:"editor.vscode.command" - ~title:"VS Code command not found (skipped)" - Check.Ok; + ~title:"VS Code command not found (skipped)" Check.Ok; ] - | Process.Exited 0 -> + | Process.Exited 0 -> ( let extensions = run "code" [ "--list-extensions" ] in - (match extensions.status with + match extensions.status with | Process.Exited 0 when has_extension extensions.stdout "ocamllabs.ocaml-platform" -> [ Check.make ~id:"editor.vscode.ocaml-platform" - ~title:"VS Code OCaml Platform extension detected" - Check.Ok; + ~title:"VS Code OCaml Platform extension detected" Check.Ok; ] | Process.Exited 0 -> [ @@ -37,7 +33,8 @@ let diagnostics ~(run : Process.runner) = ~title:"could not list VS Code extensions" ~detail:(Process.summary extensions) ~suggestion: - "Open VS Code and check whether ocamllabs.ocaml-platform is installed." + "Open VS Code and check whether ocamllabs.ocaml-platform is \ + installed." Check.Warn; ]) | _ -> @@ -46,6 +43,7 @@ let diagnostics ~(run : Process.runner) = ~title:"VS Code command exists but could not run" ~detail:(Process.summary code) ~suggestion: - "Try running `code --version`, or reinstall the VS Code command-line launcher." + "Try running `code --version`, or reinstall the VS Code \ + command-line launcher." Check.Warn; ] diff --git a/lib/opam.ml b/lib/opam.ml index f147803..4264e4e 100644 --- a/lib/opam.ml +++ b/lib/opam.ml @@ -1,7 +1,5 @@ let non_empty_lines output = - output - |> String.split_on_char '\n' - |> List.map String.trim + output |> String.split_on_char '\n' |> List.map String.trim |> List.filter (fun line -> line <> "") let first_stdout_line result = @@ -17,8 +15,7 @@ let parse_active_switch output = | [] -> None | line :: _ -> let lower = String.lowercase_ascii line in - if String.starts_with ~prefix:"[error]" lower then None - else Some line + if String.starts_with ~prefix:"[error]" lower then None else Some line let trim_switch_marker line = let line = String.trim line in @@ -42,12 +39,9 @@ let words line = let parse_installed_packages output = non_empty_lines output |> List.map (fun line -> - match words line with - | package :: _ -> package - | [] -> line) + match words line with package :: _ -> package | [] -> line) -let has_package packages package = - List.exists (String.equal package) packages +let has_package packages package = List.exists (String.equal package) packages let opam_available ~(run : Process.runner) = match (run "opam" [ "--version" ]).status with @@ -65,13 +59,11 @@ let initialized_diagnostic ~(run : Process.runner) = | Process.Exited 0 -> ( match first_stdout_line result with | Some root -> - Check.make ~id:"opam.initialized" - ~title:"opam initialized" + Check.make ~id:"opam.initialized" ~title:"opam initialized" ~detail:(Printf.sprintf "Root: %s" root) Check.Ok | None -> - Check.make ~id:"opam.initialized" - ~title:"opam root could not be read" + Check.make ~id:"opam.initialized" ~title:"opam root could not be read" ~detail:(Process.summary result) ~suggestion:"opam init" Check.Warn) | _ -> Check.make ~id:"opam.initialized" @@ -96,14 +88,12 @@ let switch_diagnostics ~(run : Process.runner) os = Check.Ok | None -> let suggestion = switch_suggestion os switch_list in - Check.make ~id:"opam.switch.active" - ~title:"opam switch not active" - ~detail:"opam did not report an active switch." - ~suggestion Check.Error) + Check.make ~id:"opam.switch.active" ~title:"opam switch not active" + ~detail:"opam did not report an active switch." ~suggestion + Check.Error) | _ -> let suggestion = switch_suggestion os switch_list in - Check.make ~id:"opam.switch.active" - ~title:"opam switch not active" + Check.make ~id:"opam.switch.active" ~title:"opam switch not active" ~detail:(Process.summary show) ~suggestion Check.Error in let list_diagnostic = @@ -119,8 +109,7 @@ let switch_diagnostics ~(run : Process.runner) os = ~title:(Printf.sprintf "opam switches available: %d" count) ?detail Check.Ok | _ -> - Check.make ~id:"opam.switch.list" - ~title:"could not list opam switches" + Check.make ~id:"opam.switch.list" ~title:"could not list opam switches" ~detail:(Process.summary switches) ~suggestion:"Run `opam switch list` to inspect your switches." Check.Warn @@ -150,8 +139,8 @@ let switch_bin_diagnostic ~(run : Process.runner) os = ~title:"shell environment may be out of sync with opam" ~detail: (Printf.sprintf - "ocaml resolves to %s, but the active switch bin is %s." - path switch_bin) + "ocaml resolves to %s, but the active switch bin is %s." path + switch_bin) ~suggestion:(Platform.environment_sync_suggestion os) Check.Warn; ] @@ -159,7 +148,8 @@ let switch_bin_diagnostic ~(run : Process.runner) os = let package_diagnostic packages package ~optional = if has_package packages package then - Check.make ~id:("opam.package." ^ package) + Check.make + ~id:("opam.package." ^ package) ~title:(Printf.sprintf "%s package installed" package) Check.Ok else @@ -167,7 +157,9 @@ let package_diagnostic packages package ~optional = if optional then Printf.sprintf "%s not installed (optional)" package else Printf.sprintf "%s not installed" package in - Check.make ~id:("opam.package." ^ package) ~title + Check.make + ~id:("opam.package." ^ package) + ~title ~suggestion:(Printf.sprintf "opam install %s" package) Check.Warn diff --git a/lib/platform.ml b/lib/platform.ml index e6ae240..60a4f23 100644 --- a/lib/platform.ml +++ b/lib/platform.ml @@ -1,10 +1,4 @@ -type os = - | Windows - | Macos - | Linux - | Wsl - | Cygwin - | Other of string +type os = Windows | Macos | Linux | Wsl | Cygwin | Other of string let env = Sys.getenv_opt @@ -33,8 +27,7 @@ let file_contains path needle = | exception End_of_file -> false in loop ()) - with - | Sys_error _ -> false + with Sys_error _ -> false let has_wsl_marker () = env "WSL_DISTRO_NAME" <> None diff --git a/lib/process.ml b/lib/process.ml index 69929e2..d89adfa 100644 --- a/lib/process.ml +++ b/lib/process.ml @@ -40,12 +40,8 @@ let read_file path = let length = in_channel_length channel in really_input_string channel length) -let remove_if_exists path = - try Sys.remove path with - | Sys_error _ -> () - +let remove_if_exists path = try Sys.remove path with Sys_error _ -> () let close_noerr fd = try Unix.close fd with Unix.Unix_error _ -> () - let null_device = if Sys.win32 then "NUL" else "/dev/null" let unix_status_to_status = function @@ -88,18 +84,15 @@ let run command args = close_noerr stderr_fd; let _pid, status = Unix.waitpid [] pid in finish (unix_status_to_status status) - with - | Unix.Unix_error (error, function_name, argument) -> - let message = - Printf.sprintf "%s: %s %s" (Unix.error_message error) function_name - argument - in - finish (Spawn_error message) + with Unix.Unix_error (error, function_name, argument) -> + let message = + Printf.sprintf "%s: %s %s" (Unix.error_message error) function_name + argument + in + finish (Spawn_error message) let trim_for_summary text = - text - |> String.split_on_char '\n' - |> List.map String.trim + text |> String.split_on_char '\n' |> List.map String.trim |> List.filter (fun line -> line <> "") |> String.concat " " @@ -108,7 +101,10 @@ let summary result = let stderr = trim_for_summary result.stderr in let stdout = trim_for_summary result.stdout in match (stdout, stderr) with - | "", "" -> Printf.sprintf "%s returned %s" (command_line result.command result.args) status + | "", "" -> + Printf.sprintf "%s returned %s" + (command_line result.command result.args) + status | "", stderr -> Printf.sprintf "%s returned %s: %s" (command_line result.command result.args) diff --git a/lib/report.ml b/lib/report.ml index b5a8d72..ceb2676 100644 --- a/lib/report.ml +++ b/lib/report.ml @@ -1,24 +1,18 @@ -let indent_for status = - String.make (String.length status + 3) ' ' +let indent_for status = String.make (String.length status + 3) ' ' let non_empty_lines text = - text - |> String.split_on_char '\n' - |> List.map String.trim + text |> String.split_on_char '\n' |> List.map String.trim |> List.filter (fun line -> line <> "") let format_extra_lines indent ~prefix text = match non_empty_lines text with | [] -> [] | first :: rest -> - (indent ^ prefix ^ first) - :: List.map (fun line -> indent ^ line) rest + (indent ^ prefix ^ first) :: List.map (fun line -> indent ^ line) rest let format_diagnostic diagnostic = let status = Check.severity_to_string diagnostic.Check.severity in - let first_line = - Printf.sprintf "[%s] %s" status diagnostic.Check.title - in + let first_line = Printf.sprintf "[%s] %s" status diagnostic.Check.title in let indent = indent_for status in let detail_lines = match diagnostic.detail with @@ -63,12 +57,8 @@ let json_escape text = text; Buffer.contents buffer -let json_string text = - Printf.sprintf "\"%s\"" (json_escape text) - -let json_option = function - | Some value -> json_string value - | None -> "null" +let json_string text = Printf.sprintf "\"%s\"" (json_escape text) +let json_option = function Some value -> json_string value | None -> "null" let json_severity = function | Check.Ok -> "ok" @@ -77,8 +67,7 @@ let json_severity = function let render_json_diagnostic diagnostic = let field ?(comma = true) name value = - Printf.sprintf " \"%s\": %s%s" name value - (if comma then "," else "") + Printf.sprintf " \"%s\": %s%s" name value (if comma then "," else "") in String.concat "\n" [ diff --git a/lib/version.ml b/lib/version.ml index d9f0a74..e598de7 100644 --- a/lib/version.ml +++ b/lib/version.ml @@ -1,3 +1,2 @@ let current = "0.1.0" - let display = "doctor " ^ current diff --git a/test/test_cli.ml b/test/test_cli.ml index d93797c..345f3d7 100644 --- a/test/test_cli.ml +++ b/test/test_cli.ml @@ -1,7 +1,5 @@ let expect_equal label expected actual = if not (String.equal expected actual) then - failwith - (Printf.sprintf "%s: expected %S, got %S" label expected actual) + failwith (Printf.sprintf "%s: expected %S, got %S" label expected actual) -let () = - expect_equal "version display" "doctor 0.1.0" Doctor.Version.display +let () = expect_equal "version display" "doctor 0.1.0" Doctor.Version.display diff --git a/test/test_diagnostics.ml b/test/test_diagnostics.ml index 3ef22ea..566fa8c 100644 --- a/test/test_diagnostics.ml +++ b/test/test_diagnostics.ml @@ -14,8 +14,7 @@ let fake_runner responses command args = let expect_string label expected actual = if not (String.equal expected actual) then - failwith - (Printf.sprintf "%s: expected %S, got %S" label expected actual) + failwith (Printf.sprintf "%s: expected %S, got %S" label expected actual) let expect_severity label expected actual = if expected <> actual then @@ -34,12 +33,10 @@ let test_command_checks_use_ocamllsp_fallback () = let responses = [ (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); - ( ( "ocaml", - [ "-version" ] ), + ( ("ocaml", [ "-version" ]), (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); - ( ( "ocaml-lsp-server", - [ "--version" ] ), + ( ("ocaml-lsp-server", [ "--version" ]), (Process.Spawn_error "not found", "", "") ); (("ocamllsp", [ "--version" ]), (Process.Exited 0, "1.26.0\n", "")); (("ocamlformat", [ "--version" ]), (Process.Exited 0, "0.27.0\n", "")); @@ -55,8 +52,7 @@ let test_missing_ocamlformat_is_a_warning () = let responses = [ (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); - ( ( "ocaml", - [ "-version" ] ), + ( ("ocaml", [ "-version" ]), (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); (("ocaml-lsp-server", [ "--version" ]), (Process.Exited 0, "1.26.0\n", "")); @@ -71,11 +67,8 @@ let test_missing_ocamlformat_is_a_warning () = let test_failed_opam_version_check_is_an_error () = let responses = [ - ( ( "opam", - [ "--version" ] ), - (Process.Exited 2, "", "opam failed\n") ); - ( ( "ocaml", - [ "-version" ] ), + (("opam", [ "--version" ]), (Process.Exited 2, "", "opam failed\n")); + ( ("ocaml", [ "-version" ]), (Process.Exited 0, "The OCaml toplevel, version 5.2.0\n", "") ); (("dune", [ "--version" ]), (Process.Exited 0, "3.17.0\n", "")); (("ocaml-lsp-server", [ "--version" ]), (Process.Exited 0, "1.26.0\n", "")); @@ -93,20 +86,14 @@ let test_opam_env_warns_when_ocaml_resolves_outside_active_switch () = (("opam", [ "--version" ]), (Process.Exited 0, "2.2.1\n", "")); (("opam", [ "var"; "root" ]), (Process.Exited 0, "/home/me/.opam\n", "")); (("opam", [ "switch"; "show" ]), (Process.Exited 0, "5.2.0\n", "")); - ( ( "opam", - [ "switch"; "list"; "--short" ] ), + ( ("opam", [ "switch"; "list"; "--short" ]), (Process.Exited 0, "default\n5.2.0\n", "") ); - ( ( "opam", - [ "var"; "bin" ] ), + ( ("opam", [ "var"; "bin" ]), (Process.Exited 0, "/home/me/.opam/5.2.0/bin\n", "") ); - ( ( "sh", - [ "-c"; "command -v ocaml" ] ), + ( ("sh", [ "-c"; "command -v ocaml" ]), (Process.Exited 0, "/usr/bin/ocaml\n", "") ); - ( ( "opam", - [ "list"; "--installed"; "--short" ] ), - ( Process.Exited 0, - "ocaml\ndune\nocaml-lsp-server\n", - "" ) ); + ( ("opam", [ "list"; "--installed"; "--short" ]), + (Process.Exited 0, "ocaml\ndune\nocaml-lsp-server\n", "") ); ] in let diagnostics = diff --git a/test/test_process.ml b/test/test_process.ml index a048736..e428fad 100644 --- a/test/test_process.ml +++ b/test/test_process.ml @@ -1,7 +1,6 @@ let expect_equal label expected actual = if not (String.equal expected actual) then - failwith - (Printf.sprintf "%s: expected %S, got %S" label expected actual) + failwith (Printf.sprintf "%s: expected %S, got %S" label expected actual) let expect_bool label value = if not value then failwith (Printf.sprintf "%s: expected true" label) @@ -24,24 +23,20 @@ let () = expect_bool "no active switch" (Doctor.Opam.parse_active_switch "[ERROR] No switch\n" = None); expect_bool "package parser finds dune" - (Doctor.Opam.parse_installed_packages - "ocaml\nbase-unix\ndune\nocaml-lsp-server\n" - |> fun packages -> Doctor.Opam.has_package packages "dune"); + ( Doctor.Opam.parse_installed_packages + "ocaml\nbase-unix\ndune\nocaml-lsp-server\n" + |> fun packages -> Doctor.Opam.has_package packages "dune" ); expect_equal "switch list parser" "default, 5.2.0" - (Doctor.Opam.parse_switch_list "default\n5.2.0\n" - |> String.concat ", "); + (Doctor.Opam.parse_switch_list "default\n5.2.0\n" |> String.concat ", "); expect_equal "active switch marker is trimmed" "default, 5.2.0" - (Doctor.Opam.parse_switch_list "* default\n 5.2.0\n" - |> String.concat ", "); + (Doctor.Opam.parse_switch_list "* default\n 5.2.0\n" |> String.concat ", "); expect_equal "package parser trims whitespace" "dune, ocamlformat" (Doctor.Opam.parse_installed_packages " dune 3.17.0\n\tocamlformat\t0.27.0\n" |> String.concat ", "); expect_bool "path below switch bin" - (Doctor.Platform.is_path_under - ~parent:"/home/me/.opam/5.2.0/bin" + (Doctor.Platform.is_path_under ~parent:"/home/me/.opam/5.2.0/bin" "/home/me/.opam/5.2.0/bin/ocaml"); expect_false "path with shared prefix is not below switch bin" - (Doctor.Platform.is_path_under - ~parent:"/home/me/.opam/5.2.0/bin" + (Doctor.Platform.is_path_under ~parent:"/home/me/.opam/5.2.0/bin" "/home/me/.opam/5.2.0/bin-old/ocaml") diff --git a/test/test_report.ml b/test/test_report.ml index dd1e739..324c56e 100644 --- a/test/test_report.ml +++ b/test/test_report.ml @@ -3,19 +3,15 @@ let diagnostic ?detail ?suggestion severity title = let expect_int label expected actual = if expected <> actual then - failwith - (Printf.sprintf "%s: expected %d, got %d" label expected actual) + failwith (Printf.sprintf "%s: expected %d, got %d" label expected actual) let expect_string label expected actual = if not (String.equal expected actual) then - failwith - (Printf.sprintf "%s: expected %S, got %S" label expected actual) + failwith (Printf.sprintf "%s: expected %S, got %S" label expected actual) let expect_line label needle haystack = if - not - (List.exists (String.equal needle) - (String.split_on_char '\n' haystack)) + not (List.exists (String.equal needle) (String.split_on_char '\n' haystack)) then failwith (Printf.sprintf "%s: missing line %S" label needle) let contains_substring haystack needle = @@ -23,9 +19,8 @@ let contains_substring haystack needle = let needle_length = String.length needle in let rec loop index = needle_length = 0 - || (index + needle_length <= haystack_length - && (String.sub haystack index needle_length = needle - || loop (index + 1))) + || index + needle_length <= haystack_length + && (String.sub haystack index needle_length = needle || loop (index + 1)) in loop 0 @@ -44,8 +39,7 @@ let error = diagnostic Doctor.Check.Error "opam switch not active" let test_exit_codes_and_counts () = expect_int "ok exit code" 0 (Doctor.Report.exit_code [ ok ]); expect_int "warning exit code" 1 (Doctor.Report.exit_code [ ok; warn ]); - expect_int "error exit code" 2 - (Doctor.Report.exit_code [ ok; warn; error ]); + expect_int "error exit code" 2 (Doctor.Report.exit_code [ ok; warn; error ]); expect_int "summary ok count" 1 (let ok_count, _, _ = Doctor.Report.counts [ ok; warn; error ] in ok_count) @@ -53,8 +47,8 @@ let test_exit_codes_and_counts () = let test_text_report_includes_suggestions () = let rendered = Doctor.Report.render [ warn ] in expect_line "warning line" "[WARN] ocamlformat not installed" rendered; - expect_line "suggestion line" - " Suggested fix: opam install ocamlformat" rendered; + expect_line "suggestion line" " Suggested fix: opam install ocamlformat" + rendered; expect_line "summary line" "Summary: 0 OK, 1 WARN, 0 ERROR" rendered let test_multiline_detail_and_suggestion_are_indented () = @@ -84,8 +78,7 @@ let test_json_escapes_strings () = in let json = Doctor.Report.render_json [ diagnostic ] in expect_contains "json quotes" "\"title\": \"quoted \\\"title\\\"\"" json; - expect_contains "json newline" "\"detail\": \"first line\\nsecond line\"" - json + expect_contains "json newline" "\"detail\": \"first line\\nsecond line\"" json let test_empty_json_report () = let expected = From 72a2c894fce5d99f32c5aab78eeb5277eb5391ee Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 6 May 2026 18:06:57 -0700 Subject: [PATCH 4/5] Fix license badge URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b60875..7580f26 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/funwithcthulhu/doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/funwithcthulhu/doctor/actions/workflows/ci.yml) [![opam](https://badgen.net/opam/v/doctor)](https://opam.ocaml.org/packages/doctor/) -[![license](https://img.shields.io/github/license/funwithcthulhu/doctor.svg)](LICENSE) +[![license](https://img.shields.io/github/license/funwithcthulhu/doctor)](LICENSE) `doctor` checks a local OCaml development environment. It reports missing tools, suspicious opam state, and editor setup issues; it does not modify switches, From 1411a93e26bedb63db0815d710ee4fc08a9ad6d1 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Thu, 21 May 2026 18:09:30 -0700 Subject: [PATCH 5/5] Prepare 0.2.0 release --- CHANGES.md | 3 ++- README.md | 2 +- RELEASE.md | 4 ++-- dune-project | 2 +- lib/version.ml | 2 +- test/test_cli.ml | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bf11a95..f753198 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,10 @@ # Changelog -## 0.2.0 - unreleased +## 0.2.0 - 2026-05-21 - Add `doctor check --json` for machine-readable diagnostics. - Tighten opam environment diagnostic tests and small parser helpers. +- Fix the README license badge URL. ## 0.1.0 - 2026-05-06 diff --git a/README.md b/README.md index 7dca222..7f81c1f 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ $ doctor check --json `doctor version` prints: ```console -doctor 0.1.0 +doctor 0.2.0 ``` ## Exit Codes diff --git a/RELEASE.md b/RELEASE.md index 241530e..8a7ea8c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,7 +3,7 @@ Notes for publishing `doctor` to opam-repository. This is a release checklist, not release automation. -Run from a clean checkout. Replace `0.1.0` with the version being released. +Run from a clean checkout. Replace `0.2.0` with the version being released. ## Prepare @@ -35,7 +35,7 @@ Push the branch and tag after checking the final diff. ```console git status --short -git tag -a 0.1.0 -m "Release 0.1.0" +git tag -a 0.2.0 -m "Release 0.2.0" ``` ## Publish diff --git a/dune-project b/dune-project index da1e201..3ffed61 100644 --- a/dune-project +++ b/dune-project @@ -1,7 +1,7 @@ (lang dune 3.11) (name doctor) -(version 0.1.0) +(version 0.2.0) (source (github funwithcthulhu/doctor)) diff --git a/lib/version.ml b/lib/version.ml index e598de7..f3f3af0 100644 --- a/lib/version.ml +++ b/lib/version.ml @@ -1,2 +1,2 @@ -let current = "0.1.0" +let current = "0.2.0" let display = "doctor " ^ current diff --git a/test/test_cli.ml b/test/test_cli.ml index d93797c..23ef1ea 100644 --- a/test/test_cli.ml +++ b/test/test_cli.ml @@ -4,4 +4,4 @@ let expect_equal label expected actual = (Printf.sprintf "%s: expected %S, got %S" label expected actual) let () = - expect_equal "version display" "doctor 0.1.0" Doctor.Version.display + expect_equal "version display" "doctor 0.2.0" Doctor.Version.display