diff --git a/docs/content/manual/dev/manual.yml b/docs/content/manual/dev/manual.yml index fcdbcfa3cf..0665ec90d1 100644 --- a/docs/content/manual/dev/manual.yml +++ b/docs/content/manual/dev/manual.yml @@ -152,6 +152,44 @@ sections: Like `-r` but jq won't print a newline after each output. + * `--in-place` / `-i`: + + Edit one file in place by writing output to a temporary file in + the same directory, then renaming it over the original. + + This follows `sed -i`-style direct-file usage: + + * the input file is also the output target; + * exactly one input file argument is required; + * stdin (`-`) is rejected; + * using multiple input files is rejected; + * combining with `--null-input` / `-n` is rejected. + + On jq compile/runtime/system errors, jq leaves the original file + unchanged and removes temporary output. + + Example usage patterns: + + * Update a package version from a shell variable: + + `jq -i ".version = ${VER}" package.json` + + * Increment a field and keep output compact: + + `jq -c --in-place '.count += 1' state.json` + + * Normalize key order in-place: + + `jq -S --in-place '.' config.json` + + * Slurp multiple JSON texts from one file, transform once, write back: + + `jq -s --in-place 'map(.enabled = true)' records.json` + + * Produce non-JSON text in-place (valid, but replaces file with raw text): + + `jq -r --in-place '.items[]' items.json` + * `--ascii-output` / `-a`: jq usually outputs non-ASCII Unicode codepoints as UTF-8, even diff --git a/jq.1.prebuilt b/jq.1.prebuilt index 0b15447ec6..f327fb6189 100644 --- a/jq.1.prebuilt +++ b/jq.1.prebuilt @@ -1,5 +1,5 @@ . -.TH "JQ" "1" "May 2025" "" "" +.TH "JQ" "1" "March 2026" "" "" . .SH "NAME" \fBjq\fR \- Command\-line JSON processor @@ -100,9 +100,58 @@ Like \fB\-r\fR but jq will print NUL instead of newline after each output\. This Like \fB\-r\fR but jq won\'t print a newline after each output\. . .TP -\fB\-\-ascii\-output\fR / \fB\-a\fR: +\fB\-\-in\-place\fR / \fB\-i\fR: +. +.IP +Edit one file in place by writing output to a temporary file in the same directory, then renaming it over the original\. . .IP +This follows \fBsed \-i\fR\-style direct\-file usage: +. +.IP "\(bu" 4 +the input file is also the output target; +. +.IP "\(bu" 4 +exactly one input file argument is required; +. +.IP "\(bu" 4 +stdin (\fB\-\fR) is rejected; +. +.IP "\(bu" 4 +using multiple input files is rejected; +. +.IP "\(bu" 4 +combining with \fB\-\-null\-input\fR / \fB\-n\fR is rejected\. +. +.IP "" 0 +. +.P +On jq compile/runtime/system errors, jq leaves the original file unchanged and removes temporary output\. +. +.P +Example usage patterns: +. +.IP "\(bu" 4 +Update a package version from a shell variable:\fBjq \-i "\.version = ${VER}" package\.json\fR +. +.IP "\(bu" 4 +Increment a field and keep output compact:\fBjq \-c \-\-in\-place \'\.count += 1\' state\.json\fR +. +.IP "\(bu" 4 +Normalize key order in\-place:\fBjq \-S \-\-in\-place \'\.\' config\.json\fR +. +.IP "\(bu" 4 +Slurp multiple JSON texts from one file, transform once, write back:\fBjq \-s \-\-in\-place \'map(\.enabled = true)\' records\.json\fR +. +.IP "\(bu" 4 +Produce non\-JSON text in\-place (valid, but replaces file with raw text):\fBjq \-r \-\-in\-place \'\.items[]\' items\.json\fR +. +.IP "\(bu" 4 +\fB\-\-ascii\-output\fR / \fB\-a\fR: +. +.IP "" 0 +. +.P jq usually outputs non\-ASCII Unicode codepoints as UTF\-8, even if the input specified them as escape sequences (like "\eu03bc")\. Using this option, you can force jq to produce pure ASCII output with every non\-ASCII character replaced with the equivalent escape sequence\. . .TP diff --git a/src/main.c b/src/main.c index 384e66d2b3..17e5d1d4f1 100644 --- a/src/main.c +++ b/src/main.c @@ -9,6 +9,7 @@ #include #include #include +#include #ifdef WIN32 #include @@ -72,6 +73,7 @@ static void usage(int code, int keep_it_short) { "Command options:\n" " -n, --null-input use `null` as the single input value;\n" " -R, --raw-input read each line as string instead of JSON;\n" + " -i, --in-place update the input file in place;\n" " -s, --slurp read all inputs into an array and use it as\n" " the single input value;\n" " -c, --compact-output compact instead of pretty-printed output;\n" @@ -122,6 +124,62 @@ static void die(void) { exit(2); } +static int replace_file(const char *src, const char *dst) { +#ifdef WIN32 + int src_wlen = MultiByteToWideChar(CP_UTF8, 0, src, -1, NULL, 0); + int dst_wlen = MultiByteToWideChar(CP_UTF8, 0, dst, -1, NULL, 0); + if (src_wlen <= 0 || dst_wlen <= 0) { + errno = EINVAL; + return -1; + } + wchar_t *src_w = malloc((size_t)src_wlen * sizeof(wchar_t)); + wchar_t *dst_w = malloc((size_t)dst_wlen * sizeof(wchar_t)); + if (src_w == NULL || dst_w == NULL) { + free(src_w); + free(dst_w); + errno = ENOMEM; + return -1; + } + if (!MultiByteToWideChar(CP_UTF8, 0, src, -1, src_w, src_wlen) || + !MultiByteToWideChar(CP_UTF8, 0, dst, -1, dst_w, dst_wlen)) { + free(src_w); + free(dst_w); + errno = EINVAL; + return -1; + } + if (!MoveFileExW(src_w, dst_w, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DWORD err = GetLastError(); + switch (err) { + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + errno = ENOENT; + break; + case ERROR_ACCESS_DENIED: + errno = EACCES; + break; + case ERROR_FILE_EXISTS: + case ERROR_ALREADY_EXISTS: + errno = EEXIST; + break; + case ERROR_SHARING_VIOLATION: + errno = EBUSY; + break; + default: + errno = EIO; + break; + } + free(src_w); + free(dst_w); + return -1; + } + free(src_w); + free(dst_w); + return 0; +#else + return rename(src, dst); +#endif +} + static int isoptish(const char* text) { return text[0] == '-' && (text[1] == '-' || isalpha((unsigned char)text[1])); } @@ -156,6 +214,7 @@ enum { RAW_NO_LF = 1024, UNBUFFERED_OUTPUT = 2048, EXIT_STATUS = 4096, + IN_PLACE = 8192, SEQ = 16384, /* debugging only */ DUMP_DISASM = 32768, @@ -297,6 +356,11 @@ int main(int argc, char* argv[]) { int last_result = -1; /* -1 = no result, 0=null or false, 1=true */ int badwrite; int options = 0; + const char *in_place_input = NULL; + char *in_place_tmp = NULL; +#ifdef WIN32 + int binary_mode = 0; +#endif #ifdef HAVE_SETLOCALE (void) setlocale(LC_ALL, ""); @@ -359,6 +423,8 @@ int main(int argc, char* argv[]) { ARGS = jv_array_append(ARGS, v); } else { jq_util_input_add_input(input_state, argv[i]); + if (nfiles == 0) + in_place_input = argv[i]; nfiles++; } } else if (!strcmp(argv[i], "--")) { @@ -419,6 +485,7 @@ int main(int argc, char* argv[]) { } } else if (isoption(&text, 'b', "binary", is_short)) { #ifdef WIN32 + binary_mode = 1; fflush(stdout); fflush(stderr); _setmode(fileno(stdin), _O_BINARY); @@ -452,6 +519,8 @@ int main(int argc, char* argv[]) { parser_flags |= JV_PARSE_STREAMING | JV_PARSE_STREAM_ERRORS; } else if (isoption(&text, 'e', "exit-status", is_short)) { options |= EXIT_STATUS; + } else if (isoption(&text, 'i', "in-place", is_short)) { + options |= IN_PLACE; } else if (isoption(&text, 0, "args", is_short)) { further_args_are_strings = 1; further_args_are_json = 0; @@ -596,6 +665,71 @@ int main(int argc, char* argv[]) { if (!program) usage(2, 1); + if (options & IN_PLACE) { + if (nfiles != 1 || in_place_input == NULL || !strcmp(in_place_input, "-")) { + fprintf(stderr, "jq: --in-place requires exactly one input file\n"); + die(); + } + if (options & PROVIDE_NULL) { + fprintf(stderr, "jq: --in-place cannot be used with --null-input\n"); + die(); + } + + size_t tlen = strlen(in_place_input) + sizeof(".XXXXXX"); + in_place_tmp = malloc(tlen); + if (in_place_tmp == NULL) { + fprintf(stderr, "jq: error: out of memory\n"); + ret = JQ_ERROR_SYSTEM; + goto out; + } + int n = snprintf(in_place_tmp, tlen, "%s.XXXXXX", in_place_input); + assert(n > 0 && (size_t)n < tlen); + + int in_place_fd = mkstemp(in_place_tmp); + if (in_place_fd == -1) { + fprintf(stderr, "jq: error: cannot create temporary file for --in-place: %s\n", strerror(errno)); + ret = JQ_ERROR_SYSTEM; + goto out; + } +#ifndef WIN32 + struct stat st; + if (stat(in_place_input, &st) == 0 && + fchmod(in_place_fd, st.st_mode & 07777) == -1) { + fprintf(stderr, "jq: error: cannot set temporary file permissions for --in-place: %s\n", + strerror(errno)); + close(in_place_fd); + unlink(in_place_tmp); + ret = JQ_ERROR_SYSTEM; + goto out; + } +#endif + if (close(in_place_fd) == -1) { + fprintf(stderr, "jq: error: cannot close temporary file for --in-place: %s\n", strerror(errno)); + unlink(in_place_tmp); + ret = JQ_ERROR_SYSTEM; + goto out; + } + const char *in_place_mode = "w"; +#ifdef WIN32 + if (binary_mode) + in_place_mode = "wb"; +#endif + if (freopen(in_place_tmp, in_place_mode, stdout) == NULL) { + fprintf(stderr, "jq: error: cannot redirect output for --in-place: %s\n", strerror(errno)); + unlink(in_place_tmp); + ret = JQ_ERROR_SYSTEM; + goto out; + } +#ifdef WIN32 + if (binary_mode) + _setmode(fileno(stdout), _O_BINARY); +#endif + // In-place output is written to a file, not an interactive terminal. + dumpopts &= ~JV_PRINT_ISATTY; + if (!(options & COLOR_OUTPUT)) + dumpopts &= ~JV_PRINT_COLOR; + } + if (options & FROM_FILE) { char *program_origin = strdup(program); if (program_origin == NULL) { @@ -701,6 +835,18 @@ int main(int argc, char* argv[]) { fprintf(stderr,"jq: error: writing output failed: %s\n", strerror(errno)); ret = JQ_ERROR_SYSTEM; } + if (in_place_tmp != NULL) { + if (ret <= JQ_OK) { + if (replace_file(in_place_tmp, in_place_input) != 0) { + fprintf(stderr, "jq: error: cannot replace %s: %s\n", in_place_input, strerror(errno)); + unlink(in_place_tmp); + ret = JQ_ERROR_SYSTEM; + } + } else { + unlink(in_place_tmp); + } + free(in_place_tmp); + } jv_free(ARGS); jv_free(program_arguments); diff --git a/tests/shtest b/tests/shtest index 90a96eafd6..f85c8f6e35 100755 --- a/tests/shtest +++ b/tests/shtest @@ -125,6 +125,125 @@ printf "$data" | $JQ --exit-status 'select(.i==2) | false' > /dev/null 2>&1 || r [ $ret -eq 1 ] printf "$data" | $JQ --exit-status 'select(.i==2) | true' > /dev/null 2>&1 +# Test --in-place / -i (no backup suffix argument needed) +printf '{"n":1}\n' > $d/inplace.json +$VALGRIND $Q $JQ -c --in-place '.n += 1' $d/inplace.json +printf '{"n":2}\n' > $d/expected +cmp $d/inplace.json $d/expected + +printf '1\n2\n' > $d/inplace-numbers.json +$VALGRIND $Q $JQ -c -i '.+1' $d/inplace-numbers.json +printf '2\n3\n' > $d/expected +cmp $d/inplace-numbers.json $d/expected + +# Make sure in-place output is not terminal-colorized by default +test_in_place_color=true +$msys && test_in_place_color=false +$mingw && test_in_place_color=false +have_faketty=false +if $test_in_place_color && command -v script >/dev/null 2>&1; then + if script -qec true /dev/null >/dev/null 2>&1; then + faketty() { script -qec "$*" /dev/null >/dev/null; } + have_faketty=true + elif script -q /dev/null true >/dev/null 2>&1; then # macOS/BSD + faketty() { script -q /dev/null "$@" >/dev/null; } + have_faketty=true + fi +fi + +if $have_faketty; then + printf '{"x":1}\n' > $d/inplace-color.json + faketty $JQ_NO_B --in-place '.x=2' $d/inplace-color.json > /dev/null + printf '{\n "x": 2\n}\n' > $d/expected + cmp $d/inplace-color.json $d/expected +fi + +# sed-like unhappy paths from issue discussions +ret=0 +if $JQ --in-place '.+1' > /dev/null 2>&1; then + echo "--in-place must reject missing input file argument" 1>&2 + exit 1 +else + ret=$? +fi +[ $ret -eq 2 ] + +ret=0 +if printf '1\n' | $JQ --in-place '.+1' - > /dev/null 2>&1; then + echo "--in-place must reject stdin as input file" 1>&2 + exit 1 +else + ret=$? +fi +[ $ret -eq 2 ] + +printf '1\n' > $d/inplace-a.json +printf '2\n' > $d/inplace-b.json +ret=0 +if $JQ --in-place '.+1' $d/inplace-a.json $d/inplace-b.json > /dev/null 2>&1; then + echo "--in-place must reject multiple input files" 1>&2 + exit 1 +else + ret=$? +fi +[ $ret -eq 2 ] +printf '1\n' > $d/expected +cmp $d/inplace-a.json $d/expected +printf '2\n' > $d/expected +cmp $d/inplace-b.json $d/expected + +ret=0 +if $JQ -n --in-place '.+1' $d/inplace-a.json > /dev/null 2>&1; then + echo "--in-place must reject --null-input" 1>&2 + exit 1 +else + ret=$? +fi +[ $ret -eq 2 ] + +# Preserve original file on compile and runtime errors +printf '{"k":1}\n' > $d/inplace-error.json +ret=0 +if $JQ --in-place '.+=' $d/inplace-error.json > /dev/null 2>&1; then + echo "--in-place should fail on compile error" 1>&2 + exit 1 +else + ret=$? +fi +[ $ret -eq 3 ] +printf '{"k":1}\n' > $d/expected +cmp $d/inplace-error.json $d/expected + +ret=0 +if $JQ --in-place 'error("boom")' $d/inplace-error.json > /dev/null 2>&1; then + echo "--in-place should fail on runtime error" 1>&2 + exit 1 +else + ret=$? +fi +[ $ret -eq 5 ] +printf '{"k":1}\n' > $d/expected +cmp $d/inplace-error.json $d/expected + +# mkstemp failure path: non-writable directory +if ! $msys && ! $mingw; then + mkdir $d/inplace-ro + printf '1\n' > $d/inplace-ro/file.json + chmod 0555 $d/inplace-ro + ret=0 + if $JQ --in-place '.+1' $d/inplace-ro/file.json > /dev/null 2>&1; then + echo "--in-place should fail when temp file cannot be created" 1>&2 + chmod 0755 $d/inplace-ro + exit 1 + else + ret=$? + fi + [ $ret -eq 2 ] + chmod 0755 $d/inplace-ro + printf '1\n' > $d/expected + cmp $d/inplace-ro/file.json $d/expected +fi + # Regression test for #951 printf '"a\n' > $d/input