Prechádzať zdrojové kódy

Merge remote-tracking branch origin/option-pars-ng

This merges in exa’s own new options parser, which has the following features:

- You can specify an option twice and it’ll use the second one, making aliases usable for defaults (fixes #144)
- Lets arguments be specified more than once (fixes #125)

Strict mode is not done yet; I just wanted to merge this in because it’s been a while, and there’s work that needs to be done on master so I don’t want them drifting apart any further.

It’s likely that you’ll find cases where multiple arguments doesn’t work or where the wrong value is being used. There aren’t tests for *everything* yet, and it still uses global environment variables.

# Conflicts:
#	src/options/view.rs
Benjamin Sago 8 rokov pred
rodič
commit
b5bcf22612

+ 1 - 0
Cargo.toml

@@ -16,6 +16,7 @@ license = "MIT"
 [[bin]]
 name = "exa"
 path = "src/bin/main.rs"
+doc = false
 
 [lib]
 name = "exa"

+ 2 - 2
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
 

+ 3 - 2
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<OsString> = args_os().skip(1).collect();
+    match Exa::new(args.iter(), &mut stdout()) {
         Ok(mut exa) => {
             match exa.run() {
                 Ok(exit_status) => exit(exit_status),

+ 13 - 14
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<String>,
+    pub args: Vec<&'args OsStr>,
 }
 
-impl<'w, W: Write + 'w> Exa<'w, W> {
-    pub fn new<C>(args: C, writer: &'w mut W) -> Result<Exa<'w, W>, Misfire>
-    where C: IntoIterator, C::Item: AsRef<OsStr> {
+impl<'args, 'w, W: Write + 'w> Exa<'args, 'w, W> {
+    pub fn new<I>(args: I, writer: &'w mut W) -> Result<Exa<'args, 'w, W>, Misfire>
+    where I: Iterator<Item=&'args OsString> {
         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 {

+ 64 - 0
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<RecurseOptions> {
+        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<usize>,
+}
+
+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
+            }
+        }
+    }
+}

+ 221 - 0
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<File>) {
+       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<File>) {
+       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<F>)
+   where F: AsRef<File<'a>> {
+
+       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<glob::Pattern>,
+}
+
+impl IgnorePatterns {
+    fn is_ignored(&self, file: &File) -> bool {
+        self.patterns.iter().any(|p| p.matches(&file.name))
+    }
+}

+ 2 - 0
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;

+ 68 - 67
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<DirAction, Misfire> {
-        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<DirAction, Misfire> {
+        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<RecurseOptions> {
-        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<usize>,
-}
-
 impl RecurseOptions {
 
     /// Determine which files should be recursed into.
-    pub fn deduce(matches: &getopts::Matches, tree: bool) -> Result<RecurseOptions, Misfire> {
-        let max_depth = if let Some(level) = matches.opt_str("level") {
-            match level.parse() {
+    pub fn deduce(matches: &MatchedFlags, tree: bool) -> Result<RecurseOptions, Misfire> {
+        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 })
     }
+}
+
+
+#[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;
 
-    /// 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
+                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::<Vec<OsString>>();
+                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)));
+}

+ 148 - 253
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;
-
-
-/// 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,
-}
+use fs::filter::{FileFilter, SortField, SortCase, IgnorePatterns};
+
+use options::{flags, Misfire};
+use options::parser::MatchedFlags;
+
 
 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<FileFilter, Misfire> {
+    pub fn deduce(matches: &MatchedFlags) -> Result<FileFilter, Misfire> {
         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<File>) {
-        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<File>) {
-        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<F>)
-    where F: AsRef<File<'a>> {
-
-        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<SortField, Misfire> {
-
-        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))
-            }
+    fn deduce(matches: &MatchedFlags) -> Result<SortField, Misfire> {
+        let word = match matches.get(&flags::SORT) {
+            Some(w)  => w,
+            None     => return Ok(SortField::default()),
+        };
+
+        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<DotFilter, Misfire> {
-        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<DotFilter, Misfire> {
+        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<glob::Pattern>,
-}
-
 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<IgnorePatterns, Misfire> {
-        let patterns = match matches.opt_str("ignore-glob") {
+    pub fn deduce(matches: &MatchedFlags) -> Result<IgnorePatterns, Misfire> {
+        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(),
+        }?;
+
+        // TODO: is to_string_lossy really the best way to handle
+        // invalid UTF-8 there?
+
+        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::<Vec<OsString>>();
+                let results = Args(TEST_ARGS).parse(bits.iter());
+                assert_eq!($type::deduce(&results.unwrap().flags), $result);
+            }
         };
+    }
 
-        Ok(IgnorePatterns {
-            patterns: patterns?,
-        })
+    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));
     }
 
