fmt-list revision 1.4
1#! /usr/bin/lua 2-- $NetBSD: fmt-list,v 1.4 2021/02/15 23:00:03 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") 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 column, the minimum column is set 298 -- 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 return a.fullname < b.fullname 341 end 342 if a.category ~= nil and b.category ~= nil and a.category ~= b.category then 343 return a.category < b.category 344 end 345 return a.flags ~= nil and b.flags ~= nil and a.flags < b.flags 346 end 347 table.sort(entries, less) 348 349 local function by_dirname(entry) 350 return entry.dirname 351 end 352 foreach_group(entries, by_dirname, add_tabs) 353 354end 355 356 357-- Read a file list completely into memory. 358local function read_list(fname) 359 local head = {} 360 local entries = {} 361 local errors = {} 362 363 local f = assert(io.open(fname, "r")) 364 local lineno = 0 365 for line in f:lines() do 366 lineno = lineno + 1 367 368 local entry = parse_entry(line) 369 if entry ~= nil then 370 table.insert(entries, entry) 371 elseif line:match("^#") then 372 table.insert(head, line) 373 else 374 local msg = string.format( 375 "%s:%d: unknown line format %q", fname, lineno, line) 376 table.insert(errors, msg) 377 end 378 end 379 380 f:close() 381 382 return head, entries, errors 383end 384 385 386-- Write the normalized list file back to disk. 387-- 388-- Duplicate lines are skipped. This allows to append arbitrary lines to 389-- the end of the file and have them cleaned up automatically. 390local function write_list(fname, head, entries) 391 local f = assert(io.open(fname, "w")) 392 393 for _, line in ipairs(head) do 394 f:write(line, "\n") 395 end 396 397 local prev_line = "" 398 for _, entry in ipairs(entries) do 399 local line = entry.prefix .. entry.fullname 400 if entry.category ~= nil then 401 line = line .. entry.category_tabs .. entry.category 402 end 403 if entry.flags ~= nil then 404 line = line .. entry.flags_tabs .. entry.flags 405 end 406 407 if line ~= prev_line then 408 prev_line = line 409 f:write(line, "\n") 410 else 411 --print(string.format("%s: duplicate entry: %s", fname, line)) 412 end 413 end 414 415 f:close() 416end 417 418 419-- Load a file list, normalize it and write it back to disk. 420local function format_list(fname, write_back) 421 local head, entries, errors = read_list(fname) 422 if #errors > 0 then 423 for _, err in ipairs(errors) do 424 print(err) 425 end 426 return false 427 end 428 429 normalize(entries) 430 431 if write_back then 432 write_list(fname, head, entries) 433 end 434 return true 435end 436 437 438local function main(arg) 439 local seen_error = false 440 local write_back = true 441 for _, fname in ipairs(arg) do 442 if fname == "-n" then 443 write_back = false 444 else 445 if not format_list(fname, write_back) then 446 seen_error = true 447 end 448 end 449 end 450 return not seen_error 451end 452 453os.exit(main(arg)) 454