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
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
local cache = require "luacheck.cache"
local config = require "luacheck.config"
local expand_rockspec = require "luacheck.expand_rockspec"
local format = require "luacheck.format"
local fs = require "luacheck.fs"
local globbing = require "luacheck.globbing"
local luacheck = require "luacheck"
local multithreading = require "luacheck.multithreading"
local options = require "luacheck.options"
local utils = require "luacheck.utils"
 
local runner = {}
 
local Runner = utils.class()
 
function Runner:__init(config_stack)
   self._config_stack = config_stack
end
 
local config_options = {
   config = utils.has_type_or_false("string"),
   default_config = utils.has_type_or_false("string")
}
 
function runner.new(opts)
   local ok, err = options.validate(config_options, opts)
 
   if not ok then
      error(("bad argument #1 to 'runner.new' (%s)"):format(err))
   end
 
   local base_config, config_err = config.load_config(opts.config, opts.default_config)
 
   if not base_config then
      return nil, config_err
   end
 
   local override_config = config.table_to_config(opts)
 
   local config_stack
   config_stack, err = config.stack_configs({base_config, override_config})
 
   if not config_stack then
      return nil, err
   end
 
   return Runner(config_stack)
end
 
local function validate_inputs(inputs)
   if type(inputs) ~= "table" then
      return nil, ("inputs table expected, got %s"):format(inputs)
   end
 
   for index, input in ipairs(inputs) do
      local context = ("invalid input table at index [%d]"):format(index)
 
      if type(input) ~= "table" then
         return nil, ("%s: table expected, got %s"):format(context, type(input))
      end
 
      local specifies_source
 
      for _, field in ipairs({"file", "filename", "path", "rockspec_path", "string"}) do
         if input[field] ~= nil then
            if field == "file" then
               if io.type(input[field]) ~= "file" then
                  return nil, ("%s: invalid field 'file': open file expected, got %s"):format(
                     context, type(input[field]))
               end
            elseif type(input[field]) ~= "string" then
               return nil, ("%s: invalid field '%s': string expected, got %s"):format(
                  context, field, type(input[field]))
            end
 
            if field ~= "filename" then
               specifies_source = true
            end
         end
      end
 
      if not specifies_source then
         return nil, ("%s: one of fields 'path', 'rockspec_path', 'file', or 'string' must be present"):format(context)
      end
   end
 
   return true
end
 
local function matches_any(globs, filename)
   for _, glob in ipairs(globs) do
      if globbing.match(glob, filename) then
         return true
      end
   end
 
   return false
end
 
