1#!  /usr/bin/lua
2-- $NetBSD: check-expect.lua,v 1.12 2024/01/28 08:54:27 rillig Exp $
3
4--[[
5
6usage: lua ./check-expect.lua [-u] *.c
7
8Check that the /* expect+-n: ... */ comments in the .c source files match the
9actual messages found in the corresponding .exp files.  The .exp files are
10expected in the current working directory.
11
12The .exp files are generated on the fly during the ATF tests, see
13t_integration.sh.  During development, they can be generated using
14lint1/accept.sh.
15]]
16
17
18local function test(func)
19  func()
20end
21
22local function assert_equals(got, expected)
23  if got ~= expected then
24    assert(false, string.format("got %q, expected %q", got, expected))
25  end
26end
27
28
29local had_errors = false
30---@param fmt string
31function print_error(fmt, ...)
32  print(fmt:format(...))
33  had_errors = true
34end
35
36
37local function load_lines(fname)
38  local lines = {}
39
40  local f, err, errno = io.open(fname, "r")
41  if f == nil then return nil, err, errno end
42
43  for line in f:lines() do
44    table.insert(lines, line)
45  end
46  f:close()
47
48  return lines
49end
50
51
52local function save_lines(fname, lines)
53  local f = io.open(fname, "w")
54  for _, line in ipairs(lines) do
55    f:write(line .. "\n")
56  end
57  f:close()
58end
59
60
61-- Load the 'expect:' comments from a C source file.
62--
63-- example return values:
64--   {
65--     ["file.c(18)"] = {"syntax error 'a' [249]", "syntax error 'b' [249]"},
66--     ["file.c(23)"] = {"not a constant expression [123]"},
67--   },
68--   { "file.c(18)", "file.c(23)" }
69local function load_c(fname)
70  local basename = fname:match("([^/]+)$")
71  local lines = load_lines(fname)
72  if lines == nil then return nil, nil end
73
74  local pp_fname = fname
75  local pp_lineno = 0
76  local comment_locations = {}
77  local comments_by_location = {}
78
79  local function add_expectation(offset, message)
80    local location = ("%s(%d)"):format(pp_fname, pp_lineno + offset)
81    if comments_by_location[location] == nil then
82      table.insert(comment_locations, location)
83      comments_by_location[location] = {}
84    end
85    local trimmed_msg = message:match("^%s*(.-)%s*$")
86    table.insert(comments_by_location[location], trimmed_msg)
87  end
88
89  for phys_lineno, line in ipairs(lines) do
90
91    for offset, comment in line:gmatch("/%* expect([+%-]%d+): (.-) %*/") do
92      add_expectation(tonumber(offset), comment)
93    end
94
95    pp_lineno = pp_lineno + 1
96
97    local ppl_lineno, ppl_fname = line:match("^#%s*(%d+)%s+\"([^\"]+)\"")
98    if ppl_lineno ~= nil then
99      if ppl_fname == basename and tonumber(ppl_lineno) ~= phys_lineno + 1 then
100        print_error("error: %s:%d: preprocessor line number must be %d",
101          fname, phys_lineno, phys_lineno + 1)
102      end
103      if ppl_fname:match("%.c$") and ppl_fname ~= basename then
104        print_error("error: %s:%d: preprocessor filename must be '%s'",
105          fname, phys_lineno, basename)
106      end
107      pp_fname = ppl_fname
108      pp_lineno = ppl_lineno
109    end
110  end
111
112  return comment_locations, comments_by_location
113end
114
115
116-- Load the expected raw lint output from a .exp file.
117--
118-- example return value: {
119--   {
120--     exp_lineno = 18,
121--     location = "file.c(18)",
122--     message = "not a constant expression [123]",
123--   }
124-- }
125local function load_exp(exp_fname)
126
127  local lines = load_lines(exp_fname)
128  if lines == nil then
129    print_error("check-expect.lua: error: file " .. exp_fname .. " not found")
130    return
131  end
132
133  local messages = {}
134  for exp_lineno, line in ipairs(lines) do
135    for location, message in line:gmatch("(.+%(%d+%)): (.+)$") do
136      table.insert(messages, {
137        exp_lineno = exp_lineno,
138        location = location,
139        message = message
140      })
141    end
142  end
143
144  return messages
145end
146
147
148local function matches(comment, pattern)
149  if comment == "" then return false end
150
151  local any_prefix = pattern:sub(1, 3) == "..."
152  if any_prefix then pattern = pattern:sub(4) end
153  local any_suffix = pattern:sub(-3) == "..."
154  if any_suffix then pattern = pattern:sub(1, -4) end
155
156  if any_prefix and any_suffix then
157    return comment:find(pattern, 1, true) ~= nil
158  elseif any_prefix then
159    return pattern ~= "" and comment:sub(-#pattern) == pattern
160  elseif any_suffix then
161    return comment:sub(1, #pattern) == pattern
162  else
163    return comment == pattern
164  end
165end
166
167test(function()
168  assert_equals(matches("a", "a"), true)
169  assert_equals(matches("a", "b"), false)
170  assert_equals(matches("a", "aaa"), false)
171
172  assert_equals(matches("abc", "a..."), true)
173  assert_equals(matches("abc", "c..."), false)
174
175  assert_equals(matches("abc", "...c"), true)
176  assert_equals(matches("abc", "...a"), false)
177
178  assert_equals(matches("abc123xyz", "...a..."), true)
179  assert_equals(matches("abc123xyz", "...b..."), true)
180  assert_equals(matches("abc123xyz", "...c..."), true)
181  assert_equals(matches("abc123xyz", "...1..."), true)
182  assert_equals(matches("abc123xyz", "...2..."), true)
183  assert_equals(matches("abc123xyz", "...3..."), true)
184  assert_equals(matches("abc123xyz", "...x..."), true)
185  assert_equals(matches("abc123xyz", "...y..."), true)
186  assert_equals(matches("abc123xyz", "...z..."), true)
187  assert_equals(matches("pattern", "...pattern..."), true)
188  assert_equals(matches("pattern", "... pattern ..."), false)
189end)
190
191
192-- Inserts the '/* expect */' lines to the .c file, so that the .c file
193-- matches the .exp file.
194--
195-- TODO: Fix crashes in tests with '# line file' preprocessing directives.
196local function insert_missing(missing)
197  for fname, items in pairs(missing) do
198    for i, item in ipairs(items) do
199      item.stable_sort_rank = i
200    end
201    local function less(a, b)
202      if a.lineno ~= b.lineno then
203        return a.lineno > b.lineno
204      end
205      return a.stable_sort_rank > b.stable_sort_rank
206    end
207    table.sort(items, less)
208    local lines = assert(load_lines(fname))
209    local seen = {}
210    for _, item in ipairs(items) do
211      local lineno, message = item.lineno, item.message
212      local indent = (lines[lineno] or ""):match("^([ \t]*)")
213      local offset = 1 + (seen[lineno] or 0)
214      local line = ("%s/* expect+%d: %s */"):format(indent, offset, message)
215      table.insert(lines, lineno, line)
216      seen[lineno] = (seen[lineno] or 0) + 1
217    end
218    save_lines(fname, lines)
219  end
220end
221
222
223local function check_test(c_fname, update)
224  local exp_fname = c_fname:gsub("%.c$", ".exp"):gsub(".+/", "")
225
226  local c_comment_locations, c_comments_by_location = load_c(c_fname)
227  if c_comment_locations == nil then return end
228
229  local exp_messages = load_exp(exp_fname) or {}
230  local missing = {}
231
232  for _, exp_message in ipairs(exp_messages) do
233    local c_comments = c_comments_by_location[exp_message.location] or {}
234    local expected_message =
235      exp_message.message:gsub("/%*", "**"):gsub("%*/", "**")
236
237    local found = false
238    for i, c_comment in ipairs(c_comments) do
239      if c_comment ~= "" then
240        if matches(expected_message, c_comment) then
241          c_comments[i] = ""
242          found = true
243        end
244        break
245      end
246    end
247
248    if not found then
249      print_error("error: %s: missing /* expect+1: %s */",
250        exp_message.location, expected_message)
251
252      if update then
253        local fname = exp_message.location:match("^([^(]+)")
254        local lineno = tonumber(exp_message.location:match("%((%d+)%)$"))
255        if not missing[fname] then missing[fname] = {} end
256        table.insert(missing[fname], {
257          lineno = lineno,
258          message = expected_message,
259        })
260      end
261    end
262  end
263
264  for _, c_comment_location in ipairs(c_comment_locations) do
265    for _, c_comment in ipairs(c_comments_by_location[c_comment_location]) do
266      if c_comment ~= "" then
267        print_error(
268          "error: %s: declared message \"%s\" is not in the actual output",
269          c_comment_location, c_comment)
270      end
271    end
272  end
273
274  if missing then
275    insert_missing(missing)
276  end
277end
278
279
280local function main(args)
281  local update = args[1] == "-u"
282  if update then
283    table.remove(args, 1)
284  end
285
286  for _, name in ipairs(args) do
287    check_test(name, update)
288  end
289end
290
291
292main(arg)
293os.exit(not had_errors)
294