Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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/')
Expand Down Expand Up @@ -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 ../..

Expand Down
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
File renamed without changes.
File renamed without changes.
162 changes: 64 additions & 98 deletions cli/cli.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
"<inline>"
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:"<stdin>" ~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:"<stdin>" ~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 ->
Expand Down
14 changes: 14 additions & 0 deletions cli/dune
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions cli/repl.ml
Original file line number Diff line number Diff line change
@@ -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:"<stdin>" ~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:"<inline>" ~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))
8 changes: 8 additions & 0 deletions cli/test/basic.t
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion cli/test/dune
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
(cram
(alias runtest)
(deps %{bin:query-json} ./mock.json))
(deps %{bin:query-json} %{bin:query-json-repl} ./mock.json))
Loading
Loading