Jianw
2025-05-13 3b39fe3810c3ee2ec9ec97236c1769c5c85e062c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
local options = require "luacheck.options"
local utils = require "luacheck.utils"
 
local stage = {}
 
stage.warnings = {
   -- Also produced during filtering for options that did not pass validation.
   ["021"] = {message_format = "{msg}", fields = {"msg"}},
   ["022"] = {message_format = "unpaired push directive", fields = {}},
   ["023"] = {message_format = "unpaired pop directive", fields = {}}
}
 
stage.inline_option_fields = {"line", "pop_count", "options", "column", "end_column"}
 
local limit_opts = utils.array_to_set({"max_line_length", "max_code_line_length", "max_string_line_length",
   "max_comment_line_length", "max_cyclomatic_complexity"})
 
local function is_valid_option_name(name)
   if name == "std" or options.variadic_inline_options[name] then
      return true
   end
 
   name = name:gsub("^no_", "")
   return options.nullary_inline_options[name] or limit_opts[name]
end
 
-- Splits a token array for an inline option invocation into
-- option name and argument array, or nil if invocation is invalid.
local function split_invocation(tokens)
   -- Name of the option can be split into several space separated tokens.
   -- Since some valid names are prefixes of some other names
   -- (e.g. `unused` and `unused arguments`), the longest prefix of token
   -- array that is a valid option name should be considered.
   local cur_name
   local last_valid_name
   local last_valid_name_end_index
 
   for i, token in ipairs(tokens) do
      cur_name = cur_name and (cur_name .. "_" .. token) or token
 
      if is_valid_option_name(cur_name) then
         last_valid_name = cur_name
         last_valid_name_end_index = i
      end
   end
 
   if not last_valid_name then
      return
   end
 
   local args = {}
 
   for i = last_valid_name_end_index + 1, #tokens do
      table.insert(args, tokens[i])
   end
 
   return last_valid_name, args
end
 
