Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ffec773
feat(output): add yaml output
Dustin-Jiang Sep 30, 2025
97d6bfb
docs: update CHANGELOG
Dustin-Jiang Sep 30, 2025
cdb7bc0
fix: escape path, and disable permission display on Windows
Dustin-Jiang Sep 30, 2025
c9cbb25
fix(ci): fix warnings in cargo clippy
Dustin-Jiang Sep 30, 2025
192ca92
refactor: make output stateful
Dustin-Jiang Sep 30, 2025
602b389
feat(output): add json output
Dustin-Jiang Sep 30, 2025
eb2f10d
fix(ci): fix warnings in cargo clippy
Dustin-Jiang Sep 30, 2025
4ea0ed3
fix: fix function calling in Windows
Dustin-Jiang Sep 30, 2025
e225946
fix: fix reference mutable type annotation in Windows
Dustin-Jiang Sep 30, 2025
977ee0e
fix: resolve suggested changes
Dustin-Jiang Oct 10, 2025
6434ee5
fix: move JSON array printing to Printer
Dustin-Jiang Oct 10, 2025
b2d385f
feat: implement NDJSON output
Dustin-Jiang Oct 11, 2025
703b32f
fix(ci): fix warnings in cargo clippy
Dustin-Jiang Oct 11, 2025
650e86c
tests: add tests for `--output` flags
Dustin-Jiang Oct 12, 2025
2e463c7
docs: update manpage for `--output` flags
Dustin-Jiang Oct 12, 2025
e46cce0
Merge branch 'master' into feature-yaml
Dustin-Jiang Oct 13, 2025
10570e9
tests: fix invalid utf8 base64 test
Dustin-Jiang Oct 15, 2025
949a5aa
docs: update manpage to change "ndjson" to "jsonl"
Dustin-Jiang Oct 15, 2025
cb3ef97
fix: change FileDetail creating logic and base64 import
Dustin-Jiang Oct 15, 2025
60ecc09
fix: change ndjson flag to commonly used jsonl
Dustin-Jiang Oct 16, 2025
7c9f1d8
fix: replace String to &str with lifetime, adopt as_encoded_bytes
Dustin-Jiang Oct 29, 2025
2c1bdb5
feat: add --json flag for JSONL output
Dustin-Jiang Oct 29, 2025
e831976
docs: add --json flag to manpage
Dustin-Jiang Oct 29, 2025
30c9e86
Merge branch 'master' into feature-yaml
Dustin-Jiang Oct 29, 2025
49654f4
fix(clippy): collapse if blocks
Dustin-Jiang Nov 2, 2025
e4741c0
Merge remote-tracking branch 'origin/master' into feature-yaml
Dustin-Jiang Nov 2, 2025
4a0ecc5
Merge remote-tracking branch 'origin/master' into feature-yaml
Dustin-Jiang Nov 8, 2025
56d347e
fix: remove the `--output` flag
Dustin-Jiang Nov 8, 2025
6db4409
tests: fix `--output` tests to `--json`
Dustin-Jiang Nov 9, 2025
c2c8497
docs: add fields explaination in manual
Dustin-Jiang Nov 13, 2025
13d0868
docs: change the flag to `--json` in CHANGELOG.md
Dustin-Jiang Nov 13, 2025
47ee6ce
fix(printer): make `Priner.stdout` private
Dustin-Jiang Nov 13, 2025
58b90f7
Merge remote-tracking branch 'origin/master' into feature-yaml
Dustin-Jiang Nov 13, 2025
8f36886
fix: use `jiff::Timestamp::try_from` to process SystemTime
Dustin-Jiang Nov 20, 2025
64da6b5
Merge remote-tracking branch 'origin/master' into feature-yaml
Dustin-Jiang Nov 20, 2025
474cdd3
refactor: json fmt mod
tmccombs Dec 23, 2025
766d684
Merge branch 'master' into feature-yaml
tmccombs Dec 23, 2025
3a620b0
refactor: json fmt mod
tmccombs Dec 23, 2025
2adb30d
fix: Use path separator in json output
tmccombs Dec 29, 2025
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Features

- Add `--json` flag for JSONL format output.

## Bugfixes

Expand Down
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 10 additions & 10 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,13 @@ description = "fd is a simple, fast and user-friendly alternative to find."
exclude = ["/benchmarks/*"]
homepage = "https://github.com/sharkdp/fd"
documentation = "https://docs.rs/fd-find"
keywords = [
"search",
"find",
"file",
"filesystem",
"tool",
]
keywords = ["search", "find", "file", "filesystem", "tool"]
license = "MIT OR Apache-2.0"
name = "fd-find"
readme = "README.md"
repository = "https://github.com/sharkdp/fd"
version = "10.3.0"
edition= "2024"
edition = "2024"
rust-version = "1.90.0"

[badges.appveyor]
Expand Down Expand Up @@ -46,6 +40,7 @@ crossbeam-channel = "0.5.15"
clap_complete = {version = "4.5.61", optional = true}
faccess = "0.2.4"
jiff = "0.2.16"
base64 = "0.22.1"

[dependencies.clap]
version = "4.5.53"
Expand All @@ -57,7 +52,11 @@ default-features = false
features = ["nu-ansi-term"]

[target.'cfg(unix)'.dependencies]
nix = { version = "0.30.1", default-features = false, features = ["signal", "user", "hostname"] }
nix = { version = "0.30.1", default-features = false, features = [
"signal",
"user",
"hostname",
] }

[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies]
libc = "0.2"
Expand All @@ -68,13 +67,14 @@ libc = "0.2"
# This has to be kept in sync with src/main.rs where the allocator for
# the program is set.
[target.'cfg(all(not(windows), not(target_os = "android"), not(target_os = "macos"), not(target_os = "freebsd"), not(target_os = "openbsd"), not(target_os = "illumos"), not(all(target_env = "musl", target_pointer_width = "32")), not(target_arch = "riscv64")))'.dependencies]
tikv-jemallocator = {version = "0.6.0", optional = true}
tikv-jemallocator = { version = "0.6.0", optional = true }

[dev-dependencies]
diff = "0.1"
tempfile = "3.23"
filetime = "0.2"
test-case = "3.3"
serde_json = "1.0.145"

[profile.release]
lto = true
Expand Down
28 changes: 28 additions & 0 deletions doc/fd.1
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,34 @@ Maximum number of arguments to pass to the command given with -X. If the number
greater than the given size, the command given with -X is run again with remaining arguments. A
batch size of zero means there is no limit (default), but note that batching might still happen
due to OS restrictions on the maximum length of command lines.
.TP
.BI "\-\-json "
.RS
Specify JSONL (as known as NDJSON) format to use for the output.

Output fields:

- "path": An object containing the path of the file. When the path is valid UTF-8, it this contains a single "text" field
containing the path as a string. Otherwise it contains a single "bytes" field containing the base64 encoded bytes of the
path.

On windows, this may use a lossy UTF-8 encoding, since there isn't an obvious way to encode the pathname.

If a custom path separator is given, it is used in the "text" field, but not in the "bytes" field.

- "type": The file type (e.g., "file", "directory", "symlink").

- "size_bytes": The file size in bytes.

- "mode": The file permissions in octal (e.g., 644).

- "modified": The last modification time in RFC3339 (ISO 8601) format (e.g., 2000-01-01T12:00:00Z).

- "accessed": The last access time in RFC3339 format.

- "created": The creation time in RFC3339 format.
.RE
.TP
.SH PATTERN SYNTAX
The regular expression syntax used by fd is documented here:

Expand Down
10 changes: 10 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,16 @@ pub struct Opts {
)]
search_path: Vec<PathBuf>,

/// Print results in JSONL format.
#[arg(
long,
value_name = "json",
help = "Print results in JSONL format so you can pipe it to tools.",
conflicts_with_all(&["format", "list_details"]),
long_help
)]
pub json: bool,

/// By default, relative paths are prefixed with './' when -x/--exec,
/// -X/--exec-batch, or -0/--print0 are given, to reduce the risk of a
/// path starting with '-' being treated as a command line option. Use
Expand Down
13 changes: 5 additions & 8 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
use std::{path::PathBuf, sync::Arc, time::Duration};

use lscolors::LsColors;
use regex::bytes::RegexSet;

use crate::exec::CommandSet;
use crate::filetypes::FileTypes;
#[cfg(unix)]
use crate::filter::OwnerFilter;
use crate::filter::{SizeFilter, TimeFilter};
use crate::fmt::FormatTemplate;
use crate::fmt::OutputFormat;

/// Configuration options for *fd*.
pub struct Config {
Expand Down Expand Up @@ -70,10 +69,6 @@ pub struct Config {
/// `max_buffer_time`.
pub max_buffer_time: Option<Duration>,

/// `None` if the output should not be colorized. Otherwise, a `LsColors` instance that defines
/// how to style different filetypes.
pub ls_colors: Option<LsColors>,

/// Whether or not we are writing to an interactive terminal
#[cfg_attr(not(unix), allow(unused))]
pub interactive_terminal: bool,
Expand All @@ -87,8 +82,10 @@ pub struct Config {
/// The value (if present) will be a lowercase string without leading dots.
pub extensions: Option<RegexSet>,

/// A format string to use to format results, similarly to exec
pub format: Option<FormatTemplate>,
/// The format to use for the output
///
/// determined by multiple options
pub format: OutputFormat,

/// If a value is supplied, each item found will be used to generate and execute commands.
pub command: Option<Arc<CommandSet>>,
Expand Down
88 changes: 88 additions & 0 deletions src/fmt/json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use std::borrow::Cow;
use std::fs::{FileType, Metadata};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::{ffi::OsStrExt, fs::MetadataExt};
use std::path::{MAIN_SEPARATOR, Path};
use std::time::SystemTime;

use base64::{Engine as _, prelude::BASE64_STANDARD};
use jiff::Timestamp;

pub fn output_json<W: Write>(
out: &mut W,
path: &Path,
filetype: Option<FileType>,
metadata: Option<&Metadata>,
path_separator: &Option<String>,
) -> std::io::Result<()> {
out.write_all(b"{")?;

// Print the path as an object that either has a "text" key containing the
// utf8 path, or a "bytes" key with the base64 encoded bytes of the path
#[cfg(unix)]
match path.to_str() {
Some(text) => {
let final_path: Cow<str> = if let Some(sep) = path_separator {
text.replace(MAIN_SEPARATOR, sep).into()
} else {
text.into()
};
// NB: This assumes that rust's debug output for a string
// is a valid JSON string. At time of writing this is the case
// but it is possible, though unlikely, that this could change
// in the future.
write!(out, r#""path":{{"text":{:?}}}"#, final_path)?;
}
None => {
let encoded_bytes = BASE64_STANDARD.encode(path.as_os_str().as_bytes());
write!(out, r#""path":{{"bytes":"{}"}}"#, encoded_bytes)?;
}
};
// On non-unix platforms, if the path isn't valid utf-8,
// we don't know what kind of encoding was used, and
// as_encoded_bytes() isn't necessarily stable between rust versions
// so the best we can really do is a lossy string
#[cfg(not(unix))]
write!(out, r#""path":{{"text":{:?}}}"#, path.to_string_lossy())?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what ripgrep does. Although I'm not sure if it would be better to do one of the following:

  1. Use OsStr::as_encoded_bytes. Currently, on windows, I think this uses WTF-8, but the documentation makes it clear this isn't guaranteed, and could change in a future version of rust.
  2. Output as (invalid) UTF-16, base64 encoded on windows. Probably not what most people would expect, but at least doesn't lose data.
  3. Explicitly output using WTF-8 by converting the OsStr to [u16] (or an iterater over wide chars), then back to wtf8. Possibly using the "wtf8" crate. It seems wasteful, and not very performant, but at least we aren't reliant on rust's current implementation.

Copy link
Copy Markdown

@BurntSushi BurntSushi Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, ripgrep has been doing this lossy encoding for years. So far there hasn't been a single complaint. I think non-UTF-16 paths are extremely rare on Windows. Much rarer than non-UTF-8 paths on Unix.

2. Output as (invalid) UTF-16, base64 encoded on windows. Probably not what most people would expect, but at least doesn't lose data.

If you want to avoid lossiness, I think this is the best option. ripgrep also does this for invalid UTF-8. (It looks like this PR does it too.) Namely, this avoids making it too easy to spread WTF-8 as an interchange format:

WTF-8 must not be used to represent text in a file format or for transmission over the Internet.


// print the type of file
let ft = match filetype {
Some(ft) if ft.is_dir() => "directory",
Some(ft) if ft.is_file() => "file",
Some(ft) if ft.is_symlink() => "symlink",
_ => "unknown",
};
write!(out, r#","type":"{}""#, ft)?;

if let Some(meta) = metadata {
// Output the mode as octal
// We also need to mask it to just include the permission
// bits and not the file type bits (that is handled by "type" above)
#[cfg(unix)]
write!(out, r#","mode":"{:o}""#, meta.mode() & 0x7777)?;

write!(out, r#","size_bytes":{}"#, meta.len())?;

// would it be better to do these with os-specific functions?
if let Ok(modified) = meta.modified().map(json_timestamp) {
write!(out, r#","modified":"{}""#, modified)?;
}
if let Ok(accessed) = meta.accessed().map(json_timestamp) {
write!(out, r#","modified":"{}""#, accessed)?;
}
if let Ok(created) = meta.created().map(json_timestamp) {
write!(out, r#","modified":"{}""#, created)?;
}
}

out.write_all(b"}")
}

fn json_timestamp(time: SystemTime) -> Timestamp {
// System timestamps should always be valid, so assume that we can
// unwrap it
// If we ever do want to handle an error here, maybe convert to either the MAX or MIN
// timestamp depending on which side of the epoch the SystemTime is?
Timestamp::try_from(time).expect("Invalid timestamp")
}
Loading
Loading