From 959bad777cac84456009b35a18afb5c930aad50e Mon Sep 17 00:00:00 2001 From: mike-ward Date: Wed, 18 Dec 2024 10:54:06 -0600 Subject: [PATCH 1/3] ls implmentation with extras --- src/ls/auto_wrap.v | 19 ++ src/ls/entry.v | 163 ++++++++++++ src/ls/entry_test.v | 24 ++ src/ls/filter.v | 9 + src/ls/format.v | 232 +++++++++++++++++ src/ls/format_long.v | 451 ++++++++++++++++++++++++++++++++++ src/ls/icons.v | 440 +++++++++++++++++++++++++++++++++ src/ls/ls.v | 161 +++--------- src/ls/ls_nix.c.v | 61 +++++ src/ls/ls_windows.c.v | 44 ++++ src/ls/natural_compare.v | 54 ++++ src/ls/natural_compare_test.v | 52 ++++ src/ls/options.v | 185 ++++++++++++++ src/ls/sort.v | 72 ++++++ src/ls/style.v | 176 +++++++++++++ src/ls/table.v | 68 +++++ 16 files changed, 2089 insertions(+), 122 deletions(-) create mode 100644 src/ls/auto_wrap.v create mode 100644 src/ls/entry.v create mode 100644 src/ls/entry_test.v create mode 100644 src/ls/filter.v create mode 100644 src/ls/format.v create mode 100644 src/ls/format_long.v create mode 100644 src/ls/icons.v create mode 100644 src/ls/ls_nix.c.v create mode 100644 src/ls/ls_windows.c.v create mode 100644 src/ls/natural_compare.v create mode 100644 src/ls/natural_compare_test.v create mode 100644 src/ls/options.v create mode 100644 src/ls/sort.v create mode 100644 src/ls/style.v create mode 100644 src/ls/table.v diff --git a/src/ls/auto_wrap.v b/src/ls/auto_wrap.v new file mode 100644 index 0000000..682cdf7 --- /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 0000000..7a7abc4 --- /dev/null +++ b/src/ls/entry.v @@ -0,0 +1,163 @@ +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 + size_ki string + size_kb string + checksum string + invalid bool // lstat could not access +} + +fn get_entries(files []string, options Options) ([]Entry, int) { + mut entries := []Entry{cap: 50} + mut status := 0 + + for file in files { + if os.is_dir(file) { + dir_files := os.ls(file) or { + status = 1 // see help for meaning of exit codes + eprintln(err) + continue + } + 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 options.all || !is_dot_file(file) { + entries << make_entry(file, '', options) + } + } + } + return entries, status +} + +fn make_entry(file string, dir_name string, options Options) Entry { + mut invalid := false + path := if dir_name == '' { file } else { os.join_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 link + link_stat = os.stat(path) or { os.Stat{} } + size = link_stat.size + } + + 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 + indicator := if is_dir && options.dir_indicator { '/' } 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 + 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 + } +} + +fn readable_size(size u64, si bool) string { + kb := if si { f64(1024) } else { f64(1000) } + mut sz := f64(size) + for unit in ['', 'k', 'm', 'g', 't', 'p', 'e', 'z'] { + if sz < kb { + readable := match unit == '' { + true { size.str() } + else { math.round_sig(sz + .049999, 1).str() } + } + bytes := match true { + // vfmt off + unit == '' { '' } + si { 'b' } + else { '' } + // vfmt on + } + return '${readable}${unit}${bytes}' + } + sz /= kb + } + return size.str() +} + +fn checksum(name string, dir_name string, options Options) string { + if options.checksum == '' { + return '' + } + file := os.join_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 0000000..38cbf6f --- /dev/null +++ b/src/ls/entry_test.v @@ -0,0 +1,24 @@ +module main + +fn test_readable_size() { + assert readable_size(395, true) == '395' + assert readable_size(395, false) == '395' + + assert readable_size(200_000, true) == '195.4kb' + assert readable_size(200_000, false) == '200.0k' + + assert readable_size(100_000_000, true) == '95.4mb' + assert readable_size(100_000_000, false) == '100.0m' + + assert readable_size(100_000_000_000, true) == '93.2gb' + assert readable_size(100_000_000_000, false) == '100.0g' + + assert readable_size(100_000_000_000_000, true) == '91.0tb' + assert readable_size(100_000_000_000_000, false) == '100.0t' + + assert readable_size(100_000_000_000_000_000, true) == '88.9pb' + assert readable_size(100_000_000_000_000_000, false) == '100.0p' + + assert readable_size(8_000_000_000_000_000_000, true) == '7.0eb' + assert readable_size(8_000_000_000_000_000_000, false) == '8.0e' +} diff --git a/src/ls/filter.v b/src/ls/filter.v new file mode 100644 index 0000000..5a523b2 --- /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 0000000..533de74 --- /dev/null +++ b/src/ls/format.v @@ -0,0 +1,232 @@ +import arrays +import os +import term +import v.mathutil + +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 + width := if options_width_ok { options.width_in_cols } else { w } + + match true { + // vfmt off + options.long_format { format_long_listing(entries, options) } + options.list_by_lines && !options.list_by_columns { format_by_lines(entries, width, options) } + options.with_commas { format_with_commas(entries, options) } + options.one_per_line { 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 := mathutil.min(width / len, cell_max) + max_cols := mathutil.max(cols, 1) + partial_row := entries.len % max_cols != 0 + rows := entries.len / max_cols + if partial_row { 1 } else { 0 } + max_rows := mathutil.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) + cell := format_cell(name, len, .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 := mathutil.min(width / len, cell_max) + max_cols := mathutil.max(cols, 1) + + for i, entry in entries { + if i % max_cols == 0 && i != 0 { + print_newline() + } + name := format_entry_name(entry, options) + cell := format_cell(name, len, .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 { + println(format_cell(entry.name, 0, .left, get_style_for_entry(entry, options), + options)) + } +} + +fn format_with_commas(entries []Entry, options Options) { + last := entries.len - 1 + for i, entry in entries { + content := if i < last { '${entry.name}, ' } else { entry.name } + print(format_cell(content, 0, .left, no_style, options)) + } + 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) { + if name.len > 0 { + print_newline() + 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 { + os.join_path(entry.dir_name, entry.name) + } else { + entry.name + } + + icon := get_icon_for_entry(entry, options) + + return match true { + entry.link { + link_style := get_style_for_link(entry, options) + missing := if link_style == unknown_style { ' (not found)' } else { '' } + link := style_string(entry.link_origin, link_style, options) + '${icon}${name} -> ${link}${missing}' + } + options.quote { + '"${icon}${name}"' + } + else { + '${icon}${name}' + } + } +} + +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 0000000..9844f35 --- /dev/null +++ b/src/ls/format_long.v @@ -0,0 +1,451 @@ +import arrays +import os +import term +import time +import v.mathutil { max } + +const inode_title = 'inode' +const permissions_title = 'Permissions' +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 YYYY HH:mm:ss' +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 +} + +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_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 { + flag := file_flag(entry, options) + print(format_cell(flag, 1, .left, no_style, options)) + print_space() + + 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 } + entry.dir || entry.socket || entry.fifo { '-' } + 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) + + // stats + if !options.no_count { + statistics(entries, header_len, options) + } +} + +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.exe { style_string('x', options.style_ex, 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('f', 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() + owner := file_permission(mode.owner, options) + group := file_permission(mode.group, options) + other := file_permission(mode.others, options) + return '${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}' +} + +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 } + } + + mut date := time.unix(entry_time) + .local() + .custom_format(time_format(options)) + + if date.starts_with('0') { + date = ' ' + date[1..] + } + + 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) + max := arrays.max(lengths) or { 0 } + return if options.no_hard_links || !options.header { max } else { max(max, title.len) } +} + +fn longest_owner_name_len(entries []Entry, title string, options Options) int { + lengths := entries.map(get_owner_name(it.stat.uid).len) + max := arrays.max(lengths) or { 0 } + return if options.no_owner_name || !options.header { max } else { max(max, title.len) } +} + +fn longest_group_name_len(entries []Entry, title string, options Options) int { + lengths := entries.map(get_group_name(it.stat.gid).len) + max := arrays.max(lengths) or { 0 } + return if options.no_group_name || !options.header { max } else { max(max, 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 } + }) + max := arrays.max(lengths) or { 0 } + return if options.no_size || !options.header { max } else { max(max, title.len) } +} + +fn longest_inode_len(entries []Entry, title string, options Options) int { + lengths := entries.map(it.stat.inode.str().len) + max := arrays.max(lengths) or { 0 } + return if !options.inode || !options.header { max } else { max(max, title.len) } +} + +fn longest_file_name_len(entries []Entry, title string, options Options) int { + lengths := entries.map(real_length(format_entry_name(it, options))) + max := arrays.max(lengths) or { 0 } + return if !options.header { max } else { max(max, title.len) } +} + +fn longest_checksum_len(entries []Entry, title string, options Options) int { + lengths := entries.map(it.checksum.len) + max := arrays.max(lengths) or { 0 } + return if !options.header { max } else { max(max, title.len) } +} diff --git a/src/ls/icons.v b/src/ls/icons.v new file mode 100644 index 0000000..1d55ed1 --- /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 362b032..ec2337f 100644 --- a/src/ls/ls.v +++ b/src/ls/ls.v @@ -1,132 +1,49 @@ +import arrays { group_by } +import datatypes { Set } import os -import common - -struct Directory { - name string -mut: - contents []string -} - -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' - } - } - } - print(constructed) -} +import math 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) - } - - // 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) - } + options, files := get_args() + set_auto_wrap(options) + entries, status := get_entries(files, options) + mut cyclic := Set[string]{} + status1 := ls(entries, options, mut cyclic) + exit(math.max(status, status1)) +} - [Directory{'.', list}] +fn ls(entries []Entry, options Options, mut cyclic Set[string]) int { + mut status := 0 + group_by_dirs := group_by[string, Entry](entries, fn (e Entry) string { + return e.dir_name + }) + sorted_dirs := group_by_dirs.keys().sorted() + + for dir in sorted_dirs { + files := group_by_dirs[dir] + filtered := filter(files, options) + sorted := sort(filtered, options) + if group_by_dirs.len > 1 || options.recursive { + print_dir_name(dir, options) } - 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) + print_files(sorted, options) + + if options.recursive { + for entry in sorted { + if entry.dir { + entry_path := os.join_path(entry.dir_name, entry.name) + if cyclic.exists(entry_path) { + println('===> cyclic reference detected <===') + continue + } + cyclic.add(entry_path) + dir_entries, status1 := get_entries([entry_path], options) + status2 := ls(dir_entries, options, mut cyclic) + cyclic.remove(entry_path) + status = math.max(status1, status2) } - dirs << Directory{name.replace('/', ''), list} } - 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() - } - } - - // 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 0000000..0b3da1f --- /dev/null +++ b/src/ls/ls_nix.c.v @@ -0,0 +1,61 @@ +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 +fn C.readlink(file &char, buf &char, buf_size usize) + +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 0000000..6e082bf --- /dev/null +++ b/src/ls/ls_windows.c.v @@ -0,0 +1,44 @@ +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() +} diff --git a/src/ls/natural_compare.v b/src/ls/natural_compare.v new file mode 100644 index 0000000..df06dd9 --- /dev/null +++ b/src/ls/natural_compare.v @@ -0,0 +1,54 @@ +import v.mathutil + +// compares strings with embedded numbers (e.g. log17.txt) +fn natural_compare(a &string, b &string) int { + pa := split(a) + pb := split(b) + max := mathutil.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 0000000..9823a34 --- /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 0000000..a5ccd79 --- /dev/null +++ b/src/ls/options.v @@ -0,0 +1,185 @@ +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'] + 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_count bool @[xdoc: 'hide file/dir counts'] + 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'] + sort_reverse bool @[only: 'r'; xdoc: 'reverse order while sorting'] + relative_path bool @[xdoc: 'show relative path'] + recursive bool @[only: 'R'; xdoc: 'list subdirectories recursively'] + recursion_depth int @[xdoc: 'limit depth of recursion'] + sort_size bool @[only: 'S'; xdoc: 'sort by file size, largest first'] + sort_by string @[only: 'sort'; xdoc: 'sort by WORD instead of name; none (-U), size (-S), time(-t), version (-v), extension (-X), width'] + sort_time bool @[only: 't'; xdoc: 'sort by time, newest first'] + table_format bool @[xdoc: 'add borders to long listing format (implies -l)'] + accessed_date bool @[only: 'time-accessed'; xdoc: 'show last accessed time'] + changed_date bool @[only: 'time-changed'; xdoc: 'show last changed time'] + time_iso bool @[xdoc: 'show time in iso format'] + time_compact bool @[xdoc: 'show time in compact format'] + time_compact_with_day bool @[xdoc: 'show time in compact format with week day'] + sort_none bool @[only: 'U'; xdoc: 'do not sort; list entries in directory order'] + sort_natural bool @[only: 'v'; xdoc: 'natural sort of (version) numbers within text'] + sort_width bool @[ignore] + width_in_cols int @[long: 'width'; short: 'w'; xdoc: 'set output width to (COLS). 0 means no limit'] + list_by_lines bool @[only: 'x'; xdoc: 'list entries by lines'] + sort_ext bool @[only: 'X'; xdoc: 'sort alphabetically by entry extension'] + one_per_line bool @[only: '1'; xdoc: 'list one file per line'] + // + // from ls colors + style_di Style @[ignore] + style_fi Style @[ignore] + style_ln Style @[ignore] + style_ex Style @[ignore] + style_pi Style @[ignore] + style_bd Style @[ignore] + style_cd Style @[ignore] + style_so Style @[ignore] + // + // help + // + show_help bool @[long: 'help'; xdoc: 'display this help and exit'] + show_version bool @[long: 'version'; xdoc: 'show version and exit'] +} + +fn get_args() (Options, []string) { + mut options, files := flag.to_struct[Options](os.args, skip: 1) or { panic(err) } + + if options.show_help { + doc := flag.to_doc[Options]( + description: 'Usage: ls [OPTION]... FILE...\n' + + 'List information about the FILEs (the current directory by default).' + // vfmt off + footer: + '\n' + + "The WHEN argument defaults to 'always' and can also be 'auto' or 'never'.\n" + + '\n' + + 'Using color to distinguish file types is disabled both by default and\n' + + 'with --color=never. With --color=auto, ls emits color codes only when\n' + + 'standard output is connected to a terminal. The LS_COLORS environment\n' + + 'variable can change the settings. Use the dircolors command to set it.\n' + + '\n' + + 'Exit status:\n' + + ' 0 if OK,\n' + + ' 1 if minor problems (e.g., cannot access subdirectory),\n' + + ' 2 if serious trouble (e.g., cannot access command-line argument).\n' + + common.coreutils_footer() + // vfmt on + ) or { panic(err) } + println(doc) + exit(0) + } + + if options.show_version { + println('${app_name} ${app_version}\n${common.coreutils_footer()}') + exit(0) + } + + if files.len > 0 && files.any(it.starts_with('-')) { + eexit('The following flags could not be mapped to any fields: ${files.filter(it.starts_with('-'))}') + } + + if options.long_no_group { + options.long_format = true + options.no_group_name = true + } + + if options.long_no_owner { + options.long_format = true + options.no_owner_name = true + } + + if options.table_format || options.header || options.checksum.len > 0 || options.no_count + || options.no_date || options.no_permissions || options.no_size || options.no_count + || options.octal_permissions { + options.long_format = true + } + + match options.sort_by { + 'none' { options.sort_none = true } + 'size' { options.sort_size = true } + 'time' { options.sort_time = true } + 'width' { options.sort_width = true } + 'version' { options.sort_natural = true } + 'extension' { options.sort_ext = true } + else {} + } + + options.colorize = match options.colorize { + // vfmt off + when_never { when_never } + when_always { when_always } + when_auto { if term.can_show_color_on_stdout() { when_always } else { when_never } } + else { eexit('invalid --color=argument (always, never, auto') } + // vfmt on + } + + style_map := make_style_map() + options.style_bd = style_map['bd'] + options.style_cd = style_map['cd'] + options.style_di = style_map['di'] + options.style_ex = style_map['ex'] + options.style_fi = style_map['fi'] + options.style_ln = style_map['ln'] + options.style_pi = style_map['pi'] + options.style_so = style_map['so'] + + if files.filter(!it.starts_with('-')).len == 0 { + return options, current_dir + } + + return options, files +} + +@[noreturn] +fn eexit(msg string) { + if msg.len > 0 { + eprintln('${app_name}: ${msg}') + } + eprintln("Try '${app_name} --help' for more information.") + exit(2) +} diff --git a/src/ls/sort.v b/src/ls/sort.v new file mode 100644 index 0000000..bc40304 --- /dev/null +++ b/src/ls/sort.v @@ -0,0 +1,72 @@ +import arrays +import os + +fn sort(entries []Entry, options Options) []Entry { + cmp := match true { + options.sort_none { + fn (a &Entry, b &Entry) int { + return 0 + } + } + options.sort_size { + fn (a &Entry, b &Entry) int { + return match true { + // vfmt off + a.size < b.size { 1 } + a.size > b.size { -1 } + else { compare_strings(a.name, b.name) } + // vfmt on + } + } + } + options.sort_time { + fn (a &Entry, b &Entry) int { + return match true { + // vfmt off + a.stat.mtime < b.stat.mtime { 1 } + a.stat.mtime > b.stat.mtime { -1 } + else { compare_strings(a.name, b.name) } + // vfmt on + } + } + } + options.sort_width { + fn (a &Entry, b &Entry) int { + a_len := a.name.len + a.link_origin.len + if a.link_origin.len > 0 { 4 } else { 0 } + b_len := b.name.len + b.link_origin.len + if b.link_origin.len > 0 { 4 } else { 0 } + result := a_len - b_len + return if result != 0 { result } else { compare_strings(a.name, b.name) } + } + } + options.sort_natural { + fn (a &Entry, b &Entry) int { + return natural_compare(a.name, b.name) + } + } + options.sort_ext { + fn (a &Entry, b &Entry) int { + result := compare_strings(os.file_ext(a.name), os.file_ext(b.name)) + return if result != 0 { result } else { compare_strings(a.name, b.name) } + } + } + else { + fn (a &Entry, b &Entry) int { + return compare_strings(a.name, b.name) + } + } + } + + // if directories first option, group entries into dirs and files + // The 'dir' and 'file' labels are discriptive. The only thing that + // matters is that the 'dir' key collates before the 'file' key + groups := arrays.group_by[string, Entry](entries, fn [options] (e Entry) string { + return if options.dirs_first && e.dir { 'dir' } else { 'file' } + }) + + mut sorted := []Entry{} + for key in groups.keys().sorted() { + sorted << groups[key].sorted_with_compare(cmp) + } + + return if options.sort_reverse { sorted.reverse() } else { sorted } +} diff --git a/src/ls/style.v b/src/ls/style.v new file mode 100644 index 0000000..ab19b2f --- /dev/null +++ b/src/ls/style.v @@ -0,0 +1,176 @@ +import term +import os + +struct Style { + fg fn (string) string = no_color + bg fn (string) string = no_color + bold bool + dim bool + ul bool +} + +const no_style = Style{} + +const unknown_style = Style{ + fg: fgf('30') + bg: bgf('43') +} +const dim_style = Style{ + dim: true +} + +const di_style = Style{ + bold: true + fg: fgf('36') // cyan +} + +const fi_style = Style{ + fg: fgf('32') // green +} + +const ln_style = Style{ + bold: true + fg: fgf('34') // magenta +} + +const ex_style = Style{ + bold: true + fg: fgf('31') // red +} + +const so_style = Style{ + fg: fgf('32') // green +} + +const pi_style = Style{ + fg: fgf('33') // orange +} + +const bd_style = Style{ + fg: fgf('34') + bg: bgf('46') +} + +const cd_style = Style{ + fg: fgf('34') + bg: bgf('43') +} + +fn style_string(s string, style Style, options Options) string { + if options.colorize == when_never { + return s + } + mut out := style.fg(s) + if style.bg != no_color { + out = style.bg(out) + } + if style.bold { + out = term.bold(out) + } + if style.ul { + out = term.underline(out) + } + if style.dim { + out = term.dim(out) + } + return out +} + +fn make_style_map() map[string]Style { + mut style_map := map[string]Style{} + + // start with some defaults + style_map['di'] = di_style + style_map['fi'] = fi_style + style_map['ln'] = ln_style + style_map['ex'] = ex_style + style_map['so'] = so_style + style_map['pi'] = pi_style + style_map['bd'] = bd_style + style_map['cd'] = cd_style + + // example LS_COLORS + // di=1;36:ln=35:so=32:pi=33:ex=31:bd=34;46:cd=34;43:su=30;41:sg=30;46:tw=30;42:ow=30;43 + ls_colors := os.getenv('LS_COLORS') + fields := ls_colors.split(':') + + for field in fields { + id_codes := field.split('=') + if id_codes.len == 2 { + id := id_codes[0] + style := make_style(id_codes[1]) + style_map[id] = style + } + } + return style_map +} + +fn make_style(ansi string) Style { + mut bold := false + mut ul := false + mut fg := no_color + mut bg := no_color + + codes := ansi.split(';') + + for code in codes { + match code { + '0' { bold = false } + '1' { bold = true } + '4' { ul = true } + '31' { fg = fgf(code) } + '32' { fg = fgf(code) } + '33' { fg = fgf(code) } + '34' { fg = fgf(code) } + '35' { fg = fgf(code) } + '36' { fg = fgf(code) } + '37' { fg = fgf(code) } + '40' { bg = bgf(code) } + '41' { bg = bgf(code) } + '42' { bg = bgf(code) } + '43' { bg = bgf(code) } + '44' { bg = bgf(code) } + '45' { bg = bgf(code) } + '46' { bg = bgf(code) } + '47' { bg = bgf(code) } + '90' { fg = fgf(code) } + '91' { fg = fgf(code) } + '92' { fg = fgf(code) } + '93' { fg = fgf(code) } + '94' { fg = fgf(code) } + '95' { fg = fgf(code) } + '96' { fg = fgf(code) } + '100' { bg = bgf(code) } + '101' { bg = bgf(code) } + '102' { bg = bgf(code) } + '103' { bg = bgf(code) } + '104' { bg = bgf(code) } + '105' { bg = bgf(code) } + '106' { bg = bgf(code) } + else {} + } + } + + return Style{ + fg: fg + bg: bg + bold: bold + ul: ul + } +} + +fn no_color(s string) string { + return s +} + +fn fgf(code string) fn (string) string { + return fn [code] (msg string) string { + return term.format(msg, code, '39') + } +} + +fn bgf(code string) fn (string) string { + return fn [code] (msg string) string { + return term.format(msg, code, '49') + } +} diff --git a/src/ls/table.v b/src/ls/table.v new file mode 100644 index 0000000..59e0d2f --- /dev/null +++ b/src/ls/table.v @@ -0,0 +1,68 @@ +const table_border = `─` +const table_border_pad_right = ' │' // border for between cells +const table_border_pad_left = '│ ' +const table_border_divider = `│` +const table_border_top_start = `┌` +const table_border_top_end = `┐` +const table_border_mid_start = `├` +const table_border_mid_end = `┤` +const table_border_bot_start = `└` +const table_border_bot_end = `┘` +const table_border_t = `┬` +const table_border_u_t = `┴` +const table_border_cross = `┼` +const table_border_size = 2 + +fn print_header_border(options Options, len int, cols []int) { + if options.table_format { + border := if options.header { + border_row_middle(len, cols) + } else { + border_row_top(len, cols) + } + print(border) + } else { + if options.header { + println(format_header_text_border(len, options)) + } + } +} + +fn print_bottom_border(options Options, len int, cols []int) { + if options.table_format { + print(border_row_bottom(len, cols)) + } +} + +fn border_row_top(len int, cols []int) string { + return format_table_border(len, cols, table_border_t, table_border_top_start, table_border_top_end) +} + +fn border_row_bottom(len int, cols []int) string { + return format_table_border(len, cols, table_border_u_t, table_border_bot_start, table_border_bot_end) +} + +fn border_row_middle_end(len int, cols []int) string { + return format_table_border(len, cols, table_border_u_t, table_border_mid_start, table_border_mid_end) +} + +fn border_row_middle(len int, cols []int) string { + return format_table_border(len, cols, table_border_cross, table_border_mid_start, + table_border_mid_end) +} + +fn format_table_border(len int, cols []int, divider rune, start rune, end rune) string { + mut border := table_border.repeat(len).runes() + border[0] = start + border[border.len - 1] = end + for col in cols { + border[col - 1] = divider + } + return '${border.string()}\n' +} + +fn format_header_text_border(len int, options Options) string { + dim := if options.no_dim { no_style } else { dim_style } + divider := '┈'.repeat(len) + return format_cell(divider, 0, .left, dim, options) +} From 14fe3e0664d5972755ecbfa0a01f7f6a534574ae Mon Sep 17 00:00:00 2001 From: mike-ward Date: Sun, 29 Dec 2024 09:19:30 -0600 Subject: [PATCH 2/3] make long format look more like gnu --- src/ls/format_long.v | 20 ++++++-------------- src/ls/options.v | 6 ++---- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/ls/format_long.v b/src/ls/format_long.v index 9844f35..f3c8ab9 100644 --- a/src/ls/format_long.v +++ b/src/ls/format_long.v @@ -5,7 +5,7 @@ import time import v.mathutil { max } const inode_title = 'inode' -const permissions_title = 'Permissions' +const permissions_title = 'Permission' const mask_title = 'Mask' const links_title = 'Links' const owner_title = 'Owner' @@ -18,7 +18,7 @@ const name_title = 'Name' const unknown = '?' const block_size = 5 const space = ' ' -const date_format = 'MMM DD YYYY HH:mm:ss' +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" @@ -82,10 +82,6 @@ fn format_long_listing(entries []Entry, options Options) { // permissions if !options.no_permissions { - flag := file_flag(entry, options) - print(format_cell(flag, 1, .left, no_style, options)) - print_space() - content := permissions(entry, options) print(format_cell(content, permissions_title.len, .right, no_style, options)) print_space() @@ -180,11 +176,6 @@ fn format_long_listing(entries []Entry, options Options) { // bottom border print_bottom_border(options, header_len, cols) - - // stats - if !options.no_count { - statistics(entries, header_len, options) - } } fn longest_entries(entries []Entry, options Options) Longest { @@ -355,8 +346,8 @@ fn file_flag(entry Entry, options Options) string { 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('f', options.style_fi, options) } - else { ' ' } + entry.file { style_string('-', options.style_fi, options) } + else { '?' } // vfmt on } } @@ -368,10 +359,11 @@ fn format_octal_permissions(entry Entry, options Options) string { 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 '${owner} ${group} ${other}' + return '${flag}${owner}${group}${other}' } fn file_permission(file_permission os.FilePermission, options Options) string { diff --git a/src/ls/options.v b/src/ls/options.v index a5ccd79..73d52d4 100644 --- a/src/ls/options.v +++ b/src/ls/options.v @@ -28,7 +28,6 @@ mut: 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_count bool @[xdoc: 'hide file/dir counts'] 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'] @@ -133,9 +132,8 @@ fn get_args() (Options, []string) { options.no_owner_name = true } - if options.table_format || options.header || options.checksum.len > 0 || options.no_count - || options.no_date || options.no_permissions || options.no_size || options.no_count - || options.octal_permissions { + if options.table_format || options.header || options.checksum.len > 0 || options.no_date + || options.no_permissions || options.no_size || options.octal_permissions { options.long_format = true } From 64acb2daf9215fcc2e8b8b1e4b1b4f4204426237 Mon Sep 17 00:00:00 2001 From: mike-ward Date: Mon, 30 Dec 2024 14:54:04 -0600 Subject: [PATCH 3/3] add total block size --- src/ls/format_long.v | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ls/format_long.v b/src/ls/format_long.v index f3c8ab9..b8d34b1 100644 --- a/src/ls/format_long.v +++ b/src/ls/format_long.v @@ -39,12 +39,20 @@ enum StatTime { modified } +fn print_total(entries []Entry, options Options) { + total := arrays.fold[Entry, u64](entries, 0, fn (a u64, e Entry) u64 { + return a + max(u64(1), e.size / 1024) + }) + println('total: ${total}') +} + 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) @@ -120,7 +128,6 @@ fn format_long_listing(entries []Entry, options Options) { content := match true { // vfmt off entry.invalid { unknown } - entry.dir || entry.socket || entry.fifo { '-' } options.size_ki && !options.size_kb { entry.size_ki } options.size_kb { entry.size_kb } else { entry.size.str() }