function Runner:_is_filename_included(abs_filename)
   return not matches_any(self._top_opts.exclude_files, abs_filename) and (
      #self._top_opts.include_files == 0 or matches_any(self._top_opts.include_files, abs_filename))
end
 
-- Normalizes inputs and filters inputs using `exclude_files` and `include_files` options.
-- Returns an array of prepared input tables.
-- Differences between normal and prepated inputs:
-- * Prepared inputs can't have `rockspec_path` field.
-- * Prepared inputs can't have `path` pointing to a directory (unless it has an error).
-- * Prepared inputs have `filename` field if possible (copied from `path` if not given).
-- * Prepared inputs that have `path` field also have `abs_path` field.
-- * Prepared inputs can have `fatal` field if the input can't be checked. The value is error type as a string.
--   `fatal` is always accompanied by an error message in `msg` field.
function Runner:_prepare_inputs(inputs)
   local current_dir = fs.get_current_dir()
   local dir_pattern = #self._top_opts.include_files > 0 and "" or "%.lua$"
 
   local res = {}
 
   local function add(input)
      if input.path then
         -- TODO: get rid of this, adjust fs.extract_files to avoid leading `./` instead.
         input.path = input.path:gsub("^%.[/\\]([^/])", "%1")
         input.abs_path = fs.normalize(fs.join(current_dir, input.path))
      end
 
      local abs_filename
 
      if input.filename then
         abs_filename = fs.normalize(fs.join(current_dir, input.filename))
      else
         input.filename = input.path
         abs_filename = input.abs_path
      end
 
      if not input.filename or self:_is_filename_included(abs_filename) then
         table.insert(res, input)
      end
   end
 
   for _, input in ipairs(inputs) do
      if input.path then
         if fs.is_dir(input.path) then
            local filenames, err_map = fs.extract_files(input.path, dir_pattern)
 
            for _, filename in ipairs(filenames) do
               local err = err_map[filename]
               if err then
                  add({path = filename, fatal = "I/O", msg = err, filename = input.filename})
               else
                  add({path = filename, filename = input.filename})
               end
            end
         else
            add({path = input.path, filename = input.filename})
         end
      elseif input.rockspec_path then
         local filenames, fatal, err = expand_rockspec(input.rockspec_path)
 
         if filenames then
            for _, filename in ipairs(filenames) do
               add({path = filename, filename = input.filename})
            end
         else
            add({path = input.rockspec_path, fatal = fatal, msg = err, filename = input.filename})
         end
      elseif input.file then
         add({file = input.file, filename = input.filename})
      elseif input.string then
         add({string = input.string, filename = input.filename})
      else
         -- Validation should ensure this never happens.
         error("input doesn't specify source to check")
      end
   end
 
   return res
end
 
-- Adds `mtime` field to inputs eligible for caching.
-- On failure no field is added, most likely the file doesn't exist
-- or is unreadable and it's better to get the error when trying to read it.
local function add_mtimes(inputs)
   for _, input in ipairs(inputs) do
      if input.path and not input.fatal then
         input.mtime = fs.get_mtime(input.path)
      end
   end
end
 
-- Loads cached reports for input with `mtime` field, assigns them to `cached_report` field.
-- Returns true on success or nil and an error message on failure.
function Runner:_add_cached_reports(inputs)
   local potentially_cached_filenames = {}
   local mtimes = {}
 
   for _, input in ipairs(inputs) do
      if input.mtime then
         table.insert(potentially_cached_filenames, input.abs_path)
         table.insert(mtimes, input.mtime)
      end
   end
 
   local filename_to_cached_report = cache.load(self._top_opts.cache, potentially_cached_filenames, mtimes)
 
   if not filename_to_cached_report then
      return nil, ("Couldn't load cache from %s: data corrupted"):format(self._top_opts.cache)
   end
 
   for _, input in ipairs(inputs) do
      input.cached_report = filename_to_cached_report[input.abs_path]
   end
 
   return true
end
 
-- Adds report as `new_report` field to all inputs that don't have a fatal error or a cached report.
-- Adds `fatal` and `msg` instead if there was an I/O error.
function Runner:_add_new_reports(inputs)
   local sources = {}
   local original_indexes = {}
 
   for index, input in ipairs(inputs) do
      if not input.fatal and not input.cached_report then
         if input.string then
            table.insert(sources, input.string)
            table.insert(original_indexes, index)
         else
            local source, err = utils.read_file(input.path or input.file)
 
            if source then
               table.insert(sources, source)
               table.insert(original_indexes, index)
            else
               input.fatal = "I/O"
               input.msg = err
            end
         end
      end
   end
 
   local map = multithreading.has_lanes and multithreading.pmap or utils.map
   local reports = map(luacheck.get_report, sources, self._top_opts.jobs)
 
   for index, report in ipairs(reports) do
      inputs[original_indexes[index]].new_report = report
   end
end
 
-- Saves `new_report` for files eligible for caching to cache.
-- Returns true on success or nil and an error message on failure.
function Runner:_save_new_reports_to_cache(inputs)
   local filenames = {}
   local mtimes = {}
   local reports = {}
 
   for _, input in ipairs(inputs) do
      if input.new_report and input.path then
         -- If report for a file could be cached but getting its `mtime` has failed,
         -- ignore the error - report is already here, might as well return it.
         if input.mtime then
            table.insert(filenames, input.abs_path)
            table.insert(mtimes, input.mtime)
            table.insert(reports, input.new_report)
         end
      end
   end
 
   local ok = cache.update(self._top_opts.cache, filenames, mtimes, reports)
 
   if ok then
      return true
   else
      return nil, ("Couldn't save cache to %s: I/O error"):format(self._top_opts.cache)
   end
end
 
-- Inputs are prepared here, see `Runner:_prepare_inputs`.
-- Returns an array of reports, one per input, possibly annotated with fields `fatal`, `msg`, and `filename`.
-- On critical error returns nil and an error message.
function Runner:_get_reports(inputs)
   if self._top_opts.cache then
      add_mtimes(inputs)
      local ok, err = self:_add_cached_reports(inputs)
 
      if not ok then
         return nil, err
      end
   end
 
   self:_add_new_reports(inputs)
 
   if self._top_opts.cache then
      local ok, err = self:_save_new_reports_to_cache(inputs)
 
      if not ok then
         return nil, err
      end
   end
 
   local res = {}
 
   for _, input in ipairs(inputs) do
      local report = input.cached_report or input.new_report
 
      if not report then
         report = {fatal = input.fatal, msg = input.msg}
      end
 
      report.filename = input.filename
      table.insert(res, report)
   end
 
   return res
end
 
function Runner:_get_final_report(reports)
   local processing_options = {}
 
   for index, report in ipairs(reports) do
      if not report.fatal then
         processing_options[index] = self._config_stack:get_options(report.filename)
      end
   end
 
   local final_report = luacheck.process_reports(reports, processing_options, self._config_stack:get_stds())
 
   -- `luacheck.process_reports` doesn't preserve `filename` fields, re-add them.
   -- TODO: make it preserve them?
   for index, report in ipairs(reports) do
      final_report[index].filename = report.filename
   end
 
   return final_report
end
 
-- Inputs is an array of tables, each one specifies an input.
-- Each input table must have one of the following fields:
-- * `path`: string pointing to a file or directory to check. Checking directories requires LuaFileSystem,
--   and recursively checks all files within the directory. If `include_files` option is not used,
--   only files with `.lua` extensions within the directory are considered.
-- * `rockspec_path`: string pointing to a rockspec, all files with `.lua` extension within its `build.modules`,
--   `build.install.lua`, and `build.install.bin` tables are checked.
-- * `file`: an open file object. It is read till EOF and closed, contents are checked.
-- * `string`: Lua code to check as a string.
-- Additionally, each input table can have `filename` field: a string used when applying `exclude_files`
-- and `include_files` options to the input, and also when figuring out which per-path option overrides to use.
-- By default, if `path` field is given, it is also used as `filename`, otherwise the input is considered unnamed.
-- Unnamed files always pass `exclude_files` and `include_files` filters and don't have any per-path options applied.
function Runner:check(inputs)
   local ok, err = validate_inputs(inputs)
 
   if not ok then
      error(("bad argument #1 to 'Runner:check' (%s)"):format(err))
   end
 
   -- Path-related top options can depend on current directory.
   -- Assume it can't somehow change during `:check` call.
   self._top_opts = self._config_stack:get_top_options()
 
   local prepared_inputs = self:_prepare_inputs(inputs)
   local reports, reports_err = self:_get_reports(prepared_inputs)
 
   if not reports then
      return nil, reports_err
   end
 
   return self:_get_final_report(reports)
end
 
-- Formats given report (same format as returned by `Runner:check`).
-- Optionally a table of options can be passed as `format_opts`,
-- it can contain options `formatter`. `quiet`, `color`, `codes`, and `ranges`,
-- with priority over options from initialization and config.
-- Returns formatted report as a string. It always has a newline at the end unless it is empty.
-- On error returns nil and an error message.
function Runner:format(report, format_opts)
   if type(report) ~= "table" then
      error(("bad argument #1 to 'Runner:format' (report table expected, got %s"):format(type(report)))
   end
 
   local is_valid, err = options.validate(config.format_options, format_opts)
 
   if not is_valid then
      error(("bad argument #2 to 'Runner:format' (%s)"):format(err))
   end
 
   local top_opts = self._config_stack:get_top_options()
   format_opts = format_opts or {}
 
   local combined_opts = {}
 
   for _, option in ipairs({"formatter", "quiet", "color", "codes", "ranges"}) do
      combined_opts[option] = top_opts[option]
 
      if format_opts[option] ~= nil then
         combined_opts[option] = format_opts[option]
      end
   end
 
   local filenames = {}
 
   for _, file_report in ipairs(report) do
      table.insert(filenames, file_report.filename or "<unnamed source>")
   end
 
   local output
 
   if format.builtin_formatters[combined_opts.formatter] then
      output = format.format(report, filenames, combined_opts)
   else
      local formatter_func = combined_opts.formatter
 
      if type(combined_opts.formatter) == "string" then
         local require_ok
         local formatter_anchor_dir
 
         if not format_opts.formatter then
            formatter_anchor_dir = top_opts.formatter_anchor_dir
         end
 
         require_ok, formatter_func = config.relative_require(formatter_anchor_dir, combined_opts.formatter)
 
         if not require_ok then
            return nil, ("Couldn't load custom formatter '%s': %s"):format(combined_opts.formatter, formatter_func)
         end
      end
 
      local ok
      ok, output = pcall(formatter_func, report, filenames, combined_opts)
 
      if not ok then
         return nil, ("Couldn't run custom formatter '%s': %s"):format(tostring(combined_opts.formatter), output)
      end
   end
 
   if #output > 0 and output:sub(-1) ~= "\n" then
      output = output .. "\n"
   end
 
   return output
end
 
return runner