Home | History | Annotate | Line # | Download | only in lint1
      1 #! /usr/bin/lua
      2 -- $NetBSD: check-expect.lua,v 1.14 2025/02/27 06:48:29 rillig Exp $
      3 
      4 --[[
      5 
      6 usage: lua ./check-expect.lua [-u] *.c
      7 
      8 Check that the /* expect+-n: ... */ comments in the .c source files match the
      9 actual messages found in the corresponding .exp files.  The .exp files are
     10 expected in the current working directory.
     11 
     12 The .exp files are generated on the fly during the ATF tests, see
     13 t_integration.sh.  During development, they can be generated using
     14 lint1/accept.sh.
     15 ]]
     16 
     17 
     18 local function test(func)
     19   func()
     20 end
     21 
     22 local function assert_equals(got, expected)
     23   if got ~= expected then
     24     assert(false, string.format("got %q, expected %q", got, expected))
     25   end
     26 end
     27 
     28 
     29 local had_errors = false
     30 ---@param fmt string
     31 function print_error(fmt, ...)
     32   print(fmt:format(...))
     33   had_errors = true
     34 end
     35 
     36 
     37 local 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
     49 end
     50 
     51 
     52 local 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()
     58 end
     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)" }
     69 local 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
    113 end
    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 -- }
    125 local 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
    145 end
    146 
    147 
    148 local 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
    165 end
    166 
    167 test(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)
    189 end)
    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.
    196 local 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
    220 end
    221 
    222 
    223 local 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
    277 end
    278 
    279 
    280 local function main(args)
    281   local update = false
    282   for _, arg in ipairs(args) do
    283     if arg == "-u" then
    284       update = true
    285     end
    286   end
    287 
    288   for _, name in ipairs(args) do
    289     if name ~= "-u" then
    290       check_test(name, update)
    291     end
    292   end
    293 end
    294 
    295 
    296 main(arg)
    297 os.exit(not had_errors)
    298