// This file is part of the Luau programming language and is licensed under MIT License; see LICENSE.txt for details #include "Luau/Linter.h" #include "Luau/AstQuery.h" #include "Luau/Module.h" #include "Luau/Scope.h" #include "Luau/TypeInfer.h" #include "Luau/StringUtils.h" #include "Luau/Common.h" #include #include #include LUAU_FASTINTVARIABLE(LuauSuggestionDistance, 4) LUAU_FASTFLAGVARIABLE(LuauLintGlobalNeverReadBeforeWritten, false) LUAU_FASTFLAGVARIABLE(LuauLintNoRobloxBits, false) namespace Luau { // clang-format off static const char* kWarningNames[] = { "Unknown", "UnknownGlobal", "DeprecatedGlobal", "GlobalUsedAsLocal", "LocalShadow", "SameLineStatement", "MultiLineStatement", "LocalUnused", "FunctionUnused", "ImportUnused", "BuiltinGlobalWrite", "PlaceholderRead", "UnreachableCode", "UnknownType", "ForRange", "UnbalancedAssignment", "ImplicitReturn", "DuplicateLocal", "FormatString", "TableLiteral", "UninitializedLocal", "DuplicateFunction", "DeprecatedApi", "TableOperations", "DuplicateCondition", "MisleadingAndOr", "CommentDirective", }; // clang-format on static_assert(std::size(kWarningNames) == unsigned(LintWarning::Code__Count), "did you forget to add warning to the list?"); struct LintContext { struct Global { TypeId type = nullptr; std::optional deprecated; }; std::vector result; LintOptions options; AstStat* root; AstName placeholder; DenseHashMap builtinGlobals; ScopePtr scope; const Module* module; LintContext() : root(nullptr) , builtinGlobals(AstName()) , module(nullptr) { } bool warningEnabled(LintWarning::Code code) { return (options.warningMask & (1ull << code)) != 0; } std::optional getType(AstExpr* expr) { if (!module) return std::nullopt; auto it = module->astTypes.find(expr); if (!it) return std::nullopt; return *it; } }; struct WarningComparator { int compare(const Position& lhs, const Position& rhs) const { if (lhs.line != rhs.line) return lhs.line < rhs.line ? -1 : 1; if (lhs.column != rhs.column) return lhs.column < rhs.column ? -1 : 1; return 0; } int compare(const Location& lhs, const Location& rhs) const { if (int c = compare(lhs.begin, rhs.begin)) return c; if (int c = compare(lhs.end, rhs.end)) return c; return 0; } bool operator()(const LintWarning& lhs, const LintWarning& rhs) const { if (int c = compare(lhs.location, rhs.location)) return c < 0; return lhs.code < rhs.code; } }; LUAU_PRINTF_ATTR(4, 5) static void emitWarning(LintContext& context, LintWarning::Code code, const Location& location, const char* format, ...) { if (!context.warningEnabled(code)) return; va_list args; va_start(args, format); std::string message = vformat(format, args); va_end(args); LintWarning warning = {code, location, message}; context.result.push_back(warning); } static bool similar(AstExpr* lhs, AstExpr* rhs) { if (lhs->classIndex != rhs->classIndex) return false; #define CASE(T) else if (T* le = lhs->as(), *re = rhs->as(); le && re) if (false) return false; CASE(AstExprGroup) return similar(le->expr, re->expr); CASE(AstExprConstantNil) return true; CASE(AstExprConstantBool) return le->value == re->value; CASE(AstExprConstantNumber) return le->value == re->value; CASE(AstExprConstantString) return le->value.size == re->value.size && memcmp(le->value.data, re->value.data, le->value.size) == 0; CASE(AstExprLocal) return le->local == re->local; CASE(AstExprGlobal) return le->name == re->name; CASE(AstExprVarargs) return true; CASE(AstExprIndexName) return le->index == re->index && similar(le->expr, re->expr); CASE(AstExprIndexExpr) return similar(le->expr, re->expr) && similar(le->index, re->index); CASE(AstExprFunction) return false; // rarely meaningful in context of this pass, avoids having to process statement nodes CASE(AstExprUnary) return le->op == re->op && similar(le->expr, re->expr); CASE(AstExprBinary) return le->op == re->op && similar(le->left, re->left) && similar(le->right, re->right); CASE(AstExprTypeAssertion) return le->expr == re->expr; // the type doesn't affect execution semantics, avoids having to process type nodes CASE(AstExprError) return false; CASE(AstExprCall) { if (le->args.size != re->args.size || le->self != re->self) return false; if (!similar(le->func, re->func)) return false; for (size_t i = 0; i < le->args.size; ++i) if (!similar(le->args.data[i], re->args.data[i])) return false; return true; } CASE(AstExprTable) { if (le->items.size != re->items.size) return false; for (size_t i = 0; i < le->items.size; ++i) { const AstExprTable::Item& li = le->items.data[i]; const AstExprTable::Item& ri = re->items.data[i]; if (li.kind != ri.kind) return false; if (bool(li.key) != bool(ri.key)) return false; else if (li.key && !similar(li.key, ri.key)) return false; if (!similar(li.value, ri.value)) return false; } return true; } CASE(AstExprIfElse) return similar(le->condition, re->condition) && similar(le->trueExpr, re->trueExpr) && similar(le->falseExpr, re->falseExpr); else { LUAU_ASSERT(!"Unknown expression type"); return false; } #undef CASE } class LintGlobalLocal : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintGlobalLocal pass; pass.context = &context; for (auto& global : context.builtinGlobals) { Global& g = pass.globals[global.first]; g.builtin = true; g.deprecated = global.second.deprecated; } context.root->visit(&pass); pass.report(); } private: struct FunctionInfo { explicit FunctionInfo(AstExprFunction* ast) : ast(ast) , dominatedGlobals({}) , conditionalExecution(false) { } AstExprFunction* ast; DenseHashSet dominatedGlobals; bool conditionalExecution; }; struct Global { AstExprGlobal* firstRef = nullptr; std::vector functionRef; bool assigned = false; bool builtin = false; bool definedInModuleScope = false; bool definedAsFunction = false; bool readBeforeWritten = false; std::optional deprecated; }; LintContext* context; DenseHashMap globals; std::vector globalRefs; std::vector functionStack; LintGlobalLocal() : globals(AstName()) { } void report() { for (size_t i = 0; i < globalRefs.size(); ++i) { AstExprGlobal* gv = globalRefs[i]; Global* g = globals.find(gv->name); if (!g || (!g->assigned && !g->builtin)) emitWarning(*context, LintWarning::Code_UnknownGlobal, gv->location, "Unknown global '%s'", gv->name.value); else if (g->deprecated) { if (*g->deprecated) emitWarning(*context, LintWarning::Code_DeprecatedGlobal, gv->location, "Global '%s' is deprecated, use '%s' instead", gv->name.value, *g->deprecated); else emitWarning(*context, LintWarning::Code_DeprecatedGlobal, gv->location, "Global '%s' is deprecated", gv->name.value); } } for (auto& global : globals) { const Global& g = global.second; if (g.functionRef.size() && g.assigned && g.firstRef->name != context->placeholder) { AstExprFunction* top = g.functionRef.back(); if (top->debugname.value) emitWarning(*context, LintWarning::Code_GlobalUsedAsLocal, g.firstRef->location, "Global '%s' is only used in the enclosing function '%s'; consider changing it to local", g.firstRef->name.value, top->debugname.value); else emitWarning(*context, LintWarning::Code_GlobalUsedAsLocal, g.firstRef->location, "Global '%s' is only used in the enclosing function defined at line %d; consider changing it to local", g.firstRef->name.value, top->location.begin.line + 1); } else if (FFlag::LuauLintGlobalNeverReadBeforeWritten && g.assigned && !g.readBeforeWritten && !g.definedInModuleScope && g.firstRef->name != context->placeholder) { emitWarning(*context, LintWarning::Code_GlobalUsedAsLocal, g.firstRef->location, "Global '%s' is never read before being written. Consider changing it to local", g.firstRef->name.value); } } } bool visit(AstExprFunction* node) override { functionStack.emplace_back(node); node->body->visit(this); functionStack.pop_back(); return false; } bool visit(AstExprGlobal* node) override { if (FFlag::LuauLintGlobalNeverReadBeforeWritten && !functionStack.empty() && !functionStack.back().dominatedGlobals.contains(node->name)) { Global& g = globals[node->name]; g.readBeforeWritten = true; } trackGlobalRef(node); if (node->name == context->placeholder) emitWarning( *context, LintWarning::Code_PlaceholderRead, node->location, "Placeholder value '_' is read here; consider using a named variable"); return true; } bool visit(AstExprLocal* node) override { if (node->local->name == context->placeholder) emitWarning( *context, LintWarning::Code_PlaceholderRead, node->location, "Placeholder value '_' is read here; consider using a named variable"); return true; } bool visit(AstStatAssign* node) override { for (size_t i = 0; i < node->vars.size; ++i) { AstExpr* var = node->vars.data[i]; if (AstExprGlobal* gv = var->as()) { Global& g = globals[gv->name]; if (FFlag::LuauLintGlobalNeverReadBeforeWritten) { if (functionStack.empty()) { g.definedInModuleScope = true; } else { if (!functionStack.back().conditionalExecution) { functionStack.back().dominatedGlobals.insert(gv->name); } } } if (g.builtin) emitWarning(*context, LintWarning::Code_BuiltinGlobalWrite, gv->location, "Built-in global '%s' is overwritten here; consider using a local or changing the name", gv->name.value); else g.assigned = true; trackGlobalRef(gv); } else if (var->is()) { // We don't visit locals here because it's a local *write*, and visit(AstExprLocal*) assumes it's a local *read* } else { var->visit(this); } } for (size_t i = 0; i < node->values.size; ++i) node->values.data[i]->visit(this); return false; } bool visit(AstStatFunction* node) override { if (AstExprGlobal* gv = node->name->as()) { Global& g = globals[gv->name]; if (g.builtin) emitWarning(*context, LintWarning::Code_BuiltinGlobalWrite, gv->location, "Built-in global '%s' is overwritten here; consider using a local or changing the name", gv->name.value); else { g.assigned = true; if (FFlag::LuauLintGlobalNeverReadBeforeWritten) { g.definedAsFunction = true; g.definedInModuleScope = functionStack.empty(); } } trackGlobalRef(gv); } return true; } class HoldConditionalExecution { public: HoldConditionalExecution(LintGlobalLocal& p) : p(p) { if (!p.functionStack.empty() && !p.functionStack.back().conditionalExecution) { resetToFalse = true; p.functionStack.back().conditionalExecution = true; } } ~HoldConditionalExecution() { if (resetToFalse) p.functionStack.back().conditionalExecution = false; } private: bool resetToFalse = false; LintGlobalLocal& p; }; bool visit(AstStatIf* node) override { if (!FFlag::LuauLintGlobalNeverReadBeforeWritten) return true; HoldConditionalExecution ce(*this); node->condition->visit(this); node->thenbody->visit(this); if (node->elsebody) node->elsebody->visit(this); return false; } bool visit(AstStatWhile* node) override { if (!FFlag::LuauLintGlobalNeverReadBeforeWritten) return true; HoldConditionalExecution ce(*this); node->condition->visit(this); node->body->visit(this); return false; } bool visit(AstStatRepeat* node) override { if (!FFlag::LuauLintGlobalNeverReadBeforeWritten) return true; HoldConditionalExecution ce(*this); node->condition->visit(this); node->body->visit(this); return false; } bool visit(AstStatFor* node) override { if (!FFlag::LuauLintGlobalNeverReadBeforeWritten) return true; HoldConditionalExecution ce(*this); node->from->visit(this); node->to->visit(this); if (node->step) node->step->visit(this); node->body->visit(this); return false; } bool visit(AstStatForIn* node) override { if (!FFlag::LuauLintGlobalNeverReadBeforeWritten) return true; HoldConditionalExecution ce(*this); for (AstExpr* expr : node->values) expr->visit(this); node->body->visit(this); return false; } void trackGlobalRef(AstExprGlobal* node) { Global& g = globals[node->name]; globalRefs.push_back(node); if (!g.firstRef) { g.firstRef = node; // to reduce the cost of tracking we only track this for user globals if (!g.builtin) { g.functionRef.clear(); g.functionRef.reserve(functionStack.size()); for (const FunctionInfo& entry : functionStack) { g.functionRef.push_back(entry.ast); } } } else { // to reduce the cost of tracking we only track this for user globals if (!g.builtin) { // we need to find a common prefix between all uses of a global size_t prefix = 0; while (prefix < g.functionRef.size() && prefix < functionStack.size() && g.functionRef[prefix] == functionStack[prefix].ast) prefix++; g.functionRef.resize(prefix); } } } }; class LintSameLineStatement : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintSameLineStatement pass; pass.context = &context; pass.lastLine = ~0u; context.root->visit(&pass); } private: LintContext* context; unsigned int lastLine; bool visit(AstStatBlock* node) override { for (size_t i = 1; i < node->body.size; ++i) { const Location& last = node->body.data[i - 1]->location; const Location& location = node->body.data[i]->location; if (location.begin.line != last.end.line) continue; // We warn once per line with multiple statements if (location.begin.line == lastLine) continue; // There's a common pattern where local variables are computed inside a do block that starts on the same line; we white-list this pattern if (node->body.data[i - 1]->is() && node->body.data[i]->is()) continue; // Another common pattern is using multiple statements on the same line with semi-colons on each of them. White-list this pattern too. if (node->body.data[i - 1]->hasSemicolon) continue; emitWarning(*context, LintWarning::Code_SameLineStatement, location, "A new statement is on the same line; add semi-colon on previous statement to silence"); lastLine = location.begin.line; } return true; } }; class LintMultiLineStatement : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintMultiLineStatement pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; struct Statement { Location start; unsigned int lastLine; bool flagged; }; std::vector stack; bool visit(AstExpr* node) override { Statement& top = stack.back(); if (!top.flagged) { Location location = node->location; if (location.begin.line > top.lastLine) { top.lastLine = location.begin.line; if (location.begin.column <= top.start.begin.column) { emitWarning( *context, LintWarning::Code_MultiLineStatement, location, "Statement spans multiple lines; use indentation to silence"); top.flagged = true; } } } return true; } bool visit(AstExprTable* node) override { (void)node; return false; } bool visit(AstStatRepeat* node) override { node->body->visit(this); return false; } bool visit(AstStatBlock* node) override { for (size_t i = 0; i < node->body.size; ++i) { AstStat* stmt = node->body.data[i]; Statement s = {stmt->location, stmt->location.begin.line, false}; stack.push_back(s); stmt->visit(this); stack.pop_back(); } return false; } }; class LintLocalHygiene : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintLocalHygiene pass; pass.context = &context; for (auto& global : context.builtinGlobals) pass.globals[global.first].builtin = true; context.root->visit(&pass); pass.report(); } private: LintContext* context; struct Local { AstNode* defined = nullptr; bool function; bool import; bool used; bool arg; }; struct Global { bool used; bool builtin; AstExprGlobal* firstRef; }; DenseHashMap locals; DenseHashMap imports; DenseHashMap globals; LintLocalHygiene() : locals(NULL) , imports(AstName()) , globals(AstName()) { } void report() { for (auto& l : locals) { if (l.second.used) reportUsedLocal(l.first, l.second); else if (l.second.defined) reportUnusedLocal(l.first, l.second); } } void reportUsedLocal(AstLocal* local, const Local& info) { if (AstLocal* shadow = local->shadow) { // LintDuplicateFunctions will catch this. Local* shadowLocal = locals.find(shadow); if (context->options.isEnabled(LintWarning::Code_DuplicateFunction) && info.function && shadowLocal && shadowLocal->function) return; // LintDuplicateLocal will catch this. if (context->options.isEnabled(LintWarning::Code_DuplicateLocal) && shadowLocal && shadowLocal->defined == info.defined) return; // don't warn on inter-function shadowing since it is much more fragile wrt refactoring if (shadow->functionDepth == local->functionDepth) emitWarning(*context, LintWarning::Code_LocalShadow, local->location, "Variable '%s' shadows previous declaration at line %d", local->name.value, shadow->location.begin.line + 1); } else if (Global* global = globals.find(local->name)) { if (global->builtin) ; // there are many builtins with common names like 'table'; some of them are deprecated as well else if (global->firstRef) { emitWarning(*context, LintWarning::Code_LocalShadow, local->location, "Variable '%s' shadows a global variable used at line %d", local->name.value, global->firstRef->location.begin.line + 1); } else { emitWarning(*context, LintWarning::Code_LocalShadow, local->location, "Variable '%s' shadows a global variable", local->name.value); } } } void reportUnusedLocal(AstLocal* local, const Local& info) { if (local->name.value[0] == '_') return; if (info.function) emitWarning(*context, LintWarning::Code_FunctionUnused, local->location, "Function '%s' is never used; prefix with '_' to silence", local->name.value); else if (info.import) emitWarning(*context, LintWarning::Code_ImportUnused, local->location, "Import '%s' is never used; prefix with '_' to silence", local->name.value); else emitWarning(*context, LintWarning::Code_LocalUnused, local->location, "Variable '%s' is never used; prefix with '_' to silence", local->name.value); } bool isRequireCall(AstExpr* expr) { AstExprCall* call = expr->as(); if (!call) return false; AstExprGlobal* glob = call->func->as(); if (!glob) return false; return glob->name == "require"; } bool visit(AstStatAssign* node) override { for (AstExpr* var : node->vars) { // We don't visit locals here because it's a local *write*, and visit(AstExprLocal*) assumes it's a local *read* if (!var->is()) var->visit(this); } for (AstExpr* value : node->values) value->visit(this); return false; } bool visit(AstStatLocal* node) override { if (node->vars.size == 1 && node->values.size == 1) { Local& l = locals[node->vars.data[0]]; l.defined = node; l.import = isRequireCall(node->values.data[0]); if (l.import) imports[node->vars.data[0]->name] = node->vars.data[0]; } else { for (size_t i = 0; i < node->vars.size; ++i) { Local& l = locals[node->vars.data[i]]; l.defined = node; } } return true; } bool visit(AstStatLocalFunction* node) override { Local& l = locals[node->name]; l.defined = node; l.function = true; return true; } bool visit(AstExprLocal* node) override { Local& l = locals[node->local]; l.used = true; return true; } bool visit(AstExprGlobal* node) override { Global& global = globals[node->name]; global.used = true; if (!global.firstRef) global.firstRef = node; return true; } bool visit(AstType* node) override { return true; } bool visit(AstTypeReference* node) override { if (!node->prefix) return true; if (!imports.contains(*node->prefix)) return true; AstLocal* astLocal = imports[*node->prefix]; Local& local = locals[astLocal]; LUAU_ASSERT(local.import); local.used = true; return true; } bool visit(AstExprFunction* node) override { if (node->self) locals[node->self].arg = true; for (size_t i = 0; i < node->args.size; ++i) locals[node->args.data[i]].arg = true; return true; } }; class LintUnusedFunction : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintUnusedFunction pass; pass.context = &context; context.root->visit(&pass); pass.report(); } private: LintContext* context; struct Global { Location location; bool function; bool used; }; DenseHashMap globals; LintUnusedFunction() : globals(AstName()) { } void report() { for (auto& g : globals) { if (g.second.function && !g.second.used && g.first.value[0] != '_') emitWarning(*context, LintWarning::Code_FunctionUnused, g.second.location, "Function '%s' is never used; prefix with '_' to silence", g.first.value); } } bool visit(AstStatFunction* node) override { if (AstExprGlobal* expr = node->name->as()) { Global& g = globals[expr->name]; g.function = true; g.location = expr->location; node->func->visit(this); return false; } return true; } bool visit(AstExprGlobal* node) override { Global& g = globals[node->name]; g.used = true; return true; } }; class LintUnreachableCode : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintUnreachableCode pass; pass.context = &context; pass.analyze(context.root); context.root->visit(&pass); } private: LintContext* context; // Note: this enum is order-sensitive! // The order is in the "severity" of the termination and affects merging of status codes from different branches // For example, if one branch breaks and one returns, the merged result is "break" enum Status { Unknown, Continue, Break, Return, Error, }; const char* getReason(Status status) { switch (status) { case Continue: return "continue"; case Break: return "break"; case Return: return "return"; case Error: return "error"; default: return "unknown"; } } Status analyze(AstStat* node) { if (AstStatBlock* stat = node->as()) { for (size_t i = 0; i < stat->body.size; ++i) { AstStat* si = stat->body.data[i]; Status step = analyze(si); if (step != Unknown) { if (i + 1 == stat->body.size) return step; AstStat* next = stat->body.data[i + 1]; // silence the warning for common pattern of Error (coming from error()) + Return if (step == Error && si->is() && next->is() && i + 2 == stat->body.size) return Error; emitWarning(*context, LintWarning::Code_UnreachableCode, next->location, "Unreachable code (previous statement always %ss)", getReason(step)); return step; } } return Unknown; } else if (AstStatIf* stat = node->as()) { Status ifs = analyze(stat->thenbody); Status elses = stat->elsebody ? analyze(stat->elsebody) : Unknown; return std::min(ifs, elses); } else if (AstStatWhile* stat = node->as()) { analyze(stat->body); return Unknown; } else if (AstStatRepeat* stat = node->as()) { analyze(stat->body); return Unknown; } else if (node->is()) { return Break; } else if (node->is()) { return Continue; } else if (node->is()) { return Return; } else if (AstStatExpr* stat = node->as()) { if (AstExprCall* call = stat->expr->as()) if (doesCallError(call)) return Error; return Unknown; } else if (AstStatFor* stat = node->as()) { analyze(stat->body); return Unknown; } else if (AstStatForIn* stat = node->as()) { analyze(stat->body); return Unknown; } else { return Unknown; } } bool visit(AstExprFunction* node) override { analyze(node->body); return true; } }; class LintUnknownType : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintUnknownType pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; enum TypeKind { Kind_Unknown, Kind_Primitive, // primitive type supported by VM - boolean/userdata/etc. No differentiation between types of userdata. Kind_Vector, // 'vector' but only used when type is used Kind_Userdata, // custom userdata type // TODO: remove these with LuauLintNoRobloxBits Kind_Class, // custom userdata type that reflects Roblox Instance-derived hierarchy - Part/etc. Kind_Enum, // custom userdata type referring to an enum item of enum classes, e.g. Enum.NormalId.Back/Enum.Axis.X/etc. }; bool containsPropName(TypeId ty, const std::string& propName) { LUAU_ASSERT(!FFlag::LuauLintNoRobloxBits); if (auto ctv = get(ty)) return lookupClassProp(ctv, propName) != nullptr; if (auto ttv = get(ty)) return ttv->props.find(propName) != ttv->props.end(); return false; } TypeKind getTypeKind(const std::string& name) { if (name == "nil" || name == "boolean" || name == "userdata" || name == "number" || name == "string" || name == "table" || name == "function" || name == "thread") return Kind_Primitive; if (name == "vector") return Kind_Vector; if (FFlag::LuauLintNoRobloxBits) { if (std::optional maybeTy = context->scope->lookupType(name)) return Kind_Userdata; return Kind_Unknown; } else { if (std::optional maybeTy = context->scope->lookupType(name)) // Kind_Userdata is probably not 100% precise but is close enough return containsPropName(maybeTy->type, "ClassName") ? Kind_Class : Kind_Userdata; else if (std::optional maybeTy = context->scope->lookupImportedType("Enum", name)) return Kind_Enum; return Kind_Unknown; } } void validateType(AstExprConstantString* expr, std::initializer_list expected, const char* expectedString) { std::string name(expr->value.data, expr->value.size); TypeKind kind = getTypeKind(name); if (kind == Kind_Unknown) { emitWarning(*context, LintWarning::Code_UnknownType, expr->location, "Unknown type '%s'", name.c_str()); return; } for (TypeKind ek : expected) { if (kind == ek) return; // as a special case, Instance and EnumItem are both a userdata type (as returned by typeof) and a class type if (!FFlag::LuauLintNoRobloxBits && ek == Kind_Userdata && (name == "Instance" || name == "EnumItem")) return; } emitWarning(*context, LintWarning::Code_UnknownType, expr->location, "Unknown type '%s' (expected %s)", name.c_str(), expectedString); } bool acceptsClassName(AstName method) { LUAU_ASSERT(!FFlag::LuauLintNoRobloxBits); return method.value[0] == 'F' && (method == "FindFirstChildOfClass" || method == "FindFirstChildWhichIsA" || method == "FindFirstAncestorOfClass" || method == "FindFirstAncestorWhichIsA"); } bool visit(AstExprCall* node) override { // TODO: Simply remove the override if (FFlag::LuauLintNoRobloxBits) return true; if (AstExprIndexName* index = node->func->as()) { AstExprConstantString* arg0 = node->args.size > 0 ? node->args.data[0]->as() : NULL; if (arg0) { if (node->self && index->index == "IsA" && node->args.size == 1) { validateType(arg0, {Kind_Class, Kind_Enum}, "class or enum type"); } else if (node->self && (index->index == "GetService" || index->index == "FindService") && node->args.size == 1) { AstExprGlobal* g = index->expr->as(); if (g && (g->name == "game" || g->name == "Game")) { validateType(arg0, {Kind_Class}, "class type"); } } else if (node->self && acceptsClassName(index->index) && node->args.size == 1) { validateType(arg0, {Kind_Class}, "class type"); } else if (!node->self && index->index == "new" && node->args.size <= 2) { AstExprGlobal* g = index->expr->as(); if (g && g->name == "Instance") { validateType(arg0, {Kind_Class}, "class type"); } } } } return true; } bool visit(AstExprBinary* node) override { if (node->op == AstExprBinary::CompareNe || node->op == AstExprBinary::CompareEq) { AstExpr* lhs = node->left; AstExpr* rhs = node->right; if (!rhs->is()) std::swap(lhs, rhs); AstExprCall* call = lhs->as(); AstExprConstantString* arg = rhs->as(); if (call && arg) { AstExprGlobal* g = call->func->as(); if (g && g->name == "type") { validateType(arg, {Kind_Primitive, Kind_Vector}, "primitive type"); } else if (g && g->name == "typeof") { validateType(arg, {Kind_Primitive, Kind_Userdata}, "primitive or userdata type"); } } } return true; } }; class LintForRange : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintForRange pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; double getLoopEnd(double from, double to) { return from + floor(to - from); } bool visit(AstStatFor* node) override { // note: we silence all warnings below if *any* step is specified, assuming that the user knows best if (!node->step) { AstExprConstantNumber* fc = node->from->as(); AstExprUnary* fu = node->from->as(); AstExprConstantNumber* tc = node->to->as(); AstExprUnary* tu = node->to->as(); Location rangeLocation(node->from->location, node->to->location); // for i=#t,1 do if (fu && fu->op == AstExprUnary::Len && tc && tc->value == 1.0) emitWarning( *context, LintWarning::Code_ForRange, rangeLocation, "For loop should iterate backwards; did you forget to specify -1 as step?"); // for i=8,1 do else if (fc && tc && fc->value > tc->value) emitWarning( *context, LintWarning::Code_ForRange, rangeLocation, "For loop should iterate backwards; did you forget to specify -1 as step?"); // for i=1,8.75 do else if (fc && tc && getLoopEnd(fc->value, tc->value) != tc->value) emitWarning(*context, LintWarning::Code_ForRange, rangeLocation, "For loop ends at %g instead of %g; did you forget to specify step?", getLoopEnd(fc->value, tc->value), tc->value); // for i=0,#t do else if (fc && tu && fc->value == 0.0 && tu->op == AstExprUnary::Len) emitWarning(*context, LintWarning::Code_ForRange, rangeLocation, "For loop starts at 0, but arrays start at 1"); // for i=#t,0 do else if (fu && fu->op == AstExprUnary::Len && tc && tc->value == 0.0) emitWarning(*context, LintWarning::Code_ForRange, rangeLocation, "For loop should iterate backwards; did you forget to specify -1 as step? Also consider changing 0 to 1 since arrays start at 1"); } return true; } }; class LintUnbalancedAssignment : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintUnbalancedAssignment pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; void assign(size_t vars, const AstArray& values, const Location& location) { if (vars != values.size && values.size > 0) { AstExpr* last = values.data[values.size - 1]; if (vars < values.size) emitWarning(*context, LintWarning::Code_UnbalancedAssignment, location, "Assigning %d values to %d variables leaves some values unused", int(values.size), int(vars)); else if (last->is() || last->is()) ; // we don't know how many values the last expression returns else if (last->is()) ; // last expression is nil which explicitly silences the nil-init warning else emitWarning(*context, LintWarning::Code_UnbalancedAssignment, location, "Assigning %d values to %d variables initializes extra variables with nil; add 'nil' to value list to silence", int(values.size), int(vars)); } } bool visit(AstStatLocal* node) override { assign(node->vars.size, node->values, node->location); return true; } bool visit(AstStatAssign* node) override { assign(node->vars.size, node->values, node->location); return true; } }; class LintImplicitReturn : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintImplicitReturn pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; Location getEndLocation(const AstStat* node) { Location loc = node->location; if (node->is() || node->is() || node->is()) return loc; if (loc.begin.line == loc.end.line) return loc; // assume that we're in context of a statement that has an "end" block return Location(Position(loc.end.line, std::max(0, int(loc.end.column) - 3)), loc.end); } AstStatReturn* getValueReturn(AstStat* node) { struct Visitor : AstVisitor { AstStatReturn* result = nullptr; bool visit(AstExpr* node) override { (void)node; return false; } bool visit(AstStatReturn* node) override { if (!result && node->list.size > 0) result = node; return false; } }; Visitor visitor; node->visit(&visitor); return visitor.result; } bool visit(AstExprFunction* node) override { const AstStat* bodyf = getFallthrough(node->body); AstStat* vret = getValueReturn(node->body); if (bodyf && vret) { Location location = getEndLocation(bodyf); if (node->debugname.value) emitWarning(*context, LintWarning::Code_ImplicitReturn, location, "Function '%s' can implicitly return no values even though there's an explicit return at line %d; add explicit return to silence", node->debugname.value, vret->location.begin.line + 1); else emitWarning(*context, LintWarning::Code_ImplicitReturn, location, "Function can implicitly return no values even though there's an explicit return at line %d; add explicit return to silence", vret->location.begin.line + 1); } return true; } }; class LintFormatString : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintFormatString pass; pass.context = &context; context.root->visit(&pass); } static void fuzz(const char* data, size_t size) { LintContext context; LintFormatString pass; pass.context = &context; pass.checkStringFormat(data, size); pass.checkStringPack(data, size, false); pass.checkStringMatch(data, size); pass.checkStringReplace(data, size, -1); pass.checkDateFormat(data, size); } private: LintContext* context; static inline bool isAlpha(char ch) { // use or trick to convert to lower case and unsigned comparison to do range check return unsigned((ch | ' ') - 'a') < 26; } static inline bool isDigit(char ch) { // use unsigned comparison to do range check for performance return unsigned(ch - '0') < 10; } const char* checkStringFormat(const char* data, size_t size) { const char* flags = "-+ #0"; const char* options = "cdiouxXeEfgGqs"; for (size_t i = 0; i < size; ++i) { if (data[i] == '%') { i++; // escaped % doesn't allow for flags/etc. if (i < size && data[i] == '%') continue; // skip flags while (i < size && strchr(flags, data[i])) i++; // skip width (up to two digits) if (i < size && isDigit(data[i])) i++; if (i < size && isDigit(data[i])) i++; // skip precision if (i < size && data[i] == '.') { i++; // up to two digits if (i < size && isDigit(data[i])) i++; if (i < size && isDigit(data[i])) i++; } if (i == size) return "unfinished format specifier"; if (!strchr(options, data[i])) return "invalid format specifier: must be a string format specifier or %"; } } return nullptr; } const char* checkStringPack(const char* data, size_t size, bool fixed) { const char* options = "<>=!bBhHlLjJTiIfdnczsxX "; const char* unsized = "<>=!zX "; for (size_t i = 0; i < size; ++i) { if (!strchr(options, data[i])) return "unexpected character; must be a pack specifier or space"; if (data[i] == 'c' && (i + 1 == size || !isDigit(data[i + 1]))) return "fixed-sized string format must specify the size"; if (data[i] == 'X' && (i + 1 == size || strchr(unsized, data[i + 1]))) return "X must be followed by a size specifier"; if (fixed && (data[i] == 'z' || data[i] == 's')) return "pack specifier must be fixed-size"; if ((data[i] == '!' || data[i] == 'i' || data[i] == 'I' || data[i] == 'c' || data[i] == 's') && i + 1 < size && isDigit(data[i + 1])) { bool isc = data[i] == 'c'; unsigned int v = 0; while (i + 1 < size && isDigit(data[i + 1]) && v <= (INT_MAX - 9) / 10) { v = v * 10 + (data[i + 1] - '0'); i++; } if (i + 1 < size && isDigit(data[i + 1])) return "size specifier is too large"; if (!isc && (v == 0 || v > 16)) return "integer size must be in range [1,16]"; } } return nullptr; } const char* checkStringMatchSet(const char* data, size_t size, const char* magic, const char* classes) { for (size_t i = 0; i < size; ++i) { if (data[i] == '%') { i++; if (i == size) return "unfinished character class"; if (isDigit(data[i])) { return "sets can not contain capture references"; } else if (isAlpha(data[i])) { // lower case lookup - upper case for every character class is defined as its inverse if (!strchr(classes, data[i] | ' ')) return "invalid character class, must refer to a defined class or its inverse"; } else { // technically % can escape any non-alphanumeric character but this is error-prone if (!strchr(magic, data[i])) return "expected a magic character after %"; } if (i + 1 < size && data[i + 1] == '-') return "character range can't include character sets"; } else if (data[i] == '-') { if (i + 1 < size && data[i + 1] == '%') return "character range can't include character sets"; } } return nullptr; } const char* checkStringMatch(const char* data, size_t size, int* outCaptures = nullptr) { const char* magic = "^$()%.[]*+-?)"; const char* classes = "acdglpsuwxz"; std::vector openCaptures; int totalCaptures = 0; for (size_t i = 0; i < size; ++i) { if (data[i] == '%') { i++; if (i == size) return "unfinished character class"; if (isDigit(data[i])) { if (data[i] == '0') return "invalid capture reference, must be 1-9"; int captureIndex = data[i] - '0'; if (captureIndex > totalCaptures) return "invalid capture reference, must refer to a valid capture"; for (int open : openCaptures) if (open == captureIndex) return "invalid capture reference, must refer to a closed capture"; } else if (isAlpha(data[i])) { if (data[i] == 'b') { if (i + 2 >= size) return "missing brace characters for balanced match"; i += 2; } else if (data[i] == 'f') { if (i + 1 >= size || data[i + 1] != '[') return "missing set after a frontier pattern"; // we can parse the set with the regular logic } else { // lower case lookup - upper case for every character class is defined as its inverse if (!strchr(classes, data[i] | ' ')) return "invalid character class, must refer to a defined class or its inverse"; } } else { // technically % can escape any non-alphanumeric character but this is error-prone if (!strchr(magic, data[i])) return "expected a magic character after %"; } } else if (data[i] == '[') { size_t j = i + 1; // empty patterns don't exist as per grammar rules, so we skip leading ^ and ] if (j < size && data[j] == '^') j++; if (j < size && data[j] == ']') j++; // scan for the end of the pattern while (j < size && data[j] != ']') { // % escapes the next character if (j + 1 < size && data[j] == '%') j++; j++; } if (j == size) return "expected ] at the end of the string to close a set"; if (const char* error = checkStringMatchSet(data + i + 1, j - i - 1, magic, classes)) return error; LUAU_ASSERT(data[j] == ']'); i = j; } else if (data[i] == '(') { totalCaptures++; openCaptures.push_back(totalCaptures); } else if (data[i] == ')') { if (openCaptures.empty()) return "unexpected ) without a matching ("; openCaptures.pop_back(); } } if (!openCaptures.empty()) return "expected ) at the end of the string to close a capture"; if (outCaptures) *outCaptures = totalCaptures; return nullptr; } const char* checkStringReplace(const char* data, size_t size, int captures) { for (size_t i = 0; i < size; ++i) { if (data[i] == '%') { i++; if (i == size) return "unfinished replacement"; if (data[i] != '%' && !isDigit(data[i])) return "unexpected replacement character; must be a digit or %"; if (isDigit(data[i]) && captures >= 0 && data[i] - '0' > captures) return "invalid capture index, must refer to pattern capture"; } } return nullptr; } const char* checkDateFormat(const char* data, size_t size) { const char* options = "aAbBcdHIjmMpSUwWxXyYzZ"; for (size_t i = 0; i < size; ++i) { if (data[i] == '%') { i++; if (i == size) return "unfinished replacement"; if (data[i] != '%' && !strchr(options, data[i])) return "unexpected replacement character; must be a date format specifier or %"; } if (data[i] == 0) return "date format can not contain null characters"; } return nullptr; } void matchStringCall(AstName name, AstExpr* self, AstArray args) { if (name == "format") { if (AstExprConstantString* fmt = self->as()) if (const char* error = checkStringFormat(fmt->value.data, fmt->value.size)) emitWarning(*context, LintWarning::Code_FormatString, fmt->location, "Invalid format string: %s", error); } else if (name == "pack" || name == "packsize" || name == "unpack") { if (AstExprConstantString* fmt = self->as()) if (const char* error = checkStringPack(fmt->value.data, fmt->value.size, name == "packsize")) emitWarning(*context, LintWarning::Code_FormatString, fmt->location, "Invalid pack format: %s", error); } else if ((name == "match" || name == "gmatch") && args.size > 0) { if (AstExprConstantString* pat = args.data[0]->as()) if (const char* error = checkStringMatch(pat->value.data, pat->value.size)) emitWarning(*context, LintWarning::Code_FormatString, pat->location, "Invalid match pattern: %s", error); } else if (name == "find" && args.size > 0 && args.size <= 2) { if (AstExprConstantString* pat = args.data[0]->as()) if (const char* error = checkStringMatch(pat->value.data, pat->value.size)) emitWarning(*context, LintWarning::Code_FormatString, pat->location, "Invalid match pattern: %s", error); } else if (name == "find" && args.size >= 3) { AstExprConstantBool* mode = args.data[2]->as(); // find(_, _, _, true) is a raw string find, not a pattern match if (mode && !mode->value) if (AstExprConstantString* pat = args.data[0]->as()) if (const char* error = checkStringMatch(pat->value.data, pat->value.size)) emitWarning(*context, LintWarning::Code_FormatString, pat->location, "Invalid match pattern: %s", error); } else if (name == "gsub" && args.size > 1) { int captures = -1; if (AstExprConstantString* pat = args.data[0]->as()) if (const char* error = checkStringMatch(pat->value.data, pat->value.size, &captures)) emitWarning(*context, LintWarning::Code_FormatString, pat->location, "Invalid match pattern: %s", error); if (AstExprConstantString* rep = args.data[1]->as()) if (const char* error = checkStringReplace(rep->value.data, rep->value.size, captures)) emitWarning(*context, LintWarning::Code_FormatString, rep->location, "Invalid match replacement: %s", error); } } void matchCall(AstExprCall* node) { AstExprIndexName* func = node->func->as(); if (!func) return; if (node->self) { AstExprGroup* group = func->expr->as(); AstExpr* self = group ? group->expr : func->expr; if (self->is()) matchStringCall(func->index, self, node->args); else if (std::optional type = context->getType(self)) if (isString(*type)) matchStringCall(func->index, self, node->args); return; } AstExprGlobal* lib = func->expr->as(); if (!lib) return; if (lib->name == "string") { if (node->args.size > 0) { AstArray rest = {node->args.data + 1, node->args.size - 1}; matchStringCall(func->index, node->args.data[0], rest); } } else if (lib->name == "os") { if (func->index == "date" && node->args.size > 0) { if (AstExprConstantString* fmt = node->args.data[0]->as()) if (const char* error = checkDateFormat(fmt->value.data, fmt->value.size)) emitWarning(*context, LintWarning::Code_FormatString, fmt->location, "Invalid date format: %s", error); } } } bool visit(AstExprCall* node) override { matchCall(node); return true; } }; class LintTableLiteral : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintTableLiteral pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; bool visit(AstExprTable* node) override { int count = 0; for (const AstExprTable::Item& item : node->items) if (item.kind == AstExprTable::Item::List) count++; DenseHashMap*, int, AstArrayPredicate, AstArrayPredicate> names(nullptr); DenseHashMap indices(-1); for (const AstExprTable::Item& item : node->items) { if (!item.key) continue; if (AstExprConstantString* expr = item.key->as()) { int& line = names[&expr->value]; if (line) emitWarning(*context, LintWarning::Code_TableLiteral, expr->location, "Table field '%.*s' is a duplicate; previously defined at line %d", int(expr->value.size), expr->value.data, line); else line = expr->location.begin.line + 1; } else if (AstExprConstantNumber* expr = item.key->as()) { if (expr->value >= 1 && expr->value <= double(count) && double(int(expr->value)) == expr->value) emitWarning(*context, LintWarning::Code_TableLiteral, expr->location, "Table index %d is a duplicate; previously defined as a list entry", int(expr->value)); else if (expr->value >= 0 && expr->value <= double(INT_MAX) && double(int(expr->value)) == expr->value) { int& line = indices[int(expr->value)]; if (line) emitWarning(*context, LintWarning::Code_TableLiteral, expr->location, "Table index %d is a duplicate; previously defined at line %d", int(expr->value), line); else line = expr->location.begin.line + 1; } } } return true; } bool visit(AstType* node) override { return true; } bool visit(AstTypeTable* node) override { DenseHashMap names(AstName{}); for (const AstTableProp& item : node->props) { int& line = names[item.name]; if (line) emitWarning(*context, LintWarning::Code_TableLiteral, item.location, "Table type field '%s' is a duplicate; previously defined at line %d", item.name.value, line); else line = item.location.begin.line + 1; } return true; } struct AstArrayPredicate { size_t operator()(const AstArray* value) const { return hashRange(value->data, value->size); } bool operator()(const AstArray* lhs, const AstArray* rhs) const { return (lhs && rhs) ? lhs->size == rhs->size && memcmp(lhs->data, rhs->data, lhs->size) == 0 : lhs == rhs; } }; }; class LintUninitializedLocal : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintUninitializedLocal pass; pass.context = &context; context.root->visit(&pass); pass.report(); } private: struct Local { bool defined; bool initialized; bool assigned; AstExprLocal* firstUse; }; LintContext* context; DenseHashMap locals; LintUninitializedLocal() : locals(NULL) { } void report() { for (auto& lp : locals) { AstLocal* local = lp.first; const Local& l = lp.second; if (l.defined && !l.initialized && !l.assigned && l.firstUse) { emitWarning(*context, LintWarning::Code_UninitializedLocal, l.firstUse->location, "Variable '%s' defined at line %d is never initialized or assigned; initialize with 'nil' to silence", local->name.value, local->location.begin.line + 1); } } } bool visit(AstStatLocal* node) override { AstExpr* last = node->values.size ? node->values.data[node->values.size - 1] : nullptr; bool vararg = last && (last->is() || last->is()); for (size_t i = 0; i < node->vars.size; ++i) { Local& l = locals[node->vars.data[i]]; l.defined = true; l.initialized = vararg || i < node->values.size; } return true; } bool visit(AstStatAssign* node) override { for (size_t i = 0; i < node->vars.size; ++i) visitAssign(node->vars.data[i]); for (size_t i = 0; i < node->values.size; ++i) node->values.data[i]->visit(this); return false; } bool visit(AstStatFunction* node) override { visitAssign(node->name); node->func->visit(this); return false; } bool visit(AstExprLocal* node) override { Local& l = locals[node->local]; if (!l.firstUse) l.firstUse = node; return false; } void visitAssign(AstExpr* var) { if (AstExprLocal* lv = var->as()) { Local& l = locals[lv->local]; l.assigned = true; } else { var->visit(this); } } }; class LintDuplicateFunction : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintDuplicateFunction pass{&context}; context.root->visit(&pass); } private: LintContext* context; DenseHashMap defns; LintDuplicateFunction(LintContext* context) : context(context) , defns("") { } bool visit(AstStatBlock* block) override { defns.clear(); for (AstStat* stat : block->body) { if (AstStatFunction* func = stat->as()) trackFunction(func->name->location, buildName(func->name)); else if (AstStatLocalFunction* func = stat->as()) trackFunction(func->name->location, func->name->name.value); } return true; } void trackFunction(Location location, const std::string& name) { if (name.empty()) return; Location& defn = defns[name]; if (defn.end.line == 0 && defn.end.column == 0) defn = location; else report(name, location, defn); } std::string buildName(AstExpr* expr) { if (AstExprLocal* local = expr->as()) return local->local->name.value; else if (AstExprGlobal* global = expr->as()) return global->name.value; else if (AstExprIndexName* indexName = expr->as()) { std::string lhs = buildName(indexName->expr); if (lhs.empty()) return lhs; lhs += '.'; lhs += indexName->index.value; return lhs; } else return std::string(); } void report(const std::string& name, Location location, Location otherLocation) { emitWarning(*context, LintWarning::Code_DuplicateFunction, location, "Duplicate function definition: '%s' also defined on line %d", name.c_str(), otherLocation.begin.line + 1); } }; class LintDeprecatedApi : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { if (!context.module) return; LintDeprecatedApi pass{&context}; context.root->visit(&pass); } private: LintContext* context; LintDeprecatedApi(LintContext* context) : context(context) { } bool visit(AstExprIndexName* node) override { std::optional ty = context->getType(node->expr); if (!ty) return true; if (const ClassTypeVar* cty = get(follow(*ty))) { const Property* prop = lookupClassProp(cty, node->index.value); if (prop && prop->deprecated) report(node->location, *prop, cty->name.c_str(), node->index.value); } else if (const TableTypeVar* tty = get(follow(*ty))) { auto prop = tty->props.find(node->index.value); if (prop != tty->props.end() && prop->second.deprecated) report(node->location, prop->second, tty->name ? tty->name->c_str() : nullptr, node->index.value); } return true; } void report(const Location& location, const Property& prop, const char* container, const char* field) { std::string suggestion = prop.deprecatedSuggestion.empty() ? "" : format(", use '%s' instead", prop.deprecatedSuggestion.c_str()); if (container) emitWarning(*context, LintWarning::Code_DeprecatedApi, location, "Member '%s.%s' is deprecated%s", container, field, suggestion.c_str()); else emitWarning(*context, LintWarning::Code_DeprecatedApi, location, "Member '%s' is deprecated%s", field, suggestion.c_str()); } }; class LintTableOperations : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { if (!context.module) return; LintTableOperations pass{&context}; context.root->visit(&pass); } private: LintContext* context; LintTableOperations(LintContext* context) : context(context) { } bool visit(AstExprCall* node) override { AstExprIndexName* func = node->func->as(); if (!func) return true; AstExprGlobal* tablib = func->expr->as(); if (!tablib || tablib->name != "table") return true; AstExpr** args = node->args.data; if (func->index == "insert" && node->args.size == 2) { if (AstExprCall* tail = args[1]->as()) { if (std::optional funty = context->getType(tail->func)) { size_t ret = getReturnCount(follow(*funty)); if (ret > 1) emitWarning(*context, LintWarning::Code_TableOperations, tail->location, "table.insert may change behavior if the call returns more than one result; consider adding parentheses around second " "argument"); } } } if (func->index == "insert" && node->args.size >= 3) { // table.insert(t, 0, ?) if (isConstant(args[1], 0.0)) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.insert uses index 0 but arrays are 1-based; did you mean 1 instead?"); // table.insert(t, #t, ?) if (isLength(args[1], args[0])) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.insert will insert the value before the last element, which is likely a bug; consider removing the second argument or " "wrap it in parentheses to silence"); // table.insert(t, #t+1, ?) if (AstExprBinary* add = args[1]->as(); add && add->op == AstExprBinary::Add && isLength(add->left, args[0]) && isConstant(add->right, 1.0)) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.insert will append the value to the table; consider removing the second argument for efficiency"); } if (func->index == "remove" && node->args.size >= 2) { // table.remove(t, 0) if (isConstant(args[1], 0.0)) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.remove uses index 0 but arrays are 1-based; did you mean 1 instead?"); // note: it's tempting to check for table.remove(t, #t), which is equivalent to table.remove(t), but it's correct, occurs frequently, // and also reads better. // table.remove(t, #t-1) if (AstExprBinary* sub = args[1]->as(); sub && sub->op == AstExprBinary::Sub && isLength(sub->left, args[0]) && isConstant(sub->right, 1.0)) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.remove will remove the value before the last element, which is likely a bug; consider removing the second argument or " "wrap it in parentheses to silence"); } if (func->index == "move" && node->args.size >= 4) { // table.move(t, 0, _, _) if (isConstant(args[1], 0.0)) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.move uses index 0 but arrays are 1-based; did you mean 1 instead?"); // table.move(t, _, _, 0) else if (isConstant(args[3], 0.0)) emitWarning(*context, LintWarning::Code_TableOperations, args[3]->location, "table.move uses index 0 but arrays are 1-based; did you mean 1 instead?"); } if (func->index == "create" && node->args.size == 2) { // table.create(n, {...}) if (args[1]->is()) emitWarning(*context, LintWarning::Code_TableOperations, args[1]->location, "table.create with a table literal will reuse the same object for all elements; consider using a for loop instead"); // table.create(n, {...} :: ?) if (AstExprTypeAssertion* as = args[1]->as(); as && as->expr->is()) emitWarning(*context, LintWarning::Code_TableOperations, as->expr->location, "table.create with a table literal will reuse the same object for all elements; consider using a for loop instead"); } return true; } bool isConstant(AstExpr* expr, double value) { AstExprConstantNumber* n = expr->as(); return n && n->value == value; } bool isLength(AstExpr* expr, AstExpr* table) { AstExprUnary* n = expr->as(); return n && n->op == AstExprUnary::Len && similar(n->expr, table); } size_t getReturnCount(TypeId ty) { if (auto ftv = get(ty)) return size(ftv->retType); if (auto itv = get(ty)) { // We don't process the type recursively to avoid having to deal with self-recursive intersection types size_t result = 0; for (TypeId part : itv->parts) if (auto ftv = get(follow(part))) result = std::max(result, size(ftv->retType)); return result; } return 0; } }; class LintDuplicateCondition : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintDuplicateCondition pass{&context}; context.root->visit(&pass); } private: LintContext* context; LintDuplicateCondition(LintContext* context) : context(context) { } bool visit(AstStatIf* stat) override { if (!stat->elsebody) return true; if (!stat->elsebody->is()) return true; // if..elseif chain detected, we need to unroll it std::vector conditions; conditions.reserve(2); AstStatIf* head = stat; while (head) { head->condition->visit(this); head->thenbody->visit(this); conditions.push_back(head->condition); if (head->elsebody && head->elsebody->is()) { head = head->elsebody->as(); continue; } if (head->elsebody) head->elsebody->visit(this); break; } detectDuplicates(conditions); // block recursive visits so that we only analyze each chain once return false; } bool visit(AstExprIfElse* expr) override { if (!expr->falseExpr->is()) return true; // if..elseif chain detected, we need to unroll it std::vector conditions; conditions.reserve(2); AstExprIfElse* head = expr; while (head) { head->condition->visit(this); head->trueExpr->visit(this); conditions.push_back(head->condition); if (head->falseExpr->is()) { head = head->falseExpr->as(); continue; } head->falseExpr->visit(this); break; } detectDuplicates(conditions); // block recursive visits so that we only analyze each chain once return false; } bool visit(AstExprBinary* expr) override { if (expr->op != AstExprBinary::And && expr->op != AstExprBinary::Or) return true; // for And expressions, it's idiomatic to use "a and a or b" as a ternary replacement, so we detect this pattern if (expr->op == AstExprBinary::Or) { AstExprBinary* la = expr->left->as(); if (la && la->op == AstExprBinary::And) { AstExprBinary* lb = la->left->as(); AstExprBinary* rb = la->right->as(); // check that the length of and-chain is exactly 2 if (!(lb && lb->op == AstExprBinary::And) && !(rb && rb->op == AstExprBinary::And)) { la->left->visit(this); la->right->visit(this); expr->right->visit(this); return false; } } } // unroll condition chain std::vector conditions; conditions.reserve(2); extractOpChain(conditions, expr, expr->op); detectDuplicates(conditions); // block recursive visits so that we only analyze each chain once return false; } void extractOpChain(std::vector& conditions, AstExpr* expr, AstExprBinary::Op op) { if (AstExprBinary* bin = expr->as(); bin && bin->op == op) { extractOpChain(conditions, bin->left, op); extractOpChain(conditions, bin->right, op); } else if (AstExprGroup* group = expr->as()) { extractOpChain(conditions, group->expr, op); } else { conditions.push_back(expr); } } void detectDuplicates(const std::vector& conditions) { // Limit the distance at which we consider duplicates to reduce N^2 complexity to KN const size_t kMaxDistance = 5; for (size_t i = 0; i < conditions.size(); ++i) { for (size_t j = std::max(i, kMaxDistance) - kMaxDistance; j < i; ++j) { if (similar(conditions[j], conditions[i])) { if (conditions[i]->location.begin.line == conditions[j]->location.begin.line) emitWarning(*context, LintWarning::Code_DuplicateCondition, conditions[i]->location, "Condition has already been checked on column %d", conditions[j]->location.begin.column + 1); else emitWarning(*context, LintWarning::Code_DuplicateCondition, conditions[i]->location, "Condition has already been checked on line %d", conditions[j]->location.begin.line + 1); break; } } } } }; class LintDuplicateLocal : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintDuplicateLocal pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; DenseHashMap locals; LintDuplicateLocal() : locals(nullptr) { } bool visit(AstStatLocal* node) override { // early out for performance if (node->vars.size == 1) return true; for (size_t i = 0; i < node->vars.size; ++i) locals[node->vars.data[i]] = node; for (size_t i = 0; i < node->vars.size; ++i) { AstLocal* local = node->vars.data[i]; if (local->shadow && locals[local->shadow] == node && !ignoreDuplicate(local)) { if (local->shadow->location.begin.line == local->location.begin.line) emitWarning(*context, LintWarning::Code_DuplicateLocal, local->location, "Variable '%s' already defined on column %d", local->name.value, local->shadow->location.begin.column + 1); else emitWarning(*context, LintWarning::Code_DuplicateLocal, local->location, "Variable '%s' already defined on line %d", local->name.value, local->shadow->location.begin.line + 1); } } return true; } bool visit(AstExprFunction* node) override { if (node->self) locals[node->self] = node; for (size_t i = 0; i < node->args.size; ++i) locals[node->args.data[i]] = node; for (size_t i = 0; i < node->args.size; ++i) { AstLocal* local = node->args.data[i]; if (local->shadow && locals[local->shadow] == node && !ignoreDuplicate(local)) { if (local->shadow == node->self) emitWarning(*context, LintWarning::Code_DuplicateLocal, local->location, "Function parameter 'self' already defined implicitly"); else if (local->shadow->location.begin.line == local->location.begin.line) emitWarning(*context, LintWarning::Code_DuplicateLocal, local->location, "Function parameter '%s' already defined on column %d", local->name.value, local->shadow->location.begin.column + 1); else emitWarning(*context, LintWarning::Code_DuplicateLocal, local->location, "Function parameter '%s' already defined on line %d", local->name.value, local->shadow->location.begin.line + 1); } } return true; } bool ignoreDuplicate(AstLocal* local) { return local->name == "_"; } }; class LintMisleadingAndOr : AstVisitor { public: LUAU_NOINLINE static void process(LintContext& context) { LintMisleadingAndOr pass; pass.context = &context; context.root->visit(&pass); } private: LintContext* context; bool visit(AstExprBinary* node) override { if (node->op != AstExprBinary::Or) return true; AstExprBinary* and_ = node->left->as(); if (!and_ || and_->op != AstExprBinary::And) return true; const char* alt = nullptr; if (and_->right->is()) alt = "nil"; else if (AstExprConstantBool* c = and_->right->as(); c && c->value == false) alt = "false"; if (alt) emitWarning(*context, LintWarning::Code_MisleadingAndOr, node->location, "The and-or expression always evaluates to the second alternative because the first alternative is %s; consider using if-then-else " "expression instead", alt); return true; } }; static void fillBuiltinGlobals(LintContext& context, const AstNameTable& names, const ScopePtr& env) { ScopePtr current = env; while (true) { for (auto& [global, binding] : current->bindings) { AstName name = names.get(global.c_str()); if (name.value) { auto& g = context.builtinGlobals[name]; g.type = binding.typeId; if (binding.deprecated) g.deprecated = binding.deprecatedSuggestion.c_str(); } } if (current->parent) current = current->parent; else break; } } static const char* fuzzyMatch(std::string_view str, const char** array, size_t size) { if (FInt::LuauSuggestionDistance == 0) return nullptr; size_t bestDistance = FInt::LuauSuggestionDistance; size_t bestMatch = size; for (size_t i = 0; i < size; ++i) { size_t ed = editDistance(str, array[i]); if (ed <= bestDistance) { bestDistance = ed; bestMatch = i; } } return bestMatch < size ? array[bestMatch] : nullptr; } static void lintComments(LintContext& context, const std::vector& hotcomments) { bool seenMode = false; for (const HotComment& hc : hotcomments) { // We reserve --! for various informational (non-directive) comments if (hc.content.empty() || hc.content[0] == ' ' || hc.content[0] == '\t') continue; if (!hc.header) { emitWarning(context, LintWarning::Code_CommentDirective, hc.location, "Comment directive is ignored because it is placed after the first non-comment token"); } else { std::string::size_type space = hc.content.find_first_of(" \t"); std::string_view first = std::string_view(hc.content).substr(0, space); if (first == "nolint") { std::string::size_type notspace = hc.content.find_first_not_of(" \t", space); if (space == std::string::npos || notspace == std::string::npos) { // disables all lints } else if (LintWarning::parseName(hc.content.c_str() + notspace) == LintWarning::Code_Unknown) { const char* rule = hc.content.c_str() + notspace; // skip Unknown if (const char* suggestion = fuzzyMatch(rule, kWarningNames + 1, LintWarning::Code__Count - 1)) emitWarning(context, LintWarning::Code_CommentDirective, hc.location, "nolint directive refers to unknown lint rule '%s'; did you mean '%s'?", rule, suggestion); else emitWarning( context, LintWarning::Code_CommentDirective, hc.location, "nolint directive refers to unknown lint rule '%s'", rule); } } else if (first == "nocheck" || first == "nonstrict" || first == "strict") { if (space != std::string::npos) emitWarning(context, LintWarning::Code_CommentDirective, hc.location, "Comment directive with the type checking mode has extra symbols at the end of the line"); else if (seenMode) emitWarning(context, LintWarning::Code_CommentDirective, hc.location, "Comment directive with the type checking mode has already been used"); else seenMode = true; } else { static const char* kHotComments[] = { "nolint", "nocheck", "nonstrict", "strict", }; if (const char* suggestion = fuzzyMatch(first, kHotComments, std::size(kHotComments))) emitWarning(context, LintWarning::Code_CommentDirective, hc.location, "Unknown comment directive '%.*s'; did you mean '%s'?", int(first.size()), first.data(), suggestion); else emitWarning(context, LintWarning::Code_CommentDirective, hc.location, "Unknown comment directive '%.*s'", int(first.size()), first.data()); } } } } void LintOptions::setDefaults() { // By default, we enable all warnings warningMask = ~0ull; } std::vector lint(AstStat* root, const AstNameTable& names, const ScopePtr& env, const Module* module, const std::vector& hotcomments, const LintOptions& options) { LintContext context; context.options = options; context.root = root; context.placeholder = names.get("_"); context.scope = env; context.module = module; fillBuiltinGlobals(context, names, env); if (context.warningEnabled(LintWarning::Code_UnknownGlobal) || context.warningEnabled(LintWarning::Code_DeprecatedGlobal) || context.warningEnabled(LintWarning::Code_GlobalUsedAsLocal) || context.warningEnabled(LintWarning::Code_PlaceholderRead) || context.warningEnabled(LintWarning::Code_BuiltinGlobalWrite)) { LintGlobalLocal::process(context); } if (context.warningEnabled(LintWarning::Code_MultiLineStatement)) LintMultiLineStatement::process(context); if (context.warningEnabled(LintWarning::Code_SameLineStatement)) LintSameLineStatement::process(context); if (context.warningEnabled(LintWarning::Code_LocalShadow) || context.warningEnabled(LintWarning::Code_FunctionUnused) || context.warningEnabled(LintWarning::Code_ImportUnused) || context.warningEnabled(LintWarning::Code_LocalUnused)) { LintLocalHygiene::process(context); } if (context.warningEnabled(LintWarning::Code_FunctionUnused)) LintUnusedFunction::process(context); if (context.warningEnabled(LintWarning::Code_UnreachableCode)) LintUnreachableCode::process(context); if (context.warningEnabled(LintWarning::Code_UnknownType)) LintUnknownType::process(context); if (context.warningEnabled(LintWarning::Code_ForRange)) LintForRange::process(context); if (context.warningEnabled(LintWarning::Code_UnbalancedAssignment)) LintUnbalancedAssignment::process(context); if (context.warningEnabled(LintWarning::Code_ImplicitReturn)) LintImplicitReturn::process(context); if (context.warningEnabled(LintWarning::Code_FormatString)) LintFormatString::process(context); if (context.warningEnabled(LintWarning::Code_TableLiteral)) LintTableLiteral::process(context); if (context.warningEnabled(LintWarning::Code_UninitializedLocal)) LintUninitializedLocal::process(context); if (context.warningEnabled(LintWarning::Code_DuplicateFunction)) LintDuplicateFunction::process(context); if (context.warningEnabled(LintWarning::Code_DeprecatedApi)) LintDeprecatedApi::process(context); if (context.warningEnabled(LintWarning::Code_TableOperations)) LintTableOperations::process(context); if (context.warningEnabled(LintWarning::Code_DuplicateCondition)) LintDuplicateCondition::process(context); if (context.warningEnabled(LintWarning::Code_DuplicateLocal)) LintDuplicateLocal::process(context); if (context.warningEnabled(LintWarning::Code_MisleadingAndOr)) LintMisleadingAndOr::process(context); if (context.warningEnabled(LintWarning::Code_CommentDirective)) lintComments(context, hotcomments); std::sort(context.result.begin(), context.result.end(), WarningComparator()); return context.result; } const char* LintWarning::getName(Code code) { LUAU_ASSERT(unsigned(code) < Code__Count); return kWarningNames[code]; } LintWarning::Code LintWarning::parseName(const char* name) { for (int code = Code_Unknown; code < Code__Count; ++code) if (strcmp(name, getName(Code(code))) == 0) return Code(code); return Code_Unknown; } uint64_t LintWarning::parseMask(const std::vector& hotcomments) { uint64_t result = 0; for (const HotComment& hc : hotcomments) { if (!hc.header) continue; if (hc.content.compare(0, 6, "nolint") != 0) continue; std::string::size_type name = hc.content.find_first_not_of(" \t", 6); // --!nolint disables everything if (name == std::string::npos) return ~0ull; // --!nolint needs to be followed by a whitespace character if (name == 6) continue; // --!nolint name disables the specific lint LintWarning::Code code = LintWarning::parseName(hc.content.c_str() + name); if (code != LintWarning::Code_Unknown) result |= 1ull << int(code); } return result; } std::vector getDeprecatedGlobals(const AstNameTable& names) { LintContext context; std::vector result; result.reserve(context.builtinGlobals.size()); for (auto& p : context.builtinGlobals) if (p.second.deprecated) result.push_back(p.first); return result; } void fuzzFormatString(const char* data, size_t size) { LintFormatString::fuzz(data, size); } } // namespace Luau