diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 8f689a84..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: ogham diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..0a4856ae --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,8 @@ +"features › icon": + - src/output/icons.rs + +"features › ui": + - src/info/filetype.rs + +"packaging › snap": + - snap/**/* diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml new file mode 100644 index 00000000..95ec12e5 --- /dev/null +++ b/.github/workflows/label.yml @@ -0,0 +1,24 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +# For inspiration see: https://github.com/NixOS/nixpkgs/blob/master/.github/workflows/labels.yml + +name: Labeler +on: [pull_request_target] + +jobs: + label: + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..3f33bc30 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,36 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '42 3 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + days-before-pr-stale: 30 + days-before-pr-close: 14 + stale-pr-message: 'This pull request is stale because it has been open for 30 days with no activity.' + close-pr-message: 'This pull request was closed because it has been inactive for 14 days since being marked as stale.' + stale-pr-label: 'stale › pr' + + days-before-issue-stale: 30 + days-before-issue-close: 14 + stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity.' + close-issue-message: 'This issue was closed because it has been inactive for 14 days since being marked as stale.' + stale-issue-label: 'stale › issue' + diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 05b7d542..736bd4cc 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -2,14 +2,14 @@ name: Unit tests on: push: - branches: [ master ] + branches: [ main ] paths: - '.github/workflows/*' - 'src/**' - 'Cargo.*' - build.rs pull_request: - branches: [ master ] + branches: [ main ] paths: - '.github/workflows/*' - 'src/**' @@ -28,27 +28,19 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest] - rust: [1.56.1, stable, beta, nightly] + rust: [1.71.0, stable, beta, nightly] steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Install Rust toolchain - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@v1 with: - profile: minimal toolchain: ${{ matrix.rust }} - override: true - name: Install cargo-hack - uses: actions-rs/cargo@v1 - with: - command: install - args: cargo-hack + run: cargo install cargo-hack@0.5.27 - name: Run unit tests - uses: actions-rs/cargo@v1 - with: - command: hack - args: test --feature-powerset + run: cargo hack test --feature-powerset diff --git a/.gitignore b/.gitignore index cb7baef0..228e2c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Nix Flake stuff +result + # Rust stuff target diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..123b726b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,259 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.10.5] - 2023-08-03 + +### Bug Fixes + +- Output wraps in terminal +- Respect icon spacing + +## [0.10.4] - 2023-08-02 + +### Bug Fixes + +- Dereferencing linksfile size. +- Dereferencing links users. +- Dereferencing links groups. +- Dereferencing links permissions. +- Dereferencing links timestamps. +- Syntax error + +### Documentation + +- Add -X/--dereference flag + +### Features + +- Add symlink dereferencing flag +- Add -X/--dereference completions +- Add -X/--dereference completions +- Added ".out" files for latex +- Add changelog generation + +### Miscellaneous Tasks + +- Release 0.10.4 + +## [0.10.3] - 2023-07-31 + +### Bug Fixes + +- More JPG extensions +- Add compression icon to .tXX files #930 +- Fish completion for -i/--inode option +- Typo +- Use eprintln instead +- Use stderr on no timezone info +- Bump openssl-src from 111.15.0+1.1.1k to 111.26.0+1.1.1u +- Bump openssl-src from 111.15.0+1.1.1k to 111.26.0+1.1.1u +- Changed bin name via cargo.toml +- Change man pages to reffer to new binary name +- Change completions to new binary name +- Change completion file names +- Change name to eza +- Bump git2 from 0.13.20 to 0.16.1 +- Fixed grid bug +- Fixed grid bug +- Bump rust to 1.71.0 +- Take -a and -A equally serious +- Changed default folder icon +- Add clippy as part of the toolchain +- Change license icon +- Change gpg icons to keys +- Add icon for ocaml (.ml extension) +- .ipynb icon comment +- Better license icon +- Replace obsolete icons +- Add Svelte icon +- Add Emacs icon for .el and org-mode for .org +- Added icons for .rmeta +- Add icon support for .mjs, .cjs, .mts, .cts files +- Add webpack.config.cjs to immediate files list +- Removed result +- Update --version info +- Update snapscraft.yaml +- Sort is_immediate +- Add flake, autoconf, cargo lock +- Added trailing commas +- Remove accidentally commited test files + +### Documentation + +- Change name in README.md +- Add `nix run` to readme +- Fix flow issue +- Fix typos +- Add mandatory snowflake emoji +- Document nix flake development +- Document nix flakew +- Update README.md +- Update README.md +- Update README.md +- Update README.md +- Update README.md +- Readme change screenshot to eza +- Add CoC badge to readme +- Add CODE_OF_CONDUCT.md +- Add crates.io badge, license badge +- Fix links +- Update README.md +- Update README.md + +### Features + +- Add sty file +- Add julia file extension icon +- Add status for git repos +- Add selinux contexts support +- Add -o shorcut to --octal-permissions +- Hyperlink flag +- Update Cargo.toml to optimise binaries for size +- Update Cargo.toml to optimise binaries for size +- Add git-status-.* completions +- Zsh add git-status-.* completions +- Add git-status-.* completions +- Add Zig module icons +- Add icon for Vagrantfile +- Add git icon to .gitignore_global file +- Left align relative time +- Add support for --time-style=relative +- Add vim icon +- Symlinks report their target's valid size +- Add justfile +- Add pxm +- Add compressed types +- Add compressed icons + +### Improve + +- Vim icon + +### Miscellaneous Tasks + +- Bump to v0.10.2 +- Bump to v0.10.3 +- Update cargo lock + +### Refactor + +- Removed commented code +- Sorted file types, color table + +### Styling + +- Add icon for reStructuredText (src) files + +### Testing + +- Change to /usr/bin/env bash + +### Add + +- Mp2 audio format icon + +### Ci + +- Remove unused .github files +- Remove unused .github files +- Create unit-tests.yml +- Create unit-tests.yml +- Add trivial nix flake +- Add treefmt, rust-toolchain, nixfmt +- Add .#test, .#clippy, .#check +- Add nix flake +- Change branch +- Bump rust to 1.71.0 +- Automatically mark issues/PRs stale +- Run tests when building with nix +- Moving actions to dtolnay's version +- Update Cargo.toml +- Create labeler.yml +- Add snap to labeler.yml +- Add filetype.rs autolabel + +### Icons + +- Add Gentoo for .ebuild + +### Src/main.rs + +- Remove clippy::unnested_or_patterns + +## [0.10.0] - 2021-04-03 + +### Documentation + +- Add hint how to install exa on Android / Termux + +### Features + +- Add support Typescript and ReasonML projects +- New Icons and CLI argument to suppress icons + +### Miscellaneous Tasks + +- Update zoneinfo_compiled, datetime to 0.5 +- Update users to 0.10 +- PR feedback + +### Build + +- Use binary name only + +### Git-feature + +- Display if a file is updated but unmerged (conflicted) + +## [0.9.0] - 2019-07-15 + +### Feat + +- Add JPF to image filetype + +### Refactor + +- Use shorthand fields + +## [0.8.0] - 2017-09-30 + +### Vagrant + +- Update apt before installing + +## [0.4.1] - 2017-03-26 + +### Fixup + +- Split prefix tests by property + +### Io + +- :Result -> IOResult + +## [0.4.0] - 2015-10-18 + +### Makefile + +- Be compatible with BSD and OS X + +## [0.3.0] - 2015-06-05 + +### StatResult + +- :Path -> Dir + +## [0.2.0] - 2015-03-02 + +### Details + +- `filter` is only used when recursing + +## [0.1.0] - 2015-02-21 + +### ToStr + +- :to_str -> ToString::to_string + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..01b35759 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at: + +matrix: @cafkafk:nixos.dev + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/Cargo.lock b/Cargo.lock index 5ee181df..122f1212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bitflags" @@ -31,9 +31,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "cc" -version = "1.0.67" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c69b077ad434294d3ce9f1f6143a2a4b89a8a2d54ef813d85003a4fd1137fd" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" dependencies = [ "jobserver", ] @@ -58,8 +58,8 @@ dependencies = [ ] [[package]] -name = "exa" -version = "0.10.1" +name = "eza" +version = "0.10.5" dependencies = [ "ansi_term", "datetime", @@ -75,6 +75,7 @@ dependencies = [ "scoped_threadpool", "term_grid", "terminal_size", + "timeago", "unicode-width", "users", "zoneinfo_compiled", @@ -92,9 +93,9 @@ dependencies = [ [[package]] name = "git2" -version = "0.13.20" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9831e983241f8c5591ed53f17d874833e2fa82cac2625f3888c50cbfe136cba" +checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc" dependencies = [ "bitflags", "libc", @@ -153,13 +154,14 @@ checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" [[package]] name = "libgit2-sys" -version = "0.12.21+1.1.0" +version = "0.14.2+1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86271bacd72b2b9e854c3dcfb82efd538f15f870e4c11af66900effb462f6825" +checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4" dependencies = [ "cc", "libc", "libz-sys", + "openssl-sys", "pkg-config", ] @@ -223,9 +225,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "openssl-src" -version = "111.15.0+1.1.1k" +version = "111.26.0+1.1.1u" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a5f6ae2ac04393b217ea9f700cd04fa9bf3d93fae2872069f3d15d908af70a" +checksum = "efc62c9f12b22b8f5208c23a7200a442b2e5999f8bdf80233852122b5a4f6f37" dependencies = [ "cc", ] @@ -279,9 +281,9 @@ checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" [[package]] name = "term_grid" -version = "0.2.0" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c9eb7705cb3f0fd71d3955b23db6d372142ac139e8c473952c93bf3c3dc4b7" +checksum = "230d3e804faaed5a39b08319efb797783df2fd9671b39b7596490cb486d702cf" dependencies = [ "unicode-width", ] @@ -296,6 +298,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "timeago" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26" + [[package]] name = "tinyvec" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 05f1cb7c..dc0ee49a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,20 @@ [package] -name = "exa" +name = "eza" description = "A modern replacement for ls" -authors = ["Benjamin Sago "] +authors = ["Christina Sørensen "] categories = ["command-line-utilities"] -edition = "2018" -rust-version = "1.56.1" +edition = "2021" +rust-version = "1.70.0" exclude = ["/devtools/*", "/Justfile", "/Vagrantfile", "/screenshots.png"] readme = "README.md" -homepage = "https://the.exa.website/" +homepage = "https://github.com/cafkafk/eza" license = "MIT" -repository = "https://github.com/ogham/exa" -version = "0.10.1" +repository = "https://github.com/cafkafk/eza" +version = "0.10.5" [[bin]] -name = "exa" +name = "eza" [dependencies] @@ -28,19 +28,22 @@ natord = "1.0" num_cpus = "1.10" number_prefix = "0.4" scoped_threadpool = "0.1" -term_grid = "0.2.0" +term_grid = "0.1" terminal_size = "0.1.16" +timeago = { version = "0.3.1", default-features = false } unicode-width = "0.1" -users = "0.11" zoneinfo_compiled = "0.5.1" +[target.'cfg(unix)'.dependencies] +users = "0.11" + [dependencies.datetime] version = "0.5.2" default-features = false features = ["format"] [dependencies.git2] -version = "0.13" +version = "0.16" optional = true default-features = false @@ -61,21 +64,23 @@ debug = false # use LTO for smaller binaries (that take longer to build) [profile.release] lto = true +strip = true +opt-level = "s" [package.metadata.deb] license-file = [ "LICENCE", "4" ] depends = "$auto" extended-description = """ -exa is a replacement for ls written in Rust. +eza is a modern, maintained replacement for ls """ section = "utils" priority = "optional" assets = [ - [ "target/release/exa", "/usr/bin/exa", "0755" ], - [ "target/release/../man/exa.1", "/usr/share/man/man1/exa.1", "0644" ], - [ "target/release/../man/exa_colors.5", "/usr/share/man/man5/exa_colors.5", "0644" ], - [ "completions/bash/exa", "/usr/share/bash-completion/completions/exa", "0644" ], - [ "completions/zsh/_exa", "/usr/share/zsh/site-functions/_exa", "0644" ], - [ "completions/fish/exa.fish", "/usr/share/fish/vendor_completions.d/exa.fish", "0644" ], + [ "target/release/eza", "/usr/bin/eza", "0755" ], + [ "target/release/../man/eza.1", "/usr/share/man/man1/eza.1", "0644" ], + [ "target/release/../man/eza_colors.5", "/usr/share/man/man5/eza_colors.5", "0644" ], + [ "completions/bash/eza", "/usr/share/bash-completion/completions/eza", "0644" ], + [ "completions/zsh/_eza", "/usr/share/zsh/site-functions/_eza", "0644" ], + [ "completions/fish/eza.fish", "/usr/share/fish/vendor_completions.d/eza.fish", "0644" ], ] diff --git a/Justfile b/Justfile index 867fc80c..0a94fa2b 100644 --- a/Justfile +++ b/Justfile @@ -98,13 +98,13 @@ all-release: build-release test-release # build the man pages @man: mkdir -p "${CARGO_TARGET_DIR:-target}/man" - pandoc --standalone -f markdown -t man man/exa.1.md > "${CARGO_TARGET_DIR:-target}/man/exa.1" - pandoc --standalone -f markdown -t man man/exa_colors.5.md > "${CARGO_TARGET_DIR:-target}/man/exa_colors.5" + pandoc --standalone -f markdown -t man man/eza.1.md > "${CARGO_TARGET_DIR:-target}/man/eza.1" + pandoc --standalone -f markdown -t man man/eza_colors.5.md > "${CARGO_TARGET_DIR:-target}/man/eza_colors.5" -# build and preview the main man page (exa.1) +# build and preview the main man page (eza.1) @man-1-preview: man - man "${CARGO_TARGET_DIR:-target}/man/exa.1" + man "${CARGO_TARGET_DIR:-target}/man/eza.1" -# build and preview the colour configuration man page (exa_colors.5) +# build and preview the colour configuration man page (eza_colors.5) @man-5-preview: man - man "${CARGO_TARGET_DIR:-target}/man/exa_colors.5" + man "${CARGO_TARGET_DIR:-target}/man/eza_colors.5" diff --git a/README.md b/README.md index 157853b0..57de8f20 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,97 @@
-# exa +# eza -[exa](https://the.exa.website/) is a modern replacement for _ls_. +eza is a modern, maintained replacement for ls, built on [exa](https://github.com/ogham/exa). **README Sections:** [Options](#options) — [Installation](#installation) — [Development](#development) -[![Unit tests](https://github.com/ogham/exa/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/ogham/exa/actions/workflows/unit-tests.yml) -[![Say thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/ogham%40bsago.me) +[![Built with Nix](https://img.shields.io/badge/Built_With-Nix-5277C3.svg?logo=nixos&labelColor=73C3D5)](https://nixos.org) +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) + +[![Unit tests](https://github.com/cafkafk/eza/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/cafkafk/eza/actions/workflows/unit-tests.yml) +![Crates.io](https://img.shields.io/crates/v/eza?link=https%3A%2F%2Fcrates.io%2Fcrates%2Feza) +![Crates.io](https://img.shields.io/crates/l/eza?link=https%3A%2F%2Fgithub.com%2Fcafkafk%2Feza%2Fblob%2Fmain%2FLICENCE) +
-![Screenshots of exa](screenshots.png) +![Screenshots of eza](screenshots.png) --- -**exa** is a modern replacement for the venerable file-listing command-line program `ls` that ships with Unix and Linux operating systems, giving it more features and better defaults. +**eza** is a modern, maintained replacement for the venerable file-listing command-line program `ls` that ships with Unix and Linux operating systems, giving it more features and better defaults. It uses colours to distinguish file types and metadata. It knows about symlinks, extended attributes, and Git. And it’s **small**, **fast**, and just **one single binary**. -By deliberately making some decisions differently, exa attempts to be a more featureful, more user-friendly version of `ls`. -For more information, see [exa’s website](https://the.exa.website/). +By deliberately making some decisions differently, eza attempts to be a more featureful, more user-friendly version of `ls`. --- +**eza** features not in exa (non-exhaustive): + + - Fixes [“The Grid Bug”](https://github.com/cafkafk/eza/issues/66#issuecomment-1656758327) introduced in exa 2021. + - Hyperlink support. + - Selinux context output. + - Git repo status output. + - Human readable relative dates. + - Several security fixes (see [dependabot](https://github.com/cafkafk/eza/security/dependabot?q=is%3Aclosed)) + - Many smaller bug fixes/changes! + +--- + + +

Try it!

+
+ +### Nix ❄️ + +If you already have Nix setup with flake support, you can try out eza with the `nix run` command: + + nix run github:cafkafk/eza + +Nix will build eza and run it. + +If you want to pass arguments this way, use e.g. `nix run github:cafkafk/eza -- -ol`. + + +

Installation

+
+ +eza is available for macOS and Linux. + +### Cargo (crates.io) + +![Crates.io](https://img.shields.io/crates/v/eza?link=https%3A%2F%2Fcrates.io%2Fcrates%2Feza) + +If you already have a Rust environment set up, you can use the `cargo install` command: + + cargo install eza + +Cargo will build the `eza` binary and place it in `$HOME/.local/share/cargo/bin/eza`. + +### Cargo (git) + +If you already have a Rust environment set up, you can use the `cargo install` command in your local clone of the repo: + + git clone https://github.com/cafkafk/eza.git + cd eza + cargo install --path . + +Cargo will build the `eza` binary and place it in `$HOME/.cargo`. + +--- +Click sections to expand. + +
+ Command-line options +

Command-line options

-exa’s options are almost, but not quite, entirely unlike `ls`’s. +eza’s options are almost, but not quite, entirely unlike `ls`’s. ### Display options @@ -44,6 +106,7 @@ exa’s options are almost, but not quite, entirely unlike `ls`’s. - **--colo[u]r-scale**: highlight levels of file sizes distinctly - **--icons**: display icons - **--no-icons**: don't display icons (always overrides --icons) +- **--hyperlink**: display entries as hyperlinks ### Filtering options @@ -74,12 +137,14 @@ These options are available when running with `--long` (`-l`): - **-t**, **--time=(field)**: which timestamp field to use - **-u**, **--accessed**: use the accessed timestamp field - **-U**, **--created**: use the created timestamp field +- **-X**, **--dereference**: dereference symlinks for file information +- **-Z**, **--context**: list each file’s security context - **-@**, **--extended**: list each file’s extended attributes and sizes - **--changed**: use the changed timestamp field - **--git**: list each file’s Git status, if tracked or ignored - **--time-style**: how to format timestamps - **--no-permissions**: suppress the permissions field -- **--octal-permissions**: list each file's permission in octal format +- **-o**, **--octal-permissions**: list each file's permission in octal format - **--no-filesize**: suppress the filesize field - **--no-user**: suppress the user field - **--no-time**: suppress the time field @@ -89,127 +154,29 @@ Some of the options accept parameters: - Valid **--color** options are **always**, **automatic**, and **never**. - Valid sort fields are **accessed**, **changed**, **created**, **extension**, **Extension**, **inode**, **modified**, **name**, **Name**, **size**, **type**, and **none**. Fields starting with a capital letter sort uppercase before lowercase. The modified field has the aliases **date**, **time**, and **newest**, while its reverse has the aliases **age** and **oldest**. - Valid time fields are **modified**, **changed**, **accessed**, and **created**. -- Valid time styles are **default**, **iso**, **long-iso**, and **full-iso**. +- Valid time styles are **default**, **iso**, **long-iso**, **full-iso**, and **relative**. - ---- - - -

Installation

-
- -exa is available for macOS and Linux. -More information on how to install exa is available on [the Installation page](https://the.exa.website/install). - -### Alpine Linux - -On Alpine Linux, [enable community repository](https://wiki.alpinelinux.org/wiki/Enable_Community_Repository) and install the [`exa`](https://pkgs.alpinelinux.org/package/edge/community/x86_64/exa) package. - - apk add exa - -### Arch Linux - -On Arch, install the [`exa`](https://www.archlinux.org/packages/community/x86_64/exa/) package. - - pacman -S exa - -### Android / Termux - -On Android / Termux, install the [`exa`](https://github.com/termux/termux-packages/tree/master/packages/exa) package. - - pkg install exa - -### Debian - -On Debian, install the [`exa`](https://packages.debian.org/stable/exa) package. - - apt install exa - -### Fedora - -On Fedora, install the [`exa`](https://src.fedoraproject.org/modules/exa) package. - - dnf install exa - -### Gentoo - -On Gentoo, install the [`sys-apps/exa`](https://packages.gentoo.org/packages/sys-apps/exa) package. - - emerge sys-apps/exa - -### Homebrew - -If you’re using [Homebrew](https://brew.sh/) on macOS, install the [`exa`](http://formulae.brew.sh/formula/exa) formula. - - brew install exa - -### MacPorts - -If you're using [MacPorts](https://www.macports.org/) on macOS, install the [`exa`](https://ports.macports.org/port/exa/summary) port. - - port install exa - -### Nix - -On nixOS, install the [`exa`](https://github.com/NixOS/nixpkgs/blob/master/pkgs/tools/misc/exa/default.nix) package. - - nix-env -i exa - -### openSUSE - -On openSUSE, install the [`exa`](https://software.opensuse.org/package/exa) package. - - zypper install exa - -### Ubuntu - -On Ubuntu 20.10 (Groovy Gorilla) and later, install the [`exa`](https://packages.ubuntu.com/jammy/exa) package. - - sudo apt install exa - -### Void Linux - -On Void Linux, install the [`exa`](https://github.com/void-linux/void-packages/blob/master/srcpkgs/exa/template) package. - - xbps-install -S exa - -### Manual installation from GitHub - -Compiled binary versions of exa are uploaded to GitHub when a release is made. -You can install exa manually by [downloading a release](https://github.com/ogham/exa/releases), extracting it, and copying the binary to a directory in your `$PATH`, such as `/usr/local/bin`. - -For more information, see the [Manual Installation page](https://the.exa.website/install/linux#manual). - -### Cargo - -If you already have a Rust environment set up, you can use the `cargo install` command: - - cargo install exa - -Cargo will build the `exa` binary and place it in `$HOME/.cargo`. - -To build without Git support, run `cargo install --no-default-features exa` is also available, if the requisite dependencies are not installed. - - ---- +
+
+ Development

Development - - Rust 1.56.1+ + + Rust 1.63.0+ - + MIT Licence

-exa is written in [Rust](https://www.rust-lang.org/). +eza is written in [Rust](https://www.rust-lang.org/). You will need rustc version 1.56.1 or higher. The recommended way to install Rust for development is from the [official download page](https://www.rust-lang.org/tools/install), using rustup. -Once Rust is installed, you can compile exa with Cargo: +Once Rust is installed, you can compile eza with Cargo: cargo build cargo test @@ -226,24 +193,34 @@ The `just man` command will compile the Markdown into manual pages, which it wil To use them, copy them into a directory that `man` will read. `/usr/local/share/man` is usually a good choice. -- exa depends on [libgit2](https://github.com/rust-lang/git2-rs) for certain features. +- eza depends on [libgit2](https://github.com/rust-lang/git2-rs) for certain features. If you’re unable to compile libgit2, you can opt out of Git support by running `cargo build --no-default-features`. - If you intend to compile for musl, you will need to use the flag `vendored-openssl` if you want to get the Git feature working. The full command is `cargo build --release --target=x86_64-unknown-linux-musl --features vendored-openssl,git`. -For more information, see the [Building from Source page](https://the.exa.website/install/source). +### Developing on Nix (experimental) ❄️ + +If you have a working Nix installation with flake support, you can use nix to manage your dev environment. + + nix develop + +The Nix Flake has a few features: +- Run `nix flake check` to run `treefmt` on the repo. +- Run `nix build` and manually test `./results/bin/eza -- ` for easy debugging. +- Run `nix build .#test` to run `cargo test` via the flake. +- Run `nix build .#clippy` to lint with clippy (still work in progress). ### Testing with Vagrant -exa uses [Vagrant][] to configure virtual machines for testing. +eza uses [Vagrant][] to configure virtual machines for testing. -Programs such as exa that are basically interfaces to the system are [notoriously difficult to test][testing]. +Programs such as eza that are basically interfaces to the system are [notoriously difficult to test][testing]. Although the internal components have unit tests, it’s impossible to do a complete end-to-end test without mandating the current user’s name, the time zone, the locale, and directory structure to test. (And yes, these tests are worth doing. I have missed an edge case on many an occasion.) -The initial attempt to solve the problem was just to create a directory of “awkward” test cases, run exa on it, and make sure it produced the correct output. +The initial attempt to solve the problem was just to create a directory of “awkward” test cases, run eza on it, and make sure it produced the correct output. But even this output would change if, say, the user’s locale formats dates in a different way. These can be mocked inside the code, but at the cost of making that code more complicated to read and understand. @@ -258,7 +235,7 @@ First, initialise the VM: host$ vagrant up -The first command downloads the virtual machine image, and then runs our provisioning script, which installs Rust and exa’s build-time dependencies, configures the environment, and generates some awkward files and folders to use as test cases. +The first command downloads the virtual machine image, and then runs our provisioning script, which installs Rust and eza’s build-time dependencies, configures the environment, and generates some awkward files and folders to use as test cases. Once this is done, you can SSH in, and build and test: host$ vagrant ssh @@ -268,5 +245,7 @@ Once this is done, you can SSH in, and build and test: All the tests passed! Of course, the drawback of having a standard development environment is that you stop noticing bugs that occur outside of it. -For this reason, Vagrant isn’t a *necessary* development step — it’s there if you’d like to use it, but exa still gets used and tested on other platforms. +For this reason, Vagrant isn’t a *necessary* development step — it’s there if you’d like to use it, but eza still gets used and tested on other platforms. It can still be built and compiled on any target triple that it supports, VM or no VM, with `cargo build` and `cargo test`. + +
diff --git a/build.rs b/build.rs index a0a13514..f3415977 100644 --- a/build.rs +++ b/build.rs @@ -22,8 +22,8 @@ use datetime::{LocalDateTime, ISO}; fn main() -> io::Result<()> { #![allow(clippy::write_with_newline)] - let tagline = "exa - list files on the command-line"; - let url = "https://the.exa.website/"; + let tagline = "eza - A modern, maintained replacement for ls"; + let url = "https://github.com/cafkafk/eza"; let ver = if is_debug_build() { @@ -41,7 +41,7 @@ fn main() -> io::Result<()> { let path = &out.join("version_string.txt"); // Bland version text - let mut f = File::create(path).expect(&path.to_string_lossy()); + let mut f = File::create(path).unwrap_or_else(|_| { panic!("{}", path.to_string_lossy().to_string()) }); writeln!(f, "{}", strip_codes(&ver))?; Ok(()) diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..0a882189 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,75 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://tera.netlify.app/docs +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ + +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))"}, # replace issue numbers +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# glob pattern for matching git tags +tag_pattern = "v[0-9]*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42 diff --git a/completions/bash/exa b/completions/bash/eza similarity index 87% rename from completions/bash/exa rename to completions/bash/eza index d0447278..65e72e0c 100644 --- a/completions/bash/exa +++ b/completions/bash/eza @@ -1,4 +1,4 @@ -_exa() +_eza() { cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]} @@ -29,7 +29,7 @@ _exa() ;; --time-style) - COMPREPLY=( $( compgen -W 'default iso long-iso full-iso --' -- "$cur" ) ) + COMPREPLY=( $( compgen -W 'default iso long-iso full-iso relative --' -- "$cur" ) ) return ;; esac @@ -38,13 +38,13 @@ _exa() # _parse_help doesn’t pick up short options when they are on the same line than long options --*) # colo[u]r isn’t parsed correctly so we filter these options out and add them by hand - parse_help=$( exa --help | grep -oE ' (\-\-[[:alnum:]@-]+)' | tr -d ' ' | grep -v '\-\-colo' ) + parse_help=$( eza --help | grep -oE ' (\-\-[[:alnum:]@-]+)' | tr -d ' ' | grep -v '\-\-colo' ) completions=$( echo '--color --colour --color-scale --colour-scale' $parse_help ) COMPREPLY=( $( compgen -W "$completions" -- "$cur" ) ) ;; -*) - completions=$( exa --help | grep -oE ' (\-[[:alnum:]@])' | tr -d ' ' ) + completions=$( eza --help | grep -oE ' (\-[[:alnum:]@])' | tr -d ' ' ) COMPREPLY=( $( compgen -W "$completions" -- "$cur" ) ) ;; @@ -53,4 +53,4 @@ _exa() ;; esac } && -complete -o filenames -o bashdefault -F _exa exa +complete -o filenames -o bashdefault -F _eza eza diff --git a/completions/fish/exa.fish b/completions/fish/exa.fish deleted file mode 100755 index daf6a4be..00000000 --- a/completions/fish/exa.fish +++ /dev/null @@ -1,91 +0,0 @@ -# Meta-stuff -complete -c exa -s 'v' -l 'version' -d "Show version of exa" -complete -c exa -s '?' -l 'help' -d "Show list of command-line options" - -# Display options -complete -c exa -s '1' -l 'oneline' -d "Display one entry per line" -complete -c exa -s 'l' -l 'long' -d "Display extended file metadata as a table" -complete -c exa -s 'G' -l 'grid' -d "Display entries in a grid" -complete -c exa -s 'x' -l 'across' -d "Sort the grid across, rather than downwards" -complete -c exa -s 'R' -l 'recurse' -d "Recurse into directories" -complete -c exa -s 'T' -l 'tree' -d "Recurse into directories as a tree" -complete -c exa -s 'F' -l 'classify' -d "Display type indicator by file names" -complete -c exa -l 'color' \ - -l 'colour' -d "When to use terminal colours" -x -a " - always\t'Always use colour' - auto\t'Use colour if standard output is a terminal' - never\t'Never use colour' -" -complete -c exa -l 'color-scale' \ - -l 'colour-scale' -d "Highlight levels of file sizes distinctly" -complete -c exa -l 'icons' -d "Display icons" -complete -c exa -l 'no-icons' -d "Don't display icons" - -# Filtering and sorting options -complete -c exa -l 'group-directories-first' -d "Sort directories before other files" -complete -c exa -l 'git-ignore' -d "Ignore files mentioned in '.gitignore'" -complete -c exa -s 'a' -l 'all' -d "Show hidden and 'dot' files" -complete -c exa -s 'd' -l 'list-dirs' -d "List directories like regular files" -complete -c exa -s 'L' -l 'level' -d "Limit the depth of recursion" -x -a "1 2 3 4 5 6 7 8 9" -complete -c exa -s 'r' -l 'reverse' -d "Reverse the sort order" -complete -c exa -s 's' -l 'sort' -d "Which field to sort by" -x -a " - accessed\t'Sort by file accessed time' - age\t'Sort by file modified time (newest first)' - changed\t'Sort by changed time' - created\t'Sort by file modified time' - date\t'Sort by file modified time' - ext\t'Sort by file extension' - Ext\t'Sort by file extension (uppercase first)' - extension\t'Sort by file extension' - Extension\t'Sort by file extension (uppercase first)' - filename\t'Sort by filename' - Filename\t'Sort by filename (uppercase first)' - inode\t'Sort by file inode' - modified\t'Sort by file modified time' - name\t'Sort by filename' - Name\t'Sort by filename (uppercase first)' - newest\t'Sort by file modified time (newest first)' - none\t'Do not sort files at all' - oldest\t'Sort by file modified time' - size\t'Sort by file size' - time\t'Sort by file modified time' - type\t'Sort by file type' -" - -complete -c exa -s 'I' -l 'ignore-glob' -d "Ignore files that match these glob patterns" -r -complete -c exa -s 'D' -l 'only-dirs' -d "List only directories" - -# Long view options -complete -c exa -s 'b' -l 'binary' -d "List file sizes with binary prefixes" -complete -c exa -s 'B' -l 'bytes' -d "List file sizes in bytes, without any prefixes" -complete -c exa -s 'g' -l 'group' -d "List each file's group" -complete -c exa -s 'h' -l 'header' -d "Add a header row to each column" -complete -c exa -s 'H' -l 'links' -d "List each file's number of hard links" -complete -c exa -s 'g' -l 'group' -d "List each file's inode number" -complete -c exa -s 'S' -l 'blocks' -d "List each file's number of filesystem blocks" -complete -c exa -s 't' -l 'time' -d "Which timestamp field to list" -x -a " - modified\t'Display modified time' - changed\t'Display changed time' - accessed\t'Display accessed time' - created\t'Display created time' -" -complete -c exa -s 'm' -l 'modified' -d "Use the modified timestamp field" -complete -c exa -s 'n' -l 'numeric' -d "List numeric user and group IDs." -complete -c exa -l 'changed' -d "Use the changed timestamp field" -complete -c exa -s 'u' -l 'accessed' -d "Use the accessed timestamp field" -complete -c exa -s 'U' -l 'created' -d "Use the created timestamp field" -complete -c exa -l 'time-style' -d "How to format timestamps" -x -a " - default\t'Use the default time style' - iso\t'Display brief ISO timestamps' - long-iso\t'Display longer ISO timestaps, up to the minute' - full-iso\t'Display full ISO timestamps, up to the nanosecond' -" -complete -c exa -l 'no-permissions' -d "Suppress the permissions field" -complete -c exa -l 'octal-permissions' -d "List each file's permission in octal format" -complete -c exa -l 'no-filesize' -d "Suppress the filesize field" -complete -c exa -l 'no-user' -d "Suppress the user field" -complete -c exa -l 'no-time' -d "Suppress the time field" - -# Optional extras -complete -c exa -l 'git' -d "List each file's Git status, if tracked" -complete -c exa -s '@' -l 'extended' -d "List each file's extended attributes and sizes" diff --git a/completions/fish/eza.fish b/completions/fish/eza.fish new file mode 100755 index 00000000..bd3049dd --- /dev/null +++ b/completions/fish/eza.fish @@ -0,0 +1,97 @@ +# Meta-stuff +complete -c eza -s v -l version -d "Show version of eza" +complete -c eza -s '?' -l help -d "Show list of command-line options" + +# Display options +complete -c eza -s 1 -l oneline -d "Display one entry per line" +complete -c eza -s l -l long -d "Display extended file metadata as a table" +complete -c eza -s G -l grid -d "Display entries in a grid" +complete -c eza -s x -l across -d "Sort the grid across, rather than downwards" +complete -c eza -s R -l recurse -d "Recurse into directories" +complete -c eza -s T -l tree -d "Recurse into directories as a tree" +complete -c eza -s F -l classify -d "Display type indicator by file names" +complete -c eza -l color \ + -l colour -d "When to use terminal colours" -x -a " + always\t'Always use colour' + auto\t'Use colour if standard output is a terminal' + never\t'Never use colour' +" +complete -c eza -l color-scale \ + -l colour-scale -d "Highlight levels of file sizes distinctly" +complete -c eza -l icons -d "Display icons" +complete -c eza -l no-icons -d "Don't display icons" +complete -c eza -l hyperlink -d "Display entries as hyperlinks" + +# Filtering and sorting options +complete -c eza -l group-directories-first -d "Sort directories before other files" +complete -c eza -l git-ignore -d "Ignore files mentioned in '.gitignore'" +complete -c eza -s a -l all -d "Show hidden and 'dot' files" +complete -c eza -s d -l list-dirs -d "List directories like regular files" +complete -c eza -s L -l level -d "Limit the depth of recursion" -x -a "1 2 3 4 5 6 7 8 9" +complete -c eza -s r -l reverse -d "Reverse the sort order" +complete -c eza -s s -l sort -d "Which field to sort by" -x -a " + accessed\t'Sort by file accessed time' + age\t'Sort by file modified time (newest first)' + changed\t'Sort by changed time' + created\t'Sort by file modified time' + date\t'Sort by file modified time' + ext\t'Sort by file extension' + Ext\t'Sort by file extension (uppercase first)' + extension\t'Sort by file extension' + Extension\t'Sort by file extension (uppercase first)' + filename\t'Sort by filename' + Filename\t'Sort by filename (uppercase first)' + inode\t'Sort by file inode' + modified\t'Sort by file modified time' + name\t'Sort by filename' + Name\t'Sort by filename (uppercase first)' + newest\t'Sort by file modified time (newest first)' + none\t'Do not sort files at all' + oldest\t'Sort by file modified time' + size\t'Sort by file size' + time\t'Sort by file modified time' + type\t'Sort by file type' +" + +complete -c eza -s I -l ignore-glob -d "Ignore files that match these glob patterns" -r +complete -c eza -s D -l only-dirs -d "List only directories" + +# Long view options +complete -c eza -s b -l binary -d "List file sizes with binary prefixes" +complete -c eza -s B -l bytes -d "List file sizes in bytes, without any prefixes" +complete -c eza -s g -l group -d "List each file's group" +complete -c eza -s h -l header -d "Add a header row to each column" +complete -c eza -s H -l links -d "List each file's number of hard links" +complete -c eza -s i -l inode -d "List each file's inode number" +complete -c eza -s S -l blocks -d "List each file's number of filesystem blocks" +complete -c eza -s t -l time -d "Which timestamp field to list" -x -a " + modified\t'Display modified time' + changed\t'Display changed time' + accessed\t'Display accessed time' + created\t'Display created time' +" +complete -c exa -s X -l dereference -d "dereference symlinks for file information" +complete -c eza -s m -l modified -d "Use the modified timestamp field" +complete -c eza -s n -l numeric -d "List numeric user and group IDs." +complete -c eza -l changed -d "Use the changed timestamp field" +complete -c eza -s u -l accessed -d "Use the accessed timestamp field" +complete -c eza -s U -l created -d "Use the created timestamp field" +complete -c eza -l time-style -d "How to format timestamps" -x -a " + default\t'Use the default time style' + iso\t'Display brief ISO timestamps' + long-iso\t'Display longer ISO timestaps, up to the minute' + full-iso\t'Display full ISO timestamps, up to the nanosecond' + relative\t'Display relative timestamps' +" +complete -c eza -l no-permissions -d "Suppress the permissions field" +complete -c eza -s o -l octal-permissions -d "List each file's permission in octal format" +complete -c eza -l no-filesize -d "Suppress the filesize field" +complete -c eza -l no-user -d "Suppress the user field" +complete -c eza -l no-time -d "Suppress the time field" + +# Optional extras +complete -c eza -l git -d "List each file's Git status, if tracked" +complete -c eza -l git-repos -d "List each git-repos status and branch name" +complete -c eza -l git-repos-no-status -d "List each git-repos branch name (much faster)" +complete -c eza -s '@' -l extended -d "List each file's extended attributes and sizes" +complete -c eza -s Z -l context -d "List each file's security context" diff --git a/completions/zsh/_exa b/completions/zsh/_eza similarity index 79% rename from completions/zsh/_exa rename to completions/zsh/_eza index b915a5d0..83aa7fc9 100644 --- a/completions/zsh/_exa +++ b/completions/zsh/_eza @@ -1,16 +1,16 @@ -#compdef exa +#compdef eza -# Save this file as _exa in /usr/local/share/zsh/site-functions or in any +# Save this file as _eza in /usr/local/share/zsh/site-functions or in any # other folder in $fpath. E.g. save it in a folder called ~/.zfunc and add a # line containing `fpath=(~/.zfunc $fpath)` somewhere before `compinit` in your # ~/.zshrc. -__exa() { +__eza() { # Give completions using the `_arguments` utility function with - # `-s` for option stacking like `exa -ab` for `exa -a -b` and - # `-S` for delimiting options with `--` like in `exa -- -a`. + # `-s` for option stacking like `eza -ab` for `eza -a -b` and + # `-S` for delimiting options with `--` like in `eza -- -a`. _arguments -s -S \ - "(- *)"{-v,--version}"[Show version of exa]" \ + "(- *)"{-v,--version}"[Show version of eza]" \ "(- *)"{-'\?',--help}"[Show list of command-line options]" \ {-1,--oneline}"[Display one entry per line]" \ {-l,--long}"[Display extended file metadata as a table]" \ @@ -23,6 +23,7 @@ __exa() { --colo{,u}r-scale"[Highlight levels of file sizes distinctly]" \ --icons"[Display icons]" \ --no-icons"[Hide icons]" \ + --hyperlink"[Display entries as hyperlinks]" \ --group-directories-first"[Sort directories before other files]" \ --git-ignore"[Ignore files mentioned in '.gitignore']" \ {-a,--all}"[Show hidden and 'dot' files]" \ @@ -43,17 +44,21 @@ __exa() { {-n,--numeric}"[List numeric user and group IDs.]" \ {-S,--blocks}"[List each file's number of filesystem blocks]" \ {-t,--time}="[Which time field to show]:(time field):(accessed changed created modified)" \ - --time-style="[How to format timestamps]:(time style):(default iso long-iso full-iso)" \ + --time-style="[How to format timestamps]:(time style):(default iso long-iso full-iso relative)" \ --no-permissions"[Suppress the permissions field]" \ - --octal-permissions"[List each file's permission in octal format]" \ + {-o, --octal-permissions}"[List each file's permission in octal format]" \ --no-filesize"[Suppress the filesize field]" \ --no-user"[Suppress the user field]" \ --no-time"[Suppress the time field]" \ {-u,--accessed}"[Use the accessed timestamp field]" \ {-U,--created}"[Use the created timestamp field]" \ + {-X,--dereference}"[dereference symlinks for file information]" \ --git"[List each file's Git status, if tracked]" \ + --git-repos"[List each git-repos status and branch name]" \ + --git-repos-no-status"[List each git-repos branch name (much faster)]" \ {-@,--extended}"[List each file's extended attributes and sizes]" \ + {-Z,--context}"[List each file's security context]" \ '*:filename:_files' } -__exa +__eza diff --git a/devtools/dev-create-test-filesystem.sh b/devtools/dev-create-test-filesystem.sh index 74d5e2ea..ac8e8c49 100755 --- a/devtools/dev-create-test-filesystem.sh +++ b/devtools/dev-create-test-filesystem.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script creates a bunch of awkward test case files. It gets # automatically run as part of Vagrant provisioning. trap 'exit' ERR diff --git a/devtools/dev-fixtures.sh b/devtools/dev-fixtures.sh index 34256b67..9710b574 100644 --- a/devtools/dev-fixtures.sh +++ b/devtools/dev-fixtures.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This file contains the text fixtures — the known, constant data — that are # used when setting up the environment that exa’s tests get run in. diff --git a/devtools/dev-run-debug.sh b/devtools/dev-run-debug.sh index 83b96fe8..2e69afec 100755 --- a/devtools/dev-run-debug.sh +++ b/devtools/dev-run-debug.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ -f ~/target/debug/exa ]]; then ~/target/debug/exa "$@" else diff --git a/devtools/dev-run-release.sh b/devtools/dev-run-release.sh index 314e0154..d8f3e363 100755 --- a/devtools/dev-run-release.sh +++ b/devtools/dev-run-release.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ -f ~/target/release/exa ]]; then ~/target/release/exa "$@" else diff --git a/devtools/dev-set-up-environment.sh b/devtools/dev-set-up-environment.sh index 518e6e40..d590a9de 100755 --- a/devtools/dev-set-up-environment.sh +++ b/devtools/dev-set-up-environment.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ ! -d "/vagrant" ]]; then echo "This script should be run in the Vagrant environment" diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..5ffc75ef --- /dev/null +++ b/flake.lock @@ -0,0 +1,197 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1689068808, + "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1681202837, + "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "naersk": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1690373729, + "narHash": "sha256-e136hTT7LqQ2QjOTZQMW+jnsevWwBpMj78u6FRUsH9I=", + "owner": "nix-community", + "repo": "naersk", + "rev": "d9a33d69a9c421d64c8d925428864e93be895dcc", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "naersk", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1688231357, + "narHash": "sha256-ZOn16X5jZ6X5ror58gOJAxPfFLAQhZJ6nOUeS4tfFwo=", + "path": "/nix/store/aw6kmwd8a02n2c1wysrfk2q31brlmqdz-source", + "rev": "645ff62e09d294a30de823cb568e9c6d68e92606", + "type": "path" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1690593349, + "narHash": "sha256-i6jdORO+YiP19pFNeR7oYIIwmzQvdxwNO+BmtATcYpA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "11cf5e1c74fe6892e860afeeaf3bfb84fdb7b1c3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1681358109, + "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1680945546, + "narHash": "sha256-8FuaH5t/aVi/pR1XxnF0qi4WwMYC+YxlfdsA0V+TEuQ=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d9f759f2ea8d265d974a6e1259bd510ac5844c5d", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "naersk": "naersk", + "nixpkgs": "nixpkgs_2", + "rust-overlay": "rust-overlay", + "treefmt-nix": "treefmt-nix" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1690596958, + "narHash": "sha256-SWqxUiEP9O2gvlWtR4Ku6rIMGM7PuNZreAPrU2yAjsk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "5c06b0ed7bfb00f3a925af6c4acd1636596381c1", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1689620039, + "narHash": "sha256-BtNwghr05z7k5YMdq+6nbue+nEalvDepuA7qdQMAKoQ=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "719c2977f958c41fa60a928e2fbc50af14844114", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..f61664d4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,79 @@ +{ + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + naersk.url = "github:nix-community/naersk"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; + + outputs = { + self, + flake-utils, + naersk, + nixpkgs, + treefmt-nix, + rust-overlay, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + overlays = [(import rust-overlay)]; + + pkgs = (import nixpkgs) { + inherit system overlays; + }; + + toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + + naersk' = pkgs.callPackage naersk { + cargo = toolchain; + rustc = toolchain; + clippy = toolchain; + }; + + treefmtEval = treefmt-nix.lib.evalModule pkgs ./treefmt.nix; + in rec { + # For `nix fmt` + formatter = treefmtEval.config.build.wrapper; + + packages = { + # For `nix build` & `nix run`: + default = naersk'.buildPackage { + src = ./.; + doCheck = true; # run `cargo test` on build + }; + + # Run `nix build .#check` to check code + check = naersk'.buildPackage { + src = ./.; + mode = "check"; + }; + + # Run `nix build .#test` to run tests + test = naersk'.buildPackage { + src = ./.; + mode = "test"; + }; + + # Run `nix build .#clippy` to lint code + clippy = naersk'.buildPackage { + src = ./.; + mode = "clippy"; + }; + }; + + # For `nix develop`: + devShells.default = pkgs.mkShell { + nativeBuildInputs = with pkgs; [toolchain]; + }; + + # for `nix flake check` + checks = { + formatting = treefmtEval.config.build.check self; + build = packages.check; + test = packages.test; + lint = packages.clippy; + }; + } + ); +} diff --git a/man/exa.1.md b/man/eza.1.md similarity index 79% rename from man/exa.1.md rename to man/eza.1.md index 17b9d4d2..c0b5b8d3 100644 --- a/man/exa.1.md +++ b/man/eza.1.md @@ -1,6 +1,6 @@ -% exa(1) v0.9.0 +% eza(1) v0.9.0 - + @@ -8,15 +8,15 @@ NAME ==== -exa — a modern replacement for ls +eza — a modern replacement for ls SYNOPSIS ======== -`exa [options] [files...]` +`eza [options] [files...]` -**exa** is a modern replacement for `ls`. +**eza** is a modern replacement for `ls`. It uses colours for information by default, helping you distinguish between many types of files, such as whether you are the owner, or in the owning group. It also has extra features not present in the original `ls`, such as viewing the Git status for a directory, or recursing into directories with a tree view. @@ -25,16 +25,16 @@ It also has extra features not present in the original `ls`, such as viewing the EXAMPLES ======== -`exa` +`eza` : Lists the contents of the current directory in a grid. -`exa --oneline --reverse --sort=size` +`eza --oneline --reverse --sort=size` : Displays a list of files with the largest at the top. -`exa --long --header --inode --git` +`eza --long --header --inode --git` : Displays a table of files with a header, showing each file’s metadata, inode, and Git status. -`exa --long --tree --level=3` +`eza --long --tree --level=3` : Displays a tree of files, three levels deep, as well as each file’s metadata. @@ -75,6 +75,9 @@ Valid settings are ‘`always`’, ‘`automatic`’, and ‘`never`’. `--no-icons` : Don't display icons. (Always overrides --icons) +`--hyperlink` +: Display entries as hyperlinks + FILTERING AND SORTING OPTIONS ============================= @@ -104,7 +107,7 @@ Sort fields starting with a capital letter will sort uppercase before lowercase: `-I`, `--ignore-glob=GLOBS` : Glob patterns, pipe-separated, of files to ignore. -`--git-ignore` [if exa was built with git support] +`--git-ignore` [if eza was built with git support] : Do not list files that are ignored by Git. `--group-directories-first` @@ -157,7 +160,7 @@ These options are available when running with `--long` (`-l`): `--time-style=STYLE` : How to format timestamps. -: Valid timestamp styles are ‘`default`’, ‘`iso`’, ‘`long-iso`’, and ‘`full-iso`’. +: Valid timestamp styles are ‘`default`’, ‘`iso`’, ‘`long-iso`’, ‘`full-iso`’, and ‘`relative`’. `-u`, `--accessed` : Use the accessed timestamp field. @@ -168,6 +171,9 @@ These options are available when running with `--long` (`-l`): `--no-permissions` : Suppress the permissions field. +`-o`, `--octal-permissions` +: List each file's permissions in octal format. + `--no-filesize` : Suppress the file size field. @@ -180,7 +186,10 @@ These options are available when running with `--long` (`-l`): `-@`, `--extended` : List each file’s extended attributes and sizes. -`--git` [if exa was built with git support] +`-Z`, `--context` +: List each file's security context. + +`--git` [if eza was built with git support] : List each file’s Git status, if tracked. This adds a two-character column indicating the staged and unstaged statuses respectively. The status character can be ‘`-`’ for not modified, ‘`M`’ for a modified file, ‘`N`’ for a new file, ‘`D`’ for deleted, ‘`R`’ for renamed, ‘`T`’ for type-change, ‘`I`’ for ignored, and ‘`U`’ for conflicted. @@ -191,29 +200,29 @@ Directories will be shown to have the status of their contents, which is how ‘ ENVIRONMENT VARIABLES ===================== -exa responds to the following environment variables: +eza responds to the following environment variables: ## `COLUMNS` Overrides the width of the terminal, in characters. -For example, ‘`COLUMNS=80 exa`’ will show a grid view with a maximum width of 80 characters. +For example, ‘`COLUMNS=80 eza`’ will show a grid view with a maximum width of 80 characters. -This option won’t do anything when exa’s output doesn’t wrap, such as when using the `--long` view. +This option won’t do anything when eza’s output doesn’t wrap, such as when using the `--long` view. ## `EXA_STRICT` -Enables _strict mode_, which will make exa error when two command-line options are incompatible. +Enables _strict mode_, which will make eza error when two command-line options are incompatible. -Usually, options can override each other going right-to-left on the command line, so that exa can be given aliases: creating an alias ‘`exa=exa --sort=ext`’ then running ‘`exa --sort=size`’ with that alias will run ‘`exa --sort=ext --sort=size`’, and the sorting specified by the user will override the sorting specified by the alias. +Usually, options can override each other going right-to-left on the command line, so that eza can be given aliases: creating an alias ‘`eza=eza --sort=ext`’ then running ‘`eza --sort=size`’ with that alias will run ‘`eza --sort=ext --sort=size`’, and the sorting specified by the user will override the sorting specified by the alias. -In strict mode, the two options will not co-operate, and exa will error. +In strict mode, the two options will not co-operate, and eza will error. This option is intended for use with automated scripts and other situations where you want to be certain you’re typing in the right command. ## `EXA_GRID_ROWS` -Limits the grid-details view (‘`exa --grid --long`’) so it’s only activated when at least the given number of rows of output would be generated. +Limits the grid-details view (‘`eza --grid --long`’) so it’s only activated when at least the given number of rows of output would be generated. With widescreen displays, it’s possible for the grid to look very wide and sparse, on just one or two lines with none of the columns lining up. By specifying a minimum number of rows, you can only use the view if it’s going to be worth using. @@ -222,7 +231,7 @@ By specifying a minimum number of rows, you can only use the view if it’s goin Specifies the number of spaces to print between an icon (see the ‘`--icons`’ option) and its file name. -Different terminals display icons differently, as they usually take up more than one character width on screen, so there’s no “standard” number of spaces that exa can use to separate an icon from text. One space may place the icon too close to the text, and two spaces may place it too far away. So the choice is left up to the user to configure depending on their terminal emulator. +Different terminals display icons differently, as they usually take up more than one character width on screen, so there’s no “standard” number of spaces that eza can use to separate an icon from text. One space may place the icon too close to the text, and two spaces may place it too far away. So the choice is left up to the user to configure depending on their terminal emulator. ## `NO_COLOR` @@ -234,7 +243,7 @@ See `https://no-color.org/` for details. Specifies the colour scheme used to highlight files based on their name and kind, as well as highlighting metadata and parts of the UI. -For more information on the format of these environment variables, see the [exa_colors.5.md](exa_colors.5.md) manual page. +For more information on the format of these environment variables, see the [eza_colors.5.md](eza_colors.5.md) manual page. EXIT STATUSES @@ -253,15 +262,15 @@ EXIT STATUSES AUTHOR ====== -exa is maintained by Benjamin ‘ogham’ Sago and many other contributors. +eza is maintained by Christina Sørensen and many other contributors. -**Website:** `https://the.exa.website/` \ -**Source code:** `https://github.com/ogham/exa` \ -**Contributors:** `https://github.com/ogham/exa/graphs/contributors` +**Source code:** `https://github.com/cafkafk/eza` \ +**Contributors:** `https://github.com/cafkafk/eza/graphs/contributors` +Our infinite thanks to Benjamin ‘ogham’ Sago and all the other contributors of exa, from which eza was forked. SEE ALSO ======== -- [exa_colors.5.md](exa_colors.5.md) -- [exa-colors-explanation.md](exa-colors-explanation.md) \ No newline at end of file +- [eza_colors.5.md](eza_colors.5.md) +- [eza-colors-explanation.md](eza-colors-explanation.md) diff --git a/man/exa_colors.5.md b/man/eza_colors.5.md similarity index 85% rename from man/exa_colors.5.md rename to man/eza_colors.5.md index ef061011..de9a3c55 100644 --- a/man/exa_colors.5.md +++ b/man/eza_colors.5.md @@ -1,6 +1,6 @@ -% exa_colors(5) v0.9.0 +% eza_colors(5) v0.9.0 - + @@ -8,13 +8,13 @@ NAME ==== -exa_colors — customising the file and UI colours of exa +eza_colors — customising the file and UI colours of eza SYNOPSIS ======== -The `EXA_COLORS` environment variable can be used to customise the colours that `exa` uses to highlight file names, file metadata, and parts of the UI. +The `EXA_COLORS` environment variable can be used to customise the colours that `eza` uses to highlight file names, file metadata, and parts of the UI. You can use the `dircolors` program to generate a script that sets the variable from an input file, or if you don’t mind editing long strings of text, you can just type it out directly. These variables have the following structure: @@ -223,9 +223,9 @@ Values in `EXA_COLORS` override those given in `LS_COLORS`, so you don’t need LIST OF STYLES ============== -Unlike some versions of `ls`, the given ANSI values must be valid colour codes: exa won’t just print out whichever characters are given. +Unlike some versions of `ls`, the given ANSI values must be valid colour codes: eza won’t just print out whichever characters are given. -The codes accepted by exa are: +The codes accepted by eza are: `1` : for bold @@ -259,8 +259,8 @@ The codes accepted by exa are: Many terminals will treat bolded text as a different colour, or at least provide the option to. -exa provides its own built-in set of file extension mappings that cover a large range of common file extensions, including documents, archives, media, and temporary files. -Any mappings in the environment variables will override this default set: running exa with `LS_COLORS="*.zip=32"` will turn zip files green but leave the colours of other compressed files alone. +eza provides its own built-in set of file extension mappings that cover a large range of common file extensions, including documents, archives, media, and temporary files. +Any mappings in the environment variables will override this default set: running eza with `LS_COLORS="*.zip=32"` will turn zip files green but leave the colours of other compressed files alone. You can also disable this built-in set entirely by including a `reset` entry at the beginning of `EXA_COLORS`. So setting `EXA_COLORS="reset:*.txt=31"` will highlight only text files; setting `EXA_COLORS="reset"` will highlight nothing. @@ -269,15 +269,16 @@ So setting `EXA_COLORS="reset:*.txt=31"` will highlight only text files; setting AUTHOR ====== -exa is maintained by Benjamin ‘ogham’ Sago and many other contributors. +eza is maintained by Christina Sørensen and many other contributors. -**Website:** `https://the.exa.website/` \ -**Source code:** `https://github.com/ogham/exa` \ -**Contributors:** `https://github.com/ogham/exa/graphs/contributors` +**Source code:** `https://github.com/cafkafk/eza` \ +**Contributors:** `https://github.com/cafkafk/eza/graphs/contributors` + +Our infinite thanks to Benjamin ‘ogham’ Sago and all the other contributors of exa, from which eza was forked. SEE ALSO ======== -- [exa.1.md](exa.1.md) -- [exa-colors-explanation.md](exa-colors-explanation.md) +- [eza.1.md](eza.1.md) +- [eza-colors-explanation.md](eza-colors-explanation.md) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 071aaaa8..a4c09c31 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,4 @@ [toolchain] -channel = "1.56.1" +channel = "nightly" +components = [ "rustfmt", "rustc", "rust-src", "rust-analyzer", "cargo", "clippy" ] +profile = "minimal" diff --git a/screenshots.png b/screenshots.png index 34cb4bc9..a8770f8e 100644 Binary files a/screenshots.png and b/screenshots.png differ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 36a6b214..2a6190b9 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,4 +1,4 @@ -name: exa +name: eza version: 'latest' summary: Replacement for 'ls' written in Rust description: | @@ -6,17 +6,17 @@ description: | many types of files, such as whether you are the owner, or in the owning group. It also has extra features not present in the original ls, such as viewing the Git status for a directory, or recursing into directories with a - tree view. exa is written in Rust, so it’s small, fast, and portable. + tree view. eza is written in Rust, and it’s small, fast, and portable. grade: stable confinement: classic apps: - exa: - command: exa + eza: + command: eza parts: - exa: + eza: plugin: rust source: . stage-packages: diff --git a/src/fs/dir.rs b/src/fs/dir.rs index 1a6ce014..f2d70931 100644 --- a/src/fs/dir.rs +++ b/src/fs/dir.rs @@ -47,7 +47,7 @@ impl Dir { /// Produce an iterator of IO results of trying to read all the files in /// this directory. - pub fn files<'dir, 'ig>(&'dir self, dots: DotFilter, git: Option<&'ig GitCache>, git_ignoring: bool) -> Files<'dir, 'ig> { + pub fn files<'dir, 'ig>(&'dir self, dots: DotFilter, git: Option<&'ig GitCache>, git_ignoring: bool, deref_links: bool) -> Files<'dir, 'ig> { Files { inner: self.contents.iter(), dir: self, @@ -55,6 +55,7 @@ impl Dir { dots: dots.dots(), git, git_ignoring, + deref_links, } } @@ -89,6 +90,9 @@ pub struct Files<'dir, 'ig> { git: Option<&'ig GitCache>, git_ignoring: bool, + + /// Whether symbolic links should be dereferenced when querying information. + deref_links: bool, } impl<'dir, 'ig> Files<'dir, 'ig> { @@ -111,6 +115,13 @@ impl<'dir, 'ig> Files<'dir, 'ig> { continue; } + // Also hide _prefix files on Windows because it's used by old applications + // as an alternative to dot-prefix files. + #[cfg(windows)] + if ! self.dotfiles && filename.starts_with('_') { + continue; + } + if self.git_ignoring { let git_status = self.git.map(|g| g.get(path, false)).unwrap_or_default(); if git_status.unstaged == GitStatus::Ignored { @@ -118,7 +129,7 @@ impl<'dir, 'ig> Files<'dir, 'ig> { } } - return Some(File::from_args(path.clone(), self.dir, filename) + return Some(File::from_args(path.clone(), self.dir, filename, self.deref_links) .map_err(|e| (path.clone(), e))) } @@ -169,7 +180,7 @@ impl<'dir, 'ig> Iterator for Files<'dir, 'ig> { /// Usually files in Unix use a leading dot to be hidden or visible, but two /// entries in particular are “extra-hidden”: `.` and `..`, which only become /// visible after an extra `-a` option. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum DotFilter { /// Shows files, dotfiles, and `.` and `..`. diff --git a/src/fs/dir_action.rs b/src/fs/dir_action.rs index ab8cedab..6e10403d 100644 --- a/src/fs/dir_action.rs +++ b/src/fs/dir_action.rs @@ -19,7 +19,7 @@ /// into them and print out their contents. The recurse mode does this by /// having extra output blocks at the end, while the tree mode will show /// directories inline, with their contents immediately underneath. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum DirAction { /// This directory should be listed along with the regular files, instead @@ -58,7 +58,7 @@ impl DirAction { /// The options that determine how to recurse into a directory. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub struct RecurseOptions { /// Whether recursion should be done as a tree or as multiple individual diff --git a/src/fs/feature/git.rs b/src/fs/feature/git.rs index 1a0c60db..34bfcfaf 100644 --- a/src/fs/feature/git.rs +++ b/src/fs/feature/git.rs @@ -162,11 +162,25 @@ impl GitRepo { /// Returns the original buffer if none is found. fn discover(path: PathBuf) -> Result { info!("Searching for Git repository above {:?}", path); - let repo = match git2::Repository::discover(&path) { + // Search with GIT_DIR env variable first if set + let repo = match git2::Repository::open_from_env() { Ok(r) => r, Err(e) => { - error!("Error discovering Git repositories: {:?}", e); - return Err(path); + // anything other than NotFound implies GIT_DIR was set and we got actual error + if e.code() != git2::ErrorCode::NotFound { + error!("Error opening Git repo from env using GIT_DIR: {:?}", e); + return Err(path); + } else { + // nothing found, search using discover + match git2::Repository::discover(&path) { + Ok(r) => r, + Err(e) => { + error!("Error discovering Git repositories: {:?}", e); + return Err(path); + + } + } + } } }; @@ -296,6 +310,7 @@ impl Git { /// Paths need to be absolute for them to be compared properly, otherwise /// you’d ask a repo about “./README.md” but it only knows about /// “/vagrant/README.md”, prefixed by the workdir. +#[cfg(unix)] fn reorient(path: &Path) -> PathBuf { use std::env::current_dir; @@ -308,6 +323,14 @@ fn reorient(path: &Path) -> PathBuf { path.canonicalize().unwrap_or(path) } +#[cfg(windows)] +fn reorient(path: &Path) -> PathBuf { + let unc_path = path.canonicalize().unwrap(); + // On Windows UNC path is returned. We need to strip the prefix for it to work. + let normal_path = unc_path.as_os_str().to_str().unwrap().trim_left_matches("\\\\?\\"); + return PathBuf::from(normal_path); +} + /// The character to display if the file has been modified, but not staged. fn working_tree_status(status: git2::Status) -> f::GitStatus { match status { @@ -334,3 +357,52 @@ fn index_status(status: git2::Status) -> f::GitStatus { _ => f::GitStatus::NotModified, } } + +fn current_branch(repo: &git2::Repository) -> Option{ + let head = match repo.head() { + Ok(head) => Some(head), + Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch || e.code() == git2::ErrorCode::NotFound => return None, + Err(e) => { + error!("Error looking up Git branch: {:?}", e); + return None + } + }; + + if let Some(h) = head{ + if let Some(s) = h.shorthand(){ + let branch_name = s.to_owned(); + if branch_name.len() > 10 { + return Some(branch_name[..8].to_string()+".."); + } + return Some(branch_name); + } + } + None +} + +impl f::SubdirGitRepo{ + pub fn from_path(dir : &Path, status : bool) -> Self{ + + let path = &reorient(&dir); + let g = git2::Repository::open(path); + if let Ok(repo) = g{ + + let branch = current_branch(&repo); + if !status{ + return Self{status : f::SubdirGitRepoStatus::GitUnknown, branch}; + } + match repo.statuses(None) { + Ok(es) => { + if es.iter().filter(|s| s.status() != git2::Status::IGNORED).any(|_| true){ + return Self{status : f::SubdirGitRepoStatus::GitDirty, branch}; + } + return Self{status : f::SubdirGitRepoStatus::GitClean, branch}; + } + Err(e) => { + error!("Error looking up Git statuses: {:?}", e) + } + } + } + Self::default() + } +} \ No newline at end of file diff --git a/src/fs/feature/mod.rs b/src/fs/feature/mod.rs index 10f49159..9be9d7ae 100644 --- a/src/fs/feature/mod.rs +++ b/src/fs/feature/mod.rs @@ -30,4 +30,10 @@ pub mod git { unreachable!(); } } + + impl f::SubdirGitRepo{ + pub fn from_path(_dir : &Path, _status : bool) -> Self{ + panic!("Tried to get subdir Git status, but Git support is disabled") + } + } } diff --git a/src/fs/feature/xattr.rs b/src/fs/feature/xattr.rs index 65b8c951..107e29ed 100644 --- a/src/fs/feature/xattr.rs +++ b/src/fs/feature/xattr.rs @@ -3,6 +3,7 @@ #![allow(trivial_casts)] // for ARM use std::cmp::Ordering; +use std::ffi::CString; use std::io; use std::path::Path; @@ -50,58 +51,98 @@ pub enum FollowSymlinks { #[derive(Debug, Clone)] pub struct Attribute { pub name: String, - pub size: usize, + pub value: String, } #[cfg(any(target_os = "macos", target_os = "linux"))] -pub fn list_attrs(lister: &lister::Lister, path: &Path) -> io::Result> { - use std::ffi::CString; +fn get_secattr(lister: &lister::Lister, c_path: &std::ffi::CString) -> io::Result> { + const SELINUX_XATTR_NAME: &str = "security.selinux"; + const ENODATA: i32 = 61; - let c_path = match path.to_str().and_then(|s| CString::new(s).ok()) { - Some(cstring) => cstring, - None => { - return Err(io::Error::new(io::ErrorKind::Other, "Error: path somehow contained a NUL?")); - } + let c_attr_name = CString::new(SELINUX_XATTR_NAME).map_err(|e| { + io::Error::new(io::ErrorKind::Other, e) + })?; + let size = lister.getxattr_first(c_path, &c_attr_name); + + let size = match size.cmp(&0) { + Ordering::Less => { + let e = io::Error::last_os_error(); + + if e.kind() == io::ErrorKind::Other && e.raw_os_error() == Some(ENODATA) { + return Ok(Vec::new()) + } + + return Err(e) + }, + Ordering::Equal => return Err(io::Error::from(io::ErrorKind::InvalidData)), + Ordering::Greater => size as usize, }; - let bufsize = lister.listxattr_first(&c_path); - match bufsize.cmp(&0) { - Ordering::Less => return Err(io::Error::last_os_error()), - Ordering::Equal => return Ok(Vec::new()), - Ordering::Greater => {}, + let mut buf_value = vec![0_u8; size]; + let size = lister.getxattr_second(c_path, &c_attr_name, &mut buf_value, size); + + match size.cmp(&0) { + Ordering::Less => return Err(io::Error::last_os_error()), + Ordering::Equal => return Err(io::Error::from(io::ErrorKind::InvalidData)), + Ordering::Greater => (), } - let mut buf = vec![0_u8; bufsize as usize]; - let err = lister.listxattr_second(&c_path, &mut buf, bufsize); + Ok(vec![Attribute { + name: String::from(SELINUX_XATTR_NAME), + value: lister.translate_attribute_data(&buf_value), + }]) +} - match err.cmp(&0) { +pub fn list_attrs(lister: &lister::Lister, path: &Path) -> io::Result> { + let c_path = CString::new(path.to_str().ok_or(io::Error::new(io::ErrorKind::Other, "Error: path not convertible to string"))?).map_err(|e| { + io::Error::new(io::ErrorKind::Other, e) + })?; + + let bufsize = lister.listxattr_first(&c_path); + let bufsize = match bufsize.cmp(&0) { + Ordering::Less => return Err(io::Error::last_os_error()), + // Some filesystems, like sysfs, return nothing on listxattr, even though the security + // attribute is set. + Ordering::Equal => return get_secattr(lister, &c_path), + Ordering::Greater => bufsize as usize, + }; + + let mut buf = vec![0_u8; bufsize]; + + match lister.listxattr_second(&c_path, &mut buf, bufsize).cmp(&0) { Ordering::Less => return Err(io::Error::last_os_error()), Ordering::Equal => return Ok(Vec::new()), Ordering::Greater => {}, } let mut names = Vec::new(); - if err > 0 { - // End indices of the attribute names - // the buffer contains 0-terminated c-strings - let idx = buf.iter().enumerate().filter_map(|(i, v)| - if *v == 0 { Some(i) } else { None } - ); - let mut start = 0; - for end in idx { - let c_end = end + 1; // end of the c-string (including 0) - let size = lister.getxattr(&c_path, &buf[start..c_end]); + for attr_name in buf.split(|c| c == &0) { + if attr_name.is_empty() { + continue; + } - if size > 0 { - names.push(Attribute { - name: lister.translate_attribute_name(&buf[start..end]), - size: size as usize, - }); + let c_attr_name = CString::new(attr_name).map_err(|e| { + io::Error::new(io::ErrorKind::Other, e) + })?; + let size = lister.getxattr_first(&c_path, &c_attr_name); + + if size > 0 { + let mut buf_value = vec![0_u8; size as usize]; + if lister.getxattr_second(&c_path, &c_attr_name, &mut buf_value, size as usize) < 0 { + return Err(io::Error::last_os_error()); } - start = c_end; + names.push(Attribute { + name: lister.translate_attribute_data(attr_name), + value: lister.translate_attribute_data(&buf_value), + }); + } else { + names.push(Attribute { + name: lister.translate_attribute_data(attr_name), + value: String::new(), + }); } } @@ -148,8 +189,8 @@ mod lister { Self { c_flags } } - pub fn translate_attribute_name(&self, input: &[u8]) -> String { - unsafe { std::str::from_utf8_unchecked(input).into() } + pub fn translate_attribute_data(&self, input: &[u8]) -> String { + unsafe { std::str::from_utf8_unchecked(input).trim_end_matches('\0').into() } } pub fn listxattr_first(&self, c_path: &CString) -> ssize_t { @@ -163,22 +204,22 @@ mod lister { } } - pub fn listxattr_second(&self, c_path: &CString, buf: &mut Vec, bufsize: ssize_t) -> ssize_t { + pub fn listxattr_second(&self, c_path: &CString, buf: &mut [u8], bufsize: size_t) -> ssize_t { unsafe { listxattr( c_path.as_ptr(), - buf.as_mut_ptr() as *mut c_char, - bufsize as size_t, + buf.as_mut_ptr().cast(), + bufsize, self.c_flags, ) } } - pub fn getxattr(&self, c_path: &CString, buf: &[u8]) -> ssize_t { + pub fn getxattr_first(&self, c_path: &CString, c_name: &CString) -> ssize_t { unsafe { getxattr( c_path.as_ptr(), - buf.as_ptr() as *const c_char, + c_name.as_ptr().cast(), ptr::null_mut(), 0, 0, @@ -186,6 +227,19 @@ mod lister { ) } } + + pub fn getxattr_second(&self, c_path: &CString, c_name: &CString, buf: &mut [u8], bufsize: size_t) -> ssize_t { + unsafe { + getxattr( + c_path.as_ptr(), + c_name.as_ptr().cast(), + buf.as_mut_ptr().cast::(), + bufsize, + 0, + self.c_flags, + ) + } + } } } @@ -234,8 +288,8 @@ mod lister { Lister { follow_symlinks } } - pub fn translate_attribute_name(&self, input: &[u8]) -> String { - String::from_utf8_lossy(input).into_owned() + pub fn translate_attribute_data(&self, input: &[u8]) -> String { + String::from_utf8_lossy(input).trim_end_matches('\0').into() } pub fn listxattr_first(&self, c_path: &CString) -> ssize_t { @@ -246,14 +300,14 @@ mod lister { unsafe { listxattr( - c_path.as_ptr().cast(), + c_path.as_ptr(), ptr::null_mut(), 0, ) } } - pub fn listxattr_second(&self, c_path: &CString, buf: &mut Vec, bufsize: ssize_t) -> ssize_t { + pub fn listxattr_second(&self, c_path: &CString, buf: &mut [u8], bufsize: size_t) -> ssize_t { let listxattr = match self.follow_symlinks { FollowSymlinks::Yes => listxattr, FollowSymlinks::No => llistxattr, @@ -261,27 +315,43 @@ mod lister { unsafe { listxattr( - c_path.as_ptr().cast(), + c_path.as_ptr(), buf.as_mut_ptr().cast(), - bufsize as size_t, + bufsize, ) } } - pub fn getxattr(&self, c_path: &CString, buf: &[u8]) -> ssize_t { + pub fn getxattr_first(&self, c_path: &CString, c_name: &CString) -> ssize_t { let getxattr = match self.follow_symlinks { - FollowSymlinks::Yes => getxattr, - FollowSymlinks::No => lgetxattr, + FollowSymlinks::Yes => getxattr, + FollowSymlinks::No => lgetxattr, }; unsafe { getxattr( - c_path.as_ptr().cast(), - buf.as_ptr().cast(), + c_path.as_ptr(), + c_name.as_ptr().cast(), ptr::null_mut(), 0, ) } } + + pub fn getxattr_second(&self, c_path: &CString, c_name: &CString, buf: &mut [u8], bufsize: size_t) -> ssize_t { + let getxattr = match self.follow_symlinks { + FollowSymlinks::Yes => getxattr, + FollowSymlinks::No => lgetxattr, + }; + + unsafe { + getxattr( + c_path.as_ptr(), + c_name.as_ptr().cast(), + buf.as_mut_ptr().cast::(), + bufsize, + ) + } + } } } diff --git a/src/fs/fields.rs b/src/fs/fields.rs index d768ebf7..e4475b55 100644 --- a/src/fs/fields.rs +++ b/src/fs/fields.rs @@ -82,13 +82,27 @@ pub struct Permissions { pub setuid: bool, } +/// The file's FileAttributes field, available only on Windows. +#[derive(Copy, Clone)] +pub struct Attributes { + pub archive: bool, + pub directory: bool, + pub readonly: bool, + pub hidden: bool, + pub system: bool, + pub reparse_point: bool, +} + /// The three pieces of information that are displayed as a single column in /// the details view. These values are fused together to make the output a /// little more compressed. #[derive(Copy, Clone)] pub struct PermissionsPlus { pub file_type: Type, + #[cfg(unix)] pub permissions: Permissions, + #[cfg(windows)] + pub attributes: Attributes, pub xattrs: bool, } @@ -196,7 +210,7 @@ pub struct Time { /// A file’s status in a Git repository. Whether a file is in a repository or /// not is handled by the Git module, rather than having a “null” variant in /// this enum. -#[derive(PartialEq, Copy, Clone)] +#[derive(PartialEq, Eq, Copy, Clone)] pub enum GitStatus { /// This file hasn’t changed since the last commit. @@ -245,3 +259,36 @@ impl Default for Git { } } } + +pub enum SecurityContextType<'a> { + SELinux(&'a str), + None +} + +pub struct SecurityContext<'a> { + pub context: SecurityContextType<'a>, +} + +#[allow(dead_code)] +#[derive(PartialEq, Copy, Clone)] +pub enum SubdirGitRepoStatus{ + NoRepo, + GitClean, + GitDirty, + GitUnknown +} + +#[derive(Clone)] +pub struct SubdirGitRepo{ + pub status : SubdirGitRepoStatus, + pub branch : Option +} + +impl Default for SubdirGitRepo{ + fn default() -> Self { + Self{ + status : SubdirGitRepoStatus::NoRepo, + branch : None + } + } +} diff --git a/src/fs/file.rs b/src/fs/file.rs index ea83f08b..54fd8705 100644 --- a/src/fs/file.rs +++ b/src/fs/file.rs @@ -1,13 +1,18 @@ //! Files, and methods and fields to access their metadata. use std::io; +#[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use log::*; use crate::fs::dir::Dir; +use crate::fs::feature::xattr; +use crate::fs::feature::xattr::{FileAttributes, Attribute}; use crate::fs::fields as f; @@ -63,10 +68,19 @@ pub struct File<'dir> { /// directory’s children, and are in fact added specifically by exa; this /// means that they should be skipped when recursing. pub is_all_all: bool, + + /// Whether to dereference symbolic links when querying for information. + /// + /// For instance, when querying the size of a symbolic link, if + /// dereferencing is enabled, the size of the target will be displayed + /// instead. + pub deref_links: bool, + /// The extended attributes of this file. + pub extended_attributes: Vec, } impl<'dir> File<'dir> { - pub fn from_args(path: PathBuf, parent_dir: PD, filename: FN) -> io::Result> + pub fn from_args(path: PathBuf, parent_dir: PD, filename: FN, deref_links: bool) -> io::Result> where PD: Into>, FN: Into> { @@ -77,8 +91,9 @@ impl<'dir> File<'dir> { debug!("Statting file {:?}", &path); let metadata = std::fs::symlink_metadata(&path)?; let is_all_all = false; + let extended_attributes = File::gather_extended_attributes(&path); - Ok(File { name, ext, path, metadata, parent_dir, is_all_all }) + Ok(File { name, ext, path, metadata, parent_dir, is_all_all, deref_links, extended_attributes }) } pub fn new_aa_current(parent_dir: &'dir Dir) -> io::Result> { @@ -89,8 +104,9 @@ impl<'dir> File<'dir> { let metadata = std::fs::symlink_metadata(&path)?; let is_all_all = true; let parent_dir = Some(parent_dir); + let extended_attributes = File::gather_extended_attributes(&path); - Ok(File { path, parent_dir, metadata, ext, name: ".".into(), is_all_all }) + Ok(File { path, parent_dir, metadata, ext, name: ".".into(), is_all_all, deref_links: false, extended_attributes }) } pub fn new_aa_parent(path: PathBuf, parent_dir: &'dir Dir) -> io::Result> { @@ -100,8 +116,9 @@ impl<'dir> File<'dir> { let metadata = std::fs::symlink_metadata(&path)?; let is_all_all = true; let parent_dir = Some(parent_dir); + let extended_attributes = File::gather_extended_attributes(&path); - Ok(File { path, parent_dir, metadata, ext, name: "..".into(), is_all_all }) + Ok(File { path, parent_dir, metadata, ext, name: "..".into(), is_all_all, deref_links: false, extended_attributes }) } /// A file’s name is derived from its string. This needs to handle directories @@ -134,6 +151,21 @@ impl<'dir> File<'dir> { .to_ascii_lowercase()) } + /// Read the extended attributes of a file path. + fn gather_extended_attributes(path: &Path) -> Vec { + if xattr::ENABLED { + match path.symlink_attributes() { + Ok(xattrs) => xattrs, + Err(e) => { + error!("Error looking up extended attributes for {}: {}", path.display(), e); + Vec::new() + } + } + } else { + Vec::new() + } + } + /// Whether this file is a directory on the filesystem. pub fn is_directory(&self) -> bool { self.metadata.is_dir() @@ -174,6 +206,7 @@ impl<'dir> File<'dir> { /// Whether this file is both a regular file *and* executable for the /// current user. An executable file has a different purpose from an /// executable directory, so they should be highlighted differently. + #[cfg(unix)] pub fn is_executable_file(&self) -> bool { let bit = modes::USER_EXECUTE; self.is_file() && (self.metadata.permissions().mode() & bit) == bit @@ -185,21 +218,25 @@ impl<'dir> File<'dir> { } /// Whether this file is a named pipe on the filesystem. + #[cfg(unix)] pub fn is_pipe(&self) -> bool { self.metadata.file_type().is_fifo() } /// Whether this file is a char device on the filesystem. + #[cfg(unix)] pub fn is_char_device(&self) -> bool { self.metadata.file_type().is_char_device() } /// Whether this file is a block device on the filesystem. + #[cfg(unix)] pub fn is_block_device(&self) -> bool { self.metadata.file_type().is_block_device() } /// Whether this file is a socket on the filesystem. + #[cfg(unix)] pub fn is_socket(&self) -> bool { self.metadata.file_type().is_socket() } @@ -213,13 +250,13 @@ impl<'dir> File<'dir> { path.to_path_buf() } else if let Some(dir) = self.parent_dir { - dir.join(&*path) + dir.join(path) } else if let Some(parent) = self.path.parent() { - parent.join(&*path) + parent.join(path) } else { - self.path.join(&*path) + self.path.join(path) } } @@ -253,7 +290,8 @@ impl<'dir> File<'dir> { Ok(metadata) => { let ext = File::ext(&path); let name = File::filename(&path); - let file = File { parent_dir: None, path, ext, metadata, name, is_all_all: false }; + let extended_attributes = File::gather_extended_attributes(&absolute_path); + let file = File { parent_dir: None, path, ext, metadata, name, is_all_all: false, deref_links: self.deref_links, extended_attributes }; FileTarget::Ok(Box::new(file)) } Err(e) => { @@ -263,6 +301,28 @@ impl<'dir> File<'dir> { } } + /// Assuming this file is a symlink, follows that link and any further + /// links recursively, returning the result from following the trail. + /// + /// For a working symlink that the user is allowed to follow, + /// this will be the `File` object at the other end, which can then have + /// its name, colour, and other details read. + /// + /// For a broken symlink, returns where the file *would* be, if it + /// existed. If this file cannot be read at all, returns the error that + /// we got when we tried to read it. + pub fn link_target_recurse(&self) -> FileTarget<'dir> { + let target = self.link_target(); + if let FileTarget::Ok(f) = target { + if f.is_link() { + return f.link_target_recurse(); + } else { + return FileTarget::Ok(f); + } + } + target + } + /// This file’s number of hard links. /// /// It also reports whether this is both a regular file, and a file with @@ -270,6 +330,7 @@ impl<'dir> File<'dir> { /// is uncommon, while you come across directories and other types /// with multiple links much more often. Thus, it should get highlighted /// more attentively. + #[cfg(unix)] pub fn links(&self) -> f::Links { let count = self.metadata.nlink(); @@ -280,6 +341,7 @@ impl<'dir> File<'dir> { } /// This file’s inode. + #[cfg(unix)] pub fn inode(&self) -> f::Inode { f::Inode(self.metadata.ino()) } @@ -287,6 +349,7 @@ impl<'dir> File<'dir> { /// This file’s number of filesystem blocks. /// /// (Not the size of each block, which we don’t actually report on) + #[cfg(unix)] pub fn blocks(&self) -> f::Blocks { if self.is_file() || self.is_link() { f::Blocks::Some(self.metadata.blocks()) @@ -296,14 +359,27 @@ impl<'dir> File<'dir> { } } - /// The ID of the user that own this file. - pub fn user(&self) -> f::User { - f::User(self.metadata.uid()) + /// The ID of the user that own this file. If dereferencing links, the links + /// may be broken, in which case `None` will be returned. + pub fn user(&self) -> Option { + if self.is_link() && self.deref_links { + match self.link_target_recurse() { + FileTarget::Ok(f) => return f.user(), + _ => return None, + } + } + Some(f::User(self.metadata.uid())) } /// The ID of the group that owns this file. - pub fn group(&self) -> f::Group { - f::Group(self.metadata.gid()) + pub fn group(&self) -> Option { + if self.is_link() && self.deref_links { + match self.link_target_recurse() { + FileTarget::Ok(f) => return f.group(), + _ => return None, + } + } + Some(f::Group(self.metadata.gid())) } /// This file’s size, if it’s a regular file. @@ -314,7 +390,18 @@ impl<'dir> File<'dir> { /// /// Block and character devices return their device IDs, because they /// usually just have a file size of zero. + /// + /// Links will return the size of their target (recursively through other + /// links) if dereferencing is enabled, otherwise the size of the link + /// itself. + #[cfg(unix)] pub fn size(&self) -> f::Size { + if self.is_link() { + let target = self.link_target(); + if let FileTarget::Ok(target) = target { + return target.size(); + } + } if self.is_directory() { f::Size::None } @@ -330,6 +417,21 @@ impl<'dir> File<'dir> { minor: device_ids[7], }) } + else if self.is_link() && self.deref_links { + match self.link_target() { + FileTarget::Ok(f) => f.size(), + _ => f::Size::None + } + } else { + f::Size::Some(self.metadata.len()) + } + } + + #[cfg(windows)] + pub fn size(&self) -> f::Size { + if self.is_directory() { + f::Size::None + } else { f::Size::Some(self.metadata.len()) } @@ -337,11 +439,26 @@ impl<'dir> File<'dir> { /// This file’s last modified timestamp, if available on this platform. pub fn modified_time(&self) -> Option { - self.metadata.modified().ok() + if self.is_link() && self.deref_links { + match self.link_target_recurse() { + FileTarget::Ok(f) => f.metadata.modified().ok(), + _ => None, + } + } else { + self.metadata.modified().ok() + } } /// This file’s last changed timestamp, if available on this platform. + #[cfg(unix)] pub fn changed_time(&self) -> Option { + if self.is_link() && self.deref_links { + match self.link_target_recurse() { + FileTarget::Ok(f) => return f.changed_time(), + _ => return None, + } + } + let (mut sec, mut nanosec) = (self.metadata.ctime(), self.metadata.ctime_nsec()); if sec < 0 { @@ -350,7 +467,7 @@ impl<'dir> File<'dir> { nanosec -= 1_000_000_000; } - let duration = Duration::new(sec.abs() as u64, nanosec.abs() as u32); + let duration = Duration::new(sec.unsigned_abs(), nanosec.unsigned_abs() as u32); Some(UNIX_EPOCH - duration) } else { @@ -359,14 +476,33 @@ impl<'dir> File<'dir> { } } + #[cfg(windows)] + pub fn changed_time(&self) -> Option { + return self.modified_time() + } + /// This file’s last accessed timestamp, if available on this platform. pub fn accessed_time(&self) -> Option { - self.metadata.accessed().ok() + if self.is_link() && self.deref_links { + match self.link_target_recurse() { + FileTarget::Ok(f) => f.metadata.accessed().ok(), + _ => None, + } + } else { + self.metadata.accessed().ok() + } } /// This file’s created timestamp, if available on this platform. pub fn created_time(&self) -> Option { - self.metadata.created().ok() + if self.is_link() && self.deref_links { + match self.link_target_recurse() { + FileTarget::Ok(f) => f.metadata.created().ok(), + _ => None, + } + } else { + self.metadata.created().ok() + } } /// This file’s ‘type’. @@ -374,6 +510,7 @@ impl<'dir> File<'dir> { /// This is used a the leftmost character of the permissions column. /// The file type can usually be guessed from the colour of the file, but /// ls puts this character there. + #[cfg(unix)] pub fn type_char(&self) -> f::Type { if self.is_file() { f::Type::File @@ -401,12 +538,35 @@ impl<'dir> File<'dir> { } } + #[cfg(windows)] + pub fn type_char(&self) -> f::Type { + if self.is_file() { + f::Type::File + } + else if self.is_directory() { + f::Type::Directory + } + else { + f::Type::Special + } + } + /// This file’s permissions, with flags for each bit. - pub fn permissions(&self) -> f::Permissions { + #[cfg(unix)] + pub fn permissions(&self) -> Option { + if self.is_link() && self.deref_links { + // If the chain of links is broken, we instead fall through and + // return the permissions of the original link, as would have been + // done if we were not dereferencing. + match self.link_target_recurse() { + FileTarget::Ok(f) => return f.permissions(), + _ => return None, + } + } let bits = self.metadata.mode(); let has_bit = |bit| bits & bit == bit; - f::Permissions { + Some(f::Permissions { user_read: has_bit(modes::USER_READ), user_write: has_bit(modes::USER_WRITE), user_execute: has_bit(modes::USER_EXECUTE), @@ -422,6 +582,22 @@ impl<'dir> File<'dir> { sticky: has_bit(modes::STICKY), setgid: has_bit(modes::SETGID), setuid: has_bit(modes::SETUID), + }) + } + + #[cfg(windows)] + pub fn attributes(&self) -> f::Attributes { + let bits = self.metadata.file_attributes(); + let has_bit = |bit| bits & bit == bit; + + // https://docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants + f::Attributes { + directory: has_bit(0x10), + archive: has_bit(0x20), + readonly: has_bit(0x1), + hidden: has_bit(0x2), + system: has_bit(0x4), + reparse_point: has_bit(0x400), } } @@ -440,6 +616,16 @@ impl<'dir> File<'dir> { pub fn name_is_one_of(&self, choices: &[&str]) -> bool { choices.contains(&&self.name[..]) } + + /// This file’s security context field. + pub fn security_context(&self) -> f::SecurityContext<'_> { + let context = match &self.extended_attributes.iter().find(|a| a.name == "security.selinux") { + Some(attr) => f::SecurityContextType::SELinux(&attr.value), + None => f::SecurityContextType::None + }; + + f::SecurityContext { context } + } } @@ -482,6 +668,7 @@ impl<'dir> FileTarget<'dir> { /// More readable aliases for the permission bits exposed by libc. #[allow(trivial_numeric_casts)] +#[cfg(unix)] mod modes { // The `libc::mode_t` type’s actual type varies, but the value returned @@ -559,6 +746,7 @@ mod filename_test { } #[test] + #[cfg(unix)] fn topmost() { assert_eq!("/", File::filename(Path::new("/"))) } diff --git a/src/fs/filter.rs b/src/fs/filter.rs index 5fb3f55e..4cd08b02 100644 --- a/src/fs/filter.rs +++ b/src/fs/filter.rs @@ -2,6 +2,7 @@ use std::cmp::Ordering; use std::iter::FromIterator; +#[cfg(unix)] use std::os::unix::fs::MetadataExt; use crate::fs::DotFilter; @@ -22,7 +23,7 @@ use crate::fs::File; /// The filter also governs sorting the list. After being filtered, pairs of /// files are compared and sorted based on the result, with the sort field /// performing the comparison. -#[derive(PartialEq, Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone)] pub struct FileFilter { /// Whether directories should be listed first, and other types of file @@ -88,7 +89,7 @@ impl FileFilter { } /// Sort the files in the given vector based on the sort field option. - pub fn sort_files<'a, F>(&self, files: &mut Vec) + pub fn sort_files<'a, F>(&self, files: &mut [F]) where F: AsRef> { files.sort_by(|a, b| { @@ -112,7 +113,7 @@ impl FileFilter { /// User-supplied field to sort by. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum SortField { /// Don’t apply any sorting. This is usually used as an optimisation in @@ -130,6 +131,7 @@ pub enum SortField { /// The file’s inode, which usually corresponds to the order in which /// files were created on the filesystem, more or less. + #[cfg(unix)] FileInode, /// The time the file was modified (the “mtime”). @@ -192,7 +194,7 @@ pub enum SortField { /// lowercase letters because it takes the difference between the two cases /// into account? I gave up and just named these two variants after the /// effects they have. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum SortCase { /// Sort files case-sensitively with uppercase first, with ‘A’ coming @@ -223,6 +225,7 @@ impl SortField { Self::Name(AaBbCc) => natord::compare_ignore_case(&a.name, &b.name), Self::Size => a.metadata.len().cmp(&b.metadata.len()), + #[cfg(unix)] Self::FileInode => a.metadata.ino().cmp(&b.metadata.ino()), Self::ModifiedDate => a.modified_time().cmp(&b.modified_time()), Self::AccessedDate => a.accessed_time().cmp(&b.accessed_time()), @@ -268,7 +271,7 @@ impl SortField { /// The **ignore patterns** are a list of globs that are tested against /// each filename, and if any of them match, that file isn’t displayed. /// This lets a user hide, say, text files by ignoring `*.txt`. -#[derive(PartialEq, Default, Debug, Clone)] +#[derive(PartialEq, Eq, Default, Debug, Clone)] pub struct IgnorePatterns { patterns: Vec, } @@ -324,7 +327,7 @@ impl IgnorePatterns { /// Whether to ignore or display files that Git would ignore. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum GitIgnore { /// Ignore files that Git would ignore. @@ -343,31 +346,31 @@ mod test_ignores { #[test] fn empty_matches_nothing() { let pats = IgnorePatterns::empty(); - assert_eq!(false, pats.is_ignored("nothing")); - assert_eq!(false, pats.is_ignored("test.mp3")); + assert!(!pats.is_ignored("nothing")); + assert!(!pats.is_ignored("test.mp3")); } #[test] fn ignores_a_glob() { let (pats, fails) = IgnorePatterns::parse_from_iter(vec![ "*.mp3" ]); assert!(fails.is_empty()); - assert_eq!(false, pats.is_ignored("nothing")); - assert_eq!(true, pats.is_ignored("test.mp3")); + assert!(!pats.is_ignored("nothing")); + assert!(pats.is_ignored("test.mp3")); } #[test] fn ignores_an_exact_filename() { let (pats, fails) = IgnorePatterns::parse_from_iter(vec![ "nothing" ]); assert!(fails.is_empty()); - assert_eq!(true, pats.is_ignored("nothing")); - assert_eq!(false, pats.is_ignored("test.mp3")); + assert!(pats.is_ignored("nothing")); + assert!(!pats.is_ignored("test.mp3")); } #[test] fn ignores_both() { let (pats, fails) = IgnorePatterns::parse_from_iter(vec![ "nothing", "*.mp3" ]); assert!(fails.is_empty()); - assert_eq!(true, pats.is_ignored("nothing")); - assert_eq!(true, pats.is_ignored("test.mp3")); + assert!(pats.is_ignored("nothing")); + assert!(pats.is_ignored("test.mp3")); } } diff --git a/src/info/filetype.rs b/src/info/filetype.rs index d7d2a4d8..5bbfeb55 100644 --- a/src/info/filetype.rs +++ b/src/info/filetype.rs @@ -3,6 +3,9 @@ //! Currently this is dependent on the file’s name and extension, because //! those are the only metadata that we have access to without reading the //! file’s contents. +//! +//! # Contributors +//! Please keep these lists sorted. If you're using vim, :sort i use ansi_term::Style; @@ -11,7 +14,7 @@ use crate::output::icons::FileIcon; use crate::theme::FileColours; -#[derive(Debug, Default, PartialEq)] +#[derive(Debug, Default, PartialEq, Eq)] pub struct FileExtensions; impl FileExtensions { @@ -21,78 +24,245 @@ impl FileExtensions { /// in directories full of source code. #[allow(clippy::case_sensitive_file_extension_comparisons)] fn is_immediate(&self, file: &File<'_>) -> bool { - file.name.to_lowercase().starts_with("readme") || - file.name.ends_with(".ninja") || - file.name_is_one_of( &[ - "Makefile", "Cargo.toml", "SConstruct", "CMakeLists.txt", - "build.gradle", "pom.xml", "Rakefile", "package.json", "Gruntfile.js", - "Gruntfile.coffee", "BUILD", "BUILD.bazel", "WORKSPACE", "build.xml", "Podfile", - "webpack.config.js", "meson.build", "composer.json", "RoboFile.php", "PKGBUILD", - "Justfile", "Procfile", "Dockerfile", "Containerfile", "Vagrantfile", "Brewfile", - "Gemfile", "Pipfile", "build.sbt", "mix.exs", "bsconfig.json", "tsconfig.json", - ]) + file.name.to_lowercase().starts_with("readme") + || file.name.ends_with(".ninja") + || matches!( + file.name.as_str(), + "BUILD" + | "Brewfile" + | "bsconfig.json" + | "BUILD.bazel" + | "build.gradle" + | "build.sbt" + | "build.xml" + | "Cargo.lock" + | "Cargo.toml" + | "CMakeLists.txt" + | "composer.json" + | "configure.ac" + | "Configure.ac" + | "Containerfile" + | "Dockerfile" + | "Earthfile" + | "flake.lock" + | "flake.nix" + | "Gemfile" + | "GNUmakefile" + | "Gruntfile.coffee" + | "Gruntfile.js" + | "Justfile" + | "justfile" + | "Makefile" + | "makefile" + | "Makefile.in" + | "makefile.in" + | "meson.build" + | "mix.exs" + | "package.json" + | "Pipfile" + | "PKGBUILD" + | "Podfile" + | "pom.xml" + | "Procfile" + | "Rakefile" + | "RoboFile.php" + | "SConstruct" + | "tsconfig.json" + | "Vagrantfile" + | "webpack.config.cjs" + | "webpack.config.js" + | "WORKSPACE" + ) } fn is_image(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "png", "jfi", "jfif", "jif", "jpe", "jpeg", "jpg", "gif", "bmp", - "tiff", "tif", "ppm", "pgm", "pbm", "pnm", "webp", "raw", "arw", - "svg", "stl", "eps", "dvi", "ps", "cbr", "jpf", "cbz", "xpm", - "ico", "cr2", "orf", "nef", "heif", "avif", "jxl", "j2k", "jp2", - "j2c", "jpx", + "arw", + "avif", + "bmp", + "cbr", + "cbz", + "cr2", + "dvi", + "eps", + "gif", + "heif", + "ico", + "j2c", + "j2k", + "jfi", + "jfif", + "jif", + "jp2", + "jpe", + "jpeg", + "jpf", + "jpg", + "jpx", + "jxl", + "nef", + "orf", + "pbm", + "pgm", + "png", + "pnm", + "ppm", + "ps", + "pxm", + "raw", + "stl", + "svg", + "tif", + "tiff", + "webp", + "xpm", ]) } fn is_video(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "avi", "flv", "m2v", "m4v", "mkv", "mov", "mp4", "mpeg", - "mpg", "ogm", "ogv", "vob", "wmv", "webm", "m2ts", "heic", + "avi", + "flv", + "heic", + "m2ts", + "m2v", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "ogm", + "ogv", + "vob", + "webm", + "wmv", ]) } fn is_music(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "aac", "m4a", "mp3", "ogg", "wma", "mka", "opus", + "aac", + "m4a", + "mka", + "mp2", + "mp3", + "ogg", + "opus", + "wma", ]) } // Lossless music, rather than any other kind of data... fn is_lossless(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "alac", "ape", "flac", "wav", + "alac", + "ape", + "flac", + "wav", ]) } fn is_crypto(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "asc", "enc", "gpg", "pgp", "sig", "signature", "pfx", "p12", + "asc", + "enc", + "gpg", + "p12", + "pfx", + "pgp", + "sig", + "signature", ]) } fn is_document(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "djvu", "doc", "docx", "dvi", "eml", "eps", "fotd", "key", - "keynote", "numbers", "odp", "odt", "pages", "pdf", "ppt", - "pptx", "rtf", "xls", "xlsx", + "djvu", + "doc", + "docx", + "dvi", + "eml", + "eps", + "fotd", + "key", + "keynote", + "numbers", + "odp", + "odt", + "pages", + "pdf", + "ppt", + "pptx", + "rtf", + "xls", + "xlsx", ]) } fn is_compressed(&self, file: &File<'_>) -> bool { file.extension_is_one_of( &[ - "zip", "tar", "Z", "z", "gz", "bz2", "a", "ar", "7z", - "iso", "dmg", "tc", "rar", "par", "tgz", "xz", "txz", - "lz", "tlz", "lzma", "deb", "rpm", "zst", "lz4", "cpio", + "7z", + "a", + "ar", + "bz", + "bz2", + "bz3", + "cpio", + "deb", + "dmg", + "gz", + "iso", + "lz", + "lz4", + "lzh", + "lzma", + "lzo", + "par", + "rar", + "rpm", + "tar", + "taz", + "tbz", + "tbz2", + "tc", + "tgz", + "tlz", + "txz", + "tz", + "tzo", + "xz", + "Z", + "z", + "zip", + "zst", ]) } fn is_temp(&self, file: &File<'_>) -> bool { file.name.ends_with('~') || (file.name.starts_with('#') && file.name.ends_with('#')) - || file.extension_is_one_of( &[ "tmp", "swp", "swo", "swn", "bak", "bkp", "bk" ]) + || file.extension_is_one_of( &[ + "bak", + "bk", + "bkp", + "swn", + "swo", + "swp", + "tmp", + ]) } fn is_compiled(&self, file: &File<'_>) -> bool { - if file.extension_is_one_of( &[ "class", "elc", "hi", "o", "pyc", "zwc", "ko" ]) { + if file.extension_is_one_of( &[ + "class", + "elc", + "hi", + "ko", + "o", + "pyc", + "zwc", + ]) { true } else if let Some(dir) = file.parent_dir { @@ -109,16 +279,16 @@ impl FileColours for FileExtensions { use ansi_term::Colour::*; Some(match file { - f if self.is_temp(f) => Fixed(244).normal(), - f if self.is_immediate(f) => Yellow.bold().underline(), - f if self.is_image(f) => Fixed(133).normal(), - f if self.is_video(f) => Fixed(135).normal(), - f if self.is_music(f) => Fixed(92).normal(), - f if self.is_lossless(f) => Fixed(93).normal(), - f if self.is_crypto(f) => Fixed(109).normal(), - f if self.is_document(f) => Fixed(105).normal(), + f if self.is_compiled(f) => Yellow.normal(), f if self.is_compressed(f) => Red.normal(), - f if self.is_compiled(f) => Fixed(137).normal(), + f if self.is_crypto(f) => Green.bold(), + f if self.is_document(f) => Green.normal(), + f if self.is_image(f) => Purple.normal(), + f if self.is_immediate(f) => Yellow.bold().underline(), + f if self.is_lossless(f) => Cyan.bold(), + f if self.is_music(f) => Cyan.normal(), + f if self.is_temp(f) => White.normal(), + f if self.is_video(f) => Purple.bold(), _ => return None, }) } @@ -137,6 +307,9 @@ impl FileIcon for FileExtensions { else if self.is_video(file) { Some(Icons::Video.value()) } + else if self.is_compressed(file) { + Some(Icons::Compressed.value()) + } else { None } diff --git a/src/info/sources.rs b/src/info/sources.rs index bc658104..5550a9e4 100644 --- a/src/info/sources.rs +++ b/src/info/sources.rs @@ -18,6 +18,8 @@ impl<'a> File<'a> { match &ext[..] { "css" => vec![self.path.with_extension("sass"), self.path.with_extension("scss"), // SASS, SCSS self.path.with_extension("styl"), self.path.with_extension("less")], // Stylus, Less + "mjs" => vec![self.path.with_extension("mts")], // JavaScript ES Modules source + "cjs" => vec![self.path.with_extension("cts")], // JavaScript Commonjs Modules source "js" => vec![self.path.with_extension("coffee"), self.path.with_extension("ts")], // CoffeeScript, TypeScript "aux" | // TeX: auxiliary file @@ -26,10 +28,13 @@ impl<'a> File<'a> { "blg" | // BibTeX log file "fdb_latexmk" | // TeX latexmk file "fls" | // TeX -recorder file + "headfootlength" | // TeX package autofancyhdr file "lof" | // TeX list of figures "log" | // TeX log file "lot" | // TeX list of tables + "out" => vec![self.path.with_extension("tex")], // hyperref list of bookmarks "toc" => vec![self.path.with_extension("tex")], // TeX table of contents + "xdv" => vec![self.path.with_extension("tex")], // XeTeX dvi _ => vec![], // No source files if none of the above } diff --git a/src/main.rs b/src/main.rs index bbdf0d68..322f7b9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,14 +49,20 @@ mod theme; fn main() { use std::process::exit; + #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } logger::configure(env::var_os(vars::EXA_DEBUG)); + #[cfg(windows)] + if let Err(e) = ansi_term::enable_ansi_support() { + warn!("Failed to enable ANSI support: {}", e); + } + let args: Vec<_> = env::args_os().skip(1).collect(); - match Options::parse(args.iter().map(|e| e.as_ref()), &LiveVars) { + match Options::parse(args.iter().map(std::convert::AsRef::as_ref), &LiveVars) { OptionsResult::Ok(options, mut input_paths) => { // List the current directory by default. @@ -171,7 +177,7 @@ impl<'args> Exa<'args> { let mut exit_status = 0; for file_path in &self.input_paths { - match File::from_args(PathBuf::from(file_path), None, None) { + match File::from_args(PathBuf::from(file_path), None, None, self.options.view.deref_links) { Err(e) => { exit_status = 2; writeln!(io::stderr(), "{:?}: {}", file_path, e)?; @@ -224,7 +230,7 @@ impl<'args> Exa<'args> { let mut children = Vec::new(); let git_ignore = self.options.filter.git_ignore == GitIgnore::CheckAndIgnore; - for file in dir.files(self.options.filter.dot_filter, self.git.as_ref(), git_ignore) { + for file in dir.files(self.options.filter.dot_filter, self.git.as_ref(), git_ignore, self.options.view.deref_links) { match file { Ok(file) => children.push(file), Err((path, e)) => writeln!(io::stderr(), "[{}: {}]", path.display(), e)?, diff --git a/src/options/error.rs b/src/options/error.rs index 1b1aa87e..81559190 100644 --- a/src/options/error.rs +++ b/src/options/error.rs @@ -7,7 +7,7 @@ use crate::options::parser::{Arg, Flag, ParseError}; /// Something wrong with the combination of options the user has picked. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub enum OptionsError { /// There was an error (from `getopts`) parsing the arguments. @@ -44,13 +44,13 @@ pub enum OptionsError { } /// The source of a string that failed to be parsed as a number. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub enum NumberSource { /// It came... from a command-line argument! Arg(&'static Arg), - /// It came... from the enviroment! + /// It came... from the environment! Env(&'static str), } @@ -119,7 +119,7 @@ impl OptionsError { /// A list of legal choices for an argument-taking option. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Choices(pub &'static [&'static str]); impl fmt::Display for Choices { diff --git a/src/options/file_name.rs b/src/options/file_name.rs index 2c1db1a6..f1ffbb00 100644 --- a/src/options/file_name.rs +++ b/src/options/file_name.rs @@ -2,15 +2,16 @@ use crate::options::{flags, OptionsError, NumberSource}; use crate::options::parser::MatchedFlags; use crate::options::vars::{self, Vars}; -use crate::output::file_name::{Options, Classify, ShowIcons}; +use crate::output::file_name::{Options, Classify, ShowIcons, EmbedHyperlinks}; impl Options { pub fn deduce(matches: &MatchedFlags<'_>, vars: &V) -> Result { let classify = Classify::deduce(matches)?; let show_icons = ShowIcons::deduce(matches, vars)?; + let embed_hyperlinks = EmbedHyperlinks::deduce(matches)?; - Ok(Self { classify, show_icons }) + Ok(Self { classify, show_icons, embed_hyperlinks }) } } @@ -44,3 +45,12 @@ impl ShowIcons { } } } + +impl EmbedHyperlinks { + fn deduce(matches: &MatchedFlags<'_>) -> Result { + let flagged = matches.has(&flags::HYPERLINK)?; + + if flagged { Ok(Self::On) } + else { Ok(Self::Off) } + } +} diff --git a/src/options/filter.rs b/src/options/filter.rs index 5524b92c..417dbb9f 100644 --- a/src/options/filter.rs +++ b/src/options/filter.rs @@ -88,6 +88,7 @@ impl SortField { "cr" | "created" => { Self::CreatedDate } + #[cfg(unix)] "inode" => { Self::FileInode } @@ -149,27 +150,32 @@ impl DotFilter { /// Determines the dot filter based on how many `--all` options were /// given: one will show dotfiles, but two will show `.` and `..` too. + /// --almost-all is equivalent to --all, included for compatibility with + /// `ls -A`. /// - /// It also checks for the `--tree` option in strict mode, because of a - /// special case where `--tree --all --all` won’t work: listing the - /// parent directory in tree mode would loop onto itself! + /// It also checks for the `--tree` option, because of a special case + /// where `--tree --all --all` won’t work: listing the parent directory + /// in tree mode would loop onto itself! + /// + /// `--almost-all` binds stronger than multiple `--all` as we currently do not take the order + /// of arguments into account and it is the safer option (does not clash with `--tree`) pub fn deduce(matches: &MatchedFlags<'_>) -> Result { - let count = matches.count(&flags::ALL); + let all_count = matches.count(&flags::ALL); + let has_almost_all = matches.has(&flags::ALMOST_ALL)?; - if count == 0 { - Ok(Self::JustFiles) - } - else if count == 1 { - Ok(Self::Dotfiles) - } - else if matches.count(&flags::TREE) > 0 { - Err(OptionsError::TreeAllAll) - } - else if count >= 3 && matches.is_strict() { - Err(OptionsError::Conflict(&flags::ALL, &flags::ALL)) - } - else { - Ok(Self::DotfilesAndDots) + match (all_count, has_almost_all) { + (0, false) => Ok(Self::JustFiles), + + // either a single --all or at least one --almost-all is given + (1, _) | (0, true) => Ok(Self::Dotfiles), + // more than one --all + (c, _) => if matches.count(&flags::TREE) > 0 { + Err(OptionsError::TreeAllAll) + } else if matches.is_strict() && c > 2 { + Err(OptionsError::Conflict(&flags::ALL, &flags::ALL)) + } else { + Ok(Self::DotfilesAndDots) + }, } } } @@ -230,7 +236,7 @@ mod test { use crate::options::test::parse_for_test; use crate::options::test::Strictnesses::*; - static TEST_ARGS: &[&Arg] = &[ &flags::SORT, &flags::ALL, &flags::TREE, &flags::IGNORE_GLOB, &flags::GIT_IGNORE ]; + static TEST_ARGS: &[&Arg] = &[ &flags::SORT, &flags::ALL, &flags::ALMOST_ALL, &flags::TREE, &flags::IGNORE_GLOB, &flags::GIT_IGNORE ]; for result in parse_for_test($inputs.as_ref(), TEST_ARGS, $stricts, |mf| $type::deduce(mf)) { assert_eq!(result, $result); } @@ -245,7 +251,7 @@ mod test { test!(empty: SortField <- []; Both => Ok(SortField::default())); // Sort field arguments - test!(one_arg: SortField <- ["--sort=mod"]; Both => Ok(SortField::ModifiedDate)); + test!(one_arg: SortField <- ["--sort=mod"]; Both => Ok(SortField::ModifiedDate)); test!(one_long: SortField <- ["--sort=size"]; Both => Ok(SortField::Size)); test!(one_short: SortField <- ["-saccessed"]; Both => Ok(SortField::AccessedDate)); test!(lowercase: SortField <- ["--sort", "name"]; Both => Ok(SortField::Name(SortCase::AaBbCc))); @@ -274,27 +280,31 @@ mod test { use super::*; // Default behaviour - test!(empty: DotFilter <- []; Both => Ok(DotFilter::JustFiles)); + test!(empty: DotFilter <- []; Both => Ok(DotFilter::JustFiles)); // --all - test!(all: DotFilter <- ["--all"]; Both => Ok(DotFilter::Dotfiles)); - test!(all_all: DotFilter <- ["--all", "-a"]; Both => Ok(DotFilter::DotfilesAndDots)); - test!(all_all_2: DotFilter <- ["-aa"]; Both => Ok(DotFilter::DotfilesAndDots)); + test!(all: DotFilter <- ["--all"]; Both => Ok(DotFilter::Dotfiles)); + test!(all_all: DotFilter <- ["--all", "-a"]; Both => Ok(DotFilter::DotfilesAndDots)); + test!(all_all_2: DotFilter <- ["-aa"]; Both => Ok(DotFilter::DotfilesAndDots)); - test!(all_all_3: DotFilter <- ["-aaa"]; Last => Ok(DotFilter::DotfilesAndDots)); - test!(all_all_4: DotFilter <- ["-aaa"]; Complain => Err(OptionsError::Conflict(&flags::ALL, &flags::ALL))); + test!(all_all_3: DotFilter <- ["-aaa"]; Last => Ok(DotFilter::DotfilesAndDots)); + test!(all_all_4: DotFilter <- ["-aaa"]; Complain => Err(OptionsError::Conflict(&flags::ALL, &flags::ALL))); // --all and --tree - test!(tree_a: DotFilter <- ["-Ta"]; Both => Ok(DotFilter::Dotfiles)); - test!(tree_aa: DotFilter <- ["-Taa"]; Both => Err(OptionsError::TreeAllAll)); - test!(tree_aaa: DotFilter <- ["-Taaa"]; Both => Err(OptionsError::TreeAllAll)); + test!(tree_a: DotFilter <- ["-Ta"]; Both => Ok(DotFilter::Dotfiles)); + test!(tree_aa: DotFilter <- ["-Taa"]; Both => Err(OptionsError::TreeAllAll)); + test!(tree_aaa: DotFilter <- ["-Taaa"]; Both => Err(OptionsError::TreeAllAll)); + + // --almost-all + test!(almost_all: DotFilter <- ["--almost-all"]; Both => Ok(DotFilter::Dotfiles)); + test!(almost_all_all: DotFilter <- ["-Aa"]; Both => Ok(DotFilter::Dotfiles)); + test!(almost_all_all_2: DotFilter <- ["-Aaa"]; Both => Ok(DotFilter::DotfilesAndDots)); } mod ignore_patterns { use super::*; use std::iter::FromIterator; - use glob; fn pat(string: &'static str) -> glob::Pattern { glob::Pattern::new(string).unwrap() diff --git a/src/options/flags.rs b/src/options/flags.rs index 1761d66a..4adbac4e 100644 --- a/src/options/flags.rs +++ b/src/options/flags.rs @@ -6,13 +6,14 @@ pub static VERSION: Arg = Arg { short: Some(b'v'), long: "version", takes_value 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 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 DEREF_LINKS: Arg = Arg { short: Some(b'X'), long: "dereference", takes_value: TakesValue::Forbidden }; pub static COLOR: Arg = Arg { short: None, long: "color", takes_value: TakesValue::Necessary(Some(COLOURS)) }; pub static COLOUR: Arg = Arg { short: None, long: "colour", takes_value: TakesValue::Necessary(Some(COLOURS)) }; @@ -23,6 +24,7 @@ pub static COLOUR_SCALE: Arg = Arg { short: None, long: "colour-scale", takes_va // filtering and sorting options pub static ALL: Arg = Arg { short: Some(b'a'), long: "all", takes_value: TakesValue::Forbidden }; +pub static ALMOST_ALL: Arg = Arg { short: Some(b'A'), long: "almost-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(None) }; pub static REVERSE: Arg = Arg { short: Some(b'r'), long: "reverse", takes_value: TakesValue::Forbidden }; @@ -51,8 +53,9 @@ pub static TIME: Arg = Arg { short: Some(b't'), long: "time", takes_ 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(Some(TIME_STYLES)) }; +pub static HYPERLINK: Arg = Arg { short: None, long: "hyperlink", takes_value: TakesValue::Forbidden}; const TIMES: Values = &["modified", "changed", "accessed", "created"]; -const TIME_STYLES: Values = &["default", "long-iso", "full-iso", "iso"]; +const TIME_STYLES: Values = &["default", "long-iso", "full-iso", "iso", "relative"]; // suppressing columns pub static NO_PERMISSIONS: Arg = Arg { short: None, long: "no-permissions", takes_value: TakesValue::Forbidden }; @@ -62,23 +65,26 @@ pub static NO_TIME: Arg = Arg { short: None, long: "no-time", takes_value: Takes pub static NO_ICONS: Arg = Arg { short: None, long: "no-icons", takes_value: TakesValue::Forbidden }; // 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 OCTAL: Arg = Arg { short: None, long: "octal-permissions", takes_value: TakesValue::Forbidden }; +pub static GIT: Arg = Arg { short: None, long: "git", takes_value: TakesValue::Forbidden }; +pub static GIT_REPOS: Arg = Arg { short: None, long: "git-repos", takes_value: TakesValue::Forbidden }; +pub static GIT_REPOS_NO_STAT: Arg = Arg { short: None, long: "git-repos-no-status", takes_value: TakesValue::Forbidden }; +pub static EXTENDED: Arg = Arg { short: Some(b'@'), long: "extended", takes_value: TakesValue::Forbidden }; +pub static OCTAL: Arg = Arg { short: Some(b'o'), long: "octal-permissions", takes_value: TakesValue::Forbidden }; +pub static SECURITY_CONTEXT: Arg = Arg { short: Some(b'Z'), long: "context", takes_value: TakesValue::Forbidden }; pub static ALL_ARGS: Args = Args(&[ &VERSION, &HELP, - &ONE_LINE, &LONG, &GRID, &ACROSS, &RECURSE, &TREE, &CLASSIFY, + &ONE_LINE, &LONG, &GRID, &ACROSS, &RECURSE, &TREE, &CLASSIFY, &DEREF_LINKS, &COLOR, &COLOUR, &COLOR_SCALE, &COLOUR_SCALE, - &ALL, &LIST_DIRS, &LEVEL, &REVERSE, &SORT, &DIRS_FIRST, + &ALL, &ALMOST_ALL, &LIST_DIRS, &LEVEL, &REVERSE, &SORT, &DIRS_FIRST, &IGNORE_GLOB, &GIT_IGNORE, &ONLY_DIRS, &BINARY, &BYTES, &GROUP, &NUMERIC, &HEADER, &ICONS, &INODE, &LINKS, &MODIFIED, &CHANGED, - &BLOCKS, &TIME, &ACCESSED, &CREATED, &TIME_STYLE, + &BLOCKS, &TIME, &ACCESSED, &CREATED, &TIME_STYLE, &HYPERLINK, &NO_PERMISSIONS, &NO_FILESIZE, &NO_USER, &NO_TIME, &NO_ICONS, - &GIT, &EXTENDED, &OCTAL + &GIT, &GIT_REPOS, &GIT_REPOS_NO_STAT, &EXTENDED, &OCTAL, &SECURITY_CONTEXT ]); diff --git a/src/options/help.rs b/src/options/help.rs index f3f40095..ef8b735f 100644 --- a/src/options/help.rs +++ b/src/options/help.rs @@ -24,6 +24,7 @@ DISPLAY OPTIONS --colo[u]r-scale highlight levels of file sizes distinctly --icons display icons --no-icons don't display icons (always overrides --icons) + --hyperlink display entries as hyperlinks FILTERING AND SORTING OPTIONS -a, --all show hidden and 'dot' files @@ -41,35 +42,35 @@ FILTERING AND SORTING OPTIONS date, time, old, and new all refer to modified. LONG VIEW OPTIONS - -b, --binary list file sizes with binary prefixes - -B, --bytes list file sizes in bytes, without any prefixes - -g, --group list each file's group - -h, --header add a header row to each column - -H, --links list each file's number of hard links - -i, --inode list each file's inode number - -m, --modified use the modified timestamp field - -n, --numeric list numeric user and group IDs - -S, --blocks show number of file system blocks - -t, --time FIELD which timestamp field to list (modified, accessed, created) - -u, --accessed use the accessed timestamp field - -U, --created use the created timestamp field - --changed use the changed timestamp field - --time-style how to format timestamps (default, iso, long-iso, full-iso) - --no-permissions suppress the permissions field - --octal-permissions list each file's permission in octal format - --no-filesize suppress the filesize field - --no-user suppress the user field - --no-time suppress the time field"; + -b, --binary list file sizes with binary prefixes + -B, --bytes list file sizes in bytes, without any prefixes + -g, --group list each file's group + -h, --header add a header row to each column + -H, --links list each file's number of hard links + -i, --inode list each file's inode number + -m, --modified use the modified timestamp field + -n, --numeric list numeric user and group IDs + -S, --blocks show number of file system blocks + -t, --time FIELD which timestamp field to list (modified, accessed, created) + -u, --accessed use the accessed timestamp field + -U, --created use the created timestamp field + --changed use the changed timestamp field + --time-style how to format timestamps (default, iso, long-iso, full-iso, relative) + --no-permissions suppress the permissions field + -o, --octal-permissions list each file's permission in octal format + --no-filesize suppress the filesize field + --no-user suppress the user field + --no-time suppress the time field"; static GIT_FILTER_HELP: &str = " --git-ignore ignore files mentioned in '.gitignore'"; static GIT_VIEW_HELP: &str = " --git list each file's Git status, if tracked or ignored"; static EXTENDED_HELP: &str = " -@, --extended list each file's extended attributes and sizes"; - +static SECATTR_HELP: &str = " -Z, --context list each file's security context"; /// 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, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub struct HelpString; impl HelpString { @@ -110,6 +111,7 @@ impl fmt::Display for HelpString { if xattr::ENABLED { write!(f, "\n{}", EXTENDED_HELP)?; + write!(f, "\n{}", SECATTR_HELP)?; } writeln!(f) diff --git a/src/options/mod.rs b/src/options/mod.rs index 528ba439..c2031762 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -216,7 +216,7 @@ pub mod test { use crate::options::parser::{Arg, MatchedFlags}; use std::ffi::OsStr; - #[derive(PartialEq, Debug)] + #[derive(PartialEq, Eq, Debug)] pub enum Strictnesses { Last, Complain, @@ -228,14 +228,14 @@ pub mod test { /// both, then both should resolve to the same result. /// /// It returns a vector with one or two elements in. - /// These elements can then be tested with assert_eq or what have you. + /// These elements can then be tested with `assert_eq` or what have you. pub fn parse_for_test(inputs: &[&str], args: &'static [&'static Arg], strictnesses: Strictnesses, get: F) -> Vec where F: Fn(&MatchedFlags<'_>) -> T { use self::Strictnesses::*; use crate::options::parser::{Args, Strictness}; - let bits = inputs.into_iter().map(OsStr::new).collect::>(); + let bits = inputs.iter().map(OsStr::new).collect::>(); let mut result = Vec::new(); if strictnesses == Last || strictnesses == Both { diff --git a/src/options/parser.rs b/src/options/parser.rs index f2e1d0ac..8095237c 100644 --- a/src/options/parser.rs +++ b/src/options/parser.rs @@ -52,7 +52,7 @@ pub type Values = &'static [&'static str]; /// A **flag** is either of the two argument types, because they have to /// be in the same array together. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum Flag { Short(ShortArg), Long(LongArg), @@ -77,7 +77,7 @@ impl fmt::Display for Flag { } /// Whether redundant arguments should be considered a problem. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum Strictness { /// Throw an error when an argument doesn’t do anything, either because @@ -91,7 +91,7 @@ pub enum Strictness { /// Whether a flag takes a value. This is applicable to both long and short /// arguments. -#[derive(Copy, Clone, PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum TakesValue { /// This flag has to be followed by a value. @@ -108,7 +108,7 @@ pub enum TakesValue { /// An **argument** can be matched by one of the user’s input strings. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub struct Arg { /// The short argument that matches it, if any. @@ -136,7 +136,7 @@ impl fmt::Display for Arg { /// Literally just several args. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Args(pub &'static [&'static Arg]); impl Args { @@ -146,8 +146,6 @@ impl Args { pub fn parse<'args, I>(&self, inputs: I, strictness: Strictness) -> Result, ParseError> where I: IntoIterator { - use std::os::unix::ffi::OsStrExt; - let mut parsing = true; // The results that get built up. @@ -159,7 +157,7 @@ impl Args { // 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(); + let bytes = os_str_to_bytes(arg); // Stop parsing if one of the arguments is the literal string “--”. // This allows a file named “--arg” to be specified by passing in @@ -174,7 +172,7 @@ impl Args { // 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..]); + let long_arg_name = bytes_to_os_str(&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 @@ -221,7 +219,7 @@ impl Args { // 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..]); + let short_arg = bytes_to_os_str(&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 @@ -236,7 +234,7 @@ impl Args { // 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(); + let (arg_with_value, other_args) = os_str_to_bytes(before).split_last().unwrap(); // Process the characters immediately following the dash... for byte in other_args { @@ -291,7 +289,7 @@ impl Args { TakesValue::Optional(values) => { if index < bytes.len() - 1 { let remnants = &bytes[index+1 ..]; - result_flags.push((flag, Some(OsStr::from_bytes(remnants)))); + result_flags.push((flag, Some(bytes_to_os_str(remnants)))); break; } else if let Some(next_arg) = inputs.next() { @@ -342,7 +340,7 @@ impl Args { /// The **matches** are the result of parsing the user’s command-line strings. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Matches<'args> { /// The flags that were parsed from the user’s input. @@ -353,7 +351,7 @@ pub struct Matches<'args> { pub frees: Vec<&'args OsStr>, } -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct MatchedFlags<'args> { /// The individual flags from the user’s input, in the order they were @@ -464,7 +462,7 @@ impl<'a> MatchedFlags<'a> { /// A problem with the user’s input that meant it couldn’t be parsed into a /// coherent list of arguments. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub enum ParseError { /// A flag that has to take a value was not given one. @@ -495,19 +493,42 @@ impl fmt::Display for ParseError { } } +#[cfg(unix)] +fn os_str_to_bytes<'b>(s: &'b OsStr) -> &'b [u8]{ + use std::os::unix::ffi::OsStrExt; + + return s.as_bytes() +} + +#[cfg(unix)] +fn bytes_to_os_str<'b>(b: &'b [u8]) -> &'b OsStr{ + use std::os::unix::ffi::OsStrExt; + + return OsStr::from_bytes(b); +} + +#[cfg(windows)] +fn os_str_to_bytes<'b>(s: &'b OsStr) -> &'b [u8]{ + return s.to_str().unwrap().as_bytes() +} + +#[cfg(windows)] +fn bytes_to_os_str<'b>(b: &'b [u8]) -> &'b OsStr{ + use std::str; + + return OsStr::new(str::from_utf8(b).unwrap()); +} /// 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); + if let Some(index) = os_str_to_bytes(input).iter().position(|elem| *elem == b'=') { + let (before, after) = os_str_to_bytes(input).split_at(index); // The after string contains the = that we need to remove. if ! before.is_empty() && after.len() >= 2 { - return Some((OsStr::from_bytes(before), - OsStr::from_bytes(&after[1..]))) + return Some((bytes_to_os_str(before), + bytes_to_os_str(&after[1..]))) } } @@ -722,6 +743,6 @@ mod matches_test { fn no_count() { let flags = MatchedFlags { flags: Vec::new(), strictness: Strictness::UseLastArguments }; - assert_eq!(flags.has(&COUNT).unwrap(), false); + assert!(!flags.has(&COUNT).unwrap()); } } diff --git a/src/options/theme.rs b/src/options/theme.rs index 010de4ad..5d27173e 100644 --- a/src/options/theme.rs +++ b/src/options/theme.rs @@ -130,18 +130,18 @@ mod terminal_test { impl MockVars { fn empty() -> MockVars { - return MockVars { + MockVars { ls: "", exa: "", no_color: "", - }; + } } fn with_no_color() -> MockVars { - return MockVars { + MockVars { ls: "", exa: "", no_color: "true", - }; + } } } diff --git a/src/options/version.rs b/src/options/version.rs index 22141644..3b78c3a4 100644 --- a/src/options/version.rs +++ b/src/options/version.rs @@ -8,7 +8,7 @@ use crate::options::flags; use crate::options::parser::MatchedFlags; -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub struct VersionString; // There were options here once, but there aren’t anymore! diff --git a/src/options/view.rs b/src/options/view.rs index 41816fb1..f66f80c3 100644 --- a/src/options/view.rs +++ b/src/options/view.rs @@ -13,7 +13,8 @@ impl View { let mode = Mode::deduce(matches, vars)?; let width = TerminalWidth::deduce(vars)?; let file_style = FileStyle::deduce(matches, vars)?; - Ok(Self { mode, width, file_style }) + let deref_links = matches.has(&flags::DEREF_LINKS)?; + Ok(Self { mode, width, file_style, deref_links }) } } @@ -117,6 +118,7 @@ impl details::Options { table: None, header: false, xattr: xattr::ENABLED && matches.has(&flags::EXTENDED)?, + secattr: xattr::ENABLED && matches.has(&flags::SECURITY_CONTEXT)?, }; Ok(details) @@ -136,6 +138,7 @@ impl details::Options { table: Some(TableOptions::deduce(matches, vars)?), header: matches.has(&flags::HEADER)?, xattr: xattr::ENABLED && matches.has(&flags::EXTENDED)?, + secattr: xattr::ENABLED && matches.has(&flags::SECURITY_CONTEXT)?, }) } } @@ -199,19 +202,23 @@ impl TableOptions { impl Columns { fn deduce(matches: &MatchedFlags<'_>) -> Result { let time_types = TimeTypes::deduce(matches)?; - let git = matches.has(&flags::GIT)?; - let blocks = matches.has(&flags::BLOCKS)?; - let group = matches.has(&flags::GROUP)?; - let inode = matches.has(&flags::INODE)?; - let links = matches.has(&flags::LINKS)?; - let octal = matches.has(&flags::OCTAL)?; + let git = matches.has(&flags::GIT)?; + let subdir_git_repos = matches.has(&flags::GIT_REPOS)?; + let subdir_git_repos_no_stat = !subdir_git_repos && matches.has(&flags::GIT_REPOS_NO_STAT)?; + + let blocks = matches.has(&flags::BLOCKS)?; + let group = matches.has(&flags::GROUP)?; + let inode = matches.has(&flags::INODE)?; + let links = matches.has(&flags::LINKS)?; + let octal = matches.has(&flags::OCTAL)?; + let security_context = xattr::ENABLED && matches.has(&flags::SECURITY_CONTEXT)?; let permissions = ! matches.has(&flags::NO_PERMISSIONS)?; let filesize = ! matches.has(&flags::NO_FILESIZE)?; let user = ! matches.has(&flags::NO_USER)?; - Ok(Self { time_types, inode, links, blocks, group, git, octal, permissions, filesize, user }) + Ok(Self { time_types, inode, links, blocks, group, git, subdir_git_repos, subdir_git_repos_no_stat, octal, security_context, permissions, filesize, user }) } } @@ -254,20 +261,13 @@ impl TimeFormat { } }; - if &word == "default" { - Ok(Self::DefaultFormat) - } - else if &word == "iso" { - Ok(Self::ISOFormat) - } - else if &word == "long-iso" { - Ok(Self::LongISO) - } - else if &word == "full-iso" { - Ok(Self::FullISO) - } - else { - Err(OptionsError::BadArgument(&flags::TIME_STYLE, word)) + match word.to_string_lossy().as_ref() { + "default" => Ok(Self::DefaultFormat), + "relative" => Ok(Self::Relative), + "iso" => Ok(Self::ISOFormat), + "long-iso" => Ok(Self::LongISO), + "full-iso" => Ok(Self::FullISO), + _ => Err(OptionsError::BadArgument(&flags::TIME_STYLE, word)) } } } @@ -378,7 +378,7 @@ mod test { ($name:ident: $type:ident <- $inputs:expr; $stricts:expr => err $result:expr) => { /// Special macro for testing Err results. - /// This is needed because sometimes the Ok type doesn’t implement PartialEq. + /// This is needed because sometimes the Ok type doesn’t implement `PartialEq`. #[test] fn $name() { for result in parse_for_test($inputs.as_ref(), TEST_ARGS, $stricts, |mf| $type::deduce(mf)) { @@ -389,7 +389,7 @@ mod test { ($name:ident: $type:ident <- $inputs:expr; $stricts:expr => like $pat:pat) => { /// More general macro for testing against a pattern. - /// Instead of using PartialEq, this just tests if it matches a pat. + /// Instead of using `PartialEq`, this just tests if it matches a pat. #[test] fn $name() { for result in parse_for_test($inputs.as_ref(), TEST_ARGS, $stricts, |mf| $type::deduce(mf)) { @@ -464,6 +464,7 @@ mod test { // Individual settings test!(default: TimeFormat <- ["--time-style=default"], None; Both => like Ok(TimeFormat::DefaultFormat)); test!(iso: TimeFormat <- ["--time-style", "iso"], None; Both => like Ok(TimeFormat::ISOFormat)); + test!(relative: TimeFormat <- ["--time-style", "relative"], None; Both => like Ok(TimeFormat::Relative)); test!(long_iso: TimeFormat <- ["--time-style=long-iso"], None; Both => like Ok(TimeFormat::LongISO)); test!(full_iso: TimeFormat <- ["--time-style", "full-iso"], None; Both => like Ok(TimeFormat::FullISO)); diff --git a/src/output/cell.rs b/src/output/cell.rs index c8d26752..959be4f0 100644 --- a/src/output/cell.rs +++ b/src/output/cell.rs @@ -77,11 +77,9 @@ impl TextCell { /// /// This method allocates a `String` to hold the spaces. pub fn add_spaces(&mut self, count: usize) { - use std::iter::repeat; - (*self.width) += count; - let spaces: String = repeat(' ').take(count).collect(); + let spaces: String = " ".repeat(count); self.contents.0.push(Style::default().paint(spaces)); } @@ -193,7 +191,7 @@ impl TextCellContents { /// /// It has `From` impls that convert an input string or fixed with to values /// of this type, and will `Deref` to the contained `usize` value. -#[derive(PartialEq, Debug, Clone, Copy, Default)] +#[derive(PartialEq, Eq, Debug, Clone, Copy, Default)] pub struct DisplayWidth(usize); impl<'a> From<&'a str> for DisplayWidth { diff --git a/src/output/details.rs b/src/output/details.rs index 62ef7d82..97ad4f20 100644 --- a/src/output/details.rs +++ b/src/output/details.rs @@ -71,7 +71,8 @@ use scoped_threadpool::Pool; use crate::fs::{Dir, File}; use crate::fs::dir_action::RecurseOptions; use crate::fs::feature::git::GitCache; -use crate::fs::feature::xattr::{Attribute, FileAttributes}; +use crate::fs::feature::xattr::Attribute; +use crate::fs::fields::SecurityContextType; use crate::fs::filter::FileFilter; use crate::output::cell::TextCell; use crate::output::file_name::Options as FileStyle; @@ -91,7 +92,7 @@ use crate::theme::Theme; /// /// Almost all the heavy lifting is done in a Table object, which handles the /// columns for each row. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Options { /// Options specific to drawing a table. @@ -105,6 +106,9 @@ pub struct Options { /// Whether to show each file’s extended attributes. pub xattr: bool, + + /// Whether to show each file's security attribute. + pub secattr: bool, } @@ -132,7 +136,7 @@ pub struct Render<'a> { struct Egg<'a> { table_row: Option, - xattrs: Vec, + xattrs: &'a [Attribute], errors: Vec<(io::Error, Option)>, dir: Option, file: &'a File<'a>, @@ -161,7 +165,7 @@ impl<'a> Render<'a> { (None, _) => {/* Keep Git how it is */}, } - let mut table = Table::new(table, self.git, &self.theme); + let mut table = Table::new(table, self.git, self.theme); if self.opts.header { let header = table.header_row(); @@ -189,11 +193,22 @@ impl<'a> Render<'a> { Ok(()) } + /// Whether to show the extended attribute hint + pub fn show_xattr_hint(&self, file: &File<'_>) -> bool { + // Do not show the hint '@' if the only extended attribute is the security + // attribute and the security attribute column is active. + let xattr_count = file.extended_attributes.len(); + let selinux_ctx_shown = self.opts.secattr && match file.security_context().context { + SecurityContextType::SELinux(_) => true, + SecurityContextType::None => false, + }; + xattr_count > 1 || (xattr_count == 1 && !selinux_ctx_shown) + } + /// Adds files to the table, possibly recursively. This is easily /// parallelisable, and uses a pool of threads. fn add_files_to_table<'dir>(&self, pool: &mut Pool, table: &mut Option>, rows: &mut Vec, src: &[File<'dir>], depth: TreeDepth) { use std::sync::{Arc, Mutex}; - use log::*; use crate::fs::feature::xattr; let mut file_eggs = (0..src.len()).map(|_| MaybeUninit::uninit()).collect::>(); @@ -207,7 +222,6 @@ impl<'a> Render<'a> { scoped.execute(move || { let mut errors = Vec::new(); - let mut xattrs = Vec::new(); // There are three “levels” of extended attribute support: // @@ -216,7 +230,7 @@ impl<'a> Render<'a> { // 2. If the feature is enabled and the --extended flag // has been specified, then display an @ in the // permissions column for files with attributes, the - // names of all attributes and their lengths, and any + // names of all attributes and their values, and any // errors encountered when getting them. // 3. If the --extended flag *hasn’t* been specified, then // display the @, but don’t display anything else. @@ -231,28 +245,14 @@ impl<'a> Render<'a> { // printed unless the user passes --extended to signify // that they want to see them. - if xattr::ENABLED { - match file.path.attributes() { - Ok(xs) => { - xattrs.extend(xs); - } - Err(e) => { - if self.opts.xattr { - errors.push((e, None)); - } - else { - error!("Error looking up xattr for {:?}: {:#?}", file.path, e); - } - } - } - } + let xattrs: &[Attribute] = if xattr::ENABLED && self.opts.xattr { + &file.extended_attributes + } else { + &[] + }; let table_row = table.as_ref() - .map(|t| t.row_for_file(file, ! xattrs.is_empty())); - - if ! self.opts.xattr { - xattrs.clear(); - } + .map(|t| t.row_for_file(file, self.show_xattr_hint(file))); let mut dir = None; if let Some(r) = self.recurse { @@ -300,7 +300,7 @@ impl<'a> Render<'a> { rows.push(row); if let Some(ref dir) = egg.dir { - for file_to_add in dir.files(self.filter.dot_filter, self.git, self.git_ignoring) { + for file_to_add in dir.files(self.filter.dot_filter, self.git, self.git_ignoring, egg.file.deref_links) { match file_to_add { Ok(f) => { files.push(f); @@ -315,7 +315,7 @@ impl<'a> Render<'a> { if ! files.is_empty() { for xattr in egg.xattrs { - rows.push(self.render_xattr(&xattr, TreeParams::new(depth.deeper(), false))); + rows.push(self.render_xattr(xattr, TreeParams::new(depth.deeper(), false))); } for (error, path) in errors { @@ -330,7 +330,7 @@ impl<'a> Render<'a> { let count = egg.xattrs.len(); for (index, xattr) in egg.xattrs.into_iter().enumerate() { let params = TreeParams::new(depth.deeper(), errors.is_empty() && index == count - 1); - let r = self.render_xattr(&xattr, params); + let r = self.render_xattr(xattr, params); rows.push(r); } @@ -367,7 +367,7 @@ impl<'a> Render<'a> { } fn render_xattr(&self, xattr: &Attribute, tree: TreeParams) -> Row { - let name = TextCell::paint(self.theme.ui.perms.attribute, format!("{} (len {})", xattr.name, xattr.size)); + let name = TextCell::paint(self.theme.ui.perms.attribute, format!("{}=\"{}\"", xattr.name, xattr.value)); Row { cells: None, name, tree } } diff --git a/src/output/file_name.rs b/src/output/file_name.rs index b6a38c0e..8ffb22d3 100644 --- a/src/output/file_name.rs +++ b/src/output/file_name.rs @@ -19,6 +19,9 @@ pub struct Options { /// Whether to prepend icon characters before file names. pub show_icons: ShowIcons, + + /// Whether to make file names hyperlinks. + pub embed_hyperlinks: EmbedHyperlinks, } impl Options { @@ -54,7 +57,7 @@ enum LinkStyle { /// Whether to append file class characters to the file names. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum Classify { /// Just display the file names, without any characters. @@ -73,7 +76,7 @@ impl Default for Classify { /// Whether and how to show icons. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum ShowIcons { /// Don’t show icons at all. @@ -84,6 +87,13 @@ pub enum ShowIcons { On(u32), } +/// Whether to embed hyperlinks. +#[derive(PartialEq, Eq, Debug, Copy, Clone)] +pub enum EmbedHyperlinks{ + + Off, + On, +} /// A **file name** holds all the information necessary to display the name /// of the given file. This is used in all of the views. @@ -101,7 +111,7 @@ pub struct FileName<'a, 'dir, C> { /// How to handle displaying links. link_style: LinkStyle, - options: Options, + pub options: Options, } impl<'a, 'dir, C> FileName<'a, 'dir, C> { @@ -151,7 +161,7 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { // indicate this fact. But when showing targets, we can just // colour the path instead (see below), and leave the broken // link’s filename as the link colour. - for bit in self.coloured_file_name() { + for bit in self.escaped_file_name() { bits.push(bit); } } @@ -171,6 +181,7 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { let target_options = Options { classify: Classify::JustFilenames, show_icons: ShowIcons::Off, + embed_hyperlinks: EmbedHyperlinks::Off, }; let target_name = FileName { @@ -181,7 +192,7 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { options: target_options, }; - for bit in target_name.coloured_file_name() { + for bit in target_name.escaped_file_name() { bits.push(bit); } @@ -226,7 +237,7 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { let coconut = parent.components().count(); if coconut == 1 && parent.has_root() { - bits.push(self.colours.symlink_path().paint("/")); + bits.push(self.colours.symlink_path().paint(std::path::MAIN_SEPARATOR.to_string())); } else if coconut >= 1 { escape( @@ -235,12 +246,13 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { self.colours.symlink_path(), self.colours.control_char(), ); - bits.push(self.colours.symlink_path().paint("/")); + bits.push(self.colours.symlink_path().paint(std::path::MAIN_SEPARATOR.to_string())); } } /// The character to be displayed after a file when classifying is on, if /// the file’s type has one associated with it. + #[cfg(unix)] fn classify_char(&self, file: &File<'_>) -> Option<&'static str> { if file.is_executable_file() { Some("*") @@ -262,9 +274,24 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { } } + #[cfg(windows)] + fn classify_char(&self, file: &File<'_>) -> Option<&'static str> { + if file.is_directory() { + Some("/") + } + else if file.is_link() { + Some("@") + } + else { + None + } + } + /// Returns at least one ANSI-highlighted string representing this file’s /// name using the given set of colours. /// + /// If --hyperlink flag is provided, it will escape the filename accordingly. + /// /// Ordinarily, this will be just one string: the file’s complete name, /// coloured according to its file type. If the name contains control /// characters such as newlines or escapes, though, we can’t just print them @@ -272,12 +299,11 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { /// /// So in that situation, those characters will be escaped and highlighted in /// a different colour. - fn coloured_file_name<'unused>(&self) -> Vec> { + fn escaped_file_name<'unused>(&self) -> Vec> { let file_style = self.style(); let mut bits = Vec::new(); - escape( - self.file.name.clone(), + self.escape_color_and_hyperlinks( &mut bits, file_style, self.colours.control_char(), @@ -286,6 +312,52 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { bits } + // An adapted version of escape::escape. + // afaik of all the calls to escape::escape, only for escaped_file_name, the call to escape needs to be checked for hyper links + // and if that's the case then I think it's best to not try and generalize escape::escape to this case, + // as this adaptation would incur some unneeded operations there + pub fn escape_color_and_hyperlinks(&self, bits: &mut Vec>, good: Style, bad: Style) { + let string = self.file.name.to_owned(); + + if string.chars().all(|c| c >= 0x20 as char && c != 0x7f as char) { + let painted = good.paint(string); + + let adjusted_filename = if let EmbedHyperlinks::On = self.options.embed_hyperlinks { + ANSIString::from(format!("\x1B]8;;{}\x1B\x5C{}\x1B]8;;\x1B\x5C", self.file.path.display(), painted)) + } else { + painted + }; + bits.push(adjusted_filename); + return; + } + + // again adapted from escape::escape + // still a slow route, but slightly improved to at least not reallocate buff + have a predetermined buff size + // + // also note that buff would never need more than len, + // even tho 'in total' it will be lenghier than len (as we expand with escape_default), + // because we clear it after an irregularity + let mut buff = String::with_capacity(string.len()); + for c in string.chars() { + // The `escape_default` method on `char` is *almost* what we want here, but + // it still escapes non-ASCII UTF-8 characters, which are still printable. + + if c >= 0x20 as char && c != 0x7f as char { + buff.push(c); + } + else { + if ! buff.is_empty() { + bits.push(good.paint(std::mem::take(&mut buff))); + } + // biased towards regular characters, so we still collect on first sight of bad char + for e in c.escape_default() { + buff.push(e); + } + bits.push(bad.paint(std::mem::take(&mut buff))); + } + } + } + /// Figures out which colour to paint the filename part of the output, /// depending on which “type” of file it appears to be — either from the /// class on the filesystem or from its name. (Or the broken link colour, @@ -301,16 +373,26 @@ impl<'a, 'dir, C: Colours> FileName<'a, 'dir, C> { match self.file { f if f.is_directory() => self.colours.directory(), + #[cfg(unix)] f if f.is_executable_file() => self.colours.executable_file(), f if f.is_link() => self.colours.symlink(), + #[cfg(unix)] f if f.is_pipe() => self.colours.pipe(), + #[cfg(unix)] f if f.is_block_device() => self.colours.block_device(), + #[cfg(unix)] f if f.is_char_device() => self.colours.char_device(), + #[cfg(unix)] f if f.is_socket() => self.colours.socket(), f if ! f.is_file() => self.colours.special(), _ => self.colours.colour_file(self.file), } } + + /// For grid's use, to cover the case of hyperlink escape sequences + pub fn bare_width(&self) -> usize { + self.file.name.len() + } } diff --git a/src/output/grid.rs b/src/output/grid.rs index 0e1b6942..b63a7a84 100644 --- a/src/output/grid.rs +++ b/src/output/grid.rs @@ -5,10 +5,11 @@ use term_grid as tg; use crate::fs::File; use crate::fs::filter::FileFilter; use crate::output::file_name::Options as FileStyle; +use crate::output::file_name::{ShowIcons, EmbedHyperlinks}; use crate::theme::Theme; -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub struct Options { pub across: bool, } @@ -41,12 +42,21 @@ impl<'a> Render<'a> { self.filter.sort_files(&mut self.files); for file in &self.files { - let filename = self.file_style.for_file(file, self.theme).paint(); + let filename = self.file_style.for_file(file, self.theme); + let contents = filename.paint(); + let width; + + match (filename.options.embed_hyperlinks, filename.options.show_icons) { + (EmbedHyperlinks::On, ShowIcons::On(spacing)) => width = filename.bare_width() + 1 + (spacing as usize), + (EmbedHyperlinks::On, ShowIcons::Off) => width = filename.bare_width(), + (EmbedHyperlinks::Off, _) => width = *contents.width(), + }; grid.add(tg::Cell { - contents: filename.strings().to_string(), - width: *filename.width(), - alignment: tg::Alignment::Left, + contents: contents.strings().to_string(), + // with hyperlink escape sequences, + // the actual *contents.width() is larger than actually needed, so we take only the filename + width: width, }); } diff --git a/src/output/grid_details.rs b/src/output/grid_details.rs index fd096da7..9168d528 100644 --- a/src/output/grid_details.rs +++ b/src/output/grid_details.rs @@ -7,7 +7,6 @@ use term_grid as grid; use crate::fs::{Dir, File}; use crate::fs::feature::git::GitCache; -use crate::fs::feature::xattr::FileAttributes; use crate::fs::filter::FileFilter; use crate::output::cell::TextCell; use crate::output::details::{Options as DetailsOptions, Row as DetailsRow, Render as DetailsRender}; @@ -18,7 +17,7 @@ use crate::output::tree::{TreeParams, TreeDepth}; use crate::theme::Theme; -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Options { pub grid: GridOptions, pub details: DetailsOptions, @@ -39,7 +38,7 @@ impl Options { /// small directory of four files in four columns, the files just look spaced /// out and it’s harder to see what’s going on. So it can be enabled just for /// larger directory listings. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum RowThreshold { /// Only use grid-details view if it would result in at least this many @@ -150,7 +149,7 @@ impl<'a> Render<'a> { let (first_table, _) = self.make_table(options, &drender); let rows = self.files.iter() - .map(|file| first_table.row_for_file(file, file_has_xattrs(file))) + .map(|file| first_table.row_for_file(file, drender.show_xattr_hint(file))) .collect::>(); let file_names = self.files.iter() @@ -202,7 +201,7 @@ impl<'a> Render<'a> { (None, _) => {/* Keep Git how it is */}, } - let mut table = Table::new(options, self.git, &self.theme); + let mut table = Table::new(options, self.git, self.theme); let mut rows = Vec::new(); if self.details.header { @@ -263,7 +262,6 @@ impl<'a> Render<'a> { let cell = grid::Cell { contents: ANSIStrings(&column[row].contents).to_string(), width: *column[row].width, - alignment: grid::Alignment::Left, }; grid.add(cell); @@ -277,7 +275,6 @@ impl<'a> Render<'a> { let cell = grid::Cell { contents: ANSIStrings(&cell.contents).to_string(), width: *cell.width, - alignment: grid::Alignment::Left, }; grid.add(cell); @@ -299,11 +296,3 @@ fn divide_rounding_up(a: usize, b: usize) -> usize { result } - - -fn file_has_xattrs(file: &File<'_>) -> bool { - match file.path.attributes() { - Ok(attrs) => ! attrs.is_empty(), - Err(_) => false, - } -} diff --git a/src/output/icons.rs b/src/output/icons.rs index 493bfea2..fdd6af25 100644 --- a/src/output/icons.rs +++ b/src/output/icons.rs @@ -16,14 +16,16 @@ pub enum Icons { Audio, Image, Video, + Compressed, } impl Icons { pub fn value(self) -> char { match self { - Self::Audio => '\u{f001}', - Self::Image => '\u{f1c5}', - Self::Video => '\u{f03d}', + Self::Audio => '\u{f001}', //  + Self::Image => '\u{f1c5}', //  + Self::Video => '\u{f03d}', //  + Self::Compressed => '\u{f410}', //  } } } @@ -57,16 +59,20 @@ lazy_static! { m.insert(".gitconfig", '\u{f1d3}'); //  m.insert(".github", '\u{f408}'); //  m.insert(".gitignore", '\u{f1d3}'); //  + m.insert(".gitignore_global", '\u{f1d3}'); //  m.insert(".gitmodules", '\u{f1d3}'); //  m.insert(".rvm", '\u{e21e}'); //  - m.insert(".vimrc", '\u{e62b}'); //  + m.insert(".vimrc", '\u{e7c5}'); //  m.insert(".vscode", '\u{e70c}'); //  m.insert(".zshrc", '\u{f489}'); //  + m.insert(".emacs", '\u{e632}'); //  + m.insert("LICENSE", '\u{f02d}'); //  m.insert("Cargo.lock", '\u{e7a8}'); //  m.insert("bin", '\u{e5fc}'); //  m.insert("config", '\u{e5fc}'); //  m.insert("docker-compose.yml", '\u{f308}'); //  m.insert("Dockerfile", '\u{f308}'); //  + m.insert("Earthfile", '\u{f30d}'); // 🌍 m.insert("ds_store", '\u{f179}'); //  m.insert("gitignore_global", '\u{f1d3}'); //  m.insert("go.mod", '\u{e626}'); //  @@ -88,6 +94,7 @@ lazy_static! { m.insert("PKGBUILD", '\u{f303}'); //  m.insert("rubydoc", '\u{e73b}'); //  m.insert("yarn.lock", '\u{e718}'); //  + m.insert("Vagrantfile", '\u{2371}'); //⍱ m }; @@ -102,7 +109,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "bin" => '\u{e5fc}', //  ".git" => '\u{f1d3}', //  ".idea" => '\u{e7b5}', //  - _ => '\u{f115}' //  + _ => '\u{f413}' //  } } else if let Some(icon) = extensions.icon_file(file) { icon } @@ -130,6 +137,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "cab" => '\u{e70f}', //  "cc" => '\u{e61d}', //  "cfg" => '\u{e615}', //  + "cjs" => '\u{e74e}', //  "class" => '\u{e256}', //  "clj" => '\u{e768}', //  "cljs" => '\u{e76a}', //  @@ -140,13 +148,14 @@ pub fn icon_for_file(file: &File<'_>) -> char { "cp" => '\u{e61d}', //  "cpio" => '\u{f410}', //  "cpp" => '\u{e61d}', //  - "cs" => '\u{f81a}', //  + "cs" => '\u{f031b}', // 󰌛 "csh" => '\u{f489}', //  "cshtml" => '\u{f1fa}', //  - "csproj" => '\u{f81a}', //  + "csproj" => '\u{f031b}', // 󰌛 "css" => '\u{e749}', //  "csv" => '\u{f1c3}', //  - "csx" => '\u{f81a}', //  + "csx" => '\u{f031b}', // 󰌛 + "cts" => '\u{e628}', //  "cxx" => '\u{e61d}', //  "d" => '\u{e7af}', //  "dart" => '\u{e798}', //  @@ -164,6 +173,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "ebuild" => '\u{f30d}', //  "editorconfig" => '\u{e615}', //  "ejs" => '\u{e618}', //  + "el" => '\u{e632}', //  "elm" => '\u{e62c}', //  "env" => '\u{f462}', //  "eot" => '\u{f031}', //  @@ -191,6 +201,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "gitignore" => '\u{f1d3}', //  "gitmodules" => '\u{f1d3}', //  "go" => '\u{e626}', //  + "gpg" => '\u{e60a}', //  "gradle" => '\u{e256}', //  "groovy" => '\u{e775}', //  "gsheet" => '\u{f1c3}', //  @@ -209,7 +220,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "img" => '\u{e271}', //  "iml" => '\u{e7b5}', //  "ini" => '\u{f17a}', //  - "ipynb" => '\u{e606}', //  + "ipynb" => '\u{e678}', //  "iso" => '\u{e271}', //  "j2c" => '\u{f1c5}', //  "j2k" => '\u{f1c5}', //  @@ -234,7 +245,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "latex" => '\u{f034}', //  "less" => '\u{e758}', //  "lhs" => '\u{e777}', //  - "license" => '\u{f718}', //  + "license" => '\u{f02d}', //  "localized" => '\u{f179}', //  "lock" => '\u{f023}', //  "log" => '\u{f18d}', //  @@ -245,6 +256,7 @@ pub fn icon_for_file(file: &File<'_>) -> char { "lzma" => '\u{f410}', //  "lzo" => '\u{f410}', //  "m" => '\u{e61e}', //  + "ml" => '\u{1d77a}',// 𝝺 "mm" => '\u{e61d}', //  "m4a" => '\u{f001}', //  "markdown" => '\u{f48a}', //  @@ -255,18 +267,21 @@ pub fn icon_for_file(file: &File<'_>) -> char { "mkv" => '\u{f03d}', //  "mobi" => '\u{e28b}', //  "mov" => '\u{f03d}', //  + "mp2" => '\u{f001}', //  "mp3" => '\u{f001}', //  "mp4" => '\u{f03d}', //  "msi" => '\u{e70f}', //  + "mts" => '\u{e628}', //  "mustache" => '\u{e60f}', //  "nix" => '\u{f313}', //  - "node" => '\u{f898}', //  + "node" => '\u{f0399}', // 󰎙 "npmignore" => '\u{e71e}', //  "odp" => '\u{f1c4}', //  "ods" => '\u{f1c3}', //  "odt" => '\u{f1c2}', //  "ogg" => '\u{f001}', //  "ogv" => '\u{f03d}', //  + "org" => '\u{e633}', //  "otf" => '\u{f031}', //  "part" => '\u{f43a}', //  "patch" => '\u{f440}', //  @@ -298,13 +313,15 @@ pub fn icon_for_file(file: &File<'_>) -> char { "readme" => '\u{f48a}', //  "rlib" => '\u{e7a8}', //  "rmd" => '\u{f48a}', //  + "rmeta" => '\u{e7a8}', //  "rpm" => '\u{e7bb}', //  "rs" => '\u{e7a8}', //  "rspec" => '\u{e21e}', //  "rspec_parallel"=> '\u{e21e}', //  "rspec_status" => '\u{e21e}', //  "rss" => '\u{f09e}', //  - "rtf" => '\u{f718}', //  + "rst" => '\u{f15c}', //  + "rtf" => '\u{f0219}', // 󰈙 "ru" => '\u{e21e}', //  "rubydoc" => '\u{e73b}', //  "sass" => '\u{e603}', //  @@ -343,8 +360,8 @@ pub fn icon_for_file(file: &File<'_>) -> char { "tz" => '\u{f410}', //  "tzo" => '\u{f410}', //  "video" => '\u{f03d}', //  - "vim" => '\u{e62b}', //  - "vue" => '\u{fd42}', // ﵂ + "vim" => '\u{e7c5}', //  + "vue" => '\u{f0844}', // 󰡄 "war" => '\u{e256}', //  "wav" => '\u{f001}', //  "webm" => '\u{f03d}', //  @@ -355,16 +372,18 @@ pub fn icon_for_file(file: &File<'_>) -> char { "xhtml" => '\u{f13b}', //  "xls" => '\u{f1c3}', //  "xlsx" => '\u{f1c3}', //  - "xml" => '\u{f121}', //  - "xul" => '\u{f121}', //  + "xml" => '\u{f05c0}', // 󰗀 + "xul" => '\u{f05c0}', // 󰗀 "xz" => '\u{f410}', //  "yaml" => '\u{f481}', //  "yml" => '\u{f481}', //  + "zig" => '\u{21af}', // ↯ "zip" => '\u{f410}', //  "zsh" => '\u{f489}', //  "zsh-theme" => '\u{f489}', //  "zshrc" => '\u{f489}', //  "zst" => '\u{f410}', //  + "svelte" => '\u{e697}', //  _ => '\u{f15b}' //  } } diff --git a/src/output/mod.rs b/src/output/mod.rs index 2c640108..35f2a053 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -22,11 +22,12 @@ pub struct View { pub mode: Mode, pub width: TerminalWidth, pub file_style: file_name::Options, + pub deref_links: bool, } /// The **mode** is the “type” of output. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] #[allow(clippy::large_enum_variant)] pub enum Mode { Grid(grid::Options), @@ -37,7 +38,7 @@ pub enum Mode { /// The width of the terminal requested by the user. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum TerminalWidth { /// The user requested this specific number of columns. diff --git a/src/output/render/blocks.rs b/src/output/render/blocks.rs index 5745f8f8..cd5182da 100644 --- a/src/output/render/blocks.rs +++ b/src/output/render/blocks.rs @@ -43,7 +43,7 @@ pub mod test { let blox = f::Blocks::None; let expected = TextCell::blank(Green.italic()); - assert_eq!(expected, blox.render(&TestColours).into()); + assert_eq!(expected, blox.render(&TestColours)); } @@ -52,6 +52,6 @@ pub mod test { let blox = f::Blocks::Some(3005); let expected = TextCell::paint_str(Red.blink(), "3005"); - assert_eq!(expected, blox.render(&TestColours).into()); + assert_eq!(expected, blox.render(&TestColours)); } } diff --git a/src/output/render/git.rs b/src/output/render/git.rs index 3740d406..2eeb5860 100644 --- a/src/output/render/git.rs +++ b/src/output/render/git.rs @@ -1,4 +1,4 @@ -use ansi_term::{ANSIString, Style}; +use ansi_term::{ANSIString, Style, Color}; use crate::output::cell::{TextCell, DisplayWidth}; use crate::fs::fields as f; @@ -16,6 +16,31 @@ impl f::Git { } } +impl f::SubdirGitRepo { + pub fn render(self) -> TextCell { + let style = Style::new(); + let branch_style = match self.branch.as_deref(){ + Some("master") => style.fg(Color::Green), + Some("main") => style.fg(Color::Green), + Some(_) => style.fg(Color::Fixed(208)), + _ => style, + }; + + let branch = branch_style.paint(self.branch.unwrap_or(String::from("-"))); + + let s = match self.status { + f::SubdirGitRepoStatus::NoRepo => style.paint("- "), + f::SubdirGitRepoStatus::GitClean => style.fg(Color::Green).paint("| "), + f::SubdirGitRepoStatus::GitDirty => style.bold().fg(Color::Red).paint("- "), + f::SubdirGitRepoStatus::GitUnknown => style.paint("- "), + }; + + TextCell { + width: DisplayWidth::from(2 + branch.len()), + contents: vec![s,branch].into(), + } + } +} impl f::GitStatus { fn render(self, colours: &dyn Colours) -> ANSIString<'static> { @@ -85,7 +110,7 @@ pub mod test { ].into(), }; - assert_eq!(expected, stati.render(&TestColours).into()) + assert_eq!(expected, stati.render(&TestColours)) } @@ -104,6 +129,6 @@ pub mod test { ].into(), }; - assert_eq!(expected, stati.render(&TestColours).into()) + assert_eq!(expected, stati.render(&TestColours)) } } diff --git a/src/output/render/groups.rs b/src/output/render/groups.rs index ce33f9fa..a250a9fa 100644 --- a/src/output/render/groups.rs +++ b/src/output/render/groups.rs @@ -5,18 +5,25 @@ use crate::fs::fields as f; use crate::output::cell::TextCell; use crate::output::table::UserFormat; +pub trait Render{ + fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell; +} -impl f::Group { - pub fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell { +impl Render for Option { + fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell { use users::os::unix::GroupExt; let mut style = colours.not_yours(); - let group = match users.get_group_by_gid(self.0) { - Some(g) => (*g).clone(), - None => return TextCell::paint(style, self.0.to_string()), + let group = match self { + Some(g) => match users.get_group_by_gid(g.0) { + Some(g) => (*g).clone(), + None => return TextCell::paint(style, g.0.to_string()), + }, + None => return TextCell::blank(colours.no_group()), }; + let current_uid = users.get_current_uid(); if let Some(current_user) = users.get_user_by_uid(current_uid) { @@ -40,13 +47,14 @@ impl f::Group { pub trait Colours { fn yours(&self) -> Style; fn not_yours(&self) -> Style; + fn no_group(&self) -> Style; } #[cfg(test)] #[allow(unused_results)] pub mod test { - use super::Colours; + use super::{Colours, Render}; use crate::fs::fields as f; use crate::output::cell::TextCell; use crate::output::table::UserFormat; @@ -63,6 +71,7 @@ pub mod test { impl Colours for TestColours { fn yours(&self) -> Style { Fixed(80).normal() } fn not_yours(&self) -> Style { Fixed(81).normal() } + fn no_group(&self) -> Style { Black.italic() } } @@ -71,7 +80,7 @@ pub mod test { let mut users = MockUsers::with_current_uid(1000); users.add_group(Group::new(100, "folk")); - let group = f::Group(100); + let group = Some(f::Group(100)); let expected = TextCell::paint_str(Fixed(81).normal(), "folk"); assert_eq!(expected, group.render(&TestColours, &users, UserFormat::Name)); @@ -84,7 +93,7 @@ pub mod test { fn unnamed() { let users = MockUsers::with_current_uid(1000); - let group = f::Group(100); + let group = Some(f::Group(100)); let expected = TextCell::paint_str(Fixed(81).normal(), "100"); assert_eq!(expected, group.render(&TestColours, &users, UserFormat::Name)); assert_eq!(expected, group.render(&TestColours, &users, UserFormat::Numeric)); @@ -96,7 +105,7 @@ pub mod test { users.add_user(User::new(2, "eve", 100)); users.add_group(Group::new(100, "folk")); - let group = f::Group(100); + let group = Some(f::Group(100)); let expected = TextCell::paint_str(Fixed(80).normal(), "folk"); assert_eq!(expected, group.render(&TestColours, &users, UserFormat::Name)) } @@ -109,14 +118,14 @@ pub mod test { let test_group = Group::new(100, "folk").add_member("eve"); users.add_group(test_group); - let group = f::Group(100); + let group = Some(f::Group(100)); let expected = TextCell::paint_str(Fixed(80).normal(), "folk"); assert_eq!(expected, group.render(&TestColours, &users, UserFormat::Name)) } #[test] fn overflow() { - let group = f::Group(2_147_483_648); + let group = Some(f::Group(2_147_483_648)); let expected = TextCell::paint_str(Fixed(81).normal(), "2147483648"); assert_eq!(expected, group.render(&TestColours, &MockUsers::with_current_uid(0), UserFormat::Numeric)); } diff --git a/src/output/render/inode.rs b/src/output/render/inode.rs index 7737aa2c..8bb3249d 100644 --- a/src/output/render/inode.rs +++ b/src/output/render/inode.rs @@ -21,8 +21,8 @@ pub mod test { #[test] fn blocklessness() { - let io = f::Inode(1414213); + let io = f::Inode(1_414_213); let expected = TextCell::paint_str(Cyan.underline(), "1414213"); - assert_eq!(expected, io.render(Cyan.underline()).into()); + assert_eq!(expected, io.render(Cyan.underline())); } } diff --git a/src/output/render/links.rs b/src/output/render/links.rs index 19b5f187..612a5d36 100644 --- a/src/output/render/links.rs +++ b/src/output/render/links.rs @@ -52,7 +52,7 @@ pub mod test { contents: vec![ Blue.paint("1") ].into(), }; - assert_eq!(expected, stati.render(&TestColours, &locale::Numeric::english()).into()); + assert_eq!(expected, stati.render(&TestColours, &locale::Numeric::english())); } #[test] @@ -67,7 +67,7 @@ pub mod test { contents: vec![ Blue.paint("3,005") ].into(), }; - assert_eq!(expected, stati.render(&TestColours, &locale::Numeric::english()).into()); + assert_eq!(expected, stati.render(&TestColours, &locale::Numeric::english())); } #[test] @@ -82,6 +82,6 @@ pub mod test { contents: vec![ Blue.on(Red).paint("3,005") ].into(), }; - assert_eq!(expected, stati.render(&TestColours, &locale::Numeric::english()).into()); + assert_eq!(expected, stati.render(&TestColours, &locale::Numeric::english())); } } diff --git a/src/output/render/mod.rs b/src/output/render/mod.rs index 7bb11892..ac77a132 100644 --- a/src/output/render/mod.rs +++ b/src/output/render/mod.rs @@ -7,8 +7,10 @@ pub use self::filetype::Colours as FiletypeColours; mod git; pub use self::git::Colours as GitColours; +#[cfg(unix)] mod groups; -pub use self::groups::Colours as GroupColours; +#[cfg(unix)] +pub use self::groups::{Colours as GroupColours, Render as GroupRender}; mod inode; // inode uses just one colour @@ -17,7 +19,7 @@ mod links; pub use self::links::Colours as LinksColours; mod permissions; -pub use self::permissions::Colours as PermissionsColours; +pub use self::permissions::{Colours as PermissionsColours, PermissionsPlusRender}; mod size; pub use self::size::Colours as SizeColours; @@ -26,8 +28,15 @@ mod times; pub use self::times::Render as TimeRender; // times does too +#[cfg(unix)] mod users; +#[cfg(unix)] pub use self::users::Colours as UserColours; +pub use self::users::Render as UserRender; mod octal; +pub use self::octal::Render as OctalPermissionsRender; // octal uses just one colour + +mod securityctx; +pub use self::securityctx::Colours as SecurityCtxColours; diff --git a/src/output/render/octal.rs b/src/output/render/octal.rs index 1c3eb074..74746597 100644 --- a/src/output/render/octal.rs +++ b/src/output/render/octal.rs @@ -3,26 +3,37 @@ use ansi_term::Style; use crate::fs::fields as f; use crate::output::cell::TextCell; +pub trait Render { + fn render(&self, style: Style) -> TextCell; +} + +impl Render for Option { + fn render(&self, style: Style) -> TextCell { + match self { + Some(p) => { + let perm = &p.permissions; + let octal_sticky = f::OctalPermissions::bits_to_octal(perm.setuid, perm.setgid, perm.sticky); + let octal_owner = f::OctalPermissions::bits_to_octal(perm.user_read, perm.user_write, perm.user_execute); + let octal_group = f::OctalPermissions::bits_to_octal(perm.group_read, perm.group_write, perm.group_execute); + let octal_other = f::OctalPermissions::bits_to_octal(perm.other_read, perm.other_write, perm.other_execute); + + TextCell::paint(style, format!("{}{}{}{}", octal_sticky, octal_owner, octal_group, octal_other)) + }, + None => TextCell::paint(style, "----".into()) + } + } +} impl f::OctalPermissions { fn bits_to_octal(r: bool, w: bool, x: bool) -> u8 { - (r as u8) * 4 + (w as u8) * 2 + (x as u8) - } - - pub fn render(&self, style: Style) -> TextCell { - let perm = &self.permissions; - let octal_sticky = Self::bits_to_octal(perm.setuid, perm.setgid, perm.sticky); - let octal_owner = Self::bits_to_octal(perm.user_read, perm.user_write, perm.user_execute); - let octal_group = Self::bits_to_octal(perm.group_read, perm.group_write, perm.group_execute); - let octal_other = Self::bits_to_octal(perm.other_read, perm.other_write, perm.other_execute); - - TextCell::paint(style, format!("{}{}{}{}", octal_sticky, octal_owner, octal_group, octal_other)) + u8::from(r) * 4 + u8::from(w) * 2 + u8::from(x) } } #[cfg(test)] pub mod test { + use super::Render; use crate::output::cell::TextCell; use crate::fs::fields as f; @@ -37,10 +48,10 @@ pub mod test { other_read: true, other_write: false, other_execute: true, sticky: false, }; - let octal = f::OctalPermissions{ permissions: bits }; + let octal = Some(f::OctalPermissions{ permissions: bits }); let expected = TextCell::paint_str(Purple.bold(), "0755"); - assert_eq!(expected, octal.render(Purple.bold()).into()); + assert_eq!(expected, octal.render(Purple.bold())); } #[test] @@ -51,10 +62,10 @@ pub mod test { other_read: true, other_write: false, other_execute: false, sticky: false, }; - let octal = f::OctalPermissions{ permissions: bits }; + let octal = Some(f::OctalPermissions{ permissions: bits }); let expected = TextCell::paint_str(Purple.bold(), "0644"); - assert_eq!(expected, octal.render(Purple.bold()).into()); + assert_eq!(expected, octal.render(Purple.bold())); } #[test] @@ -65,10 +76,10 @@ pub mod test { other_read: false, other_write: false, other_execute: false, sticky: false, }; - let octal = f::OctalPermissions{ permissions: bits }; + let octal = Some(f::OctalPermissions{ permissions: bits }); let expected = TextCell::paint_str(Purple.bold(), "0600"); - assert_eq!(expected, octal.render(Purple.bold()).into()); + assert_eq!(expected, octal.render(Purple.bold())); } #[test] @@ -79,10 +90,10 @@ pub mod test { other_read: true, other_write: true, other_execute: true, sticky: false, }; - let octal = f::OctalPermissions{ permissions: bits }; + let octal = Some(f::OctalPermissions{ permissions: bits }); let expected = TextCell::paint_str(Purple.bold(), "4777"); - assert_eq!(expected, octal.render(Purple.bold()).into()); + assert_eq!(expected, octal.render(Purple.bold())); } @@ -94,10 +105,10 @@ pub mod test { other_read: true, other_write: true, other_execute: true, sticky: false, }; - let octal = f::OctalPermissions{ permissions: bits }; + let octal = Some(f::OctalPermissions{ permissions: bits }); let expected = TextCell::paint_str(Purple.bold(), "2777"); - assert_eq!(expected, octal.render(Purple.bold()).into()); + assert_eq!(expected, octal.render(Purple.bold())); } #[test] @@ -108,9 +119,9 @@ pub mod test { other_read: true, other_write: true, other_execute: true, sticky: true, }; - let octal = f::OctalPermissions{ permissions: bits }; + let octal = Some(f::OctalPermissions{ permissions: bits }); let expected = TextCell::paint_str(Purple.bold(), "1777"); - assert_eq!(expected, octal.render(Purple.bold()).into()); + assert_eq!(expected, octal.render(Purple.bold())); } } diff --git a/src/output/render/permissions.rs b/src/output/render/permissions.rs index 2e237768..4626ad5a 100644 --- a/src/output/render/permissions.rs +++ b/src/output/render/permissions.rs @@ -1,22 +1,51 @@ +use std::iter; + use ansi_term::{ANSIString, Style}; use crate::fs::fields as f; use crate::output::cell::{TextCell, DisplayWidth}; use crate::output::render::FiletypeColours; +pub trait PermissionsPlusRender { + fn render(&self, colours: &C) -> TextCell; +} -impl f::PermissionsPlus { - pub fn render(&self, colours: &C) -> TextCell { - let mut chars = vec![ self.file_type.render(colours) ]; - chars.extend(self.permissions.render(colours, self.file_type.is_regular_file())); +#[cfg(unix)] +impl PermissionsPlusRender for Option { + fn render(&self, colours: &C) -> TextCell { + match self { + Some(p) => { + let mut chars = vec![ p.file_type.render(colours) ]; + let permissions = p.permissions; + chars.extend(Some(permissions).render(colours, p.file_type.is_regular_file())); - if self.xattrs { - chars.push(colours.attribute().paint("@")); + if p.xattrs { + chars.push(colours.attribute().paint("@")); + } + + // As these are all ASCII characters, we can guarantee that they’re + // all going to be one character wide, and don’t need to compute the + // cell’s display width. + TextCell { + width: DisplayWidth::from(chars.len()), + contents: chars.into(), + } + }, + None => { + let chars: Vec<_> = iter::repeat(colours.dash().paint("-")).take(10).collect(); + TextCell { + width: DisplayWidth::from(chars.len()), + contents: chars.into(), + } + } } + } + + #[cfg(windows)] + pub fn render(&self, colours: &C) -> TextCell { + let mut chars = vec![ self.attributes.render_type(colours) ]; + chars.extend(self.attributes.render(colours)); - // As these are all ASCII characters, we can guarantee that they’re - // all going to be one character wide, and don’t need to compute the - // cell’s display width. TextCell { width: DisplayWidth::from(chars.len()), contents: chars.into(), @@ -24,28 +53,39 @@ impl f::PermissionsPlus { } } +pub trait RenderPermissions { + fn render(&self, colours: &C, is_regular_file: bool) -> Vec>; +} + +impl RenderPermissions for Option { + fn render(&self, colours: &C, is_regular_file: bool) -> Vec> { + match self { + Some(p) => { + let bit = |bit, chr: &'static str, style: Style| { + if bit { style.paint(chr) } + else { colours.dash().paint("-") } + }; + + vec![ + bit(p.user_read, "r", colours.user_read()), + bit(p.user_write, "w", colours.user_write()), + p.user_execute_bit(colours, is_regular_file), + bit(p.group_read, "r", colours.group_read()), + bit(p.group_write, "w", colours.group_write()), + p.group_execute_bit(colours), + bit(p.other_read, "r", colours.other_read()), + bit(p.other_write, "w", colours.other_write()), + p.other_execute_bit(colours) + ] + }, + None => { + iter::repeat(colours.dash().paint("-")).take(9).collect() + } + } + } +} impl f::Permissions { - pub fn render(&self, colours: &C, is_regular_file: bool) -> Vec> { - - let bit = |bit, chr: &'static str, style: Style| { - if bit { style.paint(chr) } - else { colours.dash().paint("-") } - }; - - vec![ - bit(self.user_read, "r", colours.user_read()), - bit(self.user_write, "w", colours.user_write()), - self.user_execute_bit(colours, is_regular_file), - bit(self.group_read, "r", colours.group_read()), - bit(self.group_write, "w", colours.group_write()), - self.group_execute_bit(colours), - bit(self.other_read, "r", colours.other_read()), - bit(self.other_write, "w", colours.other_write()), - self.other_execute_bit(colours) - ] - } - fn user_execute_bit(&self, colours: &C, is_regular_file: bool) -> ANSIString<'static> { match (self.user_execute, self.setuid, is_regular_file) { (false, false, _) => colours.dash().paint("-"), @@ -76,6 +116,34 @@ impl f::Permissions { } } +#[cfg(windows)] +impl f::Attributes { + pub fn render(&self, colours: &C) -> Vec> { + let bit = |bit, chr: &'static str, style: Style| { + if bit { style.paint(chr) } + else { colours.dash().paint("-") } + }; + + vec![ + bit(self.archive, "a", colours.normal()), + bit(self.readonly, "r", colours.user_read()), + bit(self.hidden, "h", colours.special_user_file()), + bit(self.system, "s", colours.special_other()), + ] + } + + pub fn render_type(&self, colours: &C) -> ANSIString<'static> { + if self.reparse_point { + return colours.pipe().paint("l") + } + else if self.directory { + return colours.directory().paint("d") + } + else { + return colours.dash().paint("-") + } + } +} pub trait Colours { fn dash(&self) -> Style; @@ -103,7 +171,7 @@ pub trait Colours { #[cfg(test)] #[allow(unused_results)] pub mod test { - use super::Colours; + use super::{Colours, RenderPermissions}; use crate::output::cell::TextCellContents; use crate::fs::fields as f; @@ -133,11 +201,11 @@ pub mod test { #[test] fn negate() { - let bits = f::Permissions { + let bits = Some(f::Permissions { user_read: false, user_write: false, user_execute: false, setuid: false, group_read: false, group_write: false, group_execute: false, setgid: false, other_read: false, other_write: false, other_execute: false, sticky: false, - }; + }); let expected = TextCellContents::from(vec![ Fixed(11).paint("-"), Fixed(11).paint("-"), Fixed(11).paint("-"), @@ -151,11 +219,11 @@ pub mod test { #[test] fn affirm() { - let bits = f::Permissions { + let bits = Some(f::Permissions { user_read: true, user_write: true, user_execute: true, setuid: false, group_read: true, group_write: true, group_execute: true, setgid: false, other_read: true, other_write: true, other_execute: true, sticky: false, - }; + }); let expected = TextCellContents::from(vec![ Fixed(101).paint("r"), Fixed(102).paint("w"), Fixed(103).paint("x"), @@ -169,11 +237,11 @@ pub mod test { #[test] fn specials() { - let bits = f::Permissions { + let bits = Some(f::Permissions { user_read: false, user_write: false, user_execute: true, setuid: true, group_read: false, group_write: false, group_execute: true, setgid: true, other_read: false, other_write: false, other_execute: true, sticky: true, - }; + }); let expected = TextCellContents::from(vec![ Fixed(11).paint("-"), Fixed(11).paint("-"), Fixed(110).paint("s"), @@ -187,11 +255,11 @@ pub mod test { #[test] fn extra_specials() { - let bits = f::Permissions { + let bits = Some(f::Permissions { user_read: false, user_write: false, user_execute: false, setuid: true, group_read: false, group_write: false, group_execute: false, setgid: true, other_read: false, other_write: false, other_execute: false, sticky: true, - }; + }); let expected = TextCellContents::from(vec![ Fixed(11).paint("-"), Fixed(11).paint("-"), Fixed(111).paint("S"), diff --git a/src/output/render/securityctx.rs b/src/output/render/securityctx.rs new file mode 100644 index 00000000..4746715a --- /dev/null +++ b/src/output/render/securityctx.rs @@ -0,0 +1,45 @@ +use ansi_term::Style; + +use crate::fs::fields as f; +use crate::output::cell::{TextCell, DisplayWidth}; + + +impl f::SecurityContext<'_> { + pub fn render(&self, colours: &C) -> TextCell { + match &self.context { + f::SecurityContextType::None => { + TextCell::paint_str(colours.none(), "?") + } + f::SecurityContextType::SELinux(context) => { + let mut chars = Vec::with_capacity(7); + + for (i, part) in context.split(':').enumerate() { + let partcolour = match i { + 0 => colours.selinux_user(), + 1 => colours.selinux_role(), + 2 => colours.selinux_type(), + _ => colours.selinux_range() + }; + if i > 0 { + chars.push(colours.selinux_colon().paint(":")); + } + chars.push(partcolour.paint(String::from(part))); + } + + TextCell { + contents: chars.into(), + width: DisplayWidth::from(context.len()) + } + } + } + } +} + +pub trait Colours { + fn none(&self) -> Style; + fn selinux_colon(&self) -> Style; + fn selinux_user(&self) -> Style; + fn selinux_role(&self) -> Style; + fn selinux_type(&self) -> Style; + fn selinux_range(&self) -> Style; +} diff --git a/src/output/render/size.rs b/src/output/render/size.rs index 5a3ad72f..716cc487 100644 --- a/src/output/render/size.rs +++ b/src/output/render/size.rs @@ -153,7 +153,7 @@ pub mod test { #[test] fn file_bytes() { - let directory = f::Size::Some(1048576); + let directory = f::Size::Some(1_048_576); let expected = TextCell { width: DisplayWidth::from(9), contents: vec![ diff --git a/src/output/render/users.rs b/src/output/render/users.rs index 6a99a98e..96f91172 100644 --- a/src/output/render/users.rs +++ b/src/output/render/users.rs @@ -5,16 +5,23 @@ use crate::fs::fields as f; use crate::output::cell::TextCell; use crate::output::table::UserFormat; +pub trait Render { + fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell; +} -impl f::User { - pub fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell { - let user_name = match (format, users.get_user_by_uid(self.0)) { - (_, None) => self.0.to_string(), - (UserFormat::Numeric, _) => self.0.to_string(), +impl Render for Option { + fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell { + let uid = match self { + Some(u) => u.0, + None => return TextCell::blank(colours.no_user()), + }; + let user_name = match (format, users.get_user_by_uid(uid)) { + (_, None) => uid.to_string(), + (UserFormat::Numeric, _) => uid.to_string(), (UserFormat::Name, Some(user)) => user.name().to_string_lossy().into(), }; - let style = if users.get_current_uid() == self.0 { colours.you() } + let style = if users.get_current_uid() == uid { colours.you() } else { colours.someone_else() }; TextCell::paint(style, user_name) } @@ -24,13 +31,14 @@ impl f::User { pub trait Colours { fn you(&self) -> Style; fn someone_else(&self) -> Style; + fn no_user(&self) -> Style; } #[cfg(test)] #[allow(unused_results)] pub mod test { - use super::Colours; + use super::{Colours, Render}; use crate::fs::fields as f; use crate::output::cell::TextCell; use crate::output::table::UserFormat; @@ -46,6 +54,7 @@ pub mod test { impl Colours for TestColours { fn you(&self) -> Style { Red.bold() } fn someone_else(&self) -> Style { Blue.underline() } + fn no_user(&self) -> Style { Black.italic() } } @@ -54,7 +63,7 @@ pub mod test { let mut users = MockUsers::with_current_uid(1000); users.add_user(User::new(1000, "enoch", 100)); - let user = f::User(1000); + let user = Some(f::User(1000)); let expected = TextCell::paint_str(Red.bold(), "enoch"); assert_eq!(expected, user.render(&TestColours, &users, UserFormat::Name)); @@ -66,7 +75,7 @@ pub mod test { fn unnamed() { let users = MockUsers::with_current_uid(1000); - let user = f::User(1000); + let user = Some(f::User(1000)); let expected = TextCell::paint_str(Red.bold(), "1000"); assert_eq!(expected, user.render(&TestColours, &users, UserFormat::Name)); assert_eq!(expected, user.render(&TestColours, &users, UserFormat::Numeric)); @@ -77,21 +86,21 @@ pub mod test { let mut users = MockUsers::with_current_uid(0); users.add_user(User::new(1000, "enoch", 100)); - let user = f::User(1000); + let user = Some(f::User(1000)); let expected = TextCell::paint_str(Blue.underline(), "enoch"); assert_eq!(expected, user.render(&TestColours, &users, UserFormat::Name)); } #[test] fn different_unnamed() { - let user = f::User(1000); + let user = Some(f::User(1000)); let expected = TextCell::paint_str(Blue.underline(), "1000"); assert_eq!(expected, user.render(&TestColours, &MockUsers::with_current_uid(0), UserFormat::Numeric)); } #[test] fn overflow() { - let user = f::User(2_147_483_648); + let user = Some(f::User(2_147_483_648)); let expected = TextCell::paint_str(Blue.underline(), "2147483648"); assert_eq!(expected, user.render(&TestColours, &MockUsers::with_current_uid(0), UserFormat::Numeric)); } diff --git a/src/output/table.rs b/src/output/table.rs index cf8b6cf2..192a555d 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -1,6 +1,7 @@ use std::cmp::max; use std::env; use std::ops::Deref; +#[cfg(unix)] use std::sync::{Mutex, MutexGuard}; use datetime::TimeZone; @@ -8,18 +9,27 @@ use zoneinfo_compiled::{CompiledData, Result as TZResult}; use lazy_static::lazy_static; use log::*; +#[cfg(unix)] use users::UsersCache; use crate::fs::{File, fields as f}; use crate::fs::feature::git::GitCache; use crate::output::cell::TextCell; -use crate::output::render::TimeRender; +use crate::output::render::{ + GroupRender, + OctalPermissionsRender, + PermissionsPlusRender, + TimeRender, + UserRender +}; use crate::output::time::TimeFormat; use crate::theme::Theme; + + /// Options for displaying a table. -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Options { pub size_format: SizeFormat, pub time_format: TimeFormat, @@ -29,7 +39,7 @@ pub struct Options { /// Extra columns to display in the table. #[allow(clippy::struct_excessive_bools)] -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub struct Columns { /// At least one of these timestamps will be shown. @@ -41,7 +51,10 @@ pub struct Columns { pub blocks: bool, pub group: bool, pub git: bool, + pub subdir_git_repos: bool, + pub subdir_git_repos_no_stat: bool, pub octal: bool, + pub security_context: bool, // Defaults to true: pub permissions: bool, @@ -54,10 +67,12 @@ impl Columns { let mut columns = Vec::with_capacity(4); if self.inode { + #[cfg(unix)] columns.push(Column::Inode); } if self.octal { + #[cfg(unix)] columns.push(Column::Octal); } @@ -66,6 +81,7 @@ impl Columns { } if self.links { + #[cfg(unix)] columns.push(Column::HardLinks); } @@ -74,17 +90,24 @@ impl Columns { } if self.blocks { + #[cfg(unix)] columns.push(Column::Blocks); } if self.user { + #[cfg(unix)] columns.push(Column::User); } if self.group { + #[cfg(unix)] columns.push(Column::Group); } + if self.security_context { + columns.push(Column::SecurityContext); + } + if self.time_types.modified { columns.push(Column::Timestamp(TimeType::Modified)); } @@ -105,6 +128,14 @@ impl Columns { columns.push(Column::GitStatus); } + if self.subdir_git_repos { + columns.push(Column::SubdirGitRepoStatus); + } + + if self.subdir_git_repos_no_stat { + columns.push(Column::SubdirGitRepoNoStatus); + } + columns } } @@ -116,13 +147,23 @@ pub enum Column { Permissions, FileSize, Timestamp(TimeType), + #[cfg(unix)] Blocks, + #[cfg(unix)] User, + #[cfg(unix)] Group, + #[cfg(unix)] HardLinks, + #[cfg(unix)] Inode, GitStatus, + SubdirGitRepoStatus, + SubdirGitRepoNoStatus, + #[cfg(unix)] Octal, + #[cfg(unix)] + SecurityContext, } /// Each column can pick its own **Alignment**. Usually, numbers are @@ -136,6 +177,7 @@ pub enum Alignment { impl Column { /// Get the alignment this column should use. + #[cfg(unix)] pub fn alignment(self) -> Alignment { match self { Self::FileSize | @@ -143,6 +185,16 @@ impl Column { Self::Inode | Self::Blocks | Self::GitStatus => Alignment::Right, + Self::Timestamp(_) | + _ => Alignment::Left, + } + } + + #[cfg(windows)] + pub fn alignment(&self) -> Alignment { + match self { + Self::FileSize | + Self::GitStatus => Alignment::Right, _ => Alignment::Left, } } @@ -151,24 +203,37 @@ impl Column { /// to have a header row printed. pub fn header(self) -> &'static str { match self { + #[cfg(unix)] Self::Permissions => "Permissions", + #[cfg(windows)] + Self::Permissions => "Mode", Self::FileSize => "Size", Self::Timestamp(t) => t.header(), + #[cfg(unix)] Self::Blocks => "Blocks", + #[cfg(unix)] Self::User => "User", + #[cfg(unix)] Self::Group => "Group", + #[cfg(unix)] Self::HardLinks => "Links", + #[cfg(unix)] Self::Inode => "inode", Self::GitStatus => "Git", + Self::SubdirGitRepoStatus => "Repo", + Self::SubdirGitRepoNoStatus => "Repo", + #[cfg(unix)] Self::Octal => "Octal", + #[cfg(unix)] + Self::SecurityContext => "Security Context", } } } /// Formatting options for file sizes. -#[allow(clippy::pub_enum_variant_names)] -#[derive(PartialEq, Debug, Copy, Clone)] +#[allow(clippy::enum_variant_names)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum SizeFormat { /// Format the file size using **decimal** prefixes, such as “kilo”, @@ -184,7 +249,7 @@ pub enum SizeFormat { } /// Formatting options for user and group. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum UserFormat { /// The UID / GID Numeric, @@ -201,7 +266,7 @@ impl Default for SizeFormat { /// The types of a file’s time fields. These three fields are standard /// across most (all?) operating systems. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum TimeType { /// The file’s modified time (`st_mtime`). @@ -236,7 +301,7 @@ impl TimeType { /// /// 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)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] #[allow(clippy::struct_excessive_bools)] pub struct TimeTypes { pub modified: bool, @@ -274,10 +339,12 @@ pub struct Environment { tz: Option, /// Mapping cache of user IDs to usernames. + #[cfg(unix)] users: Mutex, } impl Environment { + #[cfg(unix)] pub fn lock_users(&self) -> MutexGuard<'_, UsersCache> { self.users.lock().unwrap() } @@ -288,7 +355,7 @@ impl Environment { Some(t) } Err(ref e) => { - println!("Unable to determine time zone: {}", e); + eprintln!("Unable to determine time zone: {e}"); None } }; @@ -296,12 +363,14 @@ impl Environment { let numeric = locale::Numeric::load_user_locale() .unwrap_or_else(|_| locale::Numeric::english()); + #[cfg(unix)] let users = Mutex::new(UsersCache::new()); - Self { numeric, tz, users } + Self { numeric, tz, #[cfg(unix)] users } } } +#[cfg(unix)] fn determine_time_zone() -> TZResult { if let Ok(file) = env::var("TZ") { TimeZone::from_file({ @@ -310,7 +379,7 @@ fn determine_time_zone() -> TZResult { } else { format!("/usr/share/zoneinfo/{}", { if file.starts_with(':') { - file.replacen(":", "", 1) + file.replacen(':', "", 1) } else { file } @@ -322,6 +391,31 @@ fn determine_time_zone() -> TZResult { } } +#[cfg(windows)] +fn determine_time_zone() -> TZResult { + use datetime::zone::{FixedTimespan, FixedTimespanSet, StaticTimeZone, TimeZoneSource}; + use std::borrow::Cow; + + Ok(TimeZone(TimeZoneSource::Static(&StaticTimeZone { + name: "Unsupported", + fixed_timespans: FixedTimespanSet { + first: FixedTimespan { + offset: 0, + is_dst: false, + name: Cow::Borrowed("ZONE_A"), + }, + rest: &[( + 1206838800, + FixedTimespan { + offset: 3600, + is_dst: false, + name: Cow::Borrowed("ZONE_B"), + }, + )], + }, + }))) +} + lazy_static! { static ref ENVIRONMENT: Environment = Environment::load_all(); } @@ -385,17 +479,27 @@ impl<'a, 'f> Table<'a> { self.widths.add_widths(row) } - fn permissions_plus(&self, file: &File<'_>, xattrs: bool) -> f::PermissionsPlus { - f::PermissionsPlus { - file_type: file.type_char(), - permissions: file.permissions(), - xattrs, + fn permissions_plus(&self, file: &File<'_>, xattrs: bool) -> Option { + match file.permissions() { + Some(p) => Some(f::PermissionsPlus { + file_type: file.type_char(), + #[cfg(unix)] + permissions: p, + #[cfg(windows)] + attributes: file.attributes(), + xattrs + }), + None => None, } } - fn octal_permissions(&self, file: &File<'_>) -> f::OctalPermissions { - f::OctalPermissions { - permissions: file.permissions(), + #[cfg(unix)] + fn octal_permissions(&self, file: &File<'_>) -> Option { + match file.permissions() { + Some(p) => Some(f::OctalPermissions { + permissions: p, + }), + None => None, } } @@ -407,24 +511,40 @@ impl<'a, 'f> Table<'a> { Column::FileSize => { file.size().render(self.theme, self.size_format, &self.env.numeric) } + #[cfg(unix)] Column::HardLinks => { file.links().render(self.theme, &self.env.numeric) } + #[cfg(unix)] Column::Inode => { file.inode().render(self.theme.ui.inode) } + #[cfg(unix)] Column::Blocks => { file.blocks().render(self.theme) } + #[cfg(unix)] Column::User => { file.user().render(self.theme, &*self.env.lock_users(), self.user_format) } + #[cfg(unix)] Column::Group => { file.group().render(self.theme, &*self.env.lock_users(), self.user_format) } + #[cfg(unix)] + Column::SecurityContext => { + file.security_context().render(self.theme) + } Column::GitStatus => { self.git_status(file).render(self.theme) } + Column::SubdirGitRepoStatus => { + self.subdir_git_repo(file, true).render() + } + Column::SubdirGitRepoNoStatus => { + self.subdir_git_repo(file, false).render() + } + #[cfg(unix)] Column::Octal => { self.octal_permissions(file).render(self.theme.ui.octal) } @@ -452,6 +572,15 @@ impl<'a, 'f> Table<'a> { .unwrap_or_default() } + fn subdir_git_repo(&self, file: &File<'_>, status : bool) -> f::SubdirGitRepo { + debug!("Getting subdir repo status for path {:?}", file.path); + + if file.is_directory(){ + return f::SubdirGitRepo::from_path(&file.path, status); + } + f::SubdirGitRepo::default() + } + pub fn render(&self, row: Row) -> TextCell { let mut cell = TextCell::default(); diff --git a/src/output/time.rs b/src/output/time.rs index cb18c54b..4f569084 100644 --- a/src/output/time.rs +++ b/src/output/time.rs @@ -1,8 +1,10 @@ //! Timestamp formatting. -use std::time::{SystemTime, UNIX_EPOCH}; +use std::convert::TryInto; +use std::cmp::max; +use std::time::{SystemTime, UNIX_EPOCH, Duration}; -use datetime::{LocalDateTime, TimeZone, DatePiece, TimePiece}; +use datetime::{LocalDateTime, TimeZone, DatePiece, TimePiece, Instant}; use datetime::fmt::DateFormat; use lazy_static::lazy_static; @@ -25,7 +27,7 @@ use unicode_width::UnicodeWidthStr; /// /// Currently exa does not support *custom* styles, where the user enters a /// format string in an environment variable or something. Just these four. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum TimeFormat { /// The **default format** uses the user’s locale to print month names, @@ -46,6 +48,9 @@ pub enum TimeFormat { /// millisecond and includes its offset down to the minute. This too uses /// only numbers so doesn’t require any special consideration. FullISO, + + /// Use a relative but fixed width representation. + Relative, } // There are two different formatting functions because local and zoned @@ -58,6 +63,7 @@ impl TimeFormat { Self::ISOFormat => iso_local(time), Self::LongISO => long_local(time), Self::FullISO => full_local(time), + Self::Relative => relative(time), } } @@ -67,6 +73,7 @@ impl TimeFormat { Self::ISOFormat => iso_zoned(time, zone), Self::LongISO => long_zoned(time, zone), Self::FullISO => full_zoned(time, zone), + Self::Relative => relative(time), } } } @@ -87,7 +94,7 @@ fn default_zoned(time: SystemTime, zone: &TimeZone) -> String { } fn get_dateformat(date: &LocalDateTime) -> &'static DateFormat<'static> { - match (is_recent(&date), *MAXIMUM_MONTH_WIDTH) { + match (is_recent(date), *MAXIMUM_MONTH_WIDTH) { (true, 4) => &FOUR_WIDE_DATE_TIME, (true, 5) => &FIVE_WIDE_DATE_TIME, (true, _) => &OTHER_WIDE_DATE_TIME, @@ -113,6 +120,20 @@ fn long_zoned(time: SystemTime, zone: &TimeZone) -> String { date.hour(), date.minute()) } +#[allow(trivial_numeric_casts)] +fn relative(time: SystemTime) -> String { + timeago::Formatter::new() + .ago("") + .convert( + Duration::from_secs( + max(0, Instant::now().seconds() - systemtime_epoch(time)) + // this .unwrap is safe since the call above can never result in a + // value < 0 + .try_into().unwrap() + ) + ) +} + #[allow(trivial_numeric_casts)] fn full_local(time: SystemTime) -> String { let date = LocalDateTime::at(systemtime_epoch(time)); diff --git a/src/output/tree.rs b/src/output/tree.rs index 209b82c9..c3bff438 100644 --- a/src/output/tree.rs +++ b/src/output/tree.rs @@ -39,7 +39,7 @@ //! each directory) -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum TreePart { /// Rightmost column, *not* the last in the directory. @@ -253,19 +253,19 @@ mod iter_test { #[test] fn test_iteration() { let foos = &[ "first", "middle", "last" ]; - let mut iter = TreeDepth::root().iterate_over(foos.into_iter()); + let mut iter = TreeDepth::root().iterate_over(foos.iter()); let next = iter.next().unwrap(); assert_eq!(&"first", next.1); - assert_eq!(false, next.0.last); + assert!(!next.0.last); let next = iter.next().unwrap(); assert_eq!(&"middle", next.1); - assert_eq!(false, next.0.last); + assert!(!next.0.last); let next = iter.next().unwrap(); assert_eq!(&"last", next.1); - assert_eq!(true, next.0.last); + assert!(next.0.last); assert!(iter.next().is_none()); } @@ -273,7 +273,7 @@ mod iter_test { #[test] fn test_empty() { let nothing: &[usize] = &[]; - let mut iter = TreeDepth::root().iterate_over(nothing.into_iter()); + let mut iter = TreeDepth::root().iterate_over(nothing.iter()); assert!(iter.next().is_none()); } } diff --git a/src/theme/default_theme.rs b/src/theme/default_theme.rs index b4269b73..b0649e3f 100644 --- a/src/theme/default_theme.rs +++ b/src/theme/default_theme.rs @@ -66,7 +66,18 @@ impl UiStyles { conflicted: Red.normal(), }, - punctuation: Fixed(244).normal(), + security_context: SecurityContext { + none: Style::default(), + selinux: SELinuxContext { + colon: Style::default().dimmed(), + user: Blue.normal(), + role: Green.normal(), + typ: Yellow.normal(), + range: Cyan.normal(), + }, + }, + + punctuation: Black.bold(), date: Blue.normal(), inode: Purple.normal(), blocks: Cyan.normal(), @@ -114,17 +125,17 @@ impl Size { major: Green.bold(), minor: Green.normal(), - number_byte: Fixed(118).normal(), - number_kilo: Fixed(190).normal(), - number_mega: Fixed(226).normal(), - number_giga: Fixed(220).normal(), - number_huge: Fixed(214).normal(), + number_byte: Green.normal(), + number_kilo: Green.bold(), + number_mega: Yellow.normal(), + number_giga: Red.normal(), + number_huge: Purple.normal(), unit_byte: Green.normal(), - unit_kilo: Green.normal(), - unit_mega: Green.normal(), - unit_giga: Green.normal(), - unit_huge: Green.normal(), + unit_kilo: Green.bold(), + unit_mega: Yellow.normal(), + unit_giga: Red.normal(), + unit_huge: Purple.normal(), } } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 255f054d..d8fbff4b 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -14,7 +14,7 @@ pub use self::lsc::LSColors; mod default_theme; -#[derive(PartialEq, Debug)] +#[derive(PartialEq, Eq, Debug)] pub struct Options { pub use_colours: UseColours, @@ -31,7 +31,7 @@ pub struct Options { /// Turning them on when output is going to, say, a pipe, would make programs /// such as `grep` or `more` not work properly. So the `Automatic` mode does /// this check and only displays colours when they can be truly appreciated. -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum UseColours { /// Display them even when output isn’t going to a terminal. @@ -44,13 +44,13 @@ pub enum UseColours { Never, } -#[derive(PartialEq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub enum ColourScale { Fixed, Gradient, } -#[derive(PartialEq, Debug, Default)] +#[derive(PartialEq, Eq, Debug, Default)] pub struct Definitions { pub ls: Option, pub exa: Option, @@ -229,9 +229,11 @@ impl render::GitColours for Theme { fn conflicted(&self) -> Style { self.ui.git.conflicted } } +#[cfg(unix)] impl render::GroupColours for Theme { fn yours(&self) -> Style { self.ui.users.group_yours } fn not_yours(&self) -> Style { self.ui.users.group_not_yours } + fn no_group(&self) -> Style { self.ui.punctuation } } impl render::LinksColours for Theme { @@ -261,11 +263,11 @@ impl render::SizeColours for Theme { use number_prefix::Prefix::*; match prefix { - None => self.ui.size.number_byte, - Some(Kilo) | Some(Kibi) => self.ui.size.number_kilo, - Some(Mega) | Some(Mebi) => self.ui.size.number_mega, - Some(Giga) | Some(Gibi) => self.ui.size.number_giga, - Some(_) => self.ui.size.number_huge, + Some(Kilo | Kibi) => self.ui.size.number_kilo, + Some(Mega | Mebi) => self.ui.size.number_mega, + Some(Giga | Gibi) => self.ui.size.number_giga, + Some(_) => self.ui.size.number_huge, + None => self.ui.size.number_byte, } } @@ -273,11 +275,11 @@ impl render::SizeColours for Theme { use number_prefix::Prefix::*; match prefix { - None => self.ui.size.unit_byte, - Some(Kilo) | Some(Kibi) => self.ui.size.unit_kilo, - Some(Mega) | Some(Mebi) => self.ui.size.unit_mega, - Some(Giga) | Some(Gibi) => self.ui.size.unit_giga, - Some(_) => self.ui.size.unit_huge, + Some(Kilo | Kibi) => self.ui.size.unit_kilo, + Some(Mega | Mebi) => self.ui.size.unit_mega, + Some(Giga | Gibi) => self.ui.size.unit_giga, + Some(_) => self.ui.size.unit_huge, + None => self.ui.size.unit_byte, } } @@ -287,9 +289,11 @@ impl render::SizeColours for Theme { fn minor(&self) -> Style { self.ui.size.minor } } +#[cfg(unix)] impl render::UserColours for Theme { fn you(&self) -> Style { self.ui.users.user_you } fn someone_else(&self) -> Style { self.ui.users.user_someone_else } + fn no_user(&self) -> Style { self.ui.punctuation } } impl FileNameColours for Theme { @@ -306,6 +310,15 @@ impl FileNameColours for Theme { } } +impl render::SecurityCtxColours for Theme { + fn none(&self) -> Style { self.ui.security_context.none } + fn selinux_colon(&self) -> Style { self.ui.security_context.selinux.colon } + fn selinux_user(&self) -> Style { self.ui.security_context.selinux.user } + fn selinux_role(&self) -> Style { self.ui.security_context.selinux.role } + fn selinux_type(&self) -> Style { self.ui.security_context.selinux.typ } + fn selinux_range(&self) -> Style { self.ui.security_context.selinux.range } +} + /// Some of the styles are **overlays**: although they have the same attribute /// set as regular styles (foreground and background colours, bold, underline, diff --git a/src/theme/ui_styles.rs b/src/theme/ui_styles.rs index f92c5442..65ed3cf9 100644 --- a/src/theme/ui_styles.rs +++ b/src/theme/ui_styles.rs @@ -7,12 +7,13 @@ use crate::theme::lsc::Pair; pub struct UiStyles { pub colourful: bool, - pub filekinds: FileKinds, - pub perms: Permissions, - pub size: Size, - pub users: Users, - pub links: Links, - pub git: Git, + pub filekinds: FileKinds, + pub perms: Permissions, + pub size: Size, + pub users: Users, + pub links: Links, + pub git: Git, + pub security_context: SecurityContext, pub punctuation: Style, pub date: Style, @@ -104,6 +105,21 @@ pub struct Git { pub conflicted: Style, } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SELinuxContext { + pub colon: Style, + pub user: Style, + pub role: Style, + pub typ: Style, + pub range: Style, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SecurityContext { + pub none: Style, + pub selinux: SELinuxContext, +} + impl UiStyles { pub fn plain() -> Self { Self::default() diff --git a/treefmt.nix b/treefmt.nix new file mode 100644 index 00000000..9ea5c043 --- /dev/null +++ b/treefmt.nix @@ -0,0 +1,7 @@ +{ + projectRootFile = "Cargo.toml"; + programs = { + alejandra.enable = true; + rustfmt.enable = true; + }; +} diff --git a/xtests/details-view-dates.toml b/xtests/details-view-dates.toml index 30cefebf..db1b6b88 100644 --- a/xtests/details-view-dates.toml +++ b/xtests/details-view-dates.toml @@ -35,6 +35,13 @@ stderr = { empty = true } status = 0 tags = [ 'long', 'time' ] +[[cmd]] +name = "‘exa -l --time-style=relative’ handles dates far past and future dates" +shell = "exa -l --time-style=relative /testcases/far-dates" +stdout = { file = "outputs/far_dates_relative.ansitxt" } +stderr = { empty = true } +status = 0 +tags = [ 'long', 'time' ] # alternate date formats @@ -46,6 +53,14 @@ stderr = { empty = true } status = 0 tags = [ 'long', 'time-style' ] +[[cmd]] +name = "‘exa -l --time-style=relative’ produces a table using the relative date format" +shell = "exa -l --time-style=relative /testcases/dates" +stdout = { file = "outputs/dates_long_timestyle_relative.ansitxt" } +stderr = { empty = true } +status = 0 +tags = [ 'long', 'time-style' ] + [[cmd]] name = "‘exa -l --time-style=full-iso’ produces a table using the full-iso date format" shell = "exa -l --time-style=full-iso /testcases/dates" diff --git a/xtests/input-options.toml b/xtests/input-options.toml index 6961a4e8..89e557e1 100644 --- a/xtests/input-options.toml +++ b/xtests/input-options.toml @@ -42,7 +42,7 @@ tags = [ 'options' ] name = "exa displays an error for option that takes the wrong parameter" shell = "exa -l --time-style=24" stdout = { empty = true } -stderr = { string = "Option --time-style has no \"24\" setting (choices: default, long-iso, full-iso, iso)" } +stderr = { string = "Option --time-style has no \"24\" setting (choices: default, long-iso, full-iso, iso, relative)" } status = 3 tags = [ 'options' ] diff --git a/xtests/outputs/dates_long_timestyle_relative.ansitxt b/xtests/outputs/dates_long_timestyle_relative.ansitxt new file mode 100644 index 00000000..d4f972d1 --- /dev/null +++ b/xtests/outputs/dates_long_timestyle_relative.ansitxt @@ -0,0 +1,3 @@ +.rw-rw-r-- 0 cassowary 15 years peach +.rw-rw-r-- 0 cassowary 19 years pear +.rw-rw-r-- 0 cassowary 12 years plum diff --git a/xtests/outputs/far_dates_relative.ansitxt b/xtests/outputs/far_dates_relative.ansitxt new file mode 100644 index 00000000..7bea5c5e --- /dev/null +++ b/xtests/outputs/far_dates_relative.ansitxt @@ -0,0 +1,2 @@ +.rw-rw-r-- 0 vagrant now beyond-the-future +.rw-rw-r-- 0 vagrant now the-distant-past diff --git a/xtests/outputs/help.ansitxt b/xtests/outputs/help.ansitxt index 4e8cf520..ddeec89f 100644 --- a/xtests/outputs/help.ansitxt +++ b/xtests/outputs/help.ansitxt @@ -33,24 +33,24 @@ FILTERING AND SORTING OPTIONS date, time, old, and new all refer to modified. LONG VIEW OPTIONS - -b, --binary list file sizes with binary prefixes - -B, --bytes list file sizes in bytes, without any prefixes - -g, --group list each file's group - -h, --header add a header row to each column - -H, --links list each file's number of hard links - -i, --inode list each file's inode number - -m, --modified use the modified timestamp field - -n, --numeric list numeric user and group IDs - -S, --blocks show number of file system blocks - -t, --time FIELD which timestamp field to list (modified, accessed, created) - -u, --accessed use the accessed timestamp field - -U, --created use the created timestamp field - --changed use the changed timestamp field - --time-style how to format timestamps (default, iso, long-iso, full-iso) - --no-permissions suppress the permissions field - --octal-permissions list each file's permission in octal format - --no-filesize suppress the filesize field - --no-user suppress the user field - --no-time suppress the time field - --git list each file's Git status, if tracked or ignored - -@, --extended list each file's extended attributes and sizes + -b, --binary list file sizes with binary prefixes + -B, --bytes list file sizes in bytes, without any prefixes + -g, --group list each file's group + -h, --header add a header row to each column + -H, --links list each file's number of hard links + -i, --inode list each file's inode number + -m, --modified use the modified timestamp field + -n, --numeric list numeric user and group IDs + -S, --blocks show number of file system blocks + -t, --time FIELD which timestamp field to list (modified, accessed, created) + -u, --accessed use the accessed timestamp field + -U, --created use the created timestamp field + --changed use the changed timestamp field + --time-style how to format timestamps (default, iso, long-iso, full-iso, relative) + --no-permissions suppress the permissions field + -o, --octal-permissions list each file's permission in octal format + --no-filesize suppress the filesize field + --no-user suppress the user field + --no-time suppress the time field + --git list each file's Git status, if tracked or ignored + -@, --extended list each file's extended attributes and sizes diff --git a/xtests/run.sh b/xtests/run.sh index bbae83bc..33957063 100755 --- a/xtests/run.sh +++ b/xtests/run.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash trap 'exit' ERR # Check for release mode