Skip to content

Commit dc6b178

Browse files
authored
First version of lute lint (#597)
Initial implementation of `lute lint`. It currently supports exactly one rule and one source file, with multiple rules and source files coming in a future PR. By default, it pretty prints reported violations (barring tab shenanigans). Some notes: - I had to copy `cli.luau` over from batteries because `@batteries` isn't embedded with lute commands. - I added a `@commands` alias so that the lint rules I wrote could have access to lint-related types. Longer term, we should probably figure out a better place to put those types.
1 parent 3a25262 commit dc6b178

File tree

12 files changed

+705
-2
lines changed

12 files changed

+705
-2
lines changed

.luaurc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"aliases": {
44
"batteries": "./batteries",
55
"std": "./lute/std/libs",
6-
"lute": "./definitions"
6+
"lute": "./definitions",
7+
"commands": "./lute/cli/commands"
78
}
89
}

.luaurc.ci

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"languageMode": "strict",
33
"aliases": {
44
"batteries": "./batteries",
5+
"commands": "./lute/cli/commands"
56
}
67
}

batteries/cli.luau

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ function cli.parse(self: Parser, args: { string }): ()
151151
end
152152
end
153153

154-
function cli.get(self: Parser, name: string): string
154+
function cli.get(self: Parser, name: string): string?
155155
return self.parsed.values[name]
156156
end
157157

examples/lints/almost_swapped.luau

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
local lintTypes = require("@commands/lint/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)

examples/lints/divide_by_zero.luau

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
local lintTypes = require("@commands/lint/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)

lute/cli/commands/cli.luau

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
local cli = {}
2+
cli.__index = cli
3+
4+
type ArgKind = "positional" | "flag" | "option"
5+
type ArgOptions = {
6+
help: string?,
7+
aliases: { string }?,
8+
default: string?,
9+
required: boolean?,
10+
}
11+
12+
type ArgData = {
13+
name: string,
14+
kind: ArgKind,
15+
options: ArgOptions,
16+
}
17+
18+
type ParseResult = {
19+
values: { [string]: string },
20+
flags: { [string]: boolean },
21+
fwdArgs: { string },
22+
}
23+
24+
type ParserData = {
25+
arguments: { [number]: ArgData },
26+
positional: { [number]: ArgData },
27+
parsed: ParseResult,
28+
}
29+
30+
type ParserInterface = typeof(cli)
31+
export type Parser = setmetatable<ParserData, ParserInterface>
32+
33+
function cli.parser(): Parser
34+
local self = {
35+
arguments = {},
36+
positional = {},
37+
parsed = { values = {}, flags = {}, fwdArgs = {} },
38+
}
39+
40+
return setmetatable(self, cli)
41+
end
42+
43+
function cli.add(self: Parser, name: string, kind: ArgKind, options: ArgOptions): ()
44+
local argument = {
45+
name = name,
46+
kind = kind,
47+
options = options or { aliases = {}, required = false },
48+
}
49+
50+
table.insert(self.arguments, argument)
51+
if kind == "positional" then
52+
table.insert(self.positional, argument)
53+
end
54+
end
55+
56+
function cli.parse(self: Parser, args: { string }): ()
57+
local i = 0
58+
local pos_index = 1
59+
60+
while i < #args do
61+
i += 1
62+
63+
local arg = args[i]
64+
65+
-- if the argument is exactly "--", we pass everything along
66+
if arg == "--" then
67+
table.move(args, i + 1, #args, 1, self.parsed.fwdArgs)
68+
break
69+
end
70+
71+
-- if the argument starts with two dashes, we're parsing either a flag or an option
72+
if string.sub(arg, 1, 2) == "--" then
73+
local name = string.sub(arg, 3)
74+
local found = false
75+
76+
for _, argument in self.arguments do
77+
local aliases = argument.options.aliases or {}
78+
79+
if argument.name == name or table.find(aliases, name) then
80+
found = true
81+
82+
if argument.kind == "option" then
83+
-- advance past the argument
84+
i += 1
85+
86+
assert(i <= #args, "Missing value for argument: " .. argument.name)
87+
self.parsed.values[argument.name] = args[i]
88+
89+
break
90+
end
91+
92+
self.parsed.flags[argument.name] = true
93+
break
94+
end
95+
end
96+
97+
assert(found, "Unknown argument: " .. name)
98+
continue
99+
end
100+
101+
-- if the argument starts with a single dash, we're parsing a flag
102+
if string.sub(arg, 1, 1) == "-" then
103+
local flags = string.sub(arg, 2)
104+
105+
for j = 1, #flags do
106+
local name = string.sub(flags, j, j)
107+
local found = false
108+
for _, argument in self.arguments do
109+
local aliases = argument.options.aliases or {}
110+
if argument.name == name or table.find(aliases, name) then
111+
found = true
112+
if argument.kind == "option" then
113+
i += 1
114+
assert(i <= #args, "Missing value for argument: " .. argument.name)
115+
self.parsed.values[argument.name] = args[i]
116+
else
117+
self.parsed.flags[argument.name] = true
118+
end
119+
break
120+
end
121+
end
122+
123+
assert(found, "Unknown argument: " .. name)
124+
end
125+
126+
continue
127+
end
128+
129+
-- if we have positional arguments left, we can take this argument as one
130+
if pos_index <= #self.positional then
131+
self.parsed.values[self.positional[pos_index].name] = arg
132+
pos_index += 1
133+
continue
134+
end
135+
136+
-- otherwise, the argument is forwarded on
137+
table.insert(self.parsed.fwdArgs, arg)
138+
end
139+
140+
-- check that all required arguments are present, and set any default values as needed
141+
for _, argument in self.arguments do
142+
assert(argument)
143+
144+
if argument.options.required and self.parsed.values[argument.name] == nil then
145+
assert(self.parsed.values[argument.name], "Missing required argument: " .. argument.name)
146+
end
147+
148+
if self.parsed.values[argument.name] == nil and argument.options.default then
149+
self.parsed.values[argument.name] = argument.options.default
150+
end
151+
end
152+
end
153+
154+
function cli.get(self: Parser, name: string): string?
155+
return self.parsed.values[name]
156+
end
157+
158+
function cli.has(self: Parser, name: string): boolean
159+
return self.parsed.flags[name] ~= nil
160+
end
161+
162+
function cli.forwarded(self: Parser): { string }?
163+
return self.parsed.fwdArgs
164+
end
165+
166+
function cli.help(self: Parser): ()
167+
print("Usage:")
168+
for _, argument in self.arguments do
169+
local aliasStr = table.concat(argument.options.aliases or {}, ", ")
170+
local reqStr = if argument.options.required then "(required) " else ""
171+
print(string.format(" --%s (-%s) - %s%s", argument.name, aliasStr, reqStr, argument.options.help or ""))
172+
end
173+
end
174+
175+
return table.freeze(cli)

0 commit comments

Comments
 (0)