Home | History | Annotate | Line # | Download | only in lint1
check-expect.lua revision 1.7
      1 #!  /usr/bin/lua
      2 -- $NetBSD: check-expect.lua,v 1.7 2023/07/08 11:03:00 rillig Exp $
      3 
      4 --[[
      5 
      6 usage: lua ./check-expect.lua *.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)"] = {"invalid argument 'a'", "invalid argument 'b'"},
     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("(%S+%(%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 end)
    189 
    190 
    191 -- Inserts the '/* expect */' lines to the .c file, so that the .c file matches
    192 -- the .exp file.  Multiple 'expect' comments for a single line of code are not
    193 -- handled correctly, but it's still better than doing the same work manually.
    194 local function insert_missing(missing)
    195   for fname, items in pairs(missing) do
    196     table.sort(items, function(a, b) return a.lineno > b.lineno end)
    197     local lines = assert(load_lines(fname))
    198     for _, item in ipairs(items) do
    199       local lineno, message = item.lineno, item.message
    200       local indent = (lines[lineno] or ""):match("^([ \t]*)")
    201       local line = ("%s/* expect+1: %s */"):format(indent, message)
    202       table.insert(lines, lineno, line)
    203     end
    204     save_lines(fname, lines)
    205   end
    206 end
    207 
    208 
    209 local function check_test(c_fname, update)
    210   local exp_fname = c_fname:gsub("%.c$", ".exp"):gsub(".+/", "")
    211 
    212   local c_comment_locations, c_comments_by_location = load_c(c_fname)
    213   if c_comment_locations == nil then return end
    214 
    215   local exp_messages = load_exp(exp_fname) or {}
    216   local missing = {}
    217 
    218   for _, exp_message in ipairs(exp_messages) do
    219     local c_comments = c_comments_by_location[exp_message.location] or {}
    220     local expected_message =
    221       exp_message.message:gsub("/%*", "**"):gsub("%*/", "**")
    222 
    223     local found = false
    224     for i, c_comment in ipairs(c_comments) do
    225       if c_comment ~= "" and matches(expected_message, c_comment) then
    226         c_comments[i] = ""
    227         found = true
    228         break
    229       end
    230     end
    231 
    232     if not found then
    233       print_error("error: %s: missing /* expect+1: %s */",
    234         exp_message.location, expected_message)
    235 
    236       if update then
    237         local fname = exp_message.location:match("^([^(]+)")
    238         local lineno = tonumber(exp_message.location:match("%((%d+)%)$"))
    239         if not missing[fname] then missing[fname] = {} end
    240         table.insert(missing[fname], {
    241           lineno = lineno,
    242           message = expected_message,
    243         })
    244       end
    245     end
    246   end
    247 
    248   for _, c_comment_location in ipairs(c_comment_locations) do
    249     for _, c_comment in ipairs(c_comments_by_location[c_comment_location]) do
    250       if c_comment ~= "" then
    251         print_error(
    252           "error: %s: declared message \"%s\" is not in the actual output",
    253           c_comment_location, c_comment)
    254       end
    255     end
    256   end
    257 
    258   if missing then
    259     insert_missing(missing)
    260   end
    261 end
    262 
    263 
    264 local function main(args)
    265   local update = args[1] == "-u"
    266   if update then
    267     table.remove(args, 1)
    268   end
    269 
    270   for _, name in ipairs(args) do
    271     check_test(name, update)
    272   end
    273 end
    274 
    275 
    276 main(arg)
    277 os.exit(not had_errors)
    278