local function unexpected_num_args(name, args, expected)
   return ("inline option '%s' expects %d argument%s, %d given"):format(
      name, expected, expected == 1 and "" or "s", #args)
end
 
-- Parses inline option body, returns options or nil and error message.
local function parse_options(body)
   local opts = {}
 
   local parts = utils.split(body, ",")
 
   for _, name_and_args in ipairs(parts) do
      local tokens = utils.split(name_and_args)
      local name, args = split_invocation(tokens)
 
      if not name then
         if #tokens == 0 then
            return nil, (#parts == 1) and "empty inline option" or "empty inline option invocation"
         else
            return nil, ("unknown inline option '%s'"):format(table.concat(tokens, " "))
         end
      end
 
      if name == "std" then
         if #args ~= 1 then
            return nil, unexpected_num_args(name, args, 1)
         end
 
         opts.std = args[1]
      elseif name == "ignore" and #args == 0 then
         opts.ignore = {".*"}
      elseif options.variadic_inline_options[name] then
         opts[name] = args
      else
         local full_name = name:gsub("_", " ")
         local subs
         name, subs = name:gsub("^no_", "")
         local flag = subs == 0
 
         if options.nullary_inline_options[name] then
            if #args ~= 0 then
               return nil, unexpected_num_args(full_name, args, 0)
            end
 
            opts[name] = flag
         else
            assert(limit_opts[name])
 
            if flag then
               if #args ~= 1 then
                  return nil, unexpected_num_args(full_name, args, 1)
               end
 
               local value = tonumber(args[1])
 
               if not value then
                  return nil, ("inline option '%s' expects number as argument"):format(name)
               end
 
               opts[name] = value
            else
               if #args ~= 0 then
                  return nil, unexpected_num_args(full_name, args, 0)
               end
 
               opts[name] = false
            end
         end
      end
   end
 
   return opts
end
 
-- Parses comment contents, returns up to two `options` values (tables or "push" or "pop").
-- On an invalid inline comment returns nil and an error message.
local function parse_inline_comment(comment_contents)
   local body = utils.after(utils.strip(comment_contents), "^luacheck:")
 
   if not body then
      return
   end
 
   local opts1, opts2
 
   -- Remove comments in balanced parens.
   body = utils.strip((body:gsub("%b()", " ")))
   local after_push = body:match("^push%s+(.*)")
 
   if after_push then
      opts2 = "push"
      body = after_push
   elseif body == "push" or body == "pop" then
      return body
   end
 
   local err_msg
   opts1, err_msg = parse_options(body)
   return opts1, err_msg or opts2
end
 
-- Returns an array of tables with column range info and an `options` field
-- containing a table of options or "push" or "pop".
-- Warns about invalid inline option comments.
local function parse_inline_comments(chstate)
   local res = {}
 
   for _, comment in ipairs(chstate.comments) do
      local opts1, opts2 = parse_inline_comment(comment.contents)
 
      if opts1 then
         table.insert(res, {
            line = comment.line,
            column = chstate:offset_to_column(comment.line, comment.offset),
            end_column = chstate:offset_to_column(comment.line, comment.end_offset),
            options = opts1
         })
 
         if opts2 then
            table.insert(res, {
               line = comment.line,
               column = chstate:offset_to_column(comment.line, comment.offset),
               end_column = chstate:offset_to_column(comment.line, comment.end_offset),
               options = opts2
            })
         end
      elseif opts2 then
         chstate:warn_range("021", comment, {msg = opts2})
      end
   end
 
   return res
end
 
-- Adds a table with `line`, `column`, and `options` fields to given array.
-- For each function a table with `options` set to "push" for the function start
-- and a talbe with `options` set to "pop" for the function end are added.
local function add_function_boundaries(inline_options_and_boundaries, chstate)
   for _, line in ipairs(chstate.top_line.lines) do
      local fn_node = line.node
 
      table.insert(inline_options_and_boundaries, {
         line = fn_node.line,
         column = chstate:offset_to_column(fn_node.line, fn_node.offset),
         options = "push"
      })
 
      table.insert(inline_options_and_boundaries, {
         line = fn_node.end_range.line,
         column = chstate:offset_to_column(fn_node.end_range.line, fn_node.end_range.offset),
         options = "pop"
      })
   end
end
 
local function get_order(t)
   if t.options == "push" then
      return 1
   elseif t.options == "pop" then
      return 3
   else
      return 2
   end
end
 
local function options_and_boundaries_comparator(t1, t2)
   if t1.line ~= t2.line then
      return t1.line < t2.line
   end
 
   -- For options and boundaries on the same line, all pushes are applied before options before pops.
   -- (Valid pops will be moved to the start of the next line later.)
   local order1 = get_order(t1)
   local order2 = get_order(t2)
 
   if order1 ~= order2 then
      return order1 < order2
   else
      return t1.column < t2.column
   end
end
 
-- Applies bounadaries withing `inline_options_and_boundaries` to replace them with pop count
-- instructions in the resulting array.
-- Comments on lines with code are popped at the end of line.
-- Warns about unpaired push and pop directives.
local function apply_boundaries(chstate, inline_options_and_boundaries)
   local res = {}
   local res_last
 
   -- While iterating over inline options and boundaries track push
   -- boundaries that were not popped yet plus the number of options
   -- that would be on the option stack after applying all already
   -- processed option table pushes and pops.
   local pushes = utils.Stack()
   local push_option_counts = utils.Stack()
   local option_count = 0
 
   for _, item in ipairs(inline_options_and_boundaries) do
      if item.options == "push" then
         pushes:push(item)
         push_option_counts:push(option_count)
      elseif item.options == "pop" then
         -- Function boundaries are implicit, don't allow inline options to pop
         -- them, don't allow function boundaries to pop inline option pushes either.
         -- Inline options boundaries have end_column, function boundaries don't.
         if not pushes.top or (item.end_column and not pushes.top.end_column) then
            -- Inline option pop against nothing or a function push, mark as unpaired.
            chstate:warn_column_range("023", item)
         else
            if not item.end_column then
               -- Function pop, remove any unpaired inline option pushes.
               while pushes.top and pushes.top.end_column do
                  chstate:warn_column_range("022", pushes.top)
                  pushes:pop()
                  push_option_counts:pop()
               end
            end
 
            pushes:pop()
            local prev_option_count = push_option_counts:pop()
            local pop_count = option_count - prev_option_count
 
            if pop_count > 0 then
               -- Place the pop instruction at the start of the next line so that getting option stack
               -- for a line amounts to applying both the pop instruction and the option push for the line.
               local line = item.line + 1
 
               -- Collapse with a previous table if it's on the same line. It can only be a pop count table.
               if res_last and res_last.line == line then
                  res_last.pop_count = res_last.pop_count + pop_count
               else
                  res_last = {
                     line = line,
                     pop_count = pop_count
                  }
 
                  table.insert(res, res_last)
               end
            end
 
            -- Update option stack size for this pop.
            option_count = prev_option_count
         end
      else
         -- Inline options table. Check if there is a pop count table for this line already.
         if res_last and res_last.line == item.line then
            res_last.options = item.options
            res_last.column = item.column
            res_last.end_column = item.end_column
         else
            res_last = item
            table.insert(res, item)
         end
 
         if chstate.code_lines[item.line] then
            -- Inline comment on a line with some code, immediately pop it.
            res_last = {
               line = item.line + 1,
               pop_count = 1
            }
            table.insert(res, res_last)
         else
            option_count = option_count + 1
         end
      end
   end
 
   -- Any remaining pushes are unpaired inline comments from the main chunk.
   while pushes.top do
      chstate:warn_column_range("022", pushes:pop())
   end
 
   return res
end
 
-- Warns about invalid inline options.
-- Sets `chstate.inline_options` to an array of tables that describe the way inline option tables
-- are pushed onto and popped from the option stack when iterating over lines.
-- Each table has field `line` that the array is sorted by and also ether or both sets of fields:
-- * `pop_count` - refers to a number of option tables that should be popped from the stack before processing
--   warnings on this line.
-- * `options`, `column`, `end_column` - refers to an option table that should be pushed onto the stack
--   before processing warnings on this line but after popping tables if `pop_count` is present.
function stage.run(chstate)
   local inline_options_and_boundaries = parse_inline_comments(chstate)
   add_function_boundaries(inline_options_and_boundaries, chstate)
   table.sort(inline_options_and_boundaries, options_and_boundaries_comparator)
   chstate.inline_options = apply_boundaries(chstate, inline_options_and_boundaries)
end
 
return stage