1#! /usr/bin/lua 2-- $NetBSD: fmt-list,v 1.6 2022/09/08 05:05:08 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, ("got %q, expected %q"):format(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 str:sub(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 = ("\t"):rep((width - strwidth + 7) // 8) 55 if tabs == "" then 56 error(("%q\t%d\t%d"):format(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 .. ("%d %s\n"):format(#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") 156 157 entry = parse_entry("#./dirname/filename\tcat\tflags") 158 assert_equals(entry.prefix, "#") 159 assert_equals(entry.fullname, "./dirname/filename") 160 assert_equals(entry.dirname, "./dirname") 161 assert_equals(entry.basename, "filename") 162 assert_equals(entry.category_col, 24) 163 assert_equals(entry.category, "cat") 164 assert_equals(entry.flags_col, 8) 165 assert_equals(entry.flags, "flags") 166end) 167 168 169-- Return the smaller of the given values, ignoring nil. 170local function min(curr, value) 171 if curr == nil or (value ~= nil and value < curr) then 172 return value 173 end 174 return curr 175end 176 177test(function() 178 assert_equals(min(nil, nil), nil) 179 assert_equals(min(0, nil), 0) 180 assert_equals(min(nil, 0), 0) 181 assert_equals(min(0, 0), 0) 182 assert_equals(min(1, -1), -1) 183 assert_equals(min(-1, 1), -1) 184end) 185 186 187-- Return the larger of the given values, ignoring nil. 188local function max(curr, value) 189 if curr == nil or (value ~= nil and value > curr) then 190 return value 191 end 192 return curr 193end 194 195test(function() 196 assert_equals(max(nil, nil), nil) 197 assert_equals(max(0, nil), 0) 198 assert_equals(max(nil, 0), 0) 199 assert_equals(max(0, 0), 0) 200 assert_equals(max(1, -1), 1) 201 assert_equals(max(-1, 1), 1) 202end) 203 204 205-- Calculate the column on which the field should be aligned. 206local function column(entries, get_width_before, colname) 207 208 local function nexttab(col) 209 return col // 8 * 8 + 8 210 end 211 212 local currmin, currmax, required 213 214 for _, entry in ipairs(entries) do 215 local width = get_width_before(entry) 216 if width ~= nil then 217 required = max(required, width) 218 219 local col = entry[colname] 220 currmin = min(currmin, col) 221 currmax = max(currmax, col) 222 end 223 end 224 225 if currmin == currmax then 226 return currmin, "aligned" 227 end 228 return nexttab(required), "unaligned" 229end 230 231test(function() 232 233 local function width_before_category(entry) 234 return tabwidth(entry.prefix .. entry.fullname) 235 end 236 237 local function width_before_flags(entry) 238 return tabwidth(entry.category) 239 end 240 241 -- The entries are nicely aligned, therefore there is no need to change 242 -- anything. 243 local entries = { 244 parse_entry("./file1\tcategory"), 245 parse_entry("./file2\tcategory") 246 } 247 assert_equals(entries[2].category_col, 8) 248 assert_equals(width_before_category(entries[2]), 7) 249 assert_equals(column(entries, width_before_category, "category_col"), 8) 250 251 -- The entries are currently not aligned, therefore they are aligned 252 -- to the minimum required column. 253 entries = { 254 parse_entry("./file1\tcategory"), 255 parse_entry("./directory/file2\tcategory"), 256 } 257 assert_equals(entries[2].category_col, 24) 258 assert_equals(column(entries, width_before_category, "category_col"), 24) 259 260 -- The entries are already aligned, therefore the current alignment is 261 -- preserved, even though it is more than the minimum required alignment 262 -- of 8. There are probably reasons for the large indentation. 263 entries = { 264 parse_entry("./file1\t\t\tcategory"), 265 parse_entry("./file2\t\t\tcategory") 266 } 267 assert_equals(column(entries, width_before_category, "category_col"), 24) 268 269 -- The flags are already aligned, 4 tabs to the right of the category. 270 -- There is no reason to change anything here. 271 entries = { 272 parse_entry("./file1\tcategory\t\t\tflags"), 273 parse_entry("./file2\tcategory"), 274 parse_entry("./file3\tcat\t\t\t\tflags") 275 } 276 assert_equals(column(entries, width_before_flags, "flags_col"), 32) 277 278end) 279 280 281-- Amend the entries by the tabs used for alignment. 282local function add_tabs(entries) 283 284 local function width_before_category(entry) 285 return tabwidth(entry.prefix .. entry.fullname) 286 end 287 local function width_before_flags(entry) 288 if entry.flags ~= nil then 289 return tabwidth(entry.category) 290 end 291 end 292 293 local category_col, category_aligned = 294 column(entries, width_before_category, "category_col") 295 local flags_col = column(entries, width_before_flags, "flags_col") 296 297 -- To avoid horizontal jumps for the category column, the minimum column is 298 -- set to 56. This way, the third column is usually set to 72, which is 299 -- still visible on an 80-column screen. 300 if category_aligned == "unaligned" then 301 category_col = max(category_col, 56) 302 end 303 304 for _, entry in ipairs(entries) do 305 local prefix = entry.prefix 306 local fullname = entry.fullname 307 local category = entry.category 308 local flags = entry.flags 309 310 if category ~= nil then 311 entry.category_tabs = tabs(prefix .. fullname, category_col) 312 if flags ~= nil then 313 entry.flags_tabs = tabs(category, flags_col) 314 end 315 end 316 end 317end 318 319test(function() 320 local entries = { 321 parse_entry("./file1\t\t\t\tcategory\t\tflags"), 322 parse_entry("./file2\t\t\t\tcategory\t\tflags"), 323 parse_entry("./file3\t\t\tcategory\t\tflags") 324 } 325 add_tabs(entries) 326 assert_equals(entries[1].category_tabs, "\t\t\t\t\t\t\t") 327 assert_equals(entries[2].category_tabs, "\t\t\t\t\t\t\t") 328 assert_equals(entries[3].category_tabs, "\t\t\t\t\t\t\t") 329 assert_equals(entries[1].flags_tabs, "\t\t") 330 assert_equals(entries[2].flags_tabs, "\t\t") 331 assert_equals(entries[3].flags_tabs, "\t\t") 332end) 333 334 335-- Normalize the alignment of the fields of the entries. 336local function normalize(entries) 337 338 local function less(a, b) 339 if a.fullname ~= b.fullname then 340 -- To sort by directory first, comment out the following line. 341 return a.fullname < b.fullname 342 end 343 if a.dirname ~= b.dirname then 344 return a.dirname < b.dirname 345 end 346 if a.basename ~= b.basename then 347 return a.basename < b.basename 348 end 349 if a.category ~= nil and b.category ~= nil and a.category ~= b.category then 350 return a.category < b.category 351 end 352 return a.flags ~= nil and b.flags ~= nil and a.flags < b.flags 353 end 354 table.sort(entries, less) 355 356 local function by_dirname(entry) 357 return entry.dirname 358 end 359 foreach_group(entries, by_dirname, add_tabs) 360 361end 362 363 364-- Read a file list completely into memory. 365local function read_list(fname) 366 local head = {} 367 local entries = {} 368 local errors = {} 369 370 local f = assert(io.open(fname, "r")) 371 local lineno = 0 372 for line in f:lines() do 373 lineno = lineno + 1 374 375 local entry = parse_entry(line) 376 if entry ~= nil then 377 table.insert(entries, entry) 378 elseif line:match("^#") then 379 table.insert(head, line) 380 else 381 local msg = ("%s:%d: unknown line format %q"):format(fname, lineno, line) 382 table.insert(errors, msg) 383 end 384 end 385 386 f:close() 387 388 return head, entries, errors 389end 390 391 392-- Write the normalized list file back to disk. 393-- 394-- Duplicate lines are skipped. This allows to append arbitrary lines to 395-- the end of the file and have them cleaned up automatically. 396local function write_list(fname, head, entries) 397 local f = assert(io.open(fname, "w")) 398 399 for _, line in ipairs(head) do 400 f:write(line, "\n") 401 end 402 403 local prev_line = "" 404 for _, entry in ipairs(entries) do 405 local line = entry.prefix .. entry.fullname 406 if entry.category ~= nil then 407 line = line .. entry.category_tabs .. entry.category 408 end 409 if entry.flags ~= nil then 410 line = line .. entry.flags_tabs .. entry.flags 411 end 412 413 if line ~= prev_line then 414 prev_line = line 415 f:write(line, "\n") 416 else 417 --print(("%s: duplicate entry: %s"):format(fname, line)) 418 end 419 end 420 421 f:close() 422end 423 424 425-- Load a file list, normalize it and write it back to disk. 426local function format_list(fname, write_back) 427 local head, entries, errors = read_list(fname) 428 if #errors > 0 then 429 for _, err in ipairs(errors) do 430 print(err) 431 end 432 return false 433 end 434 435 normalize(entries) 436 437 if write_back then 438 write_list(fname, head, entries) 439 end 440 return true 441end 442 443 444local function main(arg) 445 local seen_error = false 446 local write_back = true 447 for _, fname in ipairs(arg) do 448 if fname == "-n" then 449 write_back = false 450 else 451 if not format_list(fname, write_back) then 452 seen_error = true 453 end 454 end 455 end 456 return not seen_error 457end 458 459os.exit(main(arg)) 460