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