diff --git a/Cargo.lock b/Cargo.lock index f94a72a..486ec4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -168,7 +168,7 @@ dependencies = [ [[package]] name = "prefix" -version = "1.1.5" +version = "1.2.0" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 30fd4e7..153f0aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "prefix" -version = "1.1.5" +version = "1.2.0" authors = ["Shivix"] edition = "2021" description = "A customizable pretty printer for FIX messages" diff --git a/build.rs b/build.rs index 24c5dcb..2012283 100644 --- a/build.rs +++ b/build.rs @@ -12,15 +12,12 @@ fn main() -> Result<(), Error> { for shell in [Bash, Fish, PowerShell, Zsh] { let completion_path = clap_complete::generate_to(shell, &mut cmd, "prefix", &out_dir)?; - println!( - "cargo:warning=completion file is generated: {:?}", - completion_path - ); + println!("cargo:warning=completion file is generated: {completion_path:?}"); } let man = clap_mangen::Man::new(cmd); let man_path = Man::generate_to(&man, out_dir)?; - println!("cargo:warning=man file is generated: {:?}", man_path); + println!("cargo:warning=man file is generated: {man_path:?}"); Ok(()) } diff --git a/completion/_prefix b/completion/_prefix index 7ba74bc..0a7da05 100644 --- a/completion/_prefix +++ b/completion/_prefix @@ -23,10 +23,11 @@ _prefix() { '--summary=[Summarise each fix message based on an optional template]' \ '-o[Only print FIX messages]' \ '--only-fix[Only print FIX messages]' \ -'-f[Only consider full FIX messages containing both BeginString and Checksum]' \ -'--strict[Only consider full FIX messages containing both BeginString and Checksum]' \ +'--porcelain[print FIX messages closer to standard format, same as --delimiter \\x01 --strip]' \ '-r[Combine any repeating groups into a single field with a comma delimited value]' \ '--repeating[Combine any repeating groups into a single field with a comma delimited value]' \ +'-f[Only consider full FIX messages containing both BeginString and Checksum]' \ +'--strict[Only consider full FIX messages containing both BeginString and Checksum]' \ '-s[Strip the whitespace around the = in each field]' \ '--strip[Strip the whitespace around the = in each field]' \ '-t[Translate tag numbers on non FIX message lines]' \ diff --git a/completion/_prefix.ps1 b/completion/_prefix.ps1 index 136b051..e32a507 100644 --- a/completion/_prefix.ps1 +++ b/completion/_prefix.ps1 @@ -29,10 +29,11 @@ Register-ArgumentCompleter -Native -CommandName 'prefix' -ScriptBlock { [CompletionResult]::new('--summary', 'summary', [CompletionResultType]::ParameterName, 'Summarise each fix message based on an optional template') [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Only print FIX messages') [CompletionResult]::new('--only-fix', 'only-fix', [CompletionResultType]::ParameterName, 'Only print FIX messages') - [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Only consider full FIX messages containing both BeginString and Checksum') - [CompletionResult]::new('--strict', 'strict', [CompletionResultType]::ParameterName, 'Only consider full FIX messages containing both BeginString and Checksum') + [CompletionResult]::new('--porcelain', 'porcelain', [CompletionResultType]::ParameterName, 'print FIX messages closer to standard format, same as --delimiter \x01 --strip') [CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Combine any repeating groups into a single field with a comma delimited value') [CompletionResult]::new('--repeating', 'repeating', [CompletionResultType]::ParameterName, 'Combine any repeating groups into a single field with a comma delimited value') + [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Only consider full FIX messages containing both BeginString and Checksum') + [CompletionResult]::new('--strict', 'strict', [CompletionResultType]::ParameterName, 'Only consider full FIX messages containing both BeginString and Checksum') [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Strip the whitespace around the = in each field') [CompletionResult]::new('--strip', 'strip', [CompletionResultType]::ParameterName, 'Strip the whitespace around the = in each field') [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Translate tag numbers on non FIX message lines') diff --git a/completion/prefix.bash b/completion/prefix.bash index 381e811..a5ec6e9 100644 --- a/completion/prefix.bash +++ b/completion/prefix.bash @@ -19,7 +19,7 @@ _prefix() { case "${cmd}" in prefix) - opts="-c -d -o -f -r -s -S -t -v -h -V --color --delimiter --only-fix --strict --repeating --strip --summary --tag --value --help --version [message]..." + opts="-c -d -o -r -f -s -S -t -v -h -V --color --delimiter --only-fix --porcelain --repeating --strict --strip --summary --tag --value --help --version [message]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/completion/prefix.fish b/completion/prefix.fish index 65c7b2d..975c7de 100644 --- a/completion/prefix.fish +++ b/completion/prefix.fish @@ -2,8 +2,9 @@ complete -c prefix -s c -l color -d 'Adds colour to the delimiter and = in for F complete -c prefix -s d -l delimiter -d 'Set delimiter string to print after each FIX field' -r complete -c prefix -s S -l summary -d 'Summarise each fix message based on an optional template' -r complete -c prefix -s o -l only-fix -d 'Only print FIX messages' -complete -c prefix -s f -l strict -d 'Only consider full FIX messages containing both BeginString and Checksum' +complete -c prefix -l porcelain -d 'print FIX messages closer to standard format, same as --delimiter \\x01 --strip' complete -c prefix -s r -l repeating -d 'Combine any repeating groups into a single field with a comma delimited value' +complete -c prefix -s f -l strict -d 'Only consider full FIX messages containing both BeginString and Checksum' complete -c prefix -s s -l strip -d 'Strip the whitespace around the = in each field' complete -c prefix -s t -l tag -d 'Translate tag numbers on non FIX message lines' complete -c prefix -s v -l value -d 'Translate the values of some tags (for Side: 1 -> Buy)' diff --git a/man/prefix.1 b/man/prefix.1 index 81ef1ed..d13626c 100644 --- a/man/prefix.1 +++ b/man/prefix.1 @@ -1,10 +1,10 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH prefix 1 "prefix 1.1.5" +.TH prefix 1 "prefix 1.2.0" .SH NAME prefix \- A customizable pretty printer for FIX messages .SH SYNOPSIS -\fBprefix\fR [\fB\-c\fR|\fB\-\-color\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-o\fR|\fB\-\-only\-fix\fR] [\fB\-f\fR|\fB\-\-strict\fR] [\fB\-r\fR|\fB\-\-repeating\fR] [\fB\-s\fR|\fB\-\-strip\fR] [\fB\-S\fR|\fB\-\-summary\fR] [\fB\-t\fR|\fB\-\-tag\fR] [\fB\-v\fR|\fB\-\-value\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fImessage\fR] +\fBprefix\fR [\fB\-c\fR|\fB\-\-color\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-o\fR|\fB\-\-only\-fix\fR] [\fB\-\-porcelain\fR] [\fB\-r\fR|\fB\-\-repeating\fR] [\fB\-f\fR|\fB\-\-strict\fR] [\fB\-s\fR|\fB\-\-strip\fR] [\fB\-S\fR|\fB\-\-summary\fR] [\fB\-t\fR|\fB\-\-tag\fR] [\fB\-v\fR|\fB\-\-value\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fImessage\fR] .SH DESCRIPTION A customizable pretty printer for FIX messages .SH OPTIONS @@ -22,12 +22,15 @@ Set delimiter string to print after each FIX field \fB\-o\fR, \fB\-\-only\-fix\fR Only print FIX messages .TP -\fB\-f\fR, \fB\-\-strict\fR -Only consider full FIX messages containing both BeginString and Checksum +\fB\-\-porcelain\fR +print FIX messages closer to standard format, same as \-\-delimiter \\x01 \-\-strip .TP \fB\-r\fR, \fB\-\-repeating\fR Combine any repeating groups into a single field with a comma delimited value .TP +\fB\-f\fR, \fB\-\-strict\fR +Only consider full FIX messages containing both BeginString and Checksum +.TP \fB\-s\fR, \fB\-\-strip\fR Strip the whitespace around the = in each field .TP @@ -49,4 +52,4 @@ Print version [\fImessage\fR] FIX message to be parsed, if not provided will look for a message piped through stdin .SH VERSION -v1.1.5 +v1.2.0 diff --git a/src/command.rs b/src/command.rs index ea474cc..cf61dfa 100644 --- a/src/command.rs +++ b/src/command.rs @@ -22,13 +22,17 @@ pub fn make_command() -> Command { .action(ArgAction::SetTrue) ) .arg( - arg!(-f --strict "Only consider full FIX messages containing both BeginString and Checksum") + arg!(--porcelain "print FIX messages closer to standard format, same as --delimiter \\x01 --strip") .action(ArgAction::SetTrue) ) .arg( arg!(-r --repeating "Combine any repeating groups into a single field with a comma delimited value") .action(ArgAction::SetTrue) ) + .arg( + arg!(-f --strict "Only consider full FIX messages containing both BeginString and Checksum") + .action(ArgAction::SetTrue) + ) .arg( arg!(-s --strip "Strip the whitespace around the = in each field") .action(ArgAction::SetTrue) diff --git a/src/main.rs b/src/main.rs index bfb345d..3b78231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,39 @@ use std::io; fn main() { let matches = command::make_command().get_matches(); + let flags = prefix::matches_to_flags(&matches); - let fix_message: Vec = match matches.get_many::("message") { - Some(msg) => msg.map(|elem| elem.to_owned()).collect(), - None => io::stdin().lines().map(|line| line.unwrap()).collect(), - }; + // Avoid compiling regexes multiple times. + let msg_regex = prefix::get_msg_regex(); + let tag_regex = prefix::get_tag_regex(); + let summary_regexes = prefix::get_summary_regexes(&flags); - let flags = prefix::matches_to_flags(&matches); - prefix::run(&fix_message, &flags); + if let Some(msgs) = matches.get_many::("message") { + let n_msgs = msgs.len(); + for (i, msg) in msgs.enumerate() { + let is_last_line = i + 1 == n_msgs; + prefix::run( + msg, + is_last_line, + &msg_regex, + &tag_regex, + &summary_regexes, + &flags, + ); + } + } else { + let mut lines = io::stdin().lines().peekable(); + while let Some(line) = lines.next() { + let line = line.unwrap(); + let is_last_line = lines.peek().is_none(); + prefix::run( + &line, + is_last_line, + &msg_regex, + &tag_regex, + &summary_regexes, + &flags, + ); + } + } } diff --git a/src/prefix/mod.rs b/src/prefix/mod.rs index 0238943..1bc8de8 100644 --- a/src/prefix/mod.rs +++ b/src/prefix/mod.rs @@ -2,7 +2,11 @@ mod tags; use clap::ArgMatches; use regex::Regex; -use std::io::{self, IsTerminal, Write}; +use std::{ + collections::HashMap, + io::{self, IsTerminal, Write}, + process, +}; #[derive(Debug, PartialEq, Clone)] struct Field { @@ -13,13 +17,13 @@ struct Field { pub struct Options { delimiter: String, colour: bool, + only_fix: bool, repeating: bool, strict: bool, strip: bool, summary: Option, tag: bool, value: bool, - only_fix: bool, } #[derive(Debug, PartialEq)] @@ -32,89 +36,127 @@ enum FixMsg { pub fn matches_to_flags(matches: &ArgMatches) -> Options { let when = matches.get_one::("color").unwrap(); let use_colour = (io::stdout().is_terminal() && when == "auto") || when == "always"; + let delimiter; + let strip; + if matches.get_flag("porcelain") { + delimiter = String::from("\x01"); + strip = true; + } else { + delimiter = matches.get_one::("delimiter").unwrap().to_string(); + strip = matches.get_flag("strip"); + } Options { - delimiter: matches.get_one::("delimiter").unwrap().to_string(), + delimiter, colour: use_colour, + only_fix: matches.get_flag("only-fix"), repeating: matches.get_flag("repeating"), strict: matches.get_flag("strict"), - strip: matches.get_flag("strip"), + strip, summary: matches.get_one::("summary").cloned(), tag: matches.get_flag("tag"), value: matches.get_flag("value"), - only_fix: matches.get_flag("only-fix"), } } -fn get_msg_regex() -> Regex { +pub fn get_msg_regex() -> Regex { + // This regex will only match valid fields and any malformed fields will be ignored. + // This means its very unlikely for prefix to fail to parse a FIX message. Regex::new(r"(?P[0-9]+)=(?P[^\^\|\x01]+)").unwrap() } -fn get_tag_regex() -> Regex { +pub fn get_tag_regex() -> Regex { Regex::new(r"[0-9]+").unwrap() } -pub fn run(input: &[String], flags: &Options) { - let fix_msg_regex = get_msg_regex(); - let fix_tag_regex = get_tag_regex(); +pub fn get_summary_regexes(flags: &Options) -> HashMap { + let mut summary_regexes = HashMap::::new(); + if flags.summary.is_some() { + let template = flags.summary.as_ref().unwrap(); + let re = Regex::new(r"\d+").unwrap(); + for number in re.find_iter(template) { + let number = number.as_str(); + summary_regexes.insert( + number.to_string(), + Regex::new(&format!(r"\b{number}\b")).unwrap(), + ); + } + } + summary_regexes +} + +pub fn run( + input: &str, + last_line: bool, + msg_regex: &Regex, + tag_regex: &Regex, + summary_regexes: &HashMap, + flags: &Options, +) { let mut stdout = io::stdout(); - for (i, line) in input.iter().enumerate() { - match parse_fix_msg(line, &fix_msg_regex) { - FixMsg::Full(parsed) => { - print_fix_msg(&mut stdout, i, input.len(), &parsed, flags); - } - FixMsg::Partial(parsed) => { - if !flags.strict { - print_fix_msg(&mut stdout, i, input.len(), &parsed, flags); - } else if !flags.only_fix { - print_non_fix_msg(&mut stdout, line, &fix_tag_regex, flags); - } + match parse_fix_msg(input, msg_regex) { + FixMsg::Full(parsed) => { + print_fix_msg(&mut stdout, last_line, &parsed, summary_regexes, flags); + } + FixMsg::Partial(parsed) => { + if !flags.strict { + print_fix_msg(&mut stdout, last_line, &parsed, summary_regexes, flags); + } else if !flags.only_fix { + print_non_fix_msg(&mut stdout, input, tag_regex, flags); } - FixMsg::None => { - if !flags.only_fix { - print_non_fix_msg(&mut stdout, line, &fix_tag_regex, flags); - } + } + FixMsg::None => { + if !flags.only_fix { + print_non_fix_msg(&mut stdout, input, tag_regex, flags); } } } } -fn ignore_broken_pipe(result: io::Result<()>) { - if let Err(e) = result { - // When piping into certain programs like head, printing to stdout can fail. - if e.kind() != io::ErrorKind::BrokenPipe { - panic!("Error writing to stdout: {}", e); +fn handle_broken_pipe(result: io::Result<()>) { + if let Err(error) = result { + // When piping into certain programs like head, printing to stdout can fail. This is + // expected and we do not want to panic, instead we terminate cleanly. Prefix is not + // designed to be used for anything besides printing. And this keeps the behaviour closer + // to other unix tools that will terminate upon receiving the SIGPIPE (which rust programs ignore by default) + if error.kind() == io::ErrorKind::BrokenPipe { + process::exit(0); } + panic!("Error writing to stdout: {error}"); } } -fn print_non_fix_msg(stdout: &mut io::Stdout, line: &str, fix_tag_regex: &Regex, flags: &Options) { +fn print_non_fix_msg(stdout: &mut io::Stdout, line: &str, tag_regex: &Regex, flags: &Options) { let result = if flags.tag { - writeln!(stdout, "{}", parse_tags(line, fix_tag_regex)) + writeln!(stdout, "{}", parse_tags(line, tag_regex)) } else { - writeln!(stdout, "{}", line) + writeln!(stdout, "{line}") }; - ignore_broken_pipe(result); + handle_broken_pipe(result); } fn print_fix_msg( stdout: &mut io::Stdout, - line_number: usize, - last_line: usize, + last_line: bool, fix_msg: &[Field], + regex_by_tag: &HashMap, flags: &Options, ) { let result = if flags.summary.is_some() { - writeln!(stdout, "{}", format_to_summary(fix_msg, flags)) + writeln!( + stdout, + "{}", + format_to_summary(fix_msg, regex_by_tag, flags) + ) } else { // Avoid adding an empty new line at the bottom of the output. - if line_number + 1 == last_line && flags.delimiter == "\n" { + if last_line && flags.delimiter == "\n" { write!(stdout, "{}", format_to_string(fix_msg, flags)) } else { writeln!(stdout, "{}", format_to_string(fix_msg, flags)) } }; - ignore_broken_pipe(result); + handle_broken_pipe(result); } fn parse_fix_msg(input: &str, regex: &Regex) -> FixMsg { @@ -161,7 +203,7 @@ fn parse_tags(input: &str, regex: &Regex) -> String { fn add_colour(input: &str, use_colour: bool) -> String { if use_colour { // TODO: Allow configuring colour using ENV variable - format!("\x1b[33m{}\x1b[0m", input) + format!("\x1b[33m{input}\x1b[0m") } else { input.to_string() } @@ -195,7 +237,11 @@ fn format_to_string(input: &[Field], flags: &Options) -> String { }) } -fn format_to_summary(input: &[Field], flags: &Options) -> String { +fn format_to_summary( + input: &[Field], + regex_by_tag: &HashMap, + flags: &Options, +) -> String { let template = flags.summary.as_ref().unwrap(); let mut result = String::from(template); for field in input { @@ -209,8 +255,14 @@ fn format_to_summary(input: &[Field], flags: &Options) -> String { &field.value }; if !template.is_empty() { - // Replace tag numbers in template to tag name. - result = result.replace(&field.tag.to_string(), value); + let tag = field.tag.to_string(); + if template.contains(&tag) { + // Use a regex with line boundaries to ensure we don't overwrite partial numbers. + // Replace tag numbers in template to tag name. + result = regex_by_tag[tag.as_str()] + .replace_all(&result, value) + .to_string(); + } } if field.tag == 35 { let msg_type = tags::MSG_TYPES @@ -218,7 +270,7 @@ fn format_to_summary(input: &[Field], flags: &Options) -> String { .find(|(msg_type, _)| *msg_type == field.value) .expect("Invalid msg type") .1; - result = format!("{} {}", msg_type, result); + result = format!("{msg_type} {result}"); } } result @@ -310,13 +362,13 @@ mod tests { let flags = Options { delimiter: String::from("\n"), colour: false, + only_fix: false, repeating: false, strict: false, strip: false, summary: None, tag: false, value: false, - only_fix: false, }; let result = format_to_string(&parsed, &flags); let expected = String::from( @@ -334,13 +386,13 @@ mod tests { let flags = Options { delimiter: String::from("|"), colour: true, + only_fix: true, repeating: true, strict: true, strip: true, summary: None, tag: true, value: true, - only_fix: true, }; let result = format_to_string(&parsed, &flags); let expected = String::from( @@ -362,15 +414,18 @@ mod tests { let flags = Options { delimiter: String::from("\n"), colour: false, + only_fix: false, repeating: false, strict: false, strip: false, summary: Some(String::from("for 55")), tag: false, value: false, - only_fix: false, }; - let result = format_to_summary(&input, &flags); + + let regex_by_tag = + HashMap::::from([(String::from("55"), Regex::new(r"\b55\b").unwrap())]); + let result = format_to_summary(&input, ®ex_by_tag, &flags); let expected = String::from("NewOrderSingle for EUR/USD"); assert_eq!(result, expected); } diff --git a/test/brokenpipe.sh b/test/brokenpipe.sh new file mode 100644 index 0000000..9bf7b88 --- /dev/null +++ b/test/brokenpipe.sh @@ -0,0 +1,6 @@ +while true +do + echo "8=FIX|35=D|55=EURUSD|" + sleep 1 +done +