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