Skip to content
Merged
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 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

- Initial opam release.
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

[![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` is a read-only CLI for checking whether a local OCaml development
environment looks usable.
`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 reports missing core tools, opam initialization and switch state, likely shell
environment mismatches, selected opam packages, and the VS Code OCaml Platform
extension when the `code` command is available.
It currently checks platform details, core tool versions, opam initialization
state, active and available switches, whether the resolved `ocaml` appears to
match the active switch, selected opam packages, and the VS Code OCaml Platform
extension when `code` is available.

## Installation

Expand Down Expand Up @@ -85,7 +87,7 @@ $ doctor check --json
`doctor version` prints:

```console
doctor 0.1.0
doctor 0.2.0
```

## Exit Codes
Expand Down
4 changes: 2 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dune-project
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
(lang dune 3.11)

(name doctor)
(version 0.1.0)
(version 0.2.0)

(source
(github funwithcthulhu/doctor))
Expand Down
43 changes: 21 additions & 22 deletions lib/check.ml
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,14 @@ let title_with_version label version =
Printf.sprintf "%s found: %s" label version
| _ -> Printf.sprintf "%s found" label

let command_version result parser =
result |> first_output_line |> Option.map parser

let command_diagnostic ~(run : Process.runner) spec =
let result = run spec.command spec.args in
match result.status with
| Process.Exited 0 ->
let version =
result |> first_output_line |> Option.map spec.version_parser
in
let version = command_version result spec.version_parser in
let title = title_with_version spec.label version in
make ~id:("command." ^ spec.command) ~title Ok
| Process.Spawn_error _ ->
Expand All @@ -96,18 +97,14 @@ let lsp_command_diagnostic ~(run : Process.runner) =
let primary = run "ocaml-lsp-server" [ "--version" ] in
match primary.status with
| Process.Exited 0 ->
let version =
primary |> first_output_line |> Option.map String.trim
in
let version = command_version primary String.trim in
let title = title_with_version "OCaml LSP" version in
make ~id:"command.ocaml-lsp-server" ~title Ok
| Process.Spawn_error _ -> (
let fallback = run "ocamllsp" [ "--version" ] in
match fallback.status with
| Process.Exited 0 ->
let version =
fallback |> first_output_line |> Option.map String.trim
in
let version = command_version fallback String.trim in
let title =
match version with
| Some version when version <> "" ->
Expand Down Expand Up @@ -140,7 +137,7 @@ let lsp_command_diagnostic ~(run : Process.runner) =
reinstall it with opam."
Warn

let command_diagnostics ~run =
let core_command_specs =
[
{
command = "opam";
Expand Down Expand Up @@ -170,18 +167,20 @@ let command_diagnostics ~run =
version_parser = String.trim;
};
]
|> List.map (command_diagnostic ~run)
|> fun diagnostics ->
diagnostics

let ocamlformat_spec =
{
command = "ocamlformat";
args = [ "--version" ];
label = "ocamlformat";
missing_severity = Warn;
missing_suggestion = "opam install ocamlformat";
version_parser = String.trim;
}

let command_diagnostics ~run =
List.map (command_diagnostic ~run) core_command_specs
@ [
lsp_command_diagnostic ~run;
command_diagnostic ~run
{
command = "ocamlformat";
args = [ "--version" ];
label = "ocamlformat";
missing_severity = Warn;
missing_suggestion = "opam install ocamlformat";
version_parser = String.trim;
};
command_diagnostic ~run ocamlformat_spec;
]
113 changes: 56 additions & 57 deletions lib/opam.ml
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,33 @@ let non_empty_lines output =
|> List.map String.trim
|> List.filter (fun line -> line <> "")

let first_stdout_line result =
match result.Process.status with
| Process.Exited 0 -> (
match non_empty_lines result.stdout with
| line :: _ -> Some line
| [] -> None)
| _ -> None

let parse_active_switch output =
match non_empty_lines output with
| [] -> None
| line :: _ ->
let lower = String.lowercase_ascii line in
if String.length lower >= 7 && String.sub lower 0 7 = "[error]"
then None
if String.starts_with ~prefix:"[error]" lower then None
else Some line

let trim_switch_marker line =
let line = String.trim line in
match line with
| "" -> ""
| _ when line.[0] = '*' ->
String.trim (String.sub line 1 (String.length line - 1))
| _ -> line

let parse_switch_list output =
non_empty_lines output
|> List.map (fun line ->
let line = String.trim line in
if line <> "" && line.[0] = '*' then
String.trim (String.sub line 1 (String.length line - 1))
else line)
|> List.map trim_switch_marker
|> List.filter (fun line -> line <> "")

let words line =
Expand All @@ -41,16 +52,21 @@ let opam_available ~(run : Process.runner) =
| Process.Exited 0 -> true
| _ -> false

let switch_suggestion os switches =
match switches with
| [] -> "opam switch create 5.2.0"
| _ :: _ -> Platform.environment_sync_suggestion os

let initialized_diagnostic ~(run : Process.runner) =
let result = run "opam" [ "var"; "root" ] in
match result.status with
| Process.Exited 0 -> (
match non_empty_lines result.stdout with
| root :: _ ->
match first_stdout_line result with
| Some root ->
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"
~detail:(Process.summary result)
Expand Down Expand Up @@ -78,19 +94,13 @@ let switch_diagnostics ~(run : Process.runner) os =
~title:(Printf.sprintf "active switch: %s" active)
Check.Ok
| None ->
let suggestion =
if switch_list = [] then "opam switch create 5.2.0"
else Platform.environment_sync_suggestion os
in
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)
| _ ->
let suggestion =
if switch_list = [] then "opam switch create 5.2.0"
else Platform.environment_sync_suggestion os
in
let suggestion = switch_suggestion os switch_list in
Check.make ~id:"opam.switch.active"
~title:"opam switch not active" ~detail:(Process.summary show)
~suggestion Check.Error
Expand All @@ -116,47 +126,36 @@ let switch_diagnostics ~(run : Process.runner) os =
in
[ show_diagnostic; list_diagnostic ]

let locate_ocaml ~(run : Process.runner) os =
let locator, args_for = Platform.command_locator os in
first_stdout_line (run locator (args_for "ocaml"))

let switch_bin_diagnostic ~(run : Process.runner) os =
let bin = run "opam" [ "var"; "bin" ] in
match bin.status with
| Process.Exited 0 -> (
match non_empty_lines bin.stdout with
| [] -> []
| switch_bin :: _ -> (
let locator, args_for = Platform.command_locator os in
let located = run locator (args_for "ocaml") in
let first_path =
match located.status with
| Process.Exited 0 -> (
match non_empty_lines located.stdout with
| path :: _ -> Some path
| [] -> None)
| _ -> None
in
match first_path with
| Some path
when Platform.is_path_under ~parent:switch_bin path ->
[
Check.make ~id:"opam.env.sync"
~title:"shell environment appears synced with opam"
~detail:(Printf.sprintf "ocaml resolves to %s" path)
Check.Ok;
]
| Some path ->
[
Check.make ~id:"opam.env.sync"
~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)
~suggestion:(Platform.environment_sync_suggestion os)
Check.Warn;
]
| None -> []))
| _ -> []
match first_stdout_line bin with
| None -> []
| Some switch_bin -> (
match locate_ocaml ~run os with
| Some path when Platform.is_path_under ~parent:switch_bin path ->
[
Check.make ~id:"opam.env.sync"
~title:"shell environment appears synced with opam"
~detail:(Printf.sprintf "ocaml resolves to %s" path)
Check.Ok;
]
| Some path ->
[
Check.make ~id:"opam.env.sync"
~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)
~suggestion:(Platform.environment_sync_suggestion os)
Check.Warn;
]
| None -> [])

let package_diagnostic packages package ~optional =
if has_package packages package then
Expand Down
6 changes: 2 additions & 4 deletions lib/platform.ml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type os = Windows | Macos | Linux | Wsl | Cygwin | Other of string

let env name = try Some (Sys.getenv name) with Not_found -> None
let env = Sys.getenv_opt

let contains_substring haystack needle =
let haystack_length = String.length haystack in
Expand Down Expand Up @@ -84,9 +84,7 @@ let is_path_under ~parent path =
let parent = normalize_path parent in
let path = normalize_path path in
let parent =
if parent <> "" && parent.[String.length parent - 1] = '/' then
parent
else parent ^ "/"
if String.ends_with ~suffix:"/" parent then parent else parent ^ "/"
in
String.length path >= String.length parent
&& String.sub path 0 (String.length parent) = parent
Expand Down
12 changes: 1 addition & 11 deletions lib/process.ml
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,7 @@ let run command args =
close_noerr stdout_fd;
close_noerr stderr_fd;
let _pid, status = Unix.waitpid [] pid in
let stdout = read_file stdout_path in
let stderr = read_file stderr_path in
remove_if_exists stdout_path;
remove_if_exists stderr_path;
{
command;
args;
status = unix_status_to_status status;
stdout;
stderr;
}
finish (unix_status_to_status status)
with Unix.Unix_error (error, function_name, argument) ->
let message =
Printf.sprintf "%s: %s %s"
Expand Down
2 changes: 1 addition & 1 deletion lib/version.ml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
let current = "0.1.0"
let current = "0.2.0"
let display = "doctor " ^ current
2 changes: 1 addition & 1 deletion test/test_cli.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading