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