blob: 66937764b582267a365a0c87eec7d9f90d962fba [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use anyhow;
use fuchsia_async;
use futures::future::join_all;
use io::BufWriter;
use json5format::{Json5Format, ParsedDocument};
use std::{
ffi::OsString,
io::{self, Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
};
use structopt::StructOpt;
mod reader;
mod traverser;
/// Spawns a `jq` process with the specified filter and pipes `json_string` into its stdin. Returns
/// its jq output or an error if it produces an error. If `jq_path` is `None`, it assumes `jq` is in
/// the system path and attempts to invoke it using simply the command `jq`. Otherwise, it invokes
/// `jq` using the provided path.
async fn run_jq(
filter: &String,
json_string: String,
jq_path: &Option<PathBuf>,
) -> Result<String, anyhow::Error> {
let mut cmd_jq = match jq_path {
Some(path) => {
let command_str = path.as_path().to_str().unwrap();
if !Path::exists(Path::new(&command_str)) {
return Err(anyhow::anyhow!(
"Path provided in path-to-jq option did not specify a valid path to a binary."
));
}
Command::new(command_str)
.arg(&filter[..])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?
}
None => {
let command_string = OsString::from("fx");
Command::new(&command_string)
.arg("jq")
.arg(&filter[..])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?
}
};
let mut cmd_jq_stdin = cmd_jq.stdin.take().unwrap();
let bytestring = json_string.as_bytes();
let mut writer = BufWriter::new(&mut cmd_jq_stdin);
writer.write_all(bytestring)?;
//Close stdin
writer.flush()?;
drop(writer);
drop(cmd_jq_stdin);
let status = cmd_jq.wait()?;
let mut cmd_jq_stdout = String::new();
let mut cmd_jq_stderr = String::new();
let stdout = cmd_jq.stdout;
let stderr = cmd_jq.stderr;
if let Some(mut err) = stderr {
err.read_to_string(&mut cmd_jq_stderr)?;
Err(anyhow::anyhow!("jq produced the following error message:\n {}", cmd_jq_stderr))
} else if let Some(mut out) = stdout {
out.read_to_string(&mut cmd_jq_stdout)?;
Ok(cmd_jq_stdout)
} else if !status.success() {
Err(anyhow::anyhow!("jq returned with non-zero exit code but no error message"))
} else {
Err(anyhow::anyhow!("jq returned exit code 0 but no output or error message"))
}
}
/// Calls jq on the provided json and then fills back comments at correct places.
async fn run_jq5(
filter: &String,
parsed_json5: ParsedDocument,
json_string: String,
jq_path: &Option<PathBuf>,
) -> Result<String, anyhow::Error> {
let jq_out = run_jq(&filter, json_string, jq_path).await?;
let mut parsed_json = ParsedDocument::from_string(jq_out, None)?;
traverser::fill_comments(&parsed_json5.content, &mut parsed_json.content)?;
let format = Json5Format::new()?;
Ok(format.to_string(&parsed_json)?)
}
/// Calls `run_jq5` on the contents of a file and returns the return value of `run_jq5`.
async fn run_jq5_on_file(
filter: &String,
file: &PathBuf,
jq_path: &Option<PathBuf>,
) -> Result<String, anyhow::Error> {
let (parsed_json5, json_string) = reader::read_json5_fromfile(&file)?;
run_jq5(&filter, parsed_json5, json_string, jq_path).await
}
async fn run(
filter: String,
files: Vec<PathBuf>,
jq_path: &Option<PathBuf>,
) -> Result<Vec<String>, anyhow::Error> {
let mut jq5_output_futures = Vec::with_capacity(files.len());
for file in files.iter() {
jq5_output_futures.push(run_jq5_on_file(&filter, file, &jq_path));
}
let jq5_outputs = join_all(jq5_output_futures).await;
let mut trusted_outs = Vec::with_capacity(jq5_outputs.len());
for (i, jq5_output) in jq5_outputs.into_iter().enumerate() {
match jq5_output {
Err(err) => {
return Err(anyhow::anyhow!(
r"jq5 encountered an error processing at least one of the provided json5 objects.
The first error occured while processing file'{}':
{}",
files[i].as_path().to_str().unwrap(),
err
));
}
Ok(output) => {
trusted_outs.push(output);
}
}
}
Ok(trusted_outs)
}
#[fuchsia_async::run_singlethreaded]
async fn main() -> Result<(), anyhow::Error> {
eprintln!("{}", "This tool is a work in progress: use with caution.\n");
let args = Opt::from_args();
if args.files.len() == 0 {
let (parsed_json5, json_string) = reader::read_json5_from_input(&mut io::stdin())?;
let out = run_jq5(&args.filter, parsed_json5, json_string, &args.jq_path).await?;
io::stdout().write_all(out.as_bytes())?;
} else {
let outs = run(args.filter, args.files, &args.jq_path).await?;
for out in outs {
io::stdout().write_all(out.as_bytes())?;
}
}
Ok(())
}
#[derive(Debug, StructOpt)]
#[structopt(
name = "jq5",
about = "An extension of jq to work on json5 objects. \nThis tool is a work in progress: use with caution."
)]
struct Opt {
// TODO(72435) Add relevant options from jq
filter: String,
#[structopt(parse(from_os_str))]
files: Vec<PathBuf>,
#[structopt(long = "--path-to-jq", parse(from_os_str))]
jq_path: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs::OpenOptions;
const JQ_PATH_STR: &str = env!("JQ_PATH");
// Tests that run_jq successfully invokes jq using the identity filter and
// an empty JSON object.
#[fuchsia_async::run_singlethreaded(test)]
async fn run_jq_id_filter_1() {
let filter = String::from(".");
let input = String::from("{}");
let jq_path = Some(PathBuf::from(JQ_PATH_STR));
assert_eq!(run_jq(&filter, input, &jq_path).await.unwrap(), "{}\n");
}
// Tests that run_jq successfully invokes jq using the identity filter and a
// simple JSON object.
#[fuchsia_async::run_singlethreaded(test)]
async fn run_jq_id_filter_2() {
let filter = String::from(".");
let input = String::from(r#"{"foo": 1, "bar": 2}"#);
let jq_path = Some(PathBuf::from(JQ_PATH_STR));
assert_eq!(
run_jq(&filter, input, &jq_path).await.unwrap(),
r##"{
"foo": 1,
"bar": 2
}
"##
);
}
// Tests a simple filter and simple object.
#[fuchsia_async::run_singlethreaded(test)]
async fn run_jq_deconstruct_filter() {
let filter = String::from("{foo2: .foo1, bar2: .bar1}");
let input = String::from(r#"{"foo1": 0, "bar1": 42}"#);
let jq_path = Some(PathBuf::from(JQ_PATH_STR));
assert_eq!(
run_jq(&filter, input, &jq_path).await.unwrap(),
r##"{
"foo2": 0,
"bar2": 42
}
"##
);
}
#[fuchsia_async::run_singlethreaded(test)]
async fn run_jq5_deconstruct_filter() {
let filter = String::from("{foo: .foo, baz: .bar}");
let json5_string = String::from(
r##"{
//Foo
foo: 0,
//Bar
bar: 42
}"##,
);
let format = Json5Format::new().unwrap();
let (parsed_json5, json_string) = reader::read_json5(json5_string).unwrap();
let jq_path = Some(PathBuf::from(JQ_PATH_STR));
assert_eq!(
run_jq5(&filter, parsed_json5, json_string, &jq_path).await.unwrap(),
format
.to_string(
&ParsedDocument::from_str(
r##"{
//Foo
foo: 0,
baz: 42
}"##,
None
)
.unwrap()
)
.unwrap()
);
}
#[fuchsia_async::run_singlethreaded(test)]
async fn run_jq5_on_file_w_id_filter() {
let tmp_path = PathBuf::from(r"/tmp/read_from_file_2.json5");
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(tmp_path.as_path())
.unwrap();
let json5_string = String::from(
r##"{
"name": {
"last": "Smith",
"first": "John",
"middle": "Jacob"
},
"children": [
"Buffy",
"Biff",
"Balto"
],
// Consider adding a note field to the `other` contact option
"contact_options": [
{
"home": {
"email": "jj@notreallygmail.com", // This was the original user id.
// Now user id's are hash values.
"phone": "212-555-4321"
},
"other": {
"email": "volunteering@serviceprojectsrus.org"
},
"work": {
"phone": "212-555-1234",
"email": "john.j.smith@worksforme.gov"
}
}
],
"address": {
"city": "Anytown",
"country": "USA",
"state": "New York",
"street": "101 Main Street"
/* Update schema to support multiple addresses:
"work": {
"city": "Anytown",
"country": "USA",
"state": "New York",
"street": "101 Main Street"
}
*/
}
}
"##,
);
file.write_all(json5_string.as_bytes()).unwrap();
let (parsed_json5, json_string) = reader::read_json5_fromfile(&tmp_path).unwrap();
let jq_path = Some(PathBuf::from(JQ_PATH_STR));
assert_eq!(
run_jq5(&".".to_string(), parsed_json5, json_string, &jq_path).await.unwrap(),
run_jq5_on_file(&".".to_string(), &tmp_path, &jq_path).await.unwrap()
)
}
}