diff --git a/common/readutmp_nix.c.v b/common/readutmp_nix.c.v index 61a2ccd4..0b5b59f8 100644 --- a/common/readutmp_nix.c.v +++ b/common/readutmp_nix.c.v @@ -4,30 +4,30 @@ module common #include #include -// TODO: conflicts with `timeval` in -// -/* -pub struct C.timeval { - tv_sec i64 // Seconds. - tv_usec i64 // Microseconds. -}*/ - -// -pub struct C.timeval { -pub: - tv_sec u64 // Seconds. - tv_usec u64 // Microseconds. +// `struct utmpx` was previously declared twice, once here and once in +// src/users/users.c.v with a different (shorter) field list, which V rejects as +// a redeclaration. Declare it once, here, with the glibc layout. +// +// The fixed size fields are UT_LINESIZE/UT_NAMESIZE/UT_HOSTSIZE (32/32/256) and +// the element size has to match what getutxent() writes, because read_utmp() +// copies entries into a []C.utmpx. +pub struct C.exit_status { + et_code u16 + et_term u16 } pub struct C.utmpx { - ut_type i16 // Type of login. - ut_pid int // Process ID of login process. - ut_line [32]char // Devicename. - ut_id [4]char // Inittab ID. - ut_user [32]char // Username. - ut_host [256]char // Hostname for remote login. - ut_tv C.timeval // TODO: Declare sub struct correctly - ut_addr_v6 [4]int // Internet address of remote host. + ut_type i16 // Type of login. + ut_pid int // Process ID of login process. + ut_line [32]u8 // Devicename. + ut_id [4]u8 // Inittab ID. + ut_user [32]u8 // Username. + ut_host [256]u8 // Hostname for remote login. + ut_exit C.exit_status // Exit status of the process. + ut_session_id i32 // Session ID. + ut_tv C.timeval // Time entry. + ut_addr_v6 [4]i32 // Internet address of remote host. + __unused [20]u8 // Reserved. } // sets the name of the utmp-format file for the other utmp functions to access. diff --git a/common/testing/testrig.v b/common/testing/testrig.v index c87cceda..5206fa5c 100644 --- a/common/testing/testrig.v +++ b/common/testing/testrig.v @@ -84,8 +84,7 @@ pub fn (rig TestRig) assert_platform_util() { } else { // uptime was moved to procps-ng and may not be available in coreutils - assert - ver == 'uptime (GNU coreut' || ver == 'uptime (coreutils)' || ver == 'uptime from procps' + assert ver == 'uptime (GNU coreut' || ver == 'uptime (coreutils)' || ver == 'uptime from procps' } } } diff --git a/src/base64/base64.v b/src/base64/base64.v index 456c9a87..cd1cda29 100644 --- a/src/base64/base64.v +++ b/src/base64/base64.v @@ -159,7 +159,7 @@ fn main() { exit(1) } if wraping_opt < 0 { - eprintln('${application_name}: invalid wrap size: \'${wraping_opt}\'') + eprintln("${application_name}: invalid wrap size: '${wraping_opt}'") exit(1) } diff --git a/src/basenc/basenc.v b/src/basenc/basenc.v index b9175f70..f04a2e99 100644 --- a/src/basenc/basenc.v +++ b/src/basenc/basenc.v @@ -1,5 +1,5 @@ import os -import v.mathutil +import math import encoding.base64 import encoding.base32 @@ -51,7 +51,7 @@ fn print_encoded(encoded string, options Options) { return } for start := 0; start < encoded.len; start += options.wrap { - end := mathutil.min(start + options.wrap, encoded.len) + end := math.min(start + options.wrap, encoded.len) // safe to use string slicing because all chars // are in printable ascii println(encoded[start..end]) diff --git a/src/cksum/cksum.v b/src/cksum/cksum.v index 146aef5f..f4e4bad4 100644 --- a/src/cksum/cksum.v +++ b/src/cksum/cksum.v @@ -70,8 +70,9 @@ fn calc_sums(args Args) { } mut len_counter := total_length - for ; len_counter; len_counter >>= 8 { + for len_counter > 0 { crc = (crc << 8) ^ crctab[0][((crc >> 24) ^ len_counter) & 0xFF] + len_counter >>= 8 } crc = ~crc & 0xffff_ffff diff --git a/src/cp/helper.v b/src/cp/helper.v index 9c36db01..b259355d 100644 --- a/src/cp/helper.v +++ b/src/cp/helper.v @@ -69,7 +69,7 @@ fn success_exit(messages ...string) { exit(0) } -fn setup_cp_command(args []string) ?(CpCommand, []string, string) { +fn setup_cp_command(args []string) (CpCommand, []string, string) { mut fp := common.flag_parser(args) fp.application('cp') fp.limit_free_args_to_at_least(1) or { common.exit_with_error_message(name, err.msg()) } @@ -143,9 +143,7 @@ fn setup_cp_command(args []string) ?(CpCommand, []string, string) { } pub fn run_cp(args []string) { - cp, sources, dest := setup_cp_command(args) or { - common.exit_with_error_message(name, err.msg()) - } + cp, sources, dest := setup_cp_command(args) if sources.len > 1 && !os.is_dir(dest) { common.exit_with_error_message(name, target_not_dir(dest)) } diff --git a/src/cut/cut.v b/src/cut/cut.v index c3764386..03084132 100644 --- a/src/cut/cut.v +++ b/src/cut/cut.v @@ -3,8 +3,8 @@ import arrays import common import flag import io +import math import os -import v.mathutil const app_name = 'cut' const space_comma = ' ,' @@ -124,13 +124,13 @@ fn combine_ranges_and_zero_index(ranges []Range, max int, complement bool) []Ran outer: for range in ranges.sorted(a.start < b.start) { start := range.start - 1 - end := if range.end == -1 { max } else { mathutil.min(max, range.end) } + end := if range.end == -1 { max } else { math.min(max, range.end) } for mut combined_range in combined_ranges { if range_overlaps_range(start, end, combined_range.start, combined_range.end) { combined_range = Range{ - start: mathutil.min(start, combined_range.start) - end: mathutil.max(end, combined_range.end) + start: math.min(start, combined_range.start) + end: math.max(end, combined_range.end) } continue outer } @@ -189,7 +189,7 @@ fn get_args(args []string) Args { delimiter := fp.string('delimter', `d`, '', 'use instead of TAB for field delimter') fields := fp.string('fields', `f`, '', 'select only fields; also print any line${wrap}' + - 'that contains no delimiter character, unless the${wrap}-s option is specified') + 'that contains no delimiter character, unless the${wrap}-s option is specified') fp.bool('', `n`, false, '(ignored)') complement := fp.bool('complement', ` `, false, 'complement the set of selected bytes, characters${wrap}or fields') @@ -207,9 +207,9 @@ fn get_args(args []string) Args { 'range, or many ranges separated by commas. Selected input is written${eol}' + 'in the same order that it is read, and is written exactly once.${eol}${eol}' + 'Each range is one of:${eol}${eol}' + - ' N N\'th byte, character or field, counted from 1${eol}' + - ' N- from N\'th byte, character or field, to end of line${eol}' + - ' N-M from N\'th to M\'th (included) byte, character or field${eol}' + " -M from first to M'th (included) byte, character or field") + " N N'th byte, character or field, counted from 1${eol}" + + " N- from N'th byte, character or field, to end of line${eol}" + + " N-M from N'th to M'th (included) byte, character or field${eol}" + " -M from first to M'th (included) byte, character or field") fp.footer(common.coreutils_footer()) file_args := fp.finalize() or { exit_error(err.msg()) } diff --git a/src/expand/expand.v b/src/expand/expand.v index 0e54e215..8771e1c0 100644 --- a/src/expand/expand.v +++ b/src/expand/expand.v @@ -8,7 +8,7 @@ const nl = '\n' fn process_line(line string, initial bool, tabs int) { mut sp := '' - for i := tabs; i; i-- { + for _ in 0 .. tabs { sp += ' ' } diff --git a/src/expr/expr_test.v b/src/expr/expr_test.v index 5dd9c71b..7d15be0e 100644 --- a/src/expr/expr_test.v +++ b/src/expr/expr_test.v @@ -161,7 +161,7 @@ const mb_tests = [ // fail: multi; broken UTF-8 is treated in different way // "substr \u03B1bc\xB4ef 3 3", 'match abcdef ab', - 'match abcdef "\\(ab\\)"' + 'match abcdef "\\(ab\\)"', // fail: single; this is a good news, regex in vlib supports UTF-8 // "match \u03B1bc\u03B4ef .bc", // fail: both; broken regex of vlib, see #10885 diff --git a/src/fmt/fmt.v b/src/fmt/fmt.v index 1226fdb9..441ff77c 100644 --- a/src/fmt/fmt.v +++ b/src/fmt/fmt.v @@ -241,11 +241,11 @@ fn process_args(args []string) App { crown_marg := fp.bool('crown-margin', `c`, false, 'preserve the indentation of the first two lines within' + - '${pad}a paragraph, and align the left margin of each' + - '${pad}subsequent line with that of the second line') + '${pad}a paragraph, and align the left margin of each' + + '${pad}subsequent line with that of the second line') prefix_str := fp.string('prefix', `p`, '', 'reformat only lines beginning with STRING, reattaching ' + - '${pad}the prefix to reformatted lines') + '${pad}the prefix to reformatted lines') split_only := fp.bool('split-only', `s`, false, 'split long lines, but do not refill') tagged_par := fp.bool('tagged-paragraph', `t`, false, 'indentation of first line different from second') diff --git a/src/head/head.v b/src/head/head.v index 5c02bca4..73fe15ee 100644 --- a/src/head/head.v +++ b/src/head/head.v @@ -294,7 +294,7 @@ fn wrap_long_command_description(description string, max_cols int) string { return buf.str() } -fn setup_command(args []string) ?(HeadCommand, []InputFile) { +fn setup_command(args []string) (HeadCommand, []InputFile) { mut fp := common.flag_parser(args) fp.application(name) fp.usage_example('[OPTION]... [FILE]...') @@ -330,7 +330,7 @@ fn setup_command(args []string) ?(HeadCommand, []InputFile) { } fn run_head(args []string) { - head, mut files := setup_command(args) or { common.exit_with_error_message(name, err.msg()) } + head, mut files := setup_command(args) head.run(mut files) } diff --git a/src/ls/auto_wrap.v b/src/ls/auto_wrap.v new file mode 100644 index 00000000..682cdf71 --- /dev/null +++ b/src/ls/auto_wrap.v @@ -0,0 +1,19 @@ +import os + +fn set_auto_wrap(options Options) { + if options.no_wrap { + wrap_off := '\e[?7l' + wrap_reset := '\e[?7h' + println(wrap_off) + + at_exit(fn [wrap_reset] () { + println(wrap_reset) + }) or {} + + // Ctrl-C handler + os.signal_opt(os.Signal.int, fn (sig os.Signal) { + println('\e[?7h') + exit(0) + }) or {} + } +} diff --git a/src/ls/entry.v b/src/ls/entry.v new file mode 100644 index 00000000..ccc29455 --- /dev/null +++ b/src/ls/entry.v @@ -0,0 +1,229 @@ +import os +import crypto.md5 +import crypto.sha1 +import crypto.sha256 +import crypto.sha512 +import crypto.blake2b +import math + +struct Entry { + name string + dir_name string + stat os.Stat + link_stat os.Stat + dir bool + file bool + link bool + exe bool + fifo bool + block bool + socket bool + character bool + unknown bool + link_origin string + size u64 + allocated u64 + size_ki string + size_kb string + checksum string + invalid bool // lstat could not access +} + +fn get_entries(files []string, options Options) ([]Entry, []string, int) { + mut entries := []Entry{cap: 50} + mut dirs := []string{} + mut status := 0 + + for file in files { + if options.list_directory_itself { + // -d lists the operand, not what is inside it. GNU shows `.` for a + // bare `ls -d`, which is the name it was given. + entries << make_entry(file, '', options) + continue + } + if os.is_dir(file) { + dir_files := os.ls(file) or { + status = 2 // see help for meaning of exit codes + eprintln(err) + continue + } + // An empty directory still needs a section of its own, and a group + // keyed by it, or `ls emptydir anotherdir` would lose its header. + dirs << file + entries << match options.all { + true { dir_files.map(make_entry(it, file, options)) } + else { dir_files.filter(!is_dot_file(it)).map(make_entry(it, file, options)) } + } + } else { + if os.lstat(file) or { os.Stat{} }.mode == 0 { + // GNU treats an operand it cannot reach as a serious problem: + // nothing is listed for it and the exit status is 2. + eprintln("${app_name}: cannot access '${file}': No such file or directory") + status = 2 + continue + } + if options.all || !is_dot_file(file) { + entries << make_entry(file, '', options) + } + } + } + return entries, dirs, status +} + +// os.join_path is not usable here: it rewrites a backslash in a file name into +// a separator, so `./back\slash` becomes `./back/slash` and the entry then +// fails to stat. Measured, not assumed. A name cannot contain a slash, so +// joining on one is safe on POSIX and accepted on Windows too. +fn entry_path(dir_name string, name string) string { + if dir_name == '' { + return name + } + if dir_name.ends_with('/') { + return dir_name + name + } + return dir_name + '/' + name +} + +fn make_entry(file string, dir_name string, options Options) Entry { + mut invalid := false + path := entry_path(dir_name, file) + + stat := os.lstat(path) or { + // println('${path} -> ${err.msg()}') + invalid = true + os.Stat{} + } + + filetype := stat.get_filetype() + is_link := filetype == .symbolic_link + link_origin := if is_link { read_link(path) } else { '' } + mut size := stat.size + mut link_stat := os.Stat{} + + if is_link && options.long_format && !invalid { + // os.stat follows the link. The link itself is still what the size + // column reports, so only the styling reads this: a link to a + // directory is coloured as one. GNU shows 12 for a link to + // /nonexistent, which is the length of the target string. + link_stat = os.stat(path) or { os.Stat{} } + } + + is_dir := filetype == .directory + is_fifo := filetype == .fifo + is_block := filetype == .block_device + is_socket := filetype == .socket + is_character_device := filetype == .character_device + is_unknown := filetype == .unknown + is_exe := !is_dir && is_executable(stat) + is_file := filetype == .regular + // -p appends a slash to directories and -F marks the type. They overlap on a + // directory and agree there. In the long listing -F marks everything except a + // symlink: GNU puts `-> target` after one instead of `@`. An ordinary file + // gets nothing at all, and `?` is for a type GNU cannot name. + indicator := if is_dir && (options.dir_indicator || options.classify) { + '/' + } else if options.classify && !(options.long_format && is_link) { + if is_link { + '@' + } else if is_socket { + '=' + } else if is_fifo || is_block || is_character_device { + '%' + } else if is_exe { + '*' + } else if is_unknown { + '?' + } else { + '' + } + } else { + '' + } + + return Entry{ + // vfmt off + name: file + indicator + dir_name: dir_name + stat: stat + link_stat: link_stat + dir: is_dir + file: is_file + link: is_link + exe: is_exe + fifo: is_fifo + block: is_block + socket: is_socket + character: is_character_device + unknown: is_unknown + link_origin: link_origin + size: size + allocated: allocated_size(path) + size_ki: if options.size_ki { readable_size(size, true) } else { '' } + size_kb: if options.size_kb { readable_size(size, false) } else { '' } + checksum: if is_file { checksum(file, dir_name, options) } else { '' } + invalid: invalid + // vfmt on + } +} + +// GNU's -h divides by 1024 and uses an uppercase suffix, --si divides by 1000 +// and uses a lowercase one. Neither prints a `b`, and a value below the base is +// printed as it stands rather than as 1.0K. +// +// The rounding is a ceiling, not to nearest: 2500 bytes prints as 2.5K, not +// 2.4K, and 10240 as 10K while 10241 is 11K. One decimal is kept below ten and +// dropped at or above it, so 4096 is 4.0K and 102400 is 100K. All 33 sizes +// sampled from the reference fit this, and no nearest-rounding rule does. +fn readable_size(size u64, si bool) string { + base := if si { f64(1000) } else { f64(1024) } + units := if si { + ['', 'k', 'm', 'g', 't', 'p', 'e', 'z', 'y'] + } else { + ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] + } + mut sz := f64(size) + for i, unit in units { + if sz < base || i == units.len - 1 { + if unit == '' { + return size.str() + } + tenths := int(math.ceil(sz * 10)) + if tenths >= 100 { + return '${int(math.ceil(sz))}${unit}' + } + return '${tenths / 10}.${tenths % 10}${unit}' + } + sz /= base + } + return size.str() +} + +fn checksum(name string, dir_name string, options Options) string { + if options.checksum == '' { + return '' + } + file := entry_path(dir_name, name) + bytes := os.read_bytes(file) or { return unknown } + + return match options.checksum { + // vfmt off + 'md5' { md5.sum(bytes).hex() } + 'sha1' { sha1.sum(bytes).hex() } + 'sha224' { sha256.sum224(bytes).hex() } + 'sha256' { sha256.sum256(bytes).hex() } + 'sha512' { sha512.sum512(bytes).hex() } + 'blake2b' { blake2b.sum256(bytes).hex() } + else { unknown } + // vfmt on + } +} + +@[inline] +fn is_executable(stat os.Stat) bool { + return stat.get_mode().bitmask() & 0b001001001 > 0 +} + +@[inline] +fn is_dot_file(file string) bool { + return file.starts_with('.') +} diff --git a/src/ls/entry_test.v b/src/ls/entry_test.v new file mode 100644 index 00000000..671d0aeb --- /dev/null +++ b/src/ls/entry_test.v @@ -0,0 +1,60 @@ +module main + +// The expected values here were read off GNU ls 9.4, not derived from the +// implementation. Two rules are being pinned down: +// +// -h divides by 1024 and uses an uppercase suffix, --si divides by 1000 and +// uses a lowercase one. The `si` argument is therefore true for --si. +// +// The rounding is a ceiling, not to nearest. GNU prints 2500 bytes as 2.5K +// where rounding to nearest would give 2.4K, and 10240 as 10K while 10241 is +// 11K. One decimal is kept below ten and dropped at or above it, so 4096 is +// 4.0K and 102400 is 100K. +fn test_readable_size() { + // Below the base a size is printed as it stands, with no suffix. + assert readable_size(0, true) == '0' + assert readable_size(2, true) == '2' + assert readable_size(1023, false) == '1023' + assert readable_size(100, false) == '100' + + // 1024 is the first value that -h scales, and 1000 the first for --si, so + // 1023 is still unscaled by -h and already 1.1k under --si. + assert readable_size(1024, false) == '1.0K' + assert readable_size(1024, true) == '1.1k' + assert readable_size(1023, true) == '1.1k' + + // The ceiling, at each of the two points where a nearest rounding would + // disagree. + assert readable_size(2500, false) == '2.5K' + assert readable_size(2900, false) == '2.9K' + assert readable_size(2999, false) == '3.0K' + assert readable_size(1025, false) == '1.1K' + + // Exact multiples keep their exact value rather than gaining a tenth. + assert readable_size(2048, false) == '2.0K' + assert readable_size(4096, false) == '4.0K' + assert readable_size(5120, false) == '5.0K' + + // At ten the decimal goes away. + assert readable_size(10239, false) == '10K' + assert readable_size(10240, false) == '10K' + assert readable_size(10241, false) == '11K' + + assert readable_size(4095, false) == '4.0K' + assert readable_size(4097, false) == '4.1K' + assert readable_size(10_000, false) == '9.8K' + assert readable_size(20_000, false) == '20K' + assert readable_size(200_000, false) == '196K' + assert readable_size(102_400, false) == '100K' + + // --si, on a 1000 base with a lowercase suffix. + assert readable_size(4095, true) == '4.1k' + assert readable_size(100_000, true) == '100k' + assert readable_size(200_000, true) == '200k' + + // Larger units still round up, and the mantissa stays below ten. + // 100000000 / 1024 / 1024 is 95.37, which the ceiling rule takes to 96. + assert readable_size(100_000_000, false) == '96M' + assert readable_size(100_000_000, true) == '100m' + assert readable_size(8_000_000_000_000_000_000, true) == '8.0e' +} diff --git a/src/ls/filter.v b/src/ls/filter.v new file mode 100644 index 00000000..5a523b20 --- /dev/null +++ b/src/ls/filter.v @@ -0,0 +1,9 @@ +fn filter(entries []Entry, options Options) []Entry { + return match true { + // vfmt off + options.only_dirs { entries.clone().filter(it.dir) } + options.only_files { entries.clone().filter(it.file) } + else { entries } + // vfmt on + } +} diff --git a/src/ls/format.v b/src/ls/format.v new file mode 100644 index 00000000..381211e7 --- /dev/null +++ b/src/ls/format.v @@ -0,0 +1,395 @@ +import arrays +import os +import term +import math + +const cell_max = 12 // limit on wide displays +const cell_spacing = 3 // space between cells + +enum Align { + left + right +} + +fn print_files(entries_arg []Entry, options Options) { + entries := match true { + options.all && !options.almost_all { + dot := make_entry('.', '.', options) + dot_dot := make_entry('..', '.', options) + arrays.concat([dot, dot_dot], ...entries_arg) + } + else { + entries_arg + } + } + + w, _ := term.get_terminal_size() + options_width_ok := options.width_in_cols > 0 && options.width_in_cols < 1000 + // GNU falls back to 80 columns when there is no terminal, so ls -C | cat + // still makes a grid. Without this the column layouts collapse to one + // entry per line as soon as the output is redirected. + width := if options_width_ok { + options.width_in_cols + } else if w > 0 { + w + } else { + 80 + } + + match true { + // vfmt off + options.long_format { format_long_listing(entries, options) } + options.list_by_lines { format_by_lines(entries, width, options) } + options.with_commas { format_with_commas(entries, width, options) } + options.one_per_line { format_one_per_line(entries, options) } + options.list_by_columns { format_by_cells(entries, width, options) } + // GNU fills the width with columns only when the output is a terminal. + // Piped or redirected it lists one entry per line, so `ls | cat` and + // `ls` in a terminal do not agree unless -C was given. + os.is_atty(1) == 0 { format_one_per_line(entries, options) } + else { format_by_cells(entries, width, options) } + // vfmt on + } +} + +fn format_by_cells(entries []Entry, width int, options Options) { + len := entries.max_name_len(options) + cell_spacing + cols := math.min(width / len, cell_max) + max_cols := math.max(cols, 1) + partial_row := entries.len % max_cols != 0 + rows := entries.len / max_cols + if partial_row { 1 } else { 0 } + max_rows := math.max(1, rows) + + for r := 0; r < max_rows; r += 1 { + for c := 0; c < max_cols; c += 1 { + idx := r + c * max_rows + if idx < entries.len { + entry := entries[idx] + name := format_entry_name(entry, options) + // The last column of a row is not padded: GNU never ends a + // line with the spaces that would align it to the next one. + width_here := if c == max_cols - 1 { 0 } else { len } + cell := format_cell(name, width_here, .left, get_style_for_entry(entry, + options), options) + print(cell) + } + } + print_newline() + } +} + +fn format_by_lines(entries []Entry, width int, options Options) { + len := entries.max_name_len(options) + cell_spacing + cols := math.min(width / len, cell_max) + max_cols := math.max(cols, 1) + + for i, entry in entries { + if i % max_cols == 0 && i != 0 { + print_newline() + } + name := format_entry_name(entry, options) + width_here := if i % max_cols == max_cols - 1 { 0 } else { len } + cell := format_cell(name, width_here, .left, get_style_for_entry(entry, options), options) + print(cell) + } + print_newline() +} + +fn format_one_per_line(entries []Entry, options Options) { + for entry in entries { + // -i on its own is `%i %n`: the inode and then the name, no other + // column. GNU puts one space between them. + prefix := if options.inode { '${entry.stat.inode} ' } else { '' } + println('${prefix}${format_cell(format_entry_name(entry, options), 0, .left, get_style_for_entry(entry, options), options)}') + } +} + +// -m fills the width with a comma separated list. The comma is written after +// every entry but the last and the space before the next one is only written +// when there is room for it, so a wrapped line ends in `large.bin,` and not in +// `large.bin, ` the way padding each cell would leave it. +fn format_with_commas(entries []Entry, width int, options Options) { + last := entries.len - 1 + mut line := 0 + for i, entry in entries { + name := format_entry_name(entry, options) + if line > 0 { + if line + 1 + real_length(name) > width { + print_newline() + line = 0 + } else { + print(' ') + line += 1 + } + } + print(format_cell(name, 0, .left, no_style, options)) + if i < last { + print(',') + } + line += real_length(name) + } + print_newline() +} + +fn format_cell(s string, width int, align Align, style Style, options Options) string { + return match options.table_format { + true { format_table_cell(s, width, align, style, options) } + else { format_cell_content(s, width, align, style, options) } + } +} + +fn format_cell_content(s string, width int, align Align, style Style, options Options) string { + mut cell := '' + no_ansi_s := term.strip_ansi(s) + pad := width - no_ansi_s.runes().len + + if align == .right && pad > 0 { + cell += space.repeat(pad) + } + + cell += if options.colorize == when_always { + style_string(s, style, options) + } else { + no_ansi_s + } + + if align == .left && pad > 0 { + cell += space.repeat(pad) + } + + return cell +} + +fn format_table_cell(s string, width int, align Align, style Style, options Options) string { + cell := format_cell_content(s, width, align, style, options) + return '${cell}${table_border_pad_right}' +} + +// surrounds a cell with table borders +fn print_dir_name(name string, options Options, printed_any &bool) { + if name.len > 0 { + // A blank line separates sections, but there is none before the first. + if *printed_any { + print_newline() + } + unsafe { + *printed_any = true + } + nm := if options.colorize == when_always { + style_string(name, options.style_di, options) + } else { + name + } + println('${nm}:') + } +} + +fn (entries []Entry) max_name_len(options Options) int { + lengths := entries.map(real_length(format_entry_name(it, options))) + return arrays.max(lengths) or { 0 } +} + +fn get_style_for_entry(entry Entry, options Options) Style { + return match true { + // vfmt off + entry.link { options.style_ln } + entry.dir { options.style_di } + entry.exe { options.style_ex } + entry.fifo { options.style_pi } + entry.block { options.style_bd } + entry.character { options.style_cd } + entry.socket { options.style_so } + entry.file { options.style_fi } + else { no_style } + // vfmt on + } +} + +fn get_style_for_link(entry Entry, options Options) Style { + if entry.link_stat.size == 0 { + return unknown_style + } + + filetype := entry.link_stat.get_filetype() + is_dir := filetype == os.FileType.directory + is_fifo := filetype == .fifo + is_block := filetype == .block_device + is_socket := filetype == .socket + is_character_device := filetype == .character_device + is_unknown := filetype == .unknown + is_exe := is_executable(entry.link_stat) + is_file := !is_dir && !is_fifo && !is_block && !is_socket && !is_character_device && !is_unknown + && !is_exe + + return match true { + // vfmt off + is_dir { options.style_di } + is_exe { options.style_ex } + is_fifo { options.style_pi } + is_block { options.style_bd } + is_character_device { options.style_cd } + is_socket { options.style_so } + is_unknown { unknown_style } + is_file { options.style_fi } + else { no_style } + // vfmt on + } +} + +fn format_entry_name(entry Entry, options Options) string { + name := if options.relative_path { + entry_path(entry.dir_name, entry.name) + } else { + entry.name + } + + icon := get_icon_for_entry(entry, options) + + // The quoting style defaults per destination: a terminal gets shell rules, + // a pipe gets none. -Q is GNU's older spelling of --quoting-style=c, not of + // shell quoting: measured, `ls -Q` prints "new\nline" with a C escape. + style := if options.quote && options.quoting_style == '' { + 'c' + } else if options.quoting_style != '' { + options.quoting_style + } else if os.is_atty(1) != 0 { + 'shell-escape' + } else { + 'literal' + } + + return match true { + entry.link && options.long_format { + link_style := get_style_for_link(entry, options) + link := style_string(quote_name(entry.link_origin, style), link_style, options) + '${icon}${quote_name(name, style)} -> ${link}' + } + else { + '${icon}${quote_name(name, style)}' + } + } +} + +// GNU's --quoting-style, measured on the reference rather than recalled. +// `literal` never quotes. `shell` quotes only a name that needs it, `shell- +// always` quotes every one, and both use shell rules: single quotes normally, +// double quotes when the name contains a single quote, and the quoted run +// closes and reopens around an embedded newline so the name stays one argument. +// `c` is always double quoted with C escapes. +fn quote_name(name string, style string) string { + return match style { + 'c' { c_quote(name) } + 'shell' { shell_quote(name, false) } + 'shell-always' { shell_quote(name, true) } + 'shell-escape' { shell_escape_quote(name, false) } + 'shell-escape-always' { shell_escape_quote(name, true) } + else { name } + } +} + +// shell-escape differs from shell in how it treats a byte that is not printable +// ASCII: instead of leaving a newline or a tab inside the single quotes, it +// closes the run and writes $'\n' or a three digit octal escape, so a name with +// a tab in it comes out as 'tab'$'\t''name'. Bytes at or above 0x80 are escaped +// the same way, which is how GNU writes ü as ''$'\303\274''. +fn shell_escape_quote(name string, always bool) string { + if !always && !needs_quoting(name) { + return name + } + mut out := '' + mut run := '' + mut i := 0 + for i < name.len { + b := name[i] + if b >= 0x20 && b < 0x7f { + run += name[i..i + 1] + i++ + continue + } + out += quote_run(run) + run = '' + out += match b { + `\n` { "$'\\n'" } + `\t` { "$'\\t'" } + `\r` { "$'\\r'" } + else { "$'\\${b:03o}'" } + } + i++ + } + return out + quote_run(run) +} + +// A name needs no quoting when it is made only of characters a shell treats +// literally, which is the same set GNU uses to decide this. A newline needs +// quoting and a shell is happy to have it inside single quotes, which is why a +// two line name comes out as 'newline' with the quotes still open. +fn needs_quoting(name string) bool { + if name.len == 0 { + return true + } + for ch in name { + if !(ch >= `a` && ch <= `z`) && !(ch >= `A` && ch <= `Z`) && !(ch >= `0` && ch <= `9`) + && ch != `@` && ch != `%` && ch != `_` && ch != `+` && ch != `=` && ch != `:` && ch != `,` + && ch != `.` && ch != `/` && ch != `-` { + return true + } + } + return false +} + +fn shell_quote(name string, always bool) string { + if !always && !needs_quoting(name) { + return name + } + return quote_run(name) +} + +// quote_run wraps one run of characters in the quotes a shell would use. +fn quote_run(run string) string { + if run.len == 0 { + return '' + } + // A single quote cannot appear inside single quotes, so such a name is + // double quoted instead, with the double quote, backslash and dollar + // escaped. + if run.contains("'") { + mut out := '"' + for i in 0 .. run.len { + if run[i] == `"` || run[i] == `\\` || run[i] == `$` { + out += '\\' + } + out += run[i..i + 1] + } + return out + '"' + } + return "'${run}'" +} + +fn c_quote(name string) string { + mut out := '"' + for i in 0 .. name.len { + out += match name[i] { + `"` { '\\"' } + `\\` { '\\\\' } + `\n` { '\\n' } + `\t` { '\\t' } + `\r` { '\\r' } + else { name[i..i + 1] } + } + } + return out + '"' +} + +fn real_length(s string) int { + return term.strip_ansi(s).runes().len +} + +@[inline] +fn print_space() { + print_character(` `) +} + +@[inline] +fn print_newline() { + print_character(`\n`) +} diff --git a/src/ls/format_long.v b/src/ls/format_long.v new file mode 100644 index 00000000..6110e44f --- /dev/null +++ b/src/ls/format_long.v @@ -0,0 +1,510 @@ +import arrays +import os +import term +import time +import math + +const inode_title = 'inode' +const permissions_title = 'Permission' +const mask_title = 'Mask' +const links_title = 'Links' +const owner_title = 'Owner' +const group_title = 'Group' +const size_title = 'Size' +const date_modified_title = 'Modified' +const date_accessed_title = 'Accessed' +const date_status_title = 'Status Change' +const name_title = 'Name' +const unknown = '?' +const block_size = 5 +const space = ' ' +const date_format = 'MMM DD HH:mm' +const date_iso_format = 'YYYY-MM-DD HH:mm:ss' +const date_compact_format = "DD MMM'YY HH:mm" +const date_compact_format_with_day = "ddd DD MMM'YY HH:mm" + +struct Longest { + inode int + nlink int + owner_name int + group_name int + size int + checksum int + file int +} + +enum StatTime { + accessed + changed + modified +} + +// GNU prints `total 220`, with no colon, and the number is the sum of the disk +// blocks the entries occupy in 1K units -- not the sum of their lengths. A +// 200000 byte file on a 4K filesystem contributes 196, not 195. With -h the sum +// is scaled like a size instead, giving `total 220K`. An empty directory gets +// `total 0` rather than no line at all. +fn print_total(entries []Entry, options Options) { + allocated := arrays.fold[Entry, u64](entries, 0, fn (a u64, e Entry) u64 { + return a + e.allocated + }) + if options.size_kb || options.size_ki { + println('total ${readable_size(allocated, options.size_ki)}') + return + } + println('total ${allocated / 1024}') +} + +fn format_long_listing(entries []Entry, options Options) { + longest := longest_entries(entries, options) + header, cols := format_header(options, longest) + header_len := real_length(header) + term_cols, _ := term.get_terminal_size() + + print_total(entries, options) + print_header(header, options, header_len, cols) + print_header_border(options, header_len, cols) + + dim := if options.no_dim { no_style } else { dim_style } + + for idx, entry in entries { + // emit blank row every 5th row + if options.blocked_output { + if idx % block_size == 0 && idx != 0 { + match options.table_format { + true { print(border_row_middle(header_len, cols)) } + else { print_newline() } + } + } + } + + // left table border + if options.table_format { + print(table_border_pad_left) + } + + // inode + if options.inode { + content := if entry.invalid { unknown } else { entry.stat.inode.str() } + print(format_cell(content, longest.inode, Align.right, no_style, options)) + print_space() + } + + // checksum + if options.checksum != '' { + checksum := format_cell(entry.checksum, longest.checksum, .left, dim, options) + print(checksum) + print_space() + } + + // permissions + if !options.no_permissions { + content := permissions(entry, options) + print(format_cell(content, permissions_title.len, .right, no_style, options)) + print_space() + } + + // octal permissions + if options.octal_permissions { + content := format_octal_permissions(entry, options) + print(format_cell(content, 4, .left, dim, options)) + print_space() + } + + // hard links + if !options.no_hard_links { + content := if entry.invalid { unknown } else { '${entry.stat.nlink}' } + print(format_cell(content, longest.nlink, .right, dim, options)) + print_space() + } + + // owner name + if !options.no_owner_name { + content := if entry.invalid { unknown } else { get_owner_name(entry.stat.uid) } + print(format_cell(content, longest.owner_name, .right, dim, options)) + print_space() + } + + // group name + if !options.no_group_name { + content := if entry.invalid { unknown } else { get_group_name(entry.stat.gid) } + print(format_cell(content, longest.group_name, .right, dim, options)) + print_space() + } + + // size + if !options.no_size { + content := match true { + // vfmt off + entry.invalid { unknown } + options.size_ki && !options.size_kb { entry.size_ki } + options.size_kb { entry.size_kb } + else { entry.size.str() } + // vfmt on + } + size_style := match entry.link_stat.size > 0 { + true { get_style_for_link(entry, options) } + else { get_style_for_entry(entry, options) } + } + size := format_cell(content, longest.size, .right, size_style, options) + print(size) + print_space() + } + + // date/time(modified) + if !options.no_date { + print(format_time(entry, .modified, options)) + print_space() + } + + // date/time (accessed) + if options.accessed_date { + print(format_time(entry, .accessed, options)) + print_space() + } + + // date/time (status change) + if options.changed_date { + print(format_time(entry, .changed, options)) + print_space() + } + + // file name + file_name := format_entry_name(entry, options) + file_style := get_style_for_entry(entry, options) + match options.table_format { + true { print(format_cell(file_name, longest.file, .left, file_style, options)) } + else { print(format_cell(file_name, 0, .left, file_style, options)) } + } + + // line too long? Print a '≈' in the last column + if options.no_wrap { + mut coord := term.get_cursor_position() or { term.Coord{} } + if coord.x >= term_cols { + coord.x = term_cols + term.set_cursor_position(coord) + print('≈') + } + } + + print_newline() + } + + // bottom border + print_bottom_border(options, header_len, cols) +} + +fn longest_entries(entries []Entry, options Options) Longest { + return Longest{ + // vfmt off + inode: longest_inode_len(entries, inode_title, options) + nlink: longest_nlink_len(entries, links_title, options) + owner_name: longest_owner_name_len(entries, owner_title, options) + group_name: longest_group_name_len(entries, group_title, options) + size: longest_size_len(entries, size_title, options) + checksum: longest_checksum_len(entries, options.checksum, options) + file: longest_file_name_len(entries, name_title, options) + // vfmt on + } +} + +fn print_header(header string, options Options, len int, cols []int) { + if options.header { + if options.table_format { + print(border_row_top(len, cols)) + } + println(header) + } +} + +fn format_header(options Options, longest Longest) (string, []int) { + mut buffer := '' + mut cols := []int{} + dim := if options.no_dim || options.table_format { no_style } else { dim_style } + table_pad := if options.table_format { table_border_pad_left } else { '' } + + if options.table_format { + buffer += table_border_pad_left + } + if options.inode { + title := if options.header { inode_title } else { '' } + buffer += left_pad(title, longest.inode) + table_pad + cols << real_length(buffer) - 1 + } + if options.checksum != '' { + title := if options.header { options.checksum.capitalize() } else { '' } + width := longest.checksum + buffer += right_pad(title, width) + table_pad + cols << real_length(buffer) - 1 + } + if !options.no_permissions { + buffer += 'T ${table_pad}' + cols << real_length(buffer) - 1 + buffer += left_pad(permissions_title, permissions_title.len) + table_pad + cols << real_length(buffer) - 1 + } + if options.octal_permissions { + buffer += left_pad(mask_title, mask_title.len) + table_pad + cols << real_length(buffer) - 1 + } + if !options.no_hard_links { + title := if options.header { links_title } else { '' } + buffer += left_pad(title, longest.nlink) + table_pad + cols << real_length(buffer) - 1 + } + if !options.no_owner_name { + title := if options.header { owner_title } else { '' } + buffer += left_pad(title, longest.owner_name) + table_pad + cols << real_length(buffer) - 1 + } + if !options.no_group_name { + title := if options.header { group_title } else { '' } + buffer += left_pad(title, longest.group_name) + table_pad + cols << real_length(buffer) - 1 + } + if !options.no_size { + title := if options.header { size_title } else { '' } + buffer += left_pad(title, longest.size) + table_pad + cols << real_length(buffer) - 1 + } + if !options.no_date { + title := if options.header { date_modified_title } else { '' } + width := time_format(options).len + buffer += right_pad(title, width) + table_pad + cols << real_length(buffer) - 1 + } + if options.accessed_date { + title := if options.header { date_accessed_title } else { '' } + width := time_format(options).len + buffer += right_pad(title, width) + table_pad + cols << real_length(buffer) - 1 + } + if options.changed_date { + title := if options.header { date_status_title } else { '' } + width := time_format(options).len + buffer += right_pad(title, width) + table_pad + cols << real_length(buffer) - 1 + } + + buffer += right_pad_end(if options.header { name_title } else { '' }, longest.file) // drop last space + header := format_cell(buffer, 0, .left, dim, options) + return header, cols +} + +fn time_format(options Options) string { + return match true { + // vfmt off + options.time_iso { date_iso_format } + options.time_compact { date_compact_format } + options.time_compact_with_day { date_compact_format_with_day } + else { date_format } + // vfmt on + } +} + +fn left_pad(s string, width int) string { + pad := width - s.len + return if pad > 0 { space.repeat(pad) + s + space } else { s + space } +} + +fn right_pad(s string, width int) string { + pad := width - s.len + return if pad > 0 { s + space.repeat(pad) + space } else { s + space } +} + +fn right_pad_end(s string, width int) string { + pad := width - s.len + return if pad > 0 { s + space.repeat(pad) } else { s } +} + +fn statistics(entries []Entry, len int, options Options) { + file_count := entries.filter(it.file).len + total := arrays.sum(entries.map(if it.file || it.exe { it.stat.size } else { 0 })) or { 0 } + dir_count := entries.filter(it.dir).len + link_count := entries.filter(it.link).len + mut stats := '' + + dim := if options.no_dim { no_style } else { dim_style } + file_count_styled := style_string(file_count.str(), options.style_fi, options) + + file := if file_count == 1 { 'file' } else { 'files' } + files := style_string(file, dim, options) + dir_count_styled := style_string(dir_count.str(), options.style_di, options) + + dir := if dir_count == 1 { 'directory' } else { 'directories' } + dirs := style_string(dir, dim, options) + + size := match true { + options.size_ki { readable_size(total, true) } + options.size_kb { readable_size(total, false) } + else { total.str() } + } + + totals := style_string(size, options.style_fi, options) + stats = '${dir_count_styled} ${dirs} | ${file_count_styled} ${files} [${totals}]' + + if link_count > 0 { + link_count_styled := style_string(link_count.str(), options.style_ln, options) + links := style_string('links', dim, options) + stats += ' | ${link_count_styled} ${links}' + } + println(stats) +} + +fn file_flag(entry Entry, options Options) string { + return match true { + // vfmt off + entry.invalid { unknown } + entry.link { style_string('l', options.style_ln, options) } + entry.dir { style_string('d', options.style_di, options) } + entry.fifo { style_string('p', options.style_pi, options) } + entry.block { style_string('b', options.style_bd, options) } + entry.character { style_string('c', options.style_cd, options) } + entry.socket { style_string('s', options.style_so, options) } + entry.file { style_string('-', options.style_fi, options) } + else { '?' } + // vfmt on + } +} + +fn format_octal_permissions(entry Entry, options Options) string { + mode := entry.stat.get_mode() + return '0${mode.owner.bitmask()}${mode.group.bitmask()}${mode.others.bitmask()}' +} + +fn permissions(entry Entry, options Options) string { + mode := entry.stat.get_mode() + flag := file_flag(entry, options) + owner := file_permission(mode.owner, options) + group := file_permission(mode.group, options) + other := file_permission(mode.others, options) + return '${flag}${owner}${group}${other}' +} + +fn file_permission(file_permission os.FilePermission, options Options) string { + dim := if options.no_dim { no_style } else { dim_style } + dash := style_string('-', dim, options) + r := if file_permission.read { style_string('r', options.style_ln, options) } else { dash } + w := if file_permission.write { style_string('w', options.style_fi, options) } else { dash } + x := if file_permission.execute { style_string('x', options.style_ex, options) } else { dash } + return '${r}${w}${x}' +} + +// GNU shows the time of day for anything from the last six months and the year +// for anything older. The boundary is calendar months rather than a fixed count +// of days: measured on the reference with now = 2026-10-03, a file dated +// 2026-04-04 showed a time and one dated 2026-04-01 showed a year, so the mark +// sits on 2026-04-03. +fn is_recent(t time.Time) bool { + now := time.now() + mut cy := now.year + mut cm := now.month - 6 + if cm < 1 { + cm += 12 + cy -= 1 + } + // The day of the month may not exist six months back, the 31st of a month + // with 30 days being the case that bites. + cd := math.min(now.day, time.days_in_month(cm, cy) or { 31 }) + if t.year != cy || t.month != cm { + return (t.year > cy) || (t.year == cy && t.month > cm) + } + if t.day != cd { + return t.day > cd + } + if t.hour != now.hour { + return t.hour > now.hour + } + if t.minute != now.minute { + return t.minute > now.minute + } + return t.second >= now.second +} + +fn format_time(entry Entry, stat_time StatTime, options Options) string { + entry_time := match stat_time { + .accessed { entry.stat.atime } + .changed { entry.stat.ctime } + .modified { entry.stat.mtime } + } + + t := time.unix(entry_time).local() + // Only the default style switches to the year; the explicit --time-style + // ones are left to print what they were asked for. + mut date := if time_format(options) == date_format && !is_recent(t) { + // `Nov 15 2023`: the day is padded to width two and then two spaces + // separate it from the year. + day := if t.day < 10 { ' ${t.day}' } else { '${t.day}' } + '${t.smonth()} ${day} ${t.year}' + } else { + t.custom_format(time_format(options)) + } + + if date.len > 5 && date[4] == `0` && date[5] >= `0` && date[5] <= `9` + && !date.starts_with('YYYY') { + date = date[..4] + ' ' + date[5..] + } + + dim := if options.no_dim { no_style } else { dim_style } + content := if entry.invalid { '?' + space.repeat(date.len - 1) } else { date } + return format_cell(content, date.len, .left, dim, options) +} + +fn longest_nlink_len(entries []Entry, title string, options Options) int { + lengths := entries.map(it.stat.nlink.str().len) + longest := arrays.max(lengths) or { 0 } + return if options.no_hard_links || !options.header { + longest + } else { + math.max(longest, title.len) + } +} + +fn longest_owner_name_len(entries []Entry, title string, options Options) int { + lengths := entries.map(get_owner_name(it.stat.uid).len) + longest := arrays.max(lengths) or { 0 } + return if options.no_owner_name || !options.header { + longest + } else { + math.max(longest, title.len) + } +} + +fn longest_group_name_len(entries []Entry, title string, options Options) int { + lengths := entries.map(get_group_name(it.stat.gid).len) + longest := arrays.max(lengths) or { 0 } + return if options.no_group_name || !options.header { + longest + } else { + math.max(longest, title.len) + } +} + +fn longest_size_len(entries []Entry, title string, options Options) int { + lengths := entries.map(match true { + it.dir { 1 } + options.size_ki && !options.size_kb { it.size_ki.len } + options.size_kb { it.size_kb.len } + else { it.size.str().len } + }) + longest := arrays.max(lengths) or { 0 } + return if options.no_size || !options.header { longest } else { math.max(longest, title.len) } +} + +fn longest_inode_len(entries []Entry, title string, options Options) int { + lengths := entries.map(it.stat.inode.str().len) + longest := arrays.max(lengths) or { 0 } + return if !options.inode || !options.header { longest } else { math.max(longest, title.len) } +} + +fn longest_file_name_len(entries []Entry, title string, options Options) int { + lengths := entries.map(real_length(format_entry_name(it, options))) + longest := arrays.max(lengths) or { 0 } + return if !options.header { longest } else { math.max(longest, title.len) } +} + +fn longest_checksum_len(entries []Entry, title string, options Options) int { + lengths := entries.map(it.checksum.len) + longest := arrays.max(lengths) or { 0 } + return if !options.header { longest } else { math.max(longest, title.len) } +} diff --git a/src/ls/icons.v b/src/ls/icons.v new file mode 100644 index 00000000..1d55ed1d --- /dev/null +++ b/src/ls/icons.v @@ -0,0 +1,440 @@ +import os + +fn get_icon_for_entry(entry Entry, options Options) string { + if !options.icons { + return '' + } + ext := os.file_ext(entry.name) + name := os.file_name(entry.name) + return match entry.dir { + true { get_icon_for_folder(name) } + else { get_icon_for_file(name, ext) } + } +} + +fn get_icon_for_file(name string, ext string) string { + // default icon for all files. try to find a better one though... + mut icon := icons_map['file'] + // resolve aliased extensions + mut ext_key := ext.to_lower() + if ext.starts_with('.') { + ext_key = ext_key[1..] + } + alias := aliases_map[ext_key] + if alias != '' { + ext_key = alias + } + // see if we can find a better icon based on extension alone + better_icon := icons_map[ext_key] + if better_icon != '' { + icon = better_icon + } + // now look for icons based on full names + mut full_name := name.to_lower() + full_alias := aliases_map[full_name] + if full_alias != '' { + full_name = full_alias + } + best_icon := icons_map[full_name] + if best_icon != '' { + icon = best_icon + } + return icon + space +} + +fn get_icon_for_folder(name string) string { + mut icon := folders_map['folder'] + better_icon := folders_map[name] + if better_icon != '' { + icon = better_icon + } + return icon + space +} + +const icons_map = { + 'ai': '\ue7b4' + 'android': '\ue70e' + 'apple': '\uf179' + 'as': '\ue60b' + 'asm': '󰘚' + 'audio': '\uf1c7' + 'avro': '\ue60b' + 'bf': '\uf067' + 'binary': '\uf471' + 'bzl': '\ue63a' + 'c': '\ue61e' + 'cfg': '\uf423' + 'clj': '\ue768' + 'coffee': '\ue751' + 'conf': '\ue615' + 'cpp': '\ue61d' + 'cfm': '\ue645' + 'cr': '\ue62f' + 'cs': '\ue648' + 'cson': '\ue601' + 'css': '\ue749' + 'cu': '\ue64b' + 'd': '\ue7af' + 'dart': '\ue64c' + 'db': '\uf1c0' + 'deb': '\uf306' + 'diff': '\uf440' + 'doc': '\uf1c2' + 'dockerfile': '\ue650' + 'dpkg': '\uf17c' + 'ebook': '\uf02d' + 'elm': '\ue62c' + 'env': '\uf462' + 'erl': '\ue7b1' + 'ex': '\ue62d' + 'f': '󱈚' + 'file': '\uf15b' + 'font': '\uf031' + 'fs': '\ue7a7' + 'gb': '\ue272' + 'gform': '\uf298' + 'git': '\ue702' + 'go': '\ue724' + 'graphql': '\ue662' + 'glp': '󰆧' + 'groovy': '\ue775' + 'gruntfile.js': '\ue74c' + 'gulpfile.js': '\ue610' + 'gv': '\ue225' + 'h': '\uf0fd' + 'haml': '\ue664' + 'hs': '\ue777' + 'html': '\uf13b' + 'hx': '\ue666' + 'ics': '\uf073' + 'image': '\uf1c5' + 'iml': '\ue7b5' + 'ini': '󰅪' + 'ino': '\ue255' + 'iso': '󰋊' + 'jade': '\ue66c' + 'java': '\ue738' + 'jenkinsfile': '\ue767' + 'jl': '\ue624' + 'js': '\ue781' + 'json': '\ue60b' + 'jsx': '\ue7ba' + 'key': '\uf43d' + 'ko': '\uebc6' + 'kt': '\ue634' + 'less': '\ue758' + 'lock': '\uf023' + 'log': '\uf18d' + 'lua': '\ue620' + 'maintainers': '\uf0c0' + 'makefile': '\ue20f' + 'md': '\uf48a' + 'mjs': '\ue718' + 'ml': '󰘧' + 'mustache': '\ue60f' + 'nc': '󰋁' + 'nim': '\ue677' + 'nix': '\uf313' + 'npmignore': '\ue71e' + 'package': '󰏗' + 'passwd': '\uf023' + 'patch': '\uf440' + 'pdf': '\uf1c1' + 'php': '\ue608' + 'pl': '\ue7a1' + 'prisma': '\ue684' + 'ppt': '\uf1c4' + 'psd': '\ue7b8' + 'py': '\ue606' + 'r': '\ue68a' + 'rb': '\ue21e' + 'rdb': '\ue76d' + 'rpm': '\uf17c' + 'rs': '\ue7a8' + 'rss': '\uf09e' + 'rst': '󰅫' + 'rubydoc': '\ue73b' + 'sass': '\ue603' + 'scala': '\ue737' + 'shell': '\uf489' + 'shp': '󰙞' + 'sol': '󰡪' + 'sqlite': '\ue7c4' + 'styl': '\ue600' + 'svelte': '\ue697' + 'swift': '\ue755' + 'tex': '\u222b' + 'tf': '\ue69a' + 'toml': '󰅪' + 'ts': '󰛦' + 'twig': '\ue61c' + 'txt': '\uf15c' + 'v': '𝕍' + 'vagrantfile': '\ue21e' + 'video': '\uf03d' + 'vim': '\ue62b' + 'vue': '\ue6a0' + 'windows': '\uf17a' + 'xls': '\uf1c3' + 'xml': '\ue796' + 'yml': '\ue601' + 'zig': '\ue6a9' + 'zip': '\uf410' +} + +const aliases_map = { + 'apk': 'android' + 'gradle': 'android' + 'ds_store': 'apple' + 'localized': 'apple' + 'm': 'apple' + 'mm': 'apple' + 's': 'asm' + 'aac': 'audio' + 'alac': 'audio' + 'flac': 'audio' + 'm4a': 'audio' + 'mka': 'audio' + 'mp3': 'audio' + 'ogg': 'audio' + 'opus': 'audio' + 'wav': 'audio' + 'wma': 'audio' + 'b': 'bf' + 'bson': 'binary' + 'feather': 'binary' + 'mat': 'binary' + 'o': 'binary' + 'pb': 'binary' + 'pickle': 'binary' + 'pkl': 'binary' + 'tfrecord': 'binary' + 'conf': 'cfg' + 'config': 'cfg' + 'cljc': 'clj' + 'cljs': 'clj' + 'editorconfig': 'conf' + 'rc': 'conf' + 'c++': 'cpp' + 'cc': 'cpp' + 'cxx': 'cpp' + 'scss': 'css' + 'sql': 'db' + 'docx': 'doc' + 'gdoc': 'doc' + 'dockerignore': 'dockerfile' + 'epub': 'ebook' + 'ipynb': 'ebook' + 'mobi': 'ebook' + 'f03': 'f' + 'f77': 'f' + 'f90': 'f' + 'f95': 'f' + 'for': 'f' + 'fpp': 'f' + 'ftn': 'f' + 'eot': 'font' + 'otf': 'font' + 'ttf': 'font' + 'woff': 'font' + 'woff2': 'font' + 'fsi': 'fs' + 'fsscript': 'fs' + 'fsx': 'fs' + 'dna': 'gb' + 'gitattributes': 'git' + 'gitconfig': 'git' + 'gitignore': 'git' + 'gitignore_global': 'git' + 'gitmirrorall': 'git' + 'gitmodules': 'git' + 'gltf': 'glp' + 'gsh': 'groovy' + 'gvy': 'groovy' + 'gy': 'groovy' + 'h++': 'h' + 'hh': 'h' + 'hpp': 'h' + 'hxx': 'h' + 'lhs': 'hs' + 'htm': 'html' + 'xhtml': 'html' + 'bmp': 'image' + 'cbr': 'image' + 'cbz': 'image' + 'dvi': 'image' + 'eps': 'image' + 'gif': 'image' + 'ico': 'image' + 'jpeg': 'image' + 'jpg': 'image' + 'nef': 'image' + 'orf': 'image' + 'pbm': 'image' + 'pgm': 'image' + 'png': 'image' + 'pnm': 'image' + 'ppm': 'image' + 'pxm': 'image' + 'sixel': 'image' + 'stl': 'image' + 'svg': 'image' + 'tif': 'image' + 'tiff': 'image' + 'webp': 'image' + 'xpm': 'image' + 'disk': 'iso' + 'dmg': 'iso' + 'img': 'iso' + 'ipsw': 'iso' + 'smi': 'iso' + 'vhd': 'iso' + 'vhdx': 'iso' + 'vmdk': 'iso' + 'jar': 'java' + 'cjs': 'js' + 'properties': 'json' + 'webmanifest': 'json' + 'tsx': 'jsx' + 'cjsx': 'jsx' + 'cer': 'key' + 'crt': 'key' + 'der': 'key' + 'gpg': 'key' + 'p7b': 'key' + 'pem': 'key' + 'pfx': 'key' + 'pgp': 'key' + 'license': 'key' + 'codeowners': 'maintainers' + 'credits': 'maintainers' + 'cmake': 'makefile' + 'justfile': 'makefile' + 'markdown': 'md' + 'mkd': 'md' + 'rdoc': 'md' + 'readme': 'md' + 'mli': 'ml' + 'sml': 'ml' + 'netcdf': 'nc' + 'brewfile': 'package' + 'cargo.toml': 'package' + 'cargo.lock': 'package' + 'go.mod': 'package' + 'go.sum': 'package' + 'pyproject.toml': 'package' + 'poetry.lock': 'package' + 'package.json': 'package' + 'pipfile': 'package' + 'pipfile.lock': 'package' + 'php3': 'php' + 'php4': 'php' + 'php5': 'php' + 'phpt': 'php' + 'phtml': 'php' + 'gslides': 'ppt' + 'pptx': 'ppt' + 'pxd': 'py' + 'pyc': 'py' + 'pyx': 'py' + 'whl': 'py' + 'rdata': 'r' + 'rds': 'r' + 'rmd': 'r' + 'gemfile': 'rb' + 'gemspec': 'rb' + 'guardfile': 'rb' + 'procfile': 'rb' + 'rakefile': 'rb' + 'rspec': 'rb' + 'rspec_parallel': 'rb' + 'rspec_status': 'rb' + 'ru': 'rb' + 'erb': 'rubydoc' + 'slim': 'rubydoc' + 'awk': 'shell' + 'bash': 'shell' + 'bash_history': 'shell' + 'bash_profile': 'shell' + 'bashrc': 'shell' + 'csh': 'shell' + 'fish': 'shell' + 'ksh': 'shell' + 'ps1': 'shell' + 'sh': 'shell' + 'zsh': 'shell' + 'zsh-theme': 'shell' + 'zshrc': 'shell' + 'plpgsql': 'sql' + 'plsql': 'sql' + 'psql': 'sql' + 'tsql': 'sql' + 'sl3': 'sqlite' + 'sqlite3': 'sqlite' + 'stylus': 'styl' + 'cls': 'tex' + 'avi': 'video' + 'flv': 'video' + 'm2v': 'video' + 'mkv': 'video' + 'mov': 'video' + 'mp4': 'video' + 'mpeg': 'video' + 'mpg': 'video' + 'ogm': 'video' + 'ogv': 'video' + 'vob': 'video' + 'webm': 'video' + 'vimrc': 'vim' + 'bat': 'windows' + 'cmd': 'windows' + 'exe': 'windows' + 'csv': 'xls' + 'gsheet': 'xls' + 'xlsx': 'xls' + 'plist': 'xml' + 'xul': 'xml' + 'yaml': 'yml' + '7z': 'zip' + 'Z': 'zip' + 'bz2': 'zip' + 'gz': 'zip' + 'lzma': 'zip' + 'par': 'zip' + 'rar': 'zip' + 'tar': 'zip' + 'tc': 'zip' + 'tgz': 'zip' + 'txz': 'zip' + 'xz': 'zip' + 'z': 'zip' +} + +const folders_map = { + '.atom': '\ue764' + '.aws': '\ue7ad' + '.docker': '\ue7b0' + '.gem': '\ue21e' + '.git': '\ue5fb' + '.git-credential-cache': '\ue5fb' + '.github': '\ue5fd' + '.npm': '\ue5fa' + '.nvm': '\ue718' + '.rvm': '\ue21e' + '.Trash': '\uf1f8' + '.vscode': '\ue70c' + '.vim': '\ue62b' + 'config': '\ue5fc' + 'folder': '\uf07c' + 'hidden': '\uf023' + 'node_modules': '\ue5fa' +} + +const other_icons_map = { + 'link': '\uf0c1' + 'linkDir': '\uf0c1' + 'brokenLink': '\uf127' + 'device': '\uf0a0' + 'socket': '\uf1e6' + 'pipe': '\ufce3' +} diff --git a/src/ls/ls.v b/src/ls/ls.v index e4a60f27..050df767 100644 --- a/src/ls/ls.v +++ b/src/ls/ls.v @@ -1,133 +1,74 @@ -import os -import common +import arrays { group_by } +import datatypes { Set } +import math -struct Directory { - name string -mut: - contents []string +fn main() { + options, files := get_args() + set_auto_wrap(options) + entries, dirs, status := get_entries(files, options) + mut cyclic := Set[string]{} + mut printed_any := false + status1 := ls(entries, dirs, options, mut cyclic, &printed_any, '') + exit(math.max(status, status1)) } -fn go_print(file_list []Directory, seperator string) { - mut constructed := '' - if file_list.len == 1 { - // Single directory - for i, contents in file_list[0].contents { - constructed += contents - if i == file_list[0].contents.len - 1 { - break - } - constructed += seperator - } - } else { - // Multiple directories - for i, dir in file_list { - constructed += dir.name + ':\n' - for j, contents in dir.contents { - constructed += contents - if j == dir.contents.len - 1 { - break - } - constructed += seperator - } - if i != file_list.len - 1 { - constructed += '\n\n' - } +// section names the directory this call is listing, so that one holding nothing +// still gets its header. GNU prints `./Zdir:` above no rows at all rather than +// skipping it. dirs carries the directory operands so that an empty one still +// gets a section: it produces no entries, so grouping alone would lose it. +fn ls(entries []Entry, dirs []string, options Options, mut cyclic Set[string], printed_any &bool, section string) int { + mut status := 0 + group_by_dirs := group_by[string, Entry](entries, fn (e Entry) string { + return e.dir_name + }) + mut sections := dirs.clone() + for name in group_by_dirs.keys() { + if !sections.contains(name) { + sections << name } } - print(constructed) -} + sorted_dirs := sections.sorted() -fn main() { - mut fp := common.flag_parser(os.args) - fp.application('ls') - fp.description('list directory contents') - - arg_1 := fp.bool('', `1`, false, 'list one file per line') - arg_all := fp.bool('all', `a`, false, 'do not ignore entries starting with .') - arg_almost_all := fp.bool('almost-all', `A`, false, 'do not list implied . and ..') - arg_comma_seperated := fp.bool('comma-seperated', `m`, false, - 'fill width with a comma seperated list of entries') - arg_reverse := fp.bool('reverse', `r`, false, 'reverse order wile sorting') - arg_help := fp.bool('help', 0, false, 'display this help and exit') - - // Get folders - args := fp.finalize() or { - eprintln(err) - println(fp.usage()) - exit(1) - } - - // Help command - if arg_help { - println(fp.usage()) - exit(0) + if sorted_dirs.len == 0 { + // Nothing came back from the directory, but its header still has to be + // printed and the long listing still has to say `total 0`. + print_dir_name(section, options, printed_any) + print_files([], options) + return 0 } - // Get dir / dirs - mut file_list := match args.len { - 0 { - list := os.ls('.') or { - eprintln(err) - println("ls: cannot access '.': No such file or directory") - exit(1) - } - - [Directory{'.', list}] + for dir in sorted_dirs { + files := group_by_dirs[dir] + filtered := filter(files, options) + sorted := sort(filtered, options) + if sections.len > 1 || options.recursive { + print_dir_name(dir, options, printed_any) } - else { - // 1 or more dirs - mut dirs := []Directory{} - for arg in args { - name := if args.len > 1 { - arg - } else { - '.' - } - list := os.ls(arg) or { - eprintln(err) - println("ls: cannot access '" + arg + "': No such file or directory") - exit(1) - } - dirs << Directory{name.replace('/', ''), list} + print_files(sorted, options) + // A header that follows rows is still preceded by a blank line, even + // when the rows came from an unnamed section such as a bare operand. + if sorted.len > 0 { + unsafe { + *printed_any = true } - dirs } - } - - // Define initial seperator - mut seperator := ' ' - - // Modify seperator - if arg_comma_seperated { - seperator = ', ' - } - if arg_1 { - seperator += '\n' - } - // Do not list dotfiles by default - if !(arg_all || arg_almost_all) { - for i, dir in file_list { - file_list[i].contents = dir.contents.filter(fn (contents string) bool { - return contents[0] != `.` - }) - } - } - - // . and .. path listing - if arg_all { - for i, _ in file_list { - file_list[i].contents.prepend(['.', '..']) - } - } - - // Reverse - if arg_reverse { - for i, _ in file_list { - file_list[i].contents.reverse_in_place() + if options.recursive { + for entry in sorted { + if entry.dir { + path := entry_path(entry.dir_name, entry.name) + if cyclic.exists(path) { + println('===> cyclic reference detected <===') + continue + } + cyclic.add(path) + dir_entries, sub_dirs, status1 := get_entries([path], options) + status2 := ls(dir_entries, sub_dirs, options, mut cyclic, printed_any, path) + cyclic.remove(path) + status = math.max(status1, status2) + } + } } } - - // Print - go_print(file_list, seperator) + return status } diff --git a/src/ls/ls_nix.c.v b/src/ls/ls_nix.c.v new file mode 100644 index 00000000..1eecf04d --- /dev/null +++ b/src/ls/ls_nix.c.v @@ -0,0 +1,99 @@ +import os + +#include +#include +#include + +struct Passwd { + pw_name &char + pw_uid usize + pw_gid usize + pw_dir &char + pw_shell &char +} + +struct Group { + gr_name &char + gr_gid usize + gr_mem &&char +} + +fn C.getpwuid(uid usize) &Passwd +fn C.getgrgid(uid usize) &Group + +// V's os.Stat has no st_blocks, and GNU's `total` line is the sum of those in +// 1K units, so the port cannot get the number any other way. vlib/os has the +// field in its own C.stat but drops it when it builds the platform-agnostic +// Stat, and it is not reachable as os.C.stat. Declaring the same struct here +// works because the field list is identical to the one vlib/os declares; a +// different name or layout breaks vlib's own call to C.lstat, which is the +// error this replaces. st_blocks was checked against `stat -c %b` rather than +// assumed, since the layout above is reproduced here. +struct C.stat { + st_dev u64 + st_ino u64 + st_nlink u64 + st_mode u32 + st_uid u32 + st_gid u32 + st_rdev u64 + st_size u64 + st_blksize u64 + st_blocks u64 + st_atime i64 + st_mtime i64 + st_ctime i64 +} + +fn C.lstat(path &char, buf &C.stat) int + +// allocated_size returns the space the entry occupies on disk, which is not +// what GNU's -h prints: a 3000 byte file on a 4K filesystem is shown as 3.0K, +// and a 200000 byte one as 196K. st_blocks is counted in 512 byte units. +fn allocated_size(path string) u64 { + mut buf := C.stat{} + unsafe { + if C.lstat(path.str, &buf) != 0 { + return 0 + } + } + return buf.st_blocks * 512 +} + +fn get_owner_name(uid usize) string { + pwd := C.getpwuid(uid) + unsafe { + if isnil(pwd) { + // Call succeeded but user not found + if C.errno == 0 { + return '' + } + return os.error_posix().msg() + } + return cstring_to_vstring(pwd.pw_name) + } +} + +fn get_group_name(uid usize) string { + grp := C.getgrgid(uid) + unsafe { + if isnil(grp) { + // Call succeeded but user not found + if C.errno == 0 { + return '' + } + return os.error_posix().msg() + } + return cstring_to_vstring(grp.gr_name) + } +} + +fn read_link(file string) string { + buf_size := 2048 + buf := '\0'.repeat(buf_size) + len := C.readlink(file.str, buf.str, usize(buf_size)) + if len == -1 { + return os.error_posix().msg() + } + return buf.substr(0, len) +} diff --git a/src/ls/ls_windows.c.v b/src/ls/ls_windows.c.v new file mode 100644 index 00000000..e64778f8 --- /dev/null +++ b/src/ls/ls_windows.c.v @@ -0,0 +1,51 @@ +fn C.CreateFileW(lpFilename &u16, dwDesiredAccess u32, dwShareMode u32, lpSecurityAttributes &u16, dwCreationDisposition u32, dwFlagsAndAttributes u32, hTemplateFile voidptr) voidptr +fn C.GetFinalPathNameByHandleW(hFile voidptr, lpFilePath &u16, nSize u32, dwFlags u32) u32 + +const max_path_buffer_size = u32(512) + +fn read_link(path string) string { + // gets handle with GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0 + file := C.CreateFile(path.to_wide(), 0x80000000, 1, 0, 3, 0x80, 0) + if file != voidptr(-1) { + defer { + C.CloseHandle(file) + } + final_path := [max_path_buffer_size]u8{} + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew + final_len := C.GetFinalPathNameByHandleW(file, unsafe { &u16(&final_path[0]) }, + max_path_buffer_size, 0) + if final_len == 0 { + return '?' + } + if final_len < max_path_buffer_size { + sret := unsafe { string_from_wide2(&u16(&final_path[0]), int(final_len)) } + defer { + unsafe { sret.free() } + } + // remove '\\?\' from beginning (see link above) + assert sret[0..4] == r'\\?\' + sret_slice := sret[4..] + res := sret_slice.clone() + return res + } else { + return '?' + } + } else { + return '?' + } +} + +fn get_owner_name(uid usize) string { + return uid.str() +} + +fn get_group_name(uid usize) string { + return uid.str() +} + +// Windows has no st_blocks, so there is no allocated size to report and no +// `total` line: summing lengths would print a number that means nothing. +fn allocated_size(path string) u64 { + _ := path + return 0 +} diff --git a/src/ls/natural_compare.v b/src/ls/natural_compare.v new file mode 100644 index 00000000..d2dc1a85 --- /dev/null +++ b/src/ls/natural_compare.v @@ -0,0 +1,54 @@ +import math + +// compares strings with embedded numbers (e.g. log17.txt) +fn natural_compare(a &string, b &string) int { + pa := split(a) + pb := split(b) + max := math.min(pa.len, pb.len) + + for i := 0; i < max; i++ { + if pa[i].is_int() && pb[i].is_int() { + result := pa[i].int() - pb[i].int() + if result != 0 { + return result + } + } else { + result := compare_strings(pa[i], pb[i]) + if result != 0 { + return result + } + } + } + return pa.len - pb.len +} + +enum State { + init + digit + non_digit +} + +fn split(a &string) []string { + mut result := []string{} + mut start := 0 + mut state := State.init + s := a.runes() + + for i := 0; i < s.len; i++ { + if s[i] >= `0` && s[i] <= `9` { + if state == State.non_digit { + result << s[start..i].string() + start = i + } + state = State.digit + } else { + if state == State.digit { + result << s[start..i].string() + start = i + } + state = State.non_digit + } + } + result << s[start..].string() + return result +} diff --git a/src/ls/natural_compare_test.v b/src/ls/natural_compare_test.v new file mode 100644 index 00000000..9823a340 --- /dev/null +++ b/src/ls/natural_compare_test.v @@ -0,0 +1,52 @@ +module main + +fn test_numbers_embdded_in_text() { + a := 'log10.txt' + b := 'log9.txt' + + assert compare_strings(&b, &a) > 0 + assert natural_compare(&b, &a) < 0 + + assert compare_strings(&a, &b) < 0 + assert natural_compare(&a, &b) > 0 + + assert compare_strings(&a, &a) == 0 + assert natural_compare(&a, &a) == 0 + + assert compare_strings(&b, &b) == 0 + assert natural_compare(&b, &b) == 0 +} + +fn test_numbers_two_embdded_in_text() { + a := '0log10.txt' + b := '1log9.txt' + + assert compare_strings(&a, &b) < 0 + assert natural_compare(&a, &b) < 0 + + assert compare_strings(&b, &a) > 0 + assert natural_compare(&b, &a) > 0 + + assert compare_strings(&a, &a) == 0 + assert natural_compare(&a, &a) == 0 + + assert compare_strings(&b, &b) == 0 + assert natural_compare(&b, &b) == 0 +} + +fn test_no_numbers_in_text() { + a := 'abc' + b := 'bca' + + assert compare_strings(&a, &b) < 0 + assert natural_compare(&a, &b) < 0 + + assert compare_strings(&b, &a) > 0 + assert natural_compare(&b, &a) > 0 + + assert compare_strings(&a, &a) == 0 + assert natural_compare(&a, &a) == 0 + + assert compare_strings(&b, &b) == 0 + assert natural_compare(&b, &b) == 0 +} diff --git a/src/ls/options.v b/src/ls/options.v new file mode 100644 index 00000000..d3705d5f --- /dev/null +++ b/src/ls/options.v @@ -0,0 +1,186 @@ +module main + +import common +import flag +import os +import term + +const app_name = 'ls' +const app_version = '0.1' +const current_dir = ['.'] + +const when_always = 'always' +const when_never = 'never' +const when_auto = 'auto' + +@[version: app_version] +@[name: app_name] +struct Options { +mut: + // + // flags + all bool @[long: 'all'; short: 'a'; xdoc: 'do not ignore entries starting with .'] + almost_all bool @[long: 'almost-all'; short: 'A'; xdoc: 'do not list implied . and ..'] // default is to not list, not used + blocked_output bool @[xdoc: 'blank line every 5 rows'] + checksum string @[xdoc: 'show file checksum (md5, sha1, sha224, sha256, sha512, blake2b)'] + list_by_columns bool @[only: 'C'; xdoc: 'list entries by columns'] + classify bool @[only: 'F'; xdoc: 'append a character indicating file type'] + list_directory_itself bool @[only: 'd'; xdoc: 'list directories themselves, not their contents'] + colorize string = when_never @[long: 'color'; xdoc: 'color the output (WHEN); more info below'] + long_no_owner bool @[only: 'g'; xdoc: 'like -l, but do not list owner'] + dirs_first bool @[only: 'group-directories-first'; xdoc: 'group directories before files; can be augmented with a --sort option, but any use of --sort=none (-U) disables grouping'] + icons bool @[xdoc: 'show file icon (requires nerd fonts)'] + no_date bool @[xdoc: 'hide data (modified)'] + no_dim bool @[xdoc: 'hide shading; useful for light backgrounds'] + no_group_name bool @[long: 'no-group'; short: 'G'; xdoc: 'in a long listing, don\'t print group names'] + no_hard_links bool @[xdoc: 'hide hard links count'] + no_owner_name bool @[only: 'no_owner'; xdoc: 'hide owner name'] + no_permissions bool @[xdoc: 'hide permissions'] + no_size bool @[xdoc: 'hide file size'] + no_wrap bool @[xdoc: 'do not wrap long lines'] + size_kb bool @[long: 'human-readable'; short: 'h'; xdoc: 'with -l and -s, print sizes like 1K 234M 2G etc.'] + header bool @[xddoc: 'show column headers (implies -l)'] + size_ki bool @[long: 'si'; xdoc: 'likewise, but use powers of 1000 not 1024'] + inode bool @[long: 'inode'; short: 'i'; xdoc: 'print the index number of each file'] + long_format bool @[only: 'l'; xdoc: 'use a long listing format'] + with_commas bool @[only: 'm'; xdoc: 'fill width with a comma separated list of entries'] + long_no_group bool @[only: 'o'; xdoc: 'like -l, but do not list group information'] + octal_permissions bool @[xdoc: 'show as permissions octal number'] + only_dirs bool @[xdoc: 'list only directories'] + only_files bool @[xdoc: 'list only files'] + dir_indicator bool @[long: 'indicator-style'; short: 'p'; xdoc: 'append / indicator to directories'] + quote bool @[long: 'quote-name'; short: 'Q'; xdoc: 'enclose entry names in double quotes'] + quoting_style string @[only: 'quoting-style'; xdoc: 'quote names