Skip to content

Commit 637f247

Browse files
authored
lute lint: default lint rules (#624)
Implement behavior for `lute lint` to use default lint rules if none are specified. After discussing with @~Vighnesh-V, we've decided to keep the example rules in `examples` as-is, but keep the actual lint rules used inside `lute/cli/commands/lint/rules`, which will also hold any lint rules added in the future. Also deleted the `linter.luau` example in case people go thinking that's the linter instead.
1 parent c5cd640 commit 637f247

File tree

8 files changed

+238
-125
lines changed

8 files changed

+238
-125
lines changed

examples/linter.luau

Lines changed: 0 additions & 94 deletions
This file was deleted.

examples/lints/almost_swapped.luau

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
-- Check out lute/cli/commands/lint/rules for the default rules used by lute lint!
2+
13
local lintTypes = require("@commands/lint/types")
24
local path = require("@std/path")
35
local query = require("@std/syntax/query")

examples/lints/divide_by_zero.luau

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
-- Check out lute/cli/commands/lint/rules for the default rules used by lute lint!
2+
13
local lintTypes = require("@commands/lint/types")
24
local path = require("@std/path")
35
local query = require("@std/syntax/query")

lute/cli/commands/lint/init.luau

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@ local syntax = require("@std/syntax")
77
local tableext = require("@std/tableext")
88
local types = require("@self/types")
99

10+
local DEFAULT_RULES = { "almost_swapped", "divide_by_zero" }
1011
local USAGE = "Usage: lute lint [OPTIONS] [...PATHS]"
1112

1213
local function printHelp()
1314
print(USAGE)
1415
print([[
15-
Lint the specified Luau file using the specified lint rule(s).
16+
Lint the specified Luau file using the specified lint rule(s) or using the default rules.
1617
1718
OPTIONS:
1819
-h, --help Show this help message
19-
-r, --rules [RULE] Path to a single lint rule or a folder containing lint rules. If a folder is provided, any subfolders containing init.luau files will be treated as modules exporting lint rules, while all other .luau files will be treated as individual lint rules.
20+
-r, --rules [RULE] Path to a single lint rule or a folder containing lint rules. If a folder
21+
is provided, any subfolders containing init.luau files will be treated as
22+
modules exporting lint rules, while all other .luau files will be treated
23+
as individual lint rules. If unspecified, the default lint rules are used.
2024
2125
PATHS:
2226
Path(s) to the Luau file(s) or folders containing Luau files to be linted. Only files with .luau or .lua extensions will be linted.
@@ -27,17 +31,21 @@ EXAMPLES:
2731
]])
2832
end
2933

30-
local function loadLintRule(path: string): types.LintRule
31-
local loaded = luau.loadbypath(path)
32-
assert(loaded, `{path} failed to require`)
33-
assert(typeof(loaded) == "table", `{path} must return a table`)
34-
assert(typeof(loaded.name) == "string", `{path} must return a table with a 'name' string property`)
34+
local function assertIsLintRule(rule: unknown, path: string)
35+
assert(rule, `{path} failed to require`)
36+
assert(typeof(rule) == "table", `{path} must return a table`)
37+
assert(typeof(rule.name) == "string", `{path} must return a table with a 'name' string property`)
3538
assert(
36-
typeof(loaded.lint) == "function", -- LUAUFIX: type error on loaded.lint because loaded has been refined to { read name: string }
39+
typeof(rule.lint) == "function", -- LUAUFIX: type error on loaded.lint because loaded has been refined to { read name: string }
3740
`{path} must return a table with a 'lint' function property`
3841
)
42+
end
43+
44+
local function loadLintRule(path: string): types.LintRule
45+
local loaded = luau.loadbypath(path)
46+
assertIsLintRule(loaded, path)
3947

40-
return loaded :: types.LintRule -- We have to cast because we can only assert that loaded.lint is the top function type, rather than a more specific function type like (ast: syntax.AstStatBlock, sourcepath: path.path) -> { LintViolation }
48+
return loaded -- Typecast isn't needed because loaded is refined to any bc of type error in assertIsLintRule
4149
end
4250

4351
local function loadLintRules(path: string): { types.LintRule }
@@ -70,6 +78,17 @@ local function loadLintRules(path: string): { types.LintRule }
7078
return rules
7179
end
7280

81+
local function loadDefaultRules(): { types.LintRule }
82+
local rules = {}
83+
for _, ruleName in DEFAULT_RULES do
84+
local path = `@self/rules/{ruleName}`
85+
local rule = require(path)
86+
assertIsLintRule(rule, path)
87+
table.insert(rules, rule)
88+
end
89+
return rules
90+
end
91+
7392
local function lintFile(inputFilePath: pathLib.path, lintRules: { types.LintRule }): { types.LintViolation }
7493
print(`Reading input file '{inputFilePath}'`)
7594
local fileContent = fs.readfiletostring(inputFilePath)
@@ -104,7 +123,7 @@ local function main(...: string)
104123

105124
args:add("rules", "option", { help = "Linting rule(s) to apply", aliases = { "r" } })
106125
args:add("help", "flag", { help = "Show help message", aliases = { "h" } })
107-
-- TODO: handle multiple rules, multiple input files, ignore blobs, json output option
126+
-- TODO: ignore blobs, json output option
108127

109128
args:parse({ ... })
110129

@@ -113,21 +132,21 @@ local function main(...: string)
113132
return
114133
end
115134

116-
local rulePath = args:get("rules")
117-
if rulePath == nil then
118-
-- TODO: support default rules
119-
print("Error: No lint rules specified.\n\n" .. USAGE .. "\nUse --help for more information.")
120-
return
121-
end
122-
123135
local inputFiles = args:forwarded()
124136
if inputFiles == nil or #inputFiles == 0 then
125137
print("Error: No input files specified.\n\n" .. USAGE .. "\nUse --help for more information.")
126138
return
127139
end
128140

129-
print(`Loading lint rule(s) from '{rulePath}'`)
130-
local lintRules = loadLintRules(rulePath)
141+
local lintRules: { types.LintRule }
142+
local rulePath = args:get("rules")
143+
if rulePath == nil then
144+
print("Using default lint rules.")
145+
lintRules = loadDefaultRules()
146+
else
147+
print(`Loading lint rule(s) from '{rulePath}'`)
148+
lintRules = loadLintRules(rulePath)
149+
end
131150

132151
local allViolations = {}
133152

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
To add a new default lint rule to `lute lint`, create a module or luau file defining the rule in this folder. Then, add the name of the rule to the DEFAULT_RULES constant in `lint/init.luau`, and add a test for the rule in `tests/cli/lint.test.luau`. The expected type of a lint rule is specified in `lint/types.luau`.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
local lintTypes = require("../types")
2+
local path = require("@std/path")
3+
local query = require("@std/syntax/query")
4+
local syntax = require("@std/syntax")
5+
local utils = require("@std/syntax/utils")
6+
7+
local name = "almost_swapped"
8+
local message = "This looks like a failed attempt to swap."
9+
10+
local compFuncs = {}
11+
12+
function compFuncs.exprLocalsSame(a: syntax.AstExprLocal, b: syntax.AstExprLocal): boolean
13+
return a["local"] == b["local"]
14+
end
15+
16+
function compFuncs.exprGlobalsSame(a: syntax.AstExprGlobal, b: syntax.AstExprGlobal): boolean
17+
return a.name.text == b.name.text
18+
end
19+
20+
function compFuncs.exprIndexNamesSame(a: syntax.AstExprIndexName, b: syntax.AstExprIndexName): boolean
21+
if a.index.text ~= b.index.text then
22+
return false
23+
end
24+
25+
return compFuncs.refExprsSame(a.expression, b.expression)
26+
end
27+
28+
function compFuncs.exprIndexExprsSame(a: syntax.AstExprIndexExpr, b: syntax.AstExprIndexExpr): boolean
29+
if a.index.tag == "string" and b.index.tag == "string" then
30+
if a.index.text ~= b.index.text then
31+
return false
32+
else
33+
return compFuncs.refExprsSame(a.expression, b.expression)
34+
end
35+
else
36+
return compFuncs.refExprsSame(a.expression, b.expression) and compFuncs.refExprsSame(a.index, b.index)
37+
end
38+
end
39+
40+
function compFuncs.refExprsSame(a: syntax.AstExpr, b: syntax.AstExpr): boolean
41+
if a.tag ~= b.tag then
42+
return false
43+
end
44+
45+
if a.tag == "local" then
46+
return compFuncs.exprLocalsSame(a, b :: syntax.AstExprLocal)
47+
elseif a.tag == "global" then
48+
return compFuncs.exprGlobalsSame(a, b :: syntax.AstExprGlobal)
49+
elseif a.tag == "indexname" then
50+
return compFuncs.exprIndexNamesSame(a, b :: syntax.AstExprIndexName)
51+
elseif a.tag == "index" then
52+
return compFuncs.exprIndexExprsSame(a, b :: syntax.AstExprIndexExpr)
53+
else
54+
return false
55+
end
56+
end
57+
58+
-- Report instances of attempted swaps like:
59+
-- a = b; b = a
60+
local function lint(ast: syntax.AstStatBlock, sourcepath: path.path): { lintTypes.LintViolation }
61+
local violations = {}
62+
63+
local nodes = query.findallfromroot(ast, utils.isStatBlock).nodes
64+
65+
for _, block in nodes do
66+
for i = 1, #block.statements - 1 do
67+
local currStat = block.statements[i]
68+
if currStat.tag ~= "assign" or #currStat.values ~= 1 or #currStat.variables ~= 1 then
69+
continue
70+
end
71+
72+
local nextStat = block.statements[i + 1]
73+
if nextStat.tag ~= "assign" or #nextStat.values ~= 1 or #nextStat.variables ~= 1 then
74+
continue
75+
end
76+
77+
local currVar, currVal = currStat.variables[1].node, currStat.values[1].node
78+
local nextVar, nextVal = nextStat.variables[1].node, nextStat.values[1].node
79+
80+
if compFuncs.refExprsSame(currVar, nextVal) and compFuncs.refExprsSame(nextVar, currVal) then
81+
table.insert(violations, { -- LUAUFIX: severity isn't inferred as a singleton, so table.insert is mad
82+
lintname = name,
83+
location = syntax.span.create({
84+
beginline = currStat.location.beginline,
85+
begincolumn = currStat.location.begincolumn,
86+
endline = nextStat.location.endline,
87+
endcolumn = nextStat.location.endcolumn,
88+
}),
89+
message = message,
90+
severity = "warning",
91+
sourcepath = sourcepath,
92+
})
93+
end
94+
end
95+
end
96+
97+
return violations
98+
end
99+
100+
local rule: lintTypes.LintRule = {
101+
name = name,
102+
lint = lint,
103+
}
104+
105+
return table.freeze(rule)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
local lintTypes = require("../types")
2+
local path = require("@std/path")
3+
local query = require("@std/syntax/query")
4+
local syntax = require("@std/syntax")
5+
local utils = require("@std/syntax/utils")
6+
7+
local name = "divide_by_zero"
8+
local message = "Division by zero detected."
9+
10+
local function lint(ast: syntax.AstStatBlock, sourcepath: path.path): { lintTypes.LintViolation }
11+
return query
12+
.findallfromroot(ast, utils.isExprBinary)
13+
:filter(function(bin)
14+
return bin.operator.text == "/" or bin.operator.text == "//" or bin.operator.text == "%"
15+
end)
16+
:filter(function(bin)
17+
return bin.rhsoperand.kind == "expr" and bin.rhsoperand.tag == "number" and bin.rhsoperand.value == 0
18+
end)
19+
:maptoarray(
20+
function(
21+
n: syntax.AstExprBinary
22+
): lintTypes.LintViolation -- LUAUFIX: Bidiretional inference of generics should let us not need this annotation
23+
return {
24+
lintname = name,
25+
location = n.location,
26+
message = message,
27+
severity = "warning",
28+
sourcepath = sourcepath,
29+
}
30+
end
31+
)
32+
end
33+
34+
local rule: lintTypes.LintRule = {
35+
name = name,
36+
lint = lint,
37+
}
38+
39+
return table.freeze(rule)

0 commit comments

Comments
 (0)