diff --git a/Cargo.toml b/Cargo.toml index 7cb63bd..a425004 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ license = "MIT" [[bin]] name = "exa" path = "src/bin/main.rs" +doc = false [lib] name = "exa" diff --git a/Vagrantfile b/Vagrantfile index 999ed95..4c26c90 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -59,8 +59,8 @@ Vagrant.configure(2) do |config| config.vm.provision :shell, privileged: true, inline: <<-EOF set -xe - echo -e "#!/bin/sh\n/home/#{developer}/target/debug/exa \\$*" > /usr/bin/exa - echo -e "#!/bin/sh\n/home/#{developer}/target/release/exa \\$*" > /usr/bin/rexa + echo -e "#!/bin/sh\n/home/#{developer}/target/debug/exa \"\\$*\"" > /usr/bin/exa + echo -e "#!/bin/sh\n/home/#{developer}/target/release/exa \"\\$*\"" > /usr/bin/rexa chmod +x /usr/bin/{exa,rexa} EOF diff --git a/src/bin/main.rs b/src/bin/main.rs index 146e114..774ece1 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,14 +1,15 @@ extern crate exa; use exa::Exa; +use std::ffi::OsString; use std::env::args_os; use std::io::{stdout, stderr, Write, ErrorKind}; use std::process::exit; fn main() { - let args = args_os().skip(1); - match Exa::new(args, &mut stdout()) { + let args: Vec = args_os().skip(1).collect(); + match Exa::new(args.iter(), &mut stdout()) { Ok(mut exa) => { match exa.run() { Ok(exit_status) => exit(exit_status), diff --git a/src/exa.rs b/src/exa.rs index b75cfb4..cfddf9d 100644 --- a/src/exa.rs +++ b/src/exa.rs @@ -3,7 +3,6 @@ extern crate ansi_term; extern crate datetime; -extern crate getopts; extern crate glob; extern crate libc; extern crate locale; @@ -23,16 +22,16 @@ extern crate term_size; extern crate lazy_static; -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::io::{stderr, Write, Result as IOResult}; use std::path::{Component, PathBuf}; use ansi_term::{ANSIStrings, Style}; use fs::{Dir, File}; -use options::{Options, View, Mode}; +use options::Options; pub use options::Misfire; -use output::{escape, lines, grid, grid_details, details}; +use output::{escape, lines, grid, grid_details, details, View, Mode}; mod fs; mod info; @@ -41,7 +40,7 @@ mod output; /// The main program wrapper. -pub struct Exa<'w, W: Write + 'w> { +pub struct Exa<'args, 'w, W: Write + 'w> { /// List of command-line options, having been successfully parsed. pub options: Options, @@ -53,12 +52,12 @@ pub struct Exa<'w, W: Write + 'w> { /// List of the free command-line arguments that should correspond to file /// names (anything that isn’t an option). - pub args: Vec, + pub args: Vec<&'args OsStr>, } -impl<'w, W: Write + 'w> Exa<'w, W> { - pub fn new(args: C, writer: &'w mut W) -> Result, Misfire> - where C: IntoIterator, C::Item: AsRef { +impl<'args, 'w, W: Write + 'w> Exa<'args, 'w, W> { + pub fn new(args: I, writer: &'w mut W) -> Result, Misfire> + where I: Iterator { Options::getopts(args).map(move |(options, args)| { Exa { options, writer, args } }) @@ -71,20 +70,20 @@ impl<'w, W: Write + 'w> Exa<'w, W> { // List the current directory by default, like ls. if self.args.is_empty() { - self.args.push(".".to_owned()); + self.args = vec![ OsStr::new(".") ]; } - for file_name in &self.args { - match File::new(PathBuf::from(file_name), None, None) { + for file_path in &self.args { + match File::new(PathBuf::from(file_path), None, None) { Err(e) => { exit_status = 2; - writeln!(stderr(), "{}: {}", file_name, e)?; + writeln!(stderr(), "{:?}: {}", file_path, e)?; }, Ok(f) => { if f.is_directory() && !self.options.dir_action.treat_dirs_as_files() { match f.to_dir(self.options.should_scan_for_git()) { Ok(d) => dirs.push(d), - Err(e) => writeln!(stderr(), "{}: {}", file_name, e)?, + Err(e) => writeln!(stderr(), "{:?}: {}", file_path, e)?, } } else { diff --git a/src/fs/dir_action.rs b/src/fs/dir_action.rs new file mode 100644 index 0000000..1fffb59 --- /dev/null +++ b/src/fs/dir_action.rs @@ -0,0 +1,64 @@ +/// What to do when encountering a directory? +#[derive(PartialEq, Debug, Copy, Clone)] +pub enum DirAction { + + /// This directory should be listed along with the regular files, instead + /// of having its contents queried. + AsFile, + + /// This directory should not be listed, and should instead be opened and + /// *its* files listed separately. This is the default behaviour. + List, + + /// This directory should be listed along with the regular files, and then + /// its contents should be listed afterward. The recursive contents of + /// *those* contents are dictated by the options argument. + Recurse(RecurseOptions), +} + +impl DirAction { + + /// Gets the recurse options, if this dir action has any. + pub fn recurse_options(&self) -> Option { + match *self { + DirAction::Recurse(opts) => Some(opts), + _ => None, + } + } + + /// Whether to treat directories as regular files or not. + pub fn treat_dirs_as_files(&self) -> bool { + match *self { + DirAction::AsFile => true, + DirAction::Recurse(RecurseOptions { tree, .. }) => tree, + _ => false, + } + } +} + + +/// The options that determine how to recurse into a directory. +#[derive(PartialEq, Debug, Copy, Clone)] +pub struct RecurseOptions { + + /// Whether recursion should be done as a tree or as multiple individual + /// views of files. + pub tree: bool, + + /// The maximum number of times that recursion should descend to, if one + /// is specified. + pub max_depth: Option, +} + +impl RecurseOptions { + + /// Returns whether a directory of the given depth would be too deep. + pub fn is_too_deep(&self, depth: usize) -> bool { + match self.max_depth { + None => false, + Some(d) => { + d <= depth + } + } + } +} \ No newline at end of file diff --git a/src/fs/filter.rs b/src/fs/filter.rs new file mode 100644 index 0000000..69f90b1 --- /dev/null +++ b/src/fs/filter.rs @@ -0,0 +1,221 @@ +use std::cmp::Ordering; +use std::os::unix::fs::MetadataExt; + +use glob; +use natord; + +use fs::File; +use fs::DotFilter; + + +/// The **file filter** processes a vector of files before outputting them, +/// filtering and sorting the files depending on the user’s command-line +/// flags. +#[derive(PartialEq, Debug, Clone)] +pub struct FileFilter { + + /// Whether directories should be listed first, and other types of file + /// second. Some users prefer it like this. + pub list_dirs_first: bool, + + /// The metadata field to sort by. + pub sort_field: SortField, + + /// Whether to reverse the sorting order. This would sort the largest + /// files first, or files starting with Z, or the most-recently-changed + /// ones, depending on the sort field. + pub reverse: bool, + + /// Which invisible “dot” files to include when listing a directory. + /// + /// Files starting with a single “.” are used to determine “system” or + /// “configuration” files that should not be displayed in a regular + /// directory listing, and the directory entries “.” and “..” are + /// considered extra-special. + /// + /// This came about more or less by a complete historical accident, + /// when the original `ls` tried to hide `.` and `..`: + /// https://plus.google.com/+RobPikeTheHuman/posts/R58WgWwN9jp + /// + /// When one typed ls, however, these files appeared, so either Ken or + /// Dennis added a simple test to the program. It was in assembler then, + /// but the code in question was equivalent to something like this: + /// if (name[0] == '.') continue; + /// This statement was a little shorter than what it should have been, + /// which is: + /// if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + /// but hey, it was easy. + /// + /// Two things resulted. + /// + /// First, a bad precedent was set. A lot of other lazy programmers + /// introduced bugs by making the same simplification. Actual files + /// beginning with periods are often skipped when they should be counted. + /// + /// Second, and much worse, the idea of a "hidden" or "dot" file was + /// created. As a consequence, more lazy programmers started dropping + /// files into everyone's home directory. I don't have all that much + /// stuff installed on the machine I'm using to type this, but my home + /// directory has about a hundred dot files and I don't even know what + /// most of them are or whether they're still needed. Every file name + /// evaluation that goes through my home directory is slowed down by + /// this accumulated sludge. + pub dot_filter: DotFilter, + + /// Glob patterns to ignore. Any file name that matches *any* of these + /// patterns won't be displayed in the list. + pub ignore_patterns: IgnorePatterns, +} + + +impl FileFilter { +/// Remove every file in the given vector that does *not* pass the + /// filter predicate for files found inside a directory. + pub fn filter_child_files(&self, files: &mut Vec) { + files.retain(|f| !self.ignore_patterns.is_ignored(f)); + } + + /// Remove every file in the given vector that does *not* pass the + /// filter predicate for file names specified on the command-line. + /// + /// The rules are different for these types of files than the other + /// type because the ignore rules can be used with globbing. For + /// example, running "exa -I='*.tmp' .vimrc" shouldn't filter out the + /// dotfile, because it's been directly specified. But running + /// "exa -I='*.ogg' music/*" should filter out the ogg files obtained + /// from the glob, even though the globbing is done by the shell! + pub fn filter_argument_files(&self, files: &mut Vec) { + files.retain(|f| !self.ignore_patterns.is_ignored(f)); + } + + /// Sort the files in the given vector based on the sort field option. + pub fn sort_files<'a, F>(&self, files: &mut Vec) + where F: AsRef> { + + files.sort_by(|a, b| self.compare_files(a.as_ref(), b.as_ref())); + + if self.reverse { + files.reverse(); + } + + if self.list_dirs_first { + // This relies on the fact that `sort_by` is stable. + files.sort_by(|a, b| b.as_ref().is_directory().cmp(&a.as_ref().is_directory())); + } + } + + /// Compares two files to determine the order they should be listed in, + /// depending on the search field. + pub fn compare_files(&self, a: &File, b: &File) -> Ordering { + use self::SortCase::{Sensitive, Insensitive}; + + match self.sort_field { + SortField::Unsorted => Ordering::Equal, + + SortField::Name(Sensitive) => natord::compare(&a.name, &b.name), + SortField::Name(Insensitive) => natord::compare_ignore_case(&a.name, &b.name), + + SortField::Size => a.metadata.len().cmp(&b.metadata.len()), + SortField::FileInode => a.metadata.ino().cmp(&b.metadata.ino()), + SortField::ModifiedDate => a.metadata.mtime().cmp(&b.metadata.mtime()), + SortField::AccessedDate => a.metadata.atime().cmp(&b.metadata.atime()), + SortField::CreatedDate => a.metadata.ctime().cmp(&b.metadata.ctime()), + + SortField::FileType => match a.type_char().cmp(&b.type_char()) { // todo: this recomputes + Ordering::Equal => natord::compare(&*a.name, &*b.name), + order => order, + }, + + SortField::Extension(Sensitive) => match a.ext.cmp(&b.ext) { + Ordering::Equal => natord::compare(&*a.name, &*b.name), + order => order, + }, + + SortField::Extension(Insensitive) => match a.ext.cmp(&b.ext) { + Ordering::Equal => natord::compare_ignore_case(&*a.name, &*b.name), + order => order, + }, + } + } +} + + +/// User-supplied field to sort by. +#[derive(PartialEq, Debug, Copy, Clone)] +pub enum SortField { + + /// Don't apply any sorting. This is usually used as an optimisation in + /// scripts, where the order doesn't matter. + Unsorted, + + /// The file name. This is the default sorting. + Name(SortCase), + + /// The file's extension, with extensionless files being listed first. + Extension(SortCase), + + /// The file's size. + Size, + + /// The file's inode. This is sometimes analogous to the order in which + /// the files were created on the hard drive. + FileInode, + + /// The time at which this file was modified (the `mtime`). + /// + /// As this is stored as a Unix timestamp, rather than a local time + /// instance, the time zone does not matter and will only be used to + /// display the timestamps, not compare them. + ModifiedDate, + + /// The time at this file was accessed (the `atime`). + /// + /// Oddly enough, this field rarely holds the *actual* accessed time. + /// Recording a read time means writing to the file each time it’s read + /// slows the whole operation down, so many systems will only update the + /// timestamp in certain circumstances. This has become common enough that + /// it’s now expected behaviour for the `atime` field. + /// http://unix.stackexchange.com/a/8842 + AccessedDate, + + /// The time at which this file was changed or created (the `ctime`). + /// + /// Contrary to the name, this field is used to mark the time when a + /// file's metadata changed -- its permissions, owners, or link count. + /// + /// In original Unix, this was, however, meant as creation time. + /// https://www.bell-labs.com/usr/dmr/www/cacm.html + CreatedDate, + + /// The type of the file: directories, links, pipes, regular, files, etc. + /// + /// Files are ordered according to the `PartialOrd` implementation of + /// `fs::fields::Type`, so changing that will change this. + FileType, +} + +/// Whether a field should be sorted case-sensitively or case-insensitively. +/// +/// This determines which of the `natord` functions to use. +#[derive(PartialEq, Debug, Copy, Clone)] +pub enum SortCase { + + /// Sort files case-sensitively with uppercase first, with ‘A’ coming + /// before ‘a’. + Sensitive, + + /// Sort files case-insensitively, with ‘A’ being equal to ‘a’. + Insensitive, +} + + +#[derive(PartialEq, Default, Debug, Clone)] +pub struct IgnorePatterns { + pub patterns: Vec, +} + +impl IgnorePatterns { + fn is_ignored(&self, file: &File) -> bool { + self.patterns.iter().any(|p| p.matches(&file.name)) + } +} diff --git a/src/fs/mod.rs b/src/fs/mod.rs index 53ce5b0..3275ccf 100644 --- a/src/fs/mod.rs +++ b/src/fs/mod.rs @@ -6,3 +6,5 @@ pub use self::file::{File, FileTarget}; pub mod feature; pub mod fields; +pub mod filter; +pub mod dir_action; diff --git a/src/options/dir_action.rs b/src/options/dir_action.rs index 6c2aae2..a15f439 100644 --- a/src/options/dir_action.rs +++ b/src/options/dir_action.rs @@ -1,40 +1,28 @@ -use getopts; +use options::parser::MatchedFlags; +use options::{flags, Misfire}; -use options::misfire::Misfire; +use fs::dir_action::{DirAction, RecurseOptions}; -/// What to do when encountering a directory? -#[derive(PartialEq, Debug, Copy, Clone)] -pub enum DirAction { - - /// This directory should be listed along with the regular files, instead - /// of having its contents queried. - AsFile, - - /// This directory should not be listed, and should instead be opened and - /// *its* files listed separately. This is the default behaviour. - List, - - /// This directory should be listed along with the regular files, and then - /// its contents should be listed afterward. The recursive contents of - /// *those* contents are dictated by the options argument. - Recurse(RecurseOptions), -} - impl DirAction { /// Determine which action to perform when trying to list a directory. - pub fn deduce(matches: &getopts::Matches) -> Result { - let recurse = matches.opt_present("recurse"); - let list = matches.opt_present("list-dirs"); - let tree = matches.opt_present("tree"); + pub fn deduce(matches: &MatchedFlags) -> Result { + let recurse = matches.has(&flags::RECURSE); + let list = matches.has(&flags::LIST_DIRS); + let tree = matches.has(&flags::TREE); + + // Early check for --level when it wouldn’t do anything + if !recurse && !tree && matches.get(&flags::LEVEL).is_some() { + return Err(Misfire::Useless2(&flags::LEVEL, &flags::RECURSE, &flags::TREE)); + } match (recurse, list, tree) { // You can't --list-dirs along with --recurse or --tree because // they already automatically list directories. - (true, true, _ ) => Err(Misfire::Conflict("recurse", "list-dirs")), - (_, true, true ) => Err(Misfire::Conflict("tree", "list-dirs")), + (true, true, _ ) => Err(Misfire::Conflict(&flags::RECURSE, &flags::LIST_DIRS)), + (_, true, true ) => Err(Misfire::Conflict(&flags::TREE, &flags::LIST_DIRS)), (_ , _, true ) => Ok(DirAction::Recurse(RecurseOptions::deduce(matches, true)?)), (true, false, false) => Ok(DirAction::Recurse(RecurseOptions::deduce(matches, false)?)), @@ -42,45 +30,15 @@ impl DirAction { (false, false, _ ) => Ok(DirAction::List), } } - - /// Gets the recurse options, if this dir action has any. - pub fn recurse_options(&self) -> Option { - match *self { - DirAction::Recurse(opts) => Some(opts), - _ => None, - } - } - - /// Whether to treat directories as regular files or not. - pub fn treat_dirs_as_files(&self) -> bool { - match *self { - DirAction::AsFile => true, - DirAction::Recurse(RecurseOptions { tree, .. }) => tree, - _ => false, - } - } } -/// The options that determine how to recurse into a directory. -#[derive(PartialEq, Debug, Copy, Clone)] -pub struct RecurseOptions { - - /// Whether recursion should be done as a tree or as multiple individual - /// views of files. - pub tree: bool, - - /// The maximum number of times that recursion should descend to, if one - /// is specified. - pub max_depth: Option, -} - impl RecurseOptions { /// Determine which files should be recursed into. - pub fn deduce(matches: &getopts::Matches, tree: bool) -> Result { - let max_depth = if let Some(level) = matches.opt_str("level") { - match level.parse() { + pub fn deduce(matches: &MatchedFlags, tree: bool) -> Result { + let max_depth = if let Some(level) = matches.get(&flags::LEVEL) { + match level.to_string_lossy().parse() { Ok(l) => Some(l), Err(e) => return Err(Misfire::FailedParse(e)), } @@ -91,14 +49,57 @@ impl RecurseOptions { Ok(RecurseOptions { tree, max_depth }) } +} - /// Returns whether a directory of the given depth would be too deep. - pub fn is_too_deep(&self, depth: usize) -> bool { - match self.max_depth { - None => false, - Some(d) => { - d <= depth - } - } + +#[cfg(test)] +mod test { + use super::*; + use std::ffi::OsString; + use options::flags; + + pub fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os } -} \ No newline at end of file + + macro_rules! test { + ($name:ident: $type:ident <- $inputs:expr => $result:expr) => { + #[test] + fn $name() { + use options::parser::{Args, Arg}; + use std::ffi::OsString; + + static TEST_ARGS: &[&Arg] = &[ &flags::RECURSE, &flags::LIST_DIRS, &flags::TREE, &flags::LEVEL ]; + + let bits = $inputs.as_ref().into_iter().map(|&o| os(o)).collect::>(); + let results = Args(TEST_ARGS).parse(bits.iter()); + assert_eq!($type::deduce(&results.unwrap().flags), $result); + } + }; + } + + + // Default behaviour + test!(empty: DirAction <- [] => Ok(DirAction::List)); + + // Listing files as directories + test!(dirs_short: DirAction <- ["-d"] => Ok(DirAction::AsFile)); + test!(dirs_long: DirAction <- ["--list-dirs"] => Ok(DirAction::AsFile)); + + // Recursing + test!(rec_short: DirAction <- ["-R"] => Ok(DirAction::Recurse(RecurseOptions { tree: false, max_depth: None }))); + test!(rec_long: DirAction <- ["--recurse"] => Ok(DirAction::Recurse(RecurseOptions { tree: false, max_depth: None }))); + test!(rec_lim_short: DirAction <- ["-RL4"] => Ok(DirAction::Recurse(RecurseOptions { tree: false, max_depth: Some(4) }))); + test!(rec_lim_short_2: DirAction <- ["-RL=5"] => Ok(DirAction::Recurse(RecurseOptions { tree: false, max_depth: Some(5) }))); + test!(rec_lim_long: DirAction <- ["--recurse", "--level", "666"] => Ok(DirAction::Recurse(RecurseOptions { tree: false, max_depth: Some(666) }))); + test!(rec_lim_long_2: DirAction <- ["--recurse", "--level=0118"] => Ok(DirAction::Recurse(RecurseOptions { tree: false, max_depth: Some(118) }))); + test!(rec_tree: DirAction <- ["--recurse", "--tree"] => Ok(DirAction::Recurse(RecurseOptions { tree: true, max_depth: None }))); + test!(rec_short_tree: DirAction <- ["--tree", "--recurse"] => Ok(DirAction::Recurse(RecurseOptions { tree: true, max_depth: None }))); + + // Errors + test!(error: DirAction <- ["--list-dirs", "--recurse"] => Err(Misfire::Conflict(&flags::RECURSE, &flags::LIST_DIRS))); + test!(error_2: DirAction <- ["--list-dirs", "--tree"] => Err(Misfire::Conflict(&flags::TREE, &flags::LIST_DIRS))); + test!(underwaterlevel: DirAction <- ["--level=4"] => Err(Misfire::Useless2(&flags::LEVEL, &flags::RECURSE, &flags::TREE))); +} diff --git a/src/options/filter.rs b/src/options/filter.rs index 8493b24..5081141 100644 --- a/src/options/filter.rs +++ b/src/options/filter.rs @@ -1,226 +1,28 @@ -use std::cmp::Ordering; -use std::os::unix::fs::MetadataExt; - -use getopts; use glob; -use natord; -use fs::File; use fs::DotFilter; -use options::misfire::Misfire; +use fs::filter::{FileFilter, SortField, SortCase, IgnorePatterns}; +use options::{flags, Misfire}; +use options::parser::MatchedFlags; -/// The **file filter** processes a vector of files before outputting them, -/// filtering and sorting the files depending on the user’s command-line -/// flags. -#[derive(Default, PartialEq, Debug, Clone)] -pub struct FileFilter { - - /// Whether directories should be listed first, and other types of file - /// second. Some users prefer it like this. - pub list_dirs_first: bool, - - /// The metadata field to sort by. - pub sort_field: SortField, - - /// Whether to reverse the sorting order. This would sort the largest - /// files first, or files starting with Z, or the most-recently-changed - /// ones, depending on the sort field. - pub reverse: bool, - - /// Which invisible “dot” files to include when listing a directory. - /// - /// Files starting with a single “.” are used to determine “system” or - /// “configuration” files that should not be displayed in a regular - /// directory listing, and the directory entries “.” and “..” are - /// considered extra-special. - /// - /// This came about more or less by a complete historical accident, - /// when the original `ls` tried to hide `.` and `..`: - /// https://plus.google.com/+RobPikeTheHuman/posts/R58WgWwN9jp - /// - /// When one typed ls, however, these files appeared, so either Ken or - /// Dennis added a simple test to the program. It was in assembler then, - /// but the code in question was equivalent to something like this: - /// if (name[0] == '.') continue; - /// This statement was a little shorter than what it should have been, - /// which is: - /// if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - /// but hey, it was easy. - /// - /// Two things resulted. - /// - /// First, a bad precedent was set. A lot of other lazy programmers - /// introduced bugs by making the same simplification. Actual files - /// beginning with periods are often skipped when they should be counted. - /// - /// Second, and much worse, the idea of a "hidden" or "dot" file was - /// created. As a consequence, more lazy programmers started dropping - /// files into everyone's home directory. I don't have all that much - /// stuff installed on the machine I'm using to type this, but my home - /// directory has about a hundred dot files and I don't even know what - /// most of them are or whether they're still needed. Every file name - /// evaluation that goes through my home directory is slowed down by - /// this accumulated sludge. - pub dot_filter: DotFilter, - - /// Glob patterns to ignore. Any file name that matches *any* of these - /// patterns won't be displayed in the list. - ignore_patterns: IgnorePatterns, -} impl FileFilter { /// Determines the set of file filter options to use, based on the user’s /// command-line arguments. - pub fn deduce(matches: &getopts::Matches) -> Result { + pub fn deduce(matches: &MatchedFlags) -> Result { Ok(FileFilter { - list_dirs_first: matches.opt_present("group-directories-first"), - reverse: matches.opt_present("reverse"), + list_dirs_first: matches.has(&flags::DIRS_FIRST), + reverse: matches.has(&flags::REVERSE), sort_field: SortField::deduce(matches)?, dot_filter: DotFilter::deduce(matches)?, ignore_patterns: IgnorePatterns::deduce(matches)?, }) } - - /// Remove every file in the given vector that does *not* pass the - /// filter predicate for files found inside a directory. - pub fn filter_child_files(&self, files: &mut Vec) { - files.retain(|f| !self.ignore_patterns.is_ignored(f)); - } - - /// Remove every file in the given vector that does *not* pass the - /// filter predicate for file names specified on the command-line. - /// - /// The rules are different for these types of files than the other - /// type because the ignore rules can be used with globbing. For - /// example, running "exa -I='*.tmp' .vimrc" shouldn't filter out the - /// dotfile, because it's been directly specified. But running - /// "exa -I='*.ogg' music/*" should filter out the ogg files obtained - /// from the glob, even though the globbing is done by the shell! - pub fn filter_argument_files(&self, files: &mut Vec) { - files.retain(|f| !self.ignore_patterns.is_ignored(f)); - } - - /// Sort the files in the given vector based on the sort field option. - pub fn sort_files<'a, F>(&self, files: &mut Vec) - where F: AsRef> { - - files.sort_by(|a, b| self.compare_files(a.as_ref(), b.as_ref())); - - if self.reverse { - files.reverse(); - } - - if self.list_dirs_first { - // This relies on the fact that `sort_by` is stable. - files.sort_by(|a, b| b.as_ref().is_directory().cmp(&a.as_ref().is_directory())); - } - } - - /// Compares two files to determine the order they should be listed in, - /// depending on the search field. - pub fn compare_files(&self, a: &File, b: &File) -> Ordering { - use self::SortCase::{Sensitive, Insensitive}; - - match self.sort_field { - SortField::Unsorted => Ordering::Equal, - - SortField::Name(Sensitive) => natord::compare(&a.name, &b.name), - SortField::Name(Insensitive) => natord::compare_ignore_case(&a.name, &b.name), - - SortField::Size => a.metadata.len().cmp(&b.metadata.len()), - SortField::FileInode => a.metadata.ino().cmp(&b.metadata.ino()), - SortField::ModifiedDate => a.metadata.mtime().cmp(&b.metadata.mtime()), - SortField::AccessedDate => a.metadata.atime().cmp(&b.metadata.atime()), - SortField::CreatedDate => a.metadata.ctime().cmp(&b.metadata.ctime()), - - SortField::FileType => match a.type_char().cmp(&b.type_char()) { // todo: this recomputes - Ordering::Equal => natord::compare(&*a.name, &*b.name), - order => order, - }, - - SortField::Extension(Sensitive) => match a.ext.cmp(&b.ext) { - Ordering::Equal => natord::compare(&*a.name, &*b.name), - order => order, - }, - - SortField::Extension(Insensitive) => match a.ext.cmp(&b.ext) { - Ordering::Equal => natord::compare_ignore_case(&*a.name, &*b.name), - order => order, - }, - } - } } -/// User-supplied field to sort by. -#[derive(PartialEq, Debug, Copy, Clone)] -pub enum SortField { - - /// Don't apply any sorting. This is usually used as an optimisation in - /// scripts, where the order doesn't matter. - Unsorted, - - /// The file name. This is the default sorting. - Name(SortCase), - - /// The file's extension, with extensionless files being listed first. - Extension(SortCase), - - /// The file's size. - Size, - - /// The file's inode. This is sometimes analogous to the order in which - /// the files were created on the hard drive. - FileInode, - - /// The time at which this file was modified (the `mtime`). - /// - /// As this is stored as a Unix timestamp, rather than a local time - /// instance, the time zone does not matter and will only be used to - /// display the timestamps, not compare them. - ModifiedDate, - - /// The time at this file was accessed (the `atime`). - /// - /// Oddly enough, this field rarely holds the *actual* accessed time. - /// Recording a read time means writing to the file each time it’s read - /// slows the whole operation down, so many systems will only update the - /// timestamp in certain circumstances. This has become common enough that - /// it’s now expected behaviour for the `atime` field. - /// http://unix.stackexchange.com/a/8842 - AccessedDate, - - /// The time at which this file was changed or created (the `ctime`). - /// - /// Contrary to the name, this field is used to mark the time when a - /// file's metadata changed -- its permissions, owners, or link count. - /// - /// In original Unix, this was, however, meant as creation time. - /// https://www.bell-labs.com/usr/dmr/www/cacm.html - CreatedDate, - - /// The type of the file: directories, links, pipes, regular, files, etc. - /// - /// Files are ordered according to the `PartialOrd` implementation of - /// `fs::fields::Type`, so changing that will change this. - FileType, -} - -/// Whether a field should be sorted case-sensitively or case-insensitively. -/// -/// This determines which of the `natord` functions to use. -#[derive(PartialEq, Debug, Copy, Clone)] -pub enum SortCase { - - /// Sort files case-sensitively with uppercase first, with ‘A’ coming - /// before ‘a’. - Sensitive, - - /// Sort files case-insensitively, with ‘A’ being equal to ‘a’. - Insensitive, -} impl Default for SortField { fn default() -> SortField { @@ -228,78 +30,171 @@ impl Default for SortField { } } +const SORTS: &[&str] = &[ "name", "Name", "size", "extension", + "Extension", "modified", "accessed", + "created", "inode", "type", "none" ]; + impl SortField { /// Determine the sort field to use, based on the presence of a “sort” /// argument. This will return `Err` if the option is there, but does not /// correspond to a valid field. - fn deduce(matches: &getopts::Matches) -> Result { + fn deduce(matches: &MatchedFlags) -> Result { + let word = match matches.get(&flags::SORT) { + Some(w) => w, + None => return Ok(SortField::default()), + }; - const SORTS: &[&str] = &[ "name", "Name", "size", "extension", - "Extension", "modified", "accessed", - "created", "inode", "type", "none" ]; - - if let Some(word) = matches.opt_str("sort") { - match &*word { - "name" | "filename" => Ok(SortField::Name(SortCase::Sensitive)), - "Name" | "Filename" => Ok(SortField::Name(SortCase::Insensitive)), - "size" | "filesize" => Ok(SortField::Size), - "ext" | "extension" => Ok(SortField::Extension(SortCase::Sensitive)), - "Ext" | "Extension" => Ok(SortField::Extension(SortCase::Insensitive)), - "mod" | "modified" => Ok(SortField::ModifiedDate), - "acc" | "accessed" => Ok(SortField::AccessedDate), - "cr" | "created" => Ok(SortField::CreatedDate), - "inode" => Ok(SortField::FileInode), - "type" => Ok(SortField::FileType), - "none" => Ok(SortField::Unsorted), - field => Err(Misfire::bad_argument("sort", field, SORTS)) - } + if word == "name" || word == "filename" { + Ok(SortField::Name(SortCase::Sensitive)) + } + else if word == "Name" || word == "Filename" { + Ok(SortField::Name(SortCase::Insensitive)) + } + else if word == "size" || word == "filesize" { + Ok(SortField::Size) + } + else if word == "ext" || word == "extension" { + Ok(SortField::Extension(SortCase::Sensitive)) + } + else if word == "Ext" || word == "Extension" { + Ok(SortField::Extension(SortCase::Insensitive)) + } + else if word == "mod" || word == "modified" { + Ok(SortField::ModifiedDate) + } + else if word == "acc" || word == "accessed" { + Ok(SortField::AccessedDate) + } + else if word == "cr" || word == "created" { + Ok(SortField::CreatedDate) + } + else if word == "inode" { + Ok(SortField::FileInode) + } + else if word == "type" { + Ok(SortField::FileType) + } + else if word == "none" { + Ok(SortField::Unsorted) } else { - Ok(SortField::default()) + Err(Misfire::bad_argument(&flags::SORT, word, SORTS)) } } } impl DotFilter { - pub fn deduce(matches: &getopts::Matches) -> Result { - let dots = match matches.opt_count("all") { - 0 => return Ok(DotFilter::JustFiles), - 1 => DotFilter::Dotfiles, - _ => DotFilter::DotfilesAndDots, - }; - - if matches.opt_present("tree") { - Err(Misfire::Useless("all --all", true, "tree")) - } - else { - Ok(dots) + pub fn deduce(matches: &MatchedFlags) -> Result { + match matches.count(&flags::ALL) { + 0 => Ok(DotFilter::JustFiles), + 1 => Ok(DotFilter::Dotfiles), + _ => if matches.has(&flags::TREE) { Err(Misfire::TreeAllAll) } + else { Ok(DotFilter::DotfilesAndDots) } } } } -#[derive(PartialEq, Default, Debug, Clone)] -struct IgnorePatterns { - patterns: Vec, -} - impl IgnorePatterns { + /// Determines the set of file filter options to use, based on the user’s /// command-line arguments. - pub fn deduce(matches: &getopts::Matches) -> Result { - let patterns = match matches.opt_str("ignore-glob") { + pub fn deduce(matches: &MatchedFlags) -> Result { + let patterns = match matches.get(&flags::IGNORE_GLOB) { None => Ok(Vec::new()), - Some(is) => is.split('|').map(|a| glob::Pattern::new(a)).collect(), - }; + Some(is) => is.to_string_lossy().split('|').map(|a| glob::Pattern::new(a)).collect(), + }?; - Ok(IgnorePatterns { - patterns: patterns?, - }) - } + // TODO: is to_string_lossy really the best way to handle + // invalid UTF-8 there? - fn is_ignored(&self, file: &File) -> bool { - self.patterns.iter().any(|p| p.matches(&file.name)) + Ok(IgnorePatterns { patterns }) + } +} + + + +#[cfg(test)] +mod test { + use super::*; + use std::ffi::OsString; + use options::flags; + + pub fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os + } + + macro_rules! test { + ($name:ident: $type:ident <- $inputs:expr => $result:expr) => { + #[test] + fn $name() { + use options::parser::{Args, Arg}; + use std::ffi::OsString; + + static TEST_ARGS: &[&Arg] = &[ &flags::SORT, &flags::ALL, &flags::TREE, &flags::IGNORE_GLOB ]; + + let bits = $inputs.as_ref().into_iter().map(|&o| os(o)).collect::>(); + let results = Args(TEST_ARGS).parse(bits.iter()); + assert_eq!($type::deduce(&results.unwrap().flags), $result); + } + }; + } + + mod sort_fields { + use super::*; + + // Default behaviour + test!(empty: SortField <- [] => Ok(SortField::default())); + + // Sort field arguments + test!(one_arg: SortField <- ["--sort=cr"] => Ok(SortField::CreatedDate)); + test!(one_long: SortField <- ["--sort=size"] => Ok(SortField::Size)); + test!(one_short: SortField <- ["-saccessed"] => Ok(SortField::AccessedDate)); + test!(lowercase: SortField <- ["--sort", "name"] => Ok(SortField::Name(SortCase::Sensitive))); + test!(uppercase: SortField <- ["--sort", "Name"] => Ok(SortField::Name(SortCase::Insensitive))); + + // Errors + test!(error: SortField <- ["--sort=colour"] => Err(Misfire::bad_argument(&flags::SORT, &os("colour"), super::SORTS))); + + // Overriding + test!(overridden: SortField <- ["--sort=cr", "--sort", "mod"] => Ok(SortField::ModifiedDate)); + test!(overridden_2: SortField <- ["--sort", "none", "--sort=Extension"] => Ok(SortField::Extension(SortCase::Insensitive))); + } + + + mod dot_filters { + use super::*; + + // Default behaviour + test!(empty: DotFilter <- [] => Ok(DotFilter::JustFiles)); + + // --all + test!(all: DotFilter <- ["--all"] => Ok(DotFilter::Dotfiles)); + test!(all_all: DotFilter <- ["--all", "-a"] => Ok(DotFilter::DotfilesAndDots)); + test!(all_all_2: DotFilter <- ["-aa"] => Ok(DotFilter::DotfilesAndDots)); + + // --all and --tree + test!(tree_a: DotFilter <- ["-Ta"] => Ok(DotFilter::Dotfiles)); + test!(tree_aa: DotFilter <- ["-Taa"] => Err(Misfire::TreeAllAll)); + } + + + mod ignore_patternses { + use super::*; + use glob; + + fn pat(string: &'static str) -> glob::Pattern { + glob::Pattern::new(string).unwrap() + } + + // Various numbers of globs + test!(none: IgnorePatterns <- [] => Ok(IgnorePatterns { patterns: vec![] })); + test!(one: IgnorePatterns <- ["--ignore-glob", "*.ogg"] => Ok(IgnorePatterns { patterns: vec![ pat("*.ogg") ] })); + test!(two: IgnorePatterns <- ["--ignore-glob=*.ogg|*.MP3"] => Ok(IgnorePatterns { patterns: vec![ pat("*.ogg"), pat("*.MP3") ] })); + test!(loads: IgnorePatterns <- ["-I*|?|.|*"] => Ok(IgnorePatterns { patterns: vec![ pat("*"), pat("?"), pat("."), pat("*") ] })); } } diff --git a/src/options/flags.rs b/src/options/flags.rs new file mode 100644 index 0000000..8f24dab --- /dev/null +++ b/src/options/flags.rs @@ -0,0 +1,64 @@ +use options::parser::{Arg, Args, TakesValue}; + + +// exa options +pub static VERSION: Arg = Arg { short: Some(b'v'), long: "version", takes_value: TakesValue::Forbidden }; +pub static HELP: Arg = Arg { short: Some(b'?'), long: "help", takes_value: TakesValue::Forbidden }; + +// display options +pub static ONE_LINE: Arg = Arg { short: Some(b'1'), long: "oneline", takes_value: TakesValue::Forbidden }; +pub static LONG: Arg = Arg { short: Some(b'l'), long: "long", takes_value: TakesValue::Forbidden }; +pub static GRID: Arg = Arg { short: Some(b'G'), long: "grid", takes_value: TakesValue::Forbidden }; +pub static ACROSS: Arg = Arg { short: Some(b'x'), long: "across", takes_value: TakesValue::Forbidden }; +pub static RECURSE: Arg = Arg { short: Some(b'R'), long: "recurse", takes_value: TakesValue::Forbidden }; +pub static TREE: Arg = Arg { short: Some(b'T'), long: "tree", takes_value: TakesValue::Forbidden }; +pub static CLASSIFY: Arg = Arg { short: Some(b'F'), long: "classify", takes_value: TakesValue::Forbidden }; + +pub static COLOR: Arg = Arg { short: None, long: "color", takes_value: TakesValue::Necessary }; +pub static COLOUR: Arg = Arg { short: None, long: "colour", takes_value: TakesValue::Necessary }; + +pub static COLOR_SCALE: Arg = Arg { short: None, long: "color-scale", takes_value: TakesValue::Forbidden }; +pub static COLOUR_SCALE: Arg = Arg { short: None, long: "colour-scale", takes_value: TakesValue::Forbidden }; + +// filtering and sorting options +pub static ALL: Arg = Arg { short: Some(b'a'), long: "all", takes_value: TakesValue::Forbidden }; +pub static LIST_DIRS: Arg = Arg { short: Some(b'd'), long: "list-dirs", takes_value: TakesValue::Forbidden }; +pub static LEVEL: Arg = Arg { short: Some(b'L'), long: "level", takes_value: TakesValue::Necessary }; +pub static REVERSE: Arg = Arg { short: Some(b'r'), long: "reverse", takes_value: TakesValue::Forbidden }; +pub static SORT: Arg = Arg { short: Some(b's'), long: "sort", takes_value: TakesValue::Necessary }; +pub static IGNORE_GLOB: Arg = Arg { short: Some(b'I'), long: "ignore-glob", takes_value: TakesValue::Necessary }; +pub static DIRS_FIRST: Arg = Arg { short: None, long: "group-directories-first", takes_value: TakesValue::Forbidden }; + +// display options +pub static BINARY: Arg = Arg { short: Some(b'b'), long: "binary", takes_value: TakesValue::Forbidden }; +pub static BYTES: Arg = Arg { short: Some(b'B'), long: "bytes", takes_value: TakesValue::Forbidden }; +pub static GROUP: Arg = Arg { short: Some(b'g'), long: "group", takes_value: TakesValue::Forbidden }; +pub static HEADER: Arg = Arg { short: Some(b'h'), long: "header", takes_value: TakesValue::Forbidden }; +pub static INODE: Arg = Arg { short: Some(b'i'), long: "inode", takes_value: TakesValue::Forbidden }; +pub static LINKS: Arg = Arg { short: Some(b'H'), long: "links", takes_value: TakesValue::Forbidden }; +pub static MODIFIED: Arg = Arg { short: Some(b'm'), long: "modified", takes_value: TakesValue::Forbidden }; +pub static BLOCKS: Arg = Arg { short: Some(b'S'), long: "blocks", takes_value: TakesValue::Forbidden }; +pub static TIME: Arg = Arg { short: Some(b't'), long: "time", takes_value: TakesValue::Necessary }; +pub static ACCESSED: Arg = Arg { short: Some(b'u'), long: "accessed", takes_value: TakesValue::Forbidden }; +pub static CREATED: Arg = Arg { short: Some(b'U'), long: "created", takes_value: TakesValue::Forbidden }; +pub static TIME_STYLE: Arg = Arg { short: None, long: "time-style", takes_value: TakesValue::Necessary }; + +// optional feature options +pub static GIT: Arg = Arg { short: None, long: "git", takes_value: TakesValue::Forbidden }; +pub static EXTENDED: Arg = Arg { short: Some(b'@'), long: "extended", takes_value: TakesValue::Forbidden }; + + +pub static ALL_ARGS: Args = Args(&[ + &VERSION, &HELP, + + &ONE_LINE, &LONG, &GRID, &ACROSS, &RECURSE, &TREE, &CLASSIFY, + &COLOR, &COLOUR, &COLOR_SCALE, &COLOUR_SCALE, + + &ALL, &LIST_DIRS, &LEVEL, &REVERSE, &SORT, &IGNORE_GLOB, &DIRS_FIRST, + + &BINARY, &BYTES, &GROUP, &HEADER, &INODE, &LINKS, &MODIFIED, &BLOCKS, + &TIME, &ACCESSED, &CREATED, &TIME_STYLE, + + &GIT, &EXTENDED, +]); + diff --git a/src/options/help.rs b/src/options/help.rs index 3d5e871..ce7e15a 100644 --- a/src/options/help.rs +++ b/src/options/help.rs @@ -1,5 +1,9 @@ use std::fmt; +use options::flags; +use options::parser::MatchedFlags; +use fs::feature::xattr; + static OPTIONS: &str = r##" -?, --help show list of command-line options @@ -46,14 +50,45 @@ LONG VIEW OPTIONS static GIT_HELP: &str = r##" --git list each file's Git status, if tracked"##; static EXTENDED_HELP: &str = r##" -@, --extended list each file's extended attributes and sizes"##; + +/// All the information needed to display the help text, which depends +/// on which features are enabled and whether the user only wants to +/// see one section’s help. #[derive(PartialEq, Debug)] pub struct HelpString { - pub only_long: bool, - pub git: bool, - pub xattrs: bool, + + /// Only show the help for the long section, not all the help. + only_long: bool, + + /// Whether the --git option should be included in the help. + git: bool, + + /// Whether the --extended option should be included in the help. + xattrs: bool, +} + +impl HelpString { + + /// Determines how to show help, if at all, based on the user’s + /// command-line arguments. This one works backwards from the other + /// ‘deduce’ functions, returning Err if help needs to be shown. + pub fn deduce(matches: &MatchedFlags) -> Result<(), HelpString> { + if matches.has(&flags::HELP) { + let only_long = matches.has(&flags::LONG); + let git = cfg!(feature="git"); + let xattrs = xattr::ENABLED; + Err(HelpString { only_long, git, xattrs }) + } + else { + Ok(()) // no help needs to be shown + } + } } impl fmt::Display for HelpString { + + /// Format this help options into an actual string of help + /// text to be displayed to the user. fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { try!(write!(f, "Usage:\n exa [options] [files...]\n")); @@ -74,3 +109,38 @@ impl fmt::Display for HelpString { Ok(()) } } + + + +#[cfg(test)] +mod test { + use options::Options; + use std::ffi::OsString; + + fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os + } + + #[test] + fn help() { + let args = [ os("--help") ]; + let opts = Options::getopts(&args); + assert!(opts.is_err()) + } + + #[test] + fn help_with_file() { + let args = [ os("--help"), os("me") ]; + let opts = Options::getopts(&args); + assert!(opts.is_err()) + } + + #[test] + fn unhelpful() { + let args = []; + let opts = Options::getopts(&args); + assert!(opts.is_ok()) // no help when --help isn’t passed + } +} diff --git a/src/options/misfire.rs b/src/options/misfire.rs index 84ea3f5..d89f269 100644 --- a/src/options/misfire.rs +++ b/src/options/misfire.rs @@ -1,10 +1,11 @@ +use std::ffi::{OsStr, OsString}; use std::fmt; use std::num::ParseIntError; -use getopts; use glob; -use options::help::HelpString; +use options::{HelpString, VersionString}; +use options::parser::{Arg, ParseError}; /// A list of legal choices for an argument-taking option @@ -13,7 +14,7 @@ pub struct Choices(&'static [&'static str]); impl fmt::Display for Choices { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "(choices: {})", self.0.join(" ")) + write!(f, "(choices: {})", self.0.join(", ")) } } @@ -22,29 +23,32 @@ impl fmt::Display for Choices { #[derive(PartialEq, Debug)] pub enum Misfire { - /// The getopts crate didn’t like these arguments. - InvalidOptions(getopts::Fail), + /// The getopts crate didn’t like these Arguments. + InvalidOptions(ParseError), - /// The user supplied an illegal choice to an argument - BadArgument(getopts::Fail, Choices), + /// The user supplied an illegal choice to an Argument. + BadArgument(&'static Arg, OsString, Choices), /// The user asked for help. This isn’t strictly an error, which is why /// this enum isn’t named Error! Help(HelpString), /// The user wanted the version number. - Version, + Version(VersionString), /// Two options were given that conflict with one another. - Conflict(&'static str, &'static str), + Conflict(&'static Arg, &'static Arg), /// An option was given that does nothing when another one either is or /// isn't present. - Useless(&'static str, bool, &'static str), + Useless(&'static Arg, bool, &'static Arg), /// An option was given that does nothing when either of two other options /// are not present. - Useless2(&'static str, &'static str, &'static str), + Useless2(&'static Arg, &'static Arg, &'static Arg), + + /// A very specific edge case where --tree can’t be used with --all twice. + TreeAllAll, /// A numeric option was given that failed to be parsed as a number. FailedParse(ParseIntError), @@ -58,9 +62,9 @@ impl Misfire { /// The OS return code this misfire should signify. pub fn is_error(&self) -> bool { match *self { - Misfire::Help(_) => false, - Misfire::Version => false, - _ => true, + Misfire::Help(_) => false, + Misfire::Version(_) => false, + _ => true, } } @@ -68,10 +72,8 @@ impl Misfire { /// argument. This has to use one of the `getopts` failure /// variants--it’s meant to take just an option name, rather than an /// option *and* an argument, but it works just as well. - pub fn bad_argument(option: &str, otherwise: &str, legal: &'static [&'static str]) -> Misfire { - Misfire::BadArgument(getopts::Fail::UnrecognizedOption(format!( - "--{} {}", - option, otherwise)), Choices(legal)) + pub fn bad_argument(option: &'static Arg, otherwise: &OsStr, legal: &'static [&'static str]) -> Misfire { + Misfire::BadArgument(option, otherwise.to_os_string(), Choices(legal)) } } @@ -86,16 +88,17 @@ impl fmt::Display for Misfire { use self::Misfire::*; match *self { - InvalidOptions(ref e) => write!(f, "{}", e), - BadArgument(ref e, ref c) => write!(f, "{} {}", e, c), - Help(ref text) => write!(f, "{}", text), - Version => write!(f, "exa {}", env!("CARGO_PKG_VERSION")), - Conflict(a, b) => write!(f, "Option --{} conflicts with option {}.", a, b), - Useless(a, false, b) => write!(f, "Option --{} is useless without option --{}.", a, b), - Useless(a, true, b) => write!(f, "Option --{} is useless given option --{}.", a, b), - Useless2(a, b1, b2) => write!(f, "Option --{} is useless without options --{} or --{}.", a, b1, b2), - FailedParse(ref e) => write!(f, "Failed to parse number: {}", e), - FailedGlobPattern(ref e) => write!(f, "Failed to parse glob pattern: {}", e), + BadArgument(ref a, ref b, ref c) => write!(f, "Option {} has no value {:?} (Choices: {})", a, b, c), + InvalidOptions(ref e) => write!(f, "{:?}", e), + Help(ref text) => write!(f, "{}", text), + Version(ref version) => write!(f, "{}", version), + Conflict(ref a, ref b) => write!(f, "Option {} conflicts with option {}.", a, b), + Useless(ref a, false, ref b) => write!(f, "Option {} is useless without option {}.", a, b), + Useless(ref a, true, ref b) => write!(f, "Option {} is useless given option {}.", a, b), + Useless2(ref a, ref b1, ref b2) => write!(f, "Option {} is useless without options {} or {}.", a, b1, b2), + TreeAllAll => write!(f, "Option --tree is useless given --all --all."), + FailedParse(ref e) => write!(f, "Failed to parse number: {}", e), + FailedGlobPattern(ref e) => write!(f, "Failed to parse glob pattern: {}", e), } } } diff --git a/src/options/mod.rs b/src/options/mod.rs index 4215c79..0881f0b 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -1,24 +1,97 @@ -use std::ffi::OsStr; +//! Parsing command-line strings into exa options. +//! +//! This module imports exa’s configuration types, such as `View` (the details +//! of displaying multiple files) and `DirAction` (what to do when encountering +//! a directory), and implements `deduce` methods on them so they can be +//! configured using command-line options. +//! +//! +//! ## Useless and overridden options +//! +//! Let’s say exa was invoked with just one argument: `exa --inode`. The +//! `--inode` option is used in the details view, where it adds the inode +//! column to the output. But because the details view is *only* activated with +//! the `--long` argument, adding `--inode` without it would not have any +//! effect. +//! +//! For a long time, exa’s philosophy was that the user should be warned +//! whenever they could be mistaken like this. If you tell exa to display the +//! inode, and it *doesn’t* display the inode, isn’t that more annoying than +//! having it throw an error back at you? +//! +//! However, this doesn’t take into account *configuration*. Say a user wants +//! to configure exa so that it lists inodes in the details view, but otherwise +//! functions normally. A common way to do this for command-line programs is to +//! define a shell alias that specifies the details they want to use every +//! time. For the inode column, the alias would be: +//! +//! `alias exa="exa --inode"` +//! +//! Using this alias means that although the inode column will be shown in the +//! details view, you’re now *only* allowed to use the details view, as any +//! other view type will result in an error. Oops! +//! +//! Another example is when an option is specified twice, such as `exa +//! --sort=Name --sort=size`. Did the user change their mind about sorting, and +//! accidentally specify the option twice? +//! +//! Again, exa rejected this case, throwing an error back to the user instead +//! of trying to guess how they want their output sorted. And again, this +//! doesn’t take into account aliases being used to set defaults. A user who +//! wants their files to be sorted case-insensitively may configure their shell +//! with the following: +//! +//! `alias exa="exa --sort=Name"` +//! +//! Just like the earlier example, the user now can’t use any other sort order, +//! because exa refuses to guess which one they meant. It’s *more* annoying to +//! have to go back and edit the command than if there were no error. +//! +//! Fortunately, there’s a heuristic for telling which options came from an +//! alias and which came from the actual command-line: aliased options are +//! nearer the beginning of the options array, and command-line options are +//! nearer the end. This means that after the options have been parsed, exa +//! needs to traverse them *backwards* to find the last-most-specified one. +//! +//! For example, invoking exa with `exa --sort=size` when that alias is present +//! would result in a full command-line of: +//! +//! `exa --sort=Name --sort=size` +//! +//! `--sort=size` should override `--sort=Name` because it’s closer to the end +//! of the arguments array. In fact, because there’s no way to tell where the +//! arguments came from -- it’s just a heuristic -- this will still work even +//! if no aliases are being used! +//! +//! Finally, this isn’t just useful when options could override each other. +//! Creating an alias `exal=”exa --long --inode --header”` then invoking `exal +//! --grid --long` shouldn’t complain about `--long` being given twice when +//! it’s clear what the user wants. -use getopts; -use fs::feature::xattr; +use std::ffi::{OsStr, OsString}; + +use fs::dir_action::DirAction; +use fs::filter::FileFilter; +use output::{View, Mode}; use output::details; mod dir_action; -pub use self::dir_action::{DirAction, RecurseOptions}; - mod filter; -pub use self::filter::{FileFilter, SortField, SortCase}; +mod view; mod help; use self::help::HelpString; +mod version; +use self::version::VersionString; + mod misfire; pub use self::misfire::Misfire; -mod view; -pub use self::view::{View, Mode}; +mod parser; +mod flags; +use self::parser::MatchedFlags; /// These **options** represent a parsed, error-checked versions of the @@ -39,85 +112,22 @@ pub struct Options { impl Options { - // Even though the arguments go in as OsStrings, they come out - // as Strings. Invalid UTF-8 won’t be parsed, but it won’t make - // exa core dump either. - // - // https://github.com/rust-lang-nursery/getopts/pull/29 - /// Call getopts on the given slice of command-line strings. #[allow(unused_results)] - pub fn getopts(args: C) -> Result<(Options, Vec), Misfire> - where C: IntoIterator, C::Item: AsRef { - let mut opts = getopts::Options::new(); + pub fn getopts<'args, I>(args: I) -> Result<(Options, Vec<&'args OsStr>), Misfire> + where I: IntoIterator { + use options::parser::Matches; - opts.optflag("v", "version", "show version of exa"); - opts.optflag("?", "help", "show list of command-line options"); - - // Display options - opts.optflag("1", "oneline", "display one entry per line"); - opts.optflag("l", "long", "display extended file metadata in a table"); - opts.optflag("G", "grid", "display entries as a grid (default)"); - opts.optflag("x", "across", "sort the grid across, rather than downwards"); - opts.optflag("R", "recurse", "recurse into directories"); - opts.optflag("T", "tree", "recurse into directories as a tree"); - opts.optflag("F", "classify", "display type indicator by file names (one of */=@|)"); - opts.optopt ("", "color", "when to use terminal colours", "WHEN"); - opts.optopt ("", "colour", "when to use terminal colours", "WHEN"); - opts.optflag("", "color-scale", "highlight levels of file sizes distinctly"); - opts.optflag("", "colour-scale", "highlight levels of file sizes distinctly"); - - // Filtering and sorting options - opts.optflag("", "group-directories-first", "sort directories before other files"); - opts.optflagmulti("a", "all", "show hidden and 'dot' files"); - opts.optflag("d", "list-dirs", "list directories like regular files"); - opts.optopt ("L", "level", "limit the depth of recursion", "DEPTH"); - opts.optflag("r", "reverse", "reverse the sert order"); - opts.optopt ("s", "sort", "which field to sort by", "WORD"); - opts.optopt ("I", "ignore-glob", "ignore files that match these glob patterns", "GLOB1|GLOB2..."); - - // Long view options - opts.optflag("b", "binary", "list file sizes with binary prefixes"); - opts.optflag("B", "bytes", "list file sizes in bytes, without prefixes"); - opts.optflag("g", "group", "list each file's group"); - opts.optflag("h", "header", "add a header row to each column"); - opts.optflag("H", "links", "list each file's number of hard links"); - opts.optflag("i", "inode", "list each file's inode number"); - opts.optflag("m", "modified", "use the modified timestamp field"); - opts.optflag("S", "blocks", "list each file's number of file system blocks"); - opts.optopt ("t", "time", "which timestamp field to show", "WORD"); - opts.optflag("u", "accessed", "use the accessed timestamp field"); - opts.optflag("U", "created", "use the created timestamp field"); - opts.optopt ("", "time-style", "how to format timestamp fields", "STYLE"); - - if cfg!(feature="git") { - opts.optflag("", "git", "list each file's git status"); - } - - if xattr::ENABLED { - opts.optflag("@", "extended", "list each file's extended attribute keys and sizes"); - } - - let matches = match opts.parse(args) { + let Matches { flags, frees } = match flags::ALL_ARGS.parse(args) { Ok(m) => m, Err(e) => return Err(Misfire::InvalidOptions(e)), }; - if matches.opt_present("help") { - let help = HelpString { - only_long: matches.opt_present("long"), - git: cfg!(feature="git"), - xattrs: xattr::ENABLED, - }; + HelpString::deduce(&flags).map_err(Misfire::Help)?; + VersionString::deduce(&flags).map_err(Misfire::Version)?; - return Err(Misfire::Help(help)); - } - else if matches.opt_present("version") { - return Err(Misfire::Version); - } - - let options = Options::deduce(&matches)?; - Ok((options, matches.free)) + let options = Options::deduce(&flags)?; + Ok((options, frees)) } /// Whether the View specified in this set of options includes a Git @@ -133,7 +143,7 @@ impl Options { /// Determines the complete set of options based on the given command-line /// arguments, after they’ve been parsed. - fn deduce(matches: &getopts::Matches) -> Result { + fn deduce(matches: &MatchedFlags) -> Result { let dir_action = DirAction::deduce(matches)?; let filter = FileFilter::deduce(matches)?; let view = View::deduce(matches)?; @@ -143,165 +153,134 @@ impl Options { } + #[cfg(test)] mod test { - use super::{Options, Misfire, SortField, SortCase}; - use fs::DotFilter; + use super::{Options, Misfire, flags}; + use std::ffi::OsString; + use fs::filter::{SortField, SortCase}; use fs::feature::xattr; - fn is_helpful(misfire: Result) -> bool { - match misfire { - Err(Misfire::Help(_)) => true, - _ => false, - } - } - - #[test] - fn help() { - let opts = Options::getopts(&[ "--help".to_string() ]); - assert!(is_helpful(opts)) - } - - #[test] - fn help_with_file() { - let opts = Options::getopts(&[ "--help".to_string(), "me".to_string() ]); - assert!(is_helpful(opts)) + /// Creates an `OSStr` (used in tests) + #[cfg(test)] + fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os } #[test] fn files() { - let args = Options::getopts(&[ "this file".to_string(), "that file".to_string() ]).unwrap().1; - assert_eq!(args, vec![ "this file".to_string(), "that file".to_string() ]) + let args = [ os("this file"), os("that file") ]; + let outs = Options::getopts(&args).unwrap().1; + assert_eq!(outs, vec![ &os("this file"), &os("that file") ]) } #[test] fn no_args() { - let nothing: Vec = Vec::new(); - let args = Options::getopts(¬hing).unwrap().1; - assert!(args.is_empty()); // Listing the `.` directory is done in main.rs - } - - #[test] - fn file_sizes() { - let opts = Options::getopts(&[ "--long", "--binary", "--bytes" ]); - assert_eq!(opts.unwrap_err(), Misfire::Conflict("binary", "bytes")) + let nothing: Vec = Vec::new(); + let outs = Options::getopts(¬hing).unwrap().1; + assert!(outs.is_empty()); // Listing the `.` directory is done in main.rs } #[test] fn just_binary() { - let opts = Options::getopts(&[ "--binary" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("binary", false, "long")) + let args = [ os("--binary") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::BINARY, false, &flags::LONG)) } #[test] fn just_bytes() { - let opts = Options::getopts(&[ "--bytes" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("bytes", false, "long")) + let args = [ os("--bytes") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::BYTES, false, &flags::LONG)) } #[test] fn long_across() { - let opts = Options::getopts(&[ "--long", "--across" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("across", true, "long")) + let args = [ os("--long"), os("--across") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::ACROSS, true, &flags::LONG)) } #[test] fn oneline_across() { - let opts = Options::getopts(&[ "--oneline", "--across" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("across", true, "oneline")) + let args = [ os("--oneline"), os("--across") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::ACROSS, true, &flags::ONE_LINE)) } #[test] fn just_header() { - let opts = Options::getopts(&[ "--header" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("header", false, "long")) + let args = [ os("--header") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::HEADER, false, &flags::LONG)) } #[test] fn just_group() { - let opts = Options::getopts(&[ "--group" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("group", false, "long")) + let args = [ os("--group") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::GROUP, false, &flags::LONG)) } #[test] fn just_inode() { - let opts = Options::getopts(&[ "--inode" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("inode", false, "long")) + let args = [ os("--inode") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::INODE, false, &flags::LONG)) } #[test] fn just_links() { - let opts = Options::getopts(&[ "--links" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("links", false, "long")) + let args = [ os("--links") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::LINKS, false, &flags::LONG)) } #[test] fn just_blocks() { - let opts = Options::getopts(&[ "--blocks" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("blocks", false, "long")) + let args = [ os("--blocks") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::BLOCKS, false, &flags::LONG)) } #[test] fn test_sort_size() { - let opts = Options::getopts(&[ "--sort=size" ]); + let args = [ os("--sort=size") ]; + let opts = Options::getopts(&args); assert_eq!(opts.unwrap().0.filter.sort_field, SortField::Size); } #[test] fn test_sort_name() { - let opts = Options::getopts(&[ "--sort=name" ]); + let args = [ os("--sort=name") ]; + let opts = Options::getopts(&args); assert_eq!(opts.unwrap().0.filter.sort_field, SortField::Name(SortCase::Sensitive)); } #[test] fn test_sort_name_lowercase() { - let opts = Options::getopts(&[ "--sort=Name" ]); + let args = [ os("--sort=Name") ]; + let opts = Options::getopts(&args); assert_eq!(opts.unwrap().0.filter.sort_field, SortField::Name(SortCase::Insensitive)); } #[test] #[cfg(feature="git")] fn just_git() { - let opts = Options::getopts(&[ "--git" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("git", false, "long")) + let args = [ os("--git") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::GIT, false, &flags::LONG)) } #[test] fn extended_without_long() { if xattr::ENABLED { - let opts = Options::getopts(&[ "--extended" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("extended", false, "long")) + let args = [ os("--extended") ]; + let opts = Options::getopts(&args); + assert_eq!(opts.unwrap_err(), Misfire::Useless(&flags::EXTENDED, false, &flags::LONG)) } } - - #[test] - fn level_without_recurse_or_tree() { - let opts = Options::getopts(&[ "--level", "69105" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless2("level", "recurse", "tree")) - } - - #[test] - fn all_all_with_tree() { - let opts = Options::getopts(&[ "--all", "--all", "--tree" ]); - assert_eq!(opts.unwrap_err(), Misfire::Useless("all --all", true, "tree")) - } - - #[test] - fn nowt() { - let nothing: Vec = Vec::new(); - let dots = Options::getopts(¬hing).unwrap().0.filter.dot_filter; - assert_eq!(dots, DotFilter::JustFiles); - } - - #[test] - fn all() { - let dots = Options::getopts(&[ "--all".to_string() ]).unwrap().0.filter.dot_filter; - assert_eq!(dots, DotFilter::Dotfiles); - } - - #[test] - fn allall() { - let dots = Options::getopts(&[ "-a".to_string(), "-a".to_string() ]).unwrap().0.filter.dot_filter; - assert_eq!(dots, DotFilter::DotfilesAndDots); - } } diff --git a/src/options/parser.rs b/src/options/parser.rs new file mode 100644 index 0000000..27aeb20 --- /dev/null +++ b/src/options/parser.rs @@ -0,0 +1,583 @@ +//! A general parser for command-line options. +//! +//! exa uses its own hand-rolled parser for command-line options. It supports +//! the following syntax: +//! +//! - Long options: `--inode`, `--grid` +//! - Long options with values: `--sort size`, `--level=4` +//! - Short options: `-i`, `-G` +//! - Short options with values: `-ssize`, `-L=4` +//! +//! These values can be mixed and matched: `exa -lssize --grid`. If you’ve used +//! other command-line programs, then hopefully it’ll work much like them. +//! +//! Because exa already has its own files for the help text, shell completions, +//! man page, and readme, so it can get away with having the options parser do +//! very little: all it really needs to do is parse a slice of strings. +//! +//! +//! ## UTF-8 and `OsStr` +//! +//! The parser uses `OsStr` as its string type. This is necessary for exa to +//! list files that have invalid UTF-8 in their names: by treating file paths +//! as bytes with no encoding, a file can be specified on the command-line and +//! be looked up without having to be encoded into a `str` first. +//! +//! It also avoids the overhead of checking for invalid UTF-8 when parsing +//! command-line options, as all the options and their values (such as +//! `--sort size`) are guaranteed to just be 8-bit ASCII. + + +use std::ffi::{OsStr, OsString}; +use std::fmt; + + +/// A **short argument** is a single ASCII character. +pub type ShortArg = u8; + +/// A **long argument** is a string. This can be a UTF-8 string, even though +/// the arguments will all be unchecked OsStrings, because we don’t actually +/// store the user’s input after it’s been matched to a flag, we just store +/// which flag it was. +pub type LongArg = &'static str; + +/// A **flag** is either of the two argument types, because they have to +/// be in the same array together. +#[derive(PartialEq, Debug, Clone)] +pub enum Flag { + Short(ShortArg), + Long(LongArg), +} + +impl Flag { + fn matches(&self, arg: &Arg) -> bool { + match *self { + Flag::Short(short) => arg.short == Some(short), + Flag::Long(long) => arg.long == long, + } + } +} + + +/// Whether redundant arguments should be considered a problem. +#[derive(PartialEq, Debug)] +#[allow(dead_code)] // until strict mode is actually implemented +pub enum Strictness { + + /// Throw an error when an argument doesn’t do anything, either because + /// it requires another argument to be specified, or because two conflict. + ComplainAboutRedundantArguments, + + /// Search the arguments list back-to-front, giving ones specified later + /// in the list priority over earlier ones. + UseLastArguments, +} + +/// Whether a flag takes a value. This is applicable to both long and short +/// arguments. +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum TakesValue { + + /// This flag has to be followed by a value. + Necessary, + + /// This flag will throw an error if there’s a value after it. + Forbidden, +} + + +/// An **argument** can be matched by one of the user’s input strings. +#[derive(PartialEq, Debug)] +pub struct Arg { + + /// The short argument that matches it, if any. + pub short: Option, + + /// The long argument that matches it. This is non-optional; all flags + /// should at least have a descriptive long name. + pub long: LongArg, + + /// Whether this flag takes a value or not. + pub takes_value: TakesValue, +} + +impl fmt::Display for Arg { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + write!(f, "--{}", self.long)?; + + if let Some(short) = self.short { + write!(f, " (-{})", short as char)?; + } + + Ok(()) + } +} + + +/// Literally just several args. +#[derive(PartialEq, Debug)] +pub struct Args(pub &'static [&'static Arg]); + +impl Args { + + /// Iterates over the given list of command-line arguments and parses + /// them into a list of matched flags and free strings. + pub fn parse<'args, I>(&self, inputs: I) -> Result, ParseError> + where I: IntoIterator { + use std::os::unix::ffi::OsStrExt; + use self::TakesValue::*; + + let mut parsing = true; + + // The results that get built up. + let mut result_flags = Vec::new(); + let mut frees: Vec<&OsStr> = Vec::new(); + + // Iterate over the inputs with “while let” because we need to advance + // the iterator manually whenever an argument that takes a value + // doesn’t have one in its string so it needs the next one. + let mut inputs = inputs.into_iter(); + while let Some(arg) = inputs.next() { + let bytes = arg.as_bytes(); + + // Stop parsing if one of the arguments is the literal string “--”. + // This allows a file named “--arg” to be specified by passing in + // the pair “-- --arg”, without it getting matched as a flag that + // doesn’t exist. + if !parsing { + frees.push(arg) + } + else if arg == "--" { + parsing = false; + } + + // If the string starts with *two* dashes then it’s a long argument. + else if bytes.starts_with(b"--") { + let long_arg_name = OsStr::from_bytes(&bytes[2..]); + + // If there’s an equals in it, then the string before the + // equals will be the flag’s name, and the string after it + // will be its value. + if let Some((before, after)) = split_on_equals(long_arg_name) { + let arg = self.lookup_long(before)?; + let flag = Flag::Long(arg.long); + match arg.takes_value { + Necessary => result_flags.push((flag, Some(after))), + Forbidden => return Err(ParseError::ForbiddenValue { flag }) + } + } + + // If there’s no equals, then the entire string (apart from + // the dashes) is the argument name. + else { + let arg = self.lookup_long(long_arg_name)?; + let flag = Flag::Long(arg.long); + match arg.takes_value { + Forbidden => result_flags.push((flag, None)), + Necessary => { + if let Some(next_arg) = inputs.next() { + result_flags.push((flag, Some(next_arg))); + } + else { + return Err(ParseError::NeedsValue { flag }) + } + } + } + } + } + + // If the string starts with *one* dash then it’s one or more + // short arguments. + else if bytes.starts_with(b"-") && arg != "-" { + let short_arg = OsStr::from_bytes(&bytes[1..]); + + // If there’s an equals in it, then the argument immediately + // before the equals was the one that has the value, with the + // others (if any) as value-less short ones. + // + // -x=abc => ‘x=abc’ + // -abcdx=fgh => ‘a’, ‘b’, ‘c’, ‘d’, ‘x=fgh’ + // -x= => error + // -abcdx= => error + // + // There’s no way to give two values in a cluster like this: + // it's an error if any of the first set of arguments actually + // takes a value. + if let Some((before, after)) = split_on_equals(short_arg) { + let (arg_with_value, other_args) = before.as_bytes().split_last().unwrap(); + + // Process the characters immediately following the dash... + for byte in other_args { + let arg = self.lookup_short(*byte)?; + let flag = Flag::Short(*byte); + match arg.takes_value { + Forbidden => result_flags.push((flag, None)), + Necessary => return Err(ParseError::NeedsValue { flag }) + } + } + + // ...then the last one and the value after the equals. + let arg = self.lookup_short(*arg_with_value)?; + let flag = Flag::Short(arg.short.unwrap()); + match arg.takes_value { + Necessary => result_flags.push((flag, Some(after))), + Forbidden => return Err(ParseError::ForbiddenValue { flag }) + } + } + + // If there’s no equals, then every character is parsed as + // its own short argument. However, if any of the arguments + // takes a value, then the *rest* of the string is used as + // its value, and if there's no rest of the string, then it + // uses the next one in the iterator. + // + // -a => ‘a’ + // -abc => ‘a’, ‘b’, ‘c’ + // -abxdef => ‘a’, ‘b’, ‘x=def’ + // -abx def => ‘a’, ‘b’, ‘x=def’ + // -abx => error + // + else { + for (index, byte) in bytes.into_iter().enumerate().skip(1) { + let arg = self.lookup_short(*byte)?; + let flag = Flag::Short(*byte); + match arg.takes_value { + Forbidden => result_flags.push((flag, None)), + Necessary => { + if index < bytes.len() - 1 { + let remnants = &bytes[index+1 ..]; + result_flags.push((flag, Some(OsStr::from_bytes(remnants)))); + break; + } + else if let Some(next_arg) = inputs.next() { + result_flags.push((flag, Some(next_arg))); + } + else { + return Err(ParseError::NeedsValue { flag }) + } + } + } + } + } + } + + // Otherwise, it’s a free string, usually a file name. + else { + frees.push(arg) + } + } + + Ok(Matches { frees, flags: MatchedFlags { flags: result_flags } }) + } + + fn lookup_short<'a>(&self, short: ShortArg) -> Result<&Arg, ParseError> { + match self.0.into_iter().find(|arg| arg.short == Some(short)) { + Some(arg) => Ok(arg), + None => Err(ParseError::UnknownShortArgument { attempt: short }) + } + } + + fn lookup_long<'a>(&self, long: &'a OsStr) -> Result<&Arg, ParseError> { + match self.0.into_iter().find(|arg| arg.long == long) { + Some(arg) => Ok(arg), + None => Err(ParseError::UnknownArgument { attempt: long.to_os_string() }) + } + } +} + + +/// The **matches** are the result of parsing the user’s command-line strings. +#[derive(PartialEq, Debug)] +pub struct Matches<'args> { + + /// The flags that were parsed from the user’s input. + pub flags: MatchedFlags<'args>, + + /// All the strings that weren’t matched as arguments, as well as anything + /// after the special "--" string. + pub frees: Vec<&'args OsStr>, +} + +#[derive(PartialEq, Debug)] +pub struct MatchedFlags<'args> { + + /// The individual flags from the user’s input, in the order they were + /// originally given. + /// + /// Long and short arguments need to be kept in the same vector because + /// we usually want the one nearest the end to count, and to know this, + /// we need to know where they are in relation to one another. + flags: Vec<(Flag, Option<&'args OsStr>)>, +} + +impl<'a> MatchedFlags<'a> { + + /// Whether the given argument was specified. + pub fn has(&self, arg: &Arg) -> bool { + self.flags.iter().rev() + .find(|tuple| tuple.1.is_none() && tuple.0.matches(arg)) + .is_some() + } + + /// If the given argument was specified, return its value. + /// The value is not guaranteed to be valid UTF-8. + pub fn get(&self, arg: &Arg) -> Option<&OsStr> { + self.flags.iter().rev() + .find(|tuple| tuple.1.is_some() && tuple.0.matches(arg)) + .map(|tuple| tuple.1.unwrap()) + } + + // It’s annoying that ‘has’ and ‘get’ won’t work when accidentally given + // flags that do/don’t take values, but this should be caught by tests. + + /// Counts the number of occurrences of the given argument. + pub fn count(&self, arg: &Arg) -> usize { + self.flags.iter() + .filter(|tuple| tuple.0.matches(arg)) + .count() + } +} + + +/// A problem with the user's input that meant it couldn't be parsed into a +/// coherent list of arguments. +#[derive(PartialEq, Debug)] +pub enum ParseError { + + /// A flag that has to take a value was not given one. + NeedsValue { flag: Flag }, + + /// A flag that can't take a value *was* given one. + ForbiddenValue { flag: Flag }, + + /// A short argument, either alone or in a cluster, was not + /// recognised by the program. + UnknownShortArgument { attempt: ShortArg }, + + /// A long argument was not recognised by the program. + /// We don’t have a known &str version of the flag, so + /// this may not be valid UTF-8. + UnknownArgument { attempt: OsString }, +} + +// It’s technically possible for ParseError::UnknownArgument to borrow its +// OsStr rather than owning it, but that would give ParseError a lifetime, +// which would give Misfire a lifetime, which gets used everywhere. And this +// only happens when an error occurs, so it’s not really worth it. + + +/// Splits a string on its `=` character, returning the two substrings on +/// either side. Returns `None` if there’s no equals or a string is missing. +fn split_on_equals(input: &OsStr) -> Option<(&OsStr, &OsStr)> { + use std::os::unix::ffi::OsStrExt; + + if let Some(index) = input.as_bytes().iter().position(|elem| *elem == b'=') { + let (before, after) = input.as_bytes().split_at(index); + + // The after string contains the = that we need to remove. + if before.len() >= 1 && after.len() >= 2 { + return Some((OsStr::from_bytes(before), + OsStr::from_bytes(&after[1..]))) + } + } + + None +} + + +/// Creates an `OSString` (used in tests) +#[cfg(test)] +fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os +} + + +#[cfg(test)] +mod split_test { + use super::{split_on_equals, os}; + + macro_rules! test_split { + ($name:ident: $input:expr => None) => { + #[test] + fn $name() { + assert_eq!(split_on_equals(&os($input)), + None); + } + }; + + ($name:ident: $input:expr => $before:expr, $after:expr) => { + #[test] + fn $name() { + assert_eq!(split_on_equals(&os($input)), + Some((&*os($before), &*os($after)))); + } + }; + } + + test_split!(empty: "" => None); + test_split!(letter: "a" => None); + + test_split!(just: "=" => None); + test_split!(intro: "=bbb" => None); + test_split!(denou: "aaa=" => None); + test_split!(equals: "aaa=bbb" => "aaa", "bbb"); + + test_split!(sort: "--sort=size" => "--sort", "size"); + test_split!(more: "this=that=other" => "this", "that=other"); +} + + +#[cfg(test)] +mod parse_test { + use super::*; + + macro_rules! test { + ($name:ident: $inputs:expr => frees: $frees:expr, flags: $flags:expr) => { + #[test] + fn $name() { + + // Annoyingly the input &strs need to be converted to OsStrings + let inputs: Vec = $inputs.as_ref().into_iter().map(|&o| os(o)).collect(); + + // Same with the frees + let frees: Vec = $frees.as_ref().into_iter().map(|&o| os(o)).collect(); + let frees: Vec<&OsStr> = frees.iter().map(|os| os.as_os_str()).collect(); + + // And again for the flags + let flags: Vec<(Flag, Option<&OsStr>)> = $flags + .as_ref() + .into_iter() + .map(|&(ref f, ref os): &(Flag, Option<&'static str>)| (f.clone(), os.map(OsStr::new))) + .collect(); + + let got = Args(TEST_ARGS).parse(inputs.iter()); + let expected = Ok(Matches { frees, flags: MatchedFlags { flags } }); + assert_eq!(got, expected); + } + }; + + ($name:ident: $inputs:expr => error $error:expr) => { + #[test] + fn $name() { + use self::ParseError::*; + + let bits = $inputs.as_ref().into_iter().map(|&o| os(o)).collect::>(); + let got = Args(TEST_ARGS).parse(bits.iter()); + + assert_eq!(got, Err($error)); + } + }; + } + + static TEST_ARGS: &[&Arg] = &[ + &Arg { short: Some(b'l'), long: "long", takes_value: TakesValue::Forbidden }, + &Arg { short: Some(b'v'), long: "verbose", takes_value: TakesValue::Forbidden }, + &Arg { short: Some(b'c'), long: "count", takes_value: TakesValue::Necessary } + ]; + + + // Just filenames + test!(empty: [] => frees: [], flags: []); + test!(one_arg: ["exa"] => frees: [ "exa" ], flags: []); + + // Dashes and double dashes + test!(one_dash: ["-"] => frees: [ "-" ], flags: []); + test!(two_dashes: ["--"] => frees: [], flags: []); + test!(two_file: ["--", "file"] => frees: [ "file" ], flags: []); + test!(two_arg_l: ["--", "--long"] => frees: [ "--long" ], flags: []); + test!(two_arg_s: ["--", "-l"] => frees: [ "-l" ], flags: []); + + + // Long args + test!(long: ["--long"] => frees: [], flags: [ (Flag::Long("long"), None) ]); + test!(long_then: ["--long", "4"] => frees: [ "4" ], flags: [ (Flag::Long("long"), None) ]); + test!(long_two: ["--long", "--verbose"] => frees: [], flags: [ (Flag::Long("long"), None), (Flag::Long("verbose"), None) ]); + + // Long args with values + test!(bad_equals: ["--long=equals"] => error ForbiddenValue { flag: Flag::Long("long") }); + test!(no_arg: ["--count"] => error NeedsValue { flag: Flag::Long("count") }); + test!(arg_equals: ["--count=4"] => frees: [], flags: [ (Flag::Long("count"), Some("4")) ]); + test!(arg_then: ["--count", "4"] => frees: [], flags: [ (Flag::Long("count"), Some("4")) ]); + + + // Short args + test!(short: ["-l"] => frees: [], flags: [ (Flag::Short(b'l'), None) ]); + test!(short_then: ["-l", "4"] => frees: [ "4" ], flags: [ (Flag::Short(b'l'), None) ]); + test!(short_two: ["-lv"] => frees: [], flags: [ (Flag::Short(b'l'), None), (Flag::Short(b'v'), None) ]); + test!(mixed: ["-v", "--long"] => frees: [], flags: [ (Flag::Short(b'v'), None), (Flag::Long("long"), None) ]); + + // Short args with values + test!(bad_short: ["-l=equals"] => error ForbiddenValue { flag: Flag::Short(b'l') }); + test!(short_none: ["-c"] => error NeedsValue { flag: Flag::Short(b'c') }); + test!(short_arg_eq: ["-c=4"] => frees: [], flags: [(Flag::Short(b'c'), Some("4")) ]); + test!(short_arg_then: ["-c", "4"] => frees: [], flags: [(Flag::Short(b'c'), Some("4")) ]); + test!(short_two_together: ["-lctwo"] => frees: [], flags: [(Flag::Short(b'l'), None), (Flag::Short(b'c'), Some("two")) ]); + test!(short_two_equals: ["-lc=two"] => frees: [], flags: [(Flag::Short(b'l'), None), (Flag::Short(b'c'), Some("two")) ]); + test!(short_two_next: ["-lc", "two"] => frees: [], flags: [(Flag::Short(b'l'), None), (Flag::Short(b'c'), Some("two")) ]); + + + // Unknown args + test!(unknown_long: ["--quiet"] => error UnknownArgument { attempt: os("quiet") }); + test!(unknown_long_eq: ["--quiet=shhh"] => error UnknownArgument { attempt: os("quiet") }); + test!(unknown_short: ["-q"] => error UnknownShortArgument { attempt: b'q' }); + test!(unknown_short_2nd: ["-lq"] => error UnknownShortArgument { attempt: b'q' }); + test!(unknown_short_eq: ["-q=shhh"] => error UnknownShortArgument { attempt: b'q' }); + test!(unknown_short_2nd_eq: ["-lq=shhh"] => error UnknownShortArgument { attempt: b'q' }); +} + + +#[cfg(test)] +mod matches_test { + use super::*; + + macro_rules! test { + ($name:ident: $input:expr, has $param:expr => $result:expr) => { + #[test] + fn $name() { + let flags = MatchedFlags { flags: $input.to_vec() }; + assert_eq!(flags.has(&$param), $result); + } + }; + } + + static VERBOSE: Arg = Arg { short: Some(b'v'), long: "verbose", takes_value: TakesValue::Forbidden }; + static COUNT: Arg = Arg { short: Some(b'c'), long: "count", takes_value: TakesValue::Necessary }; + + + test!(short_never: [], has VERBOSE => false); + test!(short_once: [(Flag::Short(b'v'), None)], has VERBOSE => true); + test!(short_twice: [(Flag::Short(b'v'), None), (Flag::Short(b'v'), None)], has VERBOSE => true); + test!(long_once: [(Flag::Long("verbose"), None)], has VERBOSE => true); + test!(long_twice: [(Flag::Long("verbose"), None), (Flag::Long("verbose"), None)], has VERBOSE => true); + test!(long_mixed: [(Flag::Long("verbose"), None), (Flag::Short(b'v'), None)], has VERBOSE => true); + + + #[test] + fn only_count() { + let everything = os("everything"); + let flags = MatchedFlags { flags: vec![ (Flag::Short(b'c'), Some(&*everything)) ] }; + assert_eq!(flags.get(&COUNT), Some(&*everything)); + } + + #[test] + fn rightmost_count() { + let everything = os("everything"); + let nothing = os("nothing"); + + let flags = MatchedFlags { + flags: vec![ (Flag::Short(b'c'), Some(&*everything)), + (Flag::Short(b'c'), Some(&*nothing)) ] + }; + + assert_eq!(flags.get(&COUNT), Some(&*nothing)); + } + + #[test] + fn no_count() { + let flags = MatchedFlags { flags: Vec::new() }; + + assert!(!flags.has(&COUNT)); + } +} diff --git a/src/options/version.rs b/src/options/version.rs new file mode 100644 index 0000000..79197f1 --- /dev/null +++ b/src/options/version.rs @@ -0,0 +1,58 @@ +use std::fmt; + +use options::flags; +use options::parser::MatchedFlags; + + +/// All the information needed to display the version information. +#[derive(PartialEq, Debug)] +pub struct VersionString { + + /// The version number from cargo. + cargo: &'static str, +} + +impl VersionString { + + /// Determines how to show the version, if at all, based on the user’s + /// command-line arguments. This one works backwards from the other + /// ‘deduce’ functions, returning Err if help needs to be shown. + pub fn deduce(matches: &MatchedFlags) -> Result<(), VersionString> { + if matches.has(&flags::VERSION) { + Err(VersionString { cargo: env!("CARGO_PKG_VERSION") }) + } + else { + Ok(()) // no version needs to be shown + } + } +} + +impl fmt::Display for VersionString { + + /// Format this help options into an actual string of help + /// text to be displayed to the user. + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + write!(f, "exa v{}", self.cargo) + } +} + + + +#[cfg(test)] +mod test { + use options::Options; + use std::ffi::OsString; + + fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os + } + + #[test] + fn help() { + let args = [ os("--version") ]; + let opts = Options::getopts(&args); + assert!(opts.is_err()) + } +} diff --git a/src/options/view.rs b/src/options/view.rs index fda555c..1487fdc 100644 --- a/src/options/view.rs +++ b/src/options/view.rs @@ -1,28 +1,22 @@ use std::env::var_os; -use getopts; - -use info::filetype::FileExtensions; use output::Colours; -use output::{grid, details}; +use output::{View, Mode, grid, details}; use output::table::{TimeTypes, Environment, SizeFormat, Options as TableOptions}; use output::file_name::{Classify, FileStyle}; use output::time::TimeFormat; -use options::Misfire; -use fs::feature::xattr; -/// The **view** contains all information about how to format output. -#[derive(Debug)] -pub struct View { - pub mode: Mode, - pub colours: Colours, - pub style: FileStyle, -} +use options::{flags, Misfire}; +use options::parser::MatchedFlags; + +use fs::feature::xattr; +use info::filetype::FileExtensions; + impl View { /// Determine which view to use and all of that view’s arguments. - pub fn deduce(matches: &getopts::Matches) -> Result { + pub fn deduce(matches: &MatchedFlags) -> Result { let mode = Mode::deduce(matches)?; let colours = Colours::deduce(matches)?; let style = FileStyle::deduce(matches); @@ -31,52 +25,44 @@ impl View { } -/// The **mode** is the “type” of output. -#[derive(Debug)] -pub enum Mode { - Grid(grid::Options), - Details(details::Options), - GridDetails(grid::Options, details::Options), - Lines, -} - impl Mode { /// Determine the mode from the command-line arguments. - pub fn deduce(matches: &getopts::Matches) -> Result { + pub fn deduce(matches: &MatchedFlags) -> Result { use options::misfire::Misfire::*; let long = || { - if matches.opt_present("across") && !matches.opt_present("grid") { - Err(Useless("across", true, "long")) + if matches.has(&flags::ACROSS) && !matches.has(&flags::GRID) { + Err(Useless(&flags::ACROSS, true, &flags::LONG)) } - else if matches.opt_present("oneline") { - Err(Useless("oneline", true, "long")) + else if matches.has(&flags::ONE_LINE) { + Err(Useless(&flags::ONE_LINE, true, &flags::LONG)) } else { Ok(details::Options { table: Some(TableOptions::deduce(matches)?), - header: matches.opt_present("header"), - xattr: xattr::ENABLED && matches.opt_present("extended"), + header: matches.has(&flags::HEADER), + xattr: xattr::ENABLED && matches.has(&flags::EXTENDED), }) } }; let long_options_scan = || { - for option in &[ "binary", "bytes", "inode", "links", "header", "blocks", "time", "group" ] { - if matches.opt_present(option) { - return Err(Useless(option, false, "long")); + for option in &[ &flags::BINARY, &flags::BYTES, &flags::INODE, &flags::LINKS, + &flags::HEADER, &flags::BLOCKS, &flags::TIME, &flags::GROUP ] { + if matches.has(option) { + return Err(Useless(*option, false, &flags::LONG)); } } - if cfg!(feature="git") && matches.opt_present("git") { - Err(Useless("git", false, "long")) + if cfg!(feature="git") && matches.has(&flags::GIT) { + Err(Useless(&flags::GIT, false, &flags::LONG)) } - else if matches.opt_present("level") && !matches.opt_present("recurse") && !matches.opt_present("tree") { - Err(Useless2("level", "recurse", "tree")) + else if matches.has(&flags::LEVEL) && !matches.has(&flags::RECURSE) && !matches.has(&flags::TREE) { + Err(Useless2(&flags::LEVEL, &flags::RECURSE, &flags::TREE)) } - else if xattr::ENABLED && matches.opt_present("extended") { - Err(Useless("extended", false, "long")) + else if xattr::ENABLED && matches.has(&flags::EXTENDED) { + Err(Useless(&flags::EXTENDED, false, &flags::LONG)) } else { Ok(()) @@ -85,15 +71,15 @@ impl Mode { let other_options_scan = || { if let Some(width) = TerminalWidth::deduce()?.width() { - if matches.opt_present("oneline") { - if matches.opt_present("across") { - Err(Useless("across", true, "oneline")) + if matches.has(&flags::ONE_LINE) { + if matches.has(&flags::ACROSS) { + Err(Useless(&flags::ACROSS, true, &flags::ONE_LINE)) } else { Ok(Mode::Lines) } } - else if matches.opt_present("tree") { + else if matches.has(&flags::TREE) { let details = details::Options { table: None, header: false, @@ -104,7 +90,7 @@ impl Mode { } else { let grid = grid::Options { - across: matches.opt_present("across"), + across: matches.has(&flags::ACROSS), console_width: width, }; @@ -116,7 +102,7 @@ impl Mode { // as the program’s stdout being connected to a file, then // fallback to the lines view. - if matches.opt_present("tree") { + if matches.has(&flags::TREE) { let details = details::Options { table: None, header: false, @@ -131,9 +117,9 @@ impl Mode { } }; - if matches.opt_present("long") { + if matches.has(&flags::LONG) { let details = long()?; - if matches.opt_present("grid") { + if matches.has(&flags::GRID) { match other_options_scan()? { Mode::Grid(grid) => return Ok(Mode::GridDetails(grid, details)), others => return Ok(others), @@ -196,17 +182,17 @@ impl TerminalWidth { impl TableOptions { - fn deduce(matches: &getopts::Matches) -> Result { + fn deduce(matches: &MatchedFlags) -> Result { Ok(TableOptions { env: Environment::load_all(), time_format: TimeFormat::deduce(matches)?, size_format: SizeFormat::deduce(matches)?, time_types: TimeTypes::deduce(matches)?, - inode: matches.opt_present("inode"), - links: matches.opt_present("links"), - blocks: matches.opt_present("blocks"), - group: matches.opt_present("group"), - git: cfg!(feature="git") && matches.opt_present("git"), + inode: matches.has(&flags::INODE), + links: matches.has(&flags::LINKS), + blocks: matches.has(&flags::BLOCKS), + group: matches.has(&flags::GROUP), + git: cfg!(feature="git") && matches.has(&flags::GIT), }) } } @@ -222,12 +208,12 @@ impl SizeFormat { /// strings of digits in your head. Changing the format to anything else /// involves the `--binary` or `--bytes` flags, and these conflict with /// each other. - fn deduce(matches: &getopts::Matches) -> Result { - let binary = matches.opt_present("binary"); - let bytes = matches.opt_present("bytes"); + fn deduce(matches: &MatchedFlags) -> Result { + let binary = matches.has(&flags::BINARY); + let bytes = matches.has(&flags::BYTES); match (binary, bytes) { - (true, true ) => Err(Misfire::Conflict("binary", "bytes")), + (true, true ) => Err(Misfire::Conflict(&flags::BINARY, &flags::BYTES)), (true, false) => Ok(SizeFormat::BinaryBytes), (false, true ) => Ok(SizeFormat::JustBytes), (false, false) => Ok(SizeFormat::DecimalBytes), @@ -239,26 +225,36 @@ impl SizeFormat { impl TimeFormat { /// Determine how time should be formatted in timestamp columns. - fn deduce(matches: &getopts::Matches) -> Result { + fn deduce(matches: &MatchedFlags) -> Result { pub use output::time::{DefaultFormat, ISOFormat}; const STYLES: &[&str] = &["default", "long-iso", "full-iso", "iso"]; - if let Some(word) = matches.opt_str("time-style") { - match &*word { - "default" => Ok(TimeFormat::DefaultFormat(DefaultFormat::new())), - "iso" => Ok(TimeFormat::ISOFormat(ISOFormat::new())), - "long-iso" => Ok(TimeFormat::LongISO), - "full-iso" => Ok(TimeFormat::FullISO), - otherwise => Err(Misfire::bad_argument("time-style", otherwise, STYLES)), - } + let word = match matches.get(&flags::TIME_STYLE) { + Some(w) => w, + None => return Ok(TimeFormat::DefaultFormat(DefaultFormat::new())), + }; + + if word == "default" { + Ok(TimeFormat::DefaultFormat(DefaultFormat::new())) + } + else if word == "iso" { + Ok(TimeFormat::ISOFormat(ISOFormat::new())) + } + else if word == "long-iso" { + Ok(TimeFormat::LongISO) + } + else if word == "full-iso" { + Ok(TimeFormat::FullISO) } else { - Ok(TimeFormat::DefaultFormat(DefaultFormat::new())) + Err(Misfire::bad_argument(&flags::TIME_STYLE, word, STYLES)) } } } +static TIMES: &[&str] = &["modified", "accessed", "created"]; + impl TimeTypes { /// Determine which of a file’s time fields should be displayed for it @@ -271,29 +267,33 @@ impl TimeTypes { /// It’s valid to show more than one column by passing in more than one /// option, but passing *no* options means that the user just wants to /// see the default set. - fn deduce(matches: &getopts::Matches) -> Result { - let possible_word = matches.opt_str("time"); - let modified = matches.opt_present("modified"); - let created = matches.opt_present("created"); - let accessed = matches.opt_present("accessed"); + fn deduce(matches: &MatchedFlags) -> Result { + let possible_word = matches.get(&flags::TIME); + let modified = matches.has(&flags::MODIFIED); + let created = matches.has(&flags::CREATED); + let accessed = matches.has(&flags::ACCESSED); if let Some(word) = possible_word { if modified { - return Err(Misfire::Useless("modified", true, "time")); + Err(Misfire::Useless(&flags::MODIFIED, true, &flags::TIME)) } else if created { - return Err(Misfire::Useless("created", true, "time")); + Err(Misfire::Useless(&flags::CREATED, true, &flags::TIME)) } else if accessed { - return Err(Misfire::Useless("accessed", true, "time")); + Err(Misfire::Useless(&flags::ACCESSED, true, &flags::TIME)) } - - static TIMES: &[& str] = &["modified", "accessed", "created"]; - match &*word { - "mod" | "modified" => Ok(TimeTypes { accessed: false, modified: true, created: false }), - "acc" | "accessed" => Ok(TimeTypes { accessed: true, modified: false, created: false }), - "cr" | "created" => Ok(TimeTypes { accessed: false, modified: false, created: true }), - otherwise => Err(Misfire::bad_argument("time", otherwise, TIMES)) + else if word == "mod" || word == "modified" { + Ok(TimeTypes { accessed: false, modified: true, created: false }) + } + else if word == "acc" || word == "accessed" { + Ok(TimeTypes { accessed: true, modified: false, created: false }) + } + else if word == "cr" || word == "created" { + Ok(TimeTypes { accessed: false, modified: false, created: true }) + } + else { + Err(Misfire::bad_argument(&flags::TIME, word, TIMES)) } } else if modified || created || accessed { @@ -335,31 +335,37 @@ impl Default for TerminalColours { impl TerminalColours { /// Determine which terminal colour conditions to use. - fn deduce(matches: &getopts::Matches) -> Result { + fn deduce(matches: &MatchedFlags) -> Result { const COLOURS: &[&str] = &["always", "auto", "never"]; - if let Some(word) = matches.opt_str("color").or_else(|| matches.opt_str("colour")) { - match &*word { - "always" => Ok(TerminalColours::Always), - "auto" | "automatic" => Ok(TerminalColours::Automatic), - "never" => Ok(TerminalColours::Never), - otherwise => Err(Misfire::bad_argument("color", otherwise, COLOURS)) - } + let word = match matches.get(&flags::COLOR).or_else(|| matches.get(&flags::COLOUR)) { + Some(w) => w, + None => return Ok(TerminalColours::default()), + }; + + if word == "always" { + Ok(TerminalColours::Always) + } + else if word == "auto" || word == "automatic" { + Ok(TerminalColours::Automatic) + } + else if word == "never" { + Ok(TerminalColours::Never) } else { - Ok(TerminalColours::default()) + Err(Misfire::bad_argument(&flags::COLOR, word, COLOURS)) } } } impl Colours { - fn deduce(matches: &getopts::Matches) -> Result { + fn deduce(matches: &MatchedFlags) -> Result { use self::TerminalColours::*; let tc = TerminalColours::deduce(matches)?; if tc == Always || (tc == Automatic && TERM_WIDTH.is_some()) { - let scale = matches.opt_present("color-scale") || matches.opt_present("colour-scale"); + let scale = matches.has(&flags::COLOR_SCALE) || matches.has(&flags::COLOUR_SCALE); Ok(Colours::colourful(scale)) } else { @@ -371,18 +377,17 @@ impl Colours { impl FileStyle { - fn deduce(matches: &getopts::Matches) -> FileStyle { + fn deduce(matches: &MatchedFlags) -> FileStyle { let classify = Classify::deduce(matches); let exts = FileExtensions; FileStyle { classify, exts } } - } impl Classify { - fn deduce(matches: &getopts::Matches) -> Classify { - if matches.opt_present("classify") { Classify::AddFileIndicators } - else { Classify::JustFilenames } + fn deduce(matches: &MatchedFlags) -> Classify { + if matches.has(&flags::CLASSIFY) { Classify::AddFileIndicators } + else { Classify::JustFilenames } } } @@ -399,3 +404,77 @@ lazy_static! { dimensions_stdout().map(|t| t.0) }; } + + + +#[cfg(test)] +mod test { + use super::*; + use std::ffi::OsString; + use options::flags; + + pub fn os(input: &'static str) -> OsString { + let mut os = OsString::new(); + os.push(input); + os + } + + macro_rules! test { + ($name:ident: $type:ident <- $inputs:expr => $result:expr) => { + #[test] + fn $name() { + use options::parser::{Args, Arg}; + use std::ffi::OsString; + + static TEST_ARGS: &[&Arg] = &[ &flags::BINARY, &flags::BYTES, + &flags::TIME, &flags::MODIFIED, &flags::CREATED, &flags::ACCESSED ]; + + let bits = $inputs.as_ref().into_iter().map(|&o| os(o)).collect::>(); + let results = Args(TEST_ARGS).parse(bits.iter()); + assert_eq!($type::deduce(&results.unwrap().flags), $result); + } + }; + } + + + mod size_formats { + use super::*; + + test!(empty: SizeFormat <- [] => Ok(SizeFormat::DecimalBytes)); + test!(binary: SizeFormat <- ["--binary"] => Ok(SizeFormat::BinaryBytes)); + test!(bytes: SizeFormat <- ["--bytes"] => Ok(SizeFormat::JustBytes)); + test!(both: SizeFormat <- ["--binary", "--bytes"] => Err(Misfire::Conflict(&flags::BINARY, &flags::BYTES))); + } + + + mod time_types { + use super::*; + + // Default behaviour + test!(empty: TimeTypes <- [] => Ok(TimeTypes::default())); + test!(modified: TimeTypes <- ["--modified"] => Ok(TimeTypes { accessed: false, modified: true, created: false })); + test!(m: TimeTypes <- ["-m"] => Ok(TimeTypes { accessed: false, modified: true, created: false })); + test!(time_mod: TimeTypes <- ["--time=modified"] => Ok(TimeTypes { accessed: false, modified: true, created: false })); + test!(time_m: TimeTypes <- ["-tmod"] => Ok(TimeTypes { accessed: false, modified: true, created: false })); + + test!(acc: TimeTypes <- ["--accessed"] => Ok(TimeTypes { accessed: true, modified: false, created: false })); + test!(a: TimeTypes <- ["-u"] => Ok(TimeTypes { accessed: true, modified: false, created: false })); + test!(time_acc: TimeTypes <- ["--time", "accessed"] => Ok(TimeTypes { accessed: true, modified: false, created: false })); + test!(time_a: TimeTypes <- ["-t", "acc"] => Ok(TimeTypes { accessed: true, modified: false, created: false })); + + test!(cr: TimeTypes <- ["--created"] => Ok(TimeTypes { accessed: false, modified: false, created: true })); + test!(c: TimeTypes <- ["-U"] => Ok(TimeTypes { accessed: false, modified: false, created: true })); + test!(time_cr: TimeTypes <- ["--time=created"] => Ok(TimeTypes { accessed: false, modified: false, created: true })); + test!(time_c: TimeTypes <- ["-tcr"] => Ok(TimeTypes { accessed: false, modified: false, created: true })); + + // Multiples + test!(time_uu: TimeTypes <- ["-uU"] => Ok(TimeTypes { accessed: true, modified: false, created: true })); + + // Overriding + test!(time_mc: TimeTypes <- ["-tcr", "-tmod"] => Ok(TimeTypes { accessed: false, modified: true, created: false })); + + // Errors + test!(time_tea: TimeTypes <- ["--time=tea"] => Err(Misfire::bad_argument(&flags::TIME, &os("tea"), super::TIMES))); + test!(time_ea: TimeTypes <- ["-tea"] => Err(Misfire::bad_argument(&flags::TIME, &os("ea"), super::TIMES))); + } +} diff --git a/src/output/details.rs b/src/output/details.rs index 8371a34..87768cc 100644 --- a/src/output/details.rs +++ b/src/output/details.rs @@ -65,8 +65,9 @@ use std::path::PathBuf; use std::vec::IntoIter as VecIntoIter; use fs::{Dir, File}; +use fs::dir_action::RecurseOptions; +use fs::filter::FileFilter; use fs::feature::xattr::{Attribute, FileAttributes}; -use options::{FileFilter, RecurseOptions}; use output::colours::Colours; use output::cell::TextCell; use output::tree::{TreeTrunk, TreeParams, TreeDepth}; diff --git a/src/output/grid_details.rs b/src/output/grid_details.rs index 1f1ffc6..017a5ef 100644 --- a/src/output/grid_details.rs +++ b/src/output/grid_details.rs @@ -5,8 +5,8 @@ use term_grid as grid; use fs::{Dir, File}; use fs::feature::xattr::FileAttributes; +use fs::filter::FileFilter; -use options::FileFilter; use output::cell::TextCell; use output::colours::Colours; use output::details::{Options as DetailsOptions, Row as DetailsRow, Render as DetailsRender}; diff --git a/src/output/mod.rs b/src/output/mod.rs index 60ba961..672cb4f 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,3 +1,5 @@ +use output::file_name::FileStyle; + pub use self::cell::{TextCell, TextCellContents, DisplayWidth}; pub use self::colours::Colours; pub use self::escape::escape; @@ -15,3 +17,22 @@ mod colours; mod escape; mod render; mod tree; + + +/// The **view** contains all information about how to format output. +#[derive(Debug)] +pub struct View { + pub mode: Mode, + pub colours: Colours, + pub style: FileStyle, +} + + +/// The **mode** is the “type” of output. +#[derive(Debug)] +pub enum Mode { + Grid(grid::Options), + Details(details::Options), + GridDetails(grid::Options, details::Options), + Lines, +} diff --git a/xtests/run.sh b/xtests/run.sh index 3604684..1b9d0b3 100755 --- a/xtests/run.sh +++ b/xtests/run.sh @@ -78,7 +78,7 @@ COLUMNS=80 $exa $testcases/file-names -R 2>&1 | diff -q - $results/file_names_R $exa $testcases/file-names -T 2>&1 | diff -q - $results/file_names_T || exit 1 # At least make sure it handles invalid UTF-8 arguments without crashing -$exa $testcases/file-names/* 2>/dev/null +$exa $testcases/file-names/* >/dev/null || exit 1 # Sorting and extension file types