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