| // Copyright 2020 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. |
| |
| //! A command line interface (CLI) tool to format [JSON5](https://json5.org) ("JSON for |
| //! Humans") documents to a consistent style, preserving comments. |
| //! |
| //! See [json5format](../json5format/index.html) for more details. |
| //! |
| //! # Usage |
| //! |
| //! formatjson5 [FLAGS] [OPTIONS] [files]... |
| //! |
| //! FLAGS: |
| //! -h, --help Prints help information |
| //! -n, --no_trailing_commas Suppress trailing commas (otherwise added by default) |
| //! -o, --one_element_lines Objects or arrays with a single child should collapse to a |
| //! single line; no trailing comma |
| //! -r, --replace Replace (overwrite) the input file with the formatted result |
| //! -s, --sort_arrays Sort arrays of primitive values (string, number, boolean, or |
| //! null) lexicographically |
| //! -V, --version Prints version information |
| //! |
| //! OPTIONS: |
| //! -i, --indent <indent> Indent by the given number of spaces [default: 4] |
| //! |
| //! ARGS: |
| //! <files>... Files to format (use "-" for stdin) |
| |
| #![warn(missing_docs)] |
| |
| use anyhow; |
| use anyhow::Result; |
| use json5format::*; |
| use std::fs; |
| use std::io; |
| use std::io::{Read, Write}; |
| use std::path::PathBuf; |
| use structopt::StructOpt; |
| |
| /// Parses each file in the given `files` vector and returns a parsed object for each JSON5 |
| /// document. If the parser encounters an error in any input file, the command aborts without |
| /// formatting any of the documents. |
| fn parse_documents(files: Vec<PathBuf>) -> Result<Vec<ParsedDocument>, anyhow::Error> { |
| let mut parsed_documents = Vec::with_capacity(files.len()); |
| for file in files { |
| let filename = file.clone().into_os_string().to_string_lossy().to_string(); |
| let mut buffer = String::new(); |
| if filename == "-" { |
| Opt::from_stdin(&mut buffer)?; |
| } else { |
| fs::File::open(&file)?.read_to_string(&mut buffer)?; |
| } |
| |
| parsed_documents.push(ParsedDocument::from_string(buffer, Some(filename))?); |
| } |
| Ok(parsed_documents) |
| } |
| |
| /// Formats the given parsed documents, applying the given format `options`. If `replace` is true, |
| /// each input file is overwritten by its formatted version. |
| fn format_documents( |
| parsed_documents: Vec<ParsedDocument>, |
| options: FormatOptions, |
| replace: bool, |
| ) -> Result<(), anyhow::Error> { |
| let format = Json5Format::with_options(options)?; |
| for (index, parsed_document) in parsed_documents.iter().enumerate() { |
| let filename = parsed_document.filename().as_ref().unwrap(); |
| let bytes = format.to_utf8(&parsed_document)?; |
| if replace { |
| Opt::write_to_file(filename, &bytes)?; |
| } else { |
| if index > 0 { |
| println!(); |
| } |
| if parsed_documents.len() > 1 { |
| println!("{}:", filename); |
| println!("{}", "=".repeat(filename.len())); |
| } |
| print!("{}", std::str::from_utf8(&bytes)?); |
| } |
| } |
| Ok(()) |
| } |
| |
| /// The entry point for the [formatjson5](index.html) command line interface. |
| fn main() -> Result<()> { |
| let args = Opt::args(); |
| |
| if args.files.len() == 0 { |
| return Err(anyhow::anyhow!("No files to format")); |
| } |
| |
| let parsed_documents = parse_documents(args.files)?; |
| |
| let options = FormatOptions { |
| indent_by: args.indent, |
| trailing_commas: !args.no_trailing_commas, |
| collapse_containers_of_one: args.one_element_lines, |
| sort_array_items: args.sort_arrays, |
| ..Default::default() |
| }; |
| |
| format_documents(parsed_documents, options, args.replace) |
| } |
| |
| /// Command line options defined via the structopt! macrorule. These definitions generate the |
| /// option parsing, validation, and [usage documentation](index.html). |
| #[derive(Debug, StructOpt)] |
| #[structopt( |
| name = "json5format", |
| about = "Format JSON5 documents to a consistent style, preserving comments." |
| )] |
| struct Opt { |
| /// Files to format (use "-" for stdin) |
| #[structopt(parse(from_os_str))] |
| files: Vec<PathBuf>, |
| |
| /// Replace (overwrite) the input file with the formatted result |
| #[structopt(short, long)] |
| replace: bool, |
| |
| /// Suppress trailing commas (otherwise added by default) |
| #[structopt(short, long)] |
| no_trailing_commas: bool, |
| |
| /// Objects or arrays with a single child should collapse to a single line; no trailing comma |
| #[structopt(short, long)] |
| one_element_lines: bool, |
| |
| /// Sort arrays of primitive values (string, number, boolean, or null) lexicographically |
| #[structopt(short, long)] |
| sort_arrays: bool, |
| |
| /// Indent by the given number of spaces |
| #[structopt(short, long, default_value = "4")] |
| indent: usize, |
| } |
| |
| #[cfg(not(test))] |
| impl Opt { |
| fn args() -> Self { |
| Self::from_args() |
| } |
| |
| fn from_stdin(mut buf: &mut String) -> Result<usize, io::Error> { |
| io::stdin().read_to_string(&mut buf) |
| } |
| |
| fn write_to_file(filename: &str, bytes: &[u8]) -> Result<(), io::Error> { |
| fs::OpenOptions::new() |
| .create(true) |
| .truncate(true) |
| .write(true) |
| .open(filename)? |
| .write_all(&bytes) |
| } |
| } |
| |
| #[cfg(test)] |
| impl Opt { |
| fn args() -> Self { |
| if let Some(test_args) = unsafe { &self::tests::TEST_ARGS } { |
| Self::from_clap( |
| &Self::clap() |
| .get_matches_from_safe(test_args) |
| .expect("failed to parse TEST_ARGS command line arguments"), |
| ) |
| } else { |
| Self::from_args() |
| } |
| } |
| |
| fn from_stdin(mut buf: &mut String) -> Result<usize, io::Error> { |
| if let Some(test_buffer) = unsafe { &mut self::tests::TEST_BUFFER } { |
| *buf = test_buffer.clone(); |
| Ok(buf.as_bytes().len()) |
| } else { |
| io::stdin().read_to_string(&mut buf) |
| } |
| } |
| |
| fn write_to_file(filename: &str, bytes: &[u8]) -> Result<(), io::Error> { |
| if filename == "-" { |
| let buf = std::str::from_utf8(&bytes) |
| .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; |
| if let Some(test_buffer) = unsafe { &mut self::tests::TEST_BUFFER } { |
| *test_buffer = buf.to_string(); |
| } else { |
| print!("{}", buf); |
| } |
| Ok(()) |
| } else { |
| fs::OpenOptions::new() |
| .create(true) |
| .truncate(true) |
| .write(true) |
| .open(filename)? |
| .write_all(&bytes) |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| |
| use super::*; |
| |
| pub(crate) static mut TEST_ARGS: Option<Vec<&str>> = None; |
| pub(crate) static mut TEST_BUFFER: Option<String> = None; |
| |
| #[test] |
| fn test_main() { |
| let example_json5 = r##"{ |
| offer: [ |
| { |
| runner: "elf", |
| }, |
| { |
| from: "framework", |
| to: "#elements", |
| protocol: "/svc/fuchsia.sys2.Realm", |
| }, |
| { |
| to: "#elements", |
| protocol: [ |
| "/svc/fuchsia.logger.LogSink", |
| "/svc/fuchsia.cobalt.LoggerFactory", |
| ], |
| from: "realm", |
| }, |
| ], |
| collections: [ |
| { |
| name: "elements", |
| durability: "transient", |
| } |
| ], |
| use: [ |
| { |
| runner: "elf", |
| }, |
| { |
| protocol: "/svc/fuchsia.sys2.Realm", |
| from: "framework", |
| }, |
| { |
| from: "realm", |
| to: "#elements", |
| protocol: [ |
| "/svc/fuchsia.logger.LogSink", |
| "/svc/fuchsia.cobalt.LoggerFactory", |
| ], |
| }, |
| ], |
| children: [ |
| ], |
| program: { |
| args: [ "--zarg_first", "zoo_opt", "--arg3", "and_arg3_value" ], |
| binary: "bin/session_manager", |
| }, |
| }"##; |
| let expected = r##"{ |
| offer: [ |
| { runner: "elf" }, |
| { |
| from: "framework", |
| to: "#elements", |
| protocol: "/svc/fuchsia.sys2.Realm" |
| }, |
| { |
| to: "#elements", |
| protocol: [ |
| "/svc/fuchsia.cobalt.LoggerFactory", |
| "/svc/fuchsia.logger.LogSink" |
| ], |
| from: "realm" |
| } |
| ], |
| collections: [ |
| { |
| name: "elements", |
| durability: "transient" |
| } |
| ], |
| use: [ |
| { runner: "elf" }, |
| { |
| protocol: "/svc/fuchsia.sys2.Realm", |
| from: "framework" |
| }, |
| { |
| from: "realm", |
| to: "#elements", |
| protocol: [ |
| "/svc/fuchsia.cobalt.LoggerFactory", |
| "/svc/fuchsia.logger.LogSink" |
| ] |
| } |
| ], |
| children: [], |
| program: { |
| args: [ |
| "--arg3", |
| "--zarg_first", |
| "and_arg3_value", |
| "zoo_opt" |
| ], |
| binary: "bin/session_manager" |
| } |
| } |
| "##; |
| unsafe { |
| TEST_ARGS = Some(vec![ |
| "formatjson5", |
| "--replace", |
| "--no_trailing_commas", |
| "--one_element_lines", |
| "--sort_arrays", |
| "--indent", |
| "2", |
| "-", |
| ]); |
| TEST_BUFFER = Some(example_json5.to_string()); |
| } |
| main().expect("test failed"); |
| assert!(unsafe { &TEST_BUFFER }.is_some()); |
| assert_eq!(unsafe { TEST_BUFFER.as_ref().unwrap() }, expected); |
| } |
| |
| #[test] |
| fn test_args() { |
| let args = Opt::from_iter(vec![""].iter()); |
| assert_eq!(args.files.len(), 0); |
| assert_eq!(args.replace, false); |
| assert_eq!(args.no_trailing_commas, false); |
| assert_eq!(args.one_element_lines, false); |
| assert_eq!(args.sort_arrays, false); |
| assert_eq!(args.indent, 4); |
| |
| let some_filename = "some_file.json5"; |
| let args = Opt::from_iter( |
| vec!["formatjson5", "-r", "-n", "-o", "-s", "-i", "2", some_filename].iter(), |
| ); |
| assert_eq!(args.files.len(), 1); |
| assert_eq!(args.replace, true); |
| assert_eq!(args.no_trailing_commas, true); |
| assert_eq!(args.one_element_lines, true); |
| assert_eq!(args.sort_arrays, true); |
| assert_eq!(args.indent, 2); |
| |
| let filename = args.files[0].clone().into_os_string().to_string_lossy().to_string(); |
| assert_eq!(filename, some_filename); |
| } |
| } |