diff --git a/ocaml-cli-exit/lib/cli_exit.ml b/ocaml-cli-exit/lib/cli_exit.ml index 29aebdb2f6..09b341c820 100644 --- a/ocaml-cli-exit/lib/cli_exit.ml +++ b/ocaml-cli-exit/lib/cli_exit.ml @@ -143,6 +143,53 @@ let info ?deprecated ?man_xrefs ?man ?envs ?exits ?docs ?doc ?version Cmdliner.Cmd.info name ?deprecated ?man_xrefs ~man ?envs ?exits ~sdocs ?docs ?doc ?version +(* Cmdliner decides Ansi or Plain when its own module initialises, from NO_COLOR + and TERM, and keeps the answer in a ref whose setter is [Cmdliner_base]'s, + for which the package installs no interface (cmdliner 2.1.1). So a program + that answers the same question later -- because its own output turned out not + to be a terminal, or because the reader typed a flag that turns colour off -- + cannot move cmdliner's decision, and the usage and error pages cmdliner + writes for it carry SGR the rest of its output does not. + + NO_COLOR is the standard control (no-color.org) and the one every library in + a run already reads, so this reads it too: at the moment the page is written + rather than at module initialisation, which is the whole of the difference. A + program writes its answer there and cmdliner's pages follow it. + + What it does not cover is a program that wants cmdliner styled while its own + output is not; nothing can want that here, and the mechanism it would need is + the setter above. *) +let no_color () = + match Sys.getenv_opt "NO_COLOR" with + | Some value -> not (String.equal value "") + | None -> false + +(* [channel] with the ANSI escape sequences taken out and every other byte + passed through, as a formatter with cmdliner's own geometry. Cmdliner writes + a sequence with a declared width of zero, so dropping it moves no column the + page was laid out against, and the two pages differ in no other byte. + + The scan is a state machine rather than a search because a sequence is a + token Format may hand over in pieces: ESC, then [ and the parameter bytes of + a CSI sequence up to its final byte, or a single byte for the two-character + forms. *) +let plain_formatter channel = + let state = ref `Text in + let out_string s pos len = + let kept = Buffer.create len in + for i = pos to pos + len - 1 do + let c = s.[i] in + match !state with + | `Text -> + if Char.equal c '\x1b' then state := `Escape + else Buffer.add_char kept c + | `Escape -> state := if Char.equal c '[' then `Csi else `Text + | `Csi -> if c >= '\x40' && c <= '\x7e' then state := `Text + done; + output_string channel (Buffer.contents kept) + in + Format.make_formatter out_string (fun () -> flush channel) + let run ?argv ?(refusal = fun _ -> None) cmd = (* Before anything is written, because the first thing a program writes may be the help cmdliner prints for it. Under SIGPIPE's default disposition that @@ -158,9 +205,14 @@ let run ?argv ?(refusal = fun _ -> None) cmd = itself -- the help, the usage, the version -- is outside that catch and escapes either way, so one handler over the whole evaluation is the only place both halves are visible. *) + let help, err = + if no_color () then (plain_formatter stdout, plain_formatter stderr) + else (Format.std_formatter, Format.err_formatter) + in let code = match - Cmdliner.Cmd.eval ~catch:false ~env:cmdliner_environment ?argv cmd + Cmdliner.Cmd.eval ~catch:false ~env:cmdliner_environment ?argv ~help ~err + cmd with | code -> code | exception e -> ( diff --git a/ocaml-cli-exit/lib/cli_exit.mli b/ocaml-cli-exit/lib/cli_exit.mli index f958559f8d..ba6bdbc0bd 100644 --- a/ocaml-cli-exit/lib/cli_exit.mli +++ b/ocaml-cli-exit/lib/cli_exit.mli @@ -70,8 +70,19 @@ val run : from returning at all under a script or a suite, and formats the page with groff when it is redirected, so a piped manual carries backspace overstrike rather than text. [run] answers cmdliner's own "do not page" reading of - [TERM] for the manual alone: an explicit [--help=pager] still pages, and - nothing here changes how the help is styled. + [TERM] for the manual alone: an explicit [--help=pager] still pages. + + Cmdliner's own usage, error and help pages carry no terminal styling when + [NO_COLOR] holds a non-empty value, which is the standard control + (https://no-color.org) read at the moment the page is written. Cmdliner + reads it once, when its module initialises, so a program that answers the + same question later -- because its own output turned out not to be a + terminal, or because the reader typed a flag that turns colour off -- writes + its answer there and the pages follow it. What cannot follow is a value + cmdliner draws in bold in place of quotes: the styled page never carried the + quotes, so a run that turns colour off this late reads [invalid value x] + where a run that set [NO_COLOR] before it started reads [invalid value 'x']. + The manual is the same bytes either way. The manual has to say so too, and cmdliner's own item for [--help] says the opposite: {!sdocs} and {!standard_options} are how a command says it. diff --git a/ocaml-cli-exit/test/test_cli_exit.ml b/ocaml-cli-exit/test/test_cli_exit.ml index 684e3047d4..54eb0dcef8 100644 --- a/ocaml-cli-exit/test/test_cli_exit.ml +++ b/ocaml-cli-exit/test/test_cli_exit.ml @@ -307,9 +307,19 @@ let escapes s = the program, after every module has initialised, which is the only one this library decides. - Byte equality rather than an escape count, because a page written through a - different formatter can differ in where it breaks its lines without carrying - a single escape. *) + The bytes are asserted whole rather than counted, because a page written + through another formatter can break its lines elsewhere while carrying no + escape at all. + + ONE DIFFERENCE FROM CMDLINER'S OWN PLAIN PAGE REMAINS, and the golden string + below is what records it: cmdliner draws a value either in bold or in quotes + and never both ([Cmdliner_base.Fmt.code_or_quote]), so a page whose bold has + been taken out carries `invalid value nosuchmode` where the plain page + carries `invalid value 'nosuchmode'`, and Format then breaks the shorter + words at a different column. No filter can put back a delimiter that was + never written; that takes a setter for cmdliner's styler, which is the + mechanism this substitutes for. How far the loss reaches is not left to + prose: the manual is asserted byte for byte against cmdliner's own. *) let test_late_no_color () = let styling = env_with [ "TERM=xterm-256color"; "NO_COLOR=" ] in let from_the_start = @@ -330,7 +340,21 @@ let test_late_no_color () = Alcotest.(check int) "and the program's own answer reaches the usage page" 0 (escapes answered); Alcotest.(check string) - "which writes the bytes NO_COLOR from the start writes" plain answered + "and the page is cmdliner's own but for the delimiters its bold replaced" + "Usage: probe [--help] [--count=N] [OPTION]\xe2\x80\xa6 MODE\n\ + probe: MODE argument: invalid value nosuchmode, expected one of lines, \ + copy,\n\ + \ boom, deny or refuse\n" + answered; + let _, from_cmdliner, _ = + with_reader ~env:from_the_start [ "--help=plain" ] + in + let _, through_the_filter, _ = + with_reader ~env:from_inside [ "--help=plain" ] + in + Alcotest.(check string) + "and the manual is cmdliner's own, byte for byte" from_cmdliner + through_the_filter let suite = ( "cli_exit",