diff --git a/spec/lexer_spec.lua b/spec/lexer_spec.lua index f7f91ebc..e04b0842 100644 --- a/spec/lexer_spec.lua +++ b/spec/lexer_spec.lua @@ -339,6 +339,19 @@ bar"]])) assert.same({line = 1, offset = 1, end_offset = 1, msg = "unfinished long comment"}, get_error("--[=[]]")) end) + it("parses C-style block comments correctly", function() + -- FiveM/Luau block comment: reuses the "long_comment" token (see the + -- "long comments" case above), so, like "--[[ ]]", no token_value is + -- captured; the parser only needs to know the span, not the contents. + assert.same({token = "long_comment"}, get_token("/**/")) + assert.same({token = "long_comment"}, get_token("/*foo\nbar*/")) + assert.same({token = "long_comment"}, get_token("/* star * inside */")) + assert.same({token = "long_comment"}, get_token("/*/ not a closer yet */")) + assert.same({token = "/"}, get_token("/ 1")) + assert.same({token = "//"}, get_token("// 1")) + assert.same({line = 1, offset = 1, end_offset = 1, msg = "unfinished long comment"}, get_error("/*")) + end) + it("provides correct location info", function() assert.same({ {token = "local", line = 1, offset = 1}, diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 67b88b64..2494aff6 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -563,6 +563,25 @@ describe("parser", function() ) end) + it("parses local declaration with destructuring correctly", function() + assert.same({ + tag = "Local", { + {tag = "Id", "a"} + }, { + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "a"}} + } + }, get_node("local a in t")) + assert.same({ + tag = "Local", { + {tag = "Id", "a"}, + {tag = "Id", "b"} + }, { + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "a"}}, + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "b"}} + } + }, get_node("local a, b in t")) + end) + it("parses local function declaration correctly", function() assert.same({ tag = "Localrec", @@ -663,6 +682,43 @@ describe("parser", function() get_error("a, (b) = c") ) end) + + it("parses destructuring reassignment correctly", function() + assert.same({ + tag = "Set", { + {tag = "Id", "a"} + }, { + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "a"}} + } + }, get_node("a in t")) + assert.same({ + tag = "Set", { + {tag = "Id", "a"}, + {tag = "Id", "b"} + }, { + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "a"}}, + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "b"}} + } + }, get_node("a, b in t")) + assert.same({ + tag = "Set", { + {tag = "Index", {tag = "Id", "x"}, {tag = "String", "a"}} + }, { + {tag = "Index", {tag = "Id", "t"}, {tag = "String", "a"}} + } + }, get_node("x.a in t")) + assert.same({ + tag = "Set", { + {tag = "Index", {tag = "Id", "x"}, {tag = "Number", "1"}} + }, { + {tag = "Index", {tag = "Id", "t"}, {tag = "Number", "1"}} + } + }, get_node("x[1] in t")) + assert.same( + {line = 1, offset = 1, end_offset = 4, msg = "unexpected assignment key"}, + get_error("x[y] in t") + ) + end) end) describe("when parsing expression statements", function() @@ -763,6 +819,15 @@ describe("parser", function() get_error("a:b:c()")) assert.same({line = 1, offset = 3, end_offset = 3, msg = "expected identifier near "}, get_error("a:")) end) + + it("parses safe navigation on calls and method calls correctly", function() + assert.same(get_node("a:b()"), get_node("a?:b()")) + assert.same(get_node("a(b)"), get_node("a?(b)")) + assert.same(get_node("a{}"), get_node("a?{}")) + assert.same(get_node("a'b'"), get_node("a?'b'")) + assert.same({line = 1, offset = 3, end_offset = 3, msg = "expected suffixed expression after '?' near '1'"}, + get_error("a?1")) + end) end) describe("when parsing expressions", function() @@ -830,6 +895,17 @@ describe("parser", function() get_error("return {a = ")) end) + it("parses table constructor set sugar correctly", function() + assert.same({tag = "Table", + {tag = "Pair", {tag = "String", "a"}, {tag = "Number", "1"}}, + {tag = "Pair", {tag = "String", "b"}, {tag = "True"}} + }, get_expr("{.a = 1, .b}")) + assert.same({tag = "Table", + {tag = "Pair", {tag = "Number", "1"}, {tag = "Number", "2"}}, + {tag = "Pair", {tag = "Number", "3"}, {tag = "True"}} + }, get_expr("{[1] = 2, [3]}")) + end) + it("parses simple expressions correctly", function() assert.same({tag = "Op", "unm", {tag = "Number", "1"} diff --git a/src/luacheck/lexer.lua b/src/luacheck/lexer.lua index e142508e..1fadccbc 100644 --- a/src/luacheck/lexer.lua +++ b/src/luacheck/lexer.lua @@ -21,6 +21,7 @@ local BYTE_OBRACE, BYTE_CBRACE = sbyte("{"), sbyte("}") local BYTE_QUOTE, BYTE_DQUOTE, BYTE_BTQUOTE = sbyte("'"), sbyte('"'), sbyte('`') local BYTE_PLUS, BYTE_DASH, BYTE_LDASH = sbyte("+"), sbyte("-"), sbyte("_") local BYTE_SLASH, BYTE_BSLASH = sbyte("/"), sbyte("\\") +local BYTE_STAR = sbyte("*") local BYTE_EQ, BYTE_NE = sbyte("="), sbyte("~") local BYTE_LT, BYTE_GT = sbyte("<"), sbyte(">") local BYTE_LF, BYTE_CR = sbyte("\n"), sbyte("\r") @@ -587,6 +588,28 @@ local function lex_div(state) if b == BYTE_SLASH then state.offset = state.offset + 1 return "//" + elseif b == BYTE_STAR then + -- FiveM/Luau C-style block comment: /* ... */. Reuses the + -- "long_comment" token so the parser handles it exactly like a + -- "--[[ ]]" long comment (i.e. it does not feed inline + -- "luacheck: ignore" directives, same as "--[[ ]]" today). + local last + b = next_byte(state) + + while true do + if last == BYTE_STAR and b == BYTE_SLASH then + state.offset = state.offset + 1 + return "long_comment" + elseif b == nil then + return nil, "unfinished long comment" + elseif is_newline(b) then + last = b + b = skip_newline(state, b) + else + last = b + b = next_byte(state) + end + end else return "/" end diff --git a/src/luacheck/parser.lua b/src/luacheck/parser.lua index b1f2cbbb..14f621db 100644 --- a/src/luacheck/parser.lua +++ b/src/luacheck/parser.lua @@ -391,13 +391,29 @@ simple_expressions["{"] = function(state) skip_token(state) -- Load name again. value_node = parse_expression(state) end + elseif state.token == "." then + -- FiveM/Luau table-constructor sugar: `. name [ = expr ]`. + -- With no `= expr`, the value defaults to `true` (set syntax). + skip_token(state) + key_node = parse_id(state, "String") + + if test_and_skip_token(state, "=") then + value_node = parse_expression(state) + else + value_node = new_outer_node(key_node, "True", {}) + end elseif state.token == "[" then - -- [ `expr` ] = `expr`. + -- [ `expr` ] [ = `expr` ]. + -- With no `= expr`, the value defaults to `true` (set syntax). skip_token(state) key_node = parse_expression(state) check_and_skip_closing_token(state, first_token_range, "[") - check_and_skip_token(state, "=") - value_node = parse_expression(state) + + if test_and_skip_token(state, "=") then + value_node = parse_expression(state) + else + value_node = new_inner_node(first_token_range, state, "True", {}) + end else -- Expression in array part. value_node = parse_expression(state) @@ -506,6 +522,25 @@ suffix_handlers["?["] = function(state, base_node) return ast_node end +-- FiveM/Luau safe navigation on a method call or funcargs, e.g. `t?:m()`, +-- `t?(...)`, `t?{...}`, `t?"..."`. `?.` and `?[` are lexed as their own +-- combined tokens (see suffix_handlers["?."] and ["?["] above) since the +-- lexer special-cases those two, so this only ever sees a bare "?" here, +-- which it delegates to the plain (non-safe-nav) handler for whatever +-- suffix follows: the AST shape for a call/method is the same either way, +-- safe navigation only changes runtime short-circuiting, not structure. +suffix_handlers["?"] = function(state, base_node) + -- Skip "?". + skip_token(state) + local token = state.token + + if token ~= ":" and token ~= "(" and token ~= "{" and token ~= "string" then + parse_error(state, "expected suffixed expression after '?'") + end + + return suffix_handlers[token](state, base_node) +end + suffix_handlers["["] = function(state, base_node) local bracket_range = copy_range(state) -- Skip "[". @@ -851,6 +886,17 @@ statements["local"] = function(state) if test_and_skip_token(state, "=") then rhs = parse_expression_list(state) + elseif test_and_skip_token(state, "in") then + -- FiveM/Luau destructuring statement, e.g. `local a, b in tbl`. + -- Desugar into the equivalent field-access assignment so the rest of + -- the checker can treat it exactly like `local a, b = tbl.a, tbl.b`. + local table_node = parse_expression(state) + rhs = {} + + for _, name_node in ipairs(lhs) do + local field_node = new_outer_node(name_node, "String", {name_node[1]}) + rhs[#rhs + 1] = new_inner_node(name_node, table_node, "Index", {table_node, field_node}) + end end return new_inner_node(start_range, rhs and rhs[#rhs] or lhs[#lhs], "Local", {lhs, rhs}) @@ -902,6 +948,19 @@ statements["goto"] = function(state) return ast_node end +-- For the `lhs in tbl` reassignment form: the field name a given lhs target +-- reads back out of `tbl` is whatever identifies that target itself, e.g. +-- `a` reads `tbl.a`, and `x.y` (or `x["y"]`) reads `tbl.y`. +local function assignment_key_node(target_node) + if target_node.tag == "Id" then + return new_outer_node(target_node, "String", {target_node[1]}) + elseif target_node.tag == "Index" and (target_node[2].tag == "String" or target_node[2].tag == "Number") then + return target_node[2] + else + parser.syntax_error("unexpected assignment key", target_node) + end +end + local function parse_expression_statement(state) local lhs local start_range = copy_range(state) @@ -951,6 +1010,20 @@ local function parse_expression_statement(state) end return new_inner_node(start_range, rhs[1], "OpSet", {lhs, rhs, compound_operator}) + elseif state.token == "in" then + -- FiveM/Luau destructuring reassignment, e.g. `a, b in tbl`. Desugar + -- into the equivalent field-access assignment, same as the `local` + -- form, so the rest of the checker treats it like `a, b = tbl.a, tbl.b`. + skip_token(state) + local table_node = parse_expression(state) + local rhs = {} + + for _, target_node in ipairs(lhs) do + local field_node = assignment_key_node(target_node) + rhs[#rhs + 1] = new_inner_node(target_node, table_node, "Index", {table_node, field_node}) + end + + return new_inner_node(start_range, table_node, "Set", {lhs, rhs}) else -- This is an assignment in the form `lhs = rhs`. check_and_skip_token(state, "=")