Ver Fonte

Merge branch 'better-options'

Ben S há 10 anos atrás
pai
commit
021655faec
8 ficheiros alterados com 557 adições e 424 exclusões
  1. 0 92
      src/column.rs
  2. 12 12
      src/file.rs
  3. 8 5
      src/main.rs
  4. 284 299
      src/options.rs
  5. 231 0
      src/output/column.rs
  6. 18 15
      src/output/details.rs
  7. 2 1
      src/output/grid_details.rs
  8. 2 0
      src/output/mod.rs

+ 0 - 92
src/column.rs

@@ -1,92 +0,0 @@
-use ansi_term::Style;
-use unicode_width::UnicodeWidthStr;
-
-use options::{SizeFormat, TimeType};
-
-
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub enum Column {
-    Permissions,
-    FileSize(SizeFormat),
-    Timestamp(TimeType),
-    Blocks,
-    User,
-    Group,
-    HardLinks,
-    Inode,
-
-    GitStatus,
-}
-
-/// Each column can pick its own **Alignment**. Usually, numbers are
-/// right-aligned, and text is left-aligned.
-#[derive(Copy, Clone)]
-pub enum Alignment {
-    Left, Right,
-}
-
-impl Column {
-
-    /// Get the alignment this column should use.
-    pub fn alignment(&self) -> Alignment {
-        match *self {
-            Column::FileSize(_) => Alignment::Right,
-            Column::HardLinks   => Alignment::Right,
-            Column::Inode       => Alignment::Right,
-            Column::Blocks      => Alignment::Right,
-            Column::GitStatus   => Alignment::Right,
-            _                   => Alignment::Left,
-        }
-    }
-
-    /// Get the text that should be printed at the top, when the user elects
-    /// to have a header row printed.
-    pub fn header(&self) -> &'static str {
-        match *self {
-            Column::Permissions   => "Permissions",
-            Column::FileSize(_)   => "Size",
-            Column::Timestamp(t)  => t.header(),
-            Column::Blocks        => "Blocks",
-            Column::User          => "User",
-            Column::Group         => "Group",
-            Column::HardLinks     => "Links",
-            Column::Inode         => "inode",
-            Column::GitStatus     => "Git",
-        }
-    }
-}
-
-
-#[derive(PartialEq, Debug, Clone)]
-pub struct Cell {
-    pub length: usize,
-    pub text: String,
-}
-
-impl Cell {
-    pub fn empty() -> Cell {
-        Cell {
-            text: String::new(),
-            length: 0,
-        }
-    }
-
-    pub fn paint(style: Style, string: &str) -> Cell {
-        Cell {
-            text: style.paint(string).to_string(),
-            length: UnicodeWidthStr::width(string),
-        }
-    }
-
-    pub fn add_spaces(&mut self, count: usize) {
-        self.length += count;
-        for _ in 0 .. count {
-            self.text.push(' ');
-        }
-    }
-
-    pub fn append(&mut self, other: &Cell) {
-        self.length += other.length;
-        self.text.push_str(&*other.text);
-    }
-}

+ 12 - 12
src/file.rs

@@ -10,14 +10,13 @@ use std::path::{Component, Path, PathBuf};
 use unicode_width::UnicodeWidthStr;
 use unicode_width::UnicodeWidthStr;
 
 
 use dir::Dir;
 use dir::Dir;
-use options::TimeType;
 
 
 use self::fields as f;
 use self::fields as f;
 
 
-// Constant table copied from https://doc.rust-lang.org/src/std/sys/unix/ext/fs.rs.html#11-259
-// which is currently unstable and lacks vision for stabilization,
-// see https://github.com/rust-lang/rust/issues/27712
 
 
+/// Constant table copied from https://doc.rust-lang.org/src/std/sys/unix/ext/fs.rs.html#11-259
+/// which is currently unstable and lacks vision for stabilization,
+/// see https://github.com/rust-lang/rust/issues/27712
 #[allow(dead_code)]
 #[allow(dead_code)]
 mod modes {
 mod modes {
     use std::os::unix::raw;
     use std::os::unix::raw;
@@ -281,15 +280,16 @@ impl<'dir> File<'dir> {
         }
         }
     }
     }
 
 
-    /// One of this file's timestamps, as a number in seconds.
-    pub fn timestamp(&self, time_type: TimeType) -> f::Time {
-        let time_in_seconds = match time_type {
-            TimeType::FileAccessed => self.metadata.atime(),
-            TimeType::FileModified => self.metadata.mtime(),
-            TimeType::FileCreated  => self.metadata.ctime(),
-        };
+    pub fn modified_time(&self) -> f::Time {
+        f::Time(self.metadata.mtime())
+    }
+
+    pub fn created_time(&self) -> f::Time {
+        f::Time(self.metadata.ctime())
+    }
 
 
-        f::Time(time_in_seconds)
+    pub fn accessed_time(&self) -> f::Time {
+        f::Time(self.metadata.mtime())
     }
     }
 
 
     /// This file's 'type'.
     /// This file's 'type'.

+ 8 - 5
src/main.rs

@@ -28,7 +28,6 @@ use file::File;
 use options::{Options, View};
 use options::{Options, View};
 
 
 mod colours;
 mod colours;
-mod column;
 mod dir;
 mod dir;
 mod feature;
 mod feature;
 mod file;
 mod file;
@@ -43,10 +42,14 @@ struct Exa {
 }
 }
 
 
 impl Exa {
 impl Exa {
-    fn run(&mut self, args_file_names: &[String]) {
+    fn run(&mut self, mut args_file_names: Vec<String>) {
         let mut files = Vec::new();
         let mut files = Vec::new();
         let mut dirs = Vec::new();
         let mut dirs = Vec::new();
 
 
+        if args_file_names.is_empty() {
+            args_file_names.push(".".to_owned());
+        }
+
         for file_name in args_file_names.iter() {
         for file_name in args_file_names.iter() {
             match File::from_path(Path::new(&file_name), None) {
             match File::from_path(Path::new(&file_name), None) {
                 Err(e) => {
                 Err(e) => {
@@ -99,8 +102,8 @@ impl Exa {
                 }
                 }
             };
             };
 
 
-            self.options.filter_files(&mut children);
-            self.options.sort_files(&mut children);
+            self.options.filter.filter_files(&mut children);
+            self.options.filter.sort_files(&mut children);
 
 
             if let Some(recurse_opts) = self.options.dir_action.recurse_options() {
             if let Some(recurse_opts) = self.options.dir_action.recurse_options() {
                 let depth = dir.path.components().filter(|&c| c != Component::CurDir).count() + 1;
                 let depth = dir.path.components().filter(|&c| c != Component::CurDir).count() + 1;
@@ -146,7 +149,7 @@ fn main() {
     match Options::getopts(&args) {
     match Options::getopts(&args) {
         Ok((options, paths)) => {
         Ok((options, paths)) => {
             let mut exa = Exa { options: options };
             let mut exa = Exa { options: options };
-            exa.run(&paths);
+            exa.run(paths);
         },
         },
         Err(e) => {
         Err(e) => {
             println!("{}", e);
             println!("{}", e);

+ 284 - 299
src/options.rs

@@ -7,21 +7,26 @@ use getopts;
 use natord;
 use natord;
 
 
 use colours::Colours;
 use colours::Colours;
-use column::Column;
-use column::Column::*;
-use dir::Dir;
 use feature::xattr;
 use feature::xattr;
 use file::File;
 use file::File;
 use output::{Grid, Details, GridDetails, Lines};
 use output::{Grid, Details, GridDetails, Lines};
+use output::column::{Columns, TimeTypes, SizeFormat};
 use term::dimensions;
 use term::dimensions;
 
 
 
 
-/// The *Options* struct represents a parsed version of the user's
-/// command-line options.
+/// These **options** represent a parsed, error-checked versions of the
+/// user's command-line options.
 #[derive(PartialEq, Debug, Copy, Clone)]
 #[derive(PartialEq, Debug, Copy, Clone)]
 pub struct Options {
 pub struct Options {
+
+    /// The action to perform when encountering a directory rather than a
+    /// regular file.
     pub dir_action: DirAction,
     pub dir_action: DirAction,
+
+    /// How to sort and filter files before outputting them.
     pub filter: FileFilter,
     pub filter: FileFilter,
+
+    /// The type of output to use (lines, grid, or details).
     pub view: View,
     pub view: View,
 }
 }
 
 
@@ -31,39 +36,45 @@ impl Options {
     #[allow(unused_results)]
     #[allow(unused_results)]
     pub fn getopts(args: &[String]) -> Result<(Options, Vec<String>), Misfire> {
     pub fn getopts(args: &[String]) -> Result<(Options, Vec<String>), Misfire> {
         let mut opts = getopts::Options::new();
         let mut opts = getopts::Options::new();
+
+        opts.optflag("v", "version",   "display 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("1", "oneline",   "display one entry per line");
+        opts.optflag("G", "grid",      "display entries in a grid view (default)");
+        opts.optflag("l", "long",      "display extended details and attributes");
+        opts.optflag("R", "recurse",   "recurse into directories");
+        opts.optflag("T", "tree",      "recurse into subdirectories in a tree view");
+        opts.optflag("x", "across",    "sort multi-column view entries across");
+
+        // Filtering and sorting options
+        opts.optflag("",  "group-directories-first", "list directories before other files");
         opts.optflag("a", "all",       "show dot-files");
         opts.optflag("a", "all",       "show dot-files");
+        opts.optflag("d", "list-dirs", "list directories as regular files");
+        opts.optflag("r", "reverse",   "reverse order of files");
+        opts.optopt ("s", "sort",      "field to sort by", "WORD");
+
+        // Long view options
         opts.optflag("b", "binary",    "use binary prefixes in file sizes");
         opts.optflag("b", "binary",    "use binary prefixes in file sizes");
         opts.optflag("B", "bytes",     "list file sizes in bytes, without prefixes");
         opts.optflag("B", "bytes",     "list file sizes in bytes, without prefixes");
-        opts.optflag("d", "list-dirs", "list directories as regular files");
         opts.optflag("g", "group",     "show group as well as user");
         opts.optflag("g", "group",     "show group as well as user");
-        opts.optflag("G", "grid",      "display entries in a grid view (default)");
-        opts.optflag("",  "group-directories-first", "list directories before other files");
         opts.optflag("h", "header",    "show a header row at the top");
         opts.optflag("h", "header",    "show a header row at the top");
         opts.optflag("H", "links",     "show number of hard links");
         opts.optflag("H", "links",     "show number of hard links");
         opts.optflag("i", "inode",     "show each file's inode number");
         opts.optflag("i", "inode",     "show each file's inode number");
-        opts.optflag("l", "long",      "display extended details and attributes");
         opts.optopt ("L", "level",     "maximum depth of recursion", "DEPTH");
         opts.optopt ("L", "level",     "maximum depth of recursion", "DEPTH");
         opts.optflag("m", "modified",  "display timestamp of most recent modification");
         opts.optflag("m", "modified",  "display timestamp of most recent modification");
-        opts.optflag("r", "reverse",   "reverse order of files");
-        opts.optflag("R", "recurse",   "recurse into directories");
-        opts.optopt ("s", "sort",      "field to sort by", "WORD");
         opts.optflag("S", "blocks",    "show number of file system blocks");
         opts.optflag("S", "blocks",    "show number of file system blocks");
         opts.optopt ("t", "time",      "which timestamp to show for a file", "WORD");
         opts.optopt ("t", "time",      "which timestamp to show for a file", "WORD");
-        opts.optflag("T", "tree",      "recurse into subdirectories in a tree view");
         opts.optflag("u", "accessed",  "display timestamp of last access for a file");
         opts.optflag("u", "accessed",  "display timestamp of last access for a file");
         opts.optflag("U", "created",   "display timestamp of creation for a file");
         opts.optflag("U", "created",   "display timestamp of creation for a file");
-        opts.optflag("x", "across",    "sort multi-column view entries across");
-
-        opts.optflag("",  "version",   "display version of exa");
-        opts.optflag("?", "help",      "show list of command-line options");
 
 
         if cfg!(feature="git") {
         if cfg!(feature="git") {
             opts.optflag("", "git", "show git status");
             opts.optflag("", "git", "show git status");
         }
         }
 
 
         if xattr::ENABLED {
         if xattr::ENABLED {
-            opts.optflag("@", "extended", "display extended attribute keys and sizes in long (-l) output");
+            opts.optflag("@", "extended", "display extended attribute keys and sizes");
         }
         }
 
 
         let matches = match opts.parse(args) {
         let matches = match opts.parse(args) {
@@ -72,47 +83,32 @@ impl Options {
         };
         };
 
 
         if matches.opt_present("help") {
         if matches.opt_present("help") {
-            return Err(Misfire::Help(opts.usage("Usage:\n  exa [options] [files...]")));
-        }
-        else if matches.opt_present("version") {
-            return Err(Misfire::Version);
-        }
-
-        let sort_field = match matches.opt_str("sort") {
-            Some(word)  => try!(SortField::from_word(word)),
-            None        => SortField::default(),
-        };
+            let mut help_string = "Usage:\n  exa [options] [files...]\n".to_owned();
 
 
-        let filter = FileFilter {
-            list_dirs_first: matches.opt_present("group-directories-first"),
-            reverse:         matches.opt_present("reverse"),
-            show_invisibles: matches.opt_present("all"),
-            sort_field:      sort_field,
-        };
+            if !matches.opt_present("long") {
+                help_string.push_str(OPTIONS);
+            }
 
 
-        let path_strs = if matches.free.is_empty() {
-            vec![ ".".to_string() ]
-        }
-        else {
-            matches.free.clone()
-        };
+            help_string.push_str(LONG_OPTIONS);
 
 
-        let dir_action = try!(DirAction::deduce(&matches));
-        let view = try!(View::deduce(&matches, filter, dir_action));
+            if cfg!(feature="git") {
+                help_string.push_str(GIT_HELP);
+                help_string.push('\n');
+            }
 
 
-        Ok((Options {
-            dir_action: dir_action,
-            view:       view,
-            filter:     filter,
-        }, path_strs))
-    }
+            if xattr::ENABLED {
+                help_string.push_str(EXTENDED_HELP);
+                help_string.push('\n');
+            }
 
 
-    pub fn sort_files(&self, files: &mut Vec<File>) {
-        self.filter.sort_files(files)
-    }
+            return Err(Misfire::Help(help_string));
+        }
+        else if matches.opt_present("version") {
+            return Err(Misfire::Version);
+        }
 
 
-    pub fn filter_files(&self, files: &mut Vec<File>) {
-        self.filter.filter_files(files)
+        let options = try!(Options::deduce(&matches));
+        Ok((options, matches.free))
     }
     }
 
 
     /// Whether the View specified in this set of options includes a Git
     /// Whether the View specified in this set of options includes a Git
@@ -127,140 +123,17 @@ impl Options {
     }
     }
 }
 }
 
 
+impl OptionSet for Options {
+    fn deduce(matches: &getopts::Matches) -> Result<Options, Misfire> {
+        let dir_action = try!(DirAction::deduce(&matches));
+        let filter = try!(FileFilter::deduce(&matches));
+        let view = try!(View::deduce(&matches, filter, dir_action));
 
 
-#[derive(Default, PartialEq, Debug, Copy, Clone)]
-pub struct FileFilter {
-    list_dirs_first: bool,
-    reverse: bool,
-    show_invisibles: bool,
-    sort_field: SortField,
-}
-
-impl FileFilter {
-    pub fn filter_files(&self, files: &mut Vec<File>) {
-        if !self.show_invisibles {
-            files.retain(|f| !f.is_dotfile());
-        }
-    }
-
-    pub fn sort_files(&self, files: &mut Vec<File>) {
-        files.sort_by(|a, b| self.compare_files(a, b));
-
-        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.is_directory().cmp(&a.is_directory()));
-        }
-    }
-
-    pub fn compare_files(&self, a: &File, b: &File) -> cmp::Ordering {
-        match self.sort_field {
-            SortField::Unsorted      => cmp::Ordering::Equal,
-            SortField::Name          => natord::compare(&*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::Extension     => match a.ext.cmp(&b.ext) {
-                cmp::Ordering::Equal  => natord::compare(&*a.name, &*b.name),
-                order                 => order,
-            },
-        }
-    }
-}
-
-/// User-supplied field to sort by.
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub enum SortField {
-    Unsorted, Name, Extension, Size, FileInode,
-    ModifiedDate, AccessedDate, CreatedDate,
-}
-
-impl Default for SortField {
-    fn default() -> SortField {
-        SortField::Name
-    }
-}
-
-impl SortField {
-
-    /// Find which field to use based on a user-supplied word.
-    fn from_word(word: String) -> Result<SortField, Misfire> {
-        match &word[..] {
-            "name" | "filename"   => Ok(SortField::Name),
-            "size" | "filesize"   => Ok(SortField::Size),
-            "ext"  | "extension"  => Ok(SortField::Extension),
-            "mod"  | "modified"   => Ok(SortField::ModifiedDate),
-            "acc"  | "accessed"   => Ok(SortField::AccessedDate),
-            "cr"   | "created"    => Ok(SortField::CreatedDate),
-            "none"                => Ok(SortField::Unsorted),
-            "inode"               => Ok(SortField::FileInode),
-            field                 => Err(SortField::none(field))
-        }
-    }
-
-    /// How to display an error when the word didn't match with anything.
-    fn none(field: &str) -> Misfire {
-        Misfire::InvalidOptions(getopts::Fail::UnrecognizedOption(format!("--sort {}", field)))
-    }
-}
-
-
-/// One of these things could happen instead of listing files.
-#[derive(PartialEq, Debug)]
-pub enum Misfire {
-
-    /// The getopts crate didn't like these arguments.
-    InvalidOptions(getopts::Fail),
-
-    /// The user asked for help. This isn't strictly an error, which is why
-    /// this enum isn't named Error!
-    Help(String),
-
-    /// The user wanted the version number.
-    Version,
-
-    /// Two options were given that conflict with one another.
-    Conflict(&'static str, &'static str),
-
-    /// An option was given that does nothing when another one either is or
-    /// isn't present.
-    Useless(&'static str, bool, &'static str),
-
-    /// An option was given that does nothing when either of two other options
-    /// are not present.
-    Useless2(&'static str, &'static str, &'static str),
-
-    /// A numeric option was given that failed to be parsed as a number.
-    FailedParse(ParseIntError),
-}
-
-impl Misfire {
-    /// The OS return code this misfire should signify.
-    pub fn error_code(&self) -> i32 {
-        if let Misfire::Help(_) = *self { 2 }
-                                   else { 3 }
-    }
-}
-
-impl fmt::Display for Misfire {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        use self::Misfire::*;
-
-        match *self {
-            InvalidOptions(ref e)  => write!(f, "{}", e),
-            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),
-        }
+        Ok(Options {
+            dir_action: dir_action,
+            view:       view,
+            filter:     filter,
+        })
     }
     }
 }
 }
 
 
@@ -274,7 +147,7 @@ pub enum View {
 }
 }
 
 
 impl View {
 impl View {
-    pub fn deduce(matches: &getopts::Matches, filter: FileFilter, dir_action: DirAction) -> Result<View, Misfire> {
+    fn deduce(matches: &getopts::Matches, filter: FileFilter, dir_action: DirAction) -> Result<View, Misfire> {
         use self::Misfire::*;
         use self::Misfire::*;
 
 
         let long = || {
         let long = || {
@@ -356,8 +229,8 @@ impl View {
                 }
                 }
             }
             }
             else {
             else {
-                // If the terminal width couldn't be matched for some reason, such
-                // as the program's stdout being connected to a file, then
+                // If the terminal width couldnt be matched for some reason, such
+                // as the programs stdout being connected to a file, then
                 // fallback to the lines view.
                 // fallback to the lines view.
                 let lines = Lines {
                 let lines = Lines {
                      colours: Colours::plain(),
                      colours: Colours::plain(),
@@ -389,68 +262,162 @@ impl View {
 }
 }
 
 
 
 
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub enum SizeFormat {
-    DecimalBytes,
-    BinaryBytes,
-    JustBytes,
+trait OptionSet: Sized {
+    fn deduce(matches: &getopts::Matches) -> Result<Self, Misfire>;
 }
 }
 
 
-impl Default for SizeFormat {
-    fn default() -> SizeFormat {
-        SizeFormat::DecimalBytes
+impl OptionSet for Columns {
+    fn deduce(matches: &getopts::Matches) -> Result<Columns, Misfire> {
+        Ok(Columns {
+            size_format: try!(SizeFormat::deduce(matches)),
+            time_types:  try!(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"),
+        })
     }
     }
 }
 }
 
 
-impl SizeFormat {
-    pub fn deduce(matches: &getopts::Matches) -> Result<SizeFormat, Misfire> {
-        let binary = matches.opt_present("binary");
-        let bytes  = matches.opt_present("bytes");
 
 
-        match (binary, bytes) {
-            (true,  true )  => Err(Misfire::Conflict("binary", "bytes")),
-            (true,  false)  => Ok(SizeFormat::BinaryBytes),
-            (false, true )  => Ok(SizeFormat::JustBytes),
-            (false, false)  => Ok(SizeFormat::DecimalBytes),
+/// 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, Copy, Clone)]
+pub struct FileFilter {
+    list_dirs_first: bool,
+    reverse: bool,
+    show_invisibles: bool,
+    sort_field: SortField,
+}
+
+impl OptionSet for FileFilter {
+    fn deduce(matches: &getopts::Matches) -> Result<FileFilter, Misfire> {
+        let sort_field = try!(SortField::deduce(&matches));
+
+        Ok(FileFilter {
+            list_dirs_first: matches.opt_present("group-directories-first"),
+            reverse:         matches.opt_present("reverse"),
+            show_invisibles: matches.opt_present("all"),
+            sort_field:      sort_field,
+        })
+    }
+}
+
+impl FileFilter {
+
+    /// Remove every file in the given vector that does *not* pass the
+    /// filter predicate.
+    pub fn filter_files(&self, files: &mut Vec<File>) {
+        if !self.show_invisibles {
+            files.retain(|f| !f.is_dotfile());
+        }
+    }
+
+    /// Sort the files in the given vector based on the sort field option.
+    pub fn sort_files(&self, files: &mut Vec<File>) {
+        files.sort_by(|a, b| self.compare_files(a, b));
+
+        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.is_directory().cmp(&a.is_directory()));
+        }
+    }
+
+    pub fn compare_files(&self, a: &File, b: &File) -> cmp::Ordering {
+        match self.sort_field {
+            SortField::Unsorted      => cmp::Ordering::Equal,
+            SortField::Name          => natord::compare(&*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::Extension     => match a.ext.cmp(&b.ext) {
+                cmp::Ordering::Equal  => natord::compare(&*a.name, &*b.name),
+                order                 => order,
+            },
         }
         }
     }
     }
 }
 }
 
 
 
 
+/// User-supplied field to sort by.
 #[derive(PartialEq, Debug, Copy, Clone)]
 #[derive(PartialEq, Debug, Copy, Clone)]
-pub enum TimeType {
-    FileAccessed,
-    FileModified,
-    FileCreated,
+pub enum SortField {
+    Unsorted, Name, Extension, Size, FileInode,
+    ModifiedDate, AccessedDate, CreatedDate,
 }
 }
 
 
-impl TimeType {
-    pub fn header(&self) -> &'static str {
-        match *self {
-            TimeType::FileAccessed  => "Date Accessed",
-            TimeType::FileModified  => "Date Modified",
-            TimeType::FileCreated   => "Date Created",
+impl Default for SortField {
+    fn default() -> SortField {
+        SortField::Name
+    }
+}
+
+impl OptionSet for SortField {
+    fn deduce(matches: &getopts::Matches) -> Result<SortField, Misfire> {
+        if let Some(word) = matches.opt_str("sort") {
+            match &word[..] {
+                "name" | "filename"   => Ok(SortField::Name),
+                "size" | "filesize"   => Ok(SortField::Size),
+                "ext"  | "extension"  => Ok(SortField::Extension),
+                "mod"  | "modified"   => Ok(SortField::ModifiedDate),
+                "acc"  | "accessed"   => Ok(SortField::AccessedDate),
+                "cr"   | "created"    => Ok(SortField::CreatedDate),
+                "none"                => Ok(SortField::Unsorted),
+                "inode"               => Ok(SortField::FileInode),
+                field                 => Err(Misfire::bad_argument("sort", field))
+            }
+        }
+        else {
+            Ok(SortField::default())
         }
         }
     }
     }
 }
 }
 
 
 
 
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub struct TimeTypes {
-    accessed: bool,
-    modified: bool,
-    created:  bool,
-}
+impl OptionSet for SizeFormat {
 
 
-impl Default for TimeTypes {
-    fn default() -> TimeTypes {
-        TimeTypes { accessed: false, modified: true, created: false }
+    /// Determine which file size to use in the file size column based on
+    /// the user’s options.
+    ///
+    /// The default mode is to use the decimal prefixes, as they are the
+    /// most commonly-understood, and don’t involve trying to parse large
+    /// 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");
+
+        match (binary, bytes) {
+            (true,  true )  => Err(Misfire::Conflict("binary", "bytes")),
+            (true,  false)  => Ok(SizeFormat::BinaryBytes),
+            (false, true )  => Ok(SizeFormat::JustBytes),
+            (false, false)  => Ok(SizeFormat::DecimalBytes),
+        }
     }
     }
 }
 }
 
 
-impl TimeTypes {
 
 
-    /// Find which field to use based on a user-supplied word.
+impl OptionSet for TimeTypes {
+
+    /// Determine which of a file’s time fields should be displayed for it
+    /// based on the user’s options.
+    ///
+    /// There are two separate ways to pick which fields to show: with a
+    /// flag (such as `--modified`) or with a parameter (such as
+    /// `--time=modified`). An error is signaled if both ways are used.
+    ///
+    /// 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> {
     fn deduce(matches: &getopts::Matches) -> Result<TimeTypes, Misfire> {
         let possible_word = matches.opt_str("time");
         let possible_word = matches.opt_str("time");
         let modified = matches.opt_present("modified");
         let modified = matches.opt_present("modified");
@@ -468,11 +435,11 @@ impl TimeTypes {
                 return Err(Misfire::Useless("accessed", true, "time"));
                 return Err(Misfire::Useless("accessed", true, "time"));
             }
             }
 
 
-            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 }),
-                field   => Err(TimeTypes::none(field)),
+            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)),
             }
             }
         }
         }
         else {
         else {
@@ -484,11 +451,6 @@ impl TimeTypes {
             }
             }
         }
         }
     }
     }
-
-    /// How to display an error when the word didn't match with anything.
-    fn none(field: &str) -> Misfire {
-        Misfire::InvalidOptions(getopts::Fail::UnrecognizedOption(format!("--time {}", field)))
-    }
 }
 }
 
 
 
 
@@ -568,83 +530,106 @@ impl RecurseOptions {
 }
 }
 
 
 
 
-#[derive(PartialEq, Copy, Clone, Debug, Default)]
-pub struct Columns {
-    size_format: SizeFormat,
-    time_types: TimeTypes,
-    inode: bool,
-    links: bool,
-    blocks: bool,
-    group: bool,
-    git: bool
-}
-
-impl Columns {
-    pub fn deduce(matches: &getopts::Matches) -> Result<Columns, Misfire> {
-        Ok(Columns {
-            size_format: try!(SizeFormat::deduce(matches)),
-            time_types:  try!(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"),
-        })
-    }
-
-    pub fn should_scan_for_git(&self) -> bool {
-        self.git
-    }
+/// One of these things could happen instead of listing files.
+#[derive(PartialEq, Debug)]
+pub enum Misfire {
 
 
-    pub fn for_dir(&self, dir: Option<&Dir>) -> Vec<Column> {
-        let mut columns = vec![];
+    /// The getopts crate didn't like these arguments.
+    InvalidOptions(getopts::Fail),
 
 
-        if self.inode {
-            columns.push(Inode);
-        }
+    /// The user asked for help. This isn't strictly an error, which is why
+    /// this enum isn't named Error!
+    Help(String),
 
 
-        columns.push(Permissions);
+    /// The user wanted the version number.
+    Version,
 
 
-        if self.links {
-            columns.push(HardLinks);
-        }
+    /// Two options were given that conflict with one another.
+    Conflict(&'static str, &'static str),
 
 
-        columns.push(FileSize(self.size_format));
+    /// An option was given that does nothing when another one either is or
+    /// isn't present.
+    Useless(&'static str, bool, &'static str),
 
 
-        if self.blocks {
-            columns.push(Blocks);
-        }
+    /// An option was given that does nothing when either of two other options
+    /// are not present.
+    Useless2(&'static str, &'static str, &'static str),
 
 
-        columns.push(User);
+    /// A numeric option was given that failed to be parsed as a number.
+    FailedParse(ParseIntError),
+}
 
 
-        if self.group {
-            columns.push(Group);
-        }
+impl Misfire {
 
 
-        if self.time_types.modified {
-            columns.push(Timestamp(TimeType::FileModified));
-        }
+    /// The OS return code this misfire should signify.
+    pub fn error_code(&self) -> i32 {
+        if let Misfire::Help(_) = *self { 2 }
+                                   else { 3 }
+    }
 
 
-        if self.time_types.created {
-            columns.push(Timestamp(TimeType::FileCreated));
-        }
+    /// The Misfire that happens when an option gets given the wrong
+    /// 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) -> Misfire {
+        Misfire::InvalidOptions(getopts::Fail::UnrecognizedOption(format!("--{} {}", option, otherwise)))
+    }
+}
 
 
-        if self.time_types.accessed {
-            columns.push(Timestamp(TimeType::FileAccessed));
-        }
+impl fmt::Display for Misfire {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        use self::Misfire::*;
 
 
-        if cfg!(feature="git") {
-            if let Some(d) = dir {
-                if self.should_scan_for_git() && d.has_git_repo() {
-                    columns.push(GitStatus);
-                }
-            }
+        match *self {
+            InvalidOptions(ref e)  => write!(f, "{}", e),
+            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),
         }
         }
-
-        columns
     }
     }
 }
 }
 
 
+static OPTIONS: &'static str = r##"
+DISPLAY OPTIONS
+  -1, --oneline  display one entry per line
+  -G, --grid     display entries in a grid view (default)
+  -l, --long     display extended details and attributes
+  -R, --recurse  recurse into directories
+  -T, --tree     recurse into subdirectories in a tree view
+  -x, --across   sort multi-column view entries across
+
+FILTERING AND SORTING OPTIONS
+  -a, --all                  show dot-files
+  -d, --list-dirs            list directories as regular files
+  -r, --reverse              reverse order of files
+  -s, --sort WORD            field to sort by
+  --group-directories-first  list directories before other files
+"##;
+
+static LONG_OPTIONS: &'static str = r##"
+LONG VIEW OPTIONS
+  -b, --binary       use binary prefixes in file sizes
+  -B, --bytes        list file sizes in bytes, without prefixes
+  -g, --group        show group as well as user
+  -h, --header       show a header row at the top
+  -H, --links        show number of hard links
+  -i, --inode        show each file's inode number
+  -L, --level DEPTH  maximum depth of recursion
+  -m, --modified     display timestamp of most recent modification
+  -S, --blocks       show number of file system blocks
+  -t, --time WORD    which timestamp to show for a file
+  -u, --accessed     display timestamp of last access for a file
+  -U, --created      display timestamp of creation for a file
+"##;
+
+static GIT_HELP:      &'static str = r##"  -@, --extended     display extended attribute keys and sizes"##;
+static EXTENDED_HELP: &'static str = r##"  --git              show git status for files"##;
+
+
 #[cfg(test)]
 #[cfg(test)]
 mod test {
 mod test {
     use super::Options;
     use super::Options;
@@ -679,7 +664,7 @@ mod test {
     #[test]
     #[test]
     fn no_args() {
     fn no_args() {
         let args = Options::getopts(&[]).unwrap().1;
         let args = Options::getopts(&[]).unwrap().1;
-        assert_eq!(args, vec![ ".".to_string() ])
+        assert!(args.is_empty());  // Listing the `.` directory is done in main.rs
     }
     }
 
 
     #[test]
     #[test]

+ 231 - 0
src/output/column.rs

@@ -0,0 +1,231 @@
+use ansi_term::Style;
+use unicode_width::UnicodeWidthStr;
+
+use dir::Dir;
+
+
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub enum Column {
+    Permissions,
+    FileSize(SizeFormat),
+    Timestamp(TimeType),
+    Blocks,
+    User,
+    Group,
+    HardLinks,
+    Inode,
+
+    GitStatus,
+}
+
+/// Each column can pick its own **Alignment**. Usually, numbers are
+/// right-aligned, and text is left-aligned.
+#[derive(Copy, Clone)]
+pub enum Alignment {
+    Left, Right,
+}
+
+impl Column {
+
+    /// Get the alignment this column should use.
+    pub fn alignment(&self) -> Alignment {
+        match *self {
+            Column::FileSize(_) => Alignment::Right,
+            Column::HardLinks   => Alignment::Right,
+            Column::Inode       => Alignment::Right,
+            Column::Blocks      => Alignment::Right,
+            Column::GitStatus   => Alignment::Right,
+            _                   => Alignment::Left,
+        }
+    }
+
+    /// Get the text that should be printed at the top, when the user elects
+    /// to have a header row printed.
+    pub fn header(&self) -> &'static str {
+        match *self {
+            Column::Permissions   => "Permissions",
+            Column::FileSize(_)   => "Size",
+            Column::Timestamp(t)  => t.header(),
+            Column::Blocks        => "Blocks",
+            Column::User          => "User",
+            Column::Group         => "Group",
+            Column::HardLinks     => "Links",
+            Column::Inode         => "inode",
+            Column::GitStatus     => "Git",
+        }
+    }
+}
+
+
+#[derive(PartialEq, Copy, Clone, Debug, Default)]
+pub struct Columns {
+    pub size_format: SizeFormat,
+    pub time_types: TimeTypes,
+    pub inode: bool,
+    pub links: bool,
+    pub blocks: bool,
+    pub group: bool,
+    pub git: bool
+}
+
+impl Columns {
+    pub fn should_scan_for_git(&self) -> bool {
+        self.git
+    }
+
+    pub fn for_dir(&self, dir: Option<&Dir>) -> Vec<Column> {
+        let mut columns = vec![];
+
+        if self.inode {
+            columns.push(Column::Inode);
+        }
+
+        columns.push(Column::Permissions);
+
+        if self.links {
+            columns.push(Column::HardLinks);
+        }
+
+        columns.push(Column::FileSize(self.size_format));
+
+        if self.blocks {
+            columns.push(Column::Blocks);
+        }
+
+        columns.push(Column::User);
+
+        if self.group {
+            columns.push(Column::Group);
+        }
+
+        if self.time_types.modified {
+            columns.push(Column::Timestamp(TimeType::Modified));
+        }
+
+        if self.time_types.created {
+            columns.push(Column::Timestamp(TimeType::Created));
+        }
+
+        if self.time_types.accessed {
+            columns.push(Column::Timestamp(TimeType::Accessed));
+        }
+
+        if cfg!(feature="git") {
+            if let Some(d) = dir {
+                if self.should_scan_for_git() && d.has_git_repo() {
+                    columns.push(Column::GitStatus);
+                }
+            }
+        }
+
+        columns
+    }
+}
+
+
+/// Formatting options for file sizes.
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub enum SizeFormat {
+
+    /// Format the file size using **decimal** prefixes, such as “kilo”,
+    /// “mega”, or “giga”.
+    DecimalBytes,
+
+    /// Format the file size using **binary** prefixes, such as “kibi”,
+    /// “mebi”, or “gibi”.
+    BinaryBytes,
+
+    /// Do no formatting and just display the size as a number of bytes.
+    JustBytes,
+}
+
+impl Default for SizeFormat {
+    fn default() -> SizeFormat {
+        SizeFormat::DecimalBytes
+    }
+}
+
+
+/// The types of a file’s time fields. These three fields are standard
+/// across most (all?) operating systems.
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub enum TimeType {
+
+    /// The file’s accessed time (`st_atime`).
+    Accessed,
+
+    /// The file’s modified time (`st_mtime`).
+    Modified,
+
+    /// The file’s creation time (`st_ctime`).
+    Created,
+}
+
+impl TimeType {
+
+    /// Returns the text to use for a column’s heading in the columns output.
+    pub fn header(&self) -> &'static str {
+        match *self {
+            TimeType::Accessed  => "Date Accessed",
+            TimeType::Modified  => "Date Modified",
+            TimeType::Created   => "Date Created",
+        }
+    }
+}
+
+
+/// Fields for which of a file’s time fields should be displayed in the
+/// columns output.
+///
+/// There should always be at least one of these--there's no way to disable
+/// the time columns entirely (yet).
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub struct TimeTypes {
+    pub accessed: bool,
+    pub modified: bool,
+    pub created:  bool,
+}
+
+impl Default for TimeTypes {
+
+    /// By default, display just the ‘modified’ time. This is the most
+    /// common option, which is why it has this shorthand.
+    fn default() -> TimeTypes {
+        TimeTypes { accessed: false, modified: true, created: false }
+    }
+}
+
+
+#[derive(PartialEq, Debug, Clone)]
+pub struct Cell {
+    pub length: usize,
+    pub text: String,
+}
+
+impl Cell {
+    pub fn empty() -> Cell {
+        Cell {
+            text: String::new(),
+            length: 0,
+        }
+    }
+
+    pub fn paint(style: Style, string: &str) -> Cell {
+        Cell {
+            text: style.paint(string).to_string(),
+            length: UnicodeWidthStr::width(string),
+        }
+    }
+
+    pub fn add_spaces(&mut self, count: usize) {
+        self.length += count;
+        for _ in 0 .. count {
+            self.text.push(' ');
+        }
+    }
+
+    pub fn append(&mut self, other: &Cell) {
+        self.length += other.length;
+        self.text.push_str(&*other.text);
+    }
+}

+ 18 - 15
src/output/details.rs

@@ -119,12 +119,12 @@ use std::ops::Add;
 use std::iter::repeat;
 use std::iter::repeat;
 
 
 use colours::Colours;
 use colours::Colours;
-use column::{Alignment, Column, Cell};
 use dir::Dir;
 use dir::Dir;
 use feature::xattr::{Attribute, FileAttributes};
 use feature::xattr::{Attribute, FileAttributes};
 use file::fields as f;
 use file::fields as f;
 use file::File;
 use file::File;
-use options::{Columns, FileFilter, RecurseOptions, SizeFormat};
+use options::{FileFilter, RecurseOptions};
+use output::column::{Alignment, Column, Columns, Cell, SizeFormat};
 
 
 use ansi_term::{ANSIString, ANSIStrings, Style};
 use ansi_term::{ANSIString, ANSIStrings, Style};
 
 
@@ -153,7 +153,7 @@ use super::filename;
 ///
 ///
 /// Almost all the heavy lifting is done in a Table object, which handles the
 /// Almost all the heavy lifting is done in a Table object, which handles the
 /// columns for each row.
 /// columns for each row.
-#[derive(PartialEq, Debug, Copy, Clone, Default)]
+#[derive(PartialEq, Debug, Copy, Clone)]
 pub struct Details {
 pub struct Details {
 
 
     /// A Columns object that says which columns should be included in the
     /// A Columns object that says which columns should be included in the
@@ -486,16 +486,20 @@ impl<U> Table<U> where U: Users {
     }
     }
 
 
     fn display(&mut self, file: &File, column: &Column, xattrs: bool) -> Cell {
     fn display(&mut self, file: &File, column: &Column, xattrs: bool) -> Cell {
+        use output::column::TimeType::*;
+
         match *column {
         match *column {
-            Column::Permissions    => self.render_permissions(file.permissions(), xattrs),
-            Column::FileSize(fmt)  => self.render_size(file.size(), fmt),
-            Column::Timestamp(t)   => self.render_time(file.timestamp(t)),
-            Column::HardLinks      => self.render_links(file.links()),
-            Column::Inode          => self.render_inode(file.inode()),
-            Column::Blocks         => self.render_blocks(file.blocks()),
-            Column::User           => self.render_user(file.user()),
-            Column::Group          => self.render_group(file.group()),
-            Column::GitStatus      => self.render_git_status(file.git_status()),
+            Column::Permissions          => self.render_permissions(file.permissions(), xattrs),
+            Column::FileSize(fmt)        => self.render_size(file.size(), fmt),
+            Column::Timestamp(Modified)  => self.render_time(file.modified_time()),
+            Column::Timestamp(Created)   => self.render_time(file.created_time()),
+            Column::Timestamp(Accessed)  => self.render_time(file.accessed_time()),
+            Column::HardLinks            => self.render_links(file.links()),
+            Column::Inode                => self.render_inode(file.inode()),
+            Column::Blocks               => self.render_blocks(file.blocks()),
+            Column::User                 => self.render_user(file.user()),
+            Column::Group                => self.render_group(file.group()),
+            Column::GitStatus            => self.render_git_status(file.git_status()),
         }
         }
     }
     }
 
 
@@ -658,7 +662,7 @@ impl<U> Table<U> where U: Users {
             .map(|n| self.rows.iter().map(|row| row.column_width(n)).max().unwrap_or(0))
             .map(|n| self.rows.iter().map(|row| row.column_width(n)).max().unwrap_or(0))
             .collect();
             .collect();
 
 
-        let total_width: usize = self.columns.len() + column_widths.iter().fold(0,Add::add);
+        let total_width: usize = self.columns.len() + column_widths.iter().fold(0, Add::add);
 
 
         for row in self.rows.iter() {
         for row in self.rows.iter() {
             let mut cell = Cell::empty();
             let mut cell = Cell::empty();
@@ -754,8 +758,7 @@ pub mod test {
     pub use super::Table;
     pub use super::Table;
     pub use file::File;
     pub use file::File;
     pub use file::fields as f;
     pub use file::fields as f;
-
-    pub use column::{Cell, Column};
+    pub use output::column::{Cell, Column};
 
 
     pub use users::{User, Group, uid_t, gid_t};
     pub use users::{User, Group, uid_t, gid_t};
     pub use users::mock::MockUsers;
     pub use users::mock::MockUsers;

+ 2 - 1
src/output/grid_details.rs

@@ -3,10 +3,11 @@ use std::iter::repeat;
 use users::OSUsers;
 use users::OSUsers;
 use term_grid as grid;
 use term_grid as grid;
 
 
-use column::{Column, Cell};
 use dir::Dir;
 use dir::Dir;
 use feature::xattr::FileAttributes;
 use feature::xattr::FileAttributes;
 use file::File;
 use file::File;
+
+use output::column::{Column, Cell};
 use output::details::{Details, Table};
 use output::details::{Details, Table};
 use output::grid::Grid;
 use output::grid::Grid;
 
 

+ 2 - 0
src/output/mod.rs

@@ -13,6 +13,8 @@ mod grid;
 pub mod details;
 pub mod details;
 mod lines;
 mod lines;
 mod grid_details;
 mod grid_details;
+pub mod column;
+
 
 
 pub fn filename(file: &File, colours: &Colours, links: bool) -> String {
 pub fn filename(file: &File, colours: &Colours, links: bool) -> String {
     if links && file.is_link() {
     if links && file.is_link() {