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