-    fn is_ignored(&self, file: &File) -> bool {
-        self.patterns.iter().any(|p| p.matches(&file.name))
+
+    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("*") ] }));
     }
 }

+ 64 - 0
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,
+]);
+

+ 73 - 3
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
+    }
+}

+ 31 - 28
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),
         }
     }
 }

+ 147 - 168
src/options/mod.rs

@@ -1,24 +1,97 @@
-use std::ffi::OsStr;
-
-use getopts;
-
-use fs::feature::xattr;
+//! 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 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<C>(args: C) -> Result<(Options, Vec<String>), Misfire>
-    where C: IntoIterator, C::Item: AsRef<OsStr> {
-        let mut opts = getopts::Options::new();
-
-        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");
-        }
+    pub fn getopts<'args, I>(args: I) -> Result<(Options, Vec<&'args OsStr>), Misfire>
+    where I: IntoIterator<Item=&'args OsString> {
+        use options::parser::Matches;
 
-        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<Options, Misfire> {
+    fn deduce(matches: &MatchedFlags) -> Result<Options, Misfire> {
         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<T>(misfire: Result<T, Misfire>) -> 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<String> = Vec::new();
-        let args = Options::getopts(&nothing).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<OsString> = Vec::new();
+        let outs = Options::getopts(&nothing).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<String> = Vec::new();
-        let dots = Options::getopts(&nothing).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);
-    }
 }

+ 583 - 0
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<ShortArg>,
+
+    /// 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<Matches<'args>, ParseError>
+    where I: IntoIterator<Item=&'args OsString> {
+        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<OsString> = $inputs.as_ref().into_iter().map(|&o| os(o)).collect();
+
+                // Same with the frees
+                let frees: Vec<OsString> = $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::<Vec<OsString>>();
+                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));
+    }
+}

+ 58 - 0
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())
+    }
+}

+ 176 - 97
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 options::{flags, Misfire};
+use options::parser::MatchedFlags;
+
 use fs::feature::xattr;
+use info::filetype::FileExtensions;
 
-/// The **view** contains all information about how to format output.
-#[derive(Debug)]
-pub struct View {
-    pub mode: Mode,
-    pub colours: Colours,
-    pub style: FileStyle,
-}
 
 impl View {
 
     /// Determine which view to use and all of that view’s arguments.
-    pub fn deduce(matches: &getopts::Matches) -> Result<View, Misfire> {
+    pub fn deduce(matches: &MatchedFlags) -> Result<View, Misfire> {
         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<Mode, Misfire> {
+    pub fn deduce(matches: &MatchedFlags) -> Result<Mode, Misfire> {
         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<Self, Misfire> {
+    fn deduce(matches: &MatchedFlags) -> Result<Self, Misfire> {
         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<SizeFormat, Misfire> {
-        let binary = matches.opt_present("binary");
-        let bytes  = matches.opt_present("bytes");
+    fn deduce(matches: &MatchedFlags) -> Result<SizeFormat, Misfire> {
+        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<TimeFormat, Misfire> {
+    fn deduce(matches: &MatchedFlags) -> Result<TimeFormat, Misfire> {
         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<TimeTypes, Misfire> {
-        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<TimeTypes, Misfire> {
+        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<TerminalColours, Misfire> {
+    fn deduce(matches: &MatchedFlags) -> Result<TerminalColours, Misfire> {
         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<Colours, Misfire> {
+    fn deduce(matches: &MatchedFlags) -> Result<Colours, Misfire> {
         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::<Vec<OsString>>();
+                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)));
+    }
+}

+ 2 - 1
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};

+ 1 - 1
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};

+ 21 - 0
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,
+}

+ 1 - 1
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