Home | History | Annotate | Line # | Download | only in sets
fmt-list revision 1.2
      1  1.1  rillig #! /usr/bin/lua
      2  1.2  rillig -- $NetBSD: fmt-list,v 1.2 2020/09/08 19:11:30 rillig Exp $
      3  1.1  rillig 
      4  1.1  rillig --[[
      5  1.1  rillig 
      6  1.1  rillig Align the lines of a file list so that all lines from the same directory
      7  1.1  rillig have the other fields at the same indentation.
      8  1.1  rillig 
      9  1.1  rillig Sort the lines and remove duplicate lines.
     10  1.1  rillig 
     11  1.2  rillig usage: ./fmt-list [-n] */*/{mi,ad.*,md.*}
     12  1.1  rillig 
     13  1.1  rillig ]]
     14  1.1  rillig 
     15  1.1  rillig local function test(func)
     16  1.1  rillig   func()
     17  1.1  rillig end
     18  1.1  rillig 
     19  1.1  rillig local function assert_equals(got, expected)
     20  1.1  rillig   if got ~= expected then
     21  1.1  rillig     assert(false, string.format("got %q, expected %q", got, expected))
     22  1.1  rillig   end
     23  1.1  rillig end
     24  1.1  rillig 
     25  1.1  rillig 
     26  1.1  rillig -- Calculate the width of the given string on the screen, assuming that
     27  1.1  rillig -- the tab width is 8 and that the string starts at a tabstop.
     28  1.1  rillig local function tabwidth(str)
     29  1.1  rillig   local width = 0
     30  1.1  rillig   for i = 1, #str do
     31  1.1  rillig     if string.sub(str, i, i) == "\t" then
     32  1.1  rillig       width = width // 8 * 8 + 8
     33  1.1  rillig     else
     34  1.1  rillig       width = width + 1
     35  1.1  rillig     end
     36  1.1  rillig   end
     37  1.1  rillig   return width
     38  1.1  rillig end
     39  1.1  rillig 
     40  1.1  rillig test(function()
     41  1.1  rillig   assert_equals(tabwidth(""), 0)
     42  1.1  rillig   assert_equals(tabwidth("1234"), 4)
     43  1.1  rillig   assert_equals(tabwidth("\t"), 8)
     44  1.1  rillig   assert_equals(tabwidth("1234567\t"), 8)
     45  1.1  rillig   assert_equals(tabwidth("\t1234\t"), 16)
     46  1.1  rillig   assert_equals(tabwidth("\t1234\t1"), 17)
     47  1.1  rillig end)
     48  1.1  rillig 
     49  1.1  rillig 
     50  1.1  rillig -- Calculate the tab characters that are necessary to set the width
     51  1.1  rillig -- of the string to the desired width.
     52  1.1  rillig local function tabs(str, width)
     53  1.1  rillig   local strwidth = tabwidth(str)
     54  1.1  rillig   local tabs = string.rep("\t", (width - strwidth + 7) // 8)
     55  1.1  rillig   if tabs == "" then
     56  1.1  rillig     error(string.format("%q\t%d\t%d", str, strwidth, width))
     57  1.1  rillig   end
     58  1.1  rillig   assert(tabs ~= "")
     59  1.1  rillig   return tabs
     60  1.1  rillig end
     61  1.1  rillig 
     62  1.1  rillig test(function()
     63  1.1  rillig   assert_equals(tabs("", 8), "\t")
     64  1.1  rillig   assert_equals(tabs("1234567", 8), "\t")
     65  1.1  rillig   assert_equals(tabs("", 64), "\t\t\t\t\t\t\t\t")
     66  1.1  rillig end)
     67  1.1  rillig 
     68  1.1  rillig 
     69  1.1  rillig -- Group the items by a key and then execute the action on each of the
     70  1.1  rillig -- groups.
     71  1.1  rillig local function foreach_group(items, get_key, action)
     72  1.1  rillig   local key
     73  1.1  rillig   local group = {}
     74  1.1  rillig   for _, item in ipairs(items) do
     75  1.1  rillig     local item_key = assert(get_key(item))
     76  1.1  rillig     if item_key ~= key then
     77  1.1  rillig       if #group > 0 then action(group, key) end
     78  1.1  rillig       key = item_key
     79  1.1  rillig       group = {}
     80  1.1  rillig     end
     81  1.1  rillig     table.insert(group, item)
     82  1.1  rillig   end
     83  1.1  rillig   if #group > 0 then action(group, key) end
     84  1.1  rillig end
     85  1.1  rillig 
     86  1.1  rillig test(function()
     87  1.1  rillig   local items = {
     88  1.1  rillig     {"prime", 2},
     89  1.1  rillig     {"prime", 3},
     90  1.1  rillig     {"not prime", 4},
     91  1.1  rillig     {"prime", 5},
     92  1.1  rillig     {"prime", 7}
     93  1.1  rillig   }
     94  1.1  rillig   local result = ""
     95  1.1  rillig   foreach_group(
     96  1.1  rillig     items,
     97  1.1  rillig     function(item) return item[1] end,
     98  1.1  rillig     function(group, key)
     99  1.1  rillig       result = result .. string.format("%d %s\n", #group, key)
    100  1.1  rillig     end)
    101  1.1  rillig   assert_equals(result, "2 prime\n1 not prime\n2 prime\n")
    102  1.1  rillig end)
    103  1.1  rillig 
    104  1.1  rillig 
    105  1.1  rillig -- Parse a line from a file list and split it into its meaningful parts.
    106  1.1  rillig local function parse_entry(line)
    107  1.1  rillig 
    108  1.1  rillig   local category_align, prefix, fullname, flags_align, category, flags =
    109  1.1  rillig     line:match("^((%-?)(%.%S*)%s+)((%S+)%s+)(%S+)$")
    110  1.1  rillig   if fullname == nil then
    111  1.1  rillig     category_align, prefix, fullname, category =
    112  1.1  rillig       line:match("^((%-?)(%.%S*)%s+)(%S+)$")
    113  1.1  rillig   end
    114  1.1  rillig   if fullname == nil then
    115  1.1  rillig     prefix, fullname = line:match("^(%-)(%.%S*)$")
    116  1.1  rillig   end
    117  1.1  rillig   if fullname == nil then
    118  1.1  rillig     return
    119  1.1  rillig   end
    120  1.1  rillig 
    121  1.1  rillig   local dirname, basename = fullname:match("^(.+)/([^/]+)$")
    122  1.1  rillig   if dirname == nil then
    123  1.1  rillig     dirname, basename = "", fullname
    124  1.1  rillig   end
    125  1.1  rillig 
    126  1.1  rillig   local category_col, flags_col
    127  1.1  rillig   if category_align ~= nil then
    128  1.1  rillig     category_col = tabwidth(category_align)
    129  1.1  rillig   end
    130  1.1  rillig   if flags_align ~= nil then
    131  1.1  rillig     flags_col = tabwidth(flags_align)
    132  1.1  rillig   end
    133  1.1  rillig 
    134  1.1  rillig   return {
    135  1.1  rillig     prefix = prefix,
    136  1.1  rillig     fullname = fullname,
    137  1.1  rillig     dirname = dirname,
    138  1.1  rillig     basename = basename,
    139  1.1  rillig     category_col = category_col,
    140  1.1  rillig     category = category,
    141  1.1  rillig     flags_col = flags_col,
    142  1.1  rillig     flags = flags
    143  1.1  rillig   }
    144  1.1  rillig end
    145  1.1  rillig 
    146  1.1  rillig test(function()
    147  1.1  rillig   local entry = parse_entry("./dirname/filename\t\t\tcategory\tflags")
    148  1.1  rillig   assert_equals(entry.prefix, "")
    149  1.1  rillig   assert_equals(entry.fullname, "./dirname/filename")
    150  1.1  rillig   assert_equals(entry.dirname, "./dirname")
    151  1.1  rillig   assert_equals(entry.basename, "filename")
    152  1.1  rillig   assert_equals(entry.category_col, 40)
    153  1.1  rillig   assert_equals(entry.category, "category")
    154  1.1  rillig   assert_equals(entry.flags_col, 16)
    155  1.1  rillig   assert_equals(entry.flags, "flags")
    156  1.1  rillig end)
    157  1.1  rillig 
    158  1.1  rillig 
    159  1.1  rillig -- Return the smaller of the given values, ignoring nil.
    160  1.1  rillig local function min(curr, value)
    161  1.1  rillig   if curr == nil or (value ~= nil and value < curr) then
    162  1.1  rillig     return value
    163  1.1  rillig   end
    164  1.1  rillig   return curr
    165  1.1  rillig end
    166  1.1  rillig 
    167  1.1  rillig test(function()
    168  1.1  rillig   assert_equals(min(nil, nil), nil)
    169  1.1  rillig   assert_equals(min(0, nil), 0)
    170  1.1  rillig   assert_equals(min(nil, 0), 0)
    171  1.1  rillig   assert_equals(min(0, 0), 0)
    172  1.1  rillig   assert_equals(min(1, -1), -1)
    173  1.1  rillig   assert_equals(min(-1, 1), -1)
    174  1.1  rillig end)
    175  1.1  rillig 
    176  1.1  rillig 
    177  1.1  rillig -- Return the larger of the given values, ignoring nil.
    178  1.1  rillig local function max(curr, value)
    179  1.1  rillig   if curr == nil or (value ~= nil and value > curr) then
    180  1.1  rillig     return value
    181  1.1  rillig   end
    182  1.1  rillig   return curr
    183  1.1  rillig end
    184  1.1  rillig 
    185  1.1  rillig test(function()
    186  1.1  rillig   assert_equals(max(nil, nil), nil)
    187  1.1  rillig   assert_equals(max(0, nil), 0)
    188  1.1  rillig   assert_equals(max(nil, 0), 0)
    189  1.1  rillig   assert_equals(max(0, 0), 0)
    190  1.1  rillig   assert_equals(max(1, -1), 1)
    191  1.1  rillig   assert_equals(max(-1, 1), 1)
    192  1.1  rillig end)
    193  1.1  rillig 
    194  1.1  rillig 
    195  1.1  rillig -- Calculate the column on which the field should be aligned.
    196  1.1  rillig local function column(entries, get_width_before, colname)
    197  1.1  rillig 
    198  1.1  rillig   local function nexttab(col)
    199  1.1  rillig     return col // 8 * 8 + 8
    200  1.1  rillig   end
    201  1.1  rillig 
    202  1.1  rillig   local currmin, currmax, required
    203  1.1  rillig 
    204  1.1  rillig   for _, entry in ipairs(entries) do
    205  1.1  rillig     local width = get_width_before(entry)
    206  1.1  rillig     if width ~= nil then
    207  1.1  rillig       required = max(required, width)
    208  1.1  rillig 
    209  1.1  rillig       local col = entry[colname]
    210  1.1  rillig       currmin = min(currmin, col)
    211  1.1  rillig       currmax = max(currmax, col)
    212  1.1  rillig     end
    213  1.1  rillig   end
    214  1.1  rillig 
    215  1.1  rillig   if currmin == currmax then
    216  1.1  rillig     return currmin, "aligned"
    217  1.1  rillig   end
    218  1.1  rillig   return nexttab(required), "unaligned"
    219  1.1  rillig end
    220  1.1  rillig 
    221  1.1  rillig test(function()
    222  1.1  rillig 
    223  1.1  rillig   local function width_before_category(entry)
    224  1.1  rillig     return tabwidth(entry.prefix .. entry.fullname)
    225  1.1  rillig   end
    226  1.1  rillig 
    227  1.1  rillig   local function width_before_flags(entry)
    228  1.1  rillig     return tabwidth(entry.category)
    229  1.1  rillig   end
    230  1.1  rillig 
    231  1.1  rillig   -- The entries are nicely aligned, therefore there is no need to change
    232  1.1  rillig   -- anything.
    233  1.1  rillig   local entries = {
    234  1.1  rillig     parse_entry("./file1\tcategory"),
    235  1.1  rillig     parse_entry("./file2\tcategory")
    236  1.1  rillig   }
    237  1.1  rillig   assert_equals(entries[2].category_col, 8)
    238  1.1  rillig   assert_equals(width_before_category(entries[2]), 7)
    239  1.1  rillig   assert_equals(column(entries, width_before_category, "category_col"), 8)
    240  1.1  rillig 
    241  1.1  rillig   -- The entries are currently not aligned, therefore they are aligned
    242  1.1  rillig   -- to the minimum required column.
    243  1.1  rillig   entries = {
    244  1.1  rillig     parse_entry("./file1\tcategory"),
    245  1.1  rillig     parse_entry("./directory/file2\tcategory"),
    246  1.1  rillig   }
    247  1.1  rillig   assert_equals(entries[2].category_col, 24)
    248  1.1  rillig   assert_equals(column(entries, width_before_category, "category_col"), 24)
    249  1.1  rillig 
    250  1.1  rillig   -- The entries are already aligned, therefore the current alignment is
    251  1.1  rillig   -- preserved, even though it is more than the minimum required alignment
    252  1.1  rillig   -- of 8.  There are probably reasons for the large indentation.
    253  1.1  rillig   entries = {
    254  1.1  rillig     parse_entry("./file1\t\t\tcategory"),
    255  1.1  rillig     parse_entry("./file2\t\t\tcategory")
    256  1.1  rillig   }
    257  1.1  rillig   assert_equals(column(entries, width_before_category, "category_col"), 24)
    258  1.1  rillig 
    259  1.1  rillig   -- The flags are already aligned, 4 tabs to the right of the category.
    260  1.1  rillig   -- There is no reason to change anything here.
    261  1.1  rillig   entries = {
    262  1.1  rillig     parse_entry("./file1\tcategory\t\t\tflags"),
    263  1.1  rillig     parse_entry("./file2\tcategory"),
    264  1.1  rillig     parse_entry("./file3\tcat\t\t\t\tflags")
    265  1.1  rillig   }
    266  1.1  rillig   assert_equals(column(entries, width_before_flags, "flags_col"), 32)
    267  1.1  rillig 
    268  1.1  rillig end)
    269  1.1  rillig 
    270  1.1  rillig 
    271  1.1  rillig -- Amend the entries by the tabs used for alignment.
    272  1.1  rillig local function add_tabs(entries)
    273  1.1  rillig 
    274  1.1  rillig   local function width_before_category(entry)
    275  1.1  rillig     return tabwidth(entry.prefix .. entry.fullname)
    276  1.1  rillig   end
    277  1.1  rillig   local function width_before_flags(entry)
    278  1.1  rillig     if entry.flags ~= nil then
    279  1.1  rillig       return tabwidth(entry.category)
    280  1.1  rillig     end
    281  1.1  rillig   end
    282  1.1  rillig 
    283  1.1  rillig   local category_col, category_aligned =
    284  1.1  rillig     column(entries, width_before_category, "category_col")
    285  1.1  rillig   local flags_col = column(entries, width_before_flags, "flags_col")
    286  1.1  rillig 
    287  1.1  rillig   -- To avoid horizontal jumps for the column, the minimum column is set
    288  1.1  rillig   -- to 56.  This way, the third column is usually set to 72, which is
    289  1.1  rillig   -- still visible on an 80-column screen.
    290  1.1  rillig   if category_aligned == "unaligned" then
    291  1.1  rillig     category_col = max(category_col, 56)
    292  1.1  rillig   end
    293  1.1  rillig 
    294  1.1  rillig   for _, entry in ipairs(entries) do
    295  1.1  rillig     local prefix = entry.prefix
    296  1.1  rillig     local fullname = entry.fullname
    297  1.1  rillig     local category = entry.category
    298  1.1  rillig     local flags = entry.flags
    299  1.1  rillig 
    300  1.1  rillig     if category ~= nil then
    301  1.1  rillig       entry.category_tabs = tabs(prefix .. fullname, category_col)
    302  1.1  rillig       if flags ~= nil then
    303  1.1  rillig         entry.flags_tabs = tabs(category, flags_col)
    304  1.1  rillig       end
    305  1.1  rillig     end
    306  1.1  rillig   end
    307  1.1  rillig end
    308  1.1  rillig 
    309  1.1  rillig test(function()
    310  1.1  rillig   local entries = {
    311  1.1  rillig     parse_entry("./file1\t\t\t\tcategory\t\tflags"),
    312  1.1  rillig     parse_entry("./file2\t\t\t\tcategory\t\tflags"),
    313  1.1  rillig     parse_entry("./file3\t\t\tcategory\t\tflags")
    314  1.1  rillig   }
    315  1.1  rillig   add_tabs(entries)
    316  1.1  rillig   assert_equals(entries[1].category_tabs, "\t\t\t\t\t\t\t")
    317  1.1  rillig   assert_equals(entries[2].category_tabs, "\t\t\t\t\t\t\t")
    318  1.1  rillig   assert_equals(entries[3].category_tabs, "\t\t\t\t\t\t\t")
    319  1.1  rillig   assert_equals(entries[1].flags_tabs, "\t\t")
    320  1.1  rillig   assert_equals(entries[2].flags_tabs, "\t\t")
    321  1.1  rillig   assert_equals(entries[3].flags_tabs, "\t\t")
    322  1.1  rillig end)
    323  1.1  rillig 
    324  1.1  rillig 
    325  1.1  rillig -- Normalize the alignment of the fields of the entries.
    326  1.1  rillig local function normalize(entries)
    327  1.1  rillig 
    328  1.1  rillig   local function less(a, b)
    329  1.1  rillig     if a.fullname ~= b.fullname then
    330  1.1  rillig       return a.fullname < b.fullname
    331  1.1  rillig     end
    332  1.1  rillig     if a.category ~= nil and b.category ~= nil and a.category ~= b.category then
    333  1.1  rillig       return a.category < b.category
    334  1.1  rillig     end
    335  1.1  rillig     return a.flags ~= nil and b.flags ~= nil and a.flags < b.flags
    336  1.1  rillig   end
    337  1.1  rillig   table.sort(entries, less)
    338  1.1  rillig 
    339  1.1  rillig   local function by_dirname(entry)
    340  1.1  rillig     return entry.dirname
    341  1.1  rillig   end
    342  1.1  rillig   foreach_group(entries, by_dirname, add_tabs)
    343  1.1  rillig 
    344  1.1  rillig end
    345  1.1  rillig 
    346  1.1  rillig 
    347  1.1  rillig -- Read a file list completely into memory.
    348  1.1  rillig local function read_list(fname)
    349  1.1  rillig   local head = {}
    350  1.1  rillig   local entries = {}
    351  1.1  rillig   local errors = {}
    352  1.1  rillig 
    353  1.1  rillig   local f = assert(io.open(fname, "r"))
    354  1.1  rillig   local lineno = 0
    355  1.1  rillig   for line in f:lines() do
    356  1.1  rillig     lineno = lineno + 1
    357  1.1  rillig 
    358  1.1  rillig     local entry = parse_entry(line)
    359  1.1  rillig     if entry ~= nil then
    360  1.1  rillig       table.insert(entries, entry)
    361  1.1  rillig     elseif line:match("^#") then
    362  1.1  rillig       table.insert(head, line)
    363  1.1  rillig     else
    364  1.1  rillig       local msg = string.format(
    365  1.1  rillig         "%s:%d: unknown line format %q", fname, lineno, line)
    366  1.1  rillig       table.insert(errors, msg)
    367  1.1  rillig     end
    368  1.1  rillig   end
    369  1.1  rillig 
    370  1.1  rillig   f:close()
    371  1.1  rillig 
    372  1.1  rillig   return head, entries, errors
    373  1.1  rillig end
    374  1.1  rillig 
    375  1.1  rillig 
    376  1.1  rillig -- Write the normalized list file back to disk.
    377  1.1  rillig --
    378  1.1  rillig -- Duplicate lines are skipped.  This allows to append arbitrary lines to
    379  1.1  rillig -- the end of the file and have them cleaned up automatically.
    380  1.1  rillig local function write_list(fname, head, entries)
    381  1.1  rillig   local f = assert(io.open(fname, "w"))
    382  1.1  rillig 
    383  1.1  rillig   for _, line in ipairs(head) do
    384  1.1  rillig     f:write(line, "\n")
    385  1.1  rillig   end
    386  1.1  rillig 
    387  1.1  rillig   local prev_line = ""
    388  1.1  rillig   for _, entry in ipairs(entries) do
    389  1.1  rillig     local line = entry.prefix .. entry.fullname
    390  1.1  rillig     if entry.category ~= nil then
    391  1.1  rillig       line = line .. entry.category_tabs .. entry.category
    392  1.1  rillig     end
    393  1.1  rillig     if entry.flags ~= nil then
    394  1.1  rillig       line = line .. entry.flags_tabs .. entry.flags
    395  1.1  rillig     end
    396  1.1  rillig 
    397  1.1  rillig     if line ~= prev_line then
    398  1.1  rillig       prev_line = line
    399  1.1  rillig       f:write(line, "\n")
    400  1.1  rillig     end
    401  1.1  rillig   end
    402  1.1  rillig 
    403  1.1  rillig   f:close()
    404  1.1  rillig end
    405  1.1  rillig 
    406  1.1  rillig 
    407  1.1  rillig -- Load a file list, normalize it and write it back to disk.
    408  1.2  rillig local function format_list(fname, write_back)
    409  1.1  rillig   local head, entries, errors = read_list(fname)
    410  1.1  rillig   if #errors > 0 then
    411  1.1  rillig     for _, err in ipairs(errors) do
    412  1.1  rillig       print(err)
    413  1.1  rillig     end
    414  1.1  rillig     return
    415  1.1  rillig   end
    416  1.1  rillig 
    417  1.1  rillig   normalize(entries)
    418  1.2  rillig 
    419  1.2  rillig   if write_back then
    420  1.2  rillig     write_list(fname, head, entries)
    421  1.2  rillig   end
    422  1.1  rillig end
    423  1.1  rillig 
    424  1.1  rillig 
    425  1.2  rillig local function main(arg)
    426  1.2  rillig   local write_back = true
    427  1.2  rillig   for _, fname in ipairs(arg) do
    428  1.2  rillig     if fname == "-n" then
    429  1.2  rillig       write_back = false
    430  1.2  rillig     else
    431  1.2  rillig       format_list(fname, write_back)
    432  1.2  rillig     end
    433  1.2  rillig   end
    434  1.1  rillig end
    435  1.2  rillig 
    436  1.2  rillig main(arg)
    437