From 02d7619e73f32ad8b3e2fc880173f849ec9059af Mon Sep 17 00:00:00 2001 From: ysthakur <45539777+ysthakur@users.noreply.github.com> Date: Sat, 23 Dec 2023 16:41:21 -0500 Subject: [PATCH] Start on parse_deser module --- Cargo.lock | 1 + Cargo.toml | 1 + src/lib.rs | 8 ++- src/parse_deser/error.rs | 37 +++++++++++ src/parse_deser/kdl.rs | 49 ++++++++++++++ src/parse_deser/mod.rs | 52 ++++++++++++++- tests/gen_integration_tests.rs | 114 +++++++++++++++++++++++++++++++++ 7 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 src/parse_deser/error.rs create mode 100644 src/parse_deser/kdl.rs create mode 100644 tests/gen_integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index f6ac5da..95f13da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,7 @@ dependencies = [ "serde_kdl", "serde_yaml", "tempfile", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 10b3cfc..38700c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ strip = "debuginfo" anyhow = "1.0" clap = { version = "4.3", features = ["derive", "env"] } env_logger = "0.10" +thiserror = "1.0" log = "0.4" regex = "1.9" diff --git a/src/lib.rs b/src/lib.rs index 1d4cf53..4f8310b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ pub mod parse_man; use serde::{Deserialize, Serialize}; /// Flags parsed from a command, as well as its parsed subcommands -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Eq, Serialize, PartialEq)] pub struct CommandInfo { pub name: String, pub flags: Vec, @@ -13,10 +13,14 @@ pub struct CommandInfo { } /// A parsed flag -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Eq, Serialize, PartialEq)] pub struct Flag { /// The different short and long forms of a flag pub forms: Vec, /// Optional description for the flag pub desc: Option, } + +pub enum Error { + +} diff --git a/src/parse_deser/error.rs b/src/parse_deser/error.rs new file mode 100644 index 0000000..765873c --- /dev/null +++ b/src/parse_deser/error.rs @@ -0,0 +1,37 @@ +use std::io; + +use kdl::KdlError; +use thiserror::Error; + +use super::kdl::KdlDeserError; + +#[derive(Debug, Error)] +pub enum Error { + #[error("{file_path} has no extension")] + NoExtension { file_path: String }, + #[error("{file_path} has an unrecognizable extension")] + UnrecognizableExtension { file_path: String }, + #[error("error encountered while reading {file_path}")] + Io { + file_path: String, + #[source] + source: io::Error, + }, + #[error("error encountered while deserializing {file_path}")] + Deser { + file_path: String, + #[source] + source: DeserError, + }, +} + +/// An error encountered while deserializing +#[derive(Debug, Error)] +pub enum DeserError { + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Yaml(#[from] serde_yaml::Error), + #[error(transparent)] + Kdl(#[from] KdlDeserError), +} diff --git a/src/parse_deser/kdl.rs b/src/parse_deser/kdl.rs new file mode 100644 index 0000000..f34f5f2 --- /dev/null +++ b/src/parse_deser/kdl.rs @@ -0,0 +1,49 @@ +//! For deserializing from KDL, because the serde support is not great + +use kdl::KdlDocument; +use thiserror::Error; + +use crate::CommandInfo; + +/// An error encountered when deserializing KDL specifically +#[derive(Debug, Error)] +pub enum KdlDeserError { + #[error(transparent)] + ParseError(#[from] kdl::KdlError), +} + +type Result = std::result::Result; + +pub fn parse_from_str(text: &str) -> Result { + kdl_to_cmd_info(text.parse()?) +} + +fn kdl_to_cmd_info(doc: KdlDocument) -> Result { + todo!() +} + +#[cfg(test)] +mod tests { + use super::parse_from_str; + use crate::CommandInfo; + + #[test] + fn test1() { + assert_eq!( + CommandInfo { + name: "foo".to_string(), + flags: vec![], + subcommands: vec![] + }, + parse_from_str(r#" + foo { + flags { + - "--help" "-h" { + "Show help output" + } + } + } + "#).unwrap() + ) + } +} diff --git a/src/parse_deser/mod.rs b/src/parse_deser/mod.rs index 7e6187f..07520a5 100644 --- a/src/parse_deser/mod.rs +++ b/src/parse_deser/mod.rs @@ -1,7 +1,53 @@ //! For parsing completions from a serialization language (KDL, JSON, or YAML) -use std::path::Path; +pub mod error; +mod kdl; -pub fn parse(_file: impl AsRef) { - todo!() +use std::{fs, path::Path}; + +use self::error::DeserError; +use crate::{parse_deser::error::Error, CommandInfo}; + +pub type Result = std::result::Result; + +pub fn parse(file: impl AsRef) -> Result { + let file = file.as_ref(); + let file_path = file.to_string_lossy().to_string(); + if let Some(ext) = file.extension() { + match fs::read_to_string(file) { + Ok(text) => { + if let Some(ext) = ext.to_str() { + match parse_from_str(&text, ext) { + Ok(Some(cmd_info)) => Ok(cmd_info), + Ok(None) => Err(Error::UnrecognizableExtension { file_path }), + Err(e) => Err(Error::Deser { + file_path, + source: e, + }), + } + } else { + Err(Error::UnrecognizableExtension { file_path }) + } + } + Err(e) => Err(Error::Io { + file_path, + source: e, + }), + } + } else { + Err(Error::NoExtension { file_path }) + } +} + +pub fn parse_from_str( + text: &str, + ext: &str, +) -> std::result::Result, DeserError> { + let cmd_info = match ext { + "json" => Some(serde_json::from_str(&text)?), + "yaml" | "yml" => Some(serde_yaml::from_str(&text)?), + "kdl" => Some(kdl::parse_from_str(&text)?), + _ => None, + }; + Ok(cmd_info) } diff --git a/tests/gen_integration_tests.rs b/tests/gen_integration_tests.rs new file mode 100644 index 0000000..94a9092 --- /dev/null +++ b/tests/gen_integration_tests.rs @@ -0,0 +1,114 @@ +//! Test generating completions from JSON files + +use std::{ + env, fs, + path::PathBuf, + process::{Command, Stdio}, +}; + +use assert_cmd::prelude::{CommandCargoExt, OutputAssertExt}; + +const BIN_NAME: &str = "man-completions"; + +fn run_test(shell: &str, outputs: &[&str], args: &[&str]) { + // The project's root directory + let root = env::var("CARGO_MANIFEST_DIR").unwrap(); + + let test_resources = PathBuf::from(root).join("tests/resources/gen"); + let in_dir = test_resources.join("in"); + let expected_dir = test_resources.join("expected"); + + let out_dir = tempfile::tempdir().unwrap(); + + // The man-completions binary to test + let mut cmd = Command::cargo_bin(BIN_NAME).unwrap(); + let cmd = cmd.env("MANPATH", &in_dir).args(args).args([ + "--out", + &out_dir.path().display().to_string(), + "--shell", + shell, + ]); + // So we can explicitly ask for logging + if let Ok(log_level) = env::var("RUST_LOG") { + cmd.env("RUST_LOG", log_level).stderr(Stdio::inherit()); + } + cmd.assert().success(); + + // Files that didn't get generated + let mut not_generated = Vec::new(); + // Files that don't match the expected contents + let mut not_match = Vec::new(); + + for file_name in outputs { + let file_name = match shell { + "zsh" => format!("_{file_name}.zsh"), + "bash" => format!("_{file_name}.bash"), + "nu" => format!("{file_name}-completions.nu"), + "json" => format!("{file_name}.json"), + _ => todo!(), + }; + + let exp_file = expected_dir.join(&file_name); + let got_file = out_dir.path().join(&file_name); + if !got_file.exists() { + not_generated.push(file_name); + continue; + } + + if exp_file.exists() { + let expected = fs::read(exp_file).unwrap(); + let got = fs::read(&got_file).unwrap(); + if expected != got { + not_match.push(file_name); + continue; + } + } else { + println!("No {file_name} found in expected folder"); + not_match.push(file_name); + continue; + } + + // Delete outputted file if it succeeded, since we don't need it anymore + fs::remove_file(got_file).unwrap(); + } + + if !not_generated.is_empty() { + println!("The following files weren't generated:"); + for file_name in ¬_generated { + println!("- {file_name}"); + } + } + + if !not_match.is_empty() { + // Make a tmp folder to copy the incorrect outputs to, to view later + let failed_dir = test_resources.join("tmp"); + if !failed_dir.exists() { + fs::create_dir(&failed_dir).unwrap(); + } + + println!("The following files didn't match what was expected:"); + for file_name in ¬_match { + let exp = expected_dir.join(file_name); + let exp = exp.to_string_lossy(); + + // Copy the incorrect output out of the temp directory + let saved = failed_dir.join(file_name); + let got = fs::read(&out_dir.path().join(file_name)).unwrap(); + fs::write(&saved, got).unwrap(); + + let saved = saved.display().to_string(); + println!("Test for {file_name} failed: contents of {file_name} differed from expected"); + println!("To see the diff, run `diff {exp} {saved}`"); + println!("To overwrite the expected file, run `cp {saved} {exp}`"); + } + } + + out_dir.close().unwrap(); + + assert!(not_generated.is_empty() && not_match.is_empty()); +} + +#[test] +fn test1_zsh() { + run_test("zsh", &["test1"], &["--cmds", "^test1"]); +}