├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cmd └── tree │ └── tree.go ├── color.go ├── color_test.go ├── compileall.sh ├── csort_bsd.go ├── csort_generic.go ├── csort_unix.go ├── go.mod ├── modes_bsd.go ├── modes_unix.go ├── modes_unsupported.go ├── node.go ├── node_test.go ├── ostree ├── ostree.go ├── ostree_test.go └── testdata │ ├── a │ └── b │ │ └── b.txt │ └── c │ └── c.txt ├── sort.go ├── stat_unix.go └── stat_unsupported.go /.gitignore: -------------------------------------------------------------------------------- 1 | draft 2 | coverage 3 | *~ 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | arch: 2 | - amd64 3 | - ppc64le 4 | 5 | language: go 6 | sudo: false 7 | go: 8 | - 1.18.x 9 | - 1.19.x 10 | - 1.20.x 11 | - tip 12 | matrix: 13 | allow_failures: 14 | - go: tip 15 | install: 16 | - go get -t -v ./... 17 | script: 18 | - go test -v ./... 19 | - ./compileall.sh 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2017 Ariel Mashraki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tree [![Build status][travis-image]][travis-url] [![License][license-image]][license-url] 2 | --- 3 | > An implementation of the [`tree`](http://mama.indstate.edu/users/ice/tree/) command written in Go, that can be used programmatically. 4 | 5 | tree command 6 | 7 | #### Installation: 8 | ```sh 9 | $ go get github.com/a8m/tree/cmd/tree 10 | ``` 11 | 12 | #### How to use `tree` programmatically ? 13 | You can take a look on [`cmd/tree`](https://github.com/a8m/tree/blob/master/cmd/tree/tree.go), and [s3tree](http://github.com/a8m/s3tree) or see the example below. 14 | ```go 15 | import ( 16 | "github.com/a8m/tree" 17 | ) 18 | 19 | func main() { 20 | opts := &tree.Options{ 21 | // Fs, and OutFile are required fields. 22 | // fs should implement the tree file-system interface(see: tree.Fs), 23 | // and OutFile should be type io.Writer 24 | Fs: fs, 25 | OutFile: os.Stdout, 26 | // ... 27 | } 28 | inf.New("root-dir") 29 | // Visit all nodes recursively 30 | inf.Visit(opts) 31 | // Print nodes 32 | inf.Print(opts) 33 | } 34 | ``` 35 | 36 | ### License 37 | MIT 38 | 39 | 40 | [travis-image]: https://img.shields.io/travis/a8m/tree.svg?style=flat-square 41 | [travis-url]: https://travis-ci.org/a8m/tree 42 | [license-image]: http://img.shields.io/npm/l/deep-keys.svg?style=flat-square 43 | [license-url]: LICENSE 44 | -------------------------------------------------------------------------------- /cmd/tree/tree.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/a8m/tree" 10 | "github.com/a8m/tree/ostree" 11 | ) 12 | 13 | var ( 14 | // List 15 | a = flag.Bool("a", false, "") 16 | d = flag.Bool("d", false, "") 17 | f = flag.Bool("f", false, "") 18 | ignorecase = flag.Bool("ignore-case", false, "") 19 | noreport = flag.Bool("noreport", false, "") 20 | l = flag.Bool("l", false, "") 21 | L = flag.Int("L", 3, "") 22 | P = flag.String("P", "", "") 23 | I = flag.String("I", "", "") 24 | o = flag.String("o", "", "") 25 | // Files 26 | s = flag.Bool("s", false, "") 27 | h = flag.Bool("h", false, "") 28 | p = flag.Bool("p", false, "") 29 | u = flag.Bool("u", false, "") 30 | g = flag.Bool("g", false, "") 31 | Q = flag.Bool("Q", false, "") 32 | D = flag.Bool("D", false, "") 33 | inodes = flag.Bool("inodes", false, "") 34 | device = flag.Bool("device", false, "") 35 | // Sort 36 | U = flag.Bool("U", false, "") 37 | v = flag.Bool("v", false, "") 38 | t = flag.Bool("t", false, "") 39 | c = flag.Bool("c", false, "") 40 | r = flag.Bool("r", false, "") 41 | dirsfirst = flag.Bool("dirsfirst", false, "") 42 | sort = flag.String("sort", "", "") 43 | // Graphics 44 | i = flag.Bool("i", false, "") 45 | C = flag.Bool("C", false, "") 46 | ) 47 | 48 | var usage = `Usage: tree [options...] [paths...] 49 | 50 | Options: 51 | ------- Listing options ------- 52 | -a All files are listed. 53 | -d List directories only. 54 | -l Follow symbolic links like directories. 55 | -f Print the full path prefix for each file. 56 | -L Descend only level directories deep. 57 | -P List only those files that match the pattern given. 58 | -I Do not list files that match the given pattern. 59 | --ignore-case Ignore case when pattern matching. 60 | --noreport Turn off file/directory count at end of tree listing. 61 | -o filename Output to file instead of stdout. 62 | -------- File options --------- 63 | -Q Quote filenames with double quotes. 64 | -p Print the protections for each file. 65 | -u Displays file owner or UID number. 66 | -g Displays file group owner or GID number. 67 | -s Print the size in bytes of each file. 68 | -h Print the size in a more human readable way. 69 | -D Print the date of last modification or (-c) status change. 70 | --inodes Print inode number of each file. 71 | --device Print device ID number to which each file belongs. 72 | ------- Sorting options ------- 73 | -v Sort files alphanumerically by version. 74 | -t Sort files by last modification time. 75 | -c Sort files by last status change time. 76 | -U Leave files unsorted. 77 | -r Reverse the order of the sort. 78 | --dirsfirst List directories before files (-U disables). 79 | --sort X Select sort: name,version,size,mtime,ctime. 80 | ------- Graphics options ------ 81 | -i Don't print indentation lines. 82 | -C Turn colorization on always. 83 | ` 84 | 85 | func main() { 86 | flag.Usage = func() { fmt.Fprint(os.Stderr, usage) } 87 | var nd, nf int 88 | var dirs = []string{"."} 89 | flag.Parse() 90 | // Make it work with leading dirs 91 | if args := flag.Args(); len(args) > 0 { 92 | dirs = args 93 | } 94 | // Output file 95 | var outFile = os.Stdout 96 | var err error 97 | if *o != "" { 98 | outFile, err = os.Create(*o) 99 | if err != nil { 100 | errAndExit(err) 101 | } 102 | } 103 | defer outFile.Close() 104 | // Check sort-type 105 | if *sort != "" { 106 | switch *sort { 107 | case "version", "mtime", "ctime", "name", "size": 108 | default: 109 | msg := fmt.Sprintf("sort type '%s' not valid, should be one of: "+ 110 | "name,version,size,mtime,ctime", *sort) 111 | errAndExit(errors.New(msg)) 112 | } 113 | } 114 | // Set options 115 | opts := &tree.Options{ 116 | // Required 117 | Fs: new(ostree.FS), 118 | OutFile: outFile, 119 | // List 120 | All: *a, 121 | DirsOnly: *d, 122 | FullPath: *f, 123 | DeepLevel: *L, 124 | FollowLink: *l, 125 | Pattern: *P, 126 | IPattern: *I, 127 | IgnoreCase: *ignorecase, 128 | // Files 129 | ByteSize: *s, 130 | UnitSize: *h, 131 | FileMode: *p, 132 | ShowUid: *u, 133 | ShowGid: *g, 134 | LastMod: *D, 135 | Quotes: *Q, 136 | Inodes: *inodes, 137 | Device: *device, 138 | // Sort 139 | NoSort: *U, 140 | ReverSort: *r, 141 | DirSort: *dirsfirst, 142 | VerSort: *v || *sort == "version", 143 | ModSort: *t || *sort == "mtime", 144 | CTimeSort: *c || *sort == "ctime", 145 | NameSort: *sort == "name", 146 | SizeSort: *sort == "size", 147 | // Graphics 148 | NoIndent: *i, 149 | Colorize: *C, 150 | } 151 | for _, dir := range dirs { 152 | inf := tree.New(dir) 153 | d, f := inf.Visit(opts) 154 | nd, nf = nd+d, nf+f 155 | inf.Print(opts) 156 | } 157 | // Print footer report 158 | if !*noreport { 159 | footer := fmt.Sprintf("\n%d directories", nd) 160 | if !opts.DirsOnly { 161 | footer += fmt.Sprintf(", %d files", nf) 162 | } 163 | fmt.Fprintln(outFile, footer) 164 | } 165 | } 166 | 167 | func usageAndExit(msg string) { 168 | if msg != "" { 169 | fmt.Fprintf(os.Stderr, msg) 170 | fmt.Fprintf(os.Stderr, "\n\n") 171 | } 172 | flag.Usage() 173 | fmt.Fprintf(os.Stderr, "\n") 174 | os.Exit(1) 175 | } 176 | 177 | func errAndExit(err error) { 178 | fmt.Fprintf(os.Stderr, "tree: \"%s\"\n", err) 179 | os.Exit(1) 180 | } 181 | -------------------------------------------------------------------------------- /color.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | const Escape = "\x1b" 11 | const ( 12 | Reset int = 0 13 | // Not used, remove. 14 | Bold int = 1 15 | Black int = iota + 28 16 | Red 17 | Green 18 | Yellow 19 | Blue 20 | Magenta 21 | Cyan 22 | White 23 | ) 24 | 25 | // ANSIColorFormat 26 | func ANSIColorFormat(style string, s string) string { 27 | return fmt.Sprintf("%s[%sm%s%s[%dm", Escape, style, s, Escape, Reset) 28 | } 29 | 30 | // ANSIColor 31 | func ANSIColor(node *Node, s string) string { 32 | var style string 33 | var mode = node.Mode() 34 | var ext = filepath.Ext(node.Name()) 35 | switch { 36 | case contains([]string{".bat", ".btm", ".cmd", ".com", ".dll", ".exe"}, ext): 37 | style = "1;32" 38 | case contains([]string{".arj", ".bz2", ".deb", ".gz", ".lzh", ".rpm", 39 | ".tar", ".taz", ".tb2", ".tbz2", ".tbz", ".tgz", ".tz", ".tz2", ".z", 40 | ".zip", ".zoo"}, ext): 41 | style = "1;31" 42 | case contains([]string{".asf", ".avi", ".bmp", ".flac", ".gif", ".jpg", 43 | "jpeg", ".m2a", ".m2v", ".mov", ".mp3", ".mpeg", ".mpg", ".ogg", ".ppm", 44 | ".rm", ".tga", ".tif", ".wav", ".wmv", 45 | ".xbm", ".xpm"}, ext): 46 | style = "1;35" 47 | case node.IsDir() || mode&os.ModeDir != 0: 48 | style = "1;34" 49 | case mode&os.ModeNamedPipe != 0: 50 | style = "40;33" 51 | case mode&os.ModeSocket != 0: 52 | style = "40;1;35" 53 | case mode&os.ModeDevice != 0 || mode&os.ModeCharDevice != 0: 54 | style = "40;1;33" 55 | case mode&os.ModeSymlink != 0: 56 | if _, err := filepath.EvalSymlinks(node.path); err != nil { 57 | style = "40;1;31" 58 | } else { 59 | style = "1;36" 60 | } 61 | case mode&modeExecute != 0: 62 | style = "1;32" 63 | default: 64 | return s 65 | } 66 | return ANSIColorFormat(style, s) 67 | } 68 | 69 | // case-insensitive contains helper 70 | func contains(slice []string, str string) bool { 71 | for _, val := range slice { 72 | if val == strings.ToLower(str) { 73 | return true 74 | } 75 | } 76 | return false 77 | } 78 | 79 | // TODO: HTMLColor 80 | -------------------------------------------------------------------------------- /color_test.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | "testing" 7 | ) 8 | 9 | var extsTests = []struct { 10 | name string 11 | expected string 12 | }{ 13 | {"foo.jpg", "\x1b[1;35mfoo.jpg\x1b[0m"}, 14 | {"bar.tar", "\x1b[1;31mbar.tar\x1b[0m"}, 15 | {"baz.exe", "\x1b[1;32mbaz.exe\x1b[0m"}, 16 | } 17 | 18 | func TestExtension(t *testing.T) { 19 | for _, test := range extsTests { 20 | fi := &file{name: test.name} 21 | no := &Node{FileInfo: fi} 22 | if actual := ANSIColor(no, fi.name); actual != test.expected { 23 | t.Errorf("\ngot:\n%+v\nexpected:\n%+v", actual, test.expected) 24 | } 25 | } 26 | } 27 | 28 | var modeTests = []struct { 29 | path string 30 | name string 31 | expected string 32 | mode os.FileMode 33 | }{ 34 | {"", "simple", "simple", os.FileMode(0)}, 35 | {"", "dir", "\x1b[1;34mdir\x1b[0m", os.ModeDir}, 36 | {"", "socket", "\x1b[40;1;35msocket\x1b[0m", os.ModeSocket}, 37 | {"", "fifo", "\x1b[40;33mfifo\x1b[0m", os.ModeNamedPipe}, 38 | {"", "block", "\x1b[40;1;33mblock\x1b[0m", os.ModeDevice}, 39 | {"", "char", "\x1b[40;1;33mchar\x1b[0m", os.ModeCharDevice}, 40 | {"", "exist-symlink", "\x1b[1;36mexist-symlink\x1b[0m", os.ModeSymlink}, 41 | {"fake-path-a8m", "fake-path", "\x1b[40;1;31mfake-path\x1b[0m", os.ModeSymlink}, 42 | {"", "exec", "\x1b[1;32mexec\x1b[0m", os.FileMode(syscall.S_IXUSR)}, 43 | } 44 | 45 | func TestFileMode(t *testing.T) { 46 | for _, test := range modeTests { 47 | fi := &file{name: test.name, mode: test.mode} 48 | no := &Node{FileInfo: fi, path: test.path} 49 | if actual := ANSIColor(no, fi.name); actual != test.expected { 50 | t.Errorf("\ngot:\n%+v\nexpected:\n%+v", actual, test.expected) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /compileall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go tool dist list >/dev/null || { 4 | echo 1>&2 "go tool dist list not supported - can't check compile" 5 | exit 0 6 | } 7 | 8 | failures=0 9 | while read -r line; do 10 | parts=(${line//\// }) 11 | export GOOS=${parts[0]} 12 | export GOARCH=${parts[1]} 13 | if go tool compile -V >/dev/null 2>&1 ; then 14 | echo Try GOOS=${GOOS} GOARCH=${GOARCH} 15 | if ! go install; then 16 | echo "*** Failed compiling GOOS=${GOOS} GOARCH=${GOARCH}" 17 | failures=$((failures+1)) 18 | fi 19 | else 20 | echo Skipping GOOS=${GOOS} GOARCH=${GOARCH} as not supported 21 | fi 22 | done < <(go tool dist list) 23 | 24 | if [ $failures -ne 0 ]; then 25 | echo "*** $failures compile failures" 26 | exit 1 27 | fi 28 | -------------------------------------------------------------------------------- /csort_bsd.go: -------------------------------------------------------------------------------- 1 | //+build darwin freebsd netbsd 2 | 3 | package tree 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | ) 9 | 10 | func CTimeSort(f1, f2 os.FileInfo) bool { 11 | if f1 == nil || f2 == nil { 12 | return f2 == nil 13 | } 14 | s1, ok1 := f1.Sys().(*syscall.Stat_t) 15 | s2, ok2 := f2.Sys().(*syscall.Stat_t) 16 | // If this type of node isn't an os node then revert to ModSort 17 | if !ok1 || !ok2 { 18 | return ModSort(f1, f2) 19 | } 20 | return s1.Ctimespec.Sec < s2.Ctimespec.Sec 21 | } 22 | -------------------------------------------------------------------------------- /csort_generic.go: -------------------------------------------------------------------------------- 1 | //+build !linux,!openbsd,!dragonfly,!android,!solaris,!darwin,!freebsd,!netbsd 2 | 3 | package tree 4 | 5 | // CtimeSort for unsupported OS - just compare ModTime 6 | var CTimeSort = ModSort 7 | -------------------------------------------------------------------------------- /csort_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || openbsd || dragonfly || android || solaris 2 | // +build linux openbsd dragonfly android solaris 3 | 4 | package tree 5 | 6 | import ( 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | func CTimeSort(f1, f2 os.FileInfo) bool { 12 | if f1 == nil || f2 == nil { 13 | return f2 == nil 14 | } 15 | s1, ok1 := f1.Sys().(*syscall.Stat_t) 16 | s2, ok2 := f2.Sys().(*syscall.Stat_t) 17 | // If this type of node isn't an os node then revert to ModSort 18 | if !ok1 || !ok2 { 19 | return ModSort(f1, f2) 20 | } 21 | return s1.Ctim.Sec < s2.Ctim.Sec 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/a8m/tree 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /modes_bsd.go: -------------------------------------------------------------------------------- 1 | //+build dragonfly freebsd openbsd solaris windows 2 | 3 | package tree 4 | 5 | import "syscall" 6 | 7 | const modeExecute = syscall.S_IXUSR 8 | -------------------------------------------------------------------------------- /modes_unix.go: -------------------------------------------------------------------------------- 1 | //+build android darwin linux nacl netbsd 2 | 3 | package tree 4 | 5 | import "syscall" 6 | 7 | const modeExecute = syscall.S_IXUSR | syscall.S_IXGRP | syscall.S_IXOTH 8 | -------------------------------------------------------------------------------- /modes_unsupported.go: -------------------------------------------------------------------------------- 1 | //+build !dragonfly,!freebsd,!openbsd,!solaris,!windows,!android,!darwin,!linux,!nacl,!netbsd 2 | 3 | package tree 4 | 5 | const modeExecute = 0 6 | -------------------------------------------------------------------------------- /node.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | "regexp" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | // Node represent some node in the tree 18 | // contains FileInfo, and its childs 19 | type Node struct { 20 | os.FileInfo 21 | path string 22 | depth int 23 | err error 24 | nodes Nodes 25 | vpaths map[string]bool 26 | } 27 | 28 | // List of nodes 29 | type Nodes []*Node 30 | 31 | // To use this package programmatically, you must implement this 32 | // interface. 33 | // For example: PTAL on 'cmd/tree/tree.go' 34 | type Fs interface { 35 | Stat(path string) (os.FileInfo, error) 36 | ReadDir(path string) ([]string, error) 37 | } 38 | 39 | // Options store the configuration for specific tree. 40 | // Note, that 'Fs', and 'OutFile' are required (OutFile can be os.Stdout). 41 | type Options struct { 42 | Fs Fs 43 | OutFile io.Writer 44 | // List 45 | All bool 46 | DirsOnly bool 47 | FullPath bool 48 | IgnoreCase bool 49 | FollowLink bool 50 | DeepLevel int 51 | Pattern string 52 | IPattern string 53 | MatchDirs bool 54 | Prune bool 55 | // File 56 | ByteSize bool 57 | UnitSize bool 58 | FileMode bool 59 | ShowUid bool 60 | ShowGid bool 61 | LastMod bool 62 | Quotes bool 63 | Inodes bool 64 | Device bool 65 | // Sort 66 | NoSort bool 67 | VerSort bool 68 | ModSort bool 69 | DirSort bool 70 | NameSort bool 71 | SizeSort bool 72 | CTimeSort bool 73 | ReverSort bool 74 | // Graphics 75 | NoIndent bool 76 | Colorize bool 77 | // Color defaults to ANSIColor() 78 | Color func(*Node, string) string 79 | Now time.Time 80 | } 81 | 82 | func (opts *Options) color(node *Node, s string) string { 83 | f := opts.Color 84 | if f == nil { 85 | f = ANSIColor 86 | } 87 | return f(node, s) 88 | } 89 | 90 | // New get path and create new node(root). 91 | func New(path string) *Node { 92 | return &Node{path: path, vpaths: make(map[string]bool)} 93 | } 94 | 95 | // Visit all files under the given node. 96 | func (node *Node) Visit(opts *Options) (dirs, files int) { 97 | // visited paths 98 | if path, err := filepath.Abs(node.path); err == nil { 99 | path = filepath.Clean(path) 100 | node.vpaths[path] = true 101 | } 102 | // stat 103 | fi, err := opts.Fs.Stat(node.path) 104 | if err != nil { 105 | node.err = err 106 | return 107 | } 108 | node.FileInfo = fi 109 | if !fi.IsDir() { 110 | return 0, 1 111 | } 112 | // increase dirs only if it's a dir, but not the root. 113 | if node.depth != 0 { 114 | dirs++ 115 | } 116 | // DeepLevel option 117 | if opts.DeepLevel > 0 && opts.DeepLevel <= node.depth { 118 | return 119 | } 120 | // MatchDirs option 121 | var dirMatch bool 122 | if node.depth != 0 && opts.MatchDirs { 123 | // then disable prune and pattern for immediate children 124 | if opts.Pattern != "" { 125 | dirMatch = node.match(opts.Pattern, opts) 126 | } else if opts.IPattern != "" && node.match(opts.IPattern, opts) { 127 | return 128 | } 129 | } 130 | names, err := opts.Fs.ReadDir(node.path) 131 | if err != nil { 132 | node.err = err 133 | return 134 | } 135 | node.nodes = make(Nodes, 0) 136 | for _, name := range names { 137 | // "all" option 138 | if !opts.All && strings.HasPrefix(name, ".") { 139 | continue 140 | } 141 | nnode := &Node{ 142 | path: filepath.Join(node.path, name), 143 | depth: node.depth + 1, 144 | vpaths: node.vpaths, 145 | } 146 | d, f := nnode.Visit(opts) 147 | if nnode.err == nil { 148 | if nnode.IsDir() { 149 | // "prune" option, hide empty directories 150 | if opts.Prune && f == 0 { 151 | continue 152 | } 153 | if opts.MatchDirs && opts.IPattern != "" && nnode.match(opts.IPattern, opts) { 154 | continue 155 | } 156 | } else { 157 | // "dirs only" option 158 | if opts.DirsOnly { 159 | continue 160 | } 161 | // Pattern matching 162 | if !dirMatch && opts.Pattern != "" && !nnode.match(opts.Pattern, opts) { 163 | continue 164 | } 165 | // IPattern matching 166 | if opts.IPattern != "" && nnode.match(opts.IPattern, opts) { 167 | continue 168 | } 169 | } 170 | } 171 | node.nodes = append(node.nodes, nnode) 172 | dirs, files = dirs+d, files+f 173 | } 174 | // Sorting 175 | if !opts.NoSort { 176 | node.sort(opts) 177 | } 178 | return 179 | } 180 | 181 | func (node *Node) match(pattern string, opt *Options) bool { 182 | var prefix string 183 | if opt.IgnoreCase { 184 | prefix = "(?i)" 185 | } 186 | search := node.Name() 187 | if strings.Contains(pattern, "*") { 188 | search = node.path 189 | } 190 | re, err := regexp.Compile(prefix + pattern) 191 | return err == nil && re.FindString(search) != "" 192 | } 193 | 194 | func (node *Node) sort(opts *Options) { 195 | var fn SortFunc 196 | switch { 197 | case opts.ModSort: 198 | fn = ModSort 199 | case opts.CTimeSort: 200 | fn = CTimeSort 201 | case opts.DirSort: 202 | fn = DirSort 203 | case opts.VerSort: 204 | fn = VerSort 205 | case opts.SizeSort: 206 | fn = SizeSort 207 | case opts.NameSort: 208 | fn = NameSort 209 | default: 210 | fn = NameSort // Default should be sorted, not unsorted. 211 | } 212 | if fn != nil { 213 | if opts.ReverSort { 214 | sort.Sort(sort.Reverse(ByFunc{node.nodes, fn})) 215 | } else { 216 | sort.Sort(ByFunc{node.nodes, fn}) 217 | } 218 | } 219 | } 220 | 221 | // Path returns the Node's absolute path 222 | func (node *Node) Path() string { 223 | return node.path 224 | } 225 | 226 | // Print nodes based on the given configuration. 227 | func (node *Node) Print(opts *Options) { node.print("", opts) } 228 | 229 | func dirRecursiveSize(opts *Options, node *Node) (size int64, err error) { 230 | if opts.DeepLevel > 0 && node.depth >= opts.DeepLevel { 231 | err = errors.New("Depth too high") 232 | } 233 | 234 | for _, nnode := range node.nodes { 235 | if nnode.err != nil { 236 | err = nnode.err 237 | continue 238 | } 239 | 240 | if !nnode.IsDir() { 241 | size += nnode.Size() 242 | } else { 243 | nsize, e := dirRecursiveSize(opts, nnode) 244 | size += nsize 245 | if e != nil { 246 | err = e 247 | } 248 | } 249 | } 250 | return 251 | } 252 | 253 | func (node *Node) print(indent string, opts *Options) { 254 | if node.err != nil { 255 | err := node.err.Error() 256 | if msgs := strings.Split(err, ": "); len(msgs) > 1 { 257 | err = msgs[1] 258 | } 259 | name := node.path 260 | if !opts.FullPath { 261 | name = filepath.Base(name) 262 | } 263 | fmt.Fprintf(opts.OutFile, "%s [%s]\n", name, err) 264 | return 265 | } 266 | if !node.IsDir() { 267 | var props []string 268 | ok, inode, device, uid, gid := getStat(node) 269 | // inodes 270 | if ok && opts.Inodes { 271 | props = append(props, fmt.Sprintf("%d", inode)) 272 | } 273 | // device 274 | if ok && opts.Device { 275 | props = append(props, fmt.Sprintf("%3d", device)) 276 | } 277 | // Mode 278 | if opts.FileMode { 279 | props = append(props, node.Mode().String()) 280 | } 281 | // Owner/Uid 282 | if ok && opts.ShowUid { 283 | uidStr := strconv.Itoa(int(uid)) 284 | if u, err := user.LookupId(uidStr); err != nil { 285 | props = append(props, fmt.Sprintf("%-8s", uidStr)) 286 | } else { 287 | props = append(props, fmt.Sprintf("%-8s", u.Username)) 288 | } 289 | } 290 | // Gorup/Gid 291 | // TODO: support groupname 292 | if ok && opts.ShowGid { 293 | gidStr := strconv.Itoa(int(gid)) 294 | props = append(props, fmt.Sprintf("%-4s", gidStr)) 295 | } 296 | // Size 297 | if opts.ByteSize || opts.UnitSize { 298 | var size string 299 | if opts.UnitSize { 300 | size = fmt.Sprintf("%4s", formatBytes(node.Size())) 301 | } else { 302 | size = fmt.Sprintf("%11d", node.Size()) 303 | } 304 | props = append(props, size) 305 | } 306 | // Last modification 307 | if opts.LastMod { 308 | t := opts.Now 309 | if t.IsZero() { 310 | t = time.Now() 311 | } 312 | 313 | format := "Jan 02 15:04" 314 | if node.ModTime().Year() != t.Year() { 315 | format = "Jan 02 2006" 316 | } 317 | 318 | props = append(props, node.ModTime().Format(format)) 319 | } 320 | // Print properties 321 | if len(props) > 0 { 322 | fmt.Fprintf(opts.OutFile, "[%s] ", strings.Join(props, " ")) 323 | } 324 | } else { 325 | var props []string 326 | // Size 327 | if opts.ByteSize || opts.UnitSize { 328 | var size string 329 | rsize, err := dirRecursiveSize(opts, node) 330 | if err != nil && rsize <= 0 { 331 | if opts.UnitSize { 332 | size = "????" 333 | } else { 334 | size = "???????????" 335 | } 336 | } else if opts.UnitSize { 337 | size = fmt.Sprintf("%4s", formatBytes(rsize)) 338 | } else { 339 | size = fmt.Sprintf("%11d", rsize) 340 | } 341 | props = append(props, size) 342 | } 343 | // Print properties 344 | if len(props) > 0 { 345 | fmt.Fprintf(opts.OutFile, "[%s] ", strings.Join(props, " ")) 346 | } 347 | } 348 | // name/path 349 | var name string 350 | if node.depth == 0 || opts.FullPath { 351 | name = node.path 352 | } else { 353 | name = node.Name() 354 | } 355 | // Quotes 356 | if opts.Quotes { 357 | name = fmt.Sprintf("\"%s\"", name) 358 | } 359 | // Colorize 360 | if opts.Colorize { 361 | name = opts.color(node, name) 362 | } 363 | // IsSymlink 364 | if node.Mode()&os.ModeSymlink == os.ModeSymlink { 365 | vtarget, err := os.Readlink(node.path) 366 | if err != nil { 367 | vtarget = node.path 368 | } 369 | targetPath, err := filepath.EvalSymlinks(node.path) 370 | if err != nil { 371 | targetPath = vtarget 372 | } 373 | fi, err := opts.Fs.Stat(targetPath) 374 | if opts.Colorize && fi != nil { 375 | vtarget = opts.color(&Node{FileInfo: fi, path: vtarget}, vtarget) 376 | } 377 | name = fmt.Sprintf("%s -> %s", name, vtarget) 378 | // Follow symbolic links like directories 379 | if opts.FollowLink { 380 | path, err := filepath.Abs(targetPath) 381 | if err == nil && fi != nil && fi.IsDir() { 382 | if _, ok := node.vpaths[filepath.Clean(path)]; !ok { 383 | inf := &Node{FileInfo: fi, path: targetPath} 384 | inf.vpaths = node.vpaths 385 | inf.Visit(opts) 386 | node.nodes = inf.nodes 387 | } else { 388 | name += " [recursive, not followed]" 389 | } 390 | } 391 | } 392 | } 393 | // Print file details 394 | // the main idea of the print logic came from here: github.com/campoy/tools/tree 395 | fmt.Fprintln(opts.OutFile, name) 396 | add := "│ " 397 | for i, nnode := range node.nodes { 398 | if opts.NoIndent { 399 | add = "" 400 | } else { 401 | if i == len(node.nodes)-1 { 402 | fmt.Fprintf(opts.OutFile, indent+"└── ") 403 | add = " " 404 | } else { 405 | fmt.Fprintf(opts.OutFile, indent+"├── ") 406 | } 407 | } 408 | nnode.print(indent+add, opts) 409 | } 410 | } 411 | 412 | const ( 413 | _ = iota // ignore first value by assigning to blank identifier 414 | KB int64 = 1 << (10 * iota) 415 | MB 416 | GB 417 | TB 418 | PB 419 | EB 420 | ) 421 | 422 | // Convert bytes to human readable string. Like a 2 MB, 64.2 KB, 52 B 423 | func formatBytes(i int64) (result string) { 424 | var n float64 425 | sFmt, eFmt := "%.01f", "" 426 | switch { 427 | case i > EB: 428 | eFmt = "E" 429 | n = float64(i) / float64(EB) 430 | case i > PB: 431 | eFmt = "P" 432 | n = float64(i) / float64(PB) 433 | case i > TB: 434 | eFmt = "T" 435 | n = float64(i) / float64(TB) 436 | case i > GB: 437 | eFmt = "G" 438 | n = float64(i) / float64(GB) 439 | case i > MB: 440 | eFmt = "M" 441 | n = float64(i) / float64(MB) 442 | case i > KB: 443 | eFmt = "K" 444 | n = float64(i) / float64(KB) 445 | default: 446 | sFmt = "%.0f" 447 | n = float64(i) 448 | } 449 | if eFmt != "" && n >= 10 { 450 | sFmt = "%.0f" 451 | } 452 | result = fmt.Sprintf(sFmt+eFmt, n) 453 | result = strings.Trim(result, " ") 454 | return 455 | } 456 | -------------------------------------------------------------------------------- /node_test.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "syscall" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // Mock file/FileInfo 12 | type file struct { 13 | name string 14 | size int64 15 | files []*file 16 | lastMod time.Time 17 | stat interface{} 18 | mode os.FileMode 19 | } 20 | 21 | func (f file) Name() string { return f.name } 22 | func (f file) Size() int64 { return f.size } 23 | func (f file) Mode() (o os.FileMode) { 24 | if f.mode != o { 25 | return f.mode 26 | } 27 | if f.stat != nil { 28 | stat := (f.stat).(*syscall.Stat_t) 29 | o = os.FileMode(stat.Mode) 30 | } 31 | return 32 | } 33 | func (f file) ModTime() time.Time { return f.lastMod } 34 | func (f file) IsDir() bool { return nil != f.files } 35 | func (f file) Sys() interface{} { 36 | if f.stat == nil { 37 | return new(syscall.Stat_t) 38 | } 39 | return f.stat 40 | } 41 | 42 | // Mock filesystem 43 | type MockFs struct { 44 | files map[string]*file 45 | } 46 | 47 | func NewFs() *MockFs { 48 | return &MockFs{make(map[string]*file)} 49 | } 50 | 51 | func (fs *MockFs) clean() *MockFs { 52 | fs.files = make(map[string]*file) 53 | return fs 54 | } 55 | 56 | func (fs *MockFs) addFile(path string, file *file) *MockFs { 57 | fs.files[path] = file 58 | if file.IsDir() { 59 | for _, f := range file.files { 60 | fs.addFile(path+"/"+f.name, f) 61 | } 62 | } 63 | return fs 64 | } 65 | 66 | func (fs *MockFs) Stat(path string) (os.FileInfo, error) { 67 | if path == "root/bad" { 68 | return nil, errors.New("stat failed") 69 | } 70 | return fs.files[path], nil 71 | } 72 | func (fs *MockFs) ReadDir(path string) ([]string, error) { 73 | var names []string 74 | for _, file := range fs.files[path].files { 75 | names = append(names, file.Name()) 76 | } 77 | return names, nil 78 | } 79 | 80 | // Mock output file 81 | type Out struct { 82 | str string 83 | } 84 | 85 | func (o *Out) equal(s string) bool { 86 | return o.str == s 87 | } 88 | 89 | func (o *Out) Write(p []byte) (int, error) { 90 | o.str += string(p) 91 | return len(p), nil 92 | } 93 | 94 | func (o *Out) clear() { 95 | o.str = "" 96 | } 97 | 98 | // FileSystem and Stdout mocks 99 | var ( 100 | fs = NewFs() 101 | out = new(Out) 102 | ) 103 | 104 | type treeTest struct { 105 | name string 106 | opts *Options // test params. 107 | expected string // expected output. 108 | dirs int // expected dir count. 109 | files int // expected file count. 110 | } 111 | 112 | var listTests = []treeTest{ 113 | {"basic", &Options{Fs: fs, OutFile: out}, `root 114 | ├── a 115 | ├── b 116 | ├── c 117 | │ ├── d 118 | │ ├── e 119 | │ ├── g 120 | │ │ ├── h 121 | │ │ └── i 122 | │ └── k 123 | └── j 124 | `, 2, 8}, 125 | {"all", &Options{Fs: fs, OutFile: out, All: true, NoSort: true}, `root 126 | ├── a 127 | ├── b 128 | ├── c 129 | │ ├── d 130 | │ ├── e 131 | │ ├── .f 132 | │ ├── g 133 | │ │ ├── h 134 | │ │ └── i 135 | │ └── k 136 | └── j 137 | `, 2, 9}, 138 | {"dirs", &Options{Fs: fs, OutFile: out, DirsOnly: true}, `root 139 | └── c 140 | └── g 141 | `, 2, 0}, 142 | {"fullPath", &Options{Fs: fs, OutFile: out, FullPath: true}, `root 143 | ├── root/a 144 | ├── root/b 145 | ├── root/c 146 | │ ├── root/c/d 147 | │ ├── root/c/e 148 | │ ├── root/c/g 149 | │ │ ├── root/c/g/h 150 | │ │ └── root/c/g/i 151 | │ └── root/c/k 152 | └── root/j 153 | `, 2, 8}, 154 | {"deepLevel", &Options{Fs: fs, OutFile: out, DeepLevel: 1}, `root 155 | ├── a 156 | ├── b 157 | ├── c 158 | └── j 159 | `, 1, 3}, 160 | {"pattern (a|e|i)", &Options{Fs: fs, OutFile: out, Pattern: "(a|e|i)"}, `root 161 | ├── a 162 | └── c 163 | ├── e 164 | └── g 165 | └── i 166 | `, 2, 3}, 167 | {"pattern (x) + 0 files", &Options{Fs: fs, OutFile: out, Pattern: "(x)"}, `root 168 | └── c 169 | └── g 170 | `, 2, 0}, 171 | {"ipattern (a|e|i)", &Options{Fs: fs, OutFile: out, IPattern: "(a|e|i)"}, `root 172 | ├── b 173 | ├── c 174 | │ ├── d 175 | │ ├── g 176 | │ │ └── h 177 | │ └── k 178 | └── j 179 | `, 2, 5}, 180 | {"pattern (A) + ignore-case", &Options{Fs: fs, OutFile: out, Pattern: "(A)", IgnoreCase: true}, `root 181 | ├── a 182 | └── c 183 | └── g 184 | `, 2, 1}, 185 | {"pattern (A) + ignore-case + prune", &Options{Fs: fs, OutFile: out, Pattern: "(A)", Prune: true, IgnoreCase: true}, `root 186 | └── a 187 | `, 0, 1}, 188 | {"pattern (a) + prune", &Options{Fs: fs, OutFile: out, Pattern: "(a)", Prune: true}, `root 189 | └── a 190 | `, 0, 1}, 191 | {"pattern (c) + matchdirs", &Options{Fs: fs, OutFile: out, Pattern: "(c)", MatchDirs: true}, `root 192 | └── c 193 | ├── d 194 | ├── e 195 | ├── g 196 | └── k 197 | `, 2, 3}, 198 | {"pattern (c.*) + matchdirs", &Options{Fs: fs, OutFile: out, Pattern: "(c.*)", MatchDirs: true}, `root 199 | └── c 200 | ├── d 201 | ├── e 202 | ├── g 203 | │ ├── h 204 | │ └── i 205 | └── k 206 | `, 2, 5}, 207 | {"ipattern (c) + matchdirs", &Options{Fs: fs, OutFile: out, IPattern: "(c)", MatchDirs: true}, `root 208 | ├── a 209 | ├── b 210 | └── j 211 | `, 0, 3}, 212 | {"ipattern (g) + matchdirs", &Options{Fs: fs, OutFile: out, IPattern: "(g)", MatchDirs: true}, `root 213 | ├── a 214 | ├── b 215 | ├── c 216 | │ ├── d 217 | │ ├── e 218 | │ └── k 219 | └── j 220 | `, 1, 6}, 221 | {"ipattern (a|e|i|h) + matchdirs + prune", &Options{Fs: fs, OutFile: out, IPattern: "(a|e|i|h)", MatchDirs: true, Prune: true}, `root 222 | ├── b 223 | ├── c 224 | │ ├── d 225 | │ └── k 226 | └── j 227 | `, 1, 4}, 228 | {"pattern (d|e) + prune", &Options{Fs: fs, OutFile: out, Pattern: "(d|e)", Prune: true}, `root 229 | └── c 230 | ├── d 231 | └── e 232 | `, 1, 2}, 233 | {"pattern (c.*) + matchdirs + prune ", &Options{Fs: fs, OutFile: out, Pattern: "(c.*)", Prune: true, MatchDirs: true}, `root 234 | └── c 235 | ├── d 236 | ├── e 237 | ├── g 238 | │ ├── h 239 | │ └── i 240 | └── k 241 | `, 2, 5}, 242 | } 243 | 244 | func TestSimple(t *testing.T) { 245 | root := &file{ 246 | name: "root", 247 | size: 200, 248 | files: []*file{ 249 | {name: "a", size: 50}, 250 | {name: "b", size: 50}, 251 | { 252 | name: "c", 253 | size: 100, 254 | files: []*file{ 255 | {name: "d", size: 50}, 256 | {name: "e", size: 50}, 257 | {name: ".f", size: 0}, 258 | { 259 | name: "g", 260 | size: 100, 261 | files: []*file{ 262 | {name: "h", size: 50}, 263 | {name: "i", size: 50}, 264 | }, 265 | }, 266 | {name: "k", size: 50}, 267 | }, 268 | }, 269 | {name: "j", size: 50}, 270 | }, 271 | } 272 | fs.clean().addFile(root.name, root) 273 | for _, test := range listTests { 274 | inf := New(root.name) 275 | d, f := inf.Visit(test.opts) 276 | if d != test.dirs { 277 | t.Errorf("wrong dir count for test %q:\ngot:\n%d\nexpected:\n%d", test.name, d, test.dirs) 278 | } 279 | if f != test.files { 280 | t.Errorf("wrong file count for test %q:\ngot:\n%d\nexpected:\n%d", test.name, f, test.files) 281 | } 282 | inf.Print(test.opts) 283 | if !out.equal(test.expected) { 284 | t.Errorf("%s:\ngot:\n%+v\nexpected:\n%+v", test.name, out.str, test.expected) 285 | } 286 | out.clear() 287 | } 288 | } 289 | 290 | var sortTests = []treeTest{ 291 | {"name-sort", &Options{Fs: fs, OutFile: out, NameSort: true}, `root 292 | ├── a 293 | ├── b 294 | └── c 295 | └── d 296 | `, 1, 3}, 297 | {"dirs-first sort", &Options{Fs: fs, OutFile: out, DirSort: true}, `root 298 | ├── c 299 | │ └── d 300 | ├── b 301 | └── a 302 | `, 1, 3}, 303 | {"reverse sort", &Options{Fs: fs, OutFile: out, ReverSort: true, DirSort: true}, `root 304 | ├── b 305 | ├── a 306 | └── c 307 | └── d 308 | `, 1, 3}, 309 | {"no-sort", &Options{Fs: fs, OutFile: out, NoSort: true, DirSort: true}, `root 310 | ├── b 311 | ├── c 312 | │ └── d 313 | └── a 314 | `, 1, 3}, 315 | {"size-sort", &Options{Fs: fs, OutFile: out, SizeSort: true}, `root 316 | ├── a 317 | ├── c 318 | │ └── d 319 | └── b 320 | `, 1, 3}, 321 | {"last-mod-sort", &Options{Fs: fs, OutFile: out, ModSort: true}, `root 322 | ├── a 323 | ├── b 324 | └── c 325 | └── d 326 | `, 1, 3}, 327 | {"c-time-sort", &Options{Fs: fs, OutFile: out, CTimeSort: true}, `root 328 | ├── b 329 | ├── c 330 | │ └── d 331 | └── a 332 | `, 1, 3}} 333 | 334 | func TestSort(t *testing.T) { 335 | tFmt := "2006-Jan-02" 336 | aTime, _ := time.Parse(tFmt, "2015-Aug-01") 337 | bTime, _ := time.Parse(tFmt, "2015-Sep-01") 338 | cTime, _ := time.Parse(tFmt, "2015-Oct-01") 339 | root := &file{ 340 | name: "root", 341 | size: 200, 342 | files: []*file{ 343 | {name: "b", size: 11, lastMod: bTime}, 344 | {name: "c", size: 10, files: []*file{{name: "d", size: 10, lastMod: cTime}}, lastMod: cTime}, 345 | {name: "a", size: 9, lastMod: aTime}, 346 | }, 347 | } 348 | fs.clean().addFile(root.name, root) 349 | for _, test := range sortTests { 350 | inf := New(root.name) 351 | inf.Visit(test.opts) 352 | inf.Print(test.opts) 353 | if !out.equal(test.expected) { 354 | t.Errorf("%s:\ngot:\n%+v\nexpected:\n%+v", test.name, out.str, test.expected) 355 | } 356 | out.clear() 357 | } 358 | } 359 | 360 | var graphicTests = []treeTest{ 361 | {"no-indent", &Options{Fs: fs, OutFile: out, NoIndent: true}, `root 362 | a 363 | b 364 | c 365 | `, 0, 3}, 366 | {"quotes", &Options{Fs: fs, OutFile: out, Quotes: true}, `"root" 367 | ├── "a" 368 | ├── "b" 369 | └── "c" 370 | `, 0, 3}, 371 | {"byte-size", &Options{Fs: fs, OutFile: out, ByteSize: true}, `[ 12499] root 372 | ├── [ 1500] a 373 | ├── [ 9999] b 374 | └── [ 1000] c 375 | `, 0, 3}, 376 | {"unit-size", &Options{Fs: fs, OutFile: out, UnitSize: true}, `[ 12K] root 377 | ├── [1.5K] a 378 | ├── [9.8K] b 379 | └── [1000] c 380 | `, 0, 3}, 381 | {"show-gid", &Options{Fs: fs, OutFile: out, ShowGid: true}, `root 382 | ├── [1 ] a 383 | ├── [2 ] b 384 | └── [1 ] c 385 | `, 0, 3}, 386 | {"mode", &Options{Fs: fs, OutFile: out, FileMode: true}, `root 387 | ├── [-rw-r--r--] a 388 | ├── [-rwxr-xr-x] b 389 | └── [-rw-rw-rw-] c 390 | `, 0, 3}, 391 | {"lastMod", &Options{Fs: fs, OutFile: out, LastMod: true, Now: time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)}, `root 392 | ├── [Feb 11 00:00] a 393 | ├── [Jan 28 2006] b 394 | └── [Jul 12 00:00] c 395 | `, 0, 3}} 396 | 397 | func TestGraphics(t *testing.T) { 398 | tFmt := "2006-Jan-02" 399 | aTime, _ := time.Parse(tFmt, "2015-Feb-11") 400 | bTime, _ := time.Parse(tFmt, "2006-Jan-28") 401 | cTime, _ := time.Parse(tFmt, "2015-Jul-12") 402 | root := &file{ 403 | name: "root", 404 | size: 11499, 405 | files: []*file{ 406 | {name: "a", size: 1500, lastMod: aTime, stat: &syscall.Stat_t{Gid: 1, Mode: 0644}}, 407 | {name: "b", size: 9999, lastMod: bTime, stat: &syscall.Stat_t{Gid: 2, Mode: 0755}}, 408 | {name: "c", size: 1000, lastMod: cTime, stat: &syscall.Stat_t{Gid: 1, Mode: 0666}}, 409 | }, 410 | stat: &syscall.Stat_t{Gid: 1}, 411 | } 412 | fs.clean().addFile(root.name, root) 413 | for _, test := range graphicTests { 414 | inf := New(root.name) 415 | inf.Visit(test.opts) 416 | inf.Print(test.opts) 417 | if !out.equal(test.expected) { 418 | t.Errorf("%s:\ngot:\n%+v\nexpected:\n%+v", test.name, out.str, test.expected) 419 | } 420 | out.clear() 421 | } 422 | } 423 | 424 | var symlinkTests = []treeTest{ 425 | {"symlink", &Options{Fs: fs, OutFile: out}, `root 426 | └── symlink -> root/symlink 427 | `, 0, 1}, 428 | {"symlink-rec", &Options{Fs: fs, OutFile: out, FollowLink: true}, `root 429 | └── symlink -> root/symlink [recursive, not followed] 430 | `, 0, 1}} 431 | 432 | func TestSymlink(t *testing.T) { 433 | root := &file{ 434 | name: "root", 435 | files: []*file{ 436 | &file{name: "symlink", mode: os.ModeSymlink, files: make([]*file, 0)}, 437 | }, 438 | } 439 | fs.clean().addFile(root.name, root) 440 | for _, test := range symlinkTests { 441 | inf := New(root.name) 442 | inf.Visit(test.opts) 443 | inf.Print(test.opts) 444 | if !out.equal(test.expected) { 445 | t.Errorf("%s:\ngot:\n%+v\nexpected:\n%+v", test.name, out.str, test.expected) 446 | } 447 | out.clear() 448 | } 449 | } 450 | 451 | func TestCount(t *testing.T) { 452 | defer out.clear() 453 | root := &file{ 454 | name: "root", 455 | files: []*file{ 456 | &file{ 457 | name: "a", 458 | files: []*file{ 459 | { 460 | name: "b", 461 | files: []*file{{name: "c"}}, 462 | }, 463 | { 464 | name: "d", 465 | files: []*file{ 466 | { 467 | name: "e", 468 | files: []*file{{name: "f"}, {name: "g"}}, 469 | }, 470 | }, 471 | }, 472 | { 473 | name: "h", 474 | files: []*file{ 475 | { 476 | name: "i", 477 | files: []*file{{name: "j"}}, 478 | }, 479 | { 480 | name: "k", 481 | files: []*file{{name: "l"}, {name: "m"}}, 482 | }, 483 | {name: "n"}, 484 | {name: "o"}, 485 | }, 486 | }, 487 | }, 488 | }}, 489 | } 490 | fs.clean().addFile(root.name, root) 491 | opt := &Options{Fs: fs, OutFile: out} 492 | inf := New(root.name) 493 | d, f := inf.Visit(opt) 494 | if d != 7 || f != 8 { 495 | inf.Print(opt) 496 | t.Errorf("TestCount - expect (dir, file) count to be equal to (7, 8)\n%s", out.str) 497 | } 498 | } 499 | 500 | var errorTests = []treeTest{ 501 | {"basic", &Options{Fs: fs, OutFile: out}, `root 502 | ├── a 503 | ├── b 504 | ├── j 505 | └── bad [stat failed] 506 | `, 0, 3}, 507 | {"all", &Options{Fs: fs, OutFile: out, All: true, NoSort: true}, `root 508 | ├── a 509 | ├── b 510 | ├── j 511 | └── bad [stat failed] 512 | `, 0, 3}, 513 | {"dirs", &Options{Fs: fs, OutFile: out, DirsOnly: true}, `root 514 | └── bad [stat failed] 515 | `, 0, 0}, 516 | {"fullPath", &Options{Fs: fs, OutFile: out, FullPath: true}, `root 517 | ├── root/a 518 | ├── root/b 519 | ├── root/j 520 | └── root/bad [stat failed] 521 | `, 0, 3}, 522 | {"deepLevel", &Options{Fs: fs, OutFile: out, DeepLevel: 1}, `root 523 | ├── a 524 | ├── b 525 | ├── j 526 | └── bad [stat failed] 527 | `, 0, 3}, 528 | {"pattern (a|e|i)", &Options{Fs: fs, OutFile: out, Pattern: "(a|e|i)"}, `root 529 | ├── a 530 | └── bad [stat failed] 531 | `, 0, 1}, 532 | {"pattern (x) + 0 files", &Options{Fs: fs, OutFile: out, Pattern: "(x)"}, `root 533 | └── bad [stat failed] 534 | `, 0, 0}, 535 | {"ipattern (a|e|i)", &Options{Fs: fs, OutFile: out, IPattern: "(a|e|i)"}, `root 536 | ├── b 537 | ├── j 538 | └── bad [stat failed] 539 | `, 0, 2}, 540 | {"pattern (A) + ignore-case", &Options{Fs: fs, OutFile: out, Pattern: "(A)", IgnoreCase: true}, `root 541 | ├── a 542 | └── bad [stat failed] 543 | `, 0, 1}, 544 | {"pattern (A) + ignore-case + prune", &Options{Fs: fs, OutFile: out, Pattern: "(A)", Prune: true, IgnoreCase: true}, `root 545 | ├── a 546 | └── bad [stat failed] 547 | `, 0, 1}, 548 | {"pattern (a) + prune", &Options{Fs: fs, OutFile: out, Pattern: "(a)", Prune: true}, `root 549 | ├── a 550 | └── bad [stat failed] 551 | `, 0, 1}, 552 | {"pattern (c) + matchdirs", &Options{Fs: fs, OutFile: out, Pattern: "(c)", MatchDirs: true}, `root 553 | └── bad [stat failed] 554 | `, 0, 0}, 555 | {"pattern (c.*) + matchdirs", &Options{Fs: fs, OutFile: out, Pattern: "(c.*)", MatchDirs: true}, `root 556 | └── bad [stat failed] 557 | `, 0, 0}, 558 | {"ipattern (c) + matchdirs", &Options{Fs: fs, OutFile: out, IPattern: "(c)", MatchDirs: true}, `root 559 | ├── a 560 | ├── b 561 | ├── j 562 | └── bad [stat failed] 563 | `, 0, 3}, 564 | {"ipattern (g) + matchdirs", &Options{Fs: fs, OutFile: out, IPattern: "(g)", MatchDirs: true}, `root 565 | ├── a 566 | ├── b 567 | ├── j 568 | └── bad [stat failed] 569 | `, 0, 3}, 570 | {"ipattern (a|e|i|h) + matchdirs + prune", &Options{Fs: fs, OutFile: out, IPattern: "(a|e|i|h)", MatchDirs: true, Prune: true}, `root 571 | ├── b 572 | ├── j 573 | └── bad [stat failed] 574 | `, 0, 2}, 575 | {"pattern (d|e) + prune", &Options{Fs: fs, OutFile: out, Pattern: "(d|e)", Prune: true}, `root 576 | └── bad [stat failed] 577 | `, 0, 0}, 578 | {"pattern (c.*) + matchdirs + prune ", &Options{Fs: fs, OutFile: out, Pattern: "(c.*)", Prune: true, MatchDirs: true}, `root 579 | └── bad [stat failed] 580 | `, 0, 0}, 581 | 582 | {"name-sort", &Options{Fs: fs, OutFile: out, NameSort: true}, `root 583 | ├── a 584 | ├── b 585 | ├── j 586 | └── bad [stat failed] 587 | `, 0, 3}, 588 | {"dirs-first sort", &Options{Fs: fs, OutFile: out, DirSort: true}, `root 589 | ├── a 590 | ├── b 591 | ├── j 592 | └── bad [stat failed] 593 | `, 0, 3}, 594 | {"reverse sort", &Options{Fs: fs, OutFile: out, ReverSort: true, NameSort: true}, `root 595 | ├── bad [stat failed] 596 | ├── j 597 | ├── b 598 | └── a 599 | `, 0, 3}, 600 | {"no-sort", &Options{Fs: fs, OutFile: out, NoSort: true, DirSort: true}, `root 601 | ├── a 602 | ├── b 603 | ├── j 604 | └── bad [stat failed] 605 | `, 0, 3}, 606 | {"size-sort", &Options{Fs: fs, OutFile: out, SizeSort: true}, `root 607 | ├── a 608 | ├── b 609 | ├── j 610 | └── bad [stat failed] 611 | `, 0, 3}, 612 | {"last-mod-sort", &Options{Fs: fs, OutFile: out, ModSort: true}, `root 613 | ├── a 614 | ├── b 615 | ├── j 616 | └── bad [stat failed] 617 | `, 0, 3}, 618 | {"c-time-sort", &Options{Fs: fs, OutFile: out, CTimeSort: true}, `root 619 | ├── a 620 | ├── b 621 | ├── j 622 | └── bad [stat failed] 623 | `, 0, 3}, 624 | } 625 | 626 | func TestError(t *testing.T) { 627 | root := &file{ 628 | name: "root", 629 | size: 200, 630 | files: []*file{ 631 | {name: "a", size: 50}, 632 | {name: "b", size: 50}, 633 | {name: "j", size: 50}, 634 | {name: "bad", size: 50}, // stat fails on this file 635 | }, 636 | } 637 | fs.clean().addFile(root.name, root) 638 | for _, test := range errorTests { 639 | inf := New(root.name) 640 | d, f := inf.Visit(test.opts) 641 | if d != test.dirs { 642 | t.Errorf("wrong dir count for test %q:\ngot:\n%d\nexpected:\n%d", test.name, d, test.dirs) 643 | } 644 | if f != test.files { 645 | t.Errorf("wrong file count for test %q:\ngot:\n%d\nexpected:\n%d", test.name, f, test.files) 646 | } 647 | inf.Print(test.opts) 648 | if !out.equal(test.expected) { 649 | t.Errorf("%s:\ngot:\n%+v\nexpected:\n%+v", test.name, out.str, test.expected) 650 | } 651 | out.clear() 652 | } 653 | } 654 | -------------------------------------------------------------------------------- /ostree/ostree.go: -------------------------------------------------------------------------------- 1 | package ostree 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | 7 | "github.com/a8m/tree" 8 | ) 9 | 10 | // FS uses the system filesystem 11 | type FS struct{} 12 | 13 | // Stat a path 14 | func (f *FS) Stat(path string) (os.FileInfo, error) { 15 | return os.Lstat(path) 16 | } 17 | 18 | // ReadDir reads a directory 19 | func (f *FS) ReadDir(path string) ([]string, error) { 20 | dir, err := os.Open(path) 21 | if err != nil { 22 | return nil, err 23 | } 24 | names, err := dir.Readdirnames(-1) 25 | dir.Close() 26 | if err != nil { 27 | return nil, err 28 | } 29 | return names, nil 30 | } 31 | 32 | // Print a tree of the directory 33 | func Print(dir string) string { 34 | b := new(bytes.Buffer) 35 | tr := tree.New(dir) 36 | opts := &tree.Options{ 37 | Fs: new(FS), 38 | OutFile: b, 39 | } 40 | tr.Visit(opts) 41 | tr.Print(opts) 42 | return b.String() 43 | } 44 | -------------------------------------------------------------------------------- /ostree/ostree_test.go: -------------------------------------------------------------------------------- 1 | package ostree 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTree(t *testing.T) { 8 | actual := Print("testdata") 9 | expect := `testdata 10 | ├── a 11 | │ └── b 12 | │ └── b.txt 13 | └── c 14 | └── c.txt 15 | ` 16 | if actual != expect { 17 | t.Errorf("\nactual\n%s\n != expect\n%s\n", actual, expect) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ostree/testdata/a/b/b.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a8m/tree/2c8764a5f17ebabd0763b255c2ab948db99230a2/ostree/testdata/a/b/b.txt -------------------------------------------------------------------------------- /ostree/testdata/c/c.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a8m/tree/2c8764a5f17ebabd0763b255c2ab948db99230a2/ostree/testdata/c/c.txt -------------------------------------------------------------------------------- /sort.go: -------------------------------------------------------------------------------- 1 | package tree 2 | 3 | import "os" 4 | 5 | func (n Nodes) Len() int { return len(n) } 6 | func (n Nodes) Swap(i, j int) { n[i], n[j] = n[j], n[i] } 7 | 8 | type ByFunc struct { 9 | Nodes 10 | Fn SortFunc 11 | } 12 | 13 | func (b ByFunc) Less(i, j int) bool { 14 | return b.Fn(b.Nodes[i].FileInfo, b.Nodes[j].FileInfo) 15 | } 16 | 17 | type SortFunc func(f1, f2 os.FileInfo) bool 18 | 19 | func ModSort(f1, f2 os.FileInfo) bool { 20 | // This ensures any nil os.FileInfos sort at the end 21 | if f1 == nil || f2 == nil { 22 | return f2 == nil 23 | } 24 | return f1.ModTime().Before(f2.ModTime()) 25 | } 26 | 27 | func DirSort(f1, f2 os.FileInfo) bool { 28 | if f1 == nil || f2 == nil { 29 | return f2 == nil 30 | } 31 | return f1.IsDir() && !f2.IsDir() 32 | } 33 | 34 | func SizeSort(f1, f2 os.FileInfo) bool { 35 | if f1 == nil || f2 == nil { 36 | return f2 == nil 37 | } 38 | return f1.Size() < f2.Size() 39 | } 40 | 41 | func NameSort(f1, f2 os.FileInfo) bool { 42 | if f1 == nil || f2 == nil { 43 | return f2 == nil 44 | } 45 | return f1.Name() < f2.Name() 46 | } 47 | 48 | func VerSort(f1, f2 os.FileInfo) bool { 49 | if f1 == nil || f2 == nil { 50 | return f2 == nil 51 | } 52 | return NaturalLess(f1.Name(), f2.Name()) 53 | } 54 | 55 | func isdigit(b byte) bool { return '0' <= b && b <= '9' } 56 | 57 | // NaturalLess compares two strings using natural ordering. This means that e.g. 58 | // "abc2" < "abc12". 59 | // 60 | // Non-digit sequences and numbers are compared separately. The former are 61 | // compared bytewise, while the latter are compared numerically (except that 62 | // the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02") 63 | // 64 | // Limitation: only ASCII digits (0-9) are considered. 65 | // Code taken from: 66 | // https://github.com/fvbommel/util/blob/master/sortorder/natsort.go 67 | func NaturalLess(str1, str2 string) bool { 68 | idx1, idx2 := 0, 0 69 | for idx1 < len(str1) && idx2 < len(str2) { 70 | c1, c2 := str1[idx1], str2[idx2] 71 | dig1, dig2 := isdigit(c1), isdigit(c2) 72 | switch { 73 | case dig1 != dig2: // Digits before other characters. 74 | return dig1 // True if LHS is a digit, false if the RHS is one. 75 | case !dig1: // && !dig2, because dig1 == dig2 76 | // UTF-8 compares bytewise-lexicographically, no need to decode 77 | // codepoints. 78 | if c1 != c2 { 79 | return c1 < c2 80 | } 81 | idx1++ 82 | idx2++ 83 | default: // Digits 84 | // Eat zeros. 85 | for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ { 86 | } 87 | for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ { 88 | } 89 | // Eat all digits. 90 | nonZero1, nonZero2 := idx1, idx2 91 | for ; idx1 < len(str1) && isdigit(str1[idx1]); idx1++ { 92 | } 93 | for ; idx2 < len(str2) && isdigit(str2[idx2]); idx2++ { 94 | } 95 | // If lengths of numbers with non-zero prefix differ, the shorter 96 | // one is less. 97 | if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 { 98 | return len1 < len2 99 | } 100 | // If they're not equal, string comparison is correct. 101 | if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 { 102 | return nr1 < nr2 103 | } 104 | // Otherwise, the one with less zeros is less. 105 | // Because everything up to the number is equal, comparing the index 106 | // after the zeros is sufficient. 107 | if nonZero1 != nonZero2 { 108 | return nonZero1 < nonZero2 109 | } 110 | } 111 | // They're identical so far, so continue comparing. 112 | } 113 | // So far they are identical. At least one is ended. If the other continues, 114 | // it sorts last. 115 | return len(str1) < len(str2) 116 | } 117 | -------------------------------------------------------------------------------- /stat_unix.go: -------------------------------------------------------------------------------- 1 | //+build !plan9,!windows 2 | 3 | package tree 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | ) 9 | 10 | func getStat(fi os.FileInfo) (ok bool, inode, device, uid, gid uint64) { 11 | sys := fi.Sys() 12 | if sys == nil { 13 | return false, 0, 0, 0, 0 14 | } 15 | stat, ok := sys.(*syscall.Stat_t) 16 | if !ok { 17 | return false, 0, 0, 0, 0 18 | } 19 | return true, uint64(stat.Ino), uint64(stat.Dev), uint64(stat.Uid), uint64(stat.Gid) 20 | } 21 | -------------------------------------------------------------------------------- /stat_unsupported.go: -------------------------------------------------------------------------------- 1 | //+build plan9 windows 2 | 3 | package tree 4 | 5 | import "os" 6 | 7 | func getStat(fi os.FileInfo) (ok bool, inode, device, uid, gid uint64) { 8 | return false, 0, 0, 0, 0 9 | } 10 | --------------------------------------------------------------------------------