├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .gogo-release ├── LICENSE ├── README.md ├── color.go ├── color_test.go ├── completion.zsh ├── errors.go ├── go.mod ├── go.sum ├── helper_test.go ├── main.go ├── main_test.go ├── os2 ├── door_other.go ├── door_solaris.go ├── fifo_freebsd.go ├── fifo_other.go ├── fifo_unix.go ├── hidden_other.go ├── hidden_windows.go ├── os2.go ├── statfs_netbsdsol.go ├── statfs_openbsd.go ├── statfs_unix.go ├── time_bsd.go ├── time_dragonfly.go ├── time_linux.go ├── time_openbsd.go ├── time_solaris.go ├── time_windows.go ├── utime_other.go ├── utime_unix.go ├── utime_unix2.go ├── vfs_other.go ├── vfs_unix.go └── vfs_windows.go ├── print.go ├── ss ├── elles.png ├── elles_-l.png ├── elles_-lC.png ├── elles_-lT.png ├── elles_-ll.png ├── elles_-llC.png ├── elles_-m_4_.cache.png ├── elles_-w_30_.cache.png └── elles_.cache.png ├── stolen_test.go ├── usage.go └── zli2 └── usagep.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: arp242 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'test' 2 | on: 3 | push: 4 | pull_request: 5 | paths: ['**.go', 'go.mod', '.github/workflows/*'] 6 | 7 | jobs: 8 | linux: 9 | name: 'test (linux)' 10 | runs-on: 'ubuntu-latest' 11 | steps: 12 | - uses: 'actions/checkout@v4' 13 | - uses: 'WillAbides/setup-go-faster@v1' 14 | with: {go-version: '1.24'} 15 | - name: 'test (linux)' 16 | run: 'go test ./...' 17 | 18 | linux-arm64: 19 | name: 'test (linux-arm64)' 20 | runs-on: 'ubuntu-24.04-arm' 21 | steps: 22 | - uses: 'actions/checkout@v4' 23 | - uses: 'WillAbides/setup-go-faster@v1' 24 | with: {go-version: '1.24'} 25 | - name: 'test (linux-arm64)' 26 | run: 'go test ./...' 27 | 28 | windows: 29 | name: 'test (windows)' 30 | runs-on: 'windows-latest' 31 | steps: 32 | - uses: 'actions/checkout@v4' 33 | - uses: 'WillAbides/setup-go-faster@v1' 34 | with: {go-version: '1.24'} 35 | - name: 'test (windows)' 36 | run: 'go test ./...' 37 | 38 | macos: 39 | name: 'test (macos)' 40 | runs-on: 'macos-latest' 41 | steps: 42 | - uses: 'actions/checkout@v4' 43 | - uses: 'WillAbides/setup-go-faster@v1' 44 | with: {go-version: '1.24'} 45 | - name: 'test (macos)' 46 | run: 'go test ./...' 47 | 48 | freebsd: 49 | name: 'test (freebsd)' 50 | runs-on: 'ubuntu-latest' 51 | steps: 52 | - uses: 'actions/checkout@v4' 53 | - name: 'test (freebsd)' 54 | id: 'freebsd' 55 | uses: 'vmactions/freebsd-vm@v1' 56 | with: 57 | prepare: | 58 | pkg install -y go122 59 | pw user add -n action -m 60 | run: | 61 | echo 'XXXXXX' 62 | su action -c 'go122 version' 63 | su action -c 'go122 test ./...' 64 | 65 | openbsd: 66 | name: 'test (openbsd)' 67 | runs-on: 'ubuntu-latest' 68 | steps: 69 | - uses: 'actions/checkout@v4' 70 | - name: 'test (openbsd)' 71 | id: 'openbsd' 72 | uses: 'vmactions/openbsd-vm@v1' 73 | with: 74 | prepare: | 75 | useradd -mG wheel action 76 | pkg_add go 77 | run: | 78 | echo 'XXXXXX' 79 | su action -c 'go version' 80 | su action -c 'go test ./...' 81 | 82 | netbsd: 83 | name: 'test (netbsd)' 84 | runs-on: 'ubuntu-latest' 85 | steps: 86 | - uses: 'actions/checkout@v4' 87 | - name: 'test (netbsd)' 88 | id: 'netbsd' 89 | uses: 'vmactions/netbsd-vm@v1' 90 | with: 91 | prepare: | 92 | useradd -mG wheel action 93 | pkg_add go122 94 | run: | 95 | echo 'XXXXXX' 96 | su action -c '/usr/pkg/bin/go122 version' 97 | su action -c '/usr/pkg/bin/go122 test ./...' 98 | 99 | illumos: 100 | name: 'test (illumos)' 101 | runs-on: 'ubuntu-latest' 102 | steps: 103 | - uses: 'actions/checkout@v4' 104 | - name: 'test (illumos)' 105 | id: 'illumos' 106 | uses: 'vmactions/omnios-vm@v1' 107 | with: 108 | prepare: | 109 | useradd action 110 | pkg install go-122 111 | run: | 112 | echo 'XXXXXX' 113 | export GOCACHE=/tmp/go-cache 114 | export GOPATH=/tmp/go-path 115 | su action -c 'go version' 116 | su action -c 'go test ./...' 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /elles 3 | *.test 4 | *.exe 5 | -------------------------------------------------------------------------------- /.gogo-release: -------------------------------------------------------------------------------- 1 | build_flags="-trimpath -ldflags '-w -s -X \"zgo.at/zli.version=$tag\"'" 2 | 3 | post_build="gzip -f" 4 | 5 | matrix=" 6 | darwin amd64 7 | darwin arm64 8 | freebsd amd64 9 | illumos amd64 10 | linux amd64 11 | linux arm64 12 | netbsd amd64 13 | openbsd amd64 14 | windows amd64 15 | " 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © Martin Tournoij 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | The software is provided "as is", without warranty of any kind, express or 16 | implied, including but not limited to the warranties of merchantability, fitness 17 | for a particular purpose and noninfringement. In no event shall the authors or 18 | copyright holders be liable for any claim, damages or other liability, whether 19 | in an action of contract, tort or otherwise, arising from, out of or in 20 | connection with the software or the use or other dealings in the software. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is mostly "just" `ls`, but a bit better. Nothing too fancy. 2 | 3 | I wanted some flexibility in which columns to display and a few other minor 4 | things; `ls` doesn't give you many options here. It started as a simple shell 5 | script, and then became a simple Go program, and then things got rather of out 6 | of hand. 7 | 8 | Flags are sort-of compatible with `ls`, except when they're not. Full 9 | compatibility with POSIX or any other `ls` isn't the main goal. 10 | 11 | That said, most people should be able to use `alias ls=elles` and not get too 12 | surprised; defaults and the most commonly used flags are identical. 13 | 14 | It differs from [eza] or [lsd] in that it has a slightly different feature set, 15 | and makes some different choices about various aspects. 16 | 17 | [eza]: https://github.com/eza-community/eza 18 | [lsd]: https://github.com/lsd-rs/lsd 19 | 20 | Installation 21 | ------------ 22 | There are binaries on the [releases] page; or to compile from source: 23 | 24 | go install -tags=osusergo zgo.at/elles@latest 25 | 26 | Drop the `-tags=osusergo` to use libc for user lookups; only needed if you have 27 | a complex setup with NIS or LDAP or whatnot. This will require a C compiler. 28 | 29 | It should work well on Linux, {Free,Net,Open}BSD, macOS, and illumos. The 30 | Windows version is somewhat experimental. 31 | 32 | [releases]: https://github.com/arp242/elles/releases 33 | 34 | Usage 35 | ----- 36 | The default is to display something rather similar to `ls`: 37 | 38 | ![`elles /`](ss/elles.png) 39 | 40 | To get a more detailed listing, use the familiar `-l`: 41 | 42 | ![`elles -l /`](ss/elles_-l.png) 43 | 44 | This displays a human-readable size, and the modification time as follows: 45 | 46 | - just the time for today, 47 | - "yst" and the time for yesterday, 48 | - "dby" and the time for the day before yesterday, and 49 | - everything else as the date only. 50 | 51 | Add `-T` for a more complete date display: 52 | 53 | ![`elles -lT /`](ss/elles_-lT.png) 54 | 55 | `-l` will print one entry per line by default, but you can combine that with 56 | `-C`: 57 | 58 | ![`elles -lC /`](ss/elles_-lC.png) 59 | 60 | This is the main reason I started working on this. `ls -l` is often too much 61 | info. How often do I want to see the permission bits and number of links? Not 62 | that often. How often does all of that chutney push the actual filenames off the 63 | screen? Quite frequently. 64 | 65 | For a while I used some scripts to modify the `ls` output, which works well 66 | enough for the common case, but also not really for rather a lot of uncommon 67 | cases. Many of these uncommon cases are quite common. 68 | 69 | Anyway, use `-l` twice for more details: 70 | 71 | ![`elles -ll /`](ss/elles_-ll.png) 72 | 73 | This is more similar to the standard `ls -l` output, with some small 74 | differences. `-C` also works for this: 75 | 76 | ![`elles -llC /`](ss/elles_-llC.png) 77 | 78 | Sometimes things are annoying to display because of long filenames; for example 79 | listing my `~/.cache` doesn't even fit in a single window: 80 | 81 | ![`elles ~/.cache`](ss/elles_.cache.png) 82 | 83 | This one 79-character `event-sound-cache..` file forces single-column display. 84 | The `-m` option sets a minimum column width, trimming text that's too long: 85 | 86 | ![`elles -m4 ~/.cache`](ss/elles_-m_4_.cache.png) 87 | 88 | Or alternatively, set an explicit maximum width with `-w`: 89 | 90 | ![`elles -w30 ~/.cache`](ss/elles_-w_30_.cache.png) 91 | 92 | There's a bunch of other useful flags. See `elles -help` for, well, help. 93 | 94 | Differences from POSIX 95 | ---------------------- 96 | There are some intentional differences from POSIX 2017. This started as a small 97 | list of a few items, but has rather grown. 98 | 99 | - `-c` uses create ("birth") time, rather than ctime (which is usually identical 100 | to mtime, and is rarely useful for display or sorting). 101 | 102 | - `-u` and `-c` behave simpler: show update/create time when given, and sort 103 | with that when `-t` is also given. This is so much simpler than the whole 104 | "sort by last access time, unless `-l` is given, in which case it will be used 105 | for display and NOT sorting, unless `-t` is also given, in which case it will 106 | be used for display and sorting". 107 | 108 | - `-l` output is much shorter; `-l -l` (or `-ll`) is more similar to POSIX `-l`, 109 | but without the number of links (doesn't seem useful to me). 110 | 111 | - The `-l` or `-ll` output won't print a `total: …` line for directories. I 112 | don't think I've ever used it. Use `du` for this. 113 | 114 | - `-g` and `-o` for `-l` without group or owner are not implemented, as it's 115 | somewhat pointless since `-l` doesn't print either by default. 116 | 117 | - `-a` works like `-A`; I don't see why you ever want to include `.` and `..`; 118 | seems like backwards compatibility with 1971 Unix. 119 | 120 | - `-m` for CSV-y "Stream output format" is not implemented. Doesn't seem too 121 | useful and also error-prone (doesn't escape `,`). Use shell globs or `-json`. 122 | 123 | - `-x` (sort across) is not implemented. Never used it. Send patch if you want 124 | it. 125 | 126 | - `-k` to set the blocksize to 1024 is not implemented as POSIX blocksize 127 | semantics are stupid. 128 | 129 | - `-s` uses the filesystem's block size rather than 512 or 1024 bytes. POSIX 130 | blocksize semantics are stupid. 131 | 132 | - It will print with "human-readable" file sizes by default (`-h` on most `ls` 133 | implementations, but not in POSIX). Use `-B`/`-block` to set a different 134 | blocksize. 135 | 136 | - `-q` is not implemented; I don't see when this would ever be useful. 137 | 138 | - When sorting by time (`-t`), files with the same time are sorted ascending, 139 | like everything else, rather than descending. This inconsistency in sorting is 140 | a weird POSIX quirk that exists for $reasons. 141 | 142 | TODO 143 | ---- 144 | - Sorting is a simple byte sort (as LC_COLLATE=C); the golang.org/x/text/collate 145 | package is based on Unicode 6.2, from 2012, and generally seems fairly 146 | unmaintained, with a number of reported bugs. I'll have to find or write an 147 | alternative I guess... 148 | 149 | Also want it to be configurable; I would like en.UTF-8 sorts *AND* sorting 150 | capitals before lower case like with C, so this can be used to "pin" paths on 151 | top. I was never able to get that to work with ls (so I just use LC_COLLATE=C) 152 | and x/text/collate also doesn't support it. 153 | 154 | - There is no way to display file flags, ACLs, MAC labels, whiteouts, 155 | capabilities, or anything like that. 156 | 157 | - Look into displaying sparse files better. A 8G sparse file will show up as 8G, 158 | even though it has 0 allocated blocks. You can use `-s`, but should be obvious 159 | from the standard output. 160 | 161 | - There isn't really any way to customize the time format other than `-T` and 162 | `-TT`. Just a fixed `+[..]` like most other tools doesn't seem quite right, 163 | because I rather like the "display only time for today, something else for 164 | other days" type logic. Also might want to do relative times ("5 hours ago") 165 | as: "relative for this week, full date for older". 166 | 167 | - Can't configure which borders to display, or column width (FreeBSD ls has 168 | LS_COLWIDTHS for that). 169 | 170 | - I didn't implement any filtering; not sure yet what the best approach for 171 | this. Realistically, I almost never want to see `*.o` files in my listing. eza 172 | has `--git-ignore`, which seems to have a huge potential for confusion: it's 173 | pretty common to have compiled binaries in there too, or cache directories, or 174 | other things you really want in your listing. Overall, seems more of a footgun 175 | than helpful. 176 | 177 | GNU and eza both have -I/--ignore, but don't really want to construct custom 178 | paths either. GNU has -B/--ignore-backups to ignore files ending with `~`. 179 | Maybe a "ignore common files you almost never want" option might be the best, 180 | and a (non-intrusive) hint that we're ignoring *some* files. 181 | 182 | - Display "git status". I don't really want to tie elles to git; maybe something 183 | like: 184 | 185 | % elles -ext='git status --porcelain' 186 | 187 | That will call the `-ext` tool for every argv, in this case just the current 188 | directory. That will output: 189 | 190 | M README.md 191 | M main.go 192 | M main_test.go 193 | M print.go 194 | M usage.go 195 | ?? new.go 196 | 197 | And then take everything before the first space as status, and everything 198 | after that as the pathname (which we can use to match it up). 199 | 200 | Or something along those lines. This can then be used with other VCS tools, or 201 | other clever stuff. 202 | -------------------------------------------------------------------------------- /color.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "strings" 7 | 8 | "zgo.at/zli" 9 | ) 10 | 11 | var ( 12 | colorNormal, colorFile, colorDir, colorLink, colorPipe, colorSocket string 13 | colorBlockDev, colorCharDev, colorOrphan, colorExec string 14 | colorDoor, colorSuid, colorSgid, colorSticky, colorOtherWrite, colorOtherWriteStick string 15 | colorHidden string 16 | reset string 17 | colorExt map[string]string 18 | ) 19 | 20 | func setColor() { 21 | if !zli.WantColor { 22 | return 23 | } 24 | reset = zli.Reset.String() 25 | 26 | ellesColors := os.Getenv("ELLES_COLORS") 27 | if ellesColors == "" { 28 | ellesColors = os.Getenv("ELLES_COLOURS") 29 | } 30 | 31 | style := "gnu" 32 | switch runtime.GOOS { 33 | case "freebsd", "openbsd", "netbsd", "dragonfly", "darwin": 34 | style = "bsd" 35 | } 36 | switch { 37 | case strings.Contains(ellesColors, "default=bsd"): 38 | style = "bsd" 39 | case strings.Contains(ellesColors, "default=gnu"): 40 | style = "bsd" 41 | } 42 | switch style { 43 | case "bsd": 44 | colorDir, colorLink = zli.Blue.String(), zli.Magenta.String() 45 | colorSocket, colorPipe = zli.Green.String(), zli.Yellow.String() 46 | colorExec, colorBlockDev = zli.Red.String(), (zli.Blue | zli.Cyan.Bg()).String() 47 | colorCharDev = (zli.Blue | zli.Yellow.Bg()).String() 48 | colorSuid = (zli.Black | zli.Red.Bg()).String() 49 | colorSgid = (zli.Black | zli.Cyan.Bg()).String() 50 | colorOtherWriteStick = (zli.Black | zli.Green.Bg()).String() 51 | colorOtherWrite = (zli.Black | zli.Blue.Bg()).String() 52 | case "gnu": 53 | colorDir, colorLink, colorPipe = "\x1b[01;34m", "\x1b[01;36m", "\x1b[33m" 54 | colorSocket, colorBlockDev, colorCharDev = "\x1b[01;35m", "\x1b[01;33m", "\x1b[01;33m" 55 | colorExec, colorDoor, colorSuid = "\x1b[01;32m", "\x1b[01;35m", "\x1b[37;41m" 56 | colorSgid, colorSticky, colorOtherWrite = "\x1b[30;43m", "\x1b[37;44m", "\x1b[34;42m" 57 | colorOtherWriteStick = "\x1b[30;42m" 58 | } 59 | 60 | if ellesColors != "" { 61 | readGNUColors(ellesColors, true) 62 | } 63 | if !readBSDColors() { 64 | c := os.Getenv("LS_COLORS") 65 | if c == "" { 66 | c = os.Getenv("LS_COLOURS") 67 | } 68 | readGNUColors(c, false) 69 | } 70 | } 71 | 72 | // Positional «fg»«bg» pairs, 11 in total (in order): directory, symlink, 73 | // socket, pipe, blockdev, chardev, executable with setuid, executable with 74 | // setgid, world-writable dir with sticky, world-writable dir without sticky 75 | // 76 | // Values: 77 | // 78 | // a-h standard 16 colours 79 | // A-H bold/underline versions 80 | // x default colour 81 | // X default colour with bold/underline 82 | func readBSDColors() bool { 83 | c := os.Getenv("LSCOLORS") 84 | if c == "" { 85 | c = os.Getenv("LSCOLOURS") 86 | if c == "" { 87 | return false 88 | } 89 | } 90 | for i := range len(c) / 2 { 91 | var set *string 92 | switch i { 93 | case 0: 94 | set = &colorDir 95 | case 1: 96 | set = &colorLink 97 | case 2: 98 | set = &colorSocket 99 | case 3: 100 | set = &colorPipe 101 | case 4: 102 | set = &colorExec 103 | case 5: 104 | set = &colorBlockDev 105 | case 6: 106 | set = &colorCharDev 107 | case 7: 108 | set = &colorSuid 109 | case 8: 110 | set = &colorSgid 111 | case 9: 112 | set = &colorOtherWriteStick 113 | case 10: 114 | set = &colorOtherWrite 115 | default: 116 | // TODO: warn? 117 | } 118 | if col := (bsdcolor(c[i*2], false) | bsdcolor(c[i*2+1], true).Bg()); col == 0 { 119 | *set = "" 120 | } else { 121 | *set = col.String() 122 | } 123 | } 124 | return true 125 | } 126 | 127 | func bsdcolor(c byte, bold bool) zli.Color { 128 | if c >= 'a' && c <= 'h' { 129 | return zli.Black + zli.Color(c) - 0x61 130 | } 131 | if c >= 'A' && c <= 'H' { 132 | x := zli.Black + zli.Color(c) - 0x41 133 | if bold { 134 | x |= zli.Bold 135 | } else { 136 | x |= zli.Underline 137 | } 138 | return x 139 | } 140 | if c == 'X' { 141 | if bold { 142 | return zli.Bold 143 | } 144 | return zli.Underline 145 | } 146 | if c != 'x' { 147 | zli.Errorf("unknown color code in LSCOLORS: %c", c) 148 | } 149 | return 0 150 | } 151 | 152 | // key/value pair as «name»=«colour code», where colour code is the terminal 153 | // code we send without processing. 154 | func readGNUColors(c string, extended bool) bool { 155 | varname := "LS_COLORS" 156 | if extended { 157 | varname = "ELLES_COLORS" 158 | } 159 | 160 | if c == "" { 161 | return false 162 | } 163 | for _, cc := range strings.Split(c, ":") { 164 | if cc == "" { 165 | continue 166 | } 167 | k, v, ok := strings.Cut(cc, "=") 168 | if !ok { 169 | if extended && (k == "bsd" || k == "gnu") { 170 | continue 171 | } 172 | zli.Errorf("malformed %s: %q", varname, cc) 173 | continue 174 | } 175 | if k[0] == '*' { 176 | if colorExt == nil { 177 | colorExt = make(map[string]string) 178 | } 179 | colorExt[k[1:]] = "\x1b[" + v + "m" 180 | continue 181 | } 182 | switch k { 183 | case "no": 184 | colorNormal = "\x1b[" + v + "m" 185 | case "fi": 186 | colorFile = "\x1b[" + v + "m" 187 | case "di": 188 | colorDir = "\x1b[" + v + "m" 189 | case "ln": 190 | colorLink = "\x1b[" + v + "m" 191 | case "pi": 192 | colorPipe = "\x1b[" + v + "m" 193 | case "so": 194 | colorSocket = "\x1b[" + v + "m" 195 | case "bd": 196 | colorBlockDev = "\x1b[" + v + "m" 197 | case "cd": 198 | colorCharDev = "\x1b[" + v + "m" 199 | case "or": 200 | colorOrphan = "\x1b[" + v + "m" 201 | case "ex": 202 | colorExec = "\x1b[" + v + "m" 203 | case "mi": 204 | // TODO: never applied; not entirely sure when it should get 205 | // applied, because as I read it, "mi" and "or" are both the same 206 | // thing: symlinks pointing to something that doesn't exist. 207 | //colorMissing = "\x1b[" + v + "m" 208 | case "do": 209 | colorDoor = "\x1b[" + v + "m" 210 | case "su": 211 | colorSuid = "\x1b[" + v + "m" 212 | case "sg": 213 | colorSgid = "\x1b[" + v + "m" 214 | case "st": 215 | colorSticky = "\x1b[" + v + "m" 216 | case "ow": 217 | colorOtherWrite = "\x1b[" + v + "m" 218 | case "tw": 219 | colorOtherWriteStick = "\x1b[" + v + "m" 220 | default: 221 | if !extended { 222 | zli.Errorf("unknown key in %s: %q", varname, k) 223 | } 224 | switch k { 225 | default: 226 | zli.Errorf("unknown key in %s: %q", varname, k) 227 | case "hidden": 228 | colorHidden = "\x1b[" + v + "m" 229 | } 230 | } 231 | 232 | } 233 | return true 234 | } 235 | -------------------------------------------------------------------------------- /color_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/fs" 5 | "net" 6 | "os" 7 | "os/exec" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | "zgo.at/zli" 13 | ) 14 | 15 | func clearColors() { 16 | zli.WantColor = false 17 | for _, c := range []*string{ 18 | &colorNormal, &colorFile, &colorDir, &colorLink, &colorPipe, &colorSocket, 19 | &colorBlockDev, &colorCharDev, &colorOrphan, &colorExec, &colorDoor, 20 | &colorSuid, &colorSgid, &colorSticky, &colorOtherWrite, 21 | &colorOtherWriteStick, &reset, 22 | } { 23 | *c = "" 24 | } 25 | } 26 | 27 | // Just print out stuff for manual verification; this is not likely to regress, 28 | // and this is easier for now. 29 | func TestDefaultColor(t *testing.T) { 30 | if runtime.GOOS == "windows" { 31 | t.Skip() // TODO: just because of the FIFO etc. 32 | } 33 | 34 | defer clearColors() 35 | 36 | start(t) 37 | touch(t, "file") 38 | mkdirAll(t, "dir") 39 | symlink(t, "file", "link") 40 | mkfifo(t, "fifo") 41 | touch(t, "exec") 42 | chmod(t, 0o555, "exec") 43 | l, err := net.Listen("unix", "socket") 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | defer l.Close() 48 | 49 | symlink(t, "file", "link-file") 50 | symlink(t, "dir", "link-dir") 51 | symlink(t, "exec", "link-exec") 52 | symlink(t, "fifo", "link-fifo") 53 | symlink(t, "socket", "link-socket") 54 | symlink(t, "orphan", "link-orphan") 55 | 56 | touch(t, "world-file") 57 | touch(t, "world-dir") 58 | chmod(t, 0o777, "world-file") 59 | chmod(t, 0o777, "world-dir") 60 | 61 | mkdirAll(t, "sticky-dir") 62 | mkdirAll(t, "sticky-dir-world") 63 | chmod(t, 0o755|fs.ModeSticky, "sticky-dir") 64 | chmod(t, 0o777|fs.ModeSticky, "sticky-dir-world") 65 | 66 | os.Setenv("ELLES_COLORS", "gnu") 67 | haveGNU := mustRun(t, "-CF", "--color=always") + "\n" 68 | for i, l := range strings.Split(mustRun(t, "-lF", "--color=always"), "\n") { 69 | if i > 0 { 70 | if i%5 == 0 { 71 | haveGNU += "\n" 72 | } else { 73 | haveGNU += " → " 74 | } 75 | } 76 | f := strings.Split(l, " │ ") 77 | haveGNU += f[2] 78 | } 79 | 80 | os.Setenv("ELLES_COLORS", "bsd") 81 | haveBSD := mustRun(t, "-CF", "--color=always") + "\n" 82 | for i, l := range strings.Split(mustRun(t, "-lF", "--color=always"), "\n") { 83 | if i > 0 { 84 | if i%5 == 0 { 85 | haveBSD += "\n" 86 | } else { 87 | haveBSD += " → " 88 | } 89 | } 90 | f := strings.Split(l, " │ ") 91 | haveBSD += f[2] 92 | } 93 | 94 | os.Unsetenv("LS_COLORS") 95 | 96 | // Can get the system values with the following functions, assuming it point 97 | // to the correct "ls". 98 | testGNU := func() string { 99 | out1, _ := exec.Command("ls", "-CF", "--color=always").CombinedOutput() 100 | out2, _ := exec.Command("ls", "-lF", "--color=always").CombinedOutput() 101 | out3 := string(out1) 102 | for i, l := range strings.Split(string(out2), "\n")[1:] { 103 | if l == "" { 104 | continue 105 | } 106 | if i > 0 { 107 | if i%5 == 0 { 108 | out3 += "\n" 109 | } else { 110 | out3 += " → " 111 | } 112 | } 113 | f := strings.Fields(l) 114 | if len(f) > 7 { 115 | out3 += strings.Join(f[8:], " ") 116 | } 117 | } 118 | return out3 119 | } 120 | testBSD := func() string { 121 | os.Setenv("CLICOLOR_FORCE", "1") 122 | p := "/home/martin/code/Prog/boxlike/boxlike-static" 123 | out1, _ := exec.Command(p, "ls", "-CFG").CombinedOutput() 124 | out2, _ := exec.Command(p, "ls", "-lFG").CombinedOutput() 125 | out3 := string(out1) 126 | for i, l := range strings.Split(string(out2), "\n")[1:] { 127 | if l == "" { 128 | continue 129 | } 130 | if i > 0 { 131 | if i%5 == 0 { 132 | out3 += "\n" 133 | } else { 134 | out3 += " → " 135 | } 136 | } 137 | f := strings.Fields(l) 138 | if len(f) > 7 { 139 | out3 += strings.Join(f[8:], " ") 140 | } 141 | } 142 | return out3 143 | } 144 | _, _, _, _ = testGNU, testBSD, haveGNU, haveBSD 145 | 146 | //fmt.Println(haveGNU) 147 | //fmt.Print("\n-------------------------\n\n") 148 | //fmt.Println(testGNU()) 149 | 150 | //fmt.Println(haveBSD) 151 | //fmt.Print("\n-------------------------\n\n") 152 | //fmt.Println(testBSD()) 153 | } 154 | 155 | // t.Run("color-dtype-dir", func(t *testing.T) { 156 | // // Ensure "ls --color" properly colors other-writable and sticky directories. 157 | // // Before coreutils-6.2, this test would fail, coloring all three 158 | // // directories the same as the first one -- but only on a file system 159 | // // with dirent.d_type support. 160 | 161 | // start(t) 162 | 163 | // // mkdir d other-writable sticky 164 | // // chmod o+w other-writable 165 | // // chmod o+t sticky 166 | // // 167 | // // 168 | // // TERM=xterm ls --color=always > out 169 | // // cat -A out > o1 170 | // // mv o1 out 171 | // // 172 | // // cat <<\EOF > exp 173 | // // ^[[0m^[[01;34md^[[0m$ 174 | // // ^[[34;42mother-writable^[[0m$ 175 | // // out$ 176 | // // ^[[37;44msticky^[[0m$ 177 | // // EOF 178 | // // 179 | // // compare exp out 180 | // // 181 | // // rm exp 182 | // // 183 | // // # Turn off colors for other-writable dirs and ensure 184 | // // # we fall back to the color for standard directories. 185 | // // 186 | // // LS_COLORS="ow=:" ls --color=always > out 187 | // // cat -A out > o1 188 | // // mv o1 out 189 | // // 190 | // // cat <<\EOF > exp 191 | // // ^[[0m^[[01;34md^[[0m$ 192 | // // ^[[01;34mother-writable^[[0m$ 193 | // // out$ 194 | // // ^[[37;44msticky^[[0m$ 195 | // // EOF 196 | // // 197 | // // compare exp out 198 | // }) 199 | 200 | // t.Run("color-norm", func(t *testing.T) { 201 | // // Ensure "ls --color" properly colors "normal" text and files. I.e., 202 | // // that it uses NORMAL to style non file name output and file names with 203 | // // no associated color (unless FILE is also set). 204 | 205 | // start(t) 206 | 207 | // // # Output time as something constant 208 | // // export TIME_STYLE="+norm" 209 | // // 210 | // // # helper to strip ls columns up to "norm" time 211 | // // qls() { sed 's/-r.*norm/norm/'; } 212 | // // 213 | // // touch exe 214 | // // chmod u+x exe 215 | // // touch nocolor 216 | // // 217 | // // TCOLORS="no=7:ex=01;32" 218 | // // 219 | // // # Uncolored file names inherit NORMAL attributes. 220 | // // LS_COLORS=$TCOLORS ls -gGU --color exe nocolor | qls >> out 221 | // // LS_COLORS=$TCOLORS ls -xU --color exe nocolor >> out 222 | // // LS_COLORS=$TCOLORS ls -gGU --color nocolor exe | qls >> out 223 | // // LS_COLORS=$TCOLORS ls -xU --color nocolor exe >> out 224 | // // 225 | // // # NORMAL does not override FILE though 226 | // // LS_COLORS=$TCOLORS:fi=1 ls -gGU --color nocolor exe | qls >> out 227 | // // 228 | // // # Support uncolored ordinary files that do _not_ inherit from NORMAL. 229 | // // # Note there is a redundant RESET output before a non colored 230 | // // # file in this case which may be removed in future. 231 | // // LS_COLORS=$TCOLORS:fi= ls -gGU --color nocolor exe | qls >> out 232 | // // LS_COLORS=$TCOLORS:fi=0 ls -gGU --color nocolor exe | qls >> out 233 | // // 234 | // // # A caveat worth noting is that commas (-m), indicator chars (-F) 235 | // // # and the "total" line, do not currently use NORMAL attributes 236 | // // LS_COLORS=$TCOLORS ls -mFU --color nocolor exe >> out 237 | // // 238 | // // # Ensure no coloring is done unless enabled 239 | // // LS_COLORS=$TCOLORS ls -gGU nocolor exe | qls >> out 240 | // // 241 | // // cat -A out > out.display 242 | // // mv out.display out 243 | // // 244 | // // cat <<\EOF > exp 245 | // // ^[[0m^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 246 | // // ^[[7mnorm nocolor^[[0m$ 247 | // // ^[[0m^[[7m^[[m^[[01;32mexe^[[0m ^[[7mnocolor^[[0m$ 248 | // // ^[[0m^[[7mnorm nocolor^[[0m$ 249 | // // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 250 | // // ^[[0m^[[7mnocolor^[[0m ^[[7m^[[m^[[01;32mexe^[[0m$ 251 | // // ^[[0m^[[7mnorm ^[[m^[[1mnocolor^[[0m$ 252 | // // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 253 | // // ^[[0m^[[7mnorm ^[[m^[[mnocolor^[[0m$ 254 | // // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 255 | // // ^[[0m^[[7mnorm ^[[m^[[0mnocolor^[[0m$ 256 | // // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 257 | // // ^[[0m^[[7mnocolor^[[0m, ^[[7m^[[m^[[01;32mexe^[[0m*$ 258 | // // norm nocolor$ 259 | // // norm exe$ 260 | // // EOF 261 | // // 262 | // // compare exp out 263 | // }) 264 | 265 | // t.Run("multihardlink", func(t *testing.T) { 266 | // // Ensure "ls --color" properly colors names of hard linked files. 267 | // start(t) 268 | 269 | // // touch file file1 270 | // // ln file1 file2 || skip_ "can't create hard link" 271 | // // code_mh='44;37' 272 | // // code_ex='01;32' 273 | // // code_png='01;35' 274 | // // c0=$(printf '\033[0m') 275 | // // c_mh=$(printf '\033[%sm' $code_mh) 276 | // // c_ex=$(printf '\033[%sm' $code_ex) 277 | // // c_png=$(printf '\033[%sm' $code_png) 278 | 279 | // // # regular file - not hard linked 280 | // // LS_COLORS="mh=$code_mh" ls -U1 --color=always file > out 281 | // // printf "file\n" > out_ok 282 | // // compare out out_ok 283 | 284 | // // # hard links 285 | // // LS_COLORS="mh=$code_mh" ls -U1 --color=always file1 file2 > out 286 | // // printf "$c0${c_mh}file1$c0 287 | // // ${c_mh}file2$c0 288 | // // " > out_ok 289 | // // compare out out_ok 290 | 291 | // // # hard links and png (hard link coloring takes precedence) 292 | // // mv file2 file2.png 293 | // // LS_COLORS="mh=$code_mh:*.png=$code_png" ls -U1 --color=always file1 file2.png \ 294 | // // > out 295 | // // printf "$c0${c_mh}file1$c0 296 | // // ${c_mh}file2.png$c0 297 | // // " > out_ok 298 | // // compare out out_ok 299 | 300 | // // # hard links and exe (exe coloring takes precedence) 301 | // // chmod a+x file2.png 302 | // // LS_COLORS="mh=$code_mh:*.png=$code_png:ex=$code_ex" \ 303 | // // ls -U1 --color=always file1 file2.png > out 304 | // // chmod a-x file2.png 305 | // // printf "$c0${c_ex}file1$c0 306 | // // ${c_ex}file2.png$c0 307 | // // " > out_ok 308 | // // compare out out_ok 309 | 310 | // // # hard links and png (hard link coloring disabled => png coloring enabled) 311 | // // LS_COLORS="mh=00:*.png=$code_png" ls -U1 --color=always file1 file2.png > out \ 312 | 313 | // // printf "file1 314 | // // $c0${c_png}file2.png$c0 315 | // // " > out_ok 316 | // // compare out out_ok 317 | 318 | // // # hard links and png (hard link coloring not enabled explicitly => png coloring) 319 | // // LS_COLORS="*.png=$code_png" ls -U1 --color=always file1 file2.png > out \ 320 | 321 | // // printf "file1 322 | // // $c0${c_png}file2.png$c0 323 | // // " > out_ok 324 | // // compare out out_ok 325 | // }) 326 | -------------------------------------------------------------------------------- /completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef elles 2 | 3 | # Completion for "elles"; https://github.com/arp242/elles 4 | # 5 | # Save as "_elles" in any directory in $fpath; see the current list with: 6 | # 7 | # print -l $fpath 8 | # 9 | # To add your own directory (before compinit): 10 | # 11 | # fpath=(~/.zsh/funcs $fpath) 12 | 13 | local arguments 14 | 15 | arguments=( 16 | '(-a --all)'{-a,--all}'[list entries starting with .]' 17 | '(-d --directory)'{-d,--directory}'[list directories themselves, instead of contents]' 18 | '(-H)'-H'[follow symlink on the command line]' 19 | '(-R --recursive)'{-R,-recursive}'[list subdirectories recursively]' 20 | '(-i --inore)'{-i,--inode}'[print inode numbers]' 21 | '(-g -groupname)'{-g,--groupname}'[always print group name]' 22 | 23 | '(-j --json)'{-j,--json}'[print as JSON]' 24 | '(-1 -C)'-l'[long listing]' 25 | '(-1 -C)'-ll'[longer listing]' 26 | '(-l -C -ll)'-1'[single column output]' 27 | '(-1 -l -ll)'-C'[columnar output]' 28 | '(--group-dirs)'--group-dirs'[group drectories first]' 29 | '(-n)'-n'[numeric uid and gid]' 30 | '(-L)'-L"[don't show symlink targets in -l]" 31 | '(-w --width)'{-w,--width}'[maximum column width]' 32 | '(--trim --no-trim)'--trim"[trim pathnames if they're too long to fit on the screen]" 33 | '(--no-trim --trim)'--no-trim'[disable --trim]' 34 | '(-o --octal)'{-o,--octal}'[file permissions as octal]' 35 | 36 | '--color=-[control use of color]:color:(never always auto)' 37 | '--hyperlink=[output terminal codes to link files using file::// URI]::when:(none auto always)' 38 | '(-p -F)'-p'[append / to directories]' 39 | '(-F -p)'-F'[append file type indicators]' 40 | '(-,)'-,'[print file sizes with thousands separators]' 41 | '--blocks=-[format for file sizes]:block:(1 s S K M G)' 42 | '(-c -u)'-c'[use creation (btime) in -l and -t sorting]' 43 | '(-c -u)'-u'[use access in -l and -t sorting]' 44 | '(-T)'-T'[display full time info]' 45 | '(-TT)'-TT'[display full time info with nanoseconds and TZ]' 46 | '(-Q)'-Q'[quote paths with special shell characters or spaces]' 47 | '(-QQ)'-QQ'[quote all paths]' 48 | 49 | '(-r --reverse)'{-r,--reverse}'[reverse sort order]' 50 | '(--sort -t -U -v -X -W)-S[sort by size]' 51 | '(--sort -S -t -U -v -W)-X[sort by extension]' 52 | '(--sort -S -t -U -X -W)-v[sort by version (filename treated numerically)]' 53 | '(--sort -S -U -v -X -W)-t[sort by time]' 54 | '(--sort -S -U -v -X -t)-W[sort by width]' 55 | '(-S -t -U -v -X -W)--sort=[specify sort key]:sort key:(size time none version extension width)' 56 | 57 | '(- :)--help[display help information]' 58 | '(- :)--version[display version information]' 59 | 60 | '*:file:_files' 61 | ) 62 | 63 | _arguments -s -S : $arguments 64 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type errGroup struct { 8 | MaxSize int 9 | mu sync.Mutex 10 | errs []error 11 | } 12 | 13 | func (g *errGroup) Len() int { return len(g.errs) } 14 | 15 | // List all the errors; returns nil if there are no errors. 16 | func (g *errGroup) List() []error { 17 | if g.Len() == 0 { 18 | return nil 19 | } 20 | 21 | g.mu.Lock() 22 | defer g.mu.Unlock() 23 | e := make([]error, len(g.errs)) 24 | copy(e, g.errs) 25 | return e 26 | } 27 | 28 | // Append a new error to the list; this is thread-safe. 29 | // 30 | // It won't do anything if the error is nil, in which case it will return false. 31 | // This makes appending errors in a loop slightly nicer: 32 | // 33 | // for { 34 | // err := do() 35 | // if errors.Append(err) { 36 | // continue 37 | // } 38 | // } 39 | func (g *errGroup) Append(err error) bool { 40 | if err == nil { 41 | return false 42 | } 43 | 44 | g.mu.Lock() 45 | defer g.mu.Unlock() 46 | if g.MaxSize == 0 || len(g.errs) < g.MaxSize { 47 | g.errs = append(g.errs, err) 48 | } 49 | return true 50 | } 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module zgo.at/elles 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | golang.org/x/sys v0.31.0 9 | zgo.at/termtext v1.5.1-0.20240620230817-7e8a4a59650a 10 | zgo.at/zli v0.0.0-20241220135549-7a37675fadfd 11 | ) 12 | 13 | require ( 14 | github.com/rivo/uniseg v0.4.7 // indirect 15 | zgo.at/runewidth v0.1.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 2 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 3 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 4 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 5 | zgo.at/runewidth v0.1.0 h1:ED4PzJpYJlZMDEkoz+iPKjb5NrwbKnWPXDMJlNlfk9g= 6 | zgo.at/runewidth v0.1.0/go.mod h1:Ugl6FGPF5Ib/NRu2UAV2wVthEgYfEz51Bu/uyNbWZSw= 7 | zgo.at/termtext v1.5.1-0.20240620230817-7e8a4a59650a h1:jok598mPBSr9aI05qxMT4NOjB+WG/o7DoL61i6xTZ8c= 8 | zgo.at/termtext v1.5.1-0.20240620230817-7e8a4a59650a/go.mod h1:AcdAAiydkqSFadljJaEj9jv7bpyJxfQqGtPWyZCLenQ= 9 | zgo.at/zli v0.0.0-20241220135549-7a37675fadfd h1:6FgPCytAJqWegtH2X07VJVApHupmbFUTBnQQCl8Qav4= 10 | zgo.at/zli v0.0.0-20241220135549-7a37675fadfd/go.mod h1:0jjx+AGEkWOOQ0NtzbMnpko+H2G+aTg8mfCKqoc/BuA= 11 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/fs" 6 | "os" 7 | "os/user" 8 | "path/filepath" 9 | "runtime" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "zgo.at/elles/os2" 16 | "zgo.at/zli" 17 | ) 18 | 19 | func init() { 20 | zli.WantColor = false 21 | os.Unsetenv("LS_COLORS") 22 | os.Unsetenv("LSCOLORS") 23 | os.Setenv("COLUMNS", "80") 24 | columns = 80 25 | } 26 | 27 | var mydir = func() string { 28 | d, err := os.Getwd() 29 | if err != nil { 30 | panic(err) 31 | } 32 | return d 33 | }() 34 | 35 | type uinfo struct { 36 | Username, Groupname string 37 | UserID, GroupID string 38 | UID, GID int // Unix only 39 | } 40 | 41 | var userinfo = func() uinfo { 42 | u, err := user.Current() 43 | if err != nil { 44 | panic(err) 45 | } 46 | g, err := user.LookupGroupId(u.Gid) 47 | if err != nil { 48 | panic(err) 49 | } 50 | uid, _ := strconv.Atoi(u.Uid) 51 | gid, _ := strconv.Atoi(u.Gid) 52 | 53 | return uinfo{ 54 | Username: u.Username, 55 | Groupname: g.Name, 56 | UserID: u.Uid, 57 | GroupID: g.Gid, 58 | UID: uid, 59 | GID: gid, 60 | } 61 | }() 62 | 63 | var join = filepath.Join 64 | 65 | func run(t *testing.T, args ...string) (o string, ok bool) { 66 | zli.Test(t) 67 | defer func() { 68 | ok = recover() == nil 69 | o = strings.TrimSuffix(zli.Stdout.(*bytes.Buffer).String(), "\n") 70 | }() 71 | os.Args = append([]string{"elles"}, args...) 72 | main() 73 | return o, ok 74 | } 75 | 76 | func mustRun(t *testing.T, args ...string) string { 77 | t.Helper() 78 | out, ok := run(t, args...) 79 | if !ok { 80 | t.Fatalf("mustRun failed: %v", out) 81 | } 82 | return out 83 | } 84 | 85 | // Replacement patterns: 86 | // 87 | // martin - username 88 | // tournoij - group name 89 | 90 | func norm(s string, repl ...string) string { 91 | if len(repl)%2 == 1 { 92 | panic("norm: uneven repl") 93 | } 94 | s = strings.TrimPrefix(s, "\n") 95 | s = strings.ReplaceAll(s, "\t", "") 96 | 97 | repl = append(repl, 98 | "martin", userinfo.Username, 99 | "tournoij", userinfo.Groupname, 100 | ) 101 | s = strings.NewReplacer(repl...).Replace(s) 102 | return s 103 | } 104 | 105 | // Start a test by creating a new temporary directory and cd'ing to it. Register 106 | // a cleanup function to cd back to the previous directory: this is mostly 107 | // needed for Windows and illumos, who will refuse to delete the directory 108 | // otherwise causing the test to fail. 109 | func start(t *testing.T) string { 110 | t.Helper() 111 | tmp := t.TempDir() 112 | tmp, err := filepath.EvalSymlinks(tmp) // for macOS 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | cd(t, tmp) 117 | t.Cleanup(func() { cd(t, mydir) }) 118 | return tmp 119 | } 120 | 121 | func isCI() bool { 122 | _, ok := os.LookupEnv("CI") 123 | return ok 124 | } 125 | 126 | func hasRoot(t *testing.T, skip bool) bool { 127 | u, err := user.Current() 128 | if err == nil && u.Uid == "0" { 129 | return true 130 | } 131 | 132 | if skip { 133 | t.Skipf("current UID %q is not root", u.Uid) 134 | } 135 | return false 136 | } 137 | 138 | func supportsSparseFiles(t *testing.T, skip bool) bool { 139 | tmp := t.TempDir() 140 | 141 | createSparse(t, 8192, tmp, ".sparse-test") 142 | st, err := os.Stat(join(tmp, ".sparse-test")) 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | if os2.Blocks(st) != 0 { 147 | if skip { 148 | t.Skip("filesystem doesn't appear to support sparse files") 149 | } 150 | return false 151 | } 152 | return true 153 | } 154 | 155 | func supportsFIFO(t *testing.T, skip bool) bool { 156 | if runtime.GOOS == "windows" { 157 | if skip { 158 | t.Skipf("%s does not support named sockets (FIFO)", runtime.GOOS) 159 | } 160 | return false 161 | } 162 | return true 163 | } 164 | 165 | func supportsDevice(t *testing.T, skip bool) bool { 166 | switch runtime.GOOS { 167 | case "windows": 168 | if skip { 169 | t.Skipf("%s does not support device nodes", runtime.GOOS) 170 | } 171 | return false 172 | case "freebsd", "netbsd", "openbsd", "dragonfly", "darwin", "illumos", "solaris": 173 | if skip { 174 | t.Skipf("%s requires root permissions to create device nodes", runtime.GOOS) 175 | } 176 | return false 177 | } 178 | return true 179 | } 180 | 181 | func supportsBtime(t *testing.T, skip bool) bool { 182 | switch runtime.GOOS { 183 | case "openbsd", "dragonfly", "illumos", "solaris": 184 | if skip { 185 | t.Skipf("btime not supported on %s", runtime.GOOS) 186 | } 187 | return false 188 | } 189 | return true 190 | } 191 | 192 | func supportsUtimes(t *testing.T, skip bool) bool { 193 | if runtime.GOOS == "windows" { 194 | if skip { 195 | t.Skipf("%s does not support os2.Utime", runtime.GOOS) 196 | } 197 | return false 198 | } 199 | return true 200 | } 201 | 202 | // cd 203 | func cd(t testing.TB, path ...string) { 204 | t.Helper() 205 | if len(path) < 1 { 206 | t.Fatalf("cd: path must have at least one element: %s", path) 207 | } 208 | err := os.Chdir(join(path...)) 209 | if err != nil { 210 | t.Fatalf("cd(%q): %s", join(path...), err) 211 | } 212 | } 213 | 214 | // pwd 215 | func pwd(t *testing.T) string { 216 | t.Helper() 217 | wd, err := os.Getwd() 218 | if err != nil { 219 | t.Fatalf("pwd: %s", err) 220 | } 221 | return filepath.ToSlash(wd) 222 | } 223 | 224 | func createSparse(t *testing.T, sz int64, path ...string) { 225 | t.Helper() 226 | if len(path) < 1 { 227 | t.Fatalf("createSparse: path must have at least one element: %s", path) 228 | } 229 | fp, err := os.Create(join(path...)) 230 | if err != nil { 231 | t.Fatalf("createSparse(%q): %s", join(path...), err) 232 | } 233 | if err := fp.Truncate(sz); err != nil { 234 | t.Fatalf("createSparse(%q): %s", join(path...), err) 235 | } 236 | if err := fp.Close(); err != nil { 237 | t.Fatalf("createSparse(%q): %s", join(path...), err) 238 | } 239 | } 240 | 241 | // mkdir -p 242 | func mkdirAll(t *testing.T, path ...string) { 243 | t.Helper() 244 | if len(path) < 1 { 245 | t.Fatalf("mkdirAll: path must have at least one element: %s", path) 246 | } 247 | err := os.MkdirAll(join(path...), 0o0755) 248 | if err != nil { 249 | t.Fatalf("mkdirAll(%q): %s", join(path...), err) 250 | } 251 | } 252 | 253 | // touch 254 | func touch(t testing.TB, path ...string) { 255 | t.Helper() 256 | if len(path) < 1 { 257 | t.Fatalf("touch: path must have at least one element: %s", path) 258 | } 259 | fp, err := os.Create(join(path...)) 260 | if err != nil { 261 | t.Fatalf("touch(%q): %s", join(path...), err) 262 | } 263 | err = fp.Close() 264 | if err != nil { 265 | t.Fatalf("touch(%q): %s", join(path...), err) 266 | } 267 | } 268 | 269 | // touch -d 270 | func touchDate(t testing.TB, tt time.Time, path ...string) { 271 | t.Helper() 272 | if len(path) < 1 { 273 | t.Fatalf("touch: path must have at least one element: %s", path) 274 | } 275 | fp, err := os.Create(join(path...)) 276 | if err != nil { 277 | t.Fatalf("touch(%q): %s", join(path...), err) 278 | } 279 | err = fp.Close() 280 | if err != nil { 281 | t.Fatalf("touch(%q): %s", join(path...), err) 282 | } 283 | err = os2.Utimes(join(path...), tt, tt) 284 | if err != nil { 285 | t.Fatalf("touch(%q): %s", join(path...), err) 286 | } 287 | } 288 | 289 | // ln -s 290 | func symlink(t *testing.T, target string, link ...string) { 291 | t.Helper() 292 | if len(link) < 1 { 293 | t.Fatalf("symlink: link must have at least one element: %s", link) 294 | } 295 | err := os.Symlink(target, join(link...)) 296 | if err != nil { 297 | t.Fatalf("symlink(%q, %q): %s", target, join(link...), err) 298 | } 299 | } 300 | 301 | // mkfifo 302 | func mkfifo(t *testing.T, path ...string) { 303 | t.Helper() 304 | if len(path) < 1 { 305 | t.Fatalf("mkfifo: path must have at least one element: %s", path) 306 | } 307 | err := os2.Mkfifo(join(path...), 0o644) 308 | if err != nil { 309 | t.Fatalf("mkfifo(%q): %s", join(path...), err) 310 | } 311 | } 312 | 313 | // mknod 314 | func mknod(t *testing.T, dev int, path ...string) { 315 | t.Helper() 316 | if len(path) < 1 { 317 | t.Fatalf("mknod: path must have at least one element: %s", path) 318 | } 319 | err := os2.Mknod(join(path...), 0o644, dev) 320 | if err != nil { 321 | t.Fatalf("mknod(%d, %q): %s", dev, join(path...), err) 322 | } 323 | } 324 | 325 | // chmod 326 | func chmod(t *testing.T, mode fs.FileMode, path ...string) { 327 | t.Helper() 328 | if len(path) < 1 { 329 | t.Fatalf("chmod: path must have at least one element: %s", path) 330 | } 331 | err := os.Chmod(join(path...), mode) 332 | if err != nil { 333 | t.Fatalf("chmod(%q): %s", join(path...), err) 334 | } 335 | } 336 | 337 | // rm 338 | func rm(t *testing.T, path ...string) { 339 | t.Helper() 340 | if len(path) < 1 { 341 | t.Fatalf("rm: path must have at least one element: %s", path) 342 | } 343 | err := os.Remove(join(path...)) 344 | if err != nil { 345 | t.Fatalf("rm(%q): %s", join(path...), err) 346 | } 347 | } 348 | 349 | // rm -r 350 | func rmAll(t *testing.T, path ...string) { 351 | t.Helper() 352 | if len(path) < 1 { 353 | t.Fatalf("rmAll: path must have at least one element: %s", path) 354 | } 355 | err := os.RemoveAll(join(path...)) 356 | if err != nil { 357 | t.Fatalf("rmAll(%q): %s", join(path...), err) 358 | } 359 | } 360 | 361 | // echo > and echo >> 362 | func echoAppend(t *testing.T, data string, path ...string) { t.Helper(); echo(t, false, data, path...) } 363 | func echoTrunc(t *testing.T, data string, path ...string) { t.Helper(); echo(t, true, data, path...) } 364 | func echo(t *testing.T, trunc bool, data string, path ...string) { 365 | n := "echoAppend" 366 | if trunc { 367 | n = "echoTrunc" 368 | } 369 | t.Helper() 370 | if len(path) < 1 { 371 | t.Fatalf("%s: path must have at least one element: %s", n, path) 372 | } 373 | 374 | err := func() error { 375 | var ( 376 | fp *os.File 377 | err error 378 | ) 379 | if trunc { 380 | fp, err = os.Create(join(path...)) 381 | } else { 382 | fp, err = os.OpenFile(join(path...), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 383 | } 384 | if err != nil { 385 | return err 386 | } 387 | if err := fp.Sync(); err != nil { 388 | return err 389 | } 390 | if _, err := fp.WriteString(data); err != nil { 391 | return err 392 | } 393 | if err := fp.Sync(); err != nil { 394 | return err 395 | } 396 | return fp.Close() 397 | }() 398 | if err != nil { 399 | t.Fatalf("%s(%q): %s", n, join(path...), err) 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "cmp" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "math" 9 | "os" 10 | "path/filepath" 11 | "runtime" 12 | "slices" 13 | "sort" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "zgo.at/elles/os2" 19 | "zgo.at/termtext" 20 | "zgo.at/zli" 21 | ) 22 | 23 | var ( 24 | isTerm = func() bool { return zli.IsTerminal(os.Stdout.Fd()) }() 25 | columns = func() int { 26 | if c := os.Getenv("COLUMNS"); c != "" { 27 | if n, err := strconv.Atoi(c); err != nil && n > 0 { 28 | return n 29 | } 30 | 31 | } 32 | 33 | // On "| less", "| head", etc. we can get the width from stdin, but that 34 | // never works on Windows. So try both. 35 | n, _, err := zli.TerminalSize(os.Stdout.Fd()) 36 | if err != nil { 37 | n, _, err = zli.TerminalSize(os.Stdin.Fd()) 38 | } 39 | if err != nil { 40 | return 0 41 | } 42 | return n 43 | }() 44 | ) 45 | 46 | type ( 47 | printable struct { 48 | dir string // Belongs in dir; can be empty. 49 | absdir string 50 | isFiles bool 51 | fi []fileInfo 52 | } 53 | fileInfo struct { 54 | fs.FileInfo 55 | filepath, filepathAbs string 56 | } 57 | ) 58 | 59 | func main() { 60 | f := zli.NewFlags(os.Args) 61 | var ( 62 | help = f.Bool(false, "help") 63 | version = f.Bool(false, "version") 64 | manpage = f.Bool(false, "manpage") 65 | completion = f.String("", "completion") 66 | all = f.Bool(false, "a", "all", "A", "almost-all") 67 | asJSON = f.Bool(false, "j", "json") 68 | list = f.IntCounter(0, "l") 69 | prDir = f.Bool(false, "d", "directory") 70 | one = f.Bool(!isTerm, "1") 71 | cols = f.Bool(isTerm, "C") 72 | hyperlink = f.Optional().String("never", "hyperlink", "hyper") 73 | color = f.Optional().String("auto", "color", "colour") 74 | colorBSD = f.Bool(false, "G") 75 | sortReverse = f.Bool(false, "r", "reverse") 76 | sortSize = f.Bool(false, "S") 77 | sortTime = f.Bool(false, "t") 78 | sortExt = f.Bool(false, "X") 79 | sortVersion = f.Bool(false, "v") 80 | sortWidth = f.Bool(false, "W") 81 | sortNone = f.Bool(false, "U") 82 | sortNoneAll = f.Bool(false, "f") 83 | sortFlag = f.String("name", "sort") 84 | dirsFirst = f.Bool(false, "group-dir", "group-dirs", "group-directories", "group-directories-first") 85 | derefCmdline = f.Bool(false, "H") 86 | derefAll = f.Bool(false, "L") 87 | recurse = f.Bool(false, "R", "recursive") 88 | classify = f.Bool(false, "F") 89 | dirSlash = f.Bool(false, "p") 90 | numericUID = f.Bool(false, "n") 91 | inode = f.Bool(false, "i", "inode") 92 | blockSize = f.String("h", "B", "block", "blocks", "block-size") 93 | _ = f.Bool(false, "h") // No-op 94 | sizeBlock = f.Bool(false, "s", "size") 95 | timeCreate = f.Bool(false, "c") 96 | timeAccess = f.Bool(false, "u") 97 | comma = f.Bool(false, ",") 98 | quote = f.IntCounter(0, "Q") 99 | fullTime = f.IntCounter(0, "T") 100 | width = f.Int(0, "w", "width") 101 | trim = f.Bool(false, "trim") 102 | noTrim = f.Bool(false, "no-trim") 103 | octal = f.Bool(false, "o", "octal") 104 | group = f.Bool(false, "g", "groupname") 105 | minCols = f.Int(0, "m", "min") 106 | ) 107 | zli.F(f.Parse(zli.AllowMultiple())) 108 | if colorBSD.Bool() && !color.Set() { 109 | *color.Pointer() = "always" 110 | } 111 | switch strings.ToLower(color.String()) { 112 | case "auto", "tty", "if-tty": 113 | case "never", "no", "none": 114 | zli.WantColor = false 115 | case "always", "yes", "force", "": 116 | zli.WantColor = true 117 | default: 118 | zli.Fatalf("invalid value for -color: %q", color) 119 | } 120 | setColor() 121 | if help.Bool() { 122 | fmt.Fprint(zli.Stdout, usage) 123 | return 124 | } 125 | if version.Bool() { 126 | zli.PrintVersion(false) 127 | return 128 | } 129 | if manpage.Bool() { 130 | fmt.Print(usage.Mandoc("elles", 1)) 131 | return 132 | } 133 | if completion.Set() { 134 | switch shell := completion.String(); shell { 135 | case "zsh": 136 | fmt.Print(zsh) 137 | default: 138 | zli.Fatalf("no completion for %q", shell) 139 | } 140 | return 141 | } 142 | 143 | if noTrim.Set() { 144 | *trim.Pointer() = false 145 | } 146 | if sizeBlock.Bool() { 147 | *blockSize.Pointer() = "s" 148 | } 149 | 150 | doLink := false 151 | switch strings.ToLower(hyperlink.String()) { 152 | case "auto", "tty", "if-tty": 153 | doLink = zli.WantColor 154 | case "never", "no", "none": 155 | case "always", "yes", "force", "": 156 | doLink = true 157 | default: 158 | zli.Fatalf("invalid value for -hyperlink: %q", hyperlink) 159 | } 160 | 161 | nostat := list.Int() == 0 && !classify.Bool() && !inode.Bool() && !asJSON.Bool() 162 | switch { 163 | case sortNone.Bool(): 164 | *sortFlag.Pointer() = "none" 165 | case sortNoneAll.Bool(): 166 | *sortFlag.Pointer() = "none-all" 167 | case sortSize.Bool(): 168 | *sortFlag.Pointer(), nostat = "size", false 169 | nostat = false 170 | case sortTime.Bool(): 171 | *sortFlag.Pointer(), nostat = "time", false 172 | case sortVersion.Bool(): 173 | *sortFlag.Pointer() = "version" 174 | case sortExt.Bool(): 175 | *sortFlag.Pointer() = "ext" 176 | case sortWidth.Bool(): 177 | *sortFlag.Pointer() = "width" 178 | } 179 | switch sortFlag.String() { 180 | case "name", "none", "none-all", "size", "time", "version", "ext", "extension", "width": 181 | default: 182 | zli.Fatalf("invalid value for -sort: %q", sortFlag.String()) 183 | } 184 | timeField := "mtime" 185 | if timeCreate.Bool() { 186 | timeField = "btime" 187 | } else if timeAccess.Bool() { 188 | timeField = "atime" 189 | } 190 | 191 | if len(f.Args) == 0 { 192 | f.Args = []string{"."} 193 | } 194 | 195 | errs := &errGroup{MaxSize: 100} 196 | 197 | // Gather list to print. 198 | toPrint := gather(f.Args, errs, all.Bool(), recurse.Bool(), prDir.Bool(), 199 | derefCmdline.Bool(), derefAll.Bool(), nostat) 200 | 201 | // Order it. 202 | order(toPrint, sortFlag.String(), timeField, sortReverse.Bool(), dirsFirst.Bool()) 203 | 204 | // Print as JSON. 205 | if asJSON.Bool() { 206 | printJSON(toPrint, errs) 207 | return 208 | } 209 | 210 | opt := opts{ 211 | blockSize: blockSize.String(), 212 | classify: classify.Bool(), 213 | cols: cols.Bool(), 214 | comma: comma.Bool(), 215 | dirSlash: dirSlash.Bool(), 216 | fullTime: fullTime.Int(), 217 | group: group.Bool(), 218 | hyperlink: doLink, 219 | inode: inode.Bool(), 220 | list: list.Int(), 221 | numericUID: numericUID.Bool(), 222 | octal: octal.Bool(), 223 | one: one.Bool(), 224 | quote: quote.Int(), 225 | recurse: recurse.Bool(), 226 | timeField: timeField, 227 | trim: trim.Bool(), 228 | maxColWidth: width.Int(), 229 | derefAll: derefAll.Bool(), 230 | minCols: minCols.Int(), 231 | } 232 | 233 | draw(toPrint, errs, opt, cols.Set()) 234 | } 235 | 236 | func draw(toPrint []printable, errs *errGroup, opt opts, colsSet bool) { 237 | for i, p := range toPrint { 238 | // Print direcrory headers, but not when recursing with -D and there are 239 | // no directories. 240 | if len(toPrint) > 1 && p.dir != "" /*&& !(opt.dirsOnly && len(p.fi) == 0)*/ { 241 | if i > 0 { 242 | fmt.Fprintln(zli.Stdout) 243 | } 244 | fmt.Fprintln(zli.Stdout, filepath.ToSlash(filepath.Clean(p.dir))+":") 245 | } 246 | 247 | // Format for output in memory first. This makes alignment much easier 248 | // because we may or may not add things such as "/". Even with very 249 | // large directories it shouldn't take more than a few hundred K. 250 | cc := getCols(p, opt) 251 | 252 | refmt: 253 | var ( 254 | fmtRows = make([]string, 0, len(cc.rows)) 255 | widths = make([]int, 0, len(cc.rows)) 256 | longest int 257 | buf strings.Builder 258 | w int 259 | ) 260 | for _, r := range cc.rows { 261 | buf.Reset() 262 | w = 0 263 | for i, c := range r { 264 | if i > 0 { 265 | buf.WriteString(" ") 266 | w++ 267 | } 268 | 269 | if c.prop&borderToLeft != 0 { 270 | buf.WriteString("│ ") 271 | w += 2 272 | } 273 | if c.prop&alignNone != 0 { 274 | w += c.w 275 | buf.WriteString(c.s) 276 | } else if c.prop&alignLeft != 0 { 277 | pad := cc.longest[i] - c.w 278 | buf.WriteString(c.s) 279 | buf.WriteString(strings.Repeat(" ", pad)) 280 | w += c.w + pad 281 | } else { 282 | pad := cc.longest[i] - c.w 283 | buf.WriteString(strings.Repeat(" ", pad)) 284 | buf.WriteString(c.s) 285 | w += c.w + pad 286 | } 287 | } 288 | 289 | b := buf.String() 290 | if opt.maxColWidth > 0 && w > opt.maxColWidth { 291 | b = termtext.Slice(b, 0, opt.maxColWidth-1) + reset + "…" 292 | w = opt.maxColWidth 293 | } 294 | fmtRows, widths = append(fmtRows, b), append(widths, w) 295 | if w > longest { 296 | longest = w 297 | } 298 | } 299 | 300 | one: 301 | if (opt.one && !colsSet) || (opt.list > 0 && !colsSet) { 302 | for i, f := range fmtRows { 303 | if columns > 0 && opt.trim && widths[i] > columns { 304 | f = termtext.Slice(f, 0, columns-1) + reset + "…" 305 | } 306 | fmt.Fprintln(zli.Stdout, f) 307 | } 308 | } else { 309 | var ( 310 | colwidths []int 311 | rows [][]string 312 | pad = 2 313 | ) 314 | if opt.list > 0 { 315 | pad = 4 316 | } 317 | for i := range 200 { 318 | r, w := recol(fmtRows, widths, i+1, pad) 319 | if sum(w) > columns { 320 | if i <= 1 { 321 | rows, colwidths = r, w 322 | } 323 | break 324 | } 325 | rows, colwidths = r, w 326 | } 327 | if opt.minCols > 0 && len(colwidths) < opt.minCols { 328 | if opt.maxColWidth == 0 { 329 | opt.maxColWidth = longest - 1 330 | } else { 331 | opt.maxColWidth-- 332 | } 333 | goto refmt 334 | } 335 | 336 | // Only space for one column; restart as if -1 was set. Saves some 337 | // special-fu here. 338 | if len(colwidths) == 1 { 339 | opt.one, colsSet = true, false 340 | goto one 341 | } 342 | 343 | for i, r := range rows { 344 | for j, c := range r { 345 | x := i + len(rows)*j 346 | if opt.list > 0 && j != len(r)-1 { 347 | fmt.Fprint(zli.Stdout, c, strings.Repeat(" ", colwidths[j]-widths[x]-2)) 348 | fmt.Fprint(zli.Stdout, "┃ ") 349 | } else { 350 | fmt.Fprint(zli.Stdout, c) 351 | if j != len(r)-1 { 352 | fmt.Fprint(zli.Stdout, strings.Repeat(" ", colwidths[j]-widths[x])) 353 | } 354 | } 355 | } 356 | fmt.Fprintln(zli.Stdout) 357 | } 358 | } 359 | } 360 | 361 | // Print errors last, so they're more visible. ls does this at the top, and 362 | // it's easy to miss if pushed off the screen. 363 | for _, e := range errs.List() { 364 | zli.Errorf(e) 365 | } 366 | if errs.Len() > 0 { 367 | zli.Exit(1) 368 | } 369 | } 370 | 371 | func recol(paths []string, pathWidths []int, ncols, pad int) ([][]string, []int) { 372 | var ( 373 | rows = make([][]string, 0, 8) 374 | widths = make([]int, ncols) 375 | height = int(math.Ceil(float64(len(paths)) / float64(ncols))) 376 | ) 377 | for i := range height { 378 | row := make([]string, 0, ncols) 379 | for c := range ncols { 380 | j := i + height*c 381 | if j > len(paths)-1 { 382 | break 383 | } 384 | 385 | l := pathWidths[j] 386 | if c < ncols-1 { 387 | l += pad 388 | } 389 | if l > widths[c] { 390 | widths[c] = l 391 | } 392 | row = append(row, paths[j]) 393 | } 394 | rows = append(rows, row) 395 | } 396 | if i := slices.Index(widths, 0); i > -1 { 397 | widths = widths[:i] 398 | } 399 | return rows, widths 400 | } 401 | 402 | func sum(s []int) int { 403 | var n int 404 | for _, ss := range s { 405 | n += ss 406 | } 407 | return n 408 | } 409 | 410 | // Gather list of everything we want to print. 411 | func gather(args []string, errs *errGroup, all, recurse, prDir, derefCmd, derefAll, nostat bool) []printable { 412 | var ( 413 | toPrint = make([]printable, 0, 16) 414 | filesIndex = -1 // index in toPrint for individual files. 415 | stat = os.Lstat 416 | ) 417 | if derefCmd { 418 | stat = os.Stat 419 | } 420 | //cwd, err := os.Getwd() 421 | //errs.Append(err) 422 | 423 | var addArg func(string) 424 | addArg = func(a string) { 425 | fi, err := stat(a) 426 | if err != nil { 427 | if a == "." && errors.Is(err, os.ErrNotExist) { 428 | return 429 | } 430 | if errs.Append(err) { 431 | return 432 | } 433 | } 434 | 435 | if fi.IsDir() && !prDir { /// Directory. 436 | ls, err := os2.ReadDir(a) 437 | if err != nil { 438 | if a == "." && errors.Is(err, os.ErrNotExist) { 439 | return 440 | } 441 | errs.Append(err) 442 | return 443 | } 444 | 445 | d := a 446 | //if strings.TrimRight(d, "/") == "." { 447 | // d = cwd 448 | //} 449 | if !filepath.IsAbs(d) { 450 | if d == "." || d == "./" { 451 | d = "." 452 | } else { 453 | d = string(append([]byte{'.', filepath.Separator}, d...)) 454 | } 455 | } 456 | ad, err := filepath.Abs(d) 457 | errs.Append(err) 458 | pr := printable{ 459 | dir: d, 460 | absdir: ad, 461 | fi: make([]fileInfo, 0, len(ls)), 462 | } 463 | var subdirs []string 464 | for _, l := range ls { 465 | if os2.Hidden(ad, l) && !all { 466 | continue 467 | } 468 | //if !l.IsDir() && dirsOnly { continue } 469 | 470 | // Don't call stat if we don't need to. 471 | if nostat { 472 | pr.fi = append(pr.fi, fileInfo{fakeFileInfo{l}, "", ""}) 473 | } else { 474 | var fi fs.FileInfo 475 | if derefAll { 476 | fi, err = os.Stat(filepath.Join(ad, l.Name())) 477 | } else { 478 | fi, err = l.Info() 479 | } 480 | if errs.Append(err) { 481 | // Don't skip the entire file, just don't add stat info. 482 | pr.fi = append(pr.fi, fileInfo{fakeFileInfo{l}, "", ""}) 483 | } else { 484 | pr.fi = append(pr.fi, fileInfo{fi, "", ""}) 485 | } 486 | } 487 | 488 | if recurse && l.IsDir() { 489 | subdirs = append(subdirs, filepath.Join(d, l.Name())) 490 | } 491 | } 492 | toPrint = append(toPrint, pr) 493 | for _, s := range subdirs { 494 | addArg(s) 495 | } 496 | } else { /// Single file. 497 | if prDir { 498 | a = strings.TrimRight(a, "/") 499 | } 500 | d := strings.TrimSuffix(a, fi.Name()) 501 | ad, err := filepath.Abs(d) 502 | errs.Append(err) 503 | 504 | if filesIndex == -1 { 505 | toPrint = append(toPrint, printable{ 506 | dir: d, 507 | absdir: ad, 508 | isFiles: true, 509 | fi: []fileInfo{{fi, d, ad}}, 510 | }) 511 | filesIndex = len(toPrint) - 1 512 | } else { 513 | toPrint[filesIndex].fi = append(toPrint[filesIndex].fi, fileInfo{fi, d, ad}) 514 | } 515 | } 516 | } 517 | for _, a := range args { 518 | // Make sure "ls /" and "ls C:" work on Windows. 519 | if runtime.GOOS == "windows" { 520 | if a == "/" { 521 | wd, err := os.Getwd() 522 | if err == nil { 523 | a = filepath.VolumeName(wd) + `\` 524 | } 525 | } else if len(a) == 2 && a[1] == ':' { 526 | a += `\` 527 | } 528 | } 529 | addArg(a) 530 | } 531 | return toPrint 532 | } 533 | 534 | //func getEnv(name string) (string, bool) { 535 | // l, ok := os.LookupEnv(name) 536 | // if !ok { 537 | // return "", false 538 | // } 539 | // l = strings.SplitN(l, ".", 2)[0] // Remove ".UTF-8" or ".ASCII" encoding 540 | // if l == "" || l == "C" { // We can't do anything with this. 541 | // return "", false 542 | // } 543 | // return l, true 544 | //} 545 | 546 | // Sort files. 547 | func order(toPrint []printable, sortby, timeField string, reverse, dirsFirst bool) { 548 | var ( 549 | sorter func(a, b fileInfo) int 550 | // TODO: Hack for Linux btime, until we rewrite some of the stdlib stuff. 551 | sorter2 func(printable) func(a, b fileInfo) int 552 | 553 | nameSort = func(a, b fileInfo) int { return cmp.Compare(a.Name(), b.Name()) } 554 | ) 555 | 556 | // var ( 557 | // lang language.Tag 558 | // haveLang bool 559 | // ) 560 | // for _, v := range []string{"LC_COLLATE", "LC_ALL", "LANG"} { 561 | // if e, ok := getEnv(v); ok { 562 | // langs, _, err := language.ParseAcceptLanguage(e) 563 | // if err != nil || len(langs) == 0 { 564 | // zli.Errorf("invalid %s: %s", v, err) 565 | // } 566 | // lang, haveLang = langs[0], true 567 | // break 568 | // } 569 | // } 570 | //if haveLang { 571 | // col := collate.New(lang, collate.WithCase) 572 | // nameSort = func(a, b fs.FileInfo) int { return col.CompareString(a.Name(), b.Name()) } 573 | //} 574 | 575 | switch sortby { 576 | case "size": 577 | sorter = func(a, b fileInfo) int { return cmp.Compare(b.Size(), a.Size()) } 578 | case "time": 579 | switch timeField { 580 | case "btime": 581 | sorter = nil 582 | sorter2 = func(p printable) func(a, b fileInfo) int { 583 | return func(a, b fileInfo) int { return os2.Btime(p.absdir, b).Compare(os2.Btime(p.absdir, a)) } 584 | } 585 | case "atime": 586 | sorter = func(a, b fileInfo) int { return os2.Atime(b).Compare(os2.Atime(a)) } 587 | default: 588 | sorter = func(a, b fileInfo) int { return b.ModTime().Compare(a.ModTime()) } 589 | } 590 | case "ext", "extension": 591 | sorter = func(a, b fileInfo) int { return cmp.Compare(filepath.Ext(a.Name()), filepath.Ext(b.Name())) } 592 | case "version": 593 | sorter = func(a, b fileInfo) int { return versCompare(a.Name(), b.Name()) } 594 | case "width": 595 | // TODO: maybe make it sort by display width (with quotes and all of 596 | // that)? That's what GNU ls does. 597 | sorter = func(a, b fileInfo) int { return cmp.Compare(len([]rune(a.Name())), len([]rune(b.Name()))) } 598 | case "none", "none-all": 599 | sorter, nameSort = nil, nil 600 | default: 601 | sorter, nameSort = nameSort, nil 602 | } 603 | if sorter != nil || sorter2 != nil { 604 | for _, p := range toPrint { 605 | if nameSort != nil { 606 | slices.SortFunc(p.fi, nameSort) 607 | } 608 | if sorter2 != nil { 609 | slices.SortStableFunc(p.fi, sorter2(p)) 610 | } else { 611 | slices.SortStableFunc(p.fi, sorter) 612 | } 613 | } 614 | } 615 | if reverse { 616 | for _, p := range toPrint { 617 | slices.Reverse(p.fi) 618 | } 619 | } 620 | if dirsFirst { 621 | isdir := func(dir string, fi fileInfo) bool { 622 | if fi.IsDir() { 623 | return true 624 | } 625 | if fi.Mode()&fs.ModeSymlink == 0 { 626 | return false 627 | } 628 | // Symlink to dir should be counted as a "directory". 629 | l, err := os.Readlink(filepath.Join(dir, fi.Name())) 630 | if err != nil { 631 | return false 632 | } 633 | st, err := os.Stat(filepath.Join(dir, l)) 634 | return err == nil && st.IsDir() 635 | } 636 | for _, p := range toPrint { 637 | sort.SliceStable(p.fi, func(i, j int) bool { 638 | return isdir(p.dir, p.fi[i]) && !isdir(p.dir, p.fi[j]) 639 | }) 640 | } 641 | } 642 | slices.SortFunc(toPrint, func(a, b printable) int { 643 | if a.isFiles { 644 | return -1 645 | } 646 | return cmp.Compare(a.dir, b.dir) 647 | }) 648 | } 649 | 650 | // cmp(a, b) should return a negative number when a < b, a positive number when 651 | // a > b and zero when a == b. 652 | func versCompare(a, b string) int { 653 | if a == b { 654 | return 0 655 | } 656 | getNum := func(s string) (int, int, int) { 657 | var nonzero bool 658 | start, end, zeros := -1, -1, 0 659 | for i, c := range s { 660 | if start == -1 && isdigit(c) { 661 | if c == '0' { 662 | zeros++ 663 | } else { 664 | nonzero = true 665 | } 666 | start = i 667 | continue 668 | } 669 | if start > -1 && c >= '1' { 670 | nonzero = true 671 | } 672 | if !nonzero { 673 | zeros++ 674 | } 675 | if start > -1 && !isdigit(c) { 676 | end = i 677 | break 678 | } 679 | } 680 | if start > -1 && end == -1 { 681 | end = len(s) 682 | } 683 | return start, end, zeros 684 | } 685 | 686 | startA, endA, zeroA := getNum(a) 687 | if startA == -1 { 688 | return cmp.Compare(a, b) 689 | } 690 | startB, endB, zeroB := getNum(b) 691 | if startB == -1 { 692 | return cmp.Compare(a, b) 693 | } 694 | 695 | if zeroA != zeroB { 696 | return zeroB - zeroA 697 | } 698 | 699 | na, _ := strconv.ParseInt(a[startA:endA], 10, 64) 700 | nb, _ := strconv.ParseInt(b[startB:endB], 10, 64) 701 | 702 | return int(na - nb) 703 | } 704 | 705 | func isdigit(c rune) bool { return c >= '0' && c <= '9' } 706 | 707 | type fakeFileInfo struct{ fs.DirEntry } 708 | 709 | func (fakeFileInfo) ModTime() time.Time { return time.Time{} } 710 | func (fakeFileInfo) Sys() any { return nil } 711 | func (fakeFileInfo) Size() int64 { return -1 } 712 | func (f fakeFileInfo) Mode() fs.FileMode { return f.Type() } 713 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | "reflect" 11 | "runtime" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "zgo.at/elles/os2" 18 | ) 19 | 20 | // Includes tests converted from FreeBSD (commit 0dfd11abc) and GNU coreutils 21 | // (commit bbc972b). 22 | 23 | func TestGroupDigits(t *testing.T) { 24 | tests := []struct { 25 | in, want string 26 | }{ 27 | {"", ""}, 28 | {"1", "1"}, 29 | {"12", "12"}, 30 | {"123", "123"}, 31 | {"1234", "1,234"}, 32 | {"123456", "123,456"}, 33 | {"12345678", "12,345,678"}, 34 | 35 | {"123.0", "123.0"}, 36 | {"123.10", "123.10"}, 37 | {"1234.0", "1,234.0"}, 38 | {"1234.10", "1,234.10"}, 39 | {"123456.0", "123,456.0"}, 40 | {"123456.10", "123,456.10"}, 41 | {"12345678.0", "12,345,678.0"}, 42 | {"12345678.10", "12,345,678.10"}, 43 | 44 | {"102G", "102G"}, 45 | {"1024G", "1,024G"}, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.in, func(t *testing.T) { 49 | have := groupDigits(tt.in) 50 | if have != tt.want { 51 | t.Errorf("\nhave: %q\nwant: %q", have, tt.want) 52 | } 53 | }) 54 | } 55 | } 56 | 57 | func BenchmarkGroupDigits(b *testing.B) { 58 | var g any 59 | b.Run("no suffix", func(b *testing.B) { 60 | b.ReportAllocs() 61 | for n := 0; n < b.N; n++ { 62 | g = groupDigits("12345678.10") 63 | } 64 | }) 65 | b.Run("with suffix", func(b *testing.B) { 66 | b.ReportAllocs() 67 | for n := 0; n < b.N; n++ { 68 | g = groupDigits("12345678K") 69 | } 70 | }) 71 | _ = g 72 | } 73 | 74 | func TestJSON(t *testing.T) { 75 | if isCI() || runtime.GOOS == "windows" { 76 | t.Skip("TODO") 77 | } 78 | 79 | start(t) 80 | touch(t, "file1") 81 | touch(t, "file2") 82 | 83 | var have, want []map[string]any 84 | err := json.Unmarshal([]byte(mustRun(t, "-j")), &have) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | err = json.Unmarshal([]byte(` 89 | [{ 90 | "abs_dir": "/tmp/TestJSON3123184094/001", 91 | "dir": ".", 92 | "entries": [ 93 | { 94 | "access_time": "2024-06-10T01:39:35.284680724+01:00", 95 | "birth_time": "2024-06-10T01:39:35.284680724+01:00", 96 | "mod_time": "2024-06-10T01:39:35.284680724+01:00", 97 | "name": "file1", 98 | "permission": 420, 99 | "size": 0, 100 | "type": 0 101 | }, 102 | { 103 | "access_time": "2024-06-10T01:39:35.284680724+01:00", 104 | "birth_time": "2024-06-10T01:39:35.284680724+01:00", 105 | "mod_time": "2024-06-10T01:39:35.284680724+01:00", 106 | "name": "file2", 107 | "permission": 420, 108 | "size": 0, 109 | "type": 0 110 | } 111 | ] 112 | }]`), &want) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | for i := range have { 117 | have[i]["abs_dir"] = want[i]["abs_dir"] 118 | for j := range have[i]["entries"].([]any) { 119 | m := have[i]["entries"].([]any)[j].(map[string]any) 120 | m["access_time"] = want[i]["entries"].([]any)[j].(map[string]any)["access_time"] 121 | m["birth_time"] = want[i]["entries"].([]any)[j].(map[string]any)["birth_time"] 122 | m["mod_time"] = want[i]["entries"].([]any)[j].(map[string]any)["mod_time"] 123 | } 124 | } 125 | if !reflect.DeepEqual(have, want) { 126 | h, _ := json.MarshalIndent(have, "", " ") 127 | w, _ := json.MarshalIndent(want, "", " ") 128 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", h, w) 129 | } 130 | } 131 | 132 | func TestQuoteFlag(t *testing.T) { 133 | if runtime.GOOS == "windows" { 134 | // TODO: split to separate test. 135 | // Also: look up if quote characters are different on Windows. 136 | t.Skip("control characters aren't permitted in Windows") 137 | } 138 | 139 | start(t) 140 | for _, f := range []string{ 141 | "\x01", 142 | "\n'", 143 | "\"dbl\"", 144 | "$", 145 | "'quote'", 146 | "(paren)", 147 | "**", 148 | "1M", 149 | ">", 150 | "?", 151 | "Hello tab: \t lol", 152 | "[bracket]", 153 | "`", 154 | "bs \\", 155 | "file", 156 | "file with space", 157 | "€", 158 | "zwj: \u200d", 159 | "cancel tag: \U000e007f", 160 | } { 161 | touch(t, f) 162 | } 163 | 164 | { 165 | have := strings.Split(mustRun(t, "-1"), "\n") 166 | want := []string{ 167 | "$'\\x01'", 168 | "$'\\n''", 169 | "\"dbl\"", 170 | "$", 171 | "'quote'", 172 | "(paren)", 173 | "**", 174 | "1M", 175 | ">", 176 | "?", 177 | "Hello tab: $'\\t' lol", 178 | "[bracket]", 179 | "`", 180 | "bs \\", 181 | "cancel tag: $'\\U000e007f'", 182 | "file", 183 | "file with space", 184 | "zwj: $'\\u200d'", 185 | "€", 186 | } 187 | if !reflect.DeepEqual(have, want) { 188 | t.Errorf("\nhave: %s\nwant: %s", have, want) 189 | } 190 | } 191 | { 192 | have := strings.Split(mustRun(t, "-1Q"), "\n") 193 | want := []string{ 194 | `"\x01"`, 195 | `"\n'"`, 196 | `"\"dbl\""`, 197 | `"$"`, 198 | `"'quote'"`, 199 | `"(paren)"`, 200 | `"**"`, 201 | `1M`, 202 | `">"`, 203 | `"?"`, 204 | `"Hello tab: \t lol"`, 205 | `"[bracket]"`, 206 | "\"\\`\"", 207 | `"bs \"`, 208 | `"cancel tag: \U000e007f"`, 209 | `file`, 210 | `"file with space"`, 211 | `"zwj: \u200d"`, 212 | `€`, 213 | } 214 | if !reflect.DeepEqual(have, want) { 215 | t.Errorf("\nhave: %s\nwant: %s", have, want) 216 | } 217 | } 218 | { 219 | have := strings.Split(mustRun(t, "-1QQ"), "\n") 220 | want := []string{ 221 | `"\x01"`, 222 | `"\n'"`, 223 | `"\"dbl\""`, 224 | `"$"`, 225 | `"'quote'"`, 226 | `"(paren)"`, 227 | `"**"`, 228 | `"1M"`, 229 | `">"`, 230 | `"?"`, 231 | `"Hello tab: \t lol"`, 232 | `"[bracket]"`, 233 | "\"\\`\"", 234 | `"bs \"`, 235 | `"cancel tag: \U000e007f"`, 236 | `"file"`, 237 | `"file with space"`, 238 | `"zwj: \u200d"`, 239 | `"€"`, 240 | } 241 | if !reflect.DeepEqual(have, want) { 242 | t.Errorf("\nhave: %s\nwant: %s", have, want) 243 | } 244 | } 245 | } 246 | 247 | func TestLong(t *testing.T) { 248 | supportsUtimes(t, true) 249 | 250 | start(t) 251 | echoTrunc(t, strings.Repeat("x", 9999), "file") 252 | echoTrunc(t, strings.Repeat("x", 1024*1024+6), "1M") 253 | touch(t, "dir") 254 | symlink(t, "file", "ln-file") 255 | symlink(t, "dir", "ln-dir") 256 | now := time.Now() 257 | 258 | t.Run("default", func(t *testing.T) { 259 | have := mustRun(t, "-l") 260 | want := norm(` 261 | 1.0M │ 15:04 │ 1M 262 | 0 │ 15:04 │ dir 263 | 9.8K │ 15:04 │ file 264 | 3 │ 15:04 │ ln-dir → dir 265 | 4 │ 15:04 │ ln-file → file`, 266 | "15:04", now.Format("15:04")) 267 | if have != want { 268 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 269 | } 270 | }) 271 | t.Run("-T", func(t *testing.T) { 272 | have := mustRun(t, "-lT") 273 | want := norm(` 274 | 1.0M │ 2006-01-02 15:04:05 │ 1M 275 | 0 │ 2006-01-02 15:04:05 │ dir 276 | 9.8K │ 2006-01-02 15:04:05 │ file 277 | 3 │ 2006-01-02 15:04:05 │ ln-dir → dir 278 | 4 │ 2006-01-02 15:04:05 │ ln-file → file`, 279 | "2006-01-02 15:04:05", now.Format("2006-01-02 15:04:05")) 280 | if have != want { 281 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 282 | } 283 | }) 284 | t.Run("-TT", func(t *testing.T) { 285 | supportsUtimes(t, true) 286 | 287 | tt := time.Date(1985, 6, 16, 12, 13, 14, 15, time.Local) 288 | for _, f := range []string{"1M", "dir", "file", "ln-dir", "ln-file"} { 289 | if err := os2.Utimes(f, time.Time{}, tt); err != nil { 290 | t.Fatal(err) 291 | } 292 | } 293 | 294 | have := mustRun(t, "-lTT") 295 | want := norm(` 296 | 1.0M │ 2006-01-02 15:04:05.000000000 -07:00 │ 1M 297 | 0 │ 2006-01-02 15:04:05.000000000 -07:00 │ dir 298 | 9.8K │ 2006-01-02 15:04:05.000000000 -07:00 │ file 299 | 3 │ 2006-01-02 15:04:05.000000000 -07:00 │ ln-dir → dir 300 | 4 │ 2006-01-02 15:04:05.000000000 -07:00 │ ln-file → file`, 301 | "2006-01-02 15:04:05.000000000 -07:00", tt.Format("2006-01-02 15:04:05.000000000 -07:00")) 302 | if have != want { 303 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 304 | } 305 | }) 306 | } 307 | 308 | func TestLongLong(t *testing.T) { 309 | if runtime.GOOS == "windows" { 310 | t.Skip("TODO") 311 | } 312 | start(t) 313 | now := time.Now() 314 | 315 | echoTrunc(t, strings.Repeat("x", 9999), "file") 316 | echoTrunc(t, strings.Repeat("x", 1024*1024+6), "1M") 317 | touch(t, "dir") 318 | symlink(t, "file", "ln-file") 319 | symlink(t, "dir", "ln-dir") 320 | for _, f := range []string{"file", "1M", "dir", "ln-file", "ln-dir"} { 321 | os.Lchown(f, userinfo.UID, userinfo.GID) 322 | } 323 | 324 | // Permissions are different on Linux and BSD :-/ Can lchown() them on BSD, 325 | // but Go doesn't expose that. 326 | lnkprm, lnkprmO := "lrwxr-xr-x", " 755" 327 | switch runtime.GOOS { 328 | case "linux", "illumos", "solaris": 329 | lnkprm, lnkprmO = "lrwxrwxrwx", " 777" 330 | } 331 | 332 | t.Run("default", func(t *testing.T) { 333 | have := mustRun(t, "-llg") 334 | want := norm(` 335 | -rw-r--r-- martin tournoij 1.0M Jan _2 15:04 │ 1M 336 | -rw-r--r-- martin tournoij 0 Jan _2 15:04 │ dir 337 | -rw-r--r-- martin tournoij 9.8K Jan _2 15:04 │ file 338 | `+lnkprm+` martin tournoij 3 Jan _2 15:04 │ ln-dir → dir 339 | `+lnkprm+` martin tournoij 4 Jan _2 15:04 │ ln-file → file`, 340 | "Jan _2 15:04", now.Format("Jan _2 15:04")) 341 | if have != want { 342 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 343 | } 344 | }) 345 | 346 | t.Run("-T", func(t *testing.T) { 347 | have := mustRun(t, "-llgT") 348 | want := norm(` 349 | -rw-r--r-- martin tournoij 1.0M 2006-01-02 15:04:05 │ 1M 350 | -rw-r--r-- martin tournoij 0 2006-01-02 15:04:05 │ dir 351 | -rw-r--r-- martin tournoij 9.8K 2006-01-02 15:04:05 │ file 352 | `+lnkprm+` martin tournoij 3 2006-01-02 15:04:05 │ ln-dir → dir 353 | `+lnkprm+` martin tournoij 4 2006-01-02 15:04:05 │ ln-file → file`, 354 | "2006-01-02 15:04:05", now.Format("2006-01-02 15:04:05")) 355 | if have != want { 356 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 357 | } 358 | }) 359 | 360 | t.Run("-n", func(t *testing.T) { 361 | have := mustRun(t, "-llgn") 362 | want := norm(` 363 | -rw-r--r-- XXXX YYYY 1.0M Jan _2 15:04 │ 1M 364 | -rw-r--r-- XXXX YYYY 0 Jan _2 15:04 │ dir 365 | -rw-r--r-- XXXX YYYY 9.8K Jan _2 15:04 │ file 366 | `+lnkprm+` XXXX YYYY 3 Jan _2 15:04 │ ln-dir → dir 367 | `+lnkprm+` XXXX YYYY 4 Jan _2 15:04 │ ln-file → file`, 368 | "Jan _2 15:04", now.Format("Jan _2 15:04"), 369 | "XXXX", userinfo.UserID, 370 | "YYYY", userinfo.GroupID) 371 | if have != want { 372 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 373 | } 374 | }) 375 | 376 | t.Run("-o", func(t *testing.T) { 377 | have := mustRun(t, "-llgo") 378 | want := norm(` 379 | 644 martin tournoij 1.0M Jan _2 15:04 │ 1M 380 | 644 martin tournoij 0 Jan _2 15:04 │ dir 381 | 644 martin tournoij 9.8K Jan _2 15:04 │ file 382 | `+lnkprmO+` martin tournoij 3 Jan _2 15:04 │ ln-dir → dir 383 | `+lnkprmO+` martin tournoij 4 Jan _2 15:04 │ ln-file → file`, 384 | "Jan _2 15:04", now.Format("Jan _2 15:04")) 385 | if have != want { 386 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 387 | } 388 | }) 389 | } 390 | 391 | func TestLongSizeAlignment(t *testing.T) { 392 | supportsSparseFiles(t, true) 393 | start(t) 394 | 395 | createSparse(t, 0, "small") 396 | createSparse(t, 123456, "large") 397 | echoAppend(t, "\n", "alloc") 398 | 399 | x := func(in string) string { 400 | out := make([]string, 0, 8) 401 | for _, l := range strings.Split(in, "\n") { 402 | x := strings.Split(l, "│") 403 | if len(x) < 3 { 404 | t.Errorf("unexpected:\n%q", l) 405 | } 406 | out = append(out, fmt.Sprintf("%s│%s", x[0], x[2])) 407 | } 408 | return strings.Join(out, "\n") 409 | } 410 | 411 | { 412 | have := x(mustRun(t, "-l")) 413 | want := strings.ReplaceAll(` 414 | 1 │ alloc 415 | 121K │ large 416 | 0 │ small`[1:], "\t", "") 417 | if have != want { 418 | t.Errorf("\nhave:\n%s\nwant:\n%s", have, want) 419 | } 420 | } 421 | 422 | { 423 | have := x(mustRun(t, "-l", "-Bs")) 424 | want := strings.ReplaceAll(` 425 | 8 │ alloc 426 | 0 │ large 427 | 0 │ small`[1:], "\t", "") 428 | if have != want { 429 | t.Errorf("\nhave:\n%s\nwant:\n%s", have, want) 430 | } 431 | } 432 | 433 | { 434 | have := x(mustRun(t, "-l", "-BS")) 435 | want := strings.ReplaceAll(` 436 | 1 │ alloc 437 | 31 │ large 438 | 0 │ small`[1:], "\t", "") 439 | if have != want { 440 | t.Errorf("\nhave:\n%s\nwant:\n%s", have, want) 441 | } 442 | } 443 | } 444 | 445 | func TestSortMtime(t *testing.T) { 446 | start(t) 447 | touch(t, "a") 448 | time.Sleep(10 * time.Millisecond) // If we don't sleep it both are written at the same time. 449 | touch(t, "b") 450 | 451 | { 452 | have := strings.Split(mustRun(t, "-1t"), "\n") 453 | want := []string{"b", "a"} 454 | if !reflect.DeepEqual(have, want) { 455 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 456 | } 457 | } 458 | 459 | rm(t, "a") 460 | touch(t, "a") 461 | 462 | { 463 | have := strings.Split(mustRun(t, "-1t"), "\n") 464 | want := []string{"a", "b"} 465 | if !reflect.DeepEqual(have, want) { 466 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 467 | } 468 | } 469 | } 470 | 471 | func TestSortAtime(t *testing.T) { 472 | if runtime.GOOS == "darwin" { 473 | t.Skip("TODO: dunno why this fails; atime seems weird on macOS") 474 | } 475 | if runtime.GOOS == "windows" { 476 | t.Skip("TODO: fix") 477 | } 478 | 479 | start(t) 480 | touch(t, "a") 481 | time.Sleep(10 * time.Millisecond) // If we don't sleep it both are written at the same time. 482 | touch(t, "b") 483 | 484 | { 485 | have := strings.Split(mustRun(t, "-1tu"), "\n") 486 | want := []string{"b", "a"} 487 | if !reflect.DeepEqual(have, want) { 488 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 489 | } 490 | } 491 | 492 | if _, err := os.ReadFile("a"); err != nil { // cat a.file 493 | t.Fatal(err) 494 | } 495 | time.Sleep(10 * time.Millisecond) 496 | echoAppend(t, "i am a", "b") 497 | 498 | { 499 | have := strings.Split(mustRun(t, "-1tu"), "\n") 500 | want := []string{"a", "b"} 501 | if !reflect.DeepEqual(have, want) { 502 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 503 | } 504 | } 505 | } 506 | 507 | func TestSortBtime(t *testing.T) { 508 | supportsBtime(t, true) 509 | 510 | start(t) 511 | for _, f := range []string{"z", "a", "b"} { 512 | touch(t, f) 513 | time.Sleep(10 * time.Millisecond) 514 | } 515 | 516 | { 517 | have := strings.Fields(mustRun(t, "-tc")) 518 | want := []string{"b", "a", "z"} 519 | if !reflect.DeepEqual(have, want) { 520 | t.Errorf("\nhave: %s\nwant: %s", have, want) 521 | } 522 | } 523 | { 524 | echoTrunc(t, "a", "a") // Shouldn't affect anything. 525 | have := strings.Fields(mustRun(t, "-tcr")) 526 | want := []string{"z", "a", "b"} 527 | if !reflect.DeepEqual(have, want) { 528 | t.Errorf("\nhave: %s\nwant: %s", have, want) 529 | } 530 | } 531 | } 532 | 533 | // Name is used as secondary key when sorting on time 534 | func TestSortTimeName(t *testing.T) { 535 | supportsUtimes(t, true) 536 | start(t) 537 | 538 | tt := time.Date(1998, 1, 15, 0, 0, 0, 0, time.Local) 539 | touchDate(t, tt, "c") 540 | touchDate(t, tt, "a") 541 | touchDate(t, tt, "b") 542 | 543 | { 544 | have := strings.Fields(mustRun(t, "-t")) 545 | if want := []string{"a", "b", "c"}; !reflect.DeepEqual(have, want) { 546 | t.Errorf("\nhave: %s\nwant: %s", have, want) 547 | } 548 | } 549 | { 550 | have := strings.Fields(mustRun(t, "-rt")) 551 | if want := []string{"c", "b", "a"}; !reflect.DeepEqual(have, want) { 552 | t.Errorf("\nhave: %s\nwant: %s", have, want) 553 | } 554 | } 555 | } 556 | 557 | func TestSortSize(t *testing.T) { 558 | start(t) 559 | 560 | createSparse(t, 0, "aaa") 561 | createSparse(t, 1, "bbb") 562 | createSparse(t, 1, "qqq") 563 | createSparse(t, 0, "yyy") 564 | createSparse(t, 2, "zzz") 565 | 566 | have := strings.Split(mustRun(t, "-1S"), "\n") 567 | want := strings.Fields(`zzz bbb qqq aaa yyy`) 568 | if !reflect.DeepEqual(have, want) { 569 | t.Errorf("\nhave: %s\nwant: %s", have, want) 570 | } 571 | } 572 | 573 | func TestSortExt(t *testing.T) { 574 | start(t) 575 | for _, f := range []string{"none", "file.txt", "zz.png", "img.png", "a.png"} { 576 | touch(t, f) 577 | } 578 | 579 | { 580 | have := strings.Fields(mustRun(t, "-X")) 581 | want := []string{"none", "a.png", "img.png", "zz.png", "file.txt"} 582 | if !reflect.DeepEqual(have, want) { 583 | t.Errorf("\nhave: %s\nwant: %s", have, want) 584 | } 585 | } 586 | { 587 | have := strings.Fields(mustRun(t, "-Xr")) 588 | want := []string{"file.txt", "zz.png", "img.png", "a.png", "none"} 589 | if !reflect.DeepEqual(have, want) { 590 | t.Errorf("\nhave: %s\nwant: %s", have, want) 591 | } 592 | } 593 | } 594 | 595 | func TestSortVersion(t *testing.T) { 596 | tests := [][]string{ 597 | {}, 598 | {"0"}, 599 | {"0", "1"}, 600 | {"00", "02", "10"}, 601 | {"0", "2", "10"}, 602 | {"a", "z"}, 603 | {"a2", "z100"}, 604 | {"2b", "100a"}, 605 | 606 | //{"000", "00", "01", "010", "09", "0", "1", "9", "10"}, 607 | } 608 | for _, tt := range tests { 609 | t.Run("", func(t *testing.T) { 610 | start(t) 611 | for _, f := range tt { 612 | touch(t, f) 613 | } 614 | 615 | have := strings.Fields(mustRun(t, "-v")) 616 | if !reflect.DeepEqual(have, tt) { 617 | t.Errorf("\nhave: %s\nwant: %s", have, tt) 618 | } 619 | }) 620 | } 621 | } 622 | 623 | func TestSortWidth(t *testing.T) { 624 | if runtime.GOOS == "windows" { 625 | t.Skip() // Doesn't like the \n 626 | } 627 | start(t) 628 | 629 | mkdirAll(t, "subdir") 630 | touch(t, "subdir/aaaaa") 631 | touch(t, "subdir/bbb") 632 | touch(t, "subdir/cccc") 633 | touch(t, "subdir/d") 634 | touch(t, "subdir/zz") 635 | touch(t, "subdir/€") 636 | touch(t, "subdir/a\nb") 637 | 638 | have := strings.Fields(mustRun(t, "-W", "subdir")) 639 | want := []string{"d", "€", "zz", "a$'\\n'b", "bbb", "cccc", "aaaaa"} 640 | if !reflect.DeepEqual(have, want) { 641 | t.Errorf("\nhave: %s\nwant: %s", have, want) 642 | } 643 | } 644 | 645 | func TestPathNames(t *testing.T) { 646 | start(t) 647 | mkdirAll(t, "dir-one") 648 | mkdirAll(t, "dir-two") 649 | touch(t, "file") 650 | touch(t, "dir-one/file1") 651 | touch(t, "dir-two/file2") 652 | 653 | have := strings.Fields(mustRun(t, "dir-one/file1", "dir-two/file2", "file")) 654 | want := []string{"file", "dir-one/file1", "dir-two/file2"} 655 | if !reflect.DeepEqual(have, want) { 656 | t.Errorf("\nhave: %s\nwant: %s", have, want) 657 | } 658 | } 659 | 660 | func TestInode(t *testing.T) { 661 | supportsUtimes(t, true) 662 | 663 | if runtime.GOOS == "netbsd" && isCI() { 664 | t.Skip("dirsize") 665 | } 666 | 667 | start(t) 668 | touch(t, "file") 669 | mkdirAll(t, "dir") 670 | 671 | tt := time.Date(2023, 6, 11, 15, 05, 0, 0, time.Local) 672 | inodes := make([]string, 0, 2) 673 | for _, f := range []string{"dir", "file"} { 674 | st, err := os.Stat(f) 675 | if err != nil { 676 | t.Fatal(err) 677 | } 678 | if err := os2.Utimes(f, tt, tt); err != nil { 679 | t.Fatal(err) 680 | } 681 | os.Lchown(f, userinfo.UID, userinfo.GID) 682 | inodes = append(inodes, fmt.Sprintf("%d", os2.Serial(".", st))) 683 | } 684 | 685 | { 686 | have := strings.Fields(mustRun(t, "-iC")) 687 | want := []string{inodes[0], "dir", inodes[1], "file"} 688 | if !reflect.DeepEqual(have, want) { 689 | t.Errorf("\nhave:\n%s\nwant:\n%s", have, want) 690 | } 691 | } 692 | 693 | if len(inodes[0]) > len(inodes[1]) { 694 | inodes[1] = strings.Repeat(" ", len(inodes[0])-len(inodes[1])) + inodes[1] 695 | } else { 696 | inodes[0] = strings.Repeat(" ", len(inodes[1])-len(inodes[0])) + inodes[0] 697 | } 698 | 699 | { 700 | have := mustRun(t, "-gliBS") 701 | want := norm(` 702 | XXX │ 1 │ 2023-06-11 │ dir 703 | YYY │ 0 │ 2023-06-11 │ file`, 704 | "XXX", inodes[0], 705 | "YYY", inodes[1], 706 | ) 707 | if !reflect.DeepEqual(have, want) { 708 | t.Errorf("\nhave:\n%s\nwant:\n%s", have, want) 709 | } 710 | } 711 | 712 | { 713 | have := mustRun(t, "-glliBS") 714 | want := norm(` 715 | XXX drwxr-xr-x martin tournoij 1 Jun 11 15:05 │ dir 716 | YYY -rw-r--r-- martin tournoij 0 Jun 11 15:05 │ file`, 717 | "XXX", inodes[0], 718 | "YYY", inodes[1]) 719 | if !reflect.DeepEqual(have, want) { 720 | t.Errorf("\nhave:\n%s\nwant:\n%s", have, want) 721 | } 722 | } 723 | } 724 | 725 | func TestAllFlag(t *testing.T) { 726 | start(t) 727 | 728 | // No files to report. 729 | if have := mustRun(t, "-a"); have != "" { 730 | t.Fatalf("%q", have) 731 | } 732 | mkdirAll(t, "d") 733 | if have := mustRun(t, "-a", "d"); have != "" { 734 | t.Fatalf("%q", have) 735 | } 736 | 737 | touch(t, ".file") 738 | mkdirAll(t, ".dir") 739 | touch(t, "d/.file2") 740 | mkdirAll(t, "d/.dir2") 741 | 742 | { 743 | have := strings.Fields(mustRun(t, "-a")) 744 | want := []string{".dir", ".file", "d"} 745 | if !reflect.DeepEqual(have, want) { 746 | t.Fatalf("\nhave: %s\nwant: %s", have, want) 747 | } 748 | } 749 | 750 | { 751 | have := strings.Fields(mustRun(t, "-a", "d")) 752 | want := []string{".dir2", ".file2"} 753 | if !reflect.DeepEqual(have, want) { 754 | t.Fatalf("\nhave: %s\nwant: %s", have, want) 755 | } 756 | } 757 | } 758 | 759 | func TestClassifyFlag(t *testing.T) { 760 | start(t) 761 | 762 | check := func(want ...string) { 763 | t.Helper() 764 | if runtime.GOOS == "windows" { // No executable files on Windows. 765 | for i := range want { 766 | want[i] = strings.TrimSuffix(want[i], "*") 767 | } 768 | } 769 | 770 | wantNoF := make([]string, len(want)) 771 | for i := range want { 772 | wantNoF[i] = strings.TrimRight(want[i], `*/@|=`) 773 | } 774 | wantP := make([]string, len(want)) 775 | for i := range want { 776 | wantP[i] = strings.TrimRight(want[i], `*@|=`) 777 | } 778 | 779 | if have := strings.Split(mustRun(t, "-1F"), "\n"); !reflect.DeepEqual(have, want) { 780 | t.Errorf("-1F:\nhave: %s\nwant: %s", have, want) 781 | } 782 | if have := strings.Split(mustRun(t, "-1p"), "\n"); !reflect.DeepEqual(have, wantP) { 783 | t.Errorf("-1p:\nhave: %s\nwant: %s", have, wantP) 784 | } 785 | if have := strings.Split(mustRun(t, "-1"), "\n"); !reflect.DeepEqual(have, wantNoF) { 786 | t.Errorf("-1\nhave: %s\nwant: %s", have, wantNoF) 787 | } 788 | } 789 | 790 | mkdirAll(t, "dir") 791 | touch(t, "regular") 792 | touch(t, "executable") 793 | chmod(t, 0o555, "executable") 794 | symlink(t, "regular", "slink-reg") 795 | symlink(t, "dir", "slink-dir") 796 | symlink(t, "nowhere", "slink-dangle") 797 | 798 | check("dir/", 799 | "executable*", 800 | "regular", 801 | "slink-dangle@", 802 | "slink-dir@", 803 | "slink-reg@") 804 | 805 | t.Run("fifo", func(t *testing.T) { 806 | supportsFIFO(t, true) 807 | l, err := net.Listen("unix", "socket") 808 | if err != nil { 809 | t.Fatal(err) 810 | } 811 | defer l.Close() 812 | 813 | mkfifo(t, "fifo") 814 | check("dir/", 815 | "executable*", 816 | "fifo|", 817 | "regular", 818 | "slink-dangle@", 819 | "slink-dir@", 820 | "slink-reg@", 821 | "socket=") 822 | }) 823 | 824 | t.Run("device nodes", func(t *testing.T) { 825 | supportsDevice(t, true) 826 | mknod(t, 20, "block") 827 | mknod(t, 10, "char") 828 | 829 | check("block", 830 | "char", 831 | "dir/", 832 | "executable*", 833 | "fifo|", 834 | "regular", 835 | "slink-dangle@", 836 | "slink-dir@", 837 | "slink-reg@") 838 | }) 839 | } 840 | 841 | func TestInodeFlag(t *testing.T) { 842 | start(t) 843 | 844 | touch(t, "file1") 845 | touch(t, "dir1") 846 | symlink(t, "file1", "link1") 847 | symlink(t, "nowhere", "link2") 848 | if supportsFIFO(t, false) { 849 | mkfifo(t, "fifo") 850 | l, err := net.Listen("unix", "socket") 851 | if err != nil { 852 | t.Fatal(err) 853 | } 854 | defer l.Close() 855 | } 856 | if supportsDevice(t, false) { 857 | mknod(t, 10, "device") 858 | } 859 | 860 | ls, err := os.ReadDir(".") 861 | if err != nil { 862 | t.Fatal(err) 863 | } 864 | var want []string 865 | for _, f := range ls { 866 | fi, err := f.Info() 867 | if err != nil { 868 | t.Fatal(err) 869 | } 870 | want = append(want, fmt.Sprintf("%d %s", os2.Serial(".", fi), mustRun(t, "-1d", f.Name()))) 871 | } 872 | 873 | have := strings.Split(mustRun(t, "-1ai"), "\n") 874 | for i := range have { 875 | have[i] = strings.TrimLeft(have[i], " ") 876 | } 877 | if !reflect.DeepEqual(have, want) { 878 | t.Errorf("\nhave:\n%s\n\nwant:\n%s\n\nhave: %[1]q\nwant: %[2]q", have, want) 879 | } 880 | 881 | t.Run("symlinks", func(t *testing.T) { 882 | if runtime.GOOS == "windows" { 883 | t.Skip("inodes for links are the same on Windows") 884 | } 885 | mkdirAll(t, "links") 886 | cd(t, "links") 887 | touch(t, "f") 888 | symlink(t, "f", "slink") 889 | 890 | // When listed explicitly: 891 | have := strings.Fields(mustRun(t, "-i")) 892 | if len(have) != 4 { 893 | t.Fatalf("len %d", len(have)) 894 | } 895 | // The inode numbers should differ. 896 | if have[0] == have[2] { 897 | t.Fatalf("%q == %q", have[0], have[2]) 898 | } 899 | 900 | // With -H, they must be the same, but only when explicitly listed. 901 | have = strings.Fields(mustRun(t, "-iH")) 902 | if have[0] == have[2] { 903 | t.Fatalf("%q != %q", have[0], have[2]) 904 | } 905 | have = strings.Fields(mustRun(t, "-iH", "f", "slink")) 906 | if have[0] != have[2] { 907 | t.Fatalf("%q != %q", have[0], have[2]) 908 | } 909 | }) 910 | } 911 | 912 | func TestHFlag(t *testing.T) { 913 | start(t) 914 | 915 | mkdirAll(t, "dir") 916 | touch(t, "dir/file") 917 | symlink(t, "dir", "link-dir") 918 | symlink(t, "orphan", "link-orphan") 919 | 920 | if have := mustRun(t, "-1", "link-dir"); have != "link-dir" { 921 | t.Fatal(have) 922 | } 923 | if have := mustRun(t, "-H1", "link-dir"); have != "file" { 924 | t.Fatal(have) 925 | } 926 | 927 | if have := mustRun(t, "-1", "link-orphan"); have != "link-orphan" { 928 | t.Fatal(have) 929 | } 930 | if have, ok := run(t, "-H1", "link-orphan"); ok { 931 | t.Fatal(have) 932 | } 933 | } 934 | 935 | func TestLFlag(t *testing.T) { 936 | if runtime.GOOS == "windows" { 937 | t.Skip() 938 | } 939 | 940 | testFixedSizeWidth = true 941 | defer func() { testFixedSizeWidth = false }() 942 | tmp := start(t) 943 | 944 | now := time.Now().Format("15:04") 945 | mkdirAll(t, "dir") 946 | touch(t, "dir/file") 947 | echoTrunc(t, strings.Repeat("a", 6666), "file-1") 948 | touch(t, "file-2") 949 | symlink(t, "file-1", "link-file-1") 950 | symlink(t, "file-2", "link-file-2") 951 | symlink(t, "dir", "link-dir") 952 | st, err := os.Stat("dir") 953 | if err != nil { 954 | t.Fatal(err) 955 | } 956 | dsz, _ := listSize(st, "", "", false) 957 | repl := []string{"21:35", now, "TMPDIR", tmp, "DIRSZ", fmt.Sprintf("%5s", dsz)} 958 | 959 | { 960 | have := mustRun(t, "-CL") 961 | want := "dir file-1 file-2 link-dir link-file-1 link-file-2" 962 | if have != want { 963 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 964 | } 965 | } 966 | 967 | { 968 | have := mustRun(t, "-lL") 969 | want := norm(` 970 | DIRSZ │ 21:35 │ dir 971 | 6.5K │ 21:35 │ file-1 972 | 0 │ 21:35 │ file-2 973 | DIRSZ │ 21:35 │ link-dir 974 | 6.5K │ 21:35 │ link-file-1 975 | 0 │ 21:35 │ link-file-2`, 976 | repl...) 977 | if have != want { 978 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 979 | } 980 | } 981 | 982 | { // Make sure we sort by correct size 983 | have := mustRun(t, "-lLS") 984 | want := norm(` 985 | 6.5K │ 21:35 │ file-1 986 | 6.5K │ 21:35 │ link-file-1 987 | DIRSZ │ 21:35 │ dir 988 | DIRSZ │ 21:35 │ link-dir 989 | 0 │ 21:35 │ file-2 990 | 0 │ 21:35 │ link-file-2`, 991 | repl...) 992 | if have != want { 993 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 994 | } 995 | } 996 | { // And width 997 | have := mustRun(t, "-lLW") 998 | want := norm(` 999 | DIRSZ │ 21:35 │ dir 1000 | 6.5K │ 21:35 │ file-1 1001 | 0 │ 21:35 │ file-2 1002 | DIRSZ │ 21:35 │ link-dir 1003 | 6.5K │ 21:35 │ link-file-1 1004 | 0 │ 21:35 │ link-file-2`, 1005 | repl...) 1006 | if have != want { 1007 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1008 | } 1009 | } 1010 | 1011 | t.Run("orphan", func(t *testing.T) { 1012 | symlink(t, "orphan", "link-orphan") 1013 | defer rm(t, "link-orphan") 1014 | 1015 | { 1016 | have := mustRun(t, "-CL") 1017 | want := "dir file-1 file-2 link-dir link-file-1 link-file-2 link-orphan" 1018 | if have != want { 1019 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1020 | } 1021 | } 1022 | { 1023 | have, ok := run(t, "-CFL") 1024 | if ok { 1025 | t.Error("exit 0") 1026 | } 1027 | want := norm(` 1028 | dir/ file-1 file-2 link-dir/ link-file-1 link-file-2 link-orphan 1029 | elles: stat TMPDIR/link-orphan: no such file or directory`, 1030 | repl...) 1031 | if have != want { 1032 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1033 | } 1034 | } 1035 | 1036 | { 1037 | have, ok := run(t, "-lL") 1038 | if ok { 1039 | t.Error("exit 0") 1040 | } 1041 | want := norm(` 1042 | DIRSZ │ 21:35 │ dir 1043 | 6.5K │ 21:35 │ file-1 1044 | 0 │ 21:35 │ file-2 1045 | DIRSZ │ 21:35 │ link-dir 1046 | 6.5K │ 21:35 │ link-file-1 1047 | 0 │ 21:35 │ link-file-2 1048 | ??? │ ????-??-?? │ link-orphan 1049 | elles: stat TMPDIR/link-orphan: no such file or directory`, 1050 | repl...) 1051 | if have != want { 1052 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1053 | } 1054 | } 1055 | }) 1056 | 1057 | t.Run("loop", func(t *testing.T) { 1058 | msg := `too many levels of symbolic links` 1059 | if runtime.GOOS == "solaris" || runtime.GOOS == "illumos" { 1060 | msg = `number of symbolic links encountered during path name traversal exceeds MAXSYMLINKS` 1061 | } 1062 | 1063 | symlink(t, "link-loop", "link-loop") 1064 | defer rm(t, "link-loop") 1065 | 1066 | { 1067 | have := mustRun(t, "-CL") 1068 | want := "dir file-1 file-2 link-dir link-file-1 link-file-2 link-loop" 1069 | if have != want { 1070 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1071 | } 1072 | } 1073 | { 1074 | have, ok := run(t, "-CFL") 1075 | if ok { 1076 | t.Error("exit 0") 1077 | } 1078 | want := norm(` 1079 | dir/ file-1 file-2 link-dir/ link-file-1 link-file-2 link-loop 1080 | elles: stat TMPDIR/link-loop: ERRMSG`, 1081 | append([]string{"ERRMSG", msg}, repl...)...) 1082 | if have != want { 1083 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1084 | } 1085 | } 1086 | 1087 | { 1088 | have, ok := run(t, "-lL") 1089 | if ok { 1090 | t.Error("exit 0") 1091 | } 1092 | want := norm(` 1093 | DIRSZ │ 21:35 │ dir 1094 | 6.5K │ 21:35 │ file-1 1095 | 0 │ 21:35 │ file-2 1096 | DIRSZ │ 21:35 │ link-dir 1097 | 6.5K │ 21:35 │ link-file-1 1098 | 0 │ 21:35 │ link-file-2 1099 | ??? │ ????-??-?? │ link-loop 1100 | elles: stat TMPDIR/link-loop: ERRMSG`, 1101 | append([]string{"ERRMSG", msg}, repl...)...) 1102 | if have != want { 1103 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1104 | } 1105 | } 1106 | }) 1107 | } 1108 | 1109 | func TestFilesizes(t *testing.T) { 1110 | var ( 1111 | kb = int64(1024) 1112 | mb = 1024 * kb 1113 | gb = 1024 * mb 1114 | tb = 1024 * gb 1115 | ) 1116 | 1117 | start(t) 1118 | supportsSparseFiles(t, true) 1119 | 1120 | for _, sz := range []int64{1, 512, 2 * kb, 10 * kb, 512 * kb, mb, gb, tb} { 1121 | createSparse(t, sz, fmt.Sprintf("%d.file", sz)) 1122 | } 1123 | 1124 | run := func(flags ...string) string { 1125 | var h []string 1126 | for _, line := range strings.Split(mustRun(t, flags...), "\n") { 1127 | x := strings.Split(line, "│") 1128 | h = append(h, fmt.Sprintf("%s│%s", x[0], x[2])) 1129 | } 1130 | return strings.Join(h, "\n") 1131 | } 1132 | { 1133 | have := run("-l") 1134 | want := norm(` 1135 | 1 │ 1.file 1136 | 10.0K │ 10240.file 1137 | 1.0M │ 1048576.file 1138 | 1.0G │ 1073741824.file 1139 | 1024G │ 1099511627776.file 1140 | 2.0K │ 2048.file 1141 | 512 │ 512.file 1142 | 512K │ 524288.file`) 1143 | if have != want { 1144 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1145 | } 1146 | } 1147 | 1148 | { 1149 | have := run("-l", "-B", "1") 1150 | want := norm(` 1151 | 1 │ 1.file 1152 | 10240 │ 10240.file 1153 | 1048576 │ 1048576.file 1154 | 1073741824 │ 1073741824.file 1155 | 1099511627776 │ 1099511627776.file 1156 | 2048 │ 2048.file 1157 | 512 │ 512.file 1158 | 524288 │ 524288.file`) 1159 | if have != want { 1160 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1161 | } 1162 | } 1163 | 1164 | { 1165 | have := run("-l,", "-B", "1") 1166 | want := norm(` 1167 | 1 │ 1.file 1168 | 10,240 │ 10240.file 1169 | 1,048,576 │ 1048576.file 1170 | 1,073,741,824 │ 1073741824.file 1171 | 1,099,511,627,776 │ 1099511627776.file 1172 | 2,048 │ 2048.file 1173 | 512 │ 512.file 1174 | 524,288 │ 524288.file`) 1175 | if have != want { 1176 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1177 | } 1178 | } 1179 | } 1180 | 1181 | func TestTrim(t *testing.T) { 1182 | defer func() { columns = 80 }() 1183 | 1184 | start(t) 1185 | 1186 | long := strings.Repeat("0123456789", 10) 1187 | now := time.Now().Format("15:04") 1188 | touch(t, "0123456789") 1189 | touch(t, long) 1190 | 1191 | { 1192 | columns = 10 1193 | have := strings.Fields(mustRun(t, "-1", "-trim")) 1194 | want := []string{"0123456789", "012345678…"} 1195 | if !reflect.DeepEqual(have, want) { 1196 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1197 | } 1198 | } 1199 | { 1200 | columns = 10 1201 | have := strings.Fields(mustRun(t, "-C", "-trim")) 1202 | want := []string{"0123456789", "012345678…"} 1203 | if !reflect.DeepEqual(have, want) { 1204 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1205 | } 1206 | } 1207 | { 1208 | columns = 10 1209 | have := strings.Fields(mustRun(t, "-C", "-trim")) 1210 | want := []string{"0123456789", "012345678…"} 1211 | if !reflect.DeepEqual(have, want) { 1212 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1213 | } 1214 | } 1215 | { 1216 | columns = 20 1217 | have := mustRun(t, "-l", "-trim") 1218 | want := norm(` 1219 | 0 │ 01:08 │ 012345… 1220 | 0 │ 01:08 │ 012345…`, "01:08", now) 1221 | if !reflect.DeepEqual(have, want) { 1222 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1223 | } 1224 | } 1225 | 1226 | { 1227 | columns = 100 1228 | have := strings.Fields(mustRun(t, "-1", "-trim")) 1229 | want := []string{"0123456789", long} 1230 | if !reflect.DeepEqual(have, want) { 1231 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1232 | } 1233 | } 1234 | { 1235 | columns = 100 1236 | have := strings.Fields(mustRun(t, "-C", "-trim")) 1237 | want := []string{"0123456789", long} 1238 | if !reflect.DeepEqual(have, want) { 1239 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1240 | } 1241 | } 1242 | } 1243 | 1244 | func TestWidth(t *testing.T) { 1245 | defer func() { columns = 80 }() 1246 | start(t) 1247 | 1248 | long := strings.Repeat("0123456789", 10) 1249 | now := time.Now().Format("15:04") 1250 | touch(t, "0123456789") 1251 | touch(t, long) 1252 | 1253 | { 1254 | have := mustRun(t, "-1w10") 1255 | want := "0123456789\n012345678…" 1256 | if !reflect.DeepEqual(have, want) { 1257 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1258 | } 1259 | } 1260 | { 1261 | have := mustRun(t, "-Cw10") 1262 | want := "0123456789 012345678…" 1263 | if !reflect.DeepEqual(have, want) { 1264 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1265 | } 1266 | } 1267 | { 1268 | have := mustRun(t, "-lw20") 1269 | want := norm(` 1270 | 0 │ 01:08 │ 012345… 1271 | 0 │ 01:08 │ 012345…`, "01:08", now) 1272 | if !reflect.DeepEqual(have, want) { 1273 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1274 | } 1275 | } 1276 | { 1277 | have := mustRun(t, "-lw10", "-w21") 1278 | want := norm(` 1279 | 0 │ 01:08 │ 0123456… 1280 | 0 │ 01:08 │ 0123456…`, "01:08", now) 1281 | if !reflect.DeepEqual(have, want) { 1282 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1283 | } 1284 | } 1285 | 1286 | { 1287 | have := mustRun(t, "-1w100") 1288 | want := "0123456789\n" + long 1289 | if !reflect.DeepEqual(have, want) { 1290 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1291 | } 1292 | } 1293 | { 1294 | columns = 200 1295 | have := mustRun(t, "-Cw100") 1296 | want := "0123456789 " + long 1297 | if !reflect.DeepEqual(have, want) { 1298 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1299 | } 1300 | } 1301 | } 1302 | 1303 | func TestRecurse(t *testing.T) { 1304 | start(t) 1305 | 1306 | for _, d := range []string{"x", "y", "a", "b", "c", "a/1", "a/2", "a/3"} { 1307 | mkdirAll(t, d) 1308 | } 1309 | for _, f := range []string{"f", "a/1/I", "a/1/II"} { 1310 | touch(t, f) 1311 | } 1312 | 1313 | // This first example is from Andreas Schwab's bug report. 1314 | have := mustRun(t, "-R1", "a", "b", "c") 1315 | want := norm(` 1316 | a: 1317 | 1 1318 | 2 1319 | 3 1320 | 1321 | a/1: 1322 | I 1323 | II 1324 | 1325 | a/2: 1326 | 1327 | a/3: 1328 | 1329 | b: 1330 | 1331 | c:`) 1332 | if have != want { 1333 | t.Errorf("\nhave:\n%s\n\nwant:\n%s\n\nhave: %[1]q\nwant: %[2]q", have, want) 1334 | } 1335 | 1336 | have = mustRun(t, "-R1", "x", "y", "f") 1337 | want = norm(` 1338 | f 1339 | 1340 | x: 1341 | 1342 | y:`) 1343 | if have != want { 1344 | t.Errorf("\nhave:\n%s\n\nwant:\n%s\n\nhave: %[1]q\nwant: %[2]q", have, want) 1345 | } 1346 | } 1347 | 1348 | func TestRemovedDirectory(t *testing.T) { 1349 | switch runtime.GOOS { 1350 | case "illumos", "solaris", "windows": 1351 | t.Skipf("can't delete used directory on %s", runtime.GOOS) 1352 | case "netbsd": 1353 | if isCI() { 1354 | // helper_test.go:46: mustRun failed: elles: getwd: no such file or directory 1355 | t.Skip("TODO: fails in CI") 1356 | } 1357 | } 1358 | 1359 | start(t) 1360 | mkdirAll(t, "d") 1361 | cd(t, "d") 1362 | rmAll(t, "../d") 1363 | 1364 | if have := mustRun(t); have != "" { 1365 | t.Error(have) 1366 | } 1367 | if have, ok := run(t, "../d"); ok { 1368 | t.Error(have) 1369 | } 1370 | } 1371 | 1372 | func TestCase(t *testing.T) { 1373 | if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { 1374 | t.Skipf("%s doesn't like two pathnames differing only in casing", runtime.GOOS) 1375 | } 1376 | 1377 | start(t) 1378 | 1379 | for _, f := range []string{"aa", "AA", "aA", "Aa"} { 1380 | touch(t, f) 1381 | } 1382 | 1383 | have := mustRun(t, "-C") 1384 | want := norm(`AA Aa aA aa`) 1385 | if have != want { 1386 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1387 | } 1388 | } 1389 | 1390 | func TestColumns(t *testing.T) { 1391 | defer func() { columns = 80 }() 1392 | columns = 40 1393 | 1394 | start(t) 1395 | 1396 | for _, f := range []string{"c", "d", "e", "i", "klmn", "opqr", 1397 | "stuv", "wxyz", "xxxx", "Hello", "AA", "with space"} { 1398 | touch(t, f) 1399 | } 1400 | mkdirAll(t, "dir") 1401 | 1402 | have := mustRun(t, "-C") 1403 | want := norm(` 1404 | AA d i stuv xxxx 1405 | Hello dir klmn with space 1406 | c e opqr wxyz`) 1407 | if have != want { 1408 | t.Errorf("\nhave:\n%s\n\nwant:\n%s\n\nhave: %[1]q\nwant: %[2]q", have, want) 1409 | } 1410 | } 1411 | 1412 | func TestColumnsPad(t *testing.T) { 1413 | defer func() { columns = 80 }() 1414 | columns = 88 1415 | 1416 | start(t) 1417 | for _, f := range []string{"02-07 Catspaw.mkv", "02-08 I, Mudd.mkv", "02-09 Metamorphosis.mkv", 1418 | "02-11 Friday's Child.mkv", "02-12 The Deadly Years.mkv", "02-13 Obsession.mkv", 1419 | "02-14 Wolf In The Fold.mkv", "02-20 Return to Tomorrow.mkv", "02-21 Patterns of Force.mkv", 1420 | "02-22 By Any Other Name.mkv", "02-25 Bread and Circuses.mkv", "03-17 That Which Survives.mkv", 1421 | "03-21 The Cloud Minders.mkv", "03-22 The Savage Curtain.mkv", "03-24 Turnabout Intruder.mkv", 1422 | } { 1423 | touch(t, f) 1424 | } 1425 | 1426 | have := mustRun(t, "-C") 1427 | want := norm(` 1428 | 02-07 Catspaw.mkv 02-13 Obsession.mkv 02-25 Bread and Circuses.mkv 1429 | 02-08 I, Mudd.mkv 02-14 Wolf In The Fold.mkv 03-17 That Which Survives.mkv 1430 | 02-09 Metamorphosis.mkv 02-20 Return to Tomorrow.mkv 03-21 The Cloud Minders.mkv 1431 | 02-11 Friday's Child.mkv 02-21 Patterns of Force.mkv 03-22 The Savage Curtain.mkv 1432 | 02-12 The Deadly Years.mkv 02-22 By Any Other Name.mkv 03-24 Turnabout Intruder.mkv`) 1433 | if have != want { 1434 | t.Errorf("\nhave:\n%s\n\nwant:\n%s\n\nhave: %[1]q\nwant: %[2]q", have, want) 1435 | } 1436 | 1437 | } 1438 | 1439 | func TestSpace(t *testing.T) { 1440 | if runtime.GOOS == "windows" { 1441 | t.Skip("Windows doesn't like filenames as just a space, or something") 1442 | } 1443 | 1444 | start(t) 1445 | for _, f := range []string{ 1446 | "with space", 1447 | " leading space", 1448 | "trailing space ", 1449 | " ", 1450 | " ", 1451 | "\t", 1452 | } { 1453 | touch(t, f) 1454 | } 1455 | 1456 | { 1457 | have := mustRun(t, "-1") 1458 | want := norm(` 1459 | $'\t' 1460 | " " 1461 | " " 1462 | " leading space" 1463 | "trailing space " 1464 | with space`) 1465 | if have != want { 1466 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1467 | } 1468 | } 1469 | { 1470 | have := mustRun(t, "-1Q") 1471 | want := norm(` 1472 | "\t" 1473 | " " 1474 | " " 1475 | " leading space" 1476 | "trailing space " 1477 | "with space"`) 1478 | if have != want { 1479 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1480 | } 1481 | } 1482 | } 1483 | 1484 | func TestControlChar(t *testing.T) { 1485 | if runtime.GOOS == "windows" { 1486 | t.Skip("control characters aren't permitted in Windows") 1487 | } 1488 | 1489 | start(t) 1490 | 1491 | for n := range byte(0x1e) { 1492 | touch(t, string([]byte{n + 1})) 1493 | } 1494 | for n := range rune(24) { 1495 | mkdirAll(t, string([]rune{n + 0x80})) 1496 | } 1497 | touch(t, string([]byte{0x7f})) 1498 | touch(t, "a\x01b\x02") 1499 | symlink(t, "a\x01b\x02", "link1") 1500 | symlink(t, "\n", "link2") 1501 | symlink(t, "\x7f", "link3") 1502 | symlink(t, "\u0081", "link4") 1503 | symlink(t, "link1", "link\x01b\x02") 1504 | 1505 | { 1506 | have := mustRun(t, "-CF") 1507 | want := norm(` 1508 | $'\x01' $'\n' $'\x13' $'\x1c' $'\x7f' $'\x88'/ $'\x91'/ 1509 | $'\x02' $'\x0b' $'\x14' $'\x1d' $'\x80'/ $'\x89'/ $'\x92'/ 1510 | $'\x03' $'\x0c' $'\x15' $'\x1e' $'\x81'/ $'\x8a'/ $'\x93'/ 1511 | $'\x04' $'\r' $'\x16' a$'\x01'b$'\x02' $'\x82'/ $'\x8b'/ $'\x94'/ 1512 | $'\x05' $'\x0e' $'\x17' link$'\x01'b$'\x02'@ $'\x83'/ $'\x8c'/ $'\x95'/ 1513 | $'\x06' $'\x0f' $'\x18' link1@ $'\x84'/ $'\x8d'/ $'\x96'/ 1514 | $'\x07' $'\x10' $'\x19' link2@ $'\x85'/ $'\x8e'/ $'\x97'/ 1515 | $'\x08' $'\x11' $'\x1a' link3@ $'\x86'/ $'\x8f'/ 1516 | $'\t' $'\x12' $'\e' link4@ $'\x87'/ $'\x90'/`) 1517 | if have != want { 1518 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1519 | } 1520 | } 1521 | 1522 | { 1523 | h := strings.Split(mustRun(t, "-l", "link1", "link2", "link3", "link4", "link\x01b\x02"), "\n") 1524 | for i := range h { 1525 | x := strings.Split(h[i], " │ ") 1526 | h[i] = x[2] 1527 | } 1528 | have := strings.Join(h, "\n") 1529 | want := norm(` 1530 | link$'\x01'b$'\x02' → link1 1531 | link1 → a$'\x01'b$'\x02' 1532 | link2 → $'\n' 1533 | link3 → $'\x7f' 1534 | link4 → $'\x81'`) 1535 | if have != want { 1536 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1537 | } 1538 | } 1539 | } 1540 | 1541 | func TestUnprintable(t *testing.T) { 1542 | if runtime.GOOS == "windows" { 1543 | t.Skip("Windows doesn't like some of these") 1544 | } 1545 | 1546 | start(t) 1547 | 1548 | for _, n := range []string{ 1549 | "A→B", 1550 | "A\u200dB", // Zero-width joiner 1551 | "A\u200eB", // Left-to-right mark 1552 | "A\u202dB", // Left-to-right override 1553 | "A\ufe0eB", // text variation selector 1554 | "A\ufe0fB", // emoji variation selector 1555 | "A\ufe04B", // Mongolian variation selector 1556 | "a\u0305b", // Combining overline 1557 | } { 1558 | touch(t, n) 1559 | } 1560 | 1561 | { 1562 | have := mustRun(t, "-C") 1563 | want := norm(` 1564 | A$'\u200d'B A$'\u202d'B A$'\ufe04'B A$'\ufe0f'B 1565 | A$'\u200e'B A→B A$'\ufe0e'B a̅b`) 1566 | if have != want { 1567 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1568 | } 1569 | } 1570 | 1571 | { 1572 | have := mustRun(t, "-CQ") 1573 | want := norm(` 1574 | "A\u200dB" "A\u200eB" "A\u202dB" A→B "A\ufe04B" "A\ufe0eB" "A\ufe0fB" a̅b`) 1575 | if have != want { 1576 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1577 | } 1578 | } 1579 | } 1580 | 1581 | func TestSpecialShell(t *testing.T) { 1582 | if runtime.GOOS == "windows" { 1583 | t.Skip("many of these are not valid on Windows") 1584 | } 1585 | 1586 | start(t) 1587 | for _, n := range []string{ 1588 | "~", "`", "!", "#", "$", "%", "&", "*", "(", ")", 1589 | "[", "]", "{", "}", "|", "\\", ";", ":", `"`, "'", 1590 | ",", ">", "<", "?", 1591 | "...", 1592 | } { 1593 | touch(t, n) 1594 | } 1595 | 1596 | { 1597 | have := mustRun(t, "-aC") 1598 | want := "! \" # $ % & ' ( ) * , ... : ; < > ? [ \\ ] ` { | } ~" 1599 | if have != want { 1600 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1601 | } 1602 | } 1603 | 1604 | { 1605 | have := mustRun(t, "-aQC") 1606 | want := norm(` 1607 | "!" "#" "%" "'" ")" , : "<" "?" "\" "\` + "`" + `" "|" "~" 1608 | "\"" "$" "&" "(" "*" ... ";" ">" "[" "]" "{" "}"`) // " 1609 | if have != want { 1610 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1611 | } 1612 | } 1613 | } 1614 | 1615 | func TestDirFlag(t *testing.T) { 1616 | start(t) 1617 | mkdirAll(t, "dir1/dir2/dir3") 1618 | 1619 | for _, p := range []string{".", "..", "./", "../.", pwd(t), filepath.Join(pwd(t), "."), 1620 | "dir1", "dir1/dir2", "dir1/dir2/dir3", 1621 | "dir1/", "dir1/dir2/", ".//dir1//", 1622 | } { 1623 | have := mustRun(t, "-1d", p) 1624 | p = strings.TrimSuffix(filepath.ToSlash(filepath.Clean(p)), string([]rune{filepath.Separator})) 1625 | if have != p { 1626 | t.Errorf("output not equal to input:\npath: %q\nhave: %q", p, have) 1627 | } 1628 | } 1629 | } 1630 | 1631 | func TestGroupDirs(t *testing.T) { 1632 | start(t) 1633 | 1634 | mkdirAll(t, "dir/b") 1635 | touch(t, "dir/a") 1636 | symlink(t, "b", "dir/bl") 1637 | 1638 | have := strings.Fields(mustRun(t, "--group-dirs", "dir")) 1639 | want := []string{"b", "bl", "a"} 1640 | if !reflect.DeepEqual(have, want) { 1641 | t.Errorf("\nhave: %s\nwant: %s", have, want) 1642 | } 1643 | 1644 | have = strings.Fields(mustRun(t, "--group-dir", "-d", "dir/a", "dir/b", "dir/bl")) 1645 | want = []string{"dir/b", "dir/bl", "dir/a"} 1646 | if !reflect.DeepEqual(have, want) { 1647 | t.Errorf("\nhave: %s\nwant: %s", have, want) 1648 | } 1649 | } 1650 | 1651 | // Make sure 'ls' and 'ls -R' do the right thing when invoked with no arguments. 1652 | func TestWithoutArguments(t *testing.T) { 1653 | start(t) 1654 | 1655 | mkdirAll(t, "dir/subdir") 1656 | touch(t, "dir/subdir/file2") 1657 | touch(t, "exp") 1658 | touch(t, "out") 1659 | symlink(t, "f", "symlink") 1660 | 1661 | { 1662 | have := strings.Fields(mustRun(t, "-1")) 1663 | want := []string{"dir", "exp", "out", "symlink"} 1664 | if !reflect.DeepEqual(have, want) { 1665 | t.Errorf("\nhave: %s\nwant: %s", have, want) 1666 | } 1667 | } 1668 | 1669 | have := mustRun(t, "-R1") 1670 | want := norm(` 1671 | .: 1672 | dir 1673 | exp 1674 | out 1675 | symlink 1676 | 1677 | dir: 1678 | subdir 1679 | 1680 | dir/subdir: 1681 | file2`) 1682 | if have != want { 1683 | t.Errorf("\nhave:\n%s\n\nwant:\n%s\n\nhave: %[1]q\nwant: %[2]q", have, want) 1684 | } 1685 | } 1686 | 1687 | func TestSymlinkLoop(t *testing.T) { 1688 | if runtime.GOOS == "windows" { 1689 | t.Skip() 1690 | } 1691 | start(t) 1692 | symlink(t, "loop", "loop") 1693 | 1694 | loopError := func(s string) bool { 1695 | return strings.Contains(s, "too many levels of symbolic links") || 1696 | strings.Contains(s, "exceeds MAXSYMLINKS") 1697 | } 1698 | 1699 | if have := mustRun(t, "-1", "loop"); have != "loop" { 1700 | t.Error(have) 1701 | } 1702 | if have := mustRun(t, "-1l", "loop"); strings.Count(have, "\n") != 0 || !strings.Contains(have, "loop → loop") { 1703 | t.Error(have) 1704 | } 1705 | if have, ok := run(t, "-1H", "loop"); ok || !loopError(have) { 1706 | t.Error(ok, have) 1707 | } 1708 | if runtime.GOOS == "darwin" { 1709 | // TODO: on macOS it exits with 0 and: 1710 | // 4 │ 22:34 │ loop/loop → ??? 1711 | t.Skip() 1712 | // if have, ok := run(t, "-l1", "loop/"); !loopError(have) { 1713 | // t.Errorf("%v\n%s", ok, have) 1714 | // } 1715 | } else { 1716 | if have, ok := run(t, "-l1", "loop/"); ok || !loopError(have) { 1717 | t.Errorf("%v\n%s", ok, have) 1718 | } 1719 | } 1720 | } 1721 | 1722 | // Dereference symlink arg if written with a trailing slash. 1723 | func TestSymlinkSlash(t *testing.T) { 1724 | start(t) 1725 | mkdirAll(t, "dir") 1726 | touch(t, "dir/inside") 1727 | symlink(t, "dir", "symlink") 1728 | 1729 | if have := mustRun(t, "-1", "symlink"); have != "symlink" { 1730 | t.Error(have) 1731 | } 1732 | if have := mustRun(t, "-1", "symlink/"); have != "inside" { 1733 | t.Error(have) 1734 | } 1735 | } 1736 | 1737 | // Verify that ls works properly when it fails to stat a file that is not 1738 | // mentioned on the command line. 1739 | // 1740 | // This (indirectly) also tests whether just "elles" runs stat: GNU ls has a 1741 | // separate test for this, but if the first "elles dir" fails, then it 1742 | // (probably) ran stat when it shouldn't. 1743 | func TestUnreadable(t *testing.T) { 1744 | if runtime.GOOS == "windows" { 1745 | t.Skip() // TODO: figure out how to make directory unreadable on Windows. 1746 | } 1747 | if runtime.GOOS == "solaris" || runtime.GOOS == "illumos" { 1748 | // TODO: outright fails with: 1749 | // 1750 | // elles: lstat dir/link: permission denied 1751 | // 1752 | // That error comes from os2.ReadDir(a) in gather(); it seems either 1753 | // os.Open() or os.File.ReadDir() call Stat on illumos/Solaris for some 1754 | // reason(?) Need to investigate later. 1755 | t.Skip() 1756 | } 1757 | 1758 | start(t) 1759 | defer chmod(t, 0o700, "dir") 1760 | 1761 | mkdirAll(t, "dir") 1762 | symlink(t, "/", "dir/link") 1763 | chmod(t, 0o600, "dir") 1764 | 1765 | { 1766 | have := mustRun(t, "-C", "dir") 1767 | want := `link` 1768 | if have != want { 1769 | t.Errorf("\nhave: %s\nwant: %s", have, want) 1770 | } 1771 | } 1772 | 1773 | { 1774 | have, ok := run(t, "-CF", "dir") 1775 | _ = ok 1776 | want := norm(` 1777 | link@ 1778 | elles: lstat dir/link: permission denied`) 1779 | if have != want { 1780 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1781 | } 1782 | } 1783 | 1784 | { 1785 | have, ok := run(t, "-l", "dir") 1786 | _ = ok 1787 | want := norm(` 1788 | ??? │ ????-??-?? │ link → ??? 1789 | elles: lstat dir/link: permission denied`) 1790 | if have != want { 1791 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1792 | } 1793 | } 1794 | 1795 | { 1796 | have, ok := run(t, "-ll", "dir") 1797 | _ = ok 1798 | want := norm(` 1799 | l--------- :[failed] ??? ????-??-?? │ link → ??? 1800 | elles: lstat dir/link: permission denied`) 1801 | if have != want { 1802 | t.Errorf("\nhave:\n%s\n\nwant:\n%s", have, want) 1803 | } 1804 | } 1805 | } 1806 | 1807 | // Ensure that ls -l works on files with nameless uid and/or gid 1808 | func TestNamelessUID(t *testing.T) { 1809 | if runtime.GOOS == "windows" { 1810 | t.Skip() // TODO 1811 | } 1812 | 1813 | hasRoot(t, true) 1814 | 1815 | var uid, gid int 1816 | for i := range 16 * 1024 { 1817 | i += 1000 1818 | n := strconv.Itoa(i) 1819 | if uid == 0 { 1820 | _, err := user.LookupId(n) 1821 | if err != nil { 1822 | uid = i 1823 | } 1824 | } 1825 | if gid == 0 { 1826 | _, err := user.LookupGroupId(n) 1827 | if err != nil && i != uid { 1828 | gid = i 1829 | } 1830 | } 1831 | if uid != 0 && gid != 0 { 1832 | break 1833 | } 1834 | } 1835 | if uid == 0 { 1836 | t.Error("couldn't find a nameless UID") 1837 | } 1838 | if gid == 0 { 1839 | t.Error("couldn't find a nameless GID") 1840 | } 1841 | 1842 | start(t) 1843 | touch(t, "file") 1844 | mkdirAll(t, "dir") 1845 | if err := os.Chown("file", uid, gid); err != nil { 1846 | t.Fatal(err) 1847 | } 1848 | if err := os.Chown("dir", uid, gid); err != nil { 1849 | t.Fatal(err) 1850 | } 1851 | 1852 | have := strings.Split(mustRun(t, "-ll"), "\n") 1853 | want := fmt.Sprintf("%d :%d", uid, gid) 1854 | for _, h := range have { 1855 | if !strings.Contains(h, want) { 1856 | t.Error(h) 1857 | } 1858 | } 1859 | } 1860 | -------------------------------------------------------------------------------- /os2/door_other.go: -------------------------------------------------------------------------------- 1 | //go:build !solaris 2 | 3 | package os2 4 | 5 | import "io/fs" 6 | 7 | func IsDoor(fi fs.FileInfo) bool { return false } 8 | -------------------------------------------------------------------------------- /os2/door_solaris.go: -------------------------------------------------------------------------------- 1 | //go:build solaris 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | 8 | "golang.org/x/sys/unix" 9 | ) 10 | 11 | func IsDoor(fi fs.FileInfo) bool { return fi.Mode()&unix.S_IFDOOR != 0 } 12 | -------------------------------------------------------------------------------- /os2/fifo_freebsd.go: -------------------------------------------------------------------------------- 1 | //go:build freebsd 2 | 3 | package os2 4 | 5 | import "golang.org/x/sys/unix" 6 | 7 | func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } 8 | func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, uint64(dev)) } 9 | -------------------------------------------------------------------------------- /os2/fifo_other.go: -------------------------------------------------------------------------------- 1 | //go:build !unix 2 | 3 | package os2 4 | 5 | import ( 6 | "fmt" 7 | "runtime" 8 | ) 9 | 10 | func Mkfifo(path string, mode uint32) error { 11 | return fmt.Errorf("no FIFOs on %s", runtime.GOOS) 12 | } 13 | func Mknod(path string, mode uint32, dev int) error { 14 | return fmt.Errorf("no device nodes on %s", runtime.GOOS) 15 | } 16 | -------------------------------------------------------------------------------- /os2/fifo_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix && !freebsd 2 | 3 | package os2 4 | 5 | import "golang.org/x/sys/unix" 6 | 7 | func Mkfifo(path string, mode uint32) error { return unix.Mkfifo(path, mode) } 8 | func Mknod(path string, mode uint32, dev int) error { return unix.Mknod(path, mode, dev) } 9 | -------------------------------------------------------------------------------- /os2/hidden_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package os2 4 | 5 | import "io/fs" 6 | 7 | func Hidden(absdir string, fi fs.DirEntry) bool { 8 | return fi.Name()[0] == '.' 9 | } 10 | -------------------------------------------------------------------------------- /os2/hidden_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "path/filepath" 8 | 9 | "golang.org/x/sys/windows" 10 | ) 11 | 12 | func Hidden(absdir string, fi fs.DirEntry) bool { 13 | if fi.Name()[0] == '.' { 14 | return true 15 | } 16 | 17 | p := filepath.Join(absdir, fi.Name()) 18 | ptr, err := windows.UTF16PtrFromString(p) 19 | if err != nil { 20 | return false 21 | } 22 | attr, err := windows.GetFileAttributes(ptr) 23 | if err != nil { 24 | // TODO: fails on e.g. C:\pagefile.sys with: 25 | // The process cannot access the file because it is being used by another process. 26 | // 27 | // dir C:\ doesn't display it, so there must be some way to get this(?) 28 | //zli.Errorf("windows.GetFileAttributes: %q: %s", p, err) 29 | return false 30 | } 31 | return attr&windows.FILE_ATTRIBUTE_HIDDEN != 0 32 | } 33 | -------------------------------------------------------------------------------- /os2/os2.go: -------------------------------------------------------------------------------- 1 | package os2 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // Like os.ReadDir, but without the sort. 8 | func ReadDir(name string) ([]os.DirEntry, error) { 9 | f, err := os.Open(name) 10 | if err != nil { 11 | return nil, err 12 | } 13 | defer f.Close() 14 | return f.ReadDir(-1) 15 | } 16 | -------------------------------------------------------------------------------- /os2/statfs_netbsdsol.go: -------------------------------------------------------------------------------- 1 | //go:build netbsd || solaris 2 | 3 | package os2 4 | 5 | import "golang.org/x/sys/unix" 6 | 7 | func statfs(m string) (int, error) { 8 | var vfs unix.Statvfs_t 9 | err := unix.Statvfs(m, &vfs) 10 | return int(vfs.Bsize), err 11 | } 12 | -------------------------------------------------------------------------------- /os2/statfs_openbsd.go: -------------------------------------------------------------------------------- 1 | //go:build openbsd 2 | 3 | package os2 4 | 5 | import "golang.org/x/sys/unix" 6 | 7 | func statfs(m string) (int, error) { 8 | var vfs unix.Statfs_t 9 | err := unix.Statfs(m, &vfs) 10 | return int(vfs.F_bsize), err 11 | } 12 | -------------------------------------------------------------------------------- /os2/statfs_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix && !netbsd && !openbsd && !solaris 2 | 3 | package os2 4 | 5 | import ( 6 | "golang.org/x/sys/unix" 7 | ) 8 | 9 | func statfs(m string) (int, error) { 10 | var vfs unix.Statfs_t 11 | err := unix.Statfs(m, &vfs) 12 | return int(vfs.Bsize), err 13 | } 14 | -------------------------------------------------------------------------------- /os2/time_bsd.go: -------------------------------------------------------------------------------- 1 | //go:build freebsd || netbsd || darwin 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func Atime(fi fs.FileInfo) time.Time { 12 | if fi.Sys() == nil { 13 | return time.Time{} 14 | } 15 | t := fi.Sys().(*syscall.Stat_t).Atimespec 16 | return time.Unix(int64(t.Sec), int64(t.Nsec)) 17 | } 18 | 19 | func Btime(absdir string, fi fs.FileInfo) time.Time { 20 | if fi.Sys() == nil { 21 | return time.Time{} 22 | } 23 | t := fi.Sys().(*syscall.Stat_t).Birthtimespec 24 | return time.Unix(int64(t.Sec), int64(t.Nsec)) 25 | } 26 | -------------------------------------------------------------------------------- /os2/time_dragonfly.go: -------------------------------------------------------------------------------- 1 | //go:build dragonfly 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func Atime(fi fs.FileInfo) time.Time { 12 | if fi.Sys() == nil { 13 | return time.Time{} 14 | } 15 | t := fi.Sys().(*syscall.Stat_t).Atim 16 | return time.Unix(t.Sec, t.Nsec) 17 | } 18 | 19 | // TODO: doesn't seem to have birthtime? 20 | func Btime(absdir string, fi fs.FileInfo) time.Time { 21 | return time.Time{} 22 | } 23 | -------------------------------------------------------------------------------- /os2/time_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "path/filepath" 8 | "syscall" 9 | "time" 10 | 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | func Atime(fi fs.FileInfo) time.Time { 15 | if fi.Sys() == nil { 16 | return time.Time{} 17 | } 18 | t := fi.Sys().(*syscall.Stat_t).Atim 19 | return time.Unix(int64(t.Sec), int64(t.Nsec)) 20 | } 21 | 22 | // TODO: On Linux the btime is in statx, rather than stat. We call statx() here 23 | // because, well, it's the best we can do. But it's slow. And ugly. 24 | // 25 | // Should probably rewrite the stdlib bits for listing directories so we don't 26 | // need to do this. But that's some effort and can't be bothered now, and I'm 27 | // okay with the performance hit for now. 28 | func Btime(absdir string, fi fs.FileInfo) time.Time { 29 | var s unix.Statx_t 30 | err := unix.Statx(0, 31 | filepath.Join(absdir, fi.Name()), 32 | unix.AT_SYMLINK_NOFOLLOW, 33 | unix.STATX_BTIME, &s) 34 | if err == nil { 35 | return time.Unix(s.Btime.Sec, int64(s.Btime.Nsec)) 36 | } 37 | 38 | if fi.Sys() == nil { 39 | return time.Time{} 40 | } 41 | t := fi.Sys().(*syscall.Stat_t).Ctim 42 | return time.Unix(int64(t.Sec), int64(t.Nsec)) 43 | } 44 | -------------------------------------------------------------------------------- /os2/time_openbsd.go: -------------------------------------------------------------------------------- 1 | //go:build openbsd 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func Atime(fi fs.FileInfo) time.Time { 12 | if fi.Sys() == nil { 13 | return time.Time{} 14 | } 15 | t := fi.Sys().(*syscall.Stat_t).Atim 16 | return time.Unix(t.Sec, t.Nsec) 17 | } 18 | 19 | func Btime(absdir string, fi fs.FileInfo) time.Time { 20 | return time.Time{} 21 | } 22 | -------------------------------------------------------------------------------- /os2/time_solaris.go: -------------------------------------------------------------------------------- 1 | //go:build solaris 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func Atime(fi fs.FileInfo) time.Time { 12 | if fi.Sys() == nil { 13 | return time.Time{} 14 | } 15 | t := fi.Sys().(*syscall.Stat_t).Atim 16 | return time.Unix(t.Sec, t.Nsec) 17 | } 18 | 19 | // TODO: we need to use getattrat()/fgetattr() to get this, with A_CRTIME. But 20 | // this isn't exposed in syscall or x/sys/unix. 21 | func Btime(absdir string, fi fs.FileInfo) time.Time { 22 | if fi.Sys() == nil { 23 | return time.Time{} 24 | } 25 | return time.Time{} 26 | } 27 | -------------------------------------------------------------------------------- /os2/time_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "syscall" 8 | "time" 9 | ) 10 | 11 | func Atime(fi fs.FileInfo) time.Time { 12 | if fi.Sys() == nil { 13 | return time.Time{} 14 | } 15 | s := fi.Sys().(*syscall.Win32FileAttributeData).LastAccessTime 16 | return time.Unix(0, s.Nanoseconds()) 17 | } 18 | 19 | func Btime(absdir string, fi fs.FileInfo) time.Time { 20 | if fi.Sys() == nil { 21 | return time.Time{} 22 | } 23 | s := fi.Sys().(*syscall.Win32FileAttributeData).CreationTime 24 | return time.Unix(0, s.Nanoseconds()) 25 | } 26 | -------------------------------------------------------------------------------- /os2/utime_other.go: -------------------------------------------------------------------------------- 1 | //go:build !unix 2 | 3 | package os2 4 | 5 | import "time" 6 | 7 | func Utimes(path string, atime, mtime time.Time) error { 8 | // SetFileInformationByHandle()? 9 | panic("not implemented") 10 | } 11 | -------------------------------------------------------------------------------- /os2/utime_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix && !386 && !arm 2 | 3 | package os2 4 | 5 | import ( 6 | "time" 7 | 8 | "golang.org/x/sys/unix" 9 | ) 10 | 11 | func Utimes(path string, atime, mtime time.Time) error { 12 | ts := make([]unix.Timespec, 2) 13 | if !atime.IsZero() { 14 | ts[0] = unix.Timespec{Sec: atime.Unix(), Nsec: int64(atime.Nanosecond())} 15 | } 16 | if !mtime.IsZero() { 17 | ts[1] = unix.Timespec{Sec: mtime.Unix(), Nsec: int64(mtime.Nanosecond())} 18 | } 19 | return unix.UtimesNanoAt(unix.AT_FDCWD, path, ts, unix.AT_SYMLINK_NOFOLLOW) 20 | } 21 | -------------------------------------------------------------------------------- /os2/utime_unix2.go: -------------------------------------------------------------------------------- 1 | //go:build unix && (386 || arm) 2 | 3 | package os2 4 | 5 | import ( 6 | "time" 7 | 8 | "golang.org/x/sys/unix" 9 | ) 10 | 11 | func Utimes(path string, atime, mtime time.Time) error { 12 | ts := make([]unix.Timespec, 2) 13 | if !atime.IsZero() { 14 | ts[0] = unix.Timespec{Sec: int32(atime.Unix()), Nsec: int32(atime.Nanosecond())} 15 | } 16 | if !mtime.IsZero() { 17 | ts[1] = unix.Timespec{Sec: int32(mtime.Unix()), Nsec: int32(mtime.Nanosecond())} 18 | } 19 | return unix.UtimesNanoAt(unix.AT_FDCWD, path, ts, unix.AT_SYMLINK_NOFOLLOW) 20 | } 21 | -------------------------------------------------------------------------------- /os2/vfs_other.go: -------------------------------------------------------------------------------- 1 | //go:build !unix && !windows 2 | 3 | package os2 4 | 5 | import "io/fs" 6 | 7 | // No-ops for platforms we don't really support. Most of this isn't really 8 | // critical, so okay to return dummy values. 9 | 10 | func Numlinks(absdir string, fi fs.FileInfo) int { return 1 } 11 | func OwnerID(absdir string, fi fs.FileInfo) (string, string) { return "", "" } 12 | func Serial(absdir string, fi fs.FileInfo) uint64 { return 0 } 13 | func Blocksize(path string) int { return 512 } 14 | func Blocks(fi fs.FileInfo) int64 { return fi.Size() / 512 } 15 | func IsELOOP(err error) bool { return false } 16 | -------------------------------------------------------------------------------- /os2/vfs_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package os2 4 | 5 | import ( 6 | "bufio" 7 | "errors" 8 | "io/fs" 9 | "os" 10 | "os/exec" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "syscall" 15 | 16 | "zgo.at/zli" 17 | ) 18 | 19 | // TODO: unused at the moment; ls -l prints this, but I don't think I've ever 20 | // used it in 25 years. Should maybe add option though. 21 | func Numlinks(absdir string, fi fs.FileInfo) uint64 { 22 | if fi.Sys() == nil { 23 | return 0 24 | } 25 | return uint64(fi.Sys().(*syscall.Stat_t).Nlink) 26 | } 27 | 28 | func OwnerID(absdir string, fi fs.FileInfo) (string, string) { 29 | if fi.Sys() == nil { 30 | return "", "" 31 | } 32 | s := fi.Sys().(*syscall.Stat_t) 33 | return strconv.FormatUint(uint64(s.Uid), 10), strconv.FormatUint(uint64(s.Gid), 10) 34 | } 35 | 36 | func Serial(absdir string, fi fs.FileInfo) uint64 { 37 | if fi.Sys() == nil { 38 | return 0 39 | } 40 | return fi.Sys().(*syscall.Stat_t).Ino 41 | } 42 | 43 | func Blocks(fi fs.FileInfo) int64 { 44 | if fi.Sys() == nil { 45 | return -1 46 | } 47 | return fi.Sys().(*syscall.Stat_t).Blocks 48 | } 49 | 50 | func IsELOOP(err error) bool { 51 | var pErr *fs.PathError 52 | if errors.As(err, &pErr) { 53 | return errors.Is(pErr.Err, syscall.ELOOP) 54 | } 55 | return false 56 | } 57 | 58 | var ( 59 | mnts []string 60 | blocks []int 61 | mntsOnce sync.Once 62 | ) 63 | 64 | // Linux and illumos. 65 | func mntsProc() bool { 66 | var fp *os.File 67 | for _, f := range []string{ 68 | "/proc/mounts", // Linux 69 | "/etc/mnttab", // illumos 70 | } { 71 | var err error 72 | fp, err = os.Open(f) 73 | if err == nil { 74 | break 75 | } 76 | } 77 | if fp == nil { 78 | return false 79 | } 80 | defer fp.Close() 81 | 82 | scan := bufio.NewScanner(fp) 83 | mnts = make([]string, 0, 4) 84 | for scan.Scan() { 85 | l := strings.Fields(scan.Text()) 86 | if l[0] != "cgroup" && l[0] != "cgroup2" { 87 | mnts = append(mnts, l[1]) 88 | } 89 | } 90 | return true 91 | } 92 | 93 | // Other Unix. TODO: there's probably a better way. 94 | func mntsCmd() bool { 95 | out, err := exec.Command("mount").CombinedOutput() 96 | if err != nil { 97 | return false 98 | } 99 | for _, l := range strings.Split(string(out), "\n") { 100 | f := strings.Fields(l) 101 | if len(f) >= 3 && f[0] != "cgroup" && f[0] != "cgroup2" && f[0] != "map" { 102 | mnts = append(mnts, f[2]) 103 | } 104 | } 105 | return true 106 | } 107 | 108 | // Get the block size for the filesystem on which path resides. 109 | func Blocksize(path string) int { 110 | mntsOnce.Do(func() { 111 | if !mntsProc() && !mntsCmd() { 112 | return 113 | } 114 | 115 | blocks = make([]int, 0, len(mnts)) 116 | for _, m := range mnts { 117 | bsize, err := statfs(m) 118 | if err != nil { 119 | switch err { 120 | case syscall.EPERM, syscall.EACCES: 121 | // Ignore permission errors. If we can't read this, then we 122 | // won't be able to read anything else either, so it's okay. 123 | default: 124 | zli.Errorf("statfs %q: %s", m, err) 125 | } 126 | bsize = 512 127 | } 128 | 129 | // statvfs also has f_frsize, for "Fundamental file system block 130 | // size", but I'm not really sure when/why to use this over f_bsize. 131 | // FreeBSD manpage has "minimum unit of allocation on this file 132 | // system. (This corresponds to the f_bsize member of struct 133 | // statfs)", so it's always the same? POSIX doesn't say much either. 134 | // So idk. 135 | blocks = append(blocks, bsize) 136 | } 137 | }) 138 | 139 | for i, m := range mnts { 140 | if strings.HasPrefix(path, m) { 141 | return blocks[i] 142 | } 143 | } 144 | 145 | // This should never happen, but print warning just in case. 146 | zli.Errorf("blocksize: no blocksize found for %q; defaulting to 512", path) 147 | return 512 148 | } 149 | -------------------------------------------------------------------------------- /os2/vfs_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package os2 4 | 5 | import ( 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | 10 | "golang.org/x/sys/windows" 11 | "zgo.at/zli" 12 | ) 13 | 14 | func Numlinks(absdir string, fi fs.FileInfo) int { 15 | fp, err := os.Open(filepath.Join(absdir, fi.Name())) 16 | if err != nil { 17 | return 1 18 | } 19 | defer fp.Close() 20 | 21 | var info windows.ByHandleFileInformation 22 | err = windows.GetFileInformationByHandle(windows.Handle(fp.Fd()), &info) 23 | if err != nil { 24 | zli.Errorf(err) 25 | return 1 26 | } 27 | return int(info.NumberOfLinks) 28 | } 29 | 30 | func OwnerID(absdir string, fi fs.FileInfo) (string, string) { 31 | fp, err := os.Open(filepath.Join(absdir, fi.Name())) 32 | if err != nil { 33 | return "", "" 34 | } 35 | defer fp.Close() 36 | 37 | sec, err := windows.GetSecurityInfo(windows.Handle(fp.Fd()), 38 | windows.SE_FILE_OBJECT, 39 | windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION) 40 | if err != nil { 41 | return "", "" 42 | } 43 | 44 | var owner, group string 45 | if o, _, err := sec.Owner(); err == nil { 46 | owner = o.String() 47 | } 48 | if g, _, err := sec.Group(); err == nil { 49 | group = g.String() 50 | } 51 | return owner, group 52 | } 53 | 54 | func Serial(absdir string, fi fs.FileInfo) uint64 { 55 | fp, err := os.Open(filepath.Join(absdir, fi.Name())) 56 | if err != nil { 57 | return 0 58 | } 59 | defer fp.Close() 60 | 61 | var info windows.ByHandleFileInformation 62 | err = windows.GetFileInformationByHandle(windows.Handle(fp.Fd()), &info) 63 | if err != nil { 64 | zli.Errorf(err) 65 | return 0 66 | } 67 | return (uint64(info.FileIndexHigh) << 32) | uint64(info.FileIndexLow) 68 | } 69 | 70 | func Blocks(fi fs.FileInfo) int64 { 71 | // TODO: not sure how to get this. 72 | return fi.Size() / 512 73 | } 74 | 75 | func Blocksize(path string) int { 76 | // TODO: not sure how to get this. 77 | return 512 78 | } 79 | 80 | func IsELOOP(err error) bool { 81 | // TODO: can this happen on Windows? 82 | return false 83 | } 84 | -------------------------------------------------------------------------------- /print.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "math" 9 | "net/url" 10 | "os" 11 | "os/user" 12 | "path/filepath" 13 | "runtime" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "time" 18 | "unicode" 19 | 20 | "zgo.at/elles/os2" 21 | "zgo.at/zli" 22 | ) 23 | 24 | const ( 25 | _ = 0 26 | borderToLeft uint8 = 1 << (iota - 1) 27 | alignNone 28 | alignLeft 29 | ) 30 | 31 | type ( 32 | col struct { 33 | s string 34 | w int 35 | prop uint8 36 | } 37 | cols struct { 38 | longest []int 39 | rows [][]col 40 | } 41 | opts struct { 42 | list, quote, fullTime, maxColWidth, minCols int 43 | dirSlash, classify, comma bool 44 | numericUID, group, hyperlink bool 45 | blockSize, timeField string 46 | one, cols, recurse, inode bool 47 | trim, octal, derefAll bool 48 | } 49 | ) 50 | 51 | func getCols(p printable, opt opts) cols { 52 | ncols := 1 53 | if opt.list == 1 { 54 | ncols = 3 55 | } else if opt.list >= 2 { 56 | ncols = 6 57 | } 58 | if opt.inode { 59 | ncols++ 60 | } 61 | cc := cols{ 62 | longest: make([]int, ncols), 63 | rows: make([][]col, 0, len(p.fi)), 64 | } 65 | 66 | for _, fi := range p.fi { 67 | fp, afp := p.dir, p.absdir 68 | if p.isFiles { 69 | fp, afp = fi.filepath, fi.filepathAbs 70 | } 71 | cur := make([]col, 0, ncols) 72 | if opt.list == 0 { 73 | if opt.inode { 74 | n := strconv.FormatUint(os2.Serial(p.absdir, fi), 10) 75 | cur = append(cur, col{s: n, w: len(n)}) 76 | } 77 | 78 | n, w := decoratePath(fp, afp, fi, opt, false, !p.isFiles) 79 | cur = append(cur, col{s: n, w: w, prop: alignNone}) 80 | } else if opt.list == 1 { 81 | s, w := listSize(fi, p.absdir, opt.blockSize, opt.comma) 82 | 83 | if opt.inode { 84 | n := strconv.FormatUint(os2.Serial(p.absdir, fi), 10) 85 | cur = append(cur, col{s: n, w: len(n)}) 86 | cur = append(cur, col{s: s, w: w, prop: borderToLeft}) 87 | } else { 88 | w++ 89 | cur = append(cur, col{s: " " + s, w: w}) 90 | } 91 | 92 | var ( 93 | t string 94 | tt = getTime(p.absdir, fi, opt.timeField) 95 | ) 96 | switch { 97 | case tt.IsZero(): 98 | t = "????-??-??" 99 | case opt.fullTime == 0: 100 | t = shortTime(p.absdir, tt) 101 | case opt.fullTime == 1: 102 | t = tt.Format("2006-01-02 15:04:05") 103 | default: 104 | t = tt.Format("2006-01-02 15:04:05.000000000 -07:00") 105 | } 106 | cur = append(cur, col{s: t, w: len(t), prop: borderToLeft}) 107 | 108 | n, w := decoratePath(fp, afp, fi, opt, true, !p.isFiles) 109 | cur = append(cur, col{s: n, w: w, prop: borderToLeft | alignNone}) 110 | } else { 111 | if opt.inode { 112 | n := strconv.FormatUint(os2.Serial(p.absdir, fi), 10) 113 | cur = append(cur, col{s: n, w: len(n)}) 114 | } 115 | var perm string 116 | if opt.octal { 117 | m := fi.Mode() & 0o777 118 | if fi.Mode()&fs.ModeSticky != 0 { 119 | m |= 0o1000 120 | } 121 | if fi.Mode()&fs.ModeSetgid != 0 { 122 | m |= 0o2000 123 | } 124 | if fi.Mode()&fs.ModeSetuid != 0 { 125 | m |= 0o4000 126 | } 127 | perm = fmt.Sprintf("%4o", m) 128 | } else { 129 | perm = strmode(fi.Mode()) 130 | } 131 | cur = append(cur, col{s: perm, w: len(perm)}) 132 | 133 | user, group := owner(p.absdir, fi, opt.numericUID) 134 | cur = append(cur, col{s: user, w: len(user), prop: alignLeft}) 135 | if opt.group { 136 | cur = append(cur, col{s: group, w: len(group), prop: alignLeft}) 137 | } else if user != group { 138 | cur = append(cur, col{s: ":" + group, w: len(group) + 1, prop: alignLeft}) 139 | } else { 140 | cur = append(cur, col{}) 141 | } 142 | 143 | s, w := listSize(fi, p.absdir, opt.blockSize, opt.comma) 144 | cur = append(cur, col{s: s, w: w}) 145 | 146 | var ( 147 | t string 148 | tt = getTime(p.absdir, fi, opt.timeField) 149 | ) 150 | switch { 151 | case tt.IsZero(): 152 | t = "????-??-??" 153 | case opt.fullTime == 0: 154 | t = tt.Format("Jan _2 15:04") 155 | case opt.fullTime == 1: 156 | t = tt.Format("2006-01-02 15:04:05") 157 | default: 158 | t = tt.Format("2006-01-02 15:04:05.000000000 -07:00") 159 | } 160 | cur = append(cur, col{s: t, w: len(t)}) 161 | 162 | n, w := decoratePath(fp, afp, fi, opt, true, !p.isFiles) 163 | cur = append(cur, col{s: n, w: w, prop: borderToLeft | alignNone}) 164 | } 165 | 166 | cc.rows = append(cc.rows, cur) 167 | var w int 168 | for i := range ncols { 169 | if cur[i].w > cc.longest[i] { 170 | cc.longest[i] = cur[i].w 171 | } 172 | w += cur[i].w 173 | } 174 | } 175 | return cc 176 | } 177 | 178 | func decoratePath(dir, absdir string, fi fs.FileInfo, opt opts, linkDest, listingDir bool) (string, int) { 179 | n := fi.Name() 180 | hidden := n[0] == '.' 181 | if dir != "" && !opt.recurse && !listingDir { 182 | n = filepath.Join(dir, n) 183 | } 184 | n = doQuote(n, opt.quote) 185 | 186 | // TODO: this should probably use zgo.at/termtext or something, pretty 187 | // sure alignment of this will be off in cases of double-width stuff 188 | // like some emojis, CJK, etc. termtext is relatively slow though; 189 | // should look into optimising the fast path on that. 190 | width := len([]rune(n)) 191 | 192 | var didColor bool 193 | ifset := func(c string, class ...string) { 194 | if c != "" { 195 | didColor = true 196 | if hidden { 197 | c += colorHidden 198 | } 199 | n = c + n + reset 200 | } 201 | if len(class) > 0 && (opt.classify || (class[0] == "/" && opt.dirSlash)) { 202 | n += class[0] 203 | width += len(class[0]) 204 | } 205 | } 206 | ex := "" 207 | if fi.Mode()&0o111 != 0 { 208 | ex = "*" 209 | } 210 | switch { 211 | case fi.Mode()&fs.ModeSetuid != 0: 212 | ifset(colorSuid, ex) 213 | case fi.Mode()&fs.ModeSetgid != 0: 214 | ifset(colorSgid, ex) 215 | case fi.Mode().IsRegular(): 216 | if ex != "" { 217 | ifset(colorExec, ex) 218 | } else if c, ok := colorExt[filepath.Ext(n)]; ok { 219 | ifset(c) 220 | } else { 221 | ifset(colorFile) 222 | } 223 | case fi.IsDir(): 224 | switch { 225 | case fi.Mode()&0o002 != 0 && fi.Mode()&fs.ModeSticky != 0: 226 | ifset(colorOtherWriteStick, "/") 227 | case fi.Mode()&fs.ModeSticky != 0: 228 | ifset(colorSticky, "/") 229 | case fi.Mode()&0o002 != 0: 230 | ifset(colorOtherWrite, "/") 231 | default: 232 | ifset(colorDir, "/") 233 | } 234 | case fi.Mode()&fs.ModeNamedPipe != 0: 235 | ifset(colorPipe, "|") 236 | case fi.Mode()&fs.ModeSocket != 0: 237 | ifset(colorSocket, "=") 238 | case fi.Mode()&fs.ModeDevice != 0: 239 | ifset(colorBlockDev) 240 | case fi.Mode()&fs.ModeCharDevice != 0: 241 | ifset(colorCharDev) 242 | case os2.IsDoor(fi): 243 | ifset(colorDoor, ">") 244 | 245 | // Symlink 246 | case fi.Mode()&fs.ModeSymlink != 0: 247 | if opt.derefAll { 248 | // -L and unresolvable symlinks: since resolving it fails earlier on 249 | // it's still a link here, but we don't really want to display it as 250 | // such. 251 | } else if !linkDest { 252 | ifset(colorLink, "@") 253 | } else { 254 | l, err := os.Readlink(filepath.Join(dir, fi.Name())) 255 | // If the Readlink failed the stat almost certainly also failed; 256 | // don't need to issue a separate error for this. 257 | if err != nil { 258 | n += " → ???" 259 | width += 6 260 | } else { 261 | fl := l 262 | if !filepath.IsAbs(fl) { 263 | fl = filepath.Join(dir, fl) 264 | } 265 | st, err := os.Stat(fl) 266 | var ( 267 | c = colorLink 268 | targetC, targetR string 269 | ) 270 | if err != nil { 271 | if colorOrphan != "" { 272 | c, targetC, targetR = colorOrphan, colorOrphan, reset 273 | } 274 | if !errors.Is(err, os.ErrNotExist) && !os2.IsELOOP(err) { 275 | zli.Errorf(err) 276 | } 277 | } else if st.IsDir() { 278 | targetC, targetR = colorDir, reset 279 | if opt.classify { 280 | targetR += "/" 281 | width += 1 282 | } 283 | } 284 | 285 | l = doQuote(l, opt.quote) 286 | n = c + n + reset + " → " + targetC + l + targetR 287 | width += 3 + len(l) 288 | } 289 | } 290 | } 291 | if !didColor { 292 | ifset(colorNormal) 293 | } 294 | if hidden { 295 | ifset(colorHidden) 296 | } 297 | 298 | if opt.hyperlink { 299 | hostnameOnce.Do(func() { h, _ := os.Hostname(); hostname = esc(h) }) 300 | p := esc(filepath.Join(absdir, n)) 301 | n = fmt.Sprintf("\x1b]8;;file://%s%s\a%s\x1b]8;;\a", hostname, p, n) 302 | } 303 | 304 | return filepath.ToSlash(n), width 305 | } 306 | 307 | var ( 308 | hostname string 309 | hostnameOnce sync.Once 310 | ) 311 | 312 | // We don't want to escape slashes, but do want to replace everything else. 313 | // 314 | // TODO: do this properly; what we want is call that url.escape() function with 315 | // encodePath (rather than encodePathSegment). That can only be done by 316 | // constructing url.URL, setting Path, and calling EscapedPath(). Meh. 317 | func esc(s string) string { 318 | return strings.ReplaceAll(url.PathEscape(s), "%2F", "/") 319 | } 320 | 321 | func isVariationSelector(r rune) bool { 322 | return (r >= 0x180b && r <= 0x180f) || (r >= 0xfe00 && r <= 0xfe0f) || (r >= 0xe0100 && r <= 0xe01ef) 323 | } 324 | 325 | func doQuote(in string, level int) string { 326 | var ( 327 | buf = new(strings.Builder) 328 | dblQuote = level == 2 329 | n = []rune(in) 330 | ) 331 | buf.Grow(len(n)) 332 | for i, r := range n { 333 | //if !unicode.IsPrint(r) || unicode.Is(unicode.Mn, r) { 334 | if !unicode.IsPrint(r) || isVariationSelector(r) { 335 | // Only display the brief escapes for \e, \n, \r, and \t as these 336 | // are fairly well-known. Who even knows what \v is? 337 | if level == 0 { 338 | buf.WriteString("$'") 339 | } else if level == 1 { 340 | dblQuote = true 341 | } 342 | switch r { 343 | case 0x1b: 344 | buf.WriteString(`\e`) 345 | case '\n': 346 | buf.WriteString(`\n`) 347 | case '\r': 348 | buf.WriteString(`\r`) 349 | case '\t': 350 | buf.WriteString(`\t`) 351 | default: 352 | if r < 0xff { 353 | fmt.Fprintf(buf, "\\x%02x", r) 354 | } else if r < 0xffff { 355 | fmt.Fprintf(buf, "\\u%04x", r) 356 | } else { 357 | fmt.Fprintf(buf, "\\U%08x", r) 358 | } 359 | } 360 | if level == 0 { 361 | buf.WriteString("'") 362 | } 363 | } else { 364 | // Always quote paths with leading and trailing spaces; hugely 365 | // confusing otherwise. 366 | if r == ' ' && (i == 0 || i == len(n)-1) { 367 | dblQuote = true 368 | } 369 | if level == 1 && needQuote(r) { 370 | dblQuote = true 371 | } 372 | if level >= 1 && (r == '"' || r == '`') { 373 | buf.WriteRune('\\') 374 | } 375 | buf.WriteRune(r) 376 | } 377 | } 378 | if dblQuote { 379 | buf.WriteByte('"') 380 | return `"` + buf.String() 381 | } 382 | return buf.String() 383 | } 384 | 385 | func needQuote(r rune) bool { 386 | switch r { 387 | case '|', '&', ';', '<', '>', '(', ')', '$', '\\', '"', '\'', ' ', // ' 388 | '*', '?', '[', ']', '#', '~', '=', '%', '!', '`', '{', '}': 389 | return true 390 | } 391 | return false 392 | } 393 | 394 | func getTime(absdir string, fi fs.FileInfo, timeField string) time.Time { 395 | switch timeField { 396 | case "btime": 397 | return os2.Btime(absdir, fi) 398 | case "atime": 399 | return os2.Atime(fi) 400 | default: 401 | return fi.ModTime() 402 | } 403 | } 404 | 405 | func shortSize(n float64, u string) string { 406 | if n > 10 { 407 | return fmt.Sprintf("%.0f"+u, n) 408 | } 409 | return fmt.Sprintf("%.1f"+u, n) 410 | } 411 | 412 | func groupDigits(s string) string { 413 | i, frac, hasFrac := strings.Cut(s, ".") 414 | if hasFrac { 415 | frac = "." + frac 416 | } 417 | if strings.HasSuffix(s, "K") || strings.HasSuffix(s, "M") || strings.HasSuffix(s, "G") || strings.HasSuffix(s, "T") { 418 | frac += i[len(i)-1:] 419 | hasFrac = true 420 | i = i[:len(i)-1] 421 | } 422 | if len(i) <= 3 { 423 | return s 424 | } 425 | 426 | var ( 427 | l = len(i) / 3 428 | r = len(i) % 3 429 | // +1 for dot, in case of frac. Not a big deal to over-alloc 1 byte. 430 | n = make([]byte, 0, len(i)+l+len(frac)+1) 431 | ) 432 | if r != 0 { 433 | n = append(n, i[:r]...) 434 | n = append(n, ',') 435 | } 436 | for j := range l { 437 | j++ 438 | if j > 1 { 439 | n = append(n, ',') 440 | } 441 | n = append(n, i[(j-1)*3+r:j*3+r]...) 442 | } 443 | if hasFrac { 444 | //n = append(n, '.') 445 | n = append(n, frac...) 446 | } 447 | return string(n) 448 | } 449 | 450 | // Only used for the default "-h" sizes 451 | var testFixedSizeWidth bool 452 | 453 | func listSize(fi fs.FileInfo, absdir, blockSize string, comma bool) (string, int) { 454 | if fi.Size() == -1 { 455 | return "???", 3 456 | } 457 | switch blockSize { 458 | case "s": 459 | s := strconv.FormatInt(os2.Blocks(fi), 10) 460 | if comma { 461 | s = groupDigits(s) 462 | } 463 | return s, len(s) 464 | case "S": 465 | bs := os2.Blocksize(filepath.Join(absdir, fi.Name())) 466 | s := strconv.FormatFloat(math.Ceil(float64(fi.Size())/float64(bs)), 'f', 0, 64) 467 | if comma { 468 | s = groupDigits(s) 469 | } 470 | return s, len(s) 471 | case "b", "B", "1": 472 | s := strconv.FormatInt(fi.Size(), 10) 473 | if comma { 474 | s = groupDigits(s) 475 | } 476 | return s, len(s) 477 | case "k", "K": 478 | s := shortSize(float64(fi.Size())/1024, "K") 479 | if comma { 480 | s = groupDigits(s) 481 | } 482 | return s, len(s) 483 | case "m", "M": 484 | s := shortSize(float64(fi.Size())/1024/1024, "M") 485 | if comma { 486 | s = groupDigits(s) 487 | } 488 | return s, len(s) 489 | case "g": 490 | s := shortSize(float64(fi.Size())/1024/1024/1024, "G") 491 | if comma { 492 | s = groupDigits(s) 493 | } 494 | return s, len(s) 495 | default: 496 | var s string 497 | if fi.Size() < 1024 { 498 | s = strconv.FormatInt(fi.Size(), 10) 499 | } else if fi.Size() < 1024*1024 { 500 | s = shortSize(float64(fi.Size())/1024, "K") 501 | } else if fi.Size() < 1024*1024*1024 { 502 | s = shortSize(float64(fi.Size())/1024/1024, "M") 503 | } else { 504 | s = shortSize(float64(fi.Size())/1024/1024/1024, "G") 505 | } 506 | if comma { 507 | s = groupDigits(s) 508 | } 509 | if testFixedSizeWidth { 510 | return fmt.Sprintf("%5s", s), 5 511 | } 512 | return s, len(s) 513 | } 514 | } 515 | 516 | // FileMode.String() doesn't align nicely with sticky bit and setuid. This ports 517 | // strmode(). 518 | func strmode(m fs.FileMode) string { 519 | buf := make([]byte, 10) 520 | buf[0] = ftypelet(m) 521 | 522 | w := 1 523 | const rwx = "rwxrwxrwx" 524 | for i, c := range rwx { 525 | if m&(1< out 45 | // cat -A out > o1 46 | // mv o1 out 47 | // 48 | // cat <<\EOF > exp 49 | // ^[[0m^[[01;34md^[[0m$ 50 | // ^[[34;42mother-writable^[[0m$ 51 | // out$ 52 | // ^[[37;44msticky^[[0m$ 53 | // EOF 54 | // 55 | // compare exp out 56 | // 57 | // rm exp 58 | // 59 | // # Turn off colors for other-writable dirs and ensure 60 | // # we fall back to the color for standard directories. 61 | // 62 | // LS_COLORS="ow=:" ls --color=always > out 63 | // cat -A out > o1 64 | // mv o1 out 65 | // 66 | // cat <<\EOF > exp 67 | // ^[[0m^[[01;34md^[[0m$ 68 | // ^[[01;34mother-writable^[[0m$ 69 | // out$ 70 | // ^[[37;44msticky^[[0m$ 71 | // EOF 72 | // 73 | // compare exp out 74 | }) 75 | 76 | t.Run("color-norm", func(t *testing.T) { 77 | // Ensure "ls --color" properly colors "normal" text and files. I.e., 78 | // that it uses NORMAL to style non file name output and file names with 79 | // no associated color (unless FILE is also set). 80 | 81 | start(t) 82 | 83 | // # Output time as something constant 84 | // export TIME_STYLE="+norm" 85 | // 86 | // # helper to strip ls columns up to "norm" time 87 | // qls() { sed 's/-r.*norm/norm/'; } 88 | // 89 | // touch exe 90 | // chmod u+x exe 91 | // touch nocolor 92 | // 93 | // TCOLORS="no=7:ex=01;32" 94 | // 95 | // # Uncolored file names inherit NORMAL attributes. 96 | // LS_COLORS=$TCOLORS ls -gGU --color exe nocolor | qls >> out 97 | // LS_COLORS=$TCOLORS ls -xU --color exe nocolor >> out 98 | // LS_COLORS=$TCOLORS ls -gGU --color nocolor exe | qls >> out 99 | // LS_COLORS=$TCOLORS ls -xU --color nocolor exe >> out 100 | // 101 | // # NORMAL does not override FILE though 102 | // LS_COLORS=$TCOLORS:fi=1 ls -gGU --color nocolor exe | qls >> out 103 | // 104 | // # Support uncolored ordinary files that do _not_ inherit from NORMAL. 105 | // # Note there is a redundant RESET output before a non colored 106 | // # file in this case which may be removed in future. 107 | // LS_COLORS=$TCOLORS:fi= ls -gGU --color nocolor exe | qls >> out 108 | // LS_COLORS=$TCOLORS:fi=0 ls -gGU --color nocolor exe | qls >> out 109 | // 110 | // # A caveat worth noting is that commas (-m), indicator chars (-F) 111 | // # and the "total" line, do not currently use NORMAL attributes 112 | // LS_COLORS=$TCOLORS ls -mFU --color nocolor exe >> out 113 | // 114 | // # Ensure no coloring is done unless enabled 115 | // LS_COLORS=$TCOLORS ls -gGU nocolor exe | qls >> out 116 | // 117 | // cat -A out > out.display 118 | // mv out.display out 119 | // 120 | // cat <<\EOF > exp 121 | // ^[[0m^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 122 | // ^[[7mnorm nocolor^[[0m$ 123 | // ^[[0m^[[7m^[[m^[[01;32mexe^[[0m ^[[7mnocolor^[[0m$ 124 | // ^[[0m^[[7mnorm nocolor^[[0m$ 125 | // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 126 | // ^[[0m^[[7mnocolor^[[0m ^[[7m^[[m^[[01;32mexe^[[0m$ 127 | // ^[[0m^[[7mnorm ^[[m^[[1mnocolor^[[0m$ 128 | // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 129 | // ^[[0m^[[7mnorm ^[[m^[[mnocolor^[[0m$ 130 | // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 131 | // ^[[0m^[[7mnorm ^[[m^[[0mnocolor^[[0m$ 132 | // ^[[7mnorm ^[[m^[[01;32mexe^[[0m$ 133 | // ^[[0m^[[7mnocolor^[[0m, ^[[7m^[[m^[[01;32mexe^[[0m*$ 134 | // norm nocolor$ 135 | // norm exe$ 136 | // EOF 137 | // 138 | // compare exp out 139 | }) 140 | 141 | t.Run("hyperlink", func(t *testing.T) { // Test --hyperlink processing 142 | start(t) 143 | 144 | // # lookup based on first letter 145 | // encode() { 146 | // printf '%s\n' \ 147 | // 'sp%20ace' 'ques%3ftion' 'back%5cslash' 'encoded%253Fquestion' 'testdir' \ 148 | // "$1" | 149 | // sort -k1,1.1 -s | uniq -w1 -d 150 | // } 151 | // 152 | // ls_encoded() { 153 | // ef=$(encode "$1") 154 | // echo "$ef" | grep 'dir$' >/dev/null && dir=: || dir='' 155 | // printf '\033]8;;file:///%s\a%s\033]8;;\a%s\n' \ 156 | // "$ef" "$1" "$dir" 157 | // } 158 | // 159 | // # These could be encoded, so remove from consideration 160 | // strip_host_and_path() { 161 | // sed 's|file://.*/|file:///|' 162 | // } 163 | // 164 | // mkdir testdir 165 | // ( 166 | // cd testdir 167 | // ls_encoded "testdir" > ../exp.t 168 | // for f in 'back\slash' 'encoded%3Fquestion' 'ques?tion' 'sp ace'; do 169 | // touch "$f" 170 | // ls_encoded "$f" >> ../exp.t 171 | // done 172 | // ) 173 | // ln -s testdir testdirl 174 | // (cat exp.t && printf '\n' && sed 's/[^\/]testdir/&l/' exp.t) > exp \ 175 | // 176 | // ls --hyper testdir testdirl >out.t 177 | // strip_host_and_path out 178 | // compare exp out 179 | // 180 | // ln -s '/probably_missing' testlink 181 | // ls -l --hyper testlink > out.t 182 | // strip_host_and_path out 183 | // grep 'file:///probably_missing' out 184 | }) 185 | 186 | t.Run("ls-time", func(t *testing.T) { // Test some of ls's sorting options. 187 | start(t) 188 | 189 | // # Avoid any possible glitches due to daylight-saving changes near the 190 | // # timestamps used during the test. 191 | // TZ=UTC0 192 | // export TZ 193 | // 194 | // t1='1998-01-15 21:00' 195 | // t2='1998-01-15 22:00' 196 | // t3='1998-01-15 23:00' 197 | // 198 | // u1='1998-01-14 11:00' 199 | // u2='1998-01-14 12:00' 200 | // u3='1998-01-14 13:00' 201 | // 202 | // touch -m -d "$t3" a 203 | // touch -m -d "$t2" b 204 | // touch -m -d "$t1" c 205 | // 206 | // touch -a -d "$u3" c 207 | // touch -a -d "$u2" b 208 | // # Make sure A has ctime at least 1 second more recent than C's. 209 | // sleep 2 210 | // touch -a -d "$u1" a 211 | // # Updating the atime is usually enough to update the ctime, but on 212 | // # Solaris 10's tmpfs, ctime is not updated, so force an update here: 213 | // { ln a a-ctime && rm a-ctime; } 214 | // 215 | // 216 | // # A has ctime more recent than C. 217 | // set $(ls -c a c) 218 | // test "$*" = 'a c' 219 | // 220 | // # Sleep so long in an attempt to avoid spurious failures 221 | // # due to NFS caching and/or clock skew. 222 | // sleep 2 223 | // 224 | // # Create a link, updating c's ctime. 225 | // ln c d 226 | // 227 | // # Before we go any further, verify that touch's -m option works. 228 | // set -- $(ls --full -l --time=mtime a) 229 | // case "$*" in 230 | // *" $t3:00.000000000 +0000 a") ;; 231 | // *) 232 | // # This might be what's making HPUX 11 systems fail this test. 233 | // cat >&2 << EOF 234 | // A basic test of touch -m has just failed, so the subsequent 235 | // tests in this file will not be run. 236 | // 237 | // In the output below, the date of last modification for 'a' should 238 | // have been $t3. 239 | // EOF 240 | // ls --full -l a 241 | // skip_ "touch -m -d '$t3' didn't work" 242 | // ;; 243 | // esac 244 | // 245 | // # Ensure that touch's -a option works. 246 | // set -- $(ls --full -lu a) 247 | // case "$*" in 248 | // *" $u1:00.000000000 +0000 a") ;; 249 | // *) 250 | // # This might be what's making HPUX 11 systems fail this test. 251 | // cat >&2 << EOF 252 | // A fundamental touch -a test has just failed, so the subsequent 253 | // tests in this file will not be run. 254 | // 255 | // In the output below, the date of last access for 'a' should 256 | // have been $u1. 257 | // EOF 258 | // ls --full -lu a 259 | // Exit 77 260 | // ;; 261 | // esac 262 | // 263 | // set $(ls -ut a b c) 264 | // test "$*" = 'c b a' && : 265 | // test $fail = 1 && ls -l --full-time --time=access a b c 266 | // 267 | // set $(ls -t a b c) 268 | // test "$*" = 'a b c' && : 269 | // test $fail = 1 && ls -l --full-time a b c 270 | // 271 | // # Now, C should have ctime more recent than A. 272 | // set $(ls -ct a c) 273 | // if test "$*" = 'c a'; then 274 | // : ok 275 | // else 276 | // # In spite of documentation, (e.g., stat(2)), neither link nor chmod 277 | // # update a file's st_ctime on SunOS4.1.4. 278 | // cat >&2 << \EOF 279 | // failed ls ctime test -- this failure is expected at least for SunOS4.1.4 280 | // and for tmpfs file systems on Solaris 5.5.1. 281 | // It is also expected to fail on a btrfs file system until 282 | // https://bugzilla.redhat.com/591068 is addressed. 283 | // 284 | // In the output below, 'c' should have had a ctime more recent than 285 | // that of 'a', but does not. 286 | // EOF 287 | // #' 288 | // ls -ctl --full-time a c 289 | // fail=1 290 | // fi 291 | // 292 | // # This check is ineffective if: 293 | // # en_US locale is not on the system. 294 | // # The system en_US message catalog has a specific TIME_FMT translation, 295 | // # which was inadvertently the case between coreutils 8.1 and 8.5 inclusive. 296 | // 297 | // if gettext --version >/dev/null 2>&1; then 298 | // 299 | // default_tf1='%b %e %Y' 300 | // en_tf1=$(LC_ALL=en_US gettext coreutils "$default_tf1") 301 | // 302 | // if test "$default_tf1" = "$en_tf1"; then 303 | // LC_ALL=en_US ls -l c >en_output 304 | // ls -l --time-style=long-iso c >liso_output 305 | // if compare en_output liso_output; then 306 | // fail=1 307 | // echo "Long ISO TIME_FMT being used for en_US locale." >&2 308 | // fi 309 | // fi 310 | // fi 311 | }) 312 | 313 | t.Run("multihardlink", func(t *testing.T) { 314 | // Ensure "ls --color" properly colors names of hard linked files. 315 | start(t) 316 | 317 | // touch file file1 318 | // ln file1 file2 || skip_ "can't create hard link" 319 | // code_mh='44;37' 320 | // code_ex='01;32' 321 | // code_png='01;35' 322 | // c0=$(printf '\033[0m') 323 | // c_mh=$(printf '\033[%sm' $code_mh) 324 | // c_ex=$(printf '\033[%sm' $code_ex) 325 | // c_png=$(printf '\033[%sm' $code_png) 326 | // 327 | // # regular file - not hard linked 328 | // LS_COLORS="mh=$code_mh" ls -U1 --color=always file > out 329 | // printf "file\n" > out_ok 330 | // compare out out_ok 331 | // 332 | // # hard links 333 | // LS_COLORS="mh=$code_mh" ls -U1 --color=always file1 file2 > out 334 | // printf "$c0${c_mh}file1$c0 335 | // ${c_mh}file2$c0 336 | // " > out_ok 337 | // compare out out_ok 338 | // 339 | // # hard links and png (hard link coloring takes precedence) 340 | // mv file2 file2.png 341 | // LS_COLORS="mh=$code_mh:*.png=$code_png" ls -U1 --color=always file1 file2.png \ 342 | // > out 343 | // printf "$c0${c_mh}file1$c0 344 | // ${c_mh}file2.png$c0 345 | // " > out_ok 346 | // compare out out_ok 347 | // 348 | // # hard links and exe (exe coloring takes precedence) 349 | // chmod a+x file2.png 350 | // LS_COLORS="mh=$code_mh:*.png=$code_png:ex=$code_ex" \ 351 | // ls -U1 --color=always file1 file2.png > out 352 | // chmod a-x file2.png 353 | // printf "$c0${c_ex}file1$c0 354 | // ${c_ex}file2.png$c0 355 | // " > out_ok 356 | // compare out out_ok 357 | // 358 | // # hard links and png (hard link coloring disabled => png coloring enabled) 359 | // LS_COLORS="mh=00:*.png=$code_png" ls -U1 --color=always file1 file2.png > out \ 360 | // 361 | // printf "file1 362 | // $c0${c_png}file2.png$c0 363 | // " > out_ok 364 | // compare out out_ok 365 | // 366 | // # hard links and png (hard link coloring not enabled explicitly => png coloring) 367 | // LS_COLORS="*.png=$code_png" ls -U1 --color=always file1 file2.png > out \ 368 | // 369 | // printf "file1 370 | // $c0${c_png}file2.png$c0 371 | // " > out_ok 372 | // compare out out_ok 373 | }) 374 | 375 | t.Run("nameless-uid", func(t *testing.T) { 376 | // Ensure that ls -l works on files with nameless uid and/or gid 377 | // require_root_ 378 | // require_perl_ 379 | start(t) 380 | 381 | // nameless_uid=$($PERL -e ' 382 | // foreach my $i (1000..16*1024) { getpwuid $i or (print "$i\n"), exit } 383 | // ') 384 | // 385 | // if test x$nameless_uid = x; then 386 | // skip_ "couldn't find a nameless UID" 387 | // fi 388 | // 389 | // touch f 390 | // chown $nameless_uid f 391 | // 392 | // 393 | // set -- $(ls -o f) 394 | // test $3 = $nameless_uid 395 | }) 396 | 397 | t.Run("root-rel-symlink-color", func(t *testing.T) { 398 | // 8.17 ls bug with coloring relative-named symlinks in "/". 399 | start(t) 400 | 401 | // symlink_to_rel= 402 | // for i in /*; do 403 | // # Skip non-symlinks: 404 | // env test -h "$i" || continue 405 | // 406 | // # Skip dangling symlinks: 407 | // env test -e "$i" || continue 408 | // 409 | // # Skip any symlink-to-absolute-name: 410 | // case $(readlink "$i") in /*) continue ;; esac 411 | // 412 | // symlink_to_rel=$i 413 | // break 414 | // done 415 | // 416 | // test -z "$symlink_to_rel" \ 417 | // && skip_ no relative symlink in / 418 | // 419 | // e='\33' 420 | // color_code='01;36' 421 | // c_pre="$e[0m$e[${color_code}m" 422 | // c_post="$e[0m" 423 | // printf "$c_pre$symlink_to_rel$c_post\n" > exp 424 | // 425 | // env TERM=xterm LS_COLORS="ln=$color_code:or=1;31;42" \ 426 | // ls -d --color=always "$symlink_to_rel" > out 427 | // 428 | // compare exp out 429 | // 430 | // Exit $fail 431 | }) 432 | 433 | t.Run("slink-acl", func(t *testing.T) { 434 | // verify that ls -lL works when applied to a symlink to an ACL'd file 435 | 436 | // require_setfacl_ 437 | 438 | // touch k 439 | // setfacl -m user::r-- k 440 | // ln -s k s 441 | 442 | // set _ $(ls -Log s); shift; link=$1 443 | // set _ $(ls -og k); shift; reg=$1 444 | 445 | // test "$link" = "$reg" 446 | }) 447 | 448 | t.Run("stat-dtype", func(t *testing.T) { 449 | // Ensure that ls --file-type does not call stat unnecessarily. Also 450 | // check for the dtype-related (and fs-type dependent) bug in 451 | // coreutils-6.0 that made ls -CF columns misaligned. 452 | // 453 | // The trick is to create an un-stat'able symlink and to see if ls can 454 | // report its type nonetheless, using dirent.d_type. 455 | // 456 | // Skip this test unless "." is on a file system with useful d_type 457 | // info. 458 | // FIXME: This uses "ls -p" to decide whether to test "ls" with other 459 | // options, but if ls's d_type code is buggy then "ls -p" might be buggy 460 | // too. 461 | 462 | // mkdir -p c/d 463 | // chmod a-x c 464 | // if test "X$(ls -p c 2>&1)" != Xd/; then 465 | // skip_ "'.' is not on a suitable file system for this test" 466 | // fi 467 | 468 | // mkdir d 469 | // ln -s / d/s 470 | // chmod 600 d 471 | 472 | // mkdir -p e/a2345 e/b 473 | // chmod 600 e 474 | 475 | // ls --file-type d > out 476 | // cat <<\EOF > exp 477 | // s@ 478 | // EOF 479 | // compare exp out 480 | 481 | // Check for the ls -CF misaligned-columns bug: 482 | // ls -CF e > out 483 | 484 | // coreutils-6.0 would print two spaces after the first slash, 485 | // rather than the appropriate TAB. 486 | // printf 'a2345/\tb/\n' > exp 487 | // compare exp out 488 | }) 489 | } 490 | -------------------------------------------------------------------------------- /usage.go: -------------------------------------------------------------------------------- 1 | // vim:et: 2 | 3 | package main 4 | 5 | import ( 6 | _ "embed" 7 | 8 | "zgo.at/elles/zli2" 9 | ) 10 | 11 | //go:embed completion.zsh 12 | var zsh string 13 | 14 | var usage = zli2.MustParse(` 15 | elles prints directory contents. https://github.com/arp242/elles 16 | 17 | What to list: 18 | 19 | -a, -all Show entries starting with . (except . and ..) or the 20 | "hidden" attribute (on Windows) 21 | -d, -directory List directories themselves, rather than their contents. 22 | -H Follow symlinks of commandline arguments. 23 | -L Follow all symlinks. 24 | -R, -recursive List subdirectories recursively. 25 | -i, -inode Print inode numbers. 26 | -g, -groupname Always display the group by name in -ll; by default it's 27 | only shown if the group group name is different from the 28 | username. 29 | 30 | How to list it: 31 | 32 | -j, -json Print as JSON. 33 | -l Long listing with size and mtime; use twice to show more. 34 | -1 List one path per line; default when stdout is not a tty 35 | -C List paths in columns; default when stdout is a tty. 36 | Single column (-1) is automatically set for -l, but can be 37 | overridden with this. 38 | -group-dirs Group directories first. Alias: -group-directories-first. 39 | -n Display user an group ID as number, rather than username. 40 | -w, -width=.. Maximum column width; longer columns will be trimmed. Set 41 | to 0 to disable. 42 | -m, -min=n Minimum number of columns to use, trimming columns that are 43 | too long. This does not set the exact number of columns and 44 | sometimes results in more columns. 45 | -o, -octal File permissions as octal instead of "rwx…". 46 | 47 | How to format paths: 48 | 49 | -color=.. When to apply colours; always, never, or auto (default). 50 | -hyperlink=.. Add link escape codes; always, never (default), or auto. 51 | -p Print / after each directory. 52 | -F Print /@*=|> after directory, symlink, executable file, 53 | socket, FIFO, or door. 54 | -, (Comma) Print file sizes with thousands separators. 55 | -B, -blocks=.. Format for file sizes; as: 56 | "1" or "B" for bytes 57 | "s" for allocated filesystem blocks 58 | "S" for blocks (differs from "s" for sparse files) 59 | unit as K, M, or G (powers of 1024) 60 | -c Use creation ("birth") time for display in -l, and sorting 61 | with -t. Does nothing if neither -l nor -t is given. 62 | -u Use last access time for display in -l, and sorting 63 | with -t. Does nothing if neither -l nor -t is given. 64 | -T Always display full time info, as "2006-01-02 15:00:00". 65 | When given twice it will also display nanoseconds and 66 | timezone. 67 | -Q Quote paths with special shell characters or spaces; add 68 | twice to always quote everything. 69 | -trim, -no-trim Trim pathnames if they're too long to fit on the screen. 70 | Only works for interactive terminals or when -w is set. 71 | -no-trim turns this off and takes precedence over -trim (so 72 | you can set -trim from an alias and turn it off). 73 | 74 | Sorting: 75 | 76 | -r, -reverse Reverse sort order. 77 | -S By file size, largest first. 78 | -X By file extension. 79 | -v By natural numbers within text. 80 | -t By modification time, newest first. 81 | -tc By creation ("birth") time, newest first. 82 | -tu By access time, newest first. 83 | -W By pathname width (number of codepoints), shortest first. 84 | -f Don't sort, list in directory order. Implies -a. 85 | -U Don't sort, list in directory order. 86 | -sort=.. Sort by …: none (-U), size (-S), time (-t), version (-v), 87 | extension (-X), width (-W) 88 | 89 | Other: 90 | 91 | -help Print this help and edit. 92 | -version Print version and exit. 93 | -completion=.. Print shell completion file. Supported shells: "zsh". 94 | -manpage Print manpage version of this help. 95 | 96 | Environment: 97 | 98 | COLUMNS Terminal width; falls back to ioctl if not set or 0. 99 | TZ Timezone to use to for displaying dates. 100 | ELLES_COLORS Colour configuration; see "Colours" section. 101 | LS_COLORS 102 | LSCOLORS 103 | 104 | Colours: 105 | 106 | The defaults colours are identical to FreeBSD ls on all BSD systems and 107 | macOS, and GNU ls on everything else. Use LS_COLORS (GNU ls format) or 108 | LSCOLORS (BSD ls format) to configure the colours. It will try them in that 109 | order and use the first one that's found (on all platforms). 110 | 111 | ELLES_COLORS can be used for elles-specific colourings. It won't look at 112 | LS_COLORS or LSCOLORS if it's set. The syntax of this follows GNU's 113 | LS_COLORS, with additional options: 114 | 115 | default Explicitly set which defalts to use, "bsd" or "gnu". The BSD 116 | defaults tend to work better on light backgrounds, and the GNU 117 | ones on dark backgrounds. 118 | 119 | hidden Additional highlights for hidden entries (e.g. those that start 120 | with a "."). These are applied after the regular colour codes. 121 | 122 | For example, to use the BSD defaults with a grey background for hidden 123 | files and highlighting *.exe as red: 124 | 125 | ELLES_COLORS='default=bsd:hidden=48;5;255:*.exe=31' 126 | 127 | Compatibility flags: 128 | 129 | -G Alias for -color=auto. 130 | -A, -almost-all Alias for -a (both omit . and ..). 131 | -h No-op, as elles uses human-readable sizes by default. 132 | -s Alias for -blocks=s 133 | `) 134 | -------------------------------------------------------------------------------- /zli2/usagep.go: -------------------------------------------------------------------------------- 1 | package zli2 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "zgo.at/termtext" 10 | "zgo.at/zli" 11 | ) 12 | 13 | // TODO: move to zli at some point. 14 | // 15 | // Many automatic usage generation tools are not very good. e.g. the Go flag 16 | // package is horrible. Let's see if we can't do something better. 17 | // 18 | // The idea is to wrote usage messages "naturally" as plain readable text, and 19 | // then we parse this. 20 | // 21 | // A header starts at column 1 and ends with a ":". 22 | // 23 | // Flags must always be indented with four spaces or a tab. The description can 24 | // be over multiple lines as long as they start at the same or higher column: 25 | // 26 | // Header: 27 | // 28 | // -flag1 Description. 29 | // --long A flag with 30 | // more than one line 31 | // of description. 32 | // -a, -all All! 33 | // 34 | // Text may be reflowed, depending on the output method. Use a blank line to 35 | // start a new paragraph, or @@ at the end of the line to force a hard line 36 | // break. 37 | // 38 | // -flag1 Description. 39 | // --long Must by one of:@@ 40 | // auto Automatically determine it.@@ 41 | // never Never do it.@@ 42 | // always Always force it.@@ 43 | // -a, -all All! 44 | 45 | type ( 46 | Usage struct { 47 | flags map[string]string 48 | Intro string 49 | Sections []Section 50 | } 51 | Section struct { 52 | Title string 53 | Text string 54 | Line int 55 | Flags []Flag 56 | } 57 | Flag struct { 58 | Names []string 59 | Text string 60 | Line int 61 | } 62 | ) 63 | 64 | var reFlag = regexp.MustCompile(`^(?:\t| )(-(?:[a-zA-Z0-9_.=…-]+|,)(?:, )?)+`) 65 | 66 | func Parse(s string) (Usage, error) { 67 | var ( 68 | u = Usage{flags: make(map[string]string)} 69 | lines = strings.Split(strings.TrimSpace(s), "\n") 70 | curSect Section 71 | cur strings.Builder 72 | skip int 73 | ) 74 | for i, l := range lines { 75 | if skip > 0 { 76 | skip-- 77 | continue 78 | } 79 | l = strings.TrimRight(l, " \t") 80 | if l == "" { 81 | cur.WriteByte('\n') 82 | continue 83 | } 84 | 85 | if (l[0] != ' ' && l[0] != '\t') && strings.HasSuffix(l, ":") { 86 | if cur.Len() > 0 { 87 | if curSect.Title == "" { 88 | u.Intro = strings.TrimSpace(cur.String()) 89 | } else { 90 | curSect.Text = strings.TrimSpace(cur.String()) 91 | u.Sections = append(u.Sections, curSect) 92 | } 93 | cur.Reset() 94 | curSect = Section{Title: strings.TrimSuffix(l, ":"), Line: i + 1} 95 | } 96 | continue 97 | } 98 | 99 | m := reFlag.FindString(l) 100 | if m != "" { 101 | t := l[len(m):] 102 | fl := Flag{ 103 | Line: i + 1, 104 | Names: strings.Split(strings.TrimSpace(m), ", "), 105 | Text: strings.TrimSpace(t), 106 | } 107 | off := len(m) + countSpace(t) 108 | for j := i + 1; j < len(lines); j++ { 109 | next := lines[j] 110 | if countSpace(next) < off { 111 | break 112 | } 113 | fl.Text += "\n" + strings.TrimLeft(next, " ") 114 | skip++ 115 | } 116 | for _, n := range fl.Names { 117 | u.flags[strings.TrimLeft(n, "-")] = fl.Text 118 | } 119 | 120 | curSect.Flags = append(curSect.Flags, fl) 121 | continue 122 | } 123 | 124 | cur.WriteString(strings.TrimPrefix(l, " ")) 125 | cur.WriteByte('\n') 126 | } 127 | if cur.Len() > 0 { 128 | curSect.Text = strings.TrimSpace(cur.String()) 129 | u.Sections = append(u.Sections, curSect) 130 | } 131 | return u, nil 132 | } 133 | 134 | func MustParse(s string) Usage { 135 | u, err := Parse(s) 136 | if err != nil { 137 | panic(err) 138 | } 139 | return u 140 | } 141 | 142 | func countSpace(s string) int { 143 | var n int 144 | for _, c := range s { 145 | if c != ' ' { 146 | break 147 | } 148 | n++ 149 | } 150 | return n 151 | } 152 | 153 | // Section gets documentation for the section. 154 | func (u Usage) Section(name string) (string, bool) { 155 | s, ok := u.flags[strings.TrimLeft(name, "-")] 156 | return s, ok 157 | } 158 | 159 | // flag gets documentation for the flag. 160 | func (u Usage) Flag(name string) (string, bool) { 161 | s, ok := u.flags[strings.TrimLeft(name, "-")] 162 | return s, ok 163 | } 164 | 165 | // TODO: store flags map per section. 166 | // func (s Section) Flag(name string) (string,bool _ 167 | // } 168 | 169 | // Should print back the original. 170 | func (u Usage) String() string { 171 | b := new(strings.Builder) 172 | b.WriteString(u.Intro) 173 | b.WriteByte('\n') 174 | 175 | for _, s := range u.Sections { 176 | fmt.Fprintf(b, "\n%s%s:%s\n", zli.Bold, s.Title, zli.Reset) 177 | if s.Text != "" { 178 | fmt.Fprintf(b, "\n %s", strings.ReplaceAll(s.Text, "\n", "\n ")) 179 | } 180 | b.WriteByte('\n') 181 | for _, f := range s.Flags { 182 | // for i := range f.Names { f.Names[i] = zli.Colorize(f.Names[i], zli.Bold) } 183 | n := termtext.AlignLeft(strings.Join(f.Names, ", "), 16) 184 | 185 | //f.Text = termtext.WordWrap(strings.ReplaceAll(f.Text, "\n", " "), 58, strings.Repeat(" ", 22)) 186 | f.Text = strings.ReplaceAll(f.Text, "\n", "\n"+strings.Repeat(" ", 22)) 187 | fmt.Fprintf(b, " %s %s\n", n, f.Text) 188 | } 189 | } 190 | return b.String() 191 | } 192 | 193 | func (u Usage) Mandoc(name string, sect int) string { 194 | b := new(strings.Builder) 195 | 196 | fmt.Fprintf(b, ".Dd %s \n", time.Now().Format("January 2, 2006")) 197 | fmt.Fprintf(b, ".Dt %s %d\n", strings.ToUpper(name), sect) 198 | b.WriteString(".Os\n") 199 | b.WriteString(".Sh NAME\n") 200 | u.Intro = strings.TrimPrefix(u.Intro, name+" ") 201 | fmt.Fprintf(b, "%s – %s\n", name, u.Intro) 202 | 203 | for _, s := range u.Sections { 204 | fmt.Fprintf(b, ".Sh %s\n", strings.ToUpper(s.Title)) 205 | if s.Text != "" { 206 | fmt.Fprintf(b, ".Pp\n%s\n", s.Text) 207 | } 208 | fmt.Fprintf(b, ".Bl -tag -width indent\n") 209 | // .It Fl D Ar format → -D format 210 | // .It Fl -color Ns = Ns Ar when → --color=when 211 | for _, f := range s.Flags { 212 | for i := range f.Names { 213 | f.Names[i] = strings.TrimPrefix(f.Names[i], "-") 214 | } 215 | fmt.Fprintf(b, ".It Fl %s\n", strings.Join(f.Names, " , Fl ")) 216 | // TODO: marking flags like this breaks some stuff; need to look 217 | // into it 218 | // regexp.MustCompile(`-\w+`).ReplaceAllStringFunc(f.Text, func(s string) string { 219 | // s = strings.TrimLeft(s, "-") 220 | // if _, ok := u.Flag(s); ok { 221 | // return "\n.Fl " + s + "\n" 222 | // } 223 | // return s 224 | // }) 225 | fmt.Fprintf(b, "%s\n", f.Text) 226 | } 227 | fmt.Fprintf(b, ".El\n") 228 | } 229 | return b.String() 230 | } 231 | 232 | // TODO: finish; to really get something decent we need at least: 233 | // 234 | // - A short description for flags. 235 | // - Annotate which flags conflict. 236 | // - Know if _files is correct. 237 | // - Flags that can be doubled (-ll). 238 | // 239 | // And also: 240 | // 241 | // - Flags that take arguments, and what kind. 242 | // - Positional arguments. 243 | func (u Usage) CompleteZsh(name, site string) string { 244 | b := new(strings.Builder) 245 | b.WriteString(fmt.Sprintf(`#compdef %[1]s 246 | 247 | # Completion for "elles"; %[2]s 248 | # 249 | # Save as "_%[1]s" in any directory in $fpath; see the current list with: 250 | # 251 | # print -l $fpath 252 | # 253 | # To add your own directory (before compinit): 254 | # 255 | # fpath=(~/.zsh/funcs $fpath) 256 | 257 | local arguments 258 | 259 | arguments=( 260 | `, name, site)) 261 | 262 | flRepl := strings.NewReplacer(`'`, `\'`, "\n", " ") 263 | for _, s := range u.Sections { 264 | for _, f := range s.Flags { 265 | var n string 266 | if len(f.Names) > 1 { 267 | n = "{" + strings.Join(f.Names, ",") + "}" 268 | } else { 269 | n = f.Names[0] 270 | } 271 | fmt.Fprintf(b, "\t(%s)%s'[%s]'\n", 272 | strings.Join(f.Names, " "), 273 | n, 274 | flRepl.Replace(f.Text)) 275 | } 276 | fmt.Fprintf(b, "\n") 277 | } 278 | 279 | b.WriteString("\t'*:file:_files'\n") 280 | b.WriteString(")\n\n_arguments -s -S : $arguments\n") 281 | return b.String() 282 | } 283 | 284 | // TODO 285 | func (u Usage) CompleteBash() string { 286 | b := new(strings.Builder) 287 | return b.String() 288 | } 289 | 290 | // TODO 291 | func (u Usage) CompleteFish() string { 292 | b := new(strings.Builder) 293 | return b.String() 294 | } 295 | --------------------------------------------------------------------------------