GoSungrow/lsgo/lsgo.go
2022-02-10 12:55:11 +11:00

651 lines
17 KiB
Go

package lsgo
import (
"fmt"
"io/ioutil"
"math"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/acarl005/textcol"
colorable "github.com/mattn/go-colorable"
"github.com/willf/pad"
kingpin "gopkg.in/alecthomas/kingpin.v2"
)
// DisplayItem wraps the file stat info and string to be printed
type DisplayItem struct {
display string
info os.FileInfo
basename string
ext string
link *LinkInfo
}
// LinkInfo wraps link stat info and whether the link points to valid file
type LinkInfo struct {
path string
info os.FileInfo
broken bool
}
var (
// True is a helper varable to help make pointers to `true`
True = true
sizeUnits = []string{"B", "K", "M", "G", "T"}
dateFormat = "02.Jan'06" // uses the "reference time" https://golang.org/pkg/time/#Time.Format
timeFormat = "15:04"
start int64 // keep track of execution time
stdout = colorable.NewColorableStdout() // write to this to allow ANSI color codes to be compatible on Windows
)
// func LsGo(cmd *cobra.Command, args []string) {
func LsGo() error {
var err error
textcol.Output = stdout
start = time.Now().UnixNano()
// auto-generate help text for the command with -h
kingpin.CommandLine.HelpFlag.Short('h')
// parse the arguments and populate the struct
kingpin.Parse()
argsPostParse()
// separate the directories from the regular files
dirs := []string{}
files := []os.FileInfo{}
for _, pathStr := range *args.paths {
var fileStat os.FileInfo
fileStat, err = os.Stat(pathStr)
if err != nil && strings.Contains(err.Error(), "no such file or directory") {
printErrorHeader(err, prettifyPath(pathStr))
continue
} else {
check(err)
}
if fileStat.IsDir() {
dirs = append(dirs, pathStr)
} else {
files = append(files, fileStat)
}
}
// list files first
if len(files) > 0 {
pwd := os.Getenv("PWD")
listFiles(pwd, &files, true)
}
// then list the contents of each directory
for i, dir := range dirs {
// print a blank line between directories, but not before the first one
if i > 0 {
fmt.Fprintln(stdout, "")
}
listDir(dir)
}
return err
}
func listDir(pathStr string) {
items, err := ioutil.ReadDir(pathStr)
// if we couldn't read the folder, print a "header" with error message and use error-looking colors
if err != nil {
if strings.Contains(err.Error(), "no such file or directory") || strings.Contains(err.Error(), "permission denied") {
printErrorHeader(err, prettifyPath(pathStr))
return
}
check(err)
}
// filter by the regexp if one was passed
if len(*args.find) > 0 {
filteredItems := []os.FileInfo{}
for _, fileInfo := range items {
re, err := regexp.Compile(*args.find)
check(err)
if re.MatchString(fileInfo.Name()) {
filteredItems = append(filteredItems, fileInfo)
}
}
items = filteredItems
}
if !(len(*args.find) > 0 && len(items) == 0) &&
!(len(*args.paths) == 1 && (*args.paths)[0] == "." && !*args.recurse) {
printFolderHeader(pathStr)
}
if len(items) > 0 {
listFiles(pathStr, &items, false)
}
if *args.recurse {
for _, item := range items {
if item.IsDir() && (item.Name()[0] != '.' || *args.all) {
fmt.Fprintln(stdout, "") // put a blank line between directories
listDir(path.Join(pathStr, item.Name()))
}
}
}
}
func listFiles(parentDir string, items *[]os.FileInfo, forceDotfiles bool) {
absPath, err := filepath.Abs(parentDir)
check(err)
// collect all the contents here
files := []*DisplayItem{}
dirs := []*DisplayItem{}
// to help with formatting, we need to know the length of the longest name to add appropriate padding
longestOwnerName := 0
longestGroupName := 0
if *args.owner {
for _, fileInfo := range *items {
owner, group := getOwnerAndGroup(&fileInfo)
longestOwnerName = max(longestOwnerName, len(owner))
longestGroupName = max(longestGroupName, len(group))
}
}
for _, fileInfo := range *items {
// if this is a dotfile (hidden file)
if fileInfo.Name()[0] == '.' {
// we can skip everything with this file if we aren't using the `all` option
if !*args.all && !forceDotfiles {
continue
}
}
basename, ext := splitExt(fileInfo.Name())
displayItem := DisplayItem{
info: fileInfo,
ext: ext,
basename: basename,
}
// read some info about linked file if this item is a symlink
if fileInfo.Mode()&os.ModeSymlink != 0 {
getLinkInfo(&displayItem, absPath)
}
if fileInfo.IsDir() || (fileInfo.Mode()&os.ModeSymlink != 0 &&
displayItem.link.info != nil &&
displayItem.link.info.IsDir()) {
if *args.files {
continue
} else {
dirs = append(dirs, &displayItem)
}
} else {
if *args.dirs {
continue
} else {
files = append(files, &displayItem)
}
}
owner, group := getOwnerAndGroup(&fileInfo)
ownerColor, groupColor := getOwnerAndGroupColors(owner, group)
if *args.perms {
displayItem.display += permString(fileInfo, ownerColor, groupColor)
}
if *args.owner {
paddedOwner := pad.Right(owner, longestOwnerName, " ")
ownerInfo := []string{Reset + ownerColor + paddedOwner}
if !*args.nogroup {
paddedGroup := pad.Right(group, longestGroupName, " ")
ownerInfo = append(ownerInfo, groupColor+paddedGroup)
}
ownerInfo = append(ownerInfo, Reset)
displayItem.display += strings.Join(ownerInfo, " ")
}
if *args.bytes {
if fileInfo.Mode()&os.ModeDevice != 0 {
displayItem.display += deviceNumbers(path.Join(absPath, fileInfo.Name()))
} else {
displayItem.display += sizeString(fileInfo.Size())
}
}
if *args.mdate {
displayItem.display += timeString(fileInfo.ModTime())
}
displayItem.display += nameString(&displayItem)
if *args.links && fileInfo.Mode()&os.ModeSymlink != 0 {
displayItem.display += linkString(&displayItem, absPath)
}
}
if *args.sortTime {
sort.Sort(ByTime(dirs))
sort.Sort(ByTime(files))
if *args.backwards {
reverse(dirs)
reverse(files)
}
}
if *args.sortSize {
sort.Sort(BySize(files))
if *args.backwards {
reverse(files)
}
}
if *args.sortKind {
sort.Sort(ByKind(files))
if *args.backwards {
reverse(files)
}
}
// combine the items together again after sorting
allItems := append(dirs, files...)
// if using "long" display, just print one item per line
if *args.bytes || *args.mdate || *args.owner || *args.perms || *args.long {
for _, item := range allItems {
fmt.Fprintln(stdout, item.display)
}
} else {
// but if not, try to format in columns, link `ls` would
strs := []string{}
for _, item := range allItems {
strs = append(strs, item.display)
}
textcol.PrintColumns(&strs, 2)
}
if *args.stats {
printStats(len(files), len(dirs))
}
}
func getLinkInfo(item *DisplayItem, absPath string) {
fullPath := path.Join(absPath, item.info.Name())
linkPath, err1 := os.Readlink(fullPath)
check(err1)
linkFullPath := linkPath
if linkPath[0] != '/' {
linkFullPath = path.Join(absPath, linkPath)
}
linkInfo, err2 := os.Stat(linkFullPath)
if *args.linkRel {
linkRel, _ := filepath.Rel(absPath, linkPath)
if linkRel != "" && len(linkRel) <= len(linkPath) {
// i prefer the look of these relative paths prepended with ./
if linkRel[0] != '.' {
linkPath = "./" + linkRel
} else {
linkPath = linkRel
}
}
}
link := LinkInfo{
path: linkPath,
}
item.link = &link
if linkInfo != nil {
link.info = linkInfo
} else if strings.Contains(err2.Error(), "no such file or directory") {
link.broken = true
} else if !strings.Contains(err2.Error(), "permission denied") {
check(err2)
}
}
func nameString(item *DisplayItem) string {
mode := item.info.Mode()
name := item.info.Name()
if mode&os.ModeDir != 0 {
return dirString(item)
} else if mode&os.ModeSymlink != 0 {
if !item.link.broken && item.link.info.IsDir() {
color := ConfigColor["link"]["nameDir"]
if *args.nerdfont {
var linkIcon string
if item.link.broken {
linkIcon = otherIcons["brokenLink"]
} else {
linkIcon = otherIcons["linkDir"]
}
return color + linkIcon + " " + name + " " + Reset
} else if *args.icons {
return color + "🔗 " + name + " " + Reset
} else {
return color + " " + name + " " + Reset
}
} else {
color := ConfigColor["link"]["name"]
if *args.nerdfont {
var linkIcon string
if item.link.broken {
linkIcon = otherIcons["brokenLink"]
} else {
linkIcon = otherIcons["link"]
}
return color + linkIcon + " " + name + " " + Reset
} else if *args.icons {
return color + "🔗 " + name + " " + Reset
} else {
return color + name + " " + Reset
}
}
} else if mode&os.ModeDevice != 0 {
color := ConfigColor["device"]["name"]
if *args.nerdfont {
return color + otherIcons["device"] + " " + name + " " + Reset
} else if *args.icons {
return color + "💽 " + name + " " + Reset
} else {
return color + " " + name + " " + Reset
}
} else if mode&os.ModeNamedPipe != 0 {
return ConfigColor["pipe"]["name"] + " " + name + " " + Reset
} else if mode&os.ModeSocket != 0 {
return ConfigColor["socket"]["name"] + " " + name + " " + Reset
}
return fileString(item)
}
func linkString(item *DisplayItem, absPath string) string {
colors := ConfigColor["link"]
displayStrings := []string{colors["arrow"] + "►"}
if item.link.info == nil && item.link.broken {
displayStrings = append(displayStrings, colors["broken"]+item.link.path+Reset)
} else if item.link.info != nil {
linkname, linkext := splitExt(item.link.path)
displayItem := DisplayItem{
info: item.link.info,
basename: linkname,
ext: linkext,
}
displayStrings = append(displayStrings, nameString(&displayItem))
} else {
displayStrings = append(displayStrings, item.link.path)
}
return strings.Join(displayStrings, " ")
}
func fileString(item *DisplayItem) string {
key := strings.ToLower(item.ext)
// figure out which color to choose
colors := FileColor["_default"]
alias, hasAlias := FileAliases[key]
if hasAlias {
key = alias
}
betterColor, hasBetterColor := FileColor[key]
if hasBetterColor {
colors = betterColor
}
ext := item.ext
if ext != "" {
ext = "." + ext
}
// in some cases files have icons if front
// if nerd font enabled, then it'll be a file-specific icon, or if its an executable script, a little shell icon
// if the regular --icons flag is used instead, then it will show a ">_" only if the file is executable
icon := ""
executable := isExecutableScript(item)
if *args.nerdfont {
if executable {
icon = colors[0] + getIconForFile("", "shell") + " "
} else {
icon = colors[0] + getIconForFile(item.basename, item.ext) + " "
}
} else if *args.icons {
if executable {
icon = BgGray(1) + FgRGB(0, 5, 0) + ">_" + Reset + " "
}
}
displayStrings := []string{icon, colors[0], item.basename, colors[1], ext, Reset}
return strings.Join(displayStrings, "")
}
// check for executable permissions
func isExecutableScript(item *DisplayItem) bool {
if item.info.Mode()&0111 != 0 && item.info.Mode().IsRegular() {
return true
}
return false
}
func dirString(item *DisplayItem) string {
colors := ConfigColor["dir"]
if item.basename == "" {
colors = ConfigColor[".dir"]
}
displayStrings := []string{colors["name"]}
icon := ""
if *args.icons {
displayStrings = append(displayStrings, "📂 ")
} else if *args.nerdfont {
icon = getIconForFolder(item.info.Name()) + " "
displayStrings = append(displayStrings, icon)
} else {
displayStrings = append(displayStrings, " ")
}
ext := item.ext
if ext != "" {
ext = "." + ext
}
displayStrings = append(displayStrings, item.basename, colors["ext"], ext, " ", Reset)
return strings.Join(displayStrings, "")
}
func rwxString(mode os.FileMode, i uint, color string) string {
bits := mode >> (i * 3)
coloredStrings := []string{color}
if bits&4 != 0 {
coloredStrings = append(coloredStrings, "r")
} else {
coloredStrings = append(coloredStrings, "-")
}
if bits&2 != 0 {
coloredStrings = append(coloredStrings, "w")
} else {
coloredStrings = append(coloredStrings, "-")
}
if i == 0 && mode&os.ModeSticky != 0 {
if bits&1 != 0 {
coloredStrings = append(coloredStrings, "t")
} else {
coloredStrings = append(coloredStrings, "T")
}
} else {
if bits&1 != 0 {
coloredStrings = append(coloredStrings, "x")
} else {
coloredStrings = append(coloredStrings, "-")
}
}
return strings.Join(coloredStrings, "")
}
// generates the permissions string, ya know like "drwxr-xr-x" and stuff like that
func permString(info os.FileInfo, ownerColor string, groupColor string) string {
defaultColor := PermsColor["other"]["_default"]
// info.Mode().String() does not produce the same output as `ls`, so we must build that string manually
mode := info.Mode()
// this "type" is not the file extension, but type as far as the OS is concerned
filetype := "-"
if mode&os.ModeDir != 0 {
filetype = "d"
} else if mode&os.ModeSymlink != 0 {
filetype = "l"
} else if mode&os.ModeDevice != 0 {
if mode&os.ModeCharDevice == 0 {
filetype = "b" // block device
} else {
filetype = "c" // character device
}
} else if mode&os.ModeNamedPipe != 0 {
filetype = "p"
} else if mode&os.ModeSocket != 0 {
filetype = "s"
}
coloredStrings := []string{defaultColor, filetype}
coloredStrings = append(coloredStrings, rwxString(mode, 2, ownerColor))
coloredStrings = append(coloredStrings, rwxString(mode, 1, groupColor))
coloredStrings = append(coloredStrings, rwxString(mode, 0, defaultColor), Reset, Reset)
return strings.Join(coloredStrings, " ")
}
func sizeString(size int64) string {
sizeFloat := float64(size)
for i, unit := range sizeUnits {
base := math.Pow(1024, float64(i))
if sizeFloat < base*1024 {
var sizeStr string
if i == 0 {
sizeStr = strconv.FormatInt(size, 10)
} else {
sizeStr = fmt.Sprintf("%.2f", sizeFloat/base)
}
return SizeColor[unit] + pad.Left(sizeStr, 6, " ") + unit + " " + Reset
}
}
return strconv.Itoa(int(size))
}
func timeString(modtime time.Time) string {
dateStr := modtime.Format(dateFormat)
timeStr := modtime.Format(timeFormat)
hour, err := strconv.Atoi(timeStr[0:2])
check(err)
// generate a color based on the hour of the day. darkest around midnight and whitest around noon
timeColor := 14 - int(8*math.Cos(math.Pi*float64(hour)/12))
colored := []string{FgGray(22) + dateStr, FgGray(timeColor) + timeStr, Reset}
return strings.Join(colored, " ")
}
// when we list out any subdirectories, print those paths conspicuously above the contents
// this helps with visual separation
func printFolderHeader(pathStr string) {
colors := ConfigColor["folderHeader"]
headerString := colors["arrow"] + "►" + colors["main"] + " "
prettyPath := prettifyPath(pathStr)
if prettyPath == "/" {
headerString += "/"
} else {
folders := strings.Split(prettyPath, "/")
coloredFolders := make([]string, 0, len(folders))
for i, folder := range folders {
if i == len(folders)-1 { // different color for the last folder in the path
coloredFolders = append(coloredFolders, colors["lastFolder"]+folder)
} else {
coloredFolders = append(coloredFolders, colors["main"]+folder)
}
}
headerString += strings.Join(coloredFolders, colors["slash"]+"/")
}
fmt.Fprintln(stdout, headerString+" "+Reset)
}
func printErrorHeader(err error, pathStr string) {
fmt.Fprintln(stdout, ConfigColor["folderHeader"]["error"]+"► "+pathStr+Reset)
fmt.Fprintln(stdout, err.Error())
}
func prettifyPath(pathStr string) string {
prettyPath, err := filepath.Abs(pathStr)
check(err)
pwd := os.Getenv("PWD")
home := os.Getenv("HOME")
if strings.HasPrefix(prettyPath, pwd) {
prettyPath = "." + prettyPath[len(pwd):]
} else if strings.HasPrefix(prettyPath, home) {
prettyPath = "~" + prettyPath[len(home):]
}
return prettyPath
}
func getOwnerAndGroupColors(owner string, group string) (string, string) {
if owner == os.Getenv("USER") {
owner = "_self"
}
ownerColor := PermsColor["user"][owner]
if ownerColor == "" {
ownerColor = PermsColor["user"]["_default"]
}
groupColor := PermsColor["group"][group]
if groupColor == "" {
groupColor = PermsColor["group"]["_default"]
}
return ownerColor, groupColor
}
func printStats(numFiles, numDirs int) {
colors := ConfigColor["stats"]
end := time.Now().UnixNano()
microSeconds := (end - start) / int64(time.Microsecond)
milliSeconds := float64(microSeconds) / 1000
statStrings := []string{
colors["text"],
colors["number"] + strconv.Itoa(numDirs),
colors["text"] + "dirs",
colors["number"] + strconv.Itoa(numFiles),
colors["text"] + "files",
colors["ms"] + fmt.Sprintf("%.2f", milliSeconds),
colors["text"] + "ms",
Reset,
}
fmt.Fprintln(stdout, strings.Join(statStrings, " "))
}
func splitExt(filename string) (basepath, ext string) {
basename := filepath.Base(filename)
if basename[0] == '.' {
ext = basename[1:]
basepath = filename[:len(filename)-len(ext)-1]
} else {
ext = filepath.Ext(filename)
basepath = filename[:len(filename)-len(ext)]
if ext != "" {
ext = ext[1:]
}
}
return
}
// Go doesn't provide a `Max` function for ints like it does for floats (wtf?)
func max(a int, b int) int {
if a > b {
return a
}
return b
}
func check(err error) {
if err != nil {
panic(err)
}
}