diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff5c248..d0bdd79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,7 +189,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ matrix.os }}-${{ matrix.arch }}-artifact - path: _build/default/cli/cli.exe + path: | + _build/default/cli/cli.exe + _build/default/cli/repl.exe - name: Upload JavaScript artifacts for NPM if: matrix.os == 'ubuntu-latest' @@ -278,11 +280,11 @@ jobs: mkdir -p _release/platform-darwin-x64 mkdir -p _release/platform-darwin-arm64 mkdir -p _release/platform-windows-x64 - mv _release/ubuntu-latest-x64-artifact/cli.exe _release/platform-linux-x64/ - mv _release/macos-15-intel-x64-artifact/cli.exe _release/platform-darwin-x64/ - mv _release/macos-latest-arm64-artifact/cli.exe _release/platform-darwin-arm64/ - mv _release/windows-latest-x64-artifact/cli.exe _release/platform-windows-x64/ - chmod +x _release/platform-linux-x64/cli.exe + mv _release/ubuntu-latest-x64-artifact/{cli,repl}.exe _release/platform-linux-x64/ + mv _release/macos-15-intel-x64-artifact/{cli,repl}.exe _release/platform-darwin-x64/ + mv _release/macos-latest-arm64-artifact/{cli,repl}.exe _release/platform-darwin-arm64/ + mv _release/windows-latest-x64-artifact/{cli,repl}.exe _release/platform-windows-x64/ + chmod +x _release/platform-linux-x64/{cli,repl}.exe - name: Get version if: matrix.os == 'ubuntu-latest' && startsWith(github.ref, 'refs/tags/') @@ -312,21 +314,25 @@ jobs: run: | cd _release/platform-darwin-x64 mv cli.exe query-json + mv repl.exe query-json-repl zip -r ../../query-json-darwin-x64.zip . cd ../.. cd _release/platform-darwin-arm64 mv cli.exe query-json + mv repl.exe query-json-repl zip -r ../../query-json-darwin-arm64.zip . cd ../.. cd _release/platform-linux-x64 mv cli.exe query-json + mv repl.exe query-json-repl zip -r ../../query-json-linux-x64.zip . cd ../.. cd _release/platform-windows-x64 mv cli.exe query-json + mv repl.exe query-json-repl zip -r ../../query-json-windows-x64.zip . cd ../.. diff --git a/CHANGES.md b/CHANGES.md index 3dc6e8f..60978ae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,7 @@ # Unreleased +- [PERF] **Faster startup**: the REPL now ships as the separate `query-json-repl` binary, launched by `query-json --repl`. Plain queries no longer load the terminal UI stack, whose initialization cost about 3 ms and 8 MB per process (4.4 ms to 1.6 ms startup on Linux, binary 14 MB to 8 MB) + ## 1.0.0~beta-1 - [FIX] **`pow` requires two arguments**: Bare `pow` no longer silently squares the input — it now produces a helpful error pointing to the correct `pow(x; y)` usage diff --git a/Makefile b/Makefile index ae1e5ab..eafe6fe 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ dev-core: ## Build in watch mode .PHONY: repl repl: ## Run repl with "package.json" - $(DUNE) exec ./cli/cli.exe -- --repl package.json + $(DUNE) exec ./cli/repl.exe -- package.json .PHONY: web-dev web-dev: ## Build and serve the website via HMR diff --git a/README.md b/README.md index c1ab4ec..2d09529 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,10 @@ Options: --help Print help ``` +The REPL is a separate `query-json-repl` binary, installed next to `query-json`. +`query-json --repl` launches it. Keeping the terminal UI out of the main binary +keeps plain queries starting fast. + ## Contributing Contributions are welcome! See the [development setup](#development) below to get started. diff --git a/cli/Repl.mli b/cli/Repl_ui.mli similarity index 100% rename from cli/Repl.mli rename to cli/Repl_ui.mli diff --git a/cli/Repl.mlx b/cli/Repl_ui.mlx similarity index 100% rename from cli/Repl.mlx rename to cli/Repl_ui.mlx diff --git a/cli/cli.ml b/cli/cli.ml index 7d84329..775a8cf 100644 --- a/cli/cli.ml +++ b/cli/cli.ml @@ -44,38 +44,71 @@ let usage ?(colorize = true) () = |> String.concat (Console_style.enter 1) |> print_endline -let repl_usage ?(colorize = true) () = - let t = Console_style.make ~colorize in - [ - Console_style.enter 0; - t.yellow "Missing JSON file or inline JSON for REPL mode"; - Console_style.enter 1 ^ "Usage:" ^ Console_style.enter 2 - ^ t.bold "query-json" ^ " --repl " - ^ t.gray "[JSON_FILE | INLINE_JSON]" - ^ Console_style.enter 2 ^ Console_style.indent 1 ^ t.bold "EXAMPLES"; - Console_style.indent 3 ^ "query-json --repl package.json"; - Console_style.indent 3 ^ "query-json --repl '[1, 2, 3]'"; - Console_style.indent 3 ^ "query-json --repl '{\"name\": \"test\"}'"; - Console_style.indent 3 ^ "cat data.json | query-json --repl"; - Console_style.enter 1; - ] - |> String.concat (Console_style.enter 1) - |> print_endline - let ( let* ) = Result.bind -(* When JSON is piped via stdin (e.g., `cat data.json | query-json --repl`), stdin is consumed by the JSON parser. The REPL then needs stdin for interactive keyboard input, but it's exhausted/closed from the pipe. - - This function reconnects stdin to /dev/tty (the controlling terminal), - allowing the REPL to receive keyboard input after reading piped JSON. - This is the standard Unix pattern used by programs like fzf, less, and vim. *) -let reconnect_stdin_to_tty () = - try - let tty_fd = Unix.openfile "/dev/tty" [ Unix.O_RDONLY ] 0 in - Unix.dup2 tty_fd Unix.stdin; - Unix.close tty_fd; - true - with Unix.Unix_error _ -> false +(* The REPL is the separate query-json-repl binary: its TUI stack costs about + 3 ms of module initialization per process, which every plain query would + otherwise pay. Resolve it ourselves (next to argv.(0), next to the + resolved executable, then on PATH) instead of relying on the exit code + Sys.command's underlying shell gives for "not found": that's 127 on a + POSIX shell but 9009 on Windows' cmd.exe, and either way the shell prints + its own "not found" line before we get a chance to. *) +let run_repl args = + (* Sys.executable_name resolves through symlinks to dune's raw build + artifact, whose name always ends in ".exe" (even on Unix), so its + extension isn't the shipped binary's. Shipped binaries only carry + ".exe" on Windows. *) + let ext = + if Sys.win32 then + ".exe" + else + "" + in + let name = "query-json-repl" ^ ext in + let path_dirs = + match Sys.getenv_opt "PATH" with + | None -> + [] + | Some path -> + String.split_on_char + ( if Sys.win32 then + ';' + else + ':' + ) + path + in + (* A bare argv.(0) (found through PATH) has no directory of its own; using + "." there would run a stray ./query-json-repl from the current directory. *) + let argv0 = Sys.argv.(0) in + let argv0_dirs = + if Filename.basename argv0 = argv0 then + [] + else + [ Filename.dirname argv0 ] + in + let candidate_dirs = + argv0_dirs @ (Filename.dirname Sys.executable_name :: path_dirs) + in + let exe = + List.find_map + (fun dir -> + let candidate = Filename.concat dir name in + if Sys.file_exists candidate then + Some candidate + else + None + ) + candidate_dirs + in + match exe with + | None -> + print_error_message ~colorize:true + "query-json-repl was not found. Install it next to query-json or on \ + PATH."; + Stdlib.exit 127 + | Some exe -> + Stdlib.exit (Sys.command (Filename.quote_command exe args)) let execution position_0 position_1 verbose debug no_color raw_output null_input repl functions = @@ -97,74 +130,7 @@ let execution position_0 position_1 verbose debug no_color raw_output null_input Stdlib.exit 1 ) else if repl then - match (position_1, position_0) with - | Some file_or_json, query -> ( - let initial_query = Option.value ~default:"." query in - let json = - if Sys.file_exists file_or_json then - Json.parse_file file_or_json - else - Json.parse_string file_or_json - in - let path = - if Sys.file_exists file_or_json then - file_or_json - else - "" - in - match json with - | Ok json -> - Repl.make ~json ~path ~query:initial_query - | Error err -> - print_error_message ~colorize err; - Stdlib.exit 1 - ) - | None, Some file_or_json when Sys.file_exists file_or_json -> ( - match Json.parse_file file_or_json with - | Ok json -> - Repl.make ~json ~path:file_or_json ~query:"." - | Error err -> - print_error_message ~colorize err; - Stdlib.exit 1 - ) - | None, Some query -> ( - if Unix.isatty Unix.stdin then ( - repl_usage ~colorize (); - Stdlib.exit 1 - ) else - let json_result = - Json.parse_channel (Unix.in_channel_of_descr Unix.stdin) - in - if not (reconnect_stdin_to_tty ()) then ( - print_error_message ~colorize - "REPL requires an interactive terminal. No TTY available."; - Stdlib.exit 1 - ); - match json_result with - | Ok json -> - Repl.make ~json ~path:"" ~query - | Error err -> - print_error_message ~colorize err; - Stdlib.exit 1 - ) - | None, None -> ( - if Unix.isatty Unix.stdin then ( - repl_usage ~colorize (); - Stdlib.exit 1 - ) else - let json = Json.parse_channel (Unix.in_channel_of_descr Unix.stdin) in - if not (reconnect_stdin_to_tty ()) then ( - print_error_message ~colorize - "REPL requires an interactive terminal. No TTY available."; - Stdlib.exit 1 - ); - match json with - | Ok json -> - Repl.make ~json ~path:"" ~query:"." - | Error err -> - print_error_message ~colorize err; - Stdlib.exit 1 - ) + run_repl (List.filter_map Fun.id [ position_0; position_1 ]) else match position_0 with | None -> diff --git a/cli/dune b/cli/dune index a504e24..3423f44 100644 --- a/cli/dune +++ b/cli/dune @@ -3,6 +3,20 @@ (modes native) (public_name query-json) (package query-json) + (modules cli Help) + (libraries + query-json.core + query-json.console-style + cmdliner + unix + dune-build-info)) + +(executable + (name repl) + (modes native) + (public_name query-json-repl) + (package query-json) + (modules repl Repl_ui) (libraries query-json.core query-json.console-style diff --git a/cli/repl.ml b/cli/repl.ml new file mode 100644 index 0000000..d13a318 --- /dev/null +++ b/cli/repl.ml @@ -0,0 +1,100 @@ +(* The REPL links the Mosaic TUI stack. Its module initializers (cmarkit's + Unicode tables above all) cost about 3 ms and 8 MB per process, so the REPL + ships as its own binary and plain `query-json` queries keep a fast start. *) + +let version = + Option.fold ~none:"n/a" ~some:Build_info.V1.Version.to_string + (Build_info.V1.version ()) + +let print_error_message str = + print_endline (Console_style.enter 1 ^ str ^ Console_style.enter 1) + +let usage () = + let t = Console_style.make ~colorize:true in + [ + Console_style.enter 0; + t.yellow "Missing JSON file or inline JSON for REPL mode"; + Console_style.enter 1 ^ "Usage:" ^ Console_style.enter 2 + ^ t.bold "query-json" ^ " --repl " + ^ t.gray "[JSON_FILE | INLINE_JSON]" + ^ Console_style.enter 2 ^ Console_style.indent 1 ^ t.bold "EXAMPLES"; + Console_style.indent 3 ^ "query-json --repl package.json"; + Console_style.indent 3 ^ "query-json --repl '[1, 2, 3]'"; + Console_style.indent 3 ^ "query-json --repl '{\"name\": \"test\"}'"; + Console_style.indent 3 ^ "cat data.json | query-json --repl"; + Console_style.enter 1; + ] + |> String.concat (Console_style.enter 1) + |> print_endline + +(* When JSON is piped via stdin (e.g., `cat data.json | query-json --repl`), stdin is consumed by the JSON parser. The REPL then needs stdin for interactive keyboard input, but it's exhausted/closed from the pipe. + + This function reconnects stdin to /dev/tty (the controlling terminal), + allowing the REPL to receive keyboard input after reading piped JSON. + This is the standard Unix pattern used by programs like fzf, less, and vim. *) +let reconnect_stdin_to_tty () = + try + let tty_fd = Unix.openfile "/dev/tty" [ Unix.O_RDONLY ] 0 in + Unix.dup2 tty_fd Unix.stdin; + Unix.close tty_fd; + true + with Unix.Unix_error _ -> false + +let start ~json ~path ~query = + match json with + | Ok json -> + Repl_ui.make ~json ~path ~query + | Error err -> + print_error_message err; + Stdlib.exit 1 + +let from_stdin query = + if Unix.isatty Unix.stdin then ( + usage (); + Stdlib.exit 1 + ); + let json = Json.parse_channel (Unix.in_channel_of_descr Unix.stdin) in + if not (reconnect_stdin_to_tty ()) then ( + print_error_message + "REPL requires an interactive terminal. No TTY available."; + Stdlib.exit 1 + ); + start ~json ~path:"" ~query + +let execution position_0 position_1 = + match (position_0, position_1) with + | query, Some file_or_json -> + let query = Option.value ~default:"." query in + if Sys.file_exists file_or_json then + start ~json:(Json.parse_file file_or_json) ~path:file_or_json ~query + else + start ~json:(Json.parse_string file_or_json) ~path:"" ~query + | Some file, None when Sys.file_exists file -> + start ~json:(Json.parse_file file) ~path:file ~query:"." + | query, None -> + from_stdin (Option.value ~default:"." query) + +let () = + set_binary_mode_out stdout true; + let open Cmdliner.Arg in + let query = value & pos 0 (some string) None & info [] ~doc:"Initial query" in + let json = + value & pos 1 (some string) None & info [] ~doc:"JSON file or inline JSON" + in + let term = Cmdliner.Term.(const execution $ query $ json) in + let info = + Cmdliner.Cmd.info "query-json-repl" ~version + ~doc:"Interactive REPL for query-json" + ~man: + [ + `S Cmdliner.Manpage.s_description; + `P + "Explore a JSON document interactively with autocomplete. \ + $(b,query-json --repl) launches this binary."; + `S Cmdliner.Manpage.s_examples; + `P "query-json-repl package.json"; + `P "query-json-repl '.dependencies' package.json"; + `P "cat data.json | query-json-repl"; + ] + in + Stdlib.exit (Cmdliner.Cmd.eval (Cmdliner.Cmd.v info term)) diff --git a/cli/test/basic.t b/cli/test/basic.t index e6f46dd..28b6aa9 100644 --- a/cli/test/basic.t +++ b/cli/test/basic.t @@ -62,3 +62,11 @@ raw output with string containing escape sequences $ printf '{"message": "Hello\\nWorld\\t!"}' | query-json --no-color -r '.message' Hello World ! + +--repl launches query-json-repl, which reports JSON errors before opening the UI + + $ echo '{' > bad.json + $ query-json --repl . bad.json > repl.out; echo "exit $?"; grep . repl.out + exit 1 + JSON parse error: Line 2, bytes -1-0: + Unexpected end of input diff --git a/cli/test/dune b/cli/test/dune index 1ffa966..0dcc56e 100644 --- a/cli/test/dune +++ b/cli/test/dune @@ -1,3 +1,3 @@ (cram (alias runtest) - (deps %{bin:query-json} ./mock.json)) + (deps %{bin:query-json} %{bin:query-json-repl} ./mock.json)) diff --git a/scripts/install.sh b/scripts/install.sh index ee81c00..9f9ba2b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -62,6 +62,10 @@ download() { unzip -q "$DOWNLOAD_DIR/$FILENAME.zip" -d "$DOWNLOAD_DIR" mv "$DOWNLOAD_DIR/query-json" "$INSTALL_DIR/query-json" chmod u+x "$INSTALL_DIR/query-json" + if [ -f "$DOWNLOAD_DIR/query-json-repl" ]; then + mv "$DOWNLOAD_DIR/query-json-repl" "$INSTALL_DIR/query-json-repl" + chmod u+x "$INSTALL_DIR/query-json-repl" + fi } check_dependencies() { diff --git a/scripts/make-npm-release.js b/scripts/make-npm-release.js index f77dd82..07ffdb8 100644 --- a/scripts/make-npm-release.js +++ b/scripts/make-npm-release.js @@ -33,7 +33,7 @@ Fs.copyFileSync( Path.join(releaseFolder, 'postinstall.js') ); -const filesToTouch = ['query-json']; +const filesToTouch = ['query-json', 'query-json-repl']; /* Generate an empty binary */ for (const file of filesToTouch) { @@ -53,7 +53,8 @@ const pkgJson = { }, "main": "query-json-js/js.bc.js", "bin": { - "query-json": "query-json" + "query-json": "query-json", + "query-json-repl": "query-json-repl" }, "files": [ "platform-windows-x64/", diff --git a/scripts/release-postinstall.js b/scripts/release-postinstall.js index 343c714..b9cbd79 100644 --- a/scripts/release-postinstall.js +++ b/scripts/release-postinstall.js @@ -6,7 +6,7 @@ const Fs = require('fs'); const platform = process.platform; -const binariesToCopy = ['query-json']; +const binariesToCopy = ['query-json', 'query-json-repl']; function find_arch() { // The running binary is 64-bit, so the OS is clearly 64-bit.