11.1Srillig#! /usr/bin/lua 21.6Srillig-- $NetBSD: fmt-list,v 1.6 2022/09/08 05:05:08 rillig Exp $ 31.1Srillig 41.1Srillig--[[ 51.1Srillig 61.1SrilligAlign the lines of a file list so that all lines from the same directory 71.1Srillighave the other fields at the same indentation. 81.1Srillig 91.1SrilligSort the lines and remove duplicate lines. 101.1Srillig 111.2Srilligusage: ./fmt-list [-n] */*/{mi,ad.*,md.*} 121.1Srillig 131.1Srillig]] 141.1Srillig 151.1Srilliglocal function test(func) 161.1Srillig func() 171.1Srilligend 181.1Srillig 191.1Srilliglocal function assert_equals(got, expected) 201.1Srillig if got ~= expected then 211.6Srillig assert(false, ("got %q, expected %q"):format(got, expected)) 221.1Srillig end 231.1Srilligend 241.1Srillig 251.1Srillig 261.1Srillig-- Calculate the width of the given string on the screen, assuming that 271.1Srillig-- the tab width is 8 and that the string starts at a tabstop. 281.1Srilliglocal function tabwidth(str) 291.1Srillig local width = 0 301.1Srillig for i = 1, #str do 311.6Srillig if str:sub(i, i) == "\t" then 321.1Srillig width = width // 8 * 8 + 8 331.1Srillig else 341.1Srillig width = width + 1 351.1Srillig end 361.1Srillig end 371.1Srillig return width 381.1Srilligend 391.1Srillig 401.1Srilligtest(function() 411.1Srillig assert_equals(tabwidth(""), 0) 421.1Srillig assert_equals(tabwidth("1234"), 4) 431.1Srillig assert_equals(tabwidth("\t"), 8) 441.1Srillig assert_equals(tabwidth("1234567\t"), 8) 451.1Srillig assert_equals(tabwidth("\t1234\t"), 16) 461.1Srillig assert_equals(tabwidth("\t1234\t1"), 17) 471.1Srilligend) 481.1Srillig 491.1Srillig 501.1Srillig-- Calculate the tab characters that are necessary to set the width 511.1Srillig-- of the string to the desired width. 521.1Srilliglocal function tabs(str, width) 531.1Srillig local strwidth = tabwidth(str) 541.6Srillig local tabs = ("\t"):rep((width - strwidth + 7) // 8) 551.1Srillig if tabs == "" then 561.6Srillig error(("%q\t%d\t%d"):format(str, strwidth, width)) 571.1Srillig end 581.1Srillig assert(tabs ~= "") 591.1Srillig return tabs 601.1Srilligend 611.1Srillig 621.1Srilligtest(function() 631.1Srillig assert_equals(tabs("", 8), "\t") 641.1Srillig assert_equals(tabs("1234567", 8), "\t") 651.1Srillig assert_equals(tabs("", 64), "\t\t\t\t\t\t\t\t") 661.1Srilligend) 671.1Srillig 681.1Srillig 691.1Srillig-- Group the items by a key and then execute the action on each of the 701.1Srillig-- groups. 711.1Srilliglocal function foreach_group(items, get_key, action) 721.1Srillig local key 731.1Srillig local group = {} 741.1Srillig for _, item in ipairs(items) do 751.1Srillig local item_key = assert(get_key(item)) 761.1Srillig if item_key ~= key then 771.1Srillig if #group > 0 then action(group, key) end 781.1Srillig key = item_key 791.1Srillig group = {} 801.1Srillig end 811.1Srillig table.insert(group, item) 821.1Srillig end 831.1Srillig if #group > 0 then action(group, key) end 841.1Srilligend 851.1Srillig 861.1Srilligtest(function() 871.1Srillig local items = { 881.1Srillig {"prime", 2}, 891.1Srillig {"prime", 3}, 901.1Srillig {"not prime", 4}, 911.1Srillig {"prime", 5}, 921.1Srillig {"prime", 7} 931.1Srillig } 941.1Srillig local result = "" 951.1Srillig foreach_group( 961.1Srillig items, 971.1Srillig function(item) return item[1] end, 981.1Srillig function(group, key) 991.6Srillig result = result .. ("%d %s\n"):format(#group, key) 1001.1Srillig end) 1011.1Srillig assert_equals(result, "2 prime\n1 not prime\n2 prime\n") 1021.1Srilligend) 1031.1Srillig 1041.1Srillig 1051.1Srillig-- Parse a line from a file list and split it into its meaningful parts. 1061.1Srilliglocal function parse_entry(line) 1071.1Srillig 1081.1Srillig local category_align, prefix, fullname, flags_align, category, flags = 1091.4Srillig line:match("^(([#%-]?)(%.%S*)%s+)((%S+)%s+)(%S+)$") 1101.1Srillig if fullname == nil then 1111.1Srillig category_align, prefix, fullname, category = 1121.4Srillig line:match("^(([#%-]?)(%.%S*)%s+)(%S+)$") 1131.1Srillig end 1141.1Srillig if fullname == nil then 1151.1Srillig prefix, fullname = line:match("^(%-)(%.%S*)$") 1161.1Srillig end 1171.1Srillig if fullname == nil then 1181.1Srillig return 1191.1Srillig end 1201.1Srillig 1211.1Srillig local dirname, basename = fullname:match("^(.+)/([^/]+)$") 1221.1Srillig if dirname == nil then 1231.1Srillig dirname, basename = "", fullname 1241.1Srillig end 1251.1Srillig 1261.1Srillig local category_col, flags_col 1271.1Srillig if category_align ~= nil then 1281.1Srillig category_col = tabwidth(category_align) 1291.1Srillig end 1301.1Srillig if flags_align ~= nil then 1311.1Srillig flags_col = tabwidth(flags_align) 1321.1Srillig end 1331.1Srillig 1341.1Srillig return { 1351.1Srillig prefix = prefix, 1361.1Srillig fullname = fullname, 1371.1Srillig dirname = dirname, 1381.1Srillig basename = basename, 1391.1Srillig category_col = category_col, 1401.1Srillig category = category, 1411.1Srillig flags_col = flags_col, 1421.1Srillig flags = flags 1431.1Srillig } 1441.1Srilligend 1451.1Srillig 1461.1Srilligtest(function() 1471.1Srillig local entry = parse_entry("./dirname/filename\t\t\tcategory\tflags") 1481.1Srillig assert_equals(entry.prefix, "") 1491.1Srillig assert_equals(entry.fullname, "./dirname/filename") 1501.1Srillig assert_equals(entry.dirname, "./dirname") 1511.1Srillig assert_equals(entry.basename, "filename") 1521.1Srillig assert_equals(entry.category_col, 40) 1531.1Srillig assert_equals(entry.category, "category") 1541.1Srillig assert_equals(entry.flags_col, 16) 1551.1Srillig assert_equals(entry.flags, "flags") 1561.4Srillig 1571.4Srillig entry = parse_entry("#./dirname/filename\tcat\tflags") 1581.4Srillig assert_equals(entry.prefix, "#") 1591.4Srillig assert_equals(entry.fullname, "./dirname/filename") 1601.4Srillig assert_equals(entry.dirname, "./dirname") 1611.4Srillig assert_equals(entry.basename, "filename") 1621.4Srillig assert_equals(entry.category_col, 24) 1631.4Srillig assert_equals(entry.category, "cat") 1641.4Srillig assert_equals(entry.flags_col, 8) 1651.4Srillig assert_equals(entry.flags, "flags") 1661.1Srilligend) 1671.1Srillig 1681.1Srillig 1691.1Srillig-- Return the smaller of the given values, ignoring nil. 1701.1Srilliglocal function min(curr, value) 1711.1Srillig if curr == nil or (value ~= nil and value < curr) then 1721.1Srillig return value 1731.1Srillig end 1741.1Srillig return curr 1751.1Srilligend 1761.1Srillig 1771.1Srilligtest(function() 1781.1Srillig assert_equals(min(nil, nil), nil) 1791.1Srillig assert_equals(min(0, nil), 0) 1801.1Srillig assert_equals(min(nil, 0), 0) 1811.1Srillig assert_equals(min(0, 0), 0) 1821.1Srillig assert_equals(min(1, -1), -1) 1831.1Srillig assert_equals(min(-1, 1), -1) 1841.1Srilligend) 1851.1Srillig 1861.1Srillig 1871.1Srillig-- Return the larger of the given values, ignoring nil. 1881.1Srilliglocal function max(curr, value) 1891.1Srillig if curr == nil or (value ~= nil and value > curr) then 1901.1Srillig return value 1911.1Srillig end 1921.1Srillig return curr 1931.1Srilligend 1941.1Srillig 1951.1Srilligtest(function() 1961.1Srillig assert_equals(max(nil, nil), nil) 1971.1Srillig assert_equals(max(0, nil), 0) 1981.1Srillig assert_equals(max(nil, 0), 0) 1991.1Srillig assert_equals(max(0, 0), 0) 2001.1Srillig assert_equals(max(1, -1), 1) 2011.1Srillig assert_equals(max(-1, 1), 1) 2021.1Srilligend) 2031.1Srillig 2041.1Srillig 2051.1Srillig-- Calculate the column on which the field should be aligned. 2061.1Srilliglocal function column(entries, get_width_before, colname) 2071.1Srillig 2081.1Srillig local function nexttab(col) 2091.1Srillig return col // 8 * 8 + 8 2101.1Srillig end 2111.1Srillig 2121.1Srillig local currmin, currmax, required 2131.1Srillig 2141.1Srillig for _, entry in ipairs(entries) do 2151.1Srillig local width = get_width_before(entry) 2161.1Srillig if width ~= nil then 2171.1Srillig required = max(required, width) 2181.1Srillig 2191.1Srillig local col = entry[colname] 2201.1Srillig currmin = min(currmin, col) 2211.1Srillig currmax = max(currmax, col) 2221.1Srillig end 2231.1Srillig end 2241.1Srillig 2251.1Srillig if currmin == currmax then 2261.1Srillig return currmin, "aligned" 2271.1Srillig end 2281.1Srillig return nexttab(required), "unaligned" 2291.1Srilligend 2301.1Srillig 2311.1Srilligtest(function() 2321.1Srillig 2331.1Srillig local function width_before_category(entry) 2341.1Srillig return tabwidth(entry.prefix .. entry.fullname) 2351.1Srillig end 2361.1Srillig 2371.1Srillig local function width_before_flags(entry) 2381.1Srillig return tabwidth(entry.category) 2391.1Srillig end 2401.1Srillig 2411.1Srillig -- The entries are nicely aligned, therefore there is no need to change 2421.1Srillig -- anything. 2431.1Srillig local entries = { 2441.1Srillig parse_entry("./file1\tcategory"), 2451.1Srillig parse_entry("./file2\tcategory") 2461.1Srillig } 2471.1Srillig assert_equals(entries[2].category_col, 8) 2481.1Srillig assert_equals(width_before_category(entries[2]), 7) 2491.1Srillig assert_equals(column(entries, width_before_category, "category_col"), 8) 2501.1Srillig 2511.1Srillig -- The entries are currently not aligned, therefore they are aligned 2521.1Srillig -- to the minimum required column. 2531.1Srillig entries = { 2541.1Srillig parse_entry("./file1\tcategory"), 2551.1Srillig parse_entry("./directory/file2\tcategory"), 2561.1Srillig } 2571.1Srillig assert_equals(entries[2].category_col, 24) 2581.1Srillig assert_equals(column(entries, width_before_category, "category_col"), 24) 2591.1Srillig 2601.1Srillig -- The entries are already aligned, therefore the current alignment is 2611.1Srillig -- preserved, even though it is more than the minimum required alignment 2621.1Srillig -- of 8. There are probably reasons for the large indentation. 2631.1Srillig entries = { 2641.1Srillig parse_entry("./file1\t\t\tcategory"), 2651.1Srillig parse_entry("./file2\t\t\tcategory") 2661.1Srillig } 2671.1Srillig assert_equals(column(entries, width_before_category, "category_col"), 24) 2681.1Srillig 2691.1Srillig -- The flags are already aligned, 4 tabs to the right of the category. 2701.1Srillig -- There is no reason to change anything here. 2711.1Srillig entries = { 2721.1Srillig parse_entry("./file1\tcategory\t\t\tflags"), 2731.1Srillig parse_entry("./file2\tcategory"), 2741.1Srillig parse_entry("./file3\tcat\t\t\t\tflags") 2751.1Srillig } 2761.1Srillig assert_equals(column(entries, width_before_flags, "flags_col"), 32) 2771.1Srillig 2781.1Srilligend) 2791.1Srillig 2801.1Srillig 2811.1Srillig-- Amend the entries by the tabs used for alignment. 2821.1Srilliglocal function add_tabs(entries) 2831.1Srillig 2841.1Srillig local function width_before_category(entry) 2851.1Srillig return tabwidth(entry.prefix .. entry.fullname) 2861.1Srillig end 2871.1Srillig local function width_before_flags(entry) 2881.1Srillig if entry.flags ~= nil then 2891.1Srillig return tabwidth(entry.category) 2901.1Srillig end 2911.1Srillig end 2921.1Srillig 2931.1Srillig local category_col, category_aligned = 2941.1Srillig column(entries, width_before_category, "category_col") 2951.1Srillig local flags_col = column(entries, width_before_flags, "flags_col") 2961.1Srillig 2971.6Srillig -- To avoid horizontal jumps for the category column, the minimum column is 2981.6Srillig -- set to 56. This way, the third column is usually set to 72, which is 2991.1Srillig -- still visible on an 80-column screen. 3001.1Srillig if category_aligned == "unaligned" then 3011.1Srillig category_col = max(category_col, 56) 3021.1Srillig end 3031.1Srillig 3041.1Srillig for _, entry in ipairs(entries) do 3051.1Srillig local prefix = entry.prefix 3061.1Srillig local fullname = entry.fullname 3071.1Srillig local category = entry.category 3081.1Srillig local flags = entry.flags 3091.1Srillig 3101.1Srillig if category ~= nil then 3111.1Srillig entry.category_tabs = tabs(prefix .. fullname, category_col) 3121.1Srillig if flags ~= nil then 3131.1Srillig entry.flags_tabs = tabs(category, flags_col) 3141.1Srillig end 3151.1Srillig end 3161.1Srillig end 3171.1Srilligend 3181.1Srillig 3191.1Srilligtest(function() 3201.1Srillig local entries = { 3211.1Srillig parse_entry("./file1\t\t\t\tcategory\t\tflags"), 3221.1Srillig parse_entry("./file2\t\t\t\tcategory\t\tflags"), 3231.1Srillig parse_entry("./file3\t\t\tcategory\t\tflags") 3241.1Srillig } 3251.1Srillig add_tabs(entries) 3261.1Srillig assert_equals(entries[1].category_tabs, "\t\t\t\t\t\t\t") 3271.1Srillig assert_equals(entries[2].category_tabs, "\t\t\t\t\t\t\t") 3281.1Srillig assert_equals(entries[3].category_tabs, "\t\t\t\t\t\t\t") 3291.1Srillig assert_equals(entries[1].flags_tabs, "\t\t") 3301.1Srillig assert_equals(entries[2].flags_tabs, "\t\t") 3311.1Srillig assert_equals(entries[3].flags_tabs, "\t\t") 3321.1Srilligend) 3331.1Srillig 3341.1Srillig 3351.1Srillig-- Normalize the alignment of the fields of the entries. 3361.1Srilliglocal function normalize(entries) 3371.1Srillig 3381.1Srillig local function less(a, b) 3391.1Srillig if a.fullname ~= b.fullname then 3401.5Srillig -- To sort by directory first, comment out the following line. 3411.1Srillig return a.fullname < b.fullname 3421.1Srillig end 3431.5Srillig if a.dirname ~= b.dirname then 3441.5Srillig return a.dirname < b.dirname 3451.5Srillig end 3461.5Srillig if a.basename ~= b.basename then 3471.5Srillig return a.basename < b.basename 3481.5Srillig end 3491.1Srillig if a.category ~= nil and b.category ~= nil and a.category ~= b.category then 3501.1Srillig return a.category < b.category 3511.1Srillig end 3521.1Srillig return a.flags ~= nil and b.flags ~= nil and a.flags < b.flags 3531.1Srillig end 3541.1Srillig table.sort(entries, less) 3551.1Srillig 3561.1Srillig local function by_dirname(entry) 3571.1Srillig return entry.dirname 3581.1Srillig end 3591.1Srillig foreach_group(entries, by_dirname, add_tabs) 3601.1Srillig 3611.1Srilligend 3621.1Srillig 3631.1Srillig 3641.1Srillig-- Read a file list completely into memory. 3651.1Srilliglocal function read_list(fname) 3661.1Srillig local head = {} 3671.1Srillig local entries = {} 3681.1Srillig local errors = {} 3691.1Srillig 3701.1Srillig local f = assert(io.open(fname, "r")) 3711.1Srillig local lineno = 0 3721.1Srillig for line in f:lines() do 3731.1Srillig lineno = lineno + 1 3741.1Srillig 3751.1Srillig local entry = parse_entry(line) 3761.1Srillig if entry ~= nil then 3771.1Srillig table.insert(entries, entry) 3781.1Srillig elseif line:match("^#") then 3791.1Srillig table.insert(head, line) 3801.1Srillig else 3811.6Srillig local msg = ("%s:%d: unknown line format %q"):format(fname, lineno, line) 3821.1Srillig table.insert(errors, msg) 3831.1Srillig end 3841.1Srillig end 3851.1Srillig 3861.1Srillig f:close() 3871.1Srillig 3881.1Srillig return head, entries, errors 3891.1Srilligend 3901.1Srillig 3911.1Srillig 3921.1Srillig-- Write the normalized list file back to disk. 3931.1Srillig-- 3941.1Srillig-- Duplicate lines are skipped. This allows to append arbitrary lines to 3951.1Srillig-- the end of the file and have them cleaned up automatically. 3961.1Srilliglocal function write_list(fname, head, entries) 3971.1Srillig local f = assert(io.open(fname, "w")) 3981.1Srillig 3991.1Srillig for _, line in ipairs(head) do 4001.1Srillig f:write(line, "\n") 4011.1Srillig end 4021.1Srillig 4031.1Srillig local prev_line = "" 4041.1Srillig for _, entry in ipairs(entries) do 4051.1Srillig local line = entry.prefix .. entry.fullname 4061.1Srillig if entry.category ~= nil then 4071.1Srillig line = line .. entry.category_tabs .. entry.category 4081.1Srillig end 4091.1Srillig if entry.flags ~= nil then 4101.1Srillig line = line .. entry.flags_tabs .. entry.flags 4111.1Srillig end 4121.1Srillig 4131.1Srillig if line ~= prev_line then 4141.1Srillig prev_line = line 4151.1Srillig f:write(line, "\n") 4161.4Srillig else 4171.6Srillig --print(("%s: duplicate entry: %s"):format(fname, line)) 4181.1Srillig end 4191.1Srillig end 4201.1Srillig 4211.1Srillig f:close() 4221.1Srilligend 4231.1Srillig 4241.1Srillig 4251.1Srillig-- Load a file list, normalize it and write it back to disk. 4261.2Srilliglocal function format_list(fname, write_back) 4271.1Srillig local head, entries, errors = read_list(fname) 4281.1Srillig if #errors > 0 then 4291.1Srillig for _, err in ipairs(errors) do 4301.1Srillig print(err) 4311.1Srillig end 4321.3Srillig return false 4331.1Srillig end 4341.1Srillig 4351.1Srillig normalize(entries) 4361.2Srillig 4371.2Srillig if write_back then 4381.2Srillig write_list(fname, head, entries) 4391.2Srillig end 4401.3Srillig return true 4411.1Srilligend 4421.1Srillig 4431.1Srillig 4441.2Srilliglocal function main(arg) 4451.3Srillig local seen_error = false 4461.2Srillig local write_back = true 4471.2Srillig for _, fname in ipairs(arg) do 4481.2Srillig if fname == "-n" then 4491.2Srillig write_back = false 4501.2Srillig else 4511.3Srillig if not format_list(fname, write_back) then 4521.3Srillig seen_error = true 4531.3Srillig end 4541.2Srillig end 4551.2Srillig end 4561.3Srillig return not seen_error 4571.1Srilligend 4581.2Srillig 4591.3Srilligos.exit(main(arg)) 460