From 816ee8ba8ef1b04185d67e77406401b8a2d84bbd Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:43:50 +0200 Subject: [PATCH 001/193] fix(infer): type the parameters of functions written in table constructors --- crates/qbx_fivem_data/stubs/cfx_client.lua | 4 +- crates/qbx_fivem_data/stubs/lua54.lua | 34 +- .../qbx_lua_ls/src/features/class_tables.rs | 6 +- crates/qbx_lua_ls/src/features/expected.rs | 6 +- crates/qbx_lua_ls/src/features/returns.rs | 6 +- .../qbx_lua_ls/src/features/unknown_types.rs | 23 +- crates/qbx_lua_ls/src/infer.rs | 300 +++++++++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 139 ++++++++ docs/reference.md | 17 +- 9 files changed, 455 insertions(+), 80 deletions(-) diff --git a/crates/qbx_fivem_data/stubs/cfx_client.lua b/crates/qbx_fivem_data/stubs/cfx_client.lua index c6d432b..466b32e 100644 --- a/crates/qbx_fivem_data/stubs/cfx_client.lua +++ b/crates/qbx_fivem_data/stubs/cfx_client.lua @@ -2,12 +2,12 @@ ---Registers a handler for NUI fetch requests sent to `https:///`. The handler receives the decoded request body and a reply function that must be called once. ---@param name string ----@param cb fun(data: any, cb: fun(response: any)) +---@param cb fun(data: unknown, cb: fun(response: any)) function RegisterNUICallback(name, cb) end ---Alternative spelling of `RegisterNUICallback`. ---@param name string ----@param cb fun(data: any, cb: fun(response: any)) +---@param cb fun(data: unknown, cb: fun(response: any)) function RegisterNuiCallback(name, cb) end ---JSON-encodes `message` and posts it to this resource's NUI page, where it arrives as a window `message` event. diff --git a/crates/qbx_fivem_data/stubs/lua54.lua b/crates/qbx_fivem_data/stubs/lua54.lua index 3e72acf..a1f4a57 100644 --- a/crates/qbx_fivem_data/stubs/lua54.lua +++ b/crates/qbx_fivem_data/stubs/lua54.lua @@ -152,10 +152,42 @@ function require(modname) end ---@nodiscard function select(index, ...) end +---The fields Lua reads from the metatable of a table: the metamethods it calls for operations on the table, and its settings. +---@class metatable +---@field __index? table|fun(t: table, key: any): any +---@field __newindex? table|fun(t: table, key: any, value: any) +---@field __call? fun(t: table, ...: any): ...any +---@field __tostring? fun(t: table): string +---@field __len? fun(t: table): integer +---@field __pairs? fun(t: table): function, table, any +---@field __close? fun(t: table, err: any) +---@field __gc? fun(t: table) +---@field __unm? fun(a: table): any +---@field __bnot? fun(a: table): any +---@field __add? fun(a: any, b: any): any +---@field __sub? fun(a: any, b: any): any +---@field __mul? fun(a: any, b: any): any +---@field __div? fun(a: any, b: any): any +---@field __mod? fun(a: any, b: any): any +---@field __pow? fun(a: any, b: any): any +---@field __idiv? fun(a: any, b: any): any +---@field __band? fun(a: any, b: any): any +---@field __bor? fun(a: any, b: any): any +---@field __bxor? fun(a: any, b: any): any +---@field __shl? fun(a: any, b: any): any +---@field __shr? fun(a: any, b: any): any +---@field __concat? fun(a: any, b: any): any +---@field __eq? fun(a: table, b: table): boolean +---@field __lt? fun(a: any, b: any): boolean +---@field __le? fun(a: any, b: any): boolean +---@field __mode? "k"|"v"|"kv" +---@field __name? string +---@field __metatable? any + ---Sets (or clears, with nil) the metatable of `t` and returns `t`. Fails if the current metatable has a `__metatable` field. ---@generic T: table ---@param t T ----@param metatable? table +---@param metatable? table|metatable ---@return T t function setmetatable(t, metatable) end diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index e5c6409..b14c2c9 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -18,7 +18,7 @@ use qbx_lua_syntax::{NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; -use crate::infer::{documented_functions, Infer}; +use crate::infer::Infer; use crate::luacats::applies_on; use crate::types::Type; @@ -735,9 +735,7 @@ impl<'c> Finder<'_, '_, 'c> { impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { fn visit_stmt(&mut self, stmt: &'c Stmt) { - let functions = documented_functions(stmt); - if !functions.is_empty() { - let doc = self.classes.infer.ctx.doc_at(stmt.span.start); + for (doc, functions) in self.classes.infer.ctx.function_docs(stmt) { let returns: Vec = doc.returns.iter().map(|r| r.ty.clone()).collect(); for func in functions { self.documented.insert(func.params_span.start, returns.clone()); diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index 2954796..2a8c274 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -10,7 +10,7 @@ use rustc_hash::FxHashMap; use super::class_tables::{class_tables, entries, Classes, Key}; use super::comparisons::Declared; -use crate::infer::{documented_functions, Infer}; +use crate::infer::Infer; use crate::types::Type; /// Where a value is written. @@ -97,9 +97,7 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { if self.found.is_some() || !stmt.span.contains_inclusive(self.at()) { return; } - let functions = documented_functions(stmt); - if !functions.is_empty() { - let doc = self.infer.ctx.doc_at(stmt.span.start); + for (doc, functions) in self.infer.ctx.function_docs(stmt) { let returns: Vec = doc.returns.iter().map(|r| r.ty.clone()).collect(); for func in functions { self.documented.insert(func.params_span.start, returns.clone()); diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index e799cac..7420c09 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -15,7 +15,7 @@ use qbx_lua_syntax::Span; use super::class_tables::Classes; use crate::indexer::is_meta_file; -use crate::infer::{always_exits, documented_functions, return_stmts, Infer}; +use crate::infer::{always_exits, return_stmts, Infer}; use crate::types::Type; /// Each returned value that its function's `@return` does not take, with the message naming both @@ -153,9 +153,7 @@ struct Finder<'a, 'b, 'c> { impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { fn visit_stmt(&mut self, stmt: &'c Stmt) { - let functions = documented_functions(stmt); - if !functions.is_empty() { - let doc = self.infer.ctx.doc_at(stmt.span.start); + for (doc, functions) in self.infer.ctx.function_docs(stmt) { if !doc.returns.is_empty() { let returns: Vec = doc.returns.iter().map(|r| r.ty.clone()).collect(); let documented = |func| Documented { func, returns: returns.clone(), sets: doc.return_sets.clone() }; diff --git a/crates/qbx_lua_ls/src/features/unknown_types.rs b/crates/qbx_lua_ls/src/features/unknown_types.rs index 3304d92..4d2faf1 100644 --- a/crates/qbx_lua_ls/src/features/unknown_types.rs +++ b/crates/qbx_lua_ls/src/features/unknown_types.rs @@ -25,8 +25,10 @@ pub fn unknown_types(infer: &Infer, ignored_prefix: &str) -> Vec<(Span, String)> let known = match infer.local_type(id as LocalId) { Type::Unknown => false, // The handler of `RegisterNetEvent(name, function(payload) end)` takes `any` from the - // `fun(...)` it is passed as, which says no more about `payload` than nothing does. - Type::Any if local.kind == LocalKind::Param => has_param_line(infer, local), + // `...` of the `fun(...)` it is passed as, which says no more about `payload` than + // nothing does. A parameter the function type names, like `value` of + // `fun(reason: string, value: any)`, is declared `any`. + Type::Any if local.kind == LocalKind::Param => has_param_line(infer, local) || !from_vararg(infer, local), _ => true, }; if known { @@ -44,10 +46,17 @@ pub fn unknown_types(infer: &Infer, ignored_prefix: &str) -> Vec<(Span, String)> out } -/// Whether a `@param` line documents the parameter: above its function, or above the call its -/// function is passed to. +/// Whether a `@param` line documents the parameter: above its function, the call its function is +/// passed to, or the table field that holds it. pub(super) fn has_param_line(infer: &Infer, param: &Local) -> bool { - let Some(Decl::Param { doc_anchor, expected, .. }) = infer.ctx.decl(param.decl.start) else { return false }; - let anchor = doc_anchor.or_else(|| expected.as_ref().map(|expected| expected.call.span.start)); - anchor.is_some_and(|anchor| infer.ctx.doc_at(anchor).params.iter().any(|doc| doc.name == param.name)) + let Some(Decl::Param { doc_anchor, .. }) = infer.ctx.decl(param.decl.start) else { return false }; + doc_anchor.is_some_and(|anchor| infer.ctx.doc_at(anchor).params.iter().any(|doc| doc.name == param.name)) +} + +/// Whether the parameter takes its value from the `...` of the function type its function has to be. +fn from_vararg(infer: &Infer, param: &Local) -> bool { + let Some(Decl::Param { index, expected: Some(expected), .. }) = infer.ctx.decl(param.decl.start) else { + return false; + }; + infer.expected_fun(expected).is_some_and(|fun| fun.params.get(*index).is_some_and(|p| p.name == "...")) } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index f71b5bf..c4cc23e 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -95,11 +95,29 @@ pub enum Decl<'a> { GenericFor { stmt: &'a Stmt, index: usize }, } -/// A function literal passed as a call argument: its parameters can be typed from the callee. +/// Where a function literal or table constructor is written, which may declare the type it has to +/// be: the parameters of a function are typed from it, and so are those of the functions a table +/// holds. #[derive(Clone, Copy)] -pub struct Expected<'a> { - pub call: &'a Expr, - pub arg_index: usize, +pub enum Expected<'a> { + /// Argument `arg_index` of a call, which has the type of the parameter it is passed for. + Arg { call: &'a Expr, arg_index: usize }, + /// Value `index` of a `local` statement or an assignment, typed by the `---@type` above it. + Value { stmt: &'a Stmt, index: usize }, + /// The value of the field `key` of a table constructor, which has the type of that field of the + /// table. + Field { table: &'a Expr, key: FieldKey<'a> }, +} + +/// How a table constructor keys one of its fields. +#[derive(Clone, Copy)] +pub enum FieldKey<'a> { + /// `name = value` or `['name'] = value`. + Name(&'a str), + /// The `n`th value written without a key. + Position(i64), + /// `[key] = value` with a key that is no string. + Expr(&'a Expr), } pub struct FileContext<'a> { @@ -110,13 +128,20 @@ pub struct FileContext<'a> { decls: FxHashMap>, /// The start of the statement each call is the value of, by the start of the call. call_anchors: FxHashMap, + /// Where each table constructor that a declared type may describe is written, by its start. + tables: FxHashMap>, docs: RefCell>>, guards: OnceCell, } impl<'a> FileContext<'a> { pub fn new(file: FileId, source: &'a str, chunk: &'a Chunk, resolution: &'a Resolution) -> Self { - let mut collector = DeclCollector { decls: FxHashMap::default(), call_anchors: FxHashMap::default() }; + let mut collector = DeclCollector { + source, + decls: FxHashMap::default(), + call_anchors: FxHashMap::default(), + tables: FxHashMap::default(), + }; collector.block(&chunk.block); Self { file, @@ -125,6 +150,7 @@ impl<'a> FileContext<'a> { resolution, decls: collector.decls, call_anchors: collector.call_anchors, + tables: collector.tables, docs: RefCell::new(FxHashMap::default()), guards: OnceCell::new(), } @@ -149,6 +175,63 @@ impl<'a> FileContext<'a> { doc } + /// The functions `stmt` defines, grouped by the doc comment they take: those of + /// `documented_functions`, under the comment above the statement, and each function its values + /// write in a field of a table constructor, under the comment above that field. + pub fn function_docs<'s>(&self, stmt: &'s Stmt) -> Vec<(Rc, Vec<&'s FuncBody>)> { + let mut out = Vec::new(); + let functions = documented_functions(stmt); + if !functions.is_empty() { + out.push((self.doc_at(stmt.span.start), functions)); + } + let values: &[Expr] = match &stmt.kind { + StmtKind::Local { exprs, .. } | StmtKind::Assign { exprs, .. } | StmtKind::Return(exprs) => exprs, + StmtKind::Expr(expr) => std::slice::from_ref(expr), + _ => &[], + }; + for value in values { + self.field_functions(value, &mut out); + } + out + } + + /// Adds each function written in a field of a table constructor in `expr`, outside other + /// functions, with the doc comment above its field. + fn field_functions<'s>(&self, expr: &'s Expr, out: &mut Vec<(Rc, Vec<&'s FuncBody>)>) { + match &expr.kind { + ExprKind::Table(fields) => { + for field in fields { + let value = match field { + TableField::Named { value, .. } | TableField::Positional(value) => value, + TableField::Keyed { key, value } => { + self.field_functions(key, out); + value + } + TableField::SetMember(_) => continue, + }; + match &value.kind { + ExprKind::Function(func) => { + out.push((self.doc_at(field_start(self.source, field)), vec![&**func])) + } + _ => self.field_functions(value, out), + } + } + } + ExprKind::Call { callee: base, args, .. } | ExprKind::MethodCall { base, args, .. } => { + self.field_functions(base, out); + args.iter().for_each(|arg| self.field_functions(arg, out)); + } + ExprKind::Index { base, index: other, .. } | ExprKind::Binary { lhs: base, rhs: other, .. } => { + self.field_functions(base, out); + self.field_functions(other, out); + } + ExprKind::Field { base: inner, .. } | ExprKind::Unary { expr: inner, .. } | ExprKind::Paren(inner) => { + self.field_functions(inner, out) + } + _ => {} + } + } + /// Where the doc comment of the functions passed to a call is read: the start of the call, or /// of the `x = f(...)` or `local x = f(...)` statement the call is the value of. pub fn call_doc_anchor(&self, call_start: u32) -> u32 { @@ -167,8 +250,10 @@ impl<'a> FileContext<'a> { } struct DeclCollector<'a> { + source: &'a str, decls: FxHashMap>, call_anchors: FxHashMap, + tables: FxHashMap>, } impl<'a> DeclCollector<'a> { @@ -188,12 +273,17 @@ impl<'a> DeclCollector<'a> { fn stmt(&mut self, stmt: &'a Stmt) { let anchor = Some(stmt.span.start); match &stmt.kind { - StmtKind::Local { names, exprs, .. } => { + StmtKind::Local { names, exprs, in_unpack } => { for (index, name) in names.iter().enumerate() { self.decls.insert(name.name.span.start, Decl::Local { stmt, index }); } - for expr in exprs { - self.expr(expr, anchor); + for (index, expr) in exprs.iter().enumerate() { + // `local a, b in t` takes the names from `t`, which the `---@type` above does not describe. + if *in_unpack { + self.expr(expr, anchor); + } else { + self.value(expr, anchor, Expected::Value { stmt, index }); + } } } StmtKind::LocalFunction { name, func } => { @@ -208,7 +298,9 @@ impl<'a> DeclCollector<'a> { } StmtKind::Assign { targets, exprs } => { targets.iter().for_each(|e| self.expr(e, None)); - exprs.iter().for_each(|e| self.expr(e, anchor)); + for (index, expr) in exprs.iter().enumerate() { + self.value(expr, anchor, Expected::Value { stmt, index }); + } } StmtKind::CompoundAssign { target, expr, .. } => { self.expr(target, None); @@ -276,14 +368,25 @@ impl<'a> DeclCollector<'a> { } ExprKind::Unary { expr, .. } | ExprKind::Paren(expr) => self.expr(expr, None), ExprKind::Table(fields) => { + let mut position = 0; for field in fields { - match field { - TableField::Positional(value) | TableField::Named { value, .. } => self.expr(value, None), + let (key, value) = match field { + TableField::Named { name, value } => (FieldKey::Name(&name.text), value), TableField::Keyed { key, value } => { self.expr(key, None); - self.expr(value, None); + (key.as_string().map_or(FieldKey::Expr(key), |name| FieldKey::Name(name)), value) + } + TableField::Positional(value) => { + position += 1; + (FieldKey::Position(position), value) } - TableField::SetMember(_) => {} + TableField::SetMember(_) => continue, + }; + let at = Expected::Field { table: expr, key }; + // A function in a field takes the `@param` lines above the field. + match &value.kind { + ExprKind::Function(func) => self.func(func, Some(field_start(self.source, field)), Some(at)), + _ => self.value(value, None, at), } } } @@ -291,16 +394,30 @@ impl<'a> DeclCollector<'a> { } } + /// A value written where `at` may declare its type. A function takes the doc comment at + /// `doc_anchor`, and a call passes it on to the functions passed to it. + fn value(&mut self, expr: &'a Expr, doc_anchor: Option, at: Expected<'a>) { + match &expr.kind { + ExprKind::Function(func) => self.func(func, doc_anchor, Some(at)), + ExprKind::Table(_) => { + self.tables.insert(expr.span.start, at); + self.expr(expr, None); + } + _ => self.expr(expr, doc_anchor), + } + } + /// `doc_anchor` is the statement the call is the value of, whose doc comment the functions - /// passed to the call take. + /// passed to the call take. Without one, they take the doc comment above the call. fn call_args(&mut self, call: &'a Expr, args: &'a [Expr], doc_anchor: Option) { if let Some(anchor) = doc_anchor { self.call_anchors.insert(call.span.start, anchor); } for (arg_index, arg) in args.iter().enumerate() { + let at = Expected::Arg { call, arg_index }; match &arg.kind { - ExprKind::Function(func) => self.func(func, doc_anchor, Some(Expected { call, arg_index })), - _ => self.expr(arg, None), + ExprKind::Function(func) => self.func(func, Some(doc_anchor.unwrap_or(call.span.start)), Some(at)), + _ => self.value(arg, None, at), } } } @@ -621,8 +738,8 @@ impl<'a> Infer<'a> { // A function passed to a call takes the `@param` lines above that call's statement, as // the handler of `RegisterServerCallback('name', function(source, id) end)` does, // also when the statement assigns what the call returns. - if let Some(anchor) = doc_anchor.or_else(|| expected.as_ref().map(|e| e.call.span.start)) { - let doc = self.ctx.doc_at(anchor); + if let Some(anchor) = doc_anchor { + let doc = self.ctx.doc_at(*anchor); if let Some(param) = doc.params.iter().find(|p| p.name == *name) { return if param.optional { param.ty.clone().optional() } else { param.ty.clone() }; } @@ -679,8 +796,8 @@ impl<'a> Infer<'a> { } /// `lib.onCache('vehicle', function(value, oldValue)`: both parameters are `cache.vehicle`. - fn on_cache_param(&self, expected: &Expected) -> Option { - let ExprKind::Call { callee, args, .. } = &expected.call.kind else { return None }; + fn on_cache_param(&self, call: &Expr) -> Option { + let ExprKind::Call { callee, args, .. } = &call.kind else { return None }; if callee.dotted_path().as_deref() != Some("lib.onCache") { return None; } @@ -688,11 +805,68 @@ impl<'a> Infer<'a> { self.member(&self.global_type("cache"), key).map(|m| m.ty) } + /// The type of parameter `index` of a function literal written where `expected` says. fn expected_param(&self, expected: &Expected, index: usize) -> Option { - if let Some(ty) = self.on_cache_param(expected).filter(|_| index < 2) { - return Some(ty); + if let Expected::Arg { call, arg_index } = *expected { + if let Some(ty) = self.on_cache_param(call).filter(|_| index < 2) { + return Some(ty); + } + if let Some(returns) = self.triggered_returns(call, arg_index) { + return Some(returns.get(index).cloned().unwrap_or(Type::Nil)); + } + } + let param = self.expected_fun(expected)?.params.get(index)?.clone(); + Some(if param.optional { param.ty.optional() } else { param.ty }) + } + + /// `TriggerCallback('name', function(response) end)` receives what the handler of `name` + /// returns, when argument `arg_index` of `call` is that function. + fn triggered_returns(&self, call: &Expr, arg_index: usize) -> Option> { + let (fun, args, via_method) = self.call_parts(call)?; + let wrapper = Wrapper::of(&fun, via_method).filter(|w| w.tag.role == CallbackRole::Trigger)?; + if wrapper.function.map(|param| wrapper.arg(param)) != Some(arg_index) { + return None; + } + let handler = self.wrapper_handler(&wrapper, args.exprs, call.span.start)?; + (!handler.returns.is_empty()).then(|| handler.returns.clone()) + } + + /// The function type that a function literal written where `expected` says has to be. + pub fn expected_fun(&self, expected: &Expected) -> Option> { + self.fun_of(&self.expected_type(expected)?) + } + + /// The type that a value written where `expected` says has to be, when something declares it. + fn expected_type(&self, expected: &Expected) -> Option { + match *expected { + Expected::Arg { call, arg_index } => { + let (fun, args, via_method) = self.call_parts(call)?; + let (skip_params, skip_args) = fun.call_offsets(via_method); + let param = &fun.params.get((arg_index + skip_params).checked_sub(skip_args)?)?.ty; + // A table passed for a parameter like the `T` of `setmetatable(t: T)` decides that + // generic itself. A function literal never does: `bind_generics` leaves those out. + let is_function = matches!(args.exprs.get(arg_index)?.kind, ExprKind::Function(_)); + if !is_function && self.can_bind(&fun, param) { + return None; + } + Some(substitute(param, &self.bind_generics(&fun, &args, via_method, false))) + } + Expected::Value { stmt, index } => { + // `---@class Name` above a table declares the class rather than a value of it. + let doc = self.ctx.doc_at(stmt.span.start); + doc.type_at(index).filter(|_| doc.classes.is_empty()).cloned() + } + Expected::Field { table, key } => { + let at = self.ctx.tables.get(&table.span.start)?; + let table = self.guarded(|| self.expected_type(at))?; + self.field_type(&table, key) + } } - let (fun, args, via_method) = match &expected.call.kind { + } + + /// The signature that `call` uses, with its arguments and whether it calls a method. + fn call_parts<'e>(&self, call: &'e Expr) -> Option<(Arc, CallArgs<'e>, bool)> { + let (fun, args, via_method) = match &call.kind { ExprKind::Call { callee, args, .. } => (self.expr(callee).as_fun().cloned(), args, false), ExprKind::MethodCall { base, method, args, .. } => { let member = self.member(&self.expr(base), &method.text); @@ -701,22 +875,32 @@ impl<'a> Infer<'a> { _ => return None, }; let args = CallArgs::new(args); - let fun = self.signature_for(&fun?, &args, via_method, expected.call.span.start); - let (skip_params, skip_args) = fun.call_offsets(via_method); - let param_index = (expected.arg_index + skip_params).checked_sub(skip_args)?; - // `TriggerCallback('name', function(response) end)` receives what the handler of `name` returns. - if let Some(wrapper) = Wrapper::of(&fun, via_method).filter(|w| w.tag.role == CallbackRole::Trigger) { - if wrapper.function.map(|param| wrapper.arg(param)) == Some(expected.arg_index) { - if let Some(handler) = self.wrapper_handler(&wrapper, args.exprs, expected.call.span.start) { - if !handler.returns.is_empty() { - return Some(handler.returns.get(index).cloned().unwrap_or(Type::Nil)); - } + let fun = self.signature_for(&fun?, &args, via_method, call.span.start); + Some((fun, args, via_method)) + } + + /// The type of the field `key` in a table of type `table`. + fn field_type(&self, table: &Type, key: FieldKey) -> Option { + let key = match key { + FieldKey::Name(name) => { + if let Some(member) = self.member(table, name) { + return Some(member.ty); } + Type::StringLit(SmolStr::new(name)) } + FieldKey::Position(position) => Type::IntLit(position), + FieldKey::Expr(key) => self.expr(key), + }; + Some(self.element_type(&table.without_nil(), &key, 0)).filter(|ty| !ty.is_unknown()) + } + + /// The function type `ty` describes, through aliases and the `nil` of `fun()?`. + fn fun_of(&self, ty: &Type) -> Option> { + match self.resolve_alias(ty) { + Type::Fun(fun) => Some(fun), + Type::Union(types) => types.iter().find_map(|part| self.guarded(|| self.fun_of(part))), + _ => None, } - let callback = fun.params.get(param_index)?.ty.as_fun()?.clone(); - let ty = &callback.params.get(index)?.ty; - Some(substitute(ty, &self.bind_generics(&fun, &args, via_method, false))) } /// Whether `name` is the table a `---@class` annotation declares, like `Test` in `---@class Test` @@ -932,24 +1116,19 @@ impl<'a> Infer<'a> { return Type::union(fields); } } - self.element_type(&base_ty.without_nil(), index, &key_ty, 0) + self.element_type(&base_ty.without_nil(), &key_ty, 0) } - /// What `base[index]` holds when the key is not a known field name. - fn element_type(&self, base: &Type, index: &Expr, key_ty: &Type, depth: u32) -> Type { + /// What `base[key]` holds for a key of type `key_ty` that is not a known field name. + fn element_type(&self, base: &Type, key_ty: &Type, depth: u32) -> Type { match self.resolve_alias(base) { Type::Union(types) if depth < 8 => Type::union( - types - .iter() - .filter(|t| !matches!(t, Type::Nil)) - .map(|t| self.element_type(t, index, key_ty, depth + 1)), + types.iter().filter(|t| !matches!(t, Type::Nil)).map(|t| self.element_type(t, key_ty, depth + 1)), ), Type::Array(inner) => *inner, Type::Map(_, value) => *value, - Type::Tuple(items) => match &index.kind { - ExprKind::Number(NumberValue::Int(i)) => { - items.get((*i as usize).wrapping_sub(1)).cloned().unwrap_or_default() - } + Type::Tuple(items) => match key_ty { + Type::IntLit(i) => items.get((*i as usize).wrapping_sub(1)).cloned().unwrap_or_default(), _ => Type::union(items), }, Type::Shape(shape) => Type::union(shape.array.iter().chain(shape.index.as_ref().map(|(_, v)| v)).cloned()), @@ -1426,12 +1605,16 @@ impl<'a> Infer<'a> { } let mut literals = Literals::default(); for (i, (param, arg)) in fixed.iter().zip(args).enumerate() { - // A function literal is not inferred here: its parameters may be typed from this very call. - let given = match arg.unparen().kind { - ExprKind::Function(_) => None, - _ => Some(call.ty(self, skip_args + i)), + // Function literals and table constructors are not inferred here: the parameters of the + // functions in them may be typed from this very call. + let (given, given_kinds) = match arg.unparen().kind { + ExprKind::Function(_) => (None, Some(kind::FUNCTION)), + ExprKind::Table(_) => (None, Some(kind::TABLE)), + _ => { + let given = call.ty(self, skip_args + i); + (Some(given), self.value_kinds(fun, given, 0)) + } }; - let given_kinds = given.map_or(Some(kind::FUNCTION), |ty| self.value_kinds(fun, ty, 0)); if let (Some(wanted), Some(given_kinds)) = (self.param_kinds(fun, param), given_kinds) { if wanted & given_kinds == 0 { return Fit::No; @@ -2019,6 +2202,19 @@ fn breaks(block: &Block) -> bool { }) } +/// Where a table field starts, whose doc comment the function it holds takes: at its name, the `[` +/// of its key, or its value. +fn field_start(source: &str, field: &TableField) -> u32 { + match field { + TableField::Named { name, .. } | TableField::SetMember(name) => name.span.start, + TableField::Keyed { key, .. } => { + let before = source[..key.span.start as usize].trim_end(); + before.strip_suffix('[').map_or(key.span.start, |rest| rest.len() as u32) + } + TableField::Positional(value) => value.span.start, + } +} + /// The functions a statement defines under its doc comment: `function f()`, `local function f()`, /// function literals assigned directly, `local f = function()` or `M.f = function()`, and those /// passed to the call the statement makes or assigns, `RegisterServerCallback('name', function()` diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 615eeee..ec8daf2 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2506,6 +2506,29 @@ local handlers = {} handlers['demo:request'] = RegisterNetEvent('demo:request', function(requestId, ...) print(requestId, ...) end) + +---@class TArgs +---@field action fun(scrollIndex?: number) + +local documented = { + ---@param a1 number + action = function(a1) end, +} + +---@type TArgs +local declared = { + action = function(b1) end, +} + +print(json.encode(documented, { exception = function(_, value) end })) + +local proxy = setmetatable(declared, { __index = function(_, key) end }) + +local untyped = { run = function(arg) end } + +RegisterNUICallback('demo', function(data, cb) + cb(data) +end) "; write("strict/main.lua", text); write("loose.lua", "function other(bar) end\n"); @@ -2519,6 +2542,8 @@ end) finding("local pending", "The type of `pending` is unknown; add `---@type `"), finding("local result", "The type of `result` is unknown; add `---@type `"), finding("function(payload)", "Parameter `payload` has no type; add `---@param payload `"), + finding("function(arg)", "Parameter `arg` has no type; add `---@param arg `"), + finding("function(data, cb)", "Parameter `data` has no type; add `---@param data `"), ]; assert_eq!(findings(&mut client, "strict/main.lua", &["no-unknown"]), expected); client.open_with("strict/main.lua", text); @@ -3564,6 +3589,120 @@ end } } +#[test] +fn hover_types_the_parameters_of_functions_in_tables() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class TArgs +---@field action fun(scrollIndex?: number) +---@field items? { onSelect: fun(args: string) }[] + +---@alias Handler fun(source: integer, payload: string) + +local documented = { + ---@param a1 number + action = function(a1) end, + ---@param k1 boolean + [ 'keyed' ] = function(k1) end, +} + +---@type TArgs +local declared = { + action = function(b1) end, + items = { { onSelect = function(selected) end } }, +} + +---@param options { handlers: table } +local function register(options) end + +register({ handlers = { ping = function(source, payload) end } }) + +local encoded = json.encode({}, { exception = function(reason, value) end }) + +local proxy = setmetatable({}, { + __index = function(_, key) end, + __newindex = function(_, field, assigned) end, +}) + +---@type fun(count: integer) +local counter = function(n) end +"; + client.open_with(CLIENT, text); + let cases = [ + // `@param` lines above the field. + ("a1)", "a1: number"), + ("k1)", "k1: boolean"), + // The `@field` of the class a `---@type` local declares, and of the tables in its fields. + ("b1)", "b1: number?"), + ("selected)", "selected: string"), + // The type of the parameter a table is passed for. + ("source, payload", "source: integer"), + ("payload) end", "payload: string"), + ("reason, value", "reason: string"), + ("value) end", "value: any"), + // The metamethods of `setmetatable`'s second argument. + ("key) end", "key: any"), + ("assigned)", "assigned: any"), + // A function a `---@type` local holds. + ("n) end", "n: integer"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn functions_in_table_fields_return_what_the_return_above_their_field_says() { + let mut client = Client::start(fixture_root()); + let text = "\ +local handlers = { + ---@param x integer + ---@return string + name = function(x) return x end, + ---@return integer + count = function() end, + nested = { + ---@return boolean + ready = function() return true end, + }, +} + +Register({ + ---@return string + label = function() end, +}) + +return { + ---@return integer + size = function() return 'big' end, +} +"; + client.open_with(CLIENT, text); + let found: Vec<(String, u64)> = findings(&mut client, CLIENT, &["missing-return", "return-type-mismatch"]) + .into_iter() + .map(|(code, line, _)| (code, line)) + .collect(); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + assert_eq!( + found, + [ + ("return-type-mismatch".to_string(), line("name =")), + ("missing-return".to_string(), line("count =")), + ("missing-return".to_string(), line("label =")), + ("return-type-mismatch".to_string(), line("size =")), + ] + ); + + // A `return` in such a function completes the values its `@return` lists. + let text = "local modes = {\n ---@return 'on'|'off'\n get = function()\n return "; + client.change(CLIENT, 2, text); + let mut labels = client.completion_labels(CLIENT, 3, 15); + labels.retain(|label| label.starts_with('\'')); + assert_eq!(labels, ["'on'", "'off'"]); +} + #[test] fn hover_expands_aliases_and_lists_members_only_for_tables() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index a1de3f4..25e9a96 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -373,8 +373,8 @@ keeps it loose. A function documented with `@return` has to return values of those types. That includes a function passed to the call below the doc comment, such as the handler of `RegisterServerCallback('name', function(source) ... end)`, also when the statement assigns what -the call returns, as `local handler = RegisterNetEvent('name', function() ... end)` does. -qbx-lua-ls reports: +the call returns, as `local handler = RegisterNetEvent('name', function() ... end)` does, and a +function written in a table, whose doc comment goes above its field. qbx-lua-ls reports: - `return-type-mismatch` for a returned value that clearly is not of its `@return` type: a different kind of value, such as `return 5` for `---@return string`, or a literal the type does @@ -484,7 +484,8 @@ Only clear cases count, and the rest is left alone: `no-unknown` is off by default. Turned on, qbx-lua-ls reports each parameter, local and loop variable whose type is unknown: none is declared, and none can be inferred from its value, from -the function a callback is passed to, or from what a loop goes through. +the function a callback is passed to, from the declared type of the table field a function is +written in, or from what a loop goes through. ```toml [rules] @@ -501,10 +502,14 @@ end ``` A `---@param` or `---@type` gives the name a type, `any` included. Without one, a parameter that -only takes `any` from the `fun(...)` its function is passed as, like the handler of -`RegisterNetEvent`, counts as untyped. Its `---@param` goes above the statement that makes the +only takes `any` from the `...` of the `fun(...)` its function is passed as, like the handler of +`RegisterNetEvent`, counts as untyped, and so does the `data` of a `RegisterNUICallback` handler, +which only the resource's own UI decides. Its `---@param` goes above the statement that makes the call, whether that is the call alone or `handlers[name] = RegisterNetEvent(name, function(id) end)`. -A local declared without a value needs a `---@type`, since +A function written in a table takes the `---@param` lines above its field, and the parameter +types of the field's `fun(...)` when the table has a declared type: the `---@type` above its +`local`, the type of the parameter it is passed for, or `metatable` for the second argument of +`setmetatable`. A local declared without a value needs a `---@type`, since later assignments are not followed. Names that start with `ignore_unused_prefix`, and `self`, are not reported. To check only your own resources, set the level in an `[[overrides]]` entry instead of `[rules]`. From 7e44e8414305897d58c4a5859339d35d808452df Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:50:01 +0200 Subject: [PATCH 002/193] fix(luacats): resolve self in doc types to the class of the function's table --- .../qbx_lua_ls/src/features/class_tables.rs | 4 +- crates/qbx_lua_ls/src/features/expected.rs | 4 +- crates/qbx_lua_ls/src/features/returns.rs | 8 ++- crates/qbx_lua_ls/src/infer.rs | 57 ++++++++++++++- crates/qbx_lua_ls/src/locate.rs | 29 ++++++++ crates/qbx_lua_ls/tests/lsp.rs | 70 +++++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 32 +++++++-- crates/qbx_luacats/src/types.rs | 66 +++++++++++++++++ 8 files changed, 257 insertions(+), 13 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index b14c2c9..0dbd4fe 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -736,9 +736,9 @@ impl<'c> Finder<'_, '_, 'c> { impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { fn visit_stmt(&mut self, stmt: &'c Stmt) { for (doc, functions) in self.classes.infer.ctx.function_docs(stmt) { - let returns: Vec = doc.returns.iter().map(|r| r.ty.clone()).collect(); for func in functions { - self.documented.insert(func.params_span.start, returns.clone()); + let returns = doc.returns.iter().map(|r| self.classes.infer.doc_type_for(stmt, func, &r.ty)).collect(); + self.documented.insert(func.params_span.start, returns); } } match &stmt.kind { diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index 2a8c274..3cbfe72 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -98,9 +98,9 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { return; } for (doc, functions) in self.infer.ctx.function_docs(stmt) { - let returns: Vec = doc.returns.iter().map(|r| r.ty.clone()).collect(); for func in functions { - self.documented.insert(func.params_span.start, returns.clone()); + let returns = doc.returns.iter().map(|r| self.infer.doc_type_for(stmt, func, &r.ty)).collect(); + self.documented.insert(func.params_span.start, returns); } } match &stmt.kind { diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index 7420c09..3d975a2 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -155,8 +155,12 @@ impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { fn visit_stmt(&mut self, stmt: &'c Stmt) { for (doc, functions) in self.infer.ctx.function_docs(stmt) { if !doc.returns.is_empty() { - let returns: Vec = doc.returns.iter().map(|r| r.ty.clone()).collect(); - let documented = |func| Documented { func, returns: returns.clone(), sets: doc.return_sets.clone() }; + let documented = |func| { + let ty = |ty: &Type| self.infer.doc_type_for(stmt, func, ty); + let returns = doc.returns.iter().map(|r| ty(&r.ty)).collect(); + let sets = doc.return_sets.iter().map(|set| set.iter().map(ty).collect()).collect(); + Documented { func, returns, sets } + }; self.out.extend(functions.into_iter().map(documented)); } } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index c4cc23e..a57cd35 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -13,6 +13,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::callback_wrappers::{self, Wrapper}; use crate::index::{FileId, Index, SymbolKind}; +use crate::locate::statement_at; use crate::luacats::{applies_on, parse_doc_lines, DocGroup}; use crate::narrow::Guards; use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeParser}; @@ -741,7 +742,11 @@ impl<'a> Infer<'a> { if let Some(anchor) = doc_anchor { let doc = self.ctx.doc_at(*anchor); if let Some(param) = doc.params.iter().find(|p| p.name == *name) { - return if param.optional { param.ty.clone().optional() } else { param.ty.clone() }; + let ty = match param.ty.mentions_self() { + true => param.ty.with_self(&self.doc_self_at(*anchor, func)), + false => param.ty.clone(), + }; + return if param.optional { ty.optional() } else { ty }; } } expected.as_ref().and_then(|e| self.expected_param(e, *index)).unwrap_or_default() @@ -917,13 +922,56 @@ impl<'a> Infer<'a> { /// The type `self` has inside `function a.b:c()`, which is also the owner of `c`. pub fn func_name_owner_type(&self, name: &FuncName) -> Type { - let mut ty = self.name(&name.base); - for segment in &name.path { + self.path_type(&name.base, &name.path) + } + + /// The type of `base.path`, as of `a.b` for `base` `a` and `path` `b`. + fn path_type(&self, base: &Name, path: &[Name]) -> Type { + let mut ty = self.name(base); + for segment in path { ty = self.member(&ty, &segment.text).map(|m| m.ty).unwrap_or_default(); } ty } + /// The class that `self` stands for in the doc comment above `stmt`, for `func`, one of the + /// functions it defines: the type of the table that `function a.b:c()`, `function a.b.c()` or + /// `a.b.c = function()` puts the function in, `a.b`. Unknown for other functions, such as a + /// `local function`. + pub fn doc_self(&self, stmt: &Stmt, func: &FuncBody) -> Type { + match &stmt.kind { + StmtKind::Function { name, func: defined } if std::ptr::eq(&**defined, func) => match name.method { + Some(_) => self.func_name_owner_type(name), + None => match name.path.split_last() { + Some((_, owner)) => self.path_type(&name.base, owner), + None => Type::Unknown, + }, + }, + StmtKind::Assign { targets, exprs } => { + let defines = |expr: &Expr| matches!(&expr.kind, ExprKind::Function(f) if std::ptr::eq(&**f, func)); + match exprs.iter().position(defines).and_then(|index| targets.get(index)).map(|target| &target.kind) { + Some(ExprKind::Field { base, .. } | ExprKind::Index { base, .. }) => self.expr(base), + _ => Type::Unknown, + } + } + _ => Type::Unknown, + } + } + + /// `ty`, read from the doc comment above `stmt` for `func`, one of the functions it defines, + /// with `self` standing for the class `doc_self` gives. + pub fn doc_type_for(&self, stmt: &Stmt, func: &FuncBody, ty: &Type) -> Type { + match ty.mentions_self() { + true => ty.with_self(&self.doc_self(stmt, func)), + false => ty.clone(), + } + } + + /// The class that `self` stands for in the doc comment of the statement at `anchor`, for `func`. + fn doc_self_at(&self, anchor: u32, func: &FuncBody) -> Type { + statement_at(self.ctx.chunk, anchor).map_or(Type::Unknown, |stmt| self.doc_self(stmt, func)) + } + fn for_in_type(&self, stmt: &Stmt, index: usize) -> Type { let StmtKind::GenericFor { exprs, .. } = &stmt.kind else { return Type::Unknown }; let Some(first) = exprs.first() else { return Type::Unknown }; @@ -1760,6 +1808,9 @@ impl<'a> Infer<'a> { Some(doc) => doc.fun_type(&names, func.vararg.is_some(), is_method), None => DocGroup::default().fun_type(&names, func.vararg.is_some(), is_method), }; + if let Some(anchor) = doc_anchor.filter(|_| fun.mentions_self()) { + fun = fun.with_self(&self.doc_self_at(anchor, func)); + } if fun.returns.is_empty() { let (returns, sets) = self.inferred_returns(&func.body); if returns.iter().any(|t| !t.is_unknown()) { diff --git a/crates/qbx_lua_ls/src/locate.rs b/crates/qbx_lua_ls/src/locate.rs index 8f25ea6..d9872be 100644 --- a/crates/qbx_lua_ls/src/locate.rs +++ b/crates/qbx_lua_ls/src/locate.rs @@ -185,6 +185,35 @@ pub struct Located<'a> { pub table_in_call: Option<(&'a Expr, usize, &'a Expr)>, } +/// The statement that starts at `start`, at any depth. +pub fn statement_at(chunk: &Chunk, start: u32) -> Option<&Stmt> { + struct Finder<'a> { + start: u32, + found: Option<&'a Stmt>, + } + impl<'a> Visitor<'a> for Finder<'a> { + fn visit_stmt(&mut self, stmt: &'a Stmt) { + if self.found.is_some() || !stmt.span.contains_inclusive(self.start) { + return; + } + if stmt.span.start == self.start { + self.found = Some(stmt); + return; + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'a Expr) { + if self.found.is_none() && expr.span.contains_inclusive(self.start) { + visit::walk_expr(self, expr); + } + } + } + let mut finder = Finder { start, found: None }; + finder.visit_block(&chunk.block); + finder.found +} + pub fn locate(chunk: &Chunk, offset: u32) -> Located<'_> { let mut locator = Locator { offset, member: None, call: None, callee: None, string: None, func_name: None, table_in_call: None }; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index ec8daf2..6c006e7 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3703,6 +3703,76 @@ return { assert_eq!(labels, ["'on'", "'off'"]); } +#[test] +fn self_in_doc_types_is_the_class_of_the_table_a_function_is_defined_on() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Builder +---@field parent? self +local Builder = {} + +---@return self +function Builder:chain() return self end + +---@param other self +---@return self +function Builder:merge(other) + local merged = other + return merged +end + +---@return self +function Builder.new() return setmetatable({}, { __index = Builder }) end + +---@return self +Builder.copy = function() return Builder.new() end + +---@return self +function Builder:broken() return 5 end + +---@class Test.Child : Test.Builder + +---@param value self +---@return self +local function free(value) return value end + +---@type Test.Builder +local built = Builder.new() +---@type Test.Child +local child = {} +local chained = built:chain() +local inherited = child:chain() +local made = Builder.new() +local copied = Builder.copy() +local parent = built.parent +local loose = free(1) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("chained", "chained: Test.Builder"), + // As in lua-language-server, `self` is the class the method is defined on, not the receiver. + ("inherited", "inherited: Test.Builder"), + ("made", "made: Test.Builder"), + ("copied", "copied: Test.Builder"), + ("parent =", "parent: Test.Builder?"), + ("merged =", "merged: Test.Builder"), + // A function that belongs to no table has no class for `self`. + ("loose", "loose: unknown"), + ] { + let (l, c) = pos(text, &format!("local {needle}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let (l, c) = pos(text, "merge(other)", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("merge(other: Test.Builder): Test.Builder"), "{hover}"); + let mismatch = "Cannot return `integer` as return value #1 of type `Test.Builder`"; + assert_eq!( + findings(&mut client, CLIENT, &["return-type-mismatch", "missing-return", "undefined-doc-name"]), + [("return-type-mismatch".to_string(), pos(text, "return 5", 0).0 as u64, mismatch.to_string())] + ); +} + #[test] fn hover_expands_aliases_and_lists_members_only_for_tables() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 447c317..92dd44f 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -287,7 +287,8 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { "field" => parse_field(rest, index, &mut group), "overload" if !group.classes.is_empty() && !group.has_function_tags() => { if let (Some(fun), Some(class)) = (overload(rest), group.classes.last_mut()) { - class.call = Some(fun); + let owner = Type::Named(class.name.clone(), Vec::new()); + class.call = Some(if fun.mentions_self() { Arc::new(fun.with_self(&owner)) } else { fun }); } } "overload" => group.overloads.extend(overload(rest)), @@ -408,14 +409,22 @@ fn field_head(rest: &str) -> (Option, &str) { (side_attribute(before).or(side_attribute(after)), rest) } +/// `ty`, declared for the class `class`, with `self` standing for that class. +fn in_class(class: &DocClass, ty: Type) -> Type { + match ty.mentions_self() { + true => ty.with_self(&Type::Named(class.name.clone(), Vec::new())), + false => ty, + } +} + fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { let Some(class) = group.classes.last_mut() else { return }; let (side, rest) = field_head(rest); if let Some(index) = rest.strip_prefix('[') { let mut parser = TypeParser::new(index); - let key = parser.parse(); + let key = in_class(class, parser.parse()); let after = parser.rest().trim_start().strip_prefix(']').unwrap_or(parser.rest()); - let value = TypeParser::new(after).parse(); + let value = in_class(class, TypeParser::new(after).parse()); match key { Type::StringLit(name) => class.fields.push(DocField { name, ty: value, line, side, ..DocField::default() }), key @ (Type::IntLit(_) | Type::BooleanLit(_)) => { @@ -429,7 +438,7 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { let Some(name) = parser.ident() else { return }; let optional = parser.rest().starts_with('?'); let mut parser = TypeParser::new(parser.rest().trim_start_matches('?')); - let ty = parser.parse(); + let ty = in_class(class, parser.parse()); let field = DocField { name: SmolStr::new(name), ty, optional, description: clean_description(parser.rest()), line, side }; if let Some(field) = add_signature(&mut class.fields, field) { @@ -755,6 +764,21 @@ mod tests { assert_eq!(doc.aliases[1].ty.to_string(), "integer|string"); } + #[test] + fn self_in_class_fields_names_the_class() { + let doc = parse( + "---@class Node\n---@field parent self?\n---@field children self[]\n---@field [string] fun(self: self): self\n---@overload fun(parent: self): self", + ); + let class = &doc.classes[0]; + let fields: Vec = class.fields.iter().map(|f| f.ty.to_string()).collect(); + assert_eq!(fields, ["Node?", "Node[]"]); + assert_eq!(class.indices[0].ty.to_string(), "fun(self: Node): Node"); + assert_eq!(Type::Fun(class.call.clone().unwrap()).to_string(), "fun(parent: Node): Node"); + + let doc = parse("---@param other self\n---@return self"); + assert_eq!(doc.params[0].ty.to_string(), "self", "a function's `self` depends on where it is defined"); + } + #[test] fn literal_keyed_fields() { let doc = parse( diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index 5a19163..b0046bf 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -227,6 +227,52 @@ impl Type { pub fn first_return(&self) -> Type { self.as_fun().and_then(|f| f.returns.first().cloned()).unwrap_or_default() } + + /// Whether the type names `self`, which a doc comment writes for the class of the table its + /// function or field belongs to. + pub fn mentions_self(&self) -> bool { + match self { + Type::Named(name, args) => (name == "self" && args.is_empty()) || args.iter().any(Type::mentions_self), + Type::Array(inner) | Type::Variadic(inner) => inner.mentions_self(), + Type::Map(key, value) => key.mentions_self() || value.mentions_self(), + Type::Tuple(types) | Type::Union(types) => types.iter().any(Type::mentions_self), + Type::Fun(fun) => fun.mentions_self(), + Type::Shape(shape) => { + shape.fields.iter().any(|field| field.ty.mentions_self()) + || shape.array.as_ref().is_some_and(Type::mentions_self) + || shape.index.as_ref().is_some_and(|(key, value)| key.mentions_self() || value.mentions_self()) + } + _ => false, + } + } + + /// The type with each `self` replaced by `owner`, the class it stands for. With no class to + /// stand for, `self?` is unknown rather than `nil`. + pub fn with_self(&self, owner: &Type) -> Type { + match self { + Type::Named(name, args) if name == "self" && args.is_empty() => owner.clone(), + Type::Named(name, args) => Type::Named(name.clone(), args.iter().map(|t| t.with_self(owner)).collect()), + Type::Array(inner) => Type::Array(Box::new(inner.with_self(owner))), + Type::Variadic(inner) => Type::Variadic(Box::new(inner.with_self(owner))), + Type::Map(key, value) => Type::Map(Box::new(key.with_self(owner)), Box::new(value.with_self(owner))), + Type::Tuple(types) => Type::Tuple(types.iter().map(|t| t.with_self(owner)).collect()), + Type::Union(types) => { + let parts: Vec = types.iter().map(|t| t.with_self(owner)).collect(); + if parts.iter().filter(|t| !matches!(t, Type::Nil)).all(Type::is_unknown) { + Type::Unknown + } else { + Type::union(parts) + } + } + Type::Fun(fun) => Type::Fun(Arc::new(fun.with_self(owner))), + Type::Shape(shape) => Type::Shape(Arc::new(Shape { + fields: shape.fields.iter().map(|f| ShapeField { ty: f.ty.with_self(owner), ..f.clone() }).collect(), + array: shape.array.as_ref().map(|t| t.with_self(owner)), + index: shape.index.as_ref().map(|(key, value)| (key.with_self(owner), value.with_self(owner))), + })), + other => other.clone(), + } + } } /// What each position holds across the sets of values a function returns. A set that ends before @@ -255,6 +301,26 @@ impl FunType { } } + /// Whether a parameter, returned value or overload names `self`. + pub fn mentions_self(&self) -> bool { + self.params.iter().any(|param| param.ty.mentions_self()) + || self.returns.iter().any(Type::mentions_self) + || self.overloads.iter().any(|overload| overload.mentions_self()) + } + + /// The function with each `self` in its parameters, returned values and overloads replaced by + /// `owner`. + pub fn with_self(&self, owner: &Type) -> FunType { + let types = |types: &[Type]| types.iter().map(|t| t.with_self(owner)).collect(); + FunType { + params: self.params.iter().map(|p| Param { ty: p.ty.with_self(owner), ..p.clone() }).collect(), + returns: types(&self.returns), + return_sets: self.return_sets.iter().map(|set| types(set)).collect(), + overloads: self.overloads.iter().map(|overload| Arc::new(overload.with_self(owner))).collect(), + ..self.clone() + } + } + pub fn signature(&self, name: &str) -> String { let params: Vec = self.params.iter().map(Param::to_string).collect(); let mut out = format!("function {name}({})", params.join(", ")); From 0bb9e85540b47cff2dfd0f7f443bf59445a31ac0 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:50:46 +0200 Subject: [PATCH 003/193] fix(luacats): read async fun(...) types --- crates/qbx_lua_ls/src/features/hover.rs | 4 ++- crates/qbx_lua_ls/tests/lsp.rs | 48 +++++++++++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 3 ++ crates/qbx_luacats/src/types.rs | 20 +++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index ecf435a..07314f1 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -85,9 +85,11 @@ const MAX_OVERVIEW_FIELDS: usize = 14; /// `name: type`, a function signature, or for tables an overview of the fields that are in scope /// for this file, with the literal values the index remembered. Aliases follow on their own lines. +/// The signature of a function that may yield starts with `(async)`, as in lua-language-server. fn describe_value(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> String { if let (Some(fun), Type::Fun(_)) = (ty.as_fun(), ty) { - return format!("{prefix}{}", fun.signature(name)); + let marker = if fun.is_async { "(async) " } else { "" }; + return format!("{marker}{prefix}{}", fun.signature(name)); } let mut out = value_overview(infer, prefix, name, ty, literal); for (alias, target) in alias_expansions(infer, ty) { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 6c006e7..fbba3d0 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3773,6 +3773,54 @@ local loose = free(1) ); } +#[test] +fn async_function_types_are_function_types() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type async fun(x: integer): string +local fetch + +---@param job async fun(): integer +local function run(job) + local done = job() + return done +end + +local fetched = fetch(1) +local ran = run(function() return 1 end) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [("fetched", "fetched: string"), ("done", "done: integer"), ("ran", "ran: integer")] { + let (l, c) = pos(text, &format!("local {needle}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + assert!(findings(&mut client, CLIENT, &["undefined-doc-name"]).is_empty()); + + // Hover keeps the `async`, as lua-language-server shows it. + let (l, c) = pos(text, "fetch(1)", 0); + let fetch = client.hover_text(CLIENT, l, c); + assert!(fetch.contains("(async) local function fetch(x: integer): string"), "{fetch}"); + let (l, c) = pos(text, "run(job)", 0); + let run = client.hover_text(CLIENT, l, c); + assert!(run.contains("local function run(job: async fun(): integer)"), "{run}"); +} + +#[test] +fn functions_tagged_async_show_it_in_hover() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@async +---@param ms integer +local function sleep(ms) end +sleep(1) +"; + client.open_with(CLIENT, text); + let (l, c) = pos(text, "sleep(1)", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("(async) local function sleep(ms: integer)"), "{hover}"); +} + #[test] fn hover_expands_aliases_and_lists_members_only_for_tables() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 92dd44f..955f3c7 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -145,6 +145,7 @@ impl DocGroup { overloads, side: None, callback: self.callback.clone(), + is_async: self.is_async, } } @@ -921,6 +922,7 @@ mod tests { "@type table", "@type fun(value: Gar^age): boolean", "@type fun(): Gar^age", + "@type async fun(value: Gar^age)", "@type { value: Gar^age }", "@type { [Gar^age]: boolean }", "@type [string, Gar^age]", @@ -1006,6 +1008,7 @@ mod tests { "@type string Gar^age", "@type 'Gar^age'", "@type fun(Gar^age: string): boolean", + "@type as^ync fun(): string", "@type { Gar^age: string }", "@type str^ing", "@generic Gar^age: string", diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index b0046bf..b9ea116 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -68,6 +68,8 @@ pub struct FunType { pub side: Option, /// What a function tagged `@callback` does with the callback names passed to it. pub callback: Option, + /// `async fun(...)` or `---@async`: the function may yield, so it runs in a coroutine. + pub is_async: bool, } /// The role of a function tagged `---@callback register|await|trigger [family]`, which wraps a @@ -405,6 +407,9 @@ impl fmt::Display for Type { } Type::Fun(fun) => { let params: Vec = fun.params.iter().map(Param::to_string).collect(); + if fun.is_async { + f.write_str("async ")?; + } write!(f, "fun({})", params.join(", "))?; if !fun.returns.is_empty() { write!(f, ": {}", fun.returns_text())?; @@ -680,6 +685,18 @@ impl<'a> TypeParser<'a> { let Some(name) = self.ident() else { return Type::Unknown; }; + // `async fun(...)`, a function that may yield, is the function type marked as async. + if name == "async" { + let after = self.pos; + self.skip_ws(); + if self.rest().starts_with("fun(") { + return match self.named() { + Type::Fun(fun) => Type::Fun(Arc::new(FunType { is_async: true, ..(*fun).clone() })), + other => other, + }; + } + self.pos = after; + } if name == "fun" && self.peek() == b'(' { return self.fun(); } @@ -813,6 +830,9 @@ mod tests { "fun(a: string, b?: number): boolean, string" ); assert_eq!(roundtrip("fun(...: any)"), "fun(...: any)"); + assert_eq!(roundtrip("async fun(x: integer): string"), "async fun(x: integer): string"); + assert_eq!(roundtrip("(async fun())[]"), "(async fun())[]"); + assert_eq!(roundtrip("async"), "async"); assert_eq!(roundtrip("{ name: string, age?: number }"), "{ name: string, age?: number }"); assert_eq!(roundtrip("{ [string]: boolean }"), "{ [string]: boolean }"); assert_eq!(roundtrip("'left'|'right'"), "\"left\"|\"right\""); From a7f3178127bad9e110928d7b202176e7fbfdc885 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:51:34 +0200 Subject: [PATCH 004/193] fix(luacats): continue @param, @return, @type and @field with ---| lines --- crates/qbx_lua_ls/README.md | 10 +-- crates/qbx_lua_ls/tests/lsp.rs | 42 +++++++++++ crates/qbx_luacats/src/luacats.rs | 118 +++++++++++++++++++++++------- 3 files changed, 140 insertions(+), 30 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index f6db7d0..4a2c8e2 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -20,11 +20,11 @@ Available features depend on the editor's LSP client. - Completion and hover for Lua symbols, FiveM natives, exports, events and callbacks. - Call snippets for functions that take a callback, with the function literal written out. -- The values a parameter lists, through aliases and `@overload`s, as soon as its argument starts: - strings, integers, `true` and `false`, and `nil` beside them. Strings are also offered inside - the argument's string, and a function literal with the matching parameters where an argument - takes one. Call snippets stop in the quotes of listed strings, and end after a value that picks - an overload, so the handler written next matches it. +- The values a parameter lists, through aliases, `---|` lines and `@overload`s, as soon as its + argument starts: strings, integers, `true` and `false`, and `nil` beside them. Strings are also + offered inside the argument's string, and a function literal with the matching parameters where + an argument takes one. Call snippets stop in the quotes of listed strings, and end after a value + that picks an overload, so the handler written next matches it. - The same values where a value of such a type, like `"busy"|"ready"`, `1|2|3` or `boolean`, is assigned, set as a field of a class, returned or compared with `==` or `~=`, and the function literal where a function is assigned or returned. They open on the space typed after the `=`, diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index fbba3d0..1780100 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5372,6 +5372,48 @@ function Emitter:on(event, mode) end assert_eq!(loaded.map(|item| &item["filterText"]), Some(&json!("playerLoaded")), "{result}"); } +#[test] +fn value_lines_list_more_values_of_params_returns_types_and_fields() { + let mut client = Client::start(fixture_root()); + let defs = "\ +---Runs the job. +---@param mode string +---| 'fast' +---| 'slow' +---@param level? integer +---| 1 +---| 2 +function RunJob(mode, level) end + +---@class Opts +---@field kind string +---| 'car' +---| 'boat' +"; + client.open_with("myresource/shared/config.lua", defs); + // The values offered where `$` is, in the order they are listed. + let mut values = |typed: &str| -> Vec { + let (line, column) = pos(typed, "$", 0); + client.open_with(CLIENT, &typed.replace('$', "")); + let result = client.request("textDocument/completion", client.position_params(CLIENT, line, column)); + let mut items = result["items"].as_array().cloned().unwrap_or_default(); + items.retain(|item| item["kind"] == 20 || item["kind"] == 14); + items.sort_by_key(|item| item["sortText"].as_str().unwrap_or_default().to_string()); + items.iter().map(|item| item["label"].as_str().unwrap().to_string()).collect() + }; + assert_eq!(values("RunJob($"), ["'fast'", "'slow'"]); + assert_eq!(values("RunJob('fast', $)"), ["1", "2"]); + assert_eq!(values("---@type string\n---| 'a'\n---| 'b'\nlocal letter = $"), ["'a'", "'b'"]); + assert_eq!(values("---@return string\n---| 'ok'\nlocal function status()\n\treturn $\nend"), ["'ok'"]); + assert_eq!(values("---@type Opts\nlocal opts = { kind = $ }"), ["'car'", "'boat'"]); + + let text = "RunJob('fast')"; + client.open_with(CLIENT, text); + let hover = client.hover_text(CLIENT, 0, 1); + assert!(hover.contains("RunJob(mode: string|\"fast\"|\"slow\", level?: integer|1|2)"), "{hover}"); + assert!(hover.contains("Runs the job.") && !hover.contains("'fast'"), "{hover}"); +} + #[test] fn values_offer_what_their_declared_type_lists() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 955f3c7..ddccd17 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -176,11 +176,45 @@ fn strip_visibility(mut rest: &str) -> &str { rest } -/// The type on a `---| value` line that continues an `@alias`, without its `>` or `+` marker. -fn alias_member(line: &str) -> Option<&str> { +/// The type on a `---| value` line, without its `>` or `+` marker. +fn listed_value(line: &str) -> Option<&str> { Some(line.trim_start().strip_prefix('|')?.trim_start_matches(['>', '+', ' '])) } +/// The declaration that the `---| value` lines after it list more values of. +#[derive(Clone, Copy)] +enum Listing { + Alias, + Param, + Return, + Type, + Field, +} + +impl Listing { + /// Adds `value` to the type of the latest such declaration of `group`. + fn add(self, group: &mut DocGroup, value: Type) { + let ty = match self { + Listing::Alias => group.aliases.last_mut().map(|alias| &mut alias.ty), + Listing::Param => group.params.last_mut().map(|param| &mut param.ty), + Listing::Return => group.returns.last_mut().map(|ret| &mut ret.ty), + Listing::Type => group.ty_rest.last_mut().or(group.ty.as_mut()), + Listing::Field => group.classes.last_mut().and_then(|class| class.fields.last_mut()).map(|f| &mut f.ty), + }; + if let Some(ty) = ty { + *ty = Type::union([std::mem::take(ty), value]); + } + // `---| nil` makes a parameter optional, as `@param name string|nil` does. + if let (Listing::Param, Some(param)) = (self, group.params.last_mut()) { + param.optional |= allows_nil(¶m.ty); + } + } +} + +fn allows_nil(ty: &Type) -> bool { + matches!(ty, Type::Union(types) if types.contains(&Type::Nil)) +} + /// Splits attributes such as `(exact)`, `(key)` or `(server)` off the front of a tag. A parenthesized /// type like the `(fun(): string)` of an `@overload` is no list of words and stays. fn split_attributes(rest: &str) -> (&str, &str) { @@ -243,25 +277,19 @@ fn overload(rest: &str) -> Option> { pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { let mut group = DocGroup::default(); let mut description: Vec<&str> = Vec::new(); - let mut open_alias = false; + let mut listing: Option = None; for (index, raw) in lines.iter().enumerate() { let line = raw.strip_prefix(' ').unwrap_or(raw); - if let Some(member) = alias_member(line) { - if open_alias { - if let Some(alias) = group.aliases.last_mut() { - let mut parser = TypeParser::new(member); - let ty = parser.parse(); - alias.ty = Type::union([std::mem::take(&mut alias.ty), ty]); - } - continue; - } + if let (Some(value), Some(listing)) = (listed_value(line), listing) { + listing.add(&mut group, TypeParser::new(value).parse()); + continue; } let Some((tag, rest)) = split_tag(line) else { description.push(line); continue; }; - open_alias = false; + listing = None; match tag { "class" => { let (attributes, rest) = split_attributes(rest); @@ -285,7 +313,11 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { ..DocClass::default() }); } - "field" => parse_field(rest, index, &mut group), + "field" => { + if parse_field(rest, index, &mut group) { + listing = Some(Listing::Field); + } + } "overload" if !group.classes.is_empty() && !group.has_function_tags() => { if let (Some(fun), Some(class)) = (overload(rest), group.classes.last_mut()) { let owner = Type::Named(class.name.clone(), Vec::new()); @@ -306,7 +338,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { line: index, side: side_attribute(attributes), }); - open_alias = true; + listing = Some(Listing::Alias); } "enum" => { let (attributes, rest) = split_attributes(rest); @@ -330,10 +362,11 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { let ty = parser.parse(); group.params.push(DocParam { name: SmolStr::new(name), - optional: optional || matches!(&ty, Type::Union(types) if types.contains(&Type::Nil)), + optional: optional || allows_nil(&ty), ty, description: clean_description(parser.rest()), }); + listing = Some(Listing::Param); } "return" => { let mut parser = TypeParser::new(rest); @@ -375,11 +408,13 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { } } } + listing = Some(Listing::Return); } "type" => { let mut types = TypeParser::new(rest).parse_list().into_iter(); group.ty = types.next(); group.ty_rest = types.collect(); + listing = Some(Listing::Type); } "generic" => { let names = rest.split(',').filter_map(|g| g.trim().split([':', ' ']).next()).filter(|g| !g.is_empty()); @@ -418,8 +453,10 @@ fn in_class(class: &DocClass, ty: Type) -> Type { } } -fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { - let Some(class) = group.classes.last_mut() else { return }; +/// Reads a `@field` into the latest class of `group`. True when it adds a field of its own, the +/// last of `fields`, which `---| value` lines after it list more values of. +fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { + let Some(class) = group.classes.last_mut() else { return false }; let (side, rest) = field_head(rest); if let Some(index) = rest.strip_prefix('[') { let mut parser = TypeParser::new(index); @@ -427,24 +464,27 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { let after = parser.rest().trim_start().strip_prefix(']').unwrap_or(parser.rest()); let value = in_class(class, TypeParser::new(after).parse()); match key { - Type::StringLit(name) => class.fields.push(DocField { name, ty: value, line, side, ..DocField::default() }), + Type::StringLit(name) => { + class.fields.push(DocField { name, ty: value, line, side, ..DocField::default() }); + return true; + } key @ (Type::IntLit(_) | Type::BooleanLit(_)) => { class.literal_fields.push(DocIndexField { key, ty: value, side }); } key => class.indices.push(DocIndexField { key, ty: value, side }), } - return; + return false; } let mut parser = TypeParser::new(rest); - let Some(name) = parser.ident() else { return }; + let Some(name) = parser.ident() else { return false }; let optional = parser.rest().starts_with('?'); let mut parser = TypeParser::new(parser.rest().trim_start_matches('?')); let ty = in_class(class, parser.parse()); let field = DocField { name: SmolStr::new(name), ty, optional, description: clean_description(parser.rest()), line, side }; - if let Some(field) = add_signature(&mut class.fields, field) { - class.fields.push(field); - } + let Some(field) = add_signature(&mut class.fields, field) else { return false }; + class.fields.push(field); + true } /// Adds a function field declared again under the name of an unscoped one as another signature of @@ -575,7 +615,7 @@ pub fn declared_generics(line: &str) -> Vec<&str> { /// The tag of a doc line and the class and alias names on it. fn type_names(line: &str) -> Option<(&str, Vec>)> { - let (tag, rest) = match alias_member(line) { + let (tag, rest) = match listed_value(line) { Some(member) => ("|", member), None => split_tag(line)?, }; @@ -780,6 +820,34 @@ mod tests { assert_eq!(doc.params[0].ty.to_string(), "self", "a function's `self` depends on where it is defined"); } + #[test] + fn listed_values_continue_params_returns_types_and_fields() { + let doc = parse( + "---Runs.\n---@param mode string the mode\n---| 'fast' # goes quickly\n---|> 'slow'\n---@param level\n---| 1\n---| 2\n---@param key string\n---| nil\n---@return integer, string\n---| 'x'", + ); + let params: Vec<(String, bool)> = doc.params.iter().map(|p| (p.ty.to_string(), p.optional)).collect(); + assert_eq!( + params, + [("string|\"fast\"|\"slow\"".into(), false), ("1|2".into(), false), ("string?".into(), true)] + ); + assert_eq!(doc.params[0].description, "the mode"); + let returns: Vec = doc.returns.iter().map(|r| r.ty.to_string()).collect(); + assert_eq!(returns, ["integer", "string|\"x\""], "the values belong to the last value of the line"); + assert_eq!(doc.description, "Runs.", "the listed values are no description"); + + let types = |doc: DocGroup| -> Vec { doc.ty.iter().chain(&doc.ty_rest).map(Type::to_string).collect() }; + assert_eq!(types(parse("---@type string\n---| 'a'\n---| 'b'")), ["string|\"a\"|\"b\""]); + assert_eq!(types(parse("---@type integer, string\n---| 'b'")), ["integer", "string|\"b\""]); + + let doc = parse("---@class Opts\n---@field mode string\n---| 'a'\n---@field [string] integer\n---| 'b'"); + assert_eq!(doc.classes[0].fields[0].ty.to_string(), "string|\"a\""); + assert_eq!(doc.classes[0].indices[0].ty.to_string(), "integer", "an index lists no values"); + + // Sets of returned values list theirs in parentheses. + let doc = parse("---@return false | (string, string)\n---| 'x'"); + assert_eq!(doc.returns.iter().map(|r| r.ty.to_string()).collect::>(), ["false|string", "string?"]); + } + #[test] fn literal_keyed_fields() { let doc = parse( From 62149e41cbb2711c9085fbfaacbfb837fc1ddae8 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:52:30 +0200 Subject: [PATCH 005/193] fix(luacats): show the descriptions of ---| values in completion and hover --- crates/qbx_lua_ls/README.md | 4 +- crates/qbx_lua_ls/src/callback_wrappers.rs | 2 +- .../qbx_lua_ls/src/features/class_tables.rs | 9 +- crates/qbx_lua_ls/src/features/completion.rs | 69 ++++++++-- crates/qbx_lua_ls/src/features/event_call.rs | 2 +- crates/qbx_lua_ls/src/features/expected.rs | 129 +++++++++++++++--- crates/qbx_lua_ls/src/features/hover.rs | 40 ++++-- crates/qbx_lua_ls/src/index.rs | 6 +- crates/qbx_lua_ls/src/indexer.rs | 36 ++++- crates/qbx_lua_ls/src/infer.rs | 2 +- crates/qbx_lua_ls/tests/lsp.rs | 79 +++++++++++ crates/qbx_luacats/src/luacats.rs | 113 ++++++++++++--- crates/qbx_luacats/src/types.rs | 17 ++- 13 files changed, 437 insertions(+), 71 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 4a2c8e2..54be386 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -24,7 +24,9 @@ Available features depend on the editor's LSP client. argument starts: strings, integers, `true` and `false`, and `nil` beside them. Strings are also offered inside the argument's string, and a function literal with the matching parameters where an argument takes one. Call snippets stop in the quotes of listed strings, and end after a value - that picks an overload, so the handler written next matches it. + that picks an overload, so the handler written next matches it. The `# description` of a value + on a `---|` line under an `@alias` or `@param` comes with it. Hovers of the alias, or of the + function for its `@param`s and `@return`s, list the described values. - The same values where a value of such a type, like `"busy"|"ready"`, `1|2|3` or `boolean`, is assigned, set as a field of a class, returned or compared with `==` or `~=`, and the function literal where a function is assigned or returned. They open on the space typed after the `=`, diff --git a/crates/qbx_lua_ls/src/callback_wrappers.rs b/crates/qbx_lua_ls/src/callback_wrappers.rs index 0db30bd..73ac734 100644 --- a/crates/qbx_lua_ls/src/callback_wrappers.rs +++ b/crates/qbx_lua_ls/src/callback_wrappers.rs @@ -121,7 +121,7 @@ pub fn response_function(handler: &FunType) -> Type { .map(|(i, ty)| Param { name: if i == 0 { "response".into() } else { format!("response{}", i + 1).into() }, ty: ty.clone(), - optional: false, + ..Param::default() }) .collect(); Type::Fun(Arc::new(FunType { params, ..FunType::default() })) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 0dbd4fe..4e3d3a9 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -20,7 +20,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; use crate::infer::Infer; use crate::luacats::applies_on; -use crate::types::Type; +use crate::types::{DescribedValue, Type}; const MAX_DEPTH: u32 = 8; @@ -37,6 +37,8 @@ pub struct ClassField { pub name: SmolStr, pub ty: Type, pub doc: Option>, + /// The values that `---|` lines list under the field, with their descriptions. + pub values: Vec, /// The file that declares the field, whose view of the type names its type is read with. pub file: FileId, } @@ -220,10 +222,11 @@ impl<'a, 'b> Classes<'a, 'b> { let defs = self.class_defs(class, from); for (file, def) in &defs { let sides = def.field_sides.iter().copied().chain(std::iter::repeat(None)); - for (field, side) in def.fields.iter().zip(sides) { + for (i, (field, side)) in def.fields.iter().zip(sides).enumerate() { if applies_on(side, self.infer.side()) && !out.iter().any(|seen| seen.name == field.name) { let (name, ty, doc) = (field.name.clone(), field.ty.clone(), field.doc.clone()); - out.push(ClassField { name, ty, doc, file: *file }); + let values = def.field_values.get(i).cloned().unwrap_or_default(); + out.push(ClassField { name, ty, doc, values, file: *file }); } } } diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index 7a44f59..9900a7a 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -23,7 +23,7 @@ use crate::document::Document; use crate::index::{EventFamily, EventKind, FileOrigin, SymbolKind}; use crate::infer::{native_fun_type, Infer, MemberInfo}; use crate::locate::{locate, string_content_span}; -use crate::types::{CallbackRole, FunType, Param, Type}; +use crate::types::{CallbackRole, DescribedValue, FunType, Param, Type}; use crate::workspace::Workspace; const MAX_NATIVES: usize = 120; @@ -636,7 +636,7 @@ fn value_items( let space = if typed.len() == head.len() { " " } else { "" }; with_infer(ws, doc, |infer| { let Some(expected) = expected_type(infer, &doc.chunk, Place::After(at)) else { return Vec::new() }; - let values = listed_values(infer, &expected.ty); + let values = listed_values(infer, &expected.ty, &expected.values); let mut items: Vec = values.iter().enumerate().map(|(i, value)| value.written_item(i, quote, space)).collect(); if snippets && !expected.compared { @@ -811,7 +811,7 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) let Some(expected) = expected_type(infer, &doc.chunk, Place::String(string.span)) else { return Vec::new(); }; - let values = listed_values(infer, &expected.ty); + let values = listed_values(infer, &expected.ty, &expected.values); values.iter().enumerate().filter_map(|(i, value)| value.content_item(i, range, quote)).collect() }); }; @@ -924,6 +924,8 @@ struct ListedValue { ty: Type, /// The signatures that take it alone, which a call passing it picks. taken_alone_by: Vec, + /// What the `---|` line that lists it says about it, or empty. + description: String, } impl ListedValue { @@ -947,9 +949,16 @@ impl ListedValue { if !self.ty.is_literal() { out.detail = Some(self.ty.to_string()); } + let mut documentation = self.description.clone(); if !self.taken_alone_by.is_empty() { - let signatures = self.taken_alone_by.join("\n"); - out.documentation = Some(Documentation::MarkupContent(markdown(lua_block(&signatures)))); + let signatures = lua_block(&self.taken_alone_by.join("\n")); + documentation = match documentation.is_empty() { + true => signatures, + false => format!("{documentation}\n\n{signatures}"), + }; + } + if !documentation.is_empty() { + out.documentation = Some(Documentation::MarkupContent(markdown(documentation))); } out } @@ -982,10 +991,38 @@ impl ListedValue { } } -/// The values that `ty` lists, in the order they are declared. -fn listed_values(infer: &Infer, ty: &Type) -> Vec { +/// The values that `ty` lists, in the order they are declared, described by `described_by`, the +/// `---|` lines under the annotation that declares `ty`, or else by those of its aliases. +fn listed_values(infer: &Infer, ty: &Type, described_by: &[DescribedValue]) -> Vec { let values = infer.listed_literals(ty).into_iter(); - values.map(|value| ListedValue { value, ty: ty.clone(), taken_alone_by: Vec::new() }).collect() + values + .map(|value| { + let description = described(described_by, &value).or_else(|| alias_value_description(infer, ty, &value, 0)); + let description = description.unwrap_or_default(); + ListedValue { value, ty: ty.clone(), taken_alone_by: Vec::new(), description } + }) + .collect() +} + +/// What the `---|` line of an alias in `ty` that lists `value` says about it. +fn alias_value_description(infer: &Infer, ty: &Type, value: &Type, depth: u32) -> Option { + if depth > 8 { + return None; + } + match ty { + Type::Named(name, _) if infer.index.class(name, infer.side()).is_none() => { + let (_, alias) = infer.index.alias(name, infer.side())?; + described(&alias.values, value).or_else(|| alias_value_description(infer, &alias.ty, value, depth + 1)) + } + Type::Union(types) => types.iter().find_map(|part| alias_value_description(infer, part, value, depth + 1)), + _ => None, + } +} + +/// The description of `value` among the values of `---|` lines. +fn described(values: &[DescribedValue], value: &Type) -> Option { + let listed = values.iter().find(|listed| listed.value == *value && !listed.description.is_empty()); + listed.map(|listed| listed.description.clone()) } /// The values that the parameter of the argument at `arg_index` lists in `signatures`, in the @@ -1004,15 +1041,27 @@ fn argument_literals( for (signature, _) in signatures { let Some(param) = param_for_argument(signature, arg_index, via_method) else { continue }; let pinned = infer.pinned_literal(¶m.ty); - for value in infer.listed_literals(¶m.ty) { + let mut values = infer.listed_literals(¶m.ty); + // The type of `x any` keeps none of the values that the `---|` lines under it list. + if values.is_empty() && !param.values.is_empty() { + let listed = param.values.iter().map(|listed| listed.value.clone()).collect(); + values = infer.listed_literals(&Type::Union(listed)); + } + for value in values { let index = match literals.iter().position(|known| known.value == value) { Some(index) => index, None => { let ty = param.ty.clone(); - literals.push(ListedValue { value: value.clone(), ty, taken_alone_by: Vec::new() }); + let taken_alone_by = Vec::new(); + literals.push(ListedValue { value: value.clone(), ty, taken_alone_by, description: String::new() }); literals.len() - 1 } }; + if literals[index].description.is_empty() { + let description = + described(¶m.values, &value).or_else(|| alias_value_description(infer, ¶m.ty, &value, 0)); + literals[index].description = description.unwrap_or_default(); + } if pinned.as_ref() == Some(&value) { literals[index].taken_alone_by.push(signature.signature(name)); } diff --git a/crates/qbx_lua_ls/src/features/event_call.rs b/crates/qbx_lua_ls/src/features/event_call.rs index 4f1fe40..f9542f6 100644 --- a/crates/qbx_lua_ls/src/features/event_call.rs +++ b/crates/qbx_lua_ls/src/features/event_call.rs @@ -112,7 +112,7 @@ pub fn event_call( } else { (format!("arg{}", params.len() + 1).into(), Type::Unknown) }; - params.push(Param { name, ty, optional: false }); + params.push(Param { name, ty, ..Param::default() }); } params.extend(handler.params.iter().skip(handler_skip).cloned()); diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index 3cbfe72..dec6119 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -8,10 +8,13 @@ use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; use rustc_hash::FxHashMap; +use qbx_lua_analysis::scope::Resolved; + use super::class_tables::{class_tables, entries, Classes, Key}; use super::comparisons::Declared; -use crate::infer::Infer; -use crate::types::Type; +use crate::index::FileId; +use crate::infer::{Decl, Infer}; +use crate::types::{DescribedValue, Type}; /// Where a value is written. #[derive(Clone, Copy)] @@ -29,14 +32,20 @@ pub struct ExpectedValue { /// The value is compared with one of this type rather than stored as one, so any value the /// type lists can be written, but not a new value of the type such as a function. pub compared: bool, + /// What the `---|` lines under the annotation that declares `ty` say about the values they list. + pub values: Vec, } impl ExpectedValue { - fn stored(ty: Type) -> Self { - Self { ty, compared: false } + fn stored(ty: Type, values: Vec) -> Self { + Self { ty, compared: false, values } } } +/// The types of the values a function returns, each with the values the `---|` lines under its +/// `@return` describe. +type Returns = Vec<(Type, Vec)>; + /// The type that the value at `place` has to be, when something declares it. pub fn expected_type(infer: &Infer, chunk: &Chunk, place: Place) -> Option { let mut finder = Finder { @@ -56,10 +65,10 @@ struct Finder<'a, 'b> { declared: Declared<'a, 'b>, place: Place, /// The `@return` types of the functions around the statement being visited, innermost last. - function_returns: Vec>, + function_returns: Vec, /// The `@return` types of the functions whose doc comment was visited, by the start of their /// parameter list. - documented: FxHashMap>, + documented: FxHashMap, found: Option, } @@ -85,11 +94,89 @@ impl Finder<'_, '_> { } /// The type of the `@field` that `key` sets in `table`, when the table is typed as a class. - fn field(&self, table: &Expr, key: &Key) -> Option { + fn field(&self, table: &Expr, key: &Key) -> Option { let tables = class_tables(self.infer, self.infer.ctx.chunk); let found = tables.into_iter().find(|found| found.table.span == table.span)?; - Classes::new(self.infer).field_type(&found.class, found.from, key).map(|(ty, _)| ty) + let classes = Classes::new(self.infer); + let (ty, _) = classes.field_type(&found.class, found.from, key)?; + let values = match key { + Key::Name(name) => field_values(&classes, &found.class, found.from, name), + Key::Typed(_) => Vec::new(), + }; + Some(ExpectedValue::stored(ty, values)) + } + + /// What the `---|` lines that declare the values of `expr` say about them: those under the + /// `---@type` or `@param` of a local, the `@return` of the call that gives it, or the `@field` + /// of the class that `expr` reads it from. + fn described(&self, expr: &Expr) -> Vec { + let ctx = self.infer.ctx; + match &expr.unparen().kind { + ExprKind::Name(name) => { + let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(name.span.start) else { return Vec::new() }; + match ctx.decl(ctx.resolution.local(id).decl.start) { + Some(Decl::Local { stmt, index }) => { + let doc = ctx.doc_at(stmt.span.start); + let StmtKind::Local { exprs, .. } = &stmt.kind else { return Vec::new() }; + if doc.type_at(*index).is_some() { + return doc.type_values_at(*index).to_vec(); + } + match (exprs.get(*index), exprs.last()) { + (Some(value), _) => self.described(value), + (None, Some(last)) if last.is_multi_value() => self.returned(last, index + 1 - exprs.len()), + (None, _) => Vec::new(), + } + } + Some(Decl::Param { func, index, doc_anchor: Some(anchor), .. }) => { + let name = &func.params[*index].text; + let doc = ctx.doc_at(*anchor); + doc.params + .iter() + .find(|param| param.name == *name) + .map(|param| param.values.clone()) + .unwrap_or_default() + } + _ => Vec::new(), + } + } + ExprKind::Field { base, name, .. } => self.field_described(base, &name.text), + ExprKind::Index { base, index, .. } => match index.as_string() { + Some(name) => self.field_described(base, name), + None => Vec::new(), + }, + ExprKind::Call { .. } | ExprKind::MethodCall { .. } => self.returned(expr, 0), + _ => Vec::new(), + } + } + + /// The described values of the `@field` called `name` of the class that `base` is. + fn field_described(&self, base: &Expr, name: &str) -> Vec { + let classes = Classes::new(self.infer); + let from = classes.file(); + match classes.class_of(&self.infer.expr(base), from) { + Some(class) => field_values(&classes, &class, from, name), + None => Vec::new(), + } } + + /// The described values of the value at `index` of those `call` returns, as the signature it + /// picks declares them. + fn returned(&self, call: &Expr, index: usize) -> Vec { + let (base, method, args) = match &call.kind { + ExprKind::Call { callee, args, .. } => (callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => (base, Some(method), args), + _ => return Vec::new(), + }; + let Some((fun, _)) = self.infer.callee_fun(base, method) else { return Vec::new() }; + let fun = self.infer.call_signature(&fun, args, method.is_some(), base.span.start); + fun.return_values.get(index).cloned().unwrap_or_default() + } +} + +/// The described values of the `@field` called `name` of `class`, or of a parent, as `from` sees it. +fn field_values(classes: &Classes, class: &str, from: FileId, name: &str) -> Vec { + let mut fields = classes.fields(class, from).into_iter(); + fields.find(|field| field.name == name).map(|field| field.values).unwrap_or_default() } impl<'c> Visitor<'c> for Finder<'_, '_> { @@ -99,8 +186,9 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { } for (doc, functions) in self.infer.ctx.function_docs(stmt) { for func in functions { - let returns = doc.returns.iter().map(|r| self.infer.doc_type_for(stmt, func, &r.ty)).collect(); - self.documented.insert(func.params_span.start, returns); + let returns = + doc.returns.iter().map(|r| (self.infer.doc_type_for(stmt, func, &r.ty), r.values.clone())); + self.documented.insert(func.params_span.start, returns.collect()); } } match &stmt.kind { @@ -109,16 +197,20 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { let doc = self.infer.ctx.doc_at(stmt.span.start); // `---@class Name` above a table declares the class rather than an instance of it. if doc.classes.is_empty() { - self.found = doc.type_at(index).cloned().map(ExpectedValue::stored); + let values = doc.type_values_at(index).to_vec(); + self.found = doc.type_at(index).map(|ty| ExpectedValue::stored(ty.clone(), values)); } } } StmtKind::Assign { targets, exprs } => { if let Some(index) = exprs.iter().position(|expr| self.is_value(expr)) { let doc = self.infer.ctx.doc_at(stmt.span.start); - let declared = doc.type_at(index).cloned(); - let ty = declared.or_else(|| targets.get(index).map(|target| self.declared.of(target))); - self.found = ty.map(ExpectedValue::stored); + self.found = match doc.type_at(index) { + Some(ty) => Some(ExpectedValue::stored(ty.clone(), doc.type_values_at(index).to_vec())), + None => targets + .get(index) + .map(|target| ExpectedValue::stored(self.declared.of(target), self.described(target))), + }; } } StmtKind::Return(exprs) => { @@ -127,8 +219,8 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { Place::String(_) => exprs.iter().position(|expr| self.is_value(expr)), }; if let Some(index) = index { - let returns = self.function_returns.last(); - self.found = returns.and_then(|returns| returns.get(index)).cloned().map(ExpectedValue::stored); + let returned = self.function_returns.last().and_then(|returns| returns.get(index)).cloned(); + self.found = returned.map(|(ty, values)| ExpectedValue::stored(ty, values)); } } _ => {} @@ -157,14 +249,15 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { Place::String(_) => self.is_value(lhs).then_some(rhs), }; if let Some(other) = other { - self.found = Some(ExpectedValue { ty: self.declared.of(other), compared: true }); + let (ty, values) = (self.declared.of(other), self.described(other)); + self.found = Some(ExpectedValue { ty, compared: true, values }); } } ExprKind::Table(fields) => { let mut entries = entries(self.infer, fields).into_iter(); let entry = entries.find(|(_, _, value)| value.is_some_and(|value| self.is_value(value))); if let Some((key, ..)) = entry { - self.found = self.field(expr, &key).map(ExpectedValue::stored); + self.found = self.field(expr, &key); } } _ => {} diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 07314f1..0076642 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -9,12 +9,12 @@ use qbx_lua_syntax::{CommentKind, SmolStr, Span}; use super::{lua_block, markdown, with_infer}; use crate::callback_wrappers::{source_skip, target_of, Wrapper}; use crate::document::Document; -use crate::index::{ClassDef, EventDef, EventFamily, EventKind, FileId, FileOrigin, SymbolKind}; -use crate::indexer::render_doc; +use crate::index::{AliasDef, ClassDef, EventDef, EventFamily, EventKind, FileId, FileOrigin, SymbolKind}; +use crate::indexer::{described_values, render_doc}; use crate::infer::{Decl, Infer, MemberInfo}; use crate::locate::locate; use crate::luacats::{applies_on, type_name_at}; -use crate::types::{CallbackRole, Type}; +use crate::types::{CallbackRole, DescribedValue, Type}; use crate::workspace::Workspace; pub enum Target { @@ -92,8 +92,9 @@ fn describe_value(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O return format!("{marker}{prefix}{}", fun.signature(name)); } let mut out = value_overview(infer, prefix, name, ty, literal); - for (alias, target) in alias_expansions(infer, ty) { - out.push_str(&format!("\ntype {alias} = {target}")); + for (name, alias) in alias_expansions(infer, ty) { + out.push('\n'); + out.push_str(&alias_definition(&name, alias)); } out } @@ -160,12 +161,12 @@ fn table_part(infer: &Infer, ty: &Type, depth: u32) -> Type { /// The aliases in `ty` that stand for something other than a table, such as `"male"|"female"`, /// expanded one layer the way a class is expanded into its fields. -fn alias_expansions(infer: &Infer, ty: &Type) -> Vec<(SmolStr, Type)> { +fn alias_expansions<'a>(infer: &Infer<'a>, ty: &Type) -> Vec<(SmolStr, &'a AliasDef)> { let parts = match ty { Type::Union(types) => types.as_slice(), other => std::slice::from_ref(other), }; - let mut out: Vec<(SmolStr, Type)> = Vec::new(); + let mut out: Vec<(SmolStr, &AliasDef)> = Vec::new(); for part in parts { let part = match part { Type::Array(inner) => &**inner, @@ -177,12 +178,33 @@ fn alias_expansions(infer: &Infer, ty: &Type) -> Vec<(SmolStr, Type)> { } let Some((_, alias)) = infer.index.alias(name, infer.side()) else { continue }; if table_part(infer, &alias.ty, 0).is_unknown() { - out.push((name.clone(), alias.ty.clone())); + out.push((name.clone(), alias)); } } out } +/// An alias as written out in a hover: `type Mode = "fast"|"slow"`, or with each value on a line of +/// its own when the `---|` lines that list them describe any. +fn alias_definition(name: &str, alias: &AliasDef) -> String { + if alias.values.iter().all(|listed| listed.description.is_empty()) { + return format!("type {name} = {}", alias.ty); + } + let parts = match &alias.ty { + Type::Union(types) => types.as_slice(), + other => std::slice::from_ref(other), + }; + let values: Vec = parts + .iter() + .map(|value| { + let listed = alias.values.iter().find(|listed| listed.value == *value); + let description = listed.map(|listed| listed.description.clone()).unwrap_or_default(); + DescribedValue { value: value.clone(), description } + }) + .collect(); + format!("type {name} ={}", described_values(&values)) +} + /// `offset` is where the name is written, which decides what the guards around it rule out. fn local_hover(infer: &Infer, id: LocalId, offset: u32, called: Option) -> String { let local = infer.ctx.resolution.local(id); @@ -333,7 +355,7 @@ fn type_hover(infer: &Infer, name: &str) -> Option { } let aliases = infer.index.alias_defs(name).into_iter(); let (_, alias) = aliases.max_by_key(|(file, alias)| preference(*file, alias.side))?; - let mut out = lua_block(&format!("type {name} = {}", alias.ty)); + let mut out = lua_block(&alias_definition(name, alias)); if let Some(doc) = &alias.doc { out.push_str("\n\n"); out.push_str(doc); diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 1c27208..683c15c 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -10,7 +10,7 @@ use rustc_hash::FxHashMap; use smol_str::SmolStr; use crate::luacats::{applies_on, DocIndexField}; -use crate::types::{FunType, Type}; +use crate::types::{DescribedValue, FunType, Type}; pub type FileId = u32; pub type ResourceId = u32; @@ -72,6 +72,8 @@ pub struct ClassDef { pub fields: Vec, /// The side each of `fields` is scoped to by `@field (server) name type`. pub field_sides: Vec>, + /// The values that `---|` lines list under each of `fields`, with their descriptions. + pub field_values: Vec>, pub indices: Vec, /// `---@field [1] number` and `---@field [true] string`: fields keyed by an integer or boolean /// literal, with their values, in declaration order. @@ -108,6 +110,8 @@ pub struct AliasDef { pub range: Range, /// The side of `@alias (server) Name` or `@enum (server) Name`. pub side: Option, + /// The values that the `---|` lines under `@alias` list, with their descriptions. + pub values: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index a50bc6a..91d8e5c 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -16,7 +16,7 @@ use crate::index::{ }; use crate::infer::{table_elements, table_fields, FileContext, Infer}; use crate::luacats::{parse_doc_lines, DocGroup}; -use crate::types::{CallbackRole, FunType, Type}; +use crate::types::{CallbackRole, DescribedValue, FunType, Type}; const MAX_TABLE_DEPTH: u32 = 4; const MAX_TABLE_FIELDS: usize = 400; @@ -44,10 +44,37 @@ pub fn render_doc(doc: &DocGroup) -> Option> { let name = ret.name.as_deref().unwrap_or("returns"); let _ = write!(out, "\n\n*{name}*: {}", ret.description); } + // The values that `---|` lines list under a parameter or returned value, as lua-language-server + // shows them, when any of them is described. + let params = doc.params.iter().map(|param| (param.name.to_string(), ¶m.values)); + let returns = doc.returns.iter().enumerate().map(|(i, ret)| { + let name = ret.name.as_ref().map_or_else(|| format!("return #{}", i + 1), SmolStr::to_string); + (name, &ret.values) + }); + let listed: Vec = params + .chain(returns) + .filter(|(_, values)| values.iter().any(|v| !v.description.is_empty())) + .map(|(name, values)| format!("{name}:{}", described_values(values))) + .collect(); + if !listed.is_empty() { + let _ = write!(out, "\n\n```lua\n{}\n```", listed.join("\n\n")); + } let out = out.trim(); (!out.is_empty()).then(|| Arc::from(out)) } +/// Values listed by `---|` lines, one indented line each, with their descriptions as comments. +pub fn described_values(values: &[DescribedValue]) -> String { + let mut out = String::new(); + for listed in values { + let _ = write!(out, "\n | {}", listed.value); + if !listed.description.is_empty() { + let _ = write!(out, " -- {}", listed.description); + } + } + out +} + pub fn index_file( file: FileId, source: &str, @@ -182,9 +209,10 @@ impl<'a> Indexer<'a> { let lines: Vec<&str> = group.iter().map(|c| c.span.text(self.source).strip_prefix("---").unwrap_or_default()).collect(); let doc = parse_doc_lines(&lines); - for class in doc.classes { + for mut class in doc.classes { let range = self.range(group[class.line.min(group.len() - 1)].span); let field_sides = class.fields.iter().map(|field| field.side).collect(); + let field_values = class.fields.iter_mut().map(|field| std::mem::take(&mut field.values)).collect(); let fields = class .fields .into_iter() @@ -203,6 +231,7 @@ impl<'a> Indexer<'a> { parents: class.parents, fields, field_sides, + field_values, indices: class.indices, literal_fields: class.literal_fields, call: class.call, @@ -220,6 +249,7 @@ impl<'a> Indexer<'a> { doc: (!alias.description.is_empty()).then(|| Arc::from(alias.description.as_str())), range, side: alias.side, + values: alias.values, }); } } @@ -341,7 +371,7 @@ impl<'a> Indexer<'a> { }) .collect(); let range = self.range(span); - self.out.aliases.push(AliasDef { name, ty: Type::union(values), doc: None, range, side }); + self.out.aliases.push(AliasDef { name, ty: Type::union(values), doc: None, range, side, values: Vec::new() }); } fn push_element(&mut self, owner: SmolStr, key: Option, value: Type) { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index a57cd35..8555957 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -467,7 +467,7 @@ pub fn native_fun_type(native: &qbx_fivem_data::Native) -> FunType { FunType { params: native .params() - .map(|(name, ty)| Param { name: SmolStr::new(name), ty: native_type(ty), optional: false }) + .map(|(name, ty)| Param { name: SmolStr::new(name), ty: native_type(ty), ..Param::default() }) .collect(), returns: native.returns().map(native_type).collect(), ..FunType::default() diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 1780100..df9c1c1 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5414,6 +5414,85 @@ function RunJob(mode, level) end assert!(hover.contains("Runs the job.") && !hover.contains("'fast'"), "{hover}"); } +#[test] +fn value_lines_describe_their_values_in_completion_and_hover() { + let mut client = Client::start(fixture_root()); + let defs = "\ +---@alias Speed +---| 'fast' # goes quickly +---| 'slow' # takes its time + +---@param speed Speed +---@param mode string +---| 'once' # runs one time +---| 'loop' +---@return boolean +---| nil # when it did not start +function StartTask(speed, mode) end + +---@param target any +---| 'self' # the caller +---| 'all' +function Notify(target) end + +---@return +---| 'on' # switched on +---| 'off' # switched off +function GetSwitch() end + +---@class Test.Lamp +---@field color +---| 'red' # warm light +---| 'blue' +Lamp = {} +"; + client.open_with("myresource/shared/config.lua", defs); + // The values offered where `$` is, with their documentation. + let mut documented = |typed: &str| -> Vec<(String, String)> { + let (line, column) = pos(typed, "$", 0); + client.open_with(CLIENT, &typed.replace('$', "")); + let result = client.request("textDocument/completion", client.position_params(CLIENT, line, column)); + let mut items = result["items"].as_array().cloned().unwrap_or_default(); + items.retain(|item| item["kind"] == 20); + items.sort_by_key(|item| item["sortText"].as_str().unwrap_or_default().to_string()); + let doc = |item: &Value| item["documentation"]["value"].as_str().unwrap_or_default().to_string(); + items.iter().map(|item| (item["label"].as_str().unwrap().to_string(), doc(item))).collect() + }; + let pairs = |pairs: &[(&str, &str)]| -> Vec<(String, String)> { + pairs.iter().map(|(label, doc)| (label.to_string(), doc.to_string())).collect() + }; + let speeds = pairs(&[("'fast'", "goes quickly"), ("'slow'", "takes its time")]); + assert_eq!(documented("StartTask($)"), speeds); + assert_eq!(documented("StartTask('fast', $)"), pairs(&[("'once'", "runs one time"), ("'loop'", "")])); + assert_eq!(documented("---@type Speed\nlocal speed = $"), speeds); + // `any` keeps none of the values listed under it, which are offered all the same. + assert_eq!(documented("Notify($)"), pairs(&[("'self'", "the caller"), ("'all'", "")])); + assert_eq!(documented("Notify('$')"), pairs(&[("self", "the caller"), ("all", "")])); + // The values listed under `@return`, `@type` and `@field`, where a value of them is written. + let switches = pairs(&[("'on'", "switched on"), ("'off'", "switched off")]); + assert_eq!(documented("print(GetSwitch() == $)"), switches); + assert_eq!(documented("local switch = GetSwitch()\nprint(switch == $)"), switches); + let directions = pairs(&[("'up'", "going up"), ("'down'", "")]); + assert_eq!(documented("---@type\n---| 'up' # going up\n---| 'down'\nlocal direction = $"), directions); + let answer = "---@return\n---| 'yes' # agreed\n---| 'no'\nlocal function answer()\n return $\nend"; + assert_eq!(documented(answer), pairs(&[("'yes'", "agreed"), ("'no'", "")])); + let colors = pairs(&[("'red'", "warm light"), ("'blue'", "")]); + assert_eq!(documented("---@type Test.Lamp\nlocal lamp = { color = $ }"), colors); + assert_eq!(documented("---@type Test.Lamp\nlocal lamp = Lamp\nprint(lamp.color == $)"), colors); + + let text = "StartTask('fast', 'once')\n---@type Speed\nlocal speed = 'fast'\n"; + client.open_with(CLIENT, text); + let hover = client.hover_text(CLIENT, 0, 1); + let listed = "```lua\nmode:\n | \"once\" -- runs one time\n | \"loop\"\n\nreturn #1:\n | nil -- when it did not start\n```"; + assert!(hover.contains(listed), "{hover}"); + let (l, c) = pos(text, "speed =", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!( + hover.contains("type Speed =\n | \"fast\" -- goes quickly\n | \"slow\" -- takes its time"), + "{hover}" + ); +} + #[test] fn values_offer_what_their_declared_type_lists() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index ddccd17..0df84c6 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use qbx_fivem_data::Side; use smol_str::SmolStr; -use crate::types::{merged_returns, CallbackTag, FunType, Param, Type, TypeParser}; +use crate::types::{merged_returns, CallbackTag, DescribedValue, FunType, Param, Type, TypeParser}; #[derive(Clone, Debug, Default, PartialEq)] pub struct DocParam { @@ -11,6 +11,8 @@ pub struct DocParam { pub ty: Type, pub optional: bool, pub description: String, + /// The values that the `---|` lines under it list, with their descriptions. + pub values: Vec, } #[derive(Clone, Debug, Default, PartialEq)] @@ -18,6 +20,8 @@ pub struct DocReturn { pub ty: Type, pub name: Option, pub description: String, + /// The values that the `---|` lines under it list, with their descriptions. + pub values: Vec, } #[derive(Clone, Debug, Default, PartialEq)] @@ -30,6 +34,8 @@ pub struct DocField { pub line: usize, /// The side of `@field (server) name type`. pub side: Option, + /// The values that the `---|` lines under it list, with their descriptions. + pub values: Vec, } #[derive(Clone, Debug, Default, PartialEq)] @@ -67,6 +73,8 @@ pub struct DocAlias { pub line: usize, /// The side of `@alias (server) Name`, or of `@enum (server) Name` for the alias an enum becomes. pub side: Option, + /// The values that the `---|` lines under it list, with their descriptions. + pub values: Vec, } #[derive(Clone, Debug, Default, PartialEq)] @@ -83,6 +91,9 @@ pub struct DocGroup { /// The types a `@type` line lists after its first, as `---@type string, number` does for the /// second name of the statement below. pub ty_rest: Vec, + /// The values that the `---|` lines under `@type` list for its last type, with their + /// descriptions. + pub type_values: Vec, pub enum_name: Option, pub enum_keys: bool, pub enum_side: Option, @@ -109,18 +120,29 @@ impl DocGroup { } } + /// The values that `---|` lines list, with their descriptions, for the `@type` of the name at + /// `index`, as `type_at` binds it. They belong to the last type of the line. + pub fn type_values_at(&self, index: usize) -> &[DescribedValue] { + match index == self.ty_rest.len() { + true => &self.type_values, + false => &[], + } + } + /// Builds the function type from `@param`/`@return`, keeping the order of the real parameters. pub fn fun_type(&self, param_names: &[SmolStr], has_vararg: bool, is_method: bool) -> FunType { let mut params: Vec = param_names .iter() .map(|name| match self.params.iter().find(|p| p.name == *name) { - Some(doc) => Param { name: name.clone(), ty: doc.ty.clone(), optional: doc.optional }, - None => Param { name: name.clone(), ty: Type::Unknown, optional: false }, + Some(doc) => { + Param { name: name.clone(), ty: doc.ty.clone(), optional: doc.optional, values: doc.values.clone() } + } + None => Param { name: name.clone(), ty: Type::Unknown, ..Param::default() }, }) .collect(); if has_vararg { let ty = self.params.iter().find(|p| p.name == "...").map_or(Type::Any, |p| p.ty.clone()); - params.push(Param { name: "...".into(), ty, optional: false }); + params.push(Param { name: "...".into(), ty, ..Param::default() }); } // An overload shares the `@generic` names of its doc comment and is called the same way, // unless it lists `self` itself like a `fun(self, ...)` field. @@ -134,9 +156,14 @@ impl DocGroup { Arc::new(FunType { is_method, lists_receiver, generics: self.generics.clone(), ..(**overload).clone() }) }) .collect(); + let return_values = match self.returns.iter().any(|r| !r.values.is_empty()) { + true => self.returns.iter().map(|r| r.values.clone()).collect(), + false => Vec::new(), + }; FunType { params, returns: self.returns.iter().map(|r| r.ty.clone()).collect(), + return_values, return_sets: self.return_sets.clone(), returns_inferred: false, is_method, @@ -192,18 +219,22 @@ enum Listing { } impl Listing { - /// Adds `value` to the type of the latest such declaration of `group`. - fn add(self, group: &mut DocGroup, value: Type) { - let ty = match self { - Listing::Alias => group.aliases.last_mut().map(|alias| &mut alias.ty), - Listing::Param => group.params.last_mut().map(|param| &mut param.ty), - Listing::Return => group.returns.last_mut().map(|ret| &mut ret.ty), - Listing::Type => group.ty_rest.last_mut().or(group.ty.as_mut()), - Listing::Field => group.classes.last_mut().and_then(|class| class.fields.last_mut()).map(|f| &mut f.ty), + /// Adds `value` to the type of the latest such declaration of `group`, which also keeps it with + /// its `description`. + fn add(self, group: &mut DocGroup, value: Type, description: String) { + let target = match self { + Listing::Alias => group.aliases.last_mut().map(|alias| (&mut alias.ty, &mut alias.values)), + Listing::Param => group.params.last_mut().map(|param| (&mut param.ty, &mut param.values)), + Listing::Return => group.returns.last_mut().map(|ret| (&mut ret.ty, &mut ret.values)), + Listing::Type => group.ty_rest.last_mut().or(group.ty.as_mut()).map(|ty| (ty, &mut group.type_values)), + Listing::Field => { + let field = group.classes.last_mut().and_then(|class| class.fields.last_mut()); + field.map(|field| (&mut field.ty, &mut field.values)) + } }; - if let Some(ty) = ty { - *ty = Type::union([std::mem::take(ty), value]); - } + let Some((ty, values)) = target else { return }; + *ty = Type::union([std::mem::take(ty), value.clone()]); + values.push(DescribedValue { value, description }); // `---| nil` makes a parameter optional, as `@param name string|nil` does. if let (Listing::Param, Some(param)) = (self, group.params.last_mut()) { param.optional |= allows_nil(¶m.ty); @@ -282,7 +313,9 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { for (index, raw) in lines.iter().enumerate() { let line = raw.strip_prefix(' ').unwrap_or(raw); if let (Some(value), Some(listing)) = (listed_value(line), listing) { - listing.add(&mut group, TypeParser::new(value).parse()); + let mut parser = TypeParser::new(value); + let value = parser.parse(); + listing.add(&mut group, value, clean_description(parser.rest())); continue; } let Some((tag, rest)) = split_tag(line) else { @@ -337,6 +370,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { description: description.join("\n").trim().to_string(), line: index, side: side_attribute(attributes), + values: Vec::new(), }); listing = Some(Listing::Alias); } @@ -365,6 +399,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { optional: optional || allows_nil(&ty), ty, description: clean_description(parser.rest()), + values: Vec::new(), }); listing = Some(Listing::Param); } @@ -380,7 +415,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { } for (index, ty) in merged.into_iter().enumerate() { let description = if index == 0 { description.clone() } else { String::new() }; - group.returns.push(DocReturn { ty, name: None, description }); + group.returns.push(DocReturn { ty, name: None, description, values: Vec::new() }); } continue; } @@ -396,14 +431,15 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { let (name, after) = if named { (Some(SmolStr::new(word)), after) } else { (None, remainder) }; match after.trim_start().strip_prefix(',') { Some(next) => { - group.returns.push(DocReturn { ty, name, description: String::new() }); + group.returns.push(DocReturn { ty, name, ..DocReturn::default() }); if next.trim().is_empty() { break; } parser = TypeParser::new(next); } None => { - group.returns.push(DocReturn { ty, name, description: clean_description(after) }); + let description = clean_description(after); + group.returns.push(DocReturn { ty, name, description, values: Vec::new() }); break; } } @@ -480,8 +516,15 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { let optional = parser.rest().starts_with('?'); let mut parser = TypeParser::new(parser.rest().trim_start_matches('?')); let ty = in_class(class, parser.parse()); - let field = - DocField { name: SmolStr::new(name), ty, optional, description: clean_description(parser.rest()), line, side }; + let field = DocField { + name: SmolStr::new(name), + ty, + optional, + description: clean_description(parser.rest()), + line, + side, + values: Vec::new(), + }; let Some(field) = add_signature(&mut class.fields, field) else { return false }; class.fields.push(field); true @@ -848,6 +891,34 @@ mod tests { assert_eq!(doc.returns.iter().map(|r| r.ty.to_string()).collect::>(), ["false|string", "string?"]); } + #[test] + fn listed_values_keep_their_descriptions() { + let described = |values: &[DescribedValue]| -> Vec<(String, String)> { + values.iter().map(|v| (v.value.to_string(), v.description.clone())).collect() + }; + let entry = |value: &str, description: &str| (value.to_string(), description.to_string()); + let doc = parse( + "---@alias Side 'any'\n---| 'client' # runs on the client\n---| 'server' -- runs on the server\n---@param mode\n---| 'fast' # goes quickly\n---| 'slow'\n---@return boolean ok\n---| nil # when it failed\n---@type string\n---| 'a' # first", + ); + assert_eq!( + described(&doc.aliases[0].values), + [entry("\"client\"", "runs on the client"), entry("\"server\"", "runs on the server")], + "the type before the lines lists no described values" + ); + assert_eq!(described(&doc.params[0].values), [entry("\"fast\"", "goes quickly"), entry("\"slow\"", "")]); + assert_eq!(described(&doc.returns[0].values), [entry("nil", "when it failed")]); + assert_eq!(described(doc.type_values_at(0)), [entry("\"a\"", "first")]); + let fun = doc.fun_type(&["mode".into()], false, false); + assert_eq!(fun.params[0].values, doc.params[0].values, "the parameters of the function keep them"); + assert_eq!(fun.return_values, [doc.returns[0].values.clone()], "and so do its returned values"); + + let doc = parse("---@type integer, string\n---| 'b' # second"); + assert!(doc.type_values_at(0).is_empty(), "the values belong to the last type of the line"); + assert_eq!(described(doc.type_values_at(1)), [entry("\"b\"", "second")]); + let doc = parse("---@class Opts\n---@field mode string\n---| 'a' # first"); + assert_eq!(described(&doc.classes[0].fields[0].values), [entry("\"a\"", "first")]); + } + #[test] fn literal_keyed_fields() { let doc = parse( diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index b9ea116..965ff1a 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -44,12 +44,25 @@ pub struct Param { pub name: SmolStr, pub ty: Type, pub optional: bool, + /// The values that the `---|` lines under its `@param` list, with their descriptions. + pub values: Vec, +} + +/// A value that a `---| value # description` line lists, with its description, which is empty when +/// the line has none. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct DescribedValue { + pub value: Type, + pub description: String, } #[derive(Clone, Debug, PartialEq, Default)] pub struct FunType { pub params: Vec, pub returns: Vec, + /// The values that `---|` lines list under each `@return`, with their descriptions, or none + /// when no returned value lists any. + pub return_values: Vec>, /// The sets of values the function returns when it lists more than one, as `(false)` and /// `(string, string)` for `@return false | (string, string)`. `returns` then holds what each /// position has across the sets. @@ -732,11 +745,11 @@ impl<'a> TypeParser<'a> { if self.rest().starts_with("...") { self.pos += 3; let ty = if self.eat(b':') { self.parse() } else { Type::Any }; - params.push(Param { name: "...".into(), ty, optional: false }); + params.push(Param { name: "...".into(), ty, ..Param::default() }); } else if let Some(name) = self.ident() { let optional = self.eat(b'?'); let ty = if self.eat(b':') { self.parse() } else { Type::Unknown }; - params.push(Param { name: SmolStr::new(name), ty, optional }); + params.push(Param { name: SmolStr::new(name), ty, optional, ..Param::default() }); } else { self.pos += 1; } From 44856125c22accbae4ee0a9e0e6956211052adcd Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:53:36 +0200 Subject: [PATCH 006/193] fix(infer): read undeclared fields through the [key] indices of tables and classes --- .../qbx_lua_ls/src/features/class_tables.rs | 7 +- crates/qbx_lua_ls/src/features/hover.rs | 6 +- crates/qbx_lua_ls/src/index.rs | 14 ++- crates/qbx_lua_ls/src/infer.rs | 106 ++++++++++++++++-- crates/qbx_lua_ls/tests/lsp.rs | 73 ++++++++++++ crates/qbx_luacats/src/types.rs | 21 +++- docs/reference.md | 4 +- 7 files changed, 205 insertions(+), 26 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 4e3d3a9..c68131c 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -338,9 +338,10 @@ impl<'a, 'b> Classes<'a, 'b> { return None; } let defs = self.class_defs(class, from); - let own = defs.iter().find_map(|(file, def)| match def.index(self.infer.side()) { - Some((index_key, value)) if self.takes(index_key, *file, key) => Some((value.clone(), *file)), - _ => None, + let own = defs.iter().find_map(|(file, def)| { + let mut indices = def.indices(self.infer.side()).into_iter(); + let (_, value) = indices.find(|(index_key, _)| self.takes(index_key, *file, key))?; + Some((value.clone(), *file)) }); own.or_else(|| { let mut parents = defs.iter().flat_map(|(file, def)| def.parents.iter().map(move |p| (p, *file))); diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 0076642..bdce2fb 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -317,13 +317,13 @@ fn class_hover(infer: &Infer, class: &ClassDef) -> String { let members = infer.members(&Type::Named(class.name.clone(), Vec::new())); let mut fields: Vec = members.iter().map(|member| format!("{}: {}", member.name, member.ty)).collect(); fields.extend(class.literal_fields(infer.side()).map(|(key, value)| format!("[{key}]: {value}"))); - let index = class.index(infer.side()); - if !fields.is_empty() || index.is_some() { + let indices = class.indices(infer.side()); + if !fields.is_empty() || !indices.is_empty() { declaration.push_str(" {"); for field in fields.iter().take(MAX_OVERVIEW_FIELDS) { declaration.push_str(&format!("\n {field},")); } - if let Some((key, value)) = index { + for (key, value) in indices { declaration.push_str(&format!("\n [{key}]: {value},")); } if fields.len() > MAX_OVERVIEW_FIELDS { diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 683c15c..157be4e 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -89,9 +89,17 @@ pub struct ClassDef { } impl ClassDef { - /// The last general index declared for this side, as for an unscoped index before side filtering. - pub fn index(&self, side: Option) -> Option<(&Type, &Type)> { - self.indices.iter().rev().find(|field| applies_on(field.side, side)).map(|field| (&field.key, &field.ty)) + /// The general indices declared for this side, as `[string]` and `[integer]`. Of indices with + /// the same key, the last declared counts, as for an unscoped index before side filtering. + pub fn indices(&self, side: Option) -> Vec<(&Type, &Type)> { + let mut out: Vec<(&Type, &Type)> = Vec::new(); + for field in self.indices.iter().filter(|field| applies_on(field.side, side)) { + match out.iter_mut().find(|(key, _)| **key == field.key) { + Some(index) => *index = (&field.key, &field.ty), + None => out.push((&field.key, &field.ty)), + } + } + out } pub fn literal_fields(&self, side: Option) -> impl Iterator { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 8555957..19c0066 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1043,13 +1043,14 @@ impl<'a> Infer<'a> { Type::Shape(shape) => { let fields = shape.fields.iter().filter(|_| !array_only).map(|f| (Type::String, f.ty.clone())); let array = shape.array.iter().map(|value| (Type::Integer, value.clone())); - let index = shape.index.iter().filter(|(key, _)| !array_only || is_integer_key(key)).cloned(); + let index = shape.indices.iter().filter(|(key, _)| !array_only || is_integer_key(key)).cloned(); fields.chain(array).chain(index).unzip() } Type::Named(ref name, _) => { let class = self.index.class(name, self.side).map(|(_, c)| c); let index = class - .and_then(|c| c.index(self.side)) + .into_iter() + .flat_map(|c| c.indices(self.side)) .filter(|(key, _)| !array_only || is_integer_key(key)) .map(|(key, value)| (key.clone(), value.clone())); // `---@field [1] number` fields, with their keys shown as `integer` rather than `1|2|3`. @@ -1179,19 +1180,24 @@ impl<'a> Infer<'a> { Type::IntLit(i) => items.get((*i as usize).wrapping_sub(1)).cloned().unwrap_or_default(), _ => Type::union(items), }, - Type::Shape(shape) => Type::union(shape.array.iter().chain(shape.index.as_ref().map(|(_, v)| v)).cloned()), + // The array part and the `[key]` entries that take a key of its type. + Type::Shape(shape) => { + let array = shape.array.iter().filter(|_| self.index_takes(&Type::Integer, key_ty, 0)); + let indices = shape.indices.iter().filter(|(key, _)| self.index_takes(key, key_ty, 0)); + Type::union(array.chain(indices.map(|(_, value)| value)).cloned()) + } Type::Named(name, _) => { let Some((_, class)) = self.index.class(&name, self.side) else { return Type::Unknown }; - let index = class.index(self.side).map(|(_, value)| value.clone()); + let key_ty = self.resolve_alias(key_ty); + let index = self.class_index_value(&name, &key_ty, &mut FxHashSet::default(), 0); // `employee[1]` reads the `---@field [1] number` of its key, and `employee[i]` any // literal-keyed field that `i` may name. - let key_ty = self.resolve_alias(key_ty); if key_ty.is_literal() { let field = class.literal_fields(self.side).find(|(key, _)| **key == key_ty); - return field.map(|(_, value)| value.clone()).or(index).unwrap_or_default(); + return field.map_or(index, |(_, value)| value.clone()); } let fields = class.literal_fields(self.side).filter(|(key, _)| may_be_literal_key(&key_ty, key)); - Type::union(fields.map(|(_, value)| value.clone()).chain(index)) + Type::union(fields.map(|(_, value)| value.clone()).chain([index])) } // `list[i]` on a table whose array part the index or a top-level local's constructor holds. Type::GlobalTable(owner) @@ -1204,6 +1210,45 @@ impl<'a> Infer<'a> { } } + /// Whether an index declared for keys of type `declared`, such as the `[string]` of + /// `{ [string]: integer }`, takes a key of type `key`: one of a kind it takes, and when both are + /// literals, the same. An index of `'a'|'b'`, or of an alias of them, takes each of the two. + fn index_takes(&self, declared: &Type, key: &Type, depth: u32) -> bool { + if depth > 8 { + return true; + } + let (declared, key) = (self.resolve_alias(declared), self.resolve_alias(key)); + if let Type::Union(parts) = &declared { + return parts.iter().any(|part| self.index_takes(part, &key, depth + 1)); + } + if declared.is_literal() && key.is_literal() { + return declared == key; + } + let any = FunType::default(); + match (self.value_kinds(&any, &declared, 0), self.value_kinds(&any, &key, 0)) { + (Some(taken), Some(given)) => taken & given != 0, + _ => true, + } + } + + /// What a key of type `key` reads from an instance of `class` through the nearest indices of + /// the class or its parents that take it. A class that several parents share is read once. + fn class_index_value(&self, class: &str, key: &Type, visited: &mut FxHashSet, depth: u32) -> Type { + if depth > 8 || !visited.insert(SmolStr::new(class)) { + return Type::Unknown; + } + let mut defs = self.index.class_defs(class); + defs.retain(|(_, def)| applies_on(def.side, self.side)); + let indices = defs.iter().flat_map(|(_, def)| def.indices(self.side)); + let taking = indices.filter(|(index, _)| self.index_takes(index, key, 0)); + let own = Type::union(taking.map(|(_, value)| value.clone())); + if !own.is_unknown() { + return own; + } + let parents = defs.iter().flat_map(|(_, def)| &def.parents); + Type::union(parents.map(|parent| self.class_index_value(parent, key, visited, depth + 1))) + } + /// The field names a key can be: `'male'`, or every name of a `"male"|"female"` loop variable or /// alias. False when any part of the key is not a string literal. fn literal_keys(&self, key: &Type, depth: u32, out: &mut Vec) -> bool { @@ -1250,7 +1295,7 @@ impl<'a> Infer<'a> { if !elements.keyed.is_empty() { let (keys, values): (Vec, Vec) = elements.keyed.iter().map(|(key, value)| (self.expr(key).widen(), self.expr(value).widen())).unzip(); - shape.index = Some((Type::union(keys), Type::union(values))); + shape.indices.push((Type::union(keys), Type::union(values))); } Type::Shape(Arc::new(shape)) } @@ -1869,11 +1914,41 @@ impl<'a> Infer<'a> { pub fn member(&self, ty: &Type, name: &str) -> Option { self.guarded(|| { let mut found = self.members_matching(ty, Some(name)); + if found.is_empty() { + return indexed_field(name, self.index_value(ty, &Type::StringLit(SmolStr::new(name)), 0)); + } let best = (0..found.len()).max_by_key(|i| (found[*i].ty.specificity(), std::cmp::Reverse(*i)))?; Some(found.swap_remove(best)) }) } + /// What a key of type `key` reads from a value of type `ty` through the indices that take it: + /// the `[string]: integer` of `{ [string]: integer, name: string }`, the `---@field [string] + /// integer` of a class or a parent, or the value type of `table`. + fn index_value(&self, ty: &Type, key: &Type, depth: u32) -> Type { + if depth > 8 { + return Type::Unknown; + } + match ty { + Type::Union(types) => Type::union( + types.iter().filter(|t| !matches!(t, Type::Nil)).map(|t| self.index_value(t, key, depth + 1)), + ), + Type::Shape(shape) => { + let values = shape.indices.iter().filter(|(index, _)| self.index_takes(index, key, 0)); + Type::union(values.map(|(_, value)| value.clone())) + } + Type::Map(index, value) if self.index_takes(index, key, 0) => (**value).clone(), + Type::Named(name, _) if self.index.class(name, self.side).is_some() => { + self.class_index_value(name, key, &mut FxHashSet::default(), depth + 1) + } + Type::Named(..) | Type::Require(_) => match self.resolve_alias(ty) { + resolved if resolved != *ty => self.index_value(&resolved, key, depth + 1), + _ => Type::Unknown, + }, + _ => Type::Unknown, + } + } + pub fn members(&self, ty: &Type) -> Vec { let mut members = self.guarded(|| self.members_matching(ty, None)); let mut seen = FxHashSet::default(); @@ -2061,6 +2136,19 @@ fn add_signature(fun: &mut Arc, next: &Arc) { fun.overloads.extend(next.overloads.iter().cloned()); } +/// The field `name` that an index gives the value `ty`, unless the value is unknown. +fn indexed_field(name: &str, ty: Type) -> Option { + (!ty.is_unknown()).then(|| MemberInfo { + name: SmolStr::new(name), + ty, + doc: None, + deprecated: false, + literal: None, + kind: SymbolKind::Field, + location: None, + }) +} + fn member_from_symbol(file: FileId, symbol: &crate::index::Symbol) -> MemberInfo { MemberInfo { name: symbol.name.clone(), @@ -2110,7 +2198,7 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { Type::Shape(shape) => Type::Shape(Arc::new(Shape { fields: shape.fields.iter().map(|f| ShapeField { ty: substitute(&f.ty, generics), ..f.clone() }).collect(), array: shape.array.as_ref().map(|t| substitute(t, generics)), - index: shape.index.as_ref().map(|(k, v)| (substitute(k, generics), substitute(v, generics))), + indices: shape.indices.iter().map(|(k, v)| (substitute(k, generics), substitute(v, generics))).collect(), })), other => other.clone(), } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index df9c1c1..770ab6c 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -775,6 +775,79 @@ end } } +#[test] +fn hover_reads_undeclared_names_and_keys_through_indices() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Counts +---@field total integer +---@field [string] number +---@field [integer] boolean + +---@class Test.Tally : Test.Counts + +---@alias Test.Slot 'head'|'feet' + +---@class Test.Outfit +---@field [Test.Slot] integer + +---@class Test.Left : Test.Counts, Test.Right +---@class Test.Right : Test.Counts, Test.Left + +---@type { [string]: integer, [integer]: boolean, name: string } +local mixed = {} +---@type Test.Tally +local tally = {} +---@type table +local scores = {} +---@type Test.Outfit +local outfit = {} +---@type { [Test.Slot]: boolean } +local slots = {} +---@type Test.Left +local left = {} + +local named = mixed.name +local other = mixed.other +local quoted = mixed['quoted'] +local first = mixed[1] +local total = tally.total +local inherited = tally.wins +local position = tally[1] +local score = scores.alice +local head = outfit.head +local hands = outfit.hands +local feet = slots.feet +local gloves = slots.gloves +local shared = left.wins +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("named", "named: string"), + ("other", "other: integer"), + ("quoted", "quoted: integer"), + ("first", "first: boolean"), + ("total =", "total: integer"), + ("inherited", "inherited: number"), + ("position", "position: boolean"), + ("score =", "score: integer"), + // An index of the values of an alias takes those values only. + ("head", "head: integer"), + ("hands", "hands: unknown"), + ("feet", "feet: boolean"), + ("gloves", "gloves: unknown"), + // Parents that reach the same class, or each other, read it once. + ("shared", "shared: number"), + ] { + let (l, c) = pos(text, &format!("local {needle}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let (l, c) = pos(text, "Test.Counts\n", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("[string]: number,\n [integer]: boolean,"), "{hover}"); +} + #[test] fn hover_infers_loop_variables_of_top_level_tables() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index 965ff1a..c1f3b54 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -131,7 +131,8 @@ pub struct Shape { pub fields: Vec, /// The values of the array part, which `ipairs` visits, apart from the other `[key]` entries. pub array: Option, - pub index: Option<(Type, Type)>, + /// The other `[key]: value` entries, as `[string]: integer` and `[integer]: boolean`. + pub indices: Vec<(Type, Type)>, } impl Type { @@ -255,7 +256,7 @@ impl Type { Type::Shape(shape) => { shape.fields.iter().any(|field| field.ty.mentions_self()) || shape.array.as_ref().is_some_and(Type::mentions_self) - || shape.index.as_ref().is_some_and(|(key, value)| key.mentions_self() || value.mentions_self()) + || shape.indices.iter().any(|(key, value)| key.mentions_self() || value.mentions_self()) } _ => false, } @@ -283,7 +284,11 @@ impl Type { Type::Shape(shape) => Type::Shape(Arc::new(Shape { fields: shape.fields.iter().map(|f| ShapeField { ty: f.ty.with_self(owner), ..f.clone() }).collect(), array: shape.array.as_ref().map(|t| t.with_self(owner)), - index: shape.index.as_ref().map(|(key, value)| (key.with_self(owner), value.with_self(owner))), + indices: shape + .indices + .iter() + .map(|(key, value)| (key.with_self(owner), value.with_self(owner))) + .collect(), })), other => other.clone(), } @@ -430,7 +435,7 @@ impl fmt::Display for Type { Ok(()) } Type::Shape(shape) => { - if shape.fields.is_empty() && shape.array.is_none() && shape.index.is_none() { + if shape.fields.is_empty() && shape.array.is_none() && shape.indices.is_empty() { return f.write_str("table"); } let mut parts: Vec = shape @@ -442,7 +447,7 @@ impl fmt::Display for Type { if let Some(array) = &shape.array { parts.push(format!("[integer]: {array}")); } - if let Some((k, v)) = &shape.index { + for (k, v) in &shape.indices { parts.push(format!("[{k}]: {v}")); } if shape.fields.len() > 8 { @@ -801,7 +806,7 @@ impl<'a> TypeParser<'a> { self.eat(b']'); self.eat(b':'); let value = self.parse(); - shape.index = Some((key, value)); + shape.indices.push((key, value)); } else if let Some(name) = self.ident() { let optional = self.eat(b'?'); let ty = if self.eat(b':') { self.parse() } else { Type::Unknown }; @@ -848,6 +853,10 @@ mod tests { assert_eq!(roundtrip("async"), "async"); assert_eq!(roundtrip("{ name: string, age?: number }"), "{ name: string, age?: number }"); assert_eq!(roundtrip("{ [string]: boolean }"), "{ [string]: boolean }"); + assert_eq!( + roundtrip("{ [string]: integer, [integer]: boolean, name: string }"), + "{ name: string, [string]: integer, [integer]: boolean }" + ); assert_eq!(roundtrip("'left'|'right'"), "\"left\"|\"right\""); assert_eq!(roundtrip("[number, number]"), "[number, number]"); assert_eq!(roundtrip("`T`"), "T"); diff --git a/docs/reference.md b/docs/reference.md index 25e9a96..23b2815 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -359,8 +359,8 @@ both entries. A key held in an `integer` variable, as in `employee[i]`, may be e is neither reported nor checked against their types. Index types also decide what a field without its own `@field` holds, for every class and not only -strict ones: with `---@field [string] number`, `{ other = true }` and `abc.other = true` are an -`assign-type-mismatch`. +strict ones: with `---@field [string] number`, `abc.other` is a `number`, and `{ other = true }` and +`abc.other = true` are an `assign-type-mismatch`. With `strict_classes = true`, every class is strict unless it is marked `(loose)`. That default only covers classes declared in workspace files outside `exclude` and `ignore_diagnostics`, so the From 75f9406b7d269ee463824f26293f0d40d85d91e7 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:54:14 +0200 Subject: [PATCH 007/193] fix(infer): bind backtick generics to the type a string argument names --- .../qbx_lua_ls/src/features/class_tables.rs | 4 +- crates/qbx_lua_ls/src/features/returns.rs | 10 +- crates/qbx_lua_ls/src/infer.rs | 121 ++++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 97 ++++++++++++++ crates/qbx_luacats/src/types.rs | 8 +- 5 files changed, 213 insertions(+), 27 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index c68131c..f1db06b 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -416,7 +416,9 @@ impl<'a, 'b> Classes<'a, 'b> { return None; } Some(match ty { - Type::Unknown | Type::Any => return None, + // Outside a parameter, `` `T` `` is the class that a string argument names, which may be + // anything. + Type::Unknown | Type::Any | Type::NameOf(_) => return None, Type::Nil => kind::NIL, Type::Boolean | Type::BooleanLit(_) => kind::BOOLEAN, Type::Number | Type::Integer | Type::IntLit(_) | Type::Handle(_) => kind::NUMBER, diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index 3d975a2..cb32d7a 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -86,7 +86,7 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { } } if required > 0 && !always_exits(&func.body) { - let types: Vec = returns[..required].iter().map(|ty| format!("`{ty}`")).collect(); + let types: Vec = returns[..required].iter().map(code).collect(); let message = format!( "The function can reach its end without returning, but `@return` requires {}", types.join(", ") @@ -97,6 +97,14 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { out } +/// `ty` written as code: in backticks, unless it is already written in them, as `` `T` `` is. +fn code(ty: &Type) -> String { + match ty { + Type::NameOf(_) => ty.to_string(), + _ => format!("`{ty}`"), + } +} + fn values(count: usize) -> String { match count { 0 => "no values".to_string(), diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 19c0066..a32db8d 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1225,7 +1225,7 @@ impl<'a> Infer<'a> { return declared == key; } let any = FunType::default(); - match (self.value_kinds(&any, &declared, 0), self.value_kinds(&any, &key, 0)) { + match (self.value_kinds(&any, &declared, false, 0), self.value_kinds(&any, &key, false, 0)) { (Some(taken), Some(given)) => taken & given != 0, _ => true, } @@ -1483,11 +1483,14 @@ impl<'a> Infer<'a> { .collect(); let is_callback = |i: &usize| matches!(args.exprs[*i].unparen().kind, ExprKind::Function(_)); for (param, i) in bindable.iter().filter(|(_, i)| !is_callback(i)) { - self.unify(fun, ¶m.ty, &args.ty(self, *i).widen(), &mut bound, 0); + match self.named_elements(¶m.ty, &args.exprs[*i]) { + Some(names) => self.unify(fun, ¶m.ty, &names, false, &mut bound, 0), + None => self.unify(fun, ¶m.ty, args.ty(self, *i), false, &mut bound, 0), + } } if with_callbacks { for (param, i) in bindable.iter().filter(|(_, i)| is_callback(i)) { - self.unify(fun, ¶m.ty, args.ty(self, *i), &mut bound, 0); + self.unify(fun, ¶m.ty, args.ty(self, *i), false, &mut bound, 0); } } // A declared generic that no argument decides is unknown, not a type named `RV`. @@ -1499,32 +1502,60 @@ impl<'a> Infer<'a> { bound } - /// Matches a parameter type against the type of its argument, binding the generics in it. - fn unify(&self, fun: &FunType, param: &Type, arg: &Type, bound: &mut Vec<(SmolStr, Type)>, depth: u32) { + /// Matches a parameter type against the type of its argument, binding the generics in it. A + /// generic takes the argument widened, as a variable initialised with it would be, unless it is + /// `returned` by a function passed for a `fun(...)` parameter. `` `T` `` takes the class or + /// alias that a string literal names. + fn unify( + &self, + fun: &FunType, + param: &Type, + arg: &Type, + returned: bool, + bound: &mut Vec<(SmolStr, Type)>, + depth: u32, + ) { if depth > 8 || arg.is_unknown() { return; } + let mut bind = |name: &SmolStr, ty: Type| { + if !bound.iter().any(|(n, _)| n == name) { + bound.push((name.clone(), ty)); + } + }; match param { Type::Named(name, args) if args.is_empty() && self.is_generic(fun, name) => { - if !bound.iter().any(|(n, _)| n == name) { - bound.push((name.clone(), arg.clone())); + bind(name, if returned { arg.clone() } else { arg.widen() }); + } + Type::NameOf(name) if self.is_generic(fun, name) => match arg { + Type::StringLit(named) => bind(name, Type::named(named)), + // `{ "Player", "Car" }` for `` `T`[] `` names either class. + Type::Union(parts) if parts.iter().all(|part| matches!(part, Type::StringLit(_))) => { + let named = parts.iter().filter_map(|part| match part { + Type::StringLit(named) => Some(Type::named(named)), + _ => None, + }); + bind(name, Type::union(named)); } + _ => {} + }, + Type::Array(inner) => { + self.unify(fun, inner, &self.key_value_types(arg, true).1, returned, bound, depth + 1); } - Type::Array(inner) => self.unify(fun, inner, &self.key_value_types(arg, true).1, bound, depth + 1), Type::Map(key, value) => { let (arg_key, arg_value) = self.key_value_types(arg, false); - self.unify(fun, key, &arg_key, bound, depth + 1); - self.unify(fun, value, &arg_value, bound, depth + 1); + self.unify(fun, key, &arg_key, returned, bound, depth + 1); + self.unify(fun, value, &arg_value, returned, bound, depth + 1); } Type::Union(types) => { for part in types.iter().filter(|t| !matches!(t, Type::Nil)) { - self.unify(fun, part, &arg.without_nil(), bound, depth + 1); + self.unify(fun, part, &arg.without_nil(), returned, bound, depth + 1); } } Type::Fun(expected) => { if let Some(given) = arg.as_fun() { for (want, got) in expected.returns.iter().zip(&given.returns) { - self.unify(fun, want, got, bound, depth + 1); + self.unify(fun, want, got, true, bound, depth + 1); } } } @@ -1532,10 +1563,46 @@ impl<'a> Infer<'a> { } } + /// The type of a table constructor passed for `` `T`[] ``, whose strings name the classes `T` + /// stands for: an array of those strings as written, which a table built elsewhere widens to + /// `string`. + fn named_elements(&self, param: &Type, arg: &Expr) -> Option { + let Type::Array(inner) = param.without_nil() else { return None }; + let fields = self.constructor_of(arg)?; + if !matches!(*inner, Type::NameOf(_)) || fields.is_empty() { + return None; + } + let elements = table_elements(fields); + Some(Type::Array(Box::new(Type::union(elements.array.iter().map(|value| self.expr(value)))))) + } + + /// The table constructor `expr` is, or that the local it names is declared with when it is + /// never assigned again, as `names` of `local names = { 'Player' }`. + fn constructor_of<'s>(&'s self, expr: &'s Expr) -> Option<&'s [TableField]> { + match &expr.unparen().kind { + ExprKind::Table(fields) => Some(fields), + ExprKind::Name(name) => { + let Some(Resolved::Local(id)) = self.ctx.resolution.resolve_at(name.span.start) else { return None }; + let local = self.ctx.resolution.local(id); + if local.refs.iter().any(|r| r.write) { + return None; + } + let Some(Decl::Local { stmt, index }) = self.ctx.decl(local.decl.start) else { return None }; + let StmtKind::Local { exprs, in_unpack: false, .. } = &stmt.kind else { return None }; + match &exprs.get(*index)?.unparen().kind { + ExprKind::Table(fields) => Some(fields), + _ => None, + } + } + _ => None, + } + } + /// Whether an argument passed for `param` can bind a generic of `fun`, as far as `unify` looks. fn can_bind(&self, fun: &FunType, param: &Type) -> bool { match param { Type::Named(name, args) => args.is_empty() && self.is_generic(fun, name), + Type::NameOf(name) => self.is_generic(fun, name), Type::Array(inner) => self.can_bind(fun, inner), Type::Map(key, value) => self.can_bind(fun, key) || self.can_bind(fun, value), Type::Union(types) => types.iter().any(|t| self.can_bind(fun, t)), @@ -1705,7 +1772,7 @@ impl<'a> Infer<'a> { ExprKind::Table(_) => (None, Some(kind::TABLE)), _ => { let given = call.ty(self, skip_args + i); - (Some(given), self.value_kinds(fun, given, 0)) + (Some(given), self.value_kinds(fun, given, false, 0)) } }; if let (Some(wanted), Some(given_kinds)) = (self.param_kinds(fun, param), given_kinds) { @@ -1760,8 +1827,8 @@ impl<'a> Infer<'a> { } // The `nil` of `"all"|nil` takes a string no more than `"all"` does. _ => { - let wanted = self.value_kinds(fun, param, depth + 1)?; - let given = self.value_kinds(fun, arg, 0)?; + let wanted = self.value_kinds(fun, param, true, depth + 1)?; + let given = self.value_kinds(fun, arg, false, 0)?; (wanted & given == 0).then_some(false) } } @@ -1792,11 +1859,12 @@ impl<'a> Infer<'a> { /// The kinds of value a parameter takes: `id? integer` is stored as `integer`, and also takes `nil`. fn param_kinds(&self, fun: &FunType, param: &Param) -> Option { - self.value_kinds(fun, ¶m.ty, 0).map(|kinds| if param.optional { kinds | kind::NIL } else { kinds }) + self.value_kinds(fun, ¶m.ty, true, 0).map(|kinds| if param.optional { kinds | kind::NIL } else { kinds }) } /// The kinds of Lua value `ty` allows, as a set of `kind` bits, or `None` when it allows any. - fn value_kinds(&self, fun: &FunType, ty: &Type, depth: u32) -> Option { + /// `param` tells that `ty` is the type of a parameter. + fn value_kinds(&self, fun: &FunType, ty: &Type, param: bool, depth: u32) -> Option { if depth > 8 { return None; } @@ -1806,6 +1874,10 @@ impl<'a> Infer<'a> { Type::Boolean | Type::BooleanLit(_) => kind::BOOLEAN, Type::Number | Type::Integer | Type::IntLit(_) | Type::Handle(_) => kind::NUMBER, Type::String | Type::StringLit(_) => kind::STRING, + // A parameter `` `T` `` takes the name of a class. Anywhere else it is that class, + // which may be anything. + Type::NameOf(_) if param => kind::STRING, + Type::NameOf(_) => return None, Type::Table | Type::Array(_) | Type::Map(..) @@ -1816,7 +1888,7 @@ impl<'a> Infer<'a> { | Type::Require(_) => kind::TABLE, Type::Function | Type::Fun(_) => kind::FUNCTION, Type::Thread | Type::Userdata => kind::OTHER, - Type::Variadic(inner) => return self.value_kinds(fun, inner, depth + 1), + Type::Variadic(inner) => return self.value_kinds(fun, inner, param, depth + 1), Type::Named(name, _) if self.is_generic(fun, name) || NATIVE_HANDLE_TYPES.contains(&name.as_str()) => { return None } @@ -1824,12 +1896,12 @@ impl<'a> Infer<'a> { Type::Named(name, _) if self.index.class(name, self.side).is_some() => kind::TABLE | kind::OTHER, Type::Named(..) => match self.resolve_alias(ty) { Type::Named(..) => return None, - resolved => return self.value_kinds(fun, &resolved, depth + 1), + resolved => return self.value_kinds(fun, &resolved, param, depth + 1), }, Type::Union(types) => { let mut kinds = 0; for part in types { - kinds |= self.value_kinds(fun, part, depth + 1)?; + kinds |= self.value_kinds(fun, part, param, depth + 1)?; } kinds } @@ -2165,10 +2237,13 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { if generics.is_empty() { return ty.clone(); } + let bound = |name: &SmolStr| { + generics.iter().find(|(n, _)| n == name).map_or_else(|| ty.clone(), |(_, bound)| bound.clone()) + }; match ty { - Type::Named(name, args) if args.is_empty() => { - generics.iter().find(|(n, _)| n == name).map_or_else(|| ty.clone(), |(_, bound)| bound.clone()) - } + Type::Named(name, args) if args.is_empty() => bound(name), + // A returned `` `T` `` is the class that the string passed for it names. + Type::NameOf(name) => bound(name), Type::Named(name, args) => Type::Named(name.clone(), args.iter().map(|t| substitute(t, generics)).collect()), Type::Array(inner) => Type::Array(Box::new(substitute(inner, generics))), Type::Tuple(items) => Type::Tuple(items.iter().map(|t| substitute(t, generics)).collect()), diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 770ab6c..f82c374 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3776,6 +3776,103 @@ return { assert_eq!(labels, ["'on'", "'off'"]); } +#[test] +fn backtick_generics_bind_the_type_a_string_names() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Player +---@field name string + +---@generic T +---@param name `T` +---@return T +local function new(name) end + +---@generic T +---@param name `T` +---@param fallback? T +---@return T +local function find(name, fallback) end + +---@generic T +---@param name `T` +---@return `T` +local function class(name) + ---@type Test.Player + local instance = { name = name } + return instance +end + +---@class Test.Car + +---@generic T +---@param names `T`[] +---@return T +local function first(names) end + +---@generic T +---@param names `T`[] +---@return T[] +local function all(names) end + +---@generic T +---@param name `T` +---@return `T` +local function declare(name) end + +local player = new('Test.Player') +local count = new('integer') +local missing = new('Test.Missing') +local kind = 'Test.Player' +local widened = new(kind) +local found = find('Test.Player', 1) +local made = class('Test.Player') +local listed = first({ 'Test.Player' }) +local either = first({ 'Test.Player', 'Test.Car' }) +local players = all({ 'Test.Player' }) +local mixed = first({ 'Test.Player', 1 }) +local names = { 'Test.Car' } +local held = first(names) +local changing = { 'Test.Car' } +changing = { 'Test.Player' } +local reassigned = first(changing) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("player", "player: Test.Player"), + ("count", "count: integer"), + ("missing", "missing: Test.Missing"), + // A string that is not written in the call may name anything. + ("widened", "widened: unknown"), + ("found", "found: Test.Player"), + // A returned `` `T` `` is the class too, as ox_lib's `lib.class` returns it. + ("made", "made: Test.Player"), + // The strings of a table written in the call name it for `` `T`[] ``. + ("listed", "listed: Test.Player"), + ("either", "either: Test.Player|Test.Car"), + ("players", "players: Test.Player[]"), + ("mixed", "mixed: unknown"), + // ...and so do those of the table a local that is never assigned again is declared with. + ("held", "held: Test.Car"), + ("reassigned", "reassigned: unknown"), + ] { + let (l, c) = pos(text, &format!("local {needle}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let (l, c) = pos(text, "new(name) end", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("new(name: `T`): T"), "{hover}"); + let mismatches: Vec<_> = + client.diagnostics_for(CLIENT).into_iter().filter(|(code, _)| code == "return-type-mismatch").collect(); + assert!(mismatches.is_empty(), "{mismatches:?}"); + // The message writes `` `T` `` once, without wrapping it in more backticks. + let (line, _) = pos(text, "local function declare", 0); + let missing = findings(&mut client, CLIENT, &["missing-return"]); + let declare = missing.iter().find(|(_, l, _)| *l == line as u64).map(|(_, _, message)| message.as_str()); + assert_eq!(declare, Some("The function can reach its end without returning, but `@return` requires `T`")); +} + #[test] fn self_in_doc_types_is_the_class_of_the_table_a_function_is_defined_on() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index c1f3b54..5f0bea5 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -37,6 +37,9 @@ pub enum Type { Exports(Option), /// The value returned by the module a `require` call names; resolved through the index on use. Require(SmolStr), + /// `` `T` ``: the generic `T`, bound to the class or alias that the string passed for it names, + /// as `new("Player")` binds `Player`. + NameOf(SmolStr), } #[derive(Clone, Debug, PartialEq, Default)] @@ -461,6 +464,7 @@ impl fmt::Display for Type { Type::Exports(None) => f.write_str("exports"), Type::Exports(Some(resource)) => write!(f, "exports.{resource}"), Type::Require(path) => write!(f, "module \"{path}\""), + Type::NameOf(name) => write!(f, "`{name}`"), } } } @@ -661,7 +665,7 @@ impl<'a> TypeParser<'a> { self.pos += 1; let name = self.ident().unwrap_or("T"); self.eat(b'`'); - Type::Named(SmolStr::new(name), Vec::new()) + Type::NameOf(SmolStr::new(name)) } b'-' | b'0'..=b'9' => { let start = self.pos; @@ -859,7 +863,7 @@ mod tests { ); assert_eq!(roundtrip("'left'|'right'"), "\"left\"|\"right\""); assert_eq!(roundtrip("[number, number]"), "[number, number]"); - assert_eq!(roundtrip("`T`"), "T"); + assert_eq!(roundtrip("`T`"), "`T`"); assert_eq!(roundtrip("vector3|vector4"), "vector3|vector4"); assert_eq!(roundtrip("OxPlayer?"), "OxPlayer?"); assert_eq!(roundtrip("1|2|3"), "1|2|3"); From 55686158c1daba8b583a941082170ab933c5d669 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:54:48 +0200 Subject: [PATCH 008/193] fix(workspace): index a file again when it declares other names --- crates/qbx_lua_ls/src/server.rs | 3 +- crates/qbx_lua_ls/src/workspace.rs | 32 +++++++++++++++ crates/qbx_lua_ls/tests/lsp.rs | 43 ++++++++++++++++++++ crates/qbx_lua_ls/tests/workspace_refresh.rs | 26 ++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/crates/qbx_lua_ls/src/server.rs b/crates/qbx_lua_ls/src/server.rs index 1e2f1f9..7626c4b 100644 --- a/crates/qbx_lua_ls/src/server.rs +++ b/crates/qbx_lua_ls/src/server.rs @@ -340,8 +340,7 @@ impl Server { let is_source = !qbx_lua_analysis::project::is_not_source(doc.text.as_bytes()); if !doc.is_manifest() && is_source && !self.ws.lint_config.is_excluded(&doc.path) { relink |= self.ws.index.file(doc.file).is_none(); - doc.file = - self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); + doc.file = self.ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution); } } } diff --git a/crates/qbx_lua_ls/src/workspace.rs b/crates/qbx_lua_ls/src/workspace.rs index 2daa499..206e02a 100644 --- a/crates/qbx_lua_ls/src/workspace.rs +++ b/crates/qbx_lua_ls/src/workspace.rs @@ -299,6 +299,27 @@ impl Workspace { id } + /// Indexes a document open in the editor. Its symbols are inferred with what the index held for + /// the file, so when it declares other classes or aliases now, as one indexed for the first time + /// does, a second pass reads them, as the scan's second pass does for every file, and the + /// methods of `---@class Name` `Name = {}` see the class. + pub fn index_document( + &mut self, + path: &Path, + source: &str, + chunk: &qbx_lua_syntax::ast::Chunk, + resolution: &qbx_lua_analysis::scope::Resolution, + ) -> FileId { + let id = self.index.allocate(path); + let declared = |ws: &Self| ws.index.file(id).map(|file| declared_types(&file.index)).unwrap_or_default(); + let before = declared(self); + self.index_parsed(path, FileOrigin::Workspace, source, chunk, resolution); + if declared(self) != before { + self.index_parsed(path, FileOrigin::Workspace, source, chunk, resolution); + } + id + } + /// Where a definition file outside any resource applies: on the side a `side` override, or else /// the name of the file or of its nearest folder that names one, gives it, and for the resource /// that the nearest folder named after one stands for. Folders above the workspace or library @@ -416,6 +437,17 @@ impl Workspace { } } +/// The classes and aliases that a file declares. Its globals and members are left out, since typing +/// a new `Config.Foo` changes them on every key. +fn declared_types(index: &FileIndex) -> Vec<(&'static str, SmolStr)> { + let classes = index.classes.iter().map(|class| ("@class", class.name.clone())); + let aliases = index.aliases.iter().map(|alias| ("@alias", alias.name.clone())); + let mut names: Vec<(&'static str, SmolStr)> = classes.chain(aliases).collect(); + names.sort_unstable(); + names.dedup(); + names +} + /// The side the name of a file or folder gives, as `server_vehicle.lua`, `cl_main.lua` or a `client` /// folder do. Names of both sides, or `shared` and `common`, give both. fn side_named_by(name: &str) -> Option { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index f82c374..16d86c2 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3776,6 +3776,49 @@ return { assert_eq!(labels, ["'on'", "'off'"]); } +#[test] +fn files_see_the_classes_they_declare_as_soon_as_they_are_indexed() { + let mut client = Client::start(fixture_root()); + // The global table `Test{name}` of the class `Test.{name}`, with methods that return `self`. + let source = |name: &str, declared: bool| { + let class = if declared { format!("---@class Test.{name}\n") } else { String::new() }; + format!( + "{class}Test{name} = {{}} + +---@return self +function Test{name}:chain() return self end +function Test{name}:inferred() return self end + +---@type Test.{name} +local value = {{}} +local chained = value:chain() +local inferred = value:inferred() +" + ) + }; + let assert_methods_return = |client: &mut Client, file: &str, text: &str, class: &str| { + for needle in ["chained", "inferred"] { + let (l, c) = pos(text, &format!("local {needle}"), 6); + let hover = client.hover_text(file, l, c); + assert!(hover.contains(&format!("{needle}: {class}")), "{file}: expected {class} in {hover}"); + } + }; + + // A file that is not on disk is indexed for the first time when it is opened. + let fresh = "myresource/client/fresh.lua"; + let text = source("Fresh", true); + client.open_with(fresh, &text); + assert_methods_return(&mut client, fresh, &text, "Test.Fresh"); + + // An edit that declares the class takes effect without another edit. + let edited = "myresource/client/edited.lua"; + client.open_with(edited, &source("Edited", false)); + client.hover_text(edited, 0, 0); + let text = source("Edited", true); + client.change(edited, 2, &text); + assert_methods_return(&mut client, edited, &text, "Test.Edited"); +} + #[test] fn backtick_generics_bind_the_type_a_string_names() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_lua_ls/tests/workspace_refresh.rs b/crates/qbx_lua_ls/tests/workspace_refresh.rs index 767d7f6..c2cbead 100644 --- a/crates/qbx_lua_ls/tests/workspace_refresh.rs +++ b/crates/qbx_lua_ls/tests/workspace_refresh.rs @@ -347,6 +347,32 @@ fn configured_exact_imports_respect_exclusions() { assert!(ws.index.globals_named("ExcludedApi", caller).is_empty()); } +#[test] +fn open_documents_alone_are_indexed_again_for_the_classes_they_declare() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "client_script '*.lua'\n"); + let mut ws = fixture.workspace("demo"); + // What `Demo{name}:chain()` returns, as the index holds it after indexing the file once. + let mut chained = |name: &str, document: bool| -> String { + let path = fixture.0.join(format!("demo/{name}.lua")); + let text = format!( + "---@class Demo.{name}\nDemo{name} = {{}}\n\n---@return self\nfunction Demo{name}:chain() return self end\n" + ); + let doc = Document::new(path_to_uri(&path), path, 1, text); + let file = match document { + true => ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution), + false => ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution), + }; + let index = &ws.index.file(file).unwrap().index; + let chain = index.members.iter().find(|member| member.symbol.name == "chain").unwrap(); + chain.symbol.ty.as_fun().unwrap().returns[0].to_string() + }; + assert_eq!(chained("Opened", true), "Demo.Opened"); + // The scan reads every file again once it has read them all, so each of its passes reads a + // file once. + assert_ne!(chained("Scanned", false), "Demo.Scanned"); +} + #[test] fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { let fixture = Fixture::new(); From c59e7cbf4b2d052ef7c20a52875c0a073c2aa814 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:55:35 +0200 Subject: [PATCH 009/193] feat(infer): narrow locals by type() checks and comparisons with literals --- crates/qbx_lua_ls/README.md | 40 ++++- crates/qbx_lua_ls/src/infer.rs | 30 ++-- crates/qbx_lua_ls/src/narrow.rs | 257 ++++++++++++++++++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 137 +++++++++++++++++ docs/reference.md | 4 +- 5 files changed, 419 insertions(+), 49 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 54be386..bec50b3 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -44,7 +44,8 @@ Available features depend on the editor's LSP client. methods: `exports.phone` and `exports['phone']` take the members of the declared type before the exports the resource registers, also for resources the workspace lacks. - Type guards: after `if not name then return end`, inside `if name then`, and in the other - [guarded code](#type-guards), a local is no longer `nil` or `false`. A guard on one value of + [guarded code](#type-guards), a local is no longer `nil` or `false`, and `type(data) == "table"` + or `state == "busy"` narrow it to that kind or value. A guard on one value of `local ok, err = f()` also narrows the others, for functions that return [sets of values](#sets-of-returned-values) such as `false | (string, string)`. - Diagnostics and quick fixes with resource and client/server context, plus LuaCATS type checks: @@ -119,12 +120,37 @@ end print(name) -- string ``` -Guards are `name`, `not name`, comparisons with `nil`, `true` and `false`, and those joined by -`and` and `or`. They apply to the branches of an `if` or `elseif`, to the code after an `if` whose -other branches all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to -the right side of `and` and `or`, and to the code after `assert(name)`. A local that is assigned -again after its declaration is not narrowed, since a guard says nothing about the new value, and -neither are globals and fields. +Guards are `name`, comparisons with `nil`, `true`, `false`, a string or an integer, `type(name)` +and `math.type(name)` compared with a name they give, and those joined by `and`, `or` and `not`. +They apply to the branches of an `if` or `elseif`, to the code after an `if` whose other branches +all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the right side of +`and` and `or`, and to the code after `assert(name)`. A local that is assigned again after its +declaration is not narrowed, since a guard says nothing about the new value, and neither are +globals and fields. + +A comparison with a literal narrows the local to that literal: inside `if state == "busy" then`, a +`"active"|"busy"|nil` and a `string` are both `"busy"`, and in the `else` branch the first is +`"active"|nil`. `type(name)` keeps the values of the kind it names. Classes count as tables, and +the `vector2`, `vector3`, `vector4`, `quat` and `matrix` of CfxLua go by their own names: + +```lua +---@param data PlayerData|string|nil +local function load(data) + if type(data) ~= "table" then + return + end + print(data) -- PlayerData +end +``` + +A value of no known type takes the kind it is checked for, and so does one whose type has none of +that kind, since such a check handles values the annotations leave out: inside +`if type(count) == "string" then`, an `integer` is a `string`. `math.type(name)` tells an `integer` +from a `float`, and `kind == "table"` after `local kind = type(name)` narrows like +`type(name) == "table"`. Only the global `type` counts, also through a local that holds it, as +after `local type = type`, and not `table.type` of ox_lib. Where one of +several checks held, as inside `if type(value) == "string" or type(value) == "number" then`, the +local is of the kinds they let through. ### Sets of returned values diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index a32db8d..e54297c 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -611,16 +611,23 @@ impl<'a> Infer<'a> { return ty; } // An alias such as `Name = string|nil` is narrowed through what it stands for. - let declared = match &ty { - Type::Named(..) => self.resolve_alias(&ty), - _ => ty.clone(), - }; - // A guard that the type rules out altogether, like `if not name` for a `string`, is skipped. - let narrowed = facts.fold(declared.clone(), |narrowed, fact| fact.apply(&narrowed).unwrap_or(narrowed)); - if narrowed == declared { - ty - } else { - narrowed + let declared = self.expand_aliases(&ty, 0); + // Guards that leave no value of the type are ignored, like `if not name` for a `string`, or + // `action ~= "open" and action ~= "close"` for an `"open"|"close"`: such code handles values + // the annotations leave out, or never runs. + match facts.try_fold(declared.clone(), |narrowed, fact| fact.assume(&narrowed)) { + Some(narrowed) if narrowed != declared => narrowed, + _ => ty, + } + } + + /// `ty` with the aliases it names, also in a union, replaced by what they stand for. + fn expand_aliases(&self, ty: &Type, depth: u32) -> Type { + match self.resolve_alias(ty) { + Type::Union(parts) if depth < 8 => { + Type::union(parts.iter().map(|part| self.expand_aliases(part, depth + 1))) + } + other => other, } } @@ -643,8 +650,7 @@ impl<'a> Infer<'a> { } let sets = self.linked_sets(stmt)?; let value = |set: &[Type], position: usize| match set.get(position) { - Some(ty @ Type::Named(..)) => self.resolve_alias(ty), - Some(ty) => ty.clone(), + Some(ty) => self.expand_aliases(ty, 0), None => Type::Nil, }; let is_possible = |set: &&Vec| { diff --git a/crates/qbx_lua_ls/src/narrow.rs b/crates/qbx_lua_ls/src/narrow.rs index 80a3b8d..593f8a7 100644 --- a/crates/qbx_lua_ls/src/narrow.rs +++ b/crates/qbx_lua_ls/src/narrow.rs @@ -5,12 +5,21 @@ use qbx_lua_analysis::scope::{LocalId, Resolution, Resolved}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::Span; +use qbx_lua_syntax::{NumberValue, Span}; use rustc_hash::FxHashMap; use crate::infer::always_exits; use crate::types::Type; +/// The names `type` gives: those of Lua, and those of the vectors, quaternions and matrices CfxLua +/// adds. +const TYPE_NAMES: [&str; 13] = [ + "nil", "boolean", "number", "string", "table", "function", "thread", "userdata", "vector2", "vector3", "vector4", + "quat", "matrix", +]; +/// The names `math.type` gives for numbers. +const NUMBER_NAMES: [&str; 2] = ["integer", "float"]; + /// What a guard tells about the value of a local. #[derive(Clone, Debug, PartialEq)] pub enum Fact { @@ -18,47 +27,139 @@ pub enum Fact { Truthy, /// It is `nil` or `false`. Falsy, - /// It equals this `nil`, `true` or `false`. + /// It equals this `nil`, `true`, `false`, string or integer. Is(Type), - /// It differs from this `nil`, `true` or `false`. + /// It differs from this `nil`, `true`, `false`, string or integer. IsNot(Type), + /// `type` gives this name for it, or `math.type` gives `integer` or `float`. + Kind(&'static str), + /// `type` or `math.type` gives another name for it. + NotKind(&'static str), + /// The facts of one of these lists hold, as after `type(x) == 'string' or type(x) == 'number'`. + AnyOf(Vec>), } impl Fact { /// What is left of `ty` for a value the fact holds for, or `None` when no value of `ty` - /// satisfies it. A type that says nothing about its values stays as it is. + /// satisfies it. A type that says nothing about its values stays as it is. The aliases `ty` + /// names are read as classes, so the caller expands them first. pub fn apply(&self, ty: &Type) -> Option { match ty { - Type::Unknown | Type::Any => Some(match self { - Fact::Is(value) => value.clone(), - _ => ty.clone(), - }), Type::Union(parts) => { let kept: Vec = parts.iter().filter_map(|part| self.apply(part)).collect(); (!kept.is_empty()).then(|| Type::union(kept)) } - Type::Boolean => match self { - Fact::Truthy => Some(Type::BooleanLit(true)), - Fact::Falsy => Some(Type::BooleanLit(false)), - Fact::Is(Type::BooleanLit(value)) => Some(Type::BooleanLit(*value)), - Fact::IsNot(Type::BooleanLit(value)) => Some(Type::BooleanLit(!*value)), - Fact::Is(_) => None, - Fact::IsNot(_) => Some(Type::Boolean), - }, - _ => { - let falsy = matches!(ty, Type::Nil | Type::BooleanLit(false)); - let holds = match self { - Fact::Truthy => !falsy, - Fact::Falsy => falsy, - Fact::Is(value) => ty == value, - Fact::IsNot(value) => ty != value, - }; - holds.then(|| ty.clone()) + _ => self.apply_part(ty), + } + } + + /// What the code the fact guards takes `ty` to be: what `apply` leaves of it, or, for a `type` + /// check that `ty` has no value for, the kind it checks for. Such a check is how code handles + /// values its annotations leave out. + pub fn assume(&self, ty: &Type) -> Option { + self.apply(ty).or_else(|| self.kind_type()) + } + + /// The type of the values a `type` check, or each of several, lets through. + fn kind_type(&self) -> Option { + match self { + Fact::Kind("integer") => Some(Type::Integer), + Fact::Kind("float") => Some(Type::Number), + Fact::Kind(name) => Some(Type::named(name)), + Fact::AnyOf(alternatives) => { + let kinds: Option> = + alternatives.iter().map(|facts| facts.iter().rev().find_map(Fact::kind_type)).collect(); + kinds.map(Type::union) } + _ => None, + } + } + + fn apply_part(&self, ty: &Type) -> Option { + let unknown = matches!(ty, Type::Unknown | Type::Any); + match self { + Fact::AnyOf(alternatives) => { + let kept: Vec = alternatives + .iter() + .filter_map(|facts| facts.iter().try_fold(ty.clone(), |ty, fact| fact.apply(&ty))) + .collect(); + (!kept.is_empty()).then(|| Type::union(kept)) + } + Fact::Kind(_) if unknown => self.kind_type(), + Fact::Kind(name) => match is_kind(ty, name) { + Some(false) => None, + None if *name == "integer" && *ty == Type::Number => Some(Type::Integer), + _ => Some(ty.clone()), + }, + Fact::NotKind(name) => (is_kind(ty, name) != Some(true)).then(|| ty.clone()), + Fact::Is(value) if unknown => Some(value.clone()), + _ if unknown => Some(ty.clone()), + _ => match (ty, self) { + (Type::Boolean, Fact::Truthy) => Some(Type::BooleanLit(true)), + (Type::Boolean, Fact::Falsy) => Some(Type::BooleanLit(false)), + (Type::Boolean, Fact::Is(Type::BooleanLit(value))) => Some(Type::BooleanLit(*value)), + (Type::Boolean, Fact::IsNot(Type::BooleanLit(value))) => Some(Type::BooleanLit(!*value)), + (Type::Boolean, Fact::Is(_)) => None, + (Type::Boolean, _) => Some(Type::Boolean), + // A string or number equal to a literal is that literal. A handle keeps its name. + (Type::String, Fact::Is(value @ Type::StringLit(_))) + | (Type::Integer | Type::Number, Fact::Is(value @ Type::IntLit(_))) => Some(value.clone()), + (Type::Handle(_), Fact::Is(Type::IntLit(_))) => Some(ty.clone()), + _ => { + let falsy = matches!(ty, Type::Nil | Type::BooleanLit(false)); + let holds = match self { + Fact::Truthy => !falsy, + Fact::Falsy => falsy, + Fact::Is(value) => ty == value, + Fact::IsNot(value) => ty != value, + _ => true, + }; + holds.then(|| ty.clone()) + } + }, } } } +/// Whether `type`, or for `integer` and `float` `math.type`, gives `name` for every value of `ty` +/// (`Some(true)`) or for none of them (`Some(false)`). `None` when it may give it for some, or the +/// kind of `ty` is not known. +fn is_kind(ty: &Type, name: &str) -> Option { + let integer = matches!(ty, Type::Integer | Type::IntLit(_) | Type::Handle(_)); + match name { + "integer" | "float" if integer => Some(name == "integer"), + "integer" | "float" if *ty == Type::Number => None, + "integer" | "float" => type_name(ty).map(|_| false), + _ => type_name(ty).map(|kind| kind == name), + } +} + +/// The name `type` gives for the values of `ty`. +fn type_name(ty: &Type) -> Option<&str> { + Some(match ty { + Type::Nil => "nil", + Type::Boolean | Type::BooleanLit(_) => "boolean", + Type::Number | Type::Integer | Type::IntLit(_) | Type::Handle(_) => "number", + Type::String | Type::StringLit(_) => "string", + Type::Table + | Type::Array(_) + | Type::Map(..) + | Type::Tuple(_) + | Type::Shape(_) + | Type::GlobalTable(_) + | Type::Exports(_) => "table", + Type::Function | Type::Fun(_) => "function", + Type::Thread => "thread", + Type::Userdata => "userdata", + // Classes describe tables, except for the vectors, quaternions and matrices of CfxLua. + Type::Named(name, _) => match name.as_str() { + name @ ("vector2" | "vector3" | "vector4" | "quat" | "matrix") => name, + _ => "table", + }, + _ => return None, + }) +} + type Facts = Vec<(LocalId, Fact)>; /// The facts the guards of a file establish, each for a local and the code it holds in. @@ -73,7 +174,12 @@ pub struct Guards { impl Guards { pub fn of(chunk: &Chunk, resolution: &Resolution) -> Self { - let mut finder = Finder { resolution, guards: Guards::default() }; + let mut finder = Finder { + resolution, + guards: Guards::default(), + kinds: FxHashMap::default(), + type_functions: FxHashMap::default(), + }; finder.visit_block(&chunk.block); finder.guards } @@ -95,6 +201,11 @@ impl Guards { struct Finder<'r> { resolution: &'r Resolution, guards: Guards, + /// The locals that hold what `type`, or `math.type` with `true`, gives for another local, as + /// `local kind = type(value)` does. + kinds: FxHashMap, + /// The locals that hold `type`, or with `true` `math.type`, as after `local type = type`. + type_functions: FxHashMap, } impl Finder<'_> { @@ -106,6 +217,47 @@ impl Finder<'_> { (!self.resolution.local(id).refs.iter().any(|r| r.write)).then_some(id) } + fn is_global(&self, name: &Name) -> bool { + !matches!(self.resolution.resolve_at(name.span.start), Some(Resolved::Local(_))) + } + + /// The local that a `type(value)` call checks, or with `true` a `math.type(value)` call. + fn type_call(&self, expr: &Expr) -> Option<(LocalId, bool)> { + let ExprKind::Call { callee, args, .. } = &expr.unparen().kind else { return None }; + let [arg] = args.as_slice() else { return None }; + let numbers = self.type_function(callee)?; + Some((self.local(arg)?, numbers)) + } + + /// Whether `expr` is the global `type` (`false`) or `math.type` (`true`), or a local that holds + /// one of them, as after `local type = type`. + fn type_function(&self, expr: &Expr) -> Option { + match &expr.kind { + ExprKind::Name(name) => match self.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => self.type_functions.get(&id).copied(), + _ => (name.text == "type").then_some(false), + }, + ExprKind::Field { base, name, .. } if name.text == "type" => match &base.kind { + ExprKind::Name(math) if math.text == "math" && self.is_global(math) => Some(true), + _ => None, + }, + _ => None, + } + } + + /// The local whose kind `side` gives and the name of a kind that `other` compares it with, as + /// in `type(value) == 'table'`, `math.type(value) == 'integer'` and, after + /// `local kind = type(value)`, `kind == 'table'`. + fn kind_check(&self, side: &Expr, other: &Expr) -> Option<(LocalId, &'static str)> { + let name = other.unparen().as_string()?; + let (local, numbers) = match &side.unparen().kind { + ExprKind::Name(_) => *self.kinds.get(&self.local(side)?)?, + _ => self.type_call(side)?, + }; + let names: &[&'static str] = if numbers { &NUMBER_NAMES } else { &TYPE_NAMES }; + names.iter().find(|kind| **kind == name.as_str()).map(|kind| (local, *kind)) + } + /// Adds what `cond` being true, or false without `holds`, tells about the locals in it. fn facts(&self, cond: &Expr, holds: bool, out: &mut Facts) { let cond = cond.unparen(); @@ -125,20 +277,48 @@ impl Finder<'_> { self.facts(lhs, false, out); self.facts(rhs, false, out); } + // One side of a true `or` is true, and one side of a false `and` is false. + ExprKind::Binary { op: BinOp::Or | BinOp::And, lhs, rhs, .. } => self.either(lhs, rhs, holds, out), ExprKind::Binary { op: op @ (BinOp::Eq | BinOp::Ne), lhs, rhs, .. } => { + let equal = (*op == BinOp::Eq) == holds; let compared = match (self.local(lhs), literal(rhs)) { (Some(local), Some(value)) => Some((local, value)), _ => self.local(rhs).zip(literal(lhs)), }; if let Some((local, value)) = compared { - let equal = (*op == BinOp::Eq) == holds; out.push((local, if equal { Fact::Is(value) } else { Fact::IsNot(value) })); } + if let Some((local, kind)) = self.kind_check(lhs, rhs).or_else(|| self.kind_check(rhs, lhs)) { + out.push((local, if equal { Fact::Kind(kind) } else { Fact::NotKind(kind) })); + } } _ => {} } } + /// Adds what one of `lhs` and `rhs` being true, or false without `holds`, tells: for each local + /// that both tell something about, that the facts of one of them hold. + fn either(&self, lhs: &Expr, rhs: &Expr, holds: bool, out: &mut Facts) { + let (mut left, mut right) = (Facts::new(), Facts::new()); + self.facts(lhs, holds, &mut left); + self.facts(rhs, holds, &mut right); + let about = |facts: &Facts, local: LocalId| -> Vec { + facts.iter().filter(|(of, _)| *of == local).map(|(_, fact)| fact.clone()).collect() + }; + let mut locals: Vec = Vec::new(); + for (local, _) in &left { + if !locals.contains(local) { + locals.push(*local); + } + } + for local in locals { + let second = about(&right, local); + if !second.is_empty() { + out.push((local, Fact::AnyOf(vec![about(&left, local), second]))); + } + } + } + /// Links the locals that take the values of the call ending a `local` statement. Those assigned /// again later are left out, as they are not narrowed. fn link(&mut self, names: &[AttribName], exprs: &[Expr]) { @@ -238,7 +418,20 @@ impl<'ast> Visitor<'ast> for Finder<'_> { self.record(Span { start, end: stmt.span.end }, failed); } } - StmtKind::Local { names, exprs, in_unpack: false } => self.link(names, exprs), + StmtKind::Local { names, exprs, in_unpack: false } => { + self.link(names, exprs); + for (name, expr) in names.iter().zip(exprs) { + let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) else { continue }; + if self.resolution.local(id).refs.iter().any(|r| r.write) { + continue; + } + if let Some(checked) = self.type_call(expr) { + self.kinds.insert(id, checked); + } else if let Some(numbers) = self.type_function(expr) { + self.type_functions.insert(id, numbers); + } + } + } StmtKind::While { cond, .. } => { let mut facts = Facts::new(); self.facts(cond, true, &mut facts); @@ -261,10 +454,16 @@ impl<'ast> Visitor<'ast> for Finder<'_> { } fn literal(expr: &Expr) -> Option { - match expr.unparen().kind { + match &expr.unparen().kind { ExprKind::Nil => Some(Type::Nil), ExprKind::True => Some(Type::BooleanLit(true)), ExprKind::False => Some(Type::BooleanLit(false)), + ExprKind::String(value) => Some(Type::StringLit(value.clone())), + ExprKind::Number(NumberValue::Int(value)) => Some(Type::IntLit(*value)), + ExprKind::Unary { op: UnOp::Neg, expr } => match &expr.unparen().kind { + ExprKind::Number(NumberValue::Int(value)) => value.checked_neg().map(Type::IntLit), + _ => None, + }, _ => None, } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 16d86c2..03e84d1 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1939,6 +1939,143 @@ print(firstname, lastname) -- after } } +#[test] +fn type_checks_and_literal_comparisons_narrow_the_locals_they_test() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Checked +---@field name string + +---@return integer|string|fun()|nil +local function many() end + +---@return 'a'|'b'|nil +local function letter() end + +---@param action 'open'|'close' +local function toggle(action) + if action ~= 'open' and action ~= 'close' then + print(action == 'open') -- neither + end + if action == 'open' then return end + if action == 'close' then return end + print(action) -- ruled out +end +toggle('open') + +---@param id string|integer +local function aliased(id) + local type = type + if type(id) == 'string' then + print(id) -- aliased type + end +end +aliased(1) + +---@param data Checked|string|nil +---@param coords vector3|table +---@param count integer +local function check(data, coords, count, cb, decoded) + if type(data) == 'table' then + print(data) -- class + elseif type(data) == 'nil' then + print(data) -- nil kind + else + print(data) -- rest + end + if type(coords) == 'vector3' then + print(coords) -- vector + end + if type(cb) == 'function' then + print(cb) -- callback + end + if type(count) == 'string' then + print(count) -- defensive + end + if count == 0 then + print(count) -- zero + end + local value = many() + if type(value) == 'number' then + print(value) -- number + end + if not (type(value) == 'string') then + print(value) -- not string + end + if type(value) == 'string' or type(value) == 'number' then + print(value) -- either + end + if math.type(value) == 'integer' then + print(value) -- integer + end + if table.type(value) == 'array' then + print(value) -- other function + end + local kind = type(value) + if kind == 'string' then + print(value) -- through a local + end + local which = letter() + if which == 'a' then + if which == 'b' then return end + print(which) -- equal + elseif which then + print(which) -- other letter + end + if which ~= 'a' then + print(which) -- differs + end + if type(decoded) ~= 'table' then + return + end + print(decoded) -- after +end +check() +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("data) -- class", "data: Checked {"), + ("data) -- nil kind", "data: nil\n"), + ("data) -- rest", "data: string\n"), + // CfxLua's `type` names its vectors. + ("coords) -- vector", "coords: vector3 {"), + // A value of no known type is of the kind it is checked for. + ("cb) -- callback", "cb: function\n"), + ("decoded) -- after", "decoded: table\n"), + // So is one whose type has no value of that kind, as code checks for what annotations leave out. + ("count) -- defensive", "count: string\n"), + ("count) -- zero", "count: 0\n"), + ("value) -- number", "value: integer\n"), + ("value) -- not string", "value: integer|(fun())|nil\n"), + ("value) -- either", "value: integer|string\n"), + ("value) -- integer", "value: integer\n"), + ("value) -- other function", "value: integer|string|(fun())|nil\n"), + ("value) -- through a local", "value: string\n"), + ("which) -- equal", "which: \"a\"\n"), + ("which) -- other letter", "which: \"b\"\n"), + ("which) -- differs", "which: \"b\"?\n"), + // Guards that leave no value keep the declared type, so `action == 'open'` is not reported. + ("action == 'open') -- neither", "action: \"open\"|\"close\"\n"), + ("action) -- ruled out", "action: \"open\"|\"close\"\n"), + // `local type = type` keeps the checks. + ("id) -- aliased type", "id: string\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let (line, _) = pos(text, "which == 'b'", 0); + assert_eq!( + findings(&mut client, CLIENT, &["impossible-comparison"]), + [( + "impossible-comparison".to_string(), + line as u64, + "Comparing `\"a\"` with `\"b\"` is always false".to_string() + )], + "comparisons see the narrowed types" + ); +} + #[test] fn returns_have_to_match_one_of_the_sets_of_values() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 23b2815..b66a0d5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -465,7 +465,9 @@ if type(value) == "tabel" then end -- Comparing `lua_type` with `"tabel"` A side has a type when something declares it: a literal, an operator such as `not` or `..`, a `@param`, `@type` or `@return`, a `@field` of a class, an alias or enum, a stub or a native. A local that is never assigned again has the type of the value it is declared with, so -`local state = GetState()` is checked like the call. +`local state = GetState()` is checked like the call, and inside a +[type guard](../crates/qbx_lua_ls/README.md#type-guards) the type the guard narrows it to: the right +side of `class ~= 13 or class ~= 14` only runs when `class` is `13`, so it is always true. Only clear cases count, and the rest is left alone: From 2e70fe53fe725c9f18bbba4e302937b939cf3d4a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:56:23 +0200 Subject: [PATCH 010/193] feat(infer): keep the literals of a local declared from another variable --- crates/qbx_lua_ls/src/infer.rs | 15 +++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index e54297c..106e175 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -763,9 +763,10 @@ impl<'a> Infer<'a> { } } - /// The type of the name at `index` of a `local` statement. A name that takes what a call returns - /// has the type the call returns, unless it is `reassigned` after its declaration: it may then - /// hold other values of that kind, so `"active"|"busy"` becomes `string`. + /// The type of the name at `index` of a `local` statement. A name that takes what a call returns, + /// or the value of another variable or a field, has its type, unless it is `reassigned` after + /// its declaration: it may then hold other values of that kind, so `"active"|"busy"` becomes + /// `string`. A literal written out is widened, as `local mode = 'dev'` is a setting to change. fn local_stmt_type(&self, stmt: &Stmt, index: usize, top_level: bool, reassigned: bool) -> Type { let StmtKind::Local { names, exprs, in_unpack } = &stmt.kind else { return Type::Unknown }; let doc = self.ctx.doc_at(stmt.span.start); @@ -786,10 +787,16 @@ impl<'a> Infer<'a> { } let ty = if is_last { self.expr_multi(expr).into_iter().next().unwrap_or_default() } else { self.expr(expr) }; - // The `false|string` of a call keeps its `false`, while `local done = false` is a boolean. + let is_literal = matches!( + expr.unparen().kind, + ExprKind::True | ExprKind::False | ExprKind::Number(_) | ExprKind::String(_) + ); + // The `false|string` of a call keeps its `false`, and `local copy = state` the literals + // `state` lists, while `local done = false` is a boolean. return match (expr.is_call(), reassigned) { (true, false) => ty, (true, true) => ty.widen_returned(), + (false, false) if !is_literal => ty, (false, _) => ty.widen(), }; } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 03e84d1..0504f20 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1751,15 +1751,18 @@ local function GetLevel() end local state = GetState() +local copy = state local level, label = GetLevel() local changed = GetState() changed = 'other' local mode = 'dev' -print(state, level, label, changed, mode) +print(state, copy, level, label, changed, mode) "; client.open_with(CLIENT, text); for (needle, expected) in [ ("state = GetState", "local state: \"active\"|\"busy\"|\"ready\""), + // A local that takes the value of another keeps its literals. + ("copy = state", "local copy: \"active\"|\"busy\"|\"ready\""), ("level, label", "local level: 1|2"), ("label = GetLevel", "local label: \"low\"|\"high\""), // One that is assigned again may hold other values of that kind. From 30152ef2e921faf207cfb87ca7593a033c2f92de Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:57:21 +0200 Subject: [PATCH 011/193] feat(infer): apply ---@cast lines to the locals they name --- crates/qbx_lua_ls/README.md | 39 ++- crates/qbx_lua_ls/src/features/comparisons.rs | 22 +- crates/qbx_lua_ls/src/infer.rs | 103 ++++++-- crates/qbx_lua_ls/src/narrow.rs | 208 ++++++++++++++- crates/qbx_lua_ls/tests/lsp.rs | 239 +++++++++++++++++- crates/qbx_luacats/src/luacats.rs | 88 +++++++ crates/qbx_luacats/src/types.rs | 50 +++- docs/reference.md | 3 +- 8 files changed, 706 insertions(+), 46 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index bec50b3..fc4b363 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -47,7 +47,8 @@ Available features depend on the editor's LSP client. [guarded code](#type-guards), a local is no longer `nil` or `false`, and `type(data) == "table"` or `state == "busy"` narrow it to that kind or value. A guard on one value of `local ok, err = f()` also narrows the others, for functions that return - [sets of values](#sets-of-returned-values) such as `false | (string, string)`. + [sets of values](#sets-of-returned-values) such as `false | (string, string)`, and a + [`---@cast`](#casts) changes the type of a local from its line on. - Diagnostics and quick fixes with resource and client/server context, plus LuaCATS type checks: `missing-fields` and `assign-type-mismatch` for tables and assignments that leave out required fields of their class or store a value of the wrong type, in a field or in a variable typed with @@ -125,8 +126,8 @@ and `math.type(name)` compared with a name they give, and those joined by `and`, They apply to the branches of an `if` or `elseif`, to the code after an `if` whose other branches all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the right side of `and` and `or`, and to the code after `assert(name)`. A local that is assigned again after its -declaration is not narrowed, since a guard says nothing about the new value, and neither are -globals and fields. +declaration is only narrowed by the guards after a [`---@cast`](#casts) of it, since a guard says +nothing about the new value, and globals and fields are not narrowed. A comparison with a literal narrows the local to that literal: inside `if state == "busy" then`, a `"active"|"busy"|nil` and a `string` are both `"busy"`, and in the `else` branch the first is @@ -152,6 +153,38 @@ after `local type = type`, and not `table.type` of ox_lib. Where one of several checks held, as inside `if type(value) == "string" or type(value) == "number" then`, the local is of the kinds they let through. +### Casts + +A `---@cast` line changes the type of a local from its line on, as in lua-language-server: + +```lua +local data = json.decode(payload) +---@cast data PlayerData +``` + +`---@cast name T` makes it a `T`, `+T` adds `T` to it, `-T` takes `T` out, `+?` and `-?` add and +take out `nil`, and one line can list several, as in `---@cast value +?, -string`. Adding to a local +whose type is unknown leaves a value that may still be anything else, as `string|unknown` for +`+string`, as in lua-language-server. A cast holds to +the end of the block of the code after it, including the functions defined there, and until the +statement that assigns the local again. Unlike lua-language-server, a cast inside an `if` ends with +its branch, except on the last line of the branch: there it holds after the `if`, as it does in +lua-language-server, which is how code types what the branch leaves: + +```lua +if type(translation) == "table" then + translation = translation[1] + ---@cast translation string +end +print(translation) -- string +``` + +On the last line of another block, such as the body of a function or a loop, a cast holds nowhere. +Casts also apply to locals that are assigned again, and only to locals. A guard that +took effect before a cast tells nothing about the type it gives, while one after it narrows that +type. `+T` and `-T` change the type the guards around their line leave instead, so +`---@cast items +number[]` inside `if items then` keeps out the `nil` of a `string[]?`. + ### Sets of returned values A function often returns either one set of values or another: `false` when it fails, and diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index ee0a890..2bb6e20 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -18,6 +18,7 @@ use qbx_lua_syntax::Span; use super::class_tables::{Classes, Key}; use super::unknown_types::has_param_line; use crate::infer::{Decl, Infer}; +use crate::luacats::CastEntry; use crate::types::Type; const MAX_DEPTH: u32 = 16; @@ -104,23 +105,32 @@ impl<'a, 'b> Declared<'a, 'b> { } /// The type of a local where it is read at `offset`. One that is assigned again after its - /// declaration is `unknown`, since its declared type may not be what it holds. + /// declaration is `unknown`, since its declared type may not be what it holds, unless a + /// `---@cast` line types it. A `+T` entry adds to a type, so one that is not known stays so. fn local(&self, id: LocalId, offset: u32, depth: u32) -> Type { + let ty = self.local_declaration(id, depth); + let typed = || { + let mut casts = self.infer.ctx.casts().at(id, offset); + casts.any(|cast| cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_)))) + }; + if ty.is_unknown() && !typed() { + return ty; + } + self.infer.narrowed(id, offset, ty) + } + + fn local_declaration(&self, id: LocalId, depth: u32) -> Type { let local = self.infer.ctx.resolution.local(id); if local.refs.iter().any(|r| r.write) && !(self.keeps_annotations && self.is_annotated(local)) { return Type::Unknown; } - let ty = match self.infer.ctx.decl(local.decl.start) { + match self.infer.ctx.decl(local.decl.start) { Some(Decl::Local { stmt, index }) => self.local_value(id, stmt, *index, depth), Some(Decl::Param { .. }) if has_param_line(self.infer, local) => self.infer.local_type(id), Some(Decl::LocalFunction { .. } | Decl::SelfParam { .. }) => self.infer.local_type(id), Some(Decl::NumericFor) => Type::Number, _ => Type::Unknown, - }; - if ty.is_unknown() { - return ty; } - self.infer.narrowed(id, offset, ty) } /// Whether a `---@type` above its declaration or a `@param` line types the local. diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 106e175..a4acda4 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -8,14 +8,14 @@ use qbx_lua_analysis::env::leading_doc_lines; use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolution, Resolved}; use qbx_lua_analysis::side_guard::SideRegions; use qbx_lua_syntax::ast::*; -use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr}; +use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::callback_wrappers::{self, Wrapper}; use crate::index::{FileId, Index, SymbolKind}; use crate::locate::statement_at; -use crate::luacats::{applies_on, parse_doc_lines, DocGroup}; -use crate::narrow::Guards; +use crate::luacats::{applies_on, parse_doc_lines, CastEntry, DocGroup}; +use crate::narrow::{Casts, Guards}; use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeParser}; const MAX_DEPTH: u32 = 24; @@ -133,6 +133,7 @@ pub struct FileContext<'a> { tables: FxHashMap>, docs: RefCell>>, guards: OnceCell, + casts: OnceCell, } impl<'a> FileContext<'a> { @@ -154,6 +155,7 @@ impl<'a> FileContext<'a> { tables: collector.tables, docs: RefCell::new(FxHashMap::default()), guards: OnceCell::new(), + casts: OnceCell::new(), } } @@ -162,6 +164,11 @@ impl<'a> FileContext<'a> { self.guards.get_or_init(|| Guards::of(self.chunk, self.resolution)) } + /// The `---@cast` lines of the file. + pub fn casts(&self) -> &Casts { + self.casts.get_or_init(|| Casts::of(self.source, self.chunk, self.resolution)) + } + pub fn decl(&self, decl_start: u32) -> Option<&Decl<'a>> { self.decls.get(&decl_start) } @@ -597,16 +604,41 @@ impl<'a> Infer<'a> { } } - /// The type of a local where it is read at `offset`: its own type without what the guards - /// around the read rule out, such as the `nil` of a `string?` after `if not name then return end`. + /// The type of a local where it is read at `offset`: its own type as the `---@cast` lines before + /// the read change it, without what the guards around the read rule out, such as the `nil` of a + /// `string?` after `if not name then return end`. pub fn local_type_at(&self, id: LocalId, offset: u32) -> Type { self.narrowed(id, offset, self.local_type(id)) } - /// `ty`, the type of the local `id`, without what the guards around a read at `offset` rule out. + /// `ty`, the type of the local `id`, as the casts before a read at `offset` change it and + /// without what the guards around the read rule out. A guard that takes effect before a cast + /// tells nothing about the type the cast gives, but `+T` and `-T` change the type that the + /// guards around their line leave, as `---@cast items +number[]` inside `if items then` does, + /// for as long as those guards hold. pub fn narrowed(&self, id: LocalId, offset: u32, ty: Type) -> Type { - let ty = self.linked_type(id, offset).unwrap_or(ty); - let mut facts = self.ctx.guards().at(id, offset).peekable(); + let mut since = None; + let mut ty = ty; + for cast in self.ctx.casts().at(id, offset) { + // A guard that ends before the read, as one does at a label that a `goto` past the cast + // reaches, tells nothing about the read. + if !cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_))) { + ty = self.guards_applied(id, since, Span::new(cast.span.start, offset), ty); + } + ty = cast.entries.iter().fold(ty, |ty, (entry, _)| self.cast(ty, entry)); + since = Some(cast.span.start); + } + self.guards_applied(id, since, Span::empty(offset), ty) + } + + /// `ty` without what the guards that hold from the start to the end of `code` rule out, of + /// those that take effect at `since` or later. + fn guards_applied(&self, id: LocalId, since: Option, code: Span, ty: Type) -> Type { + let ty = match since { + Some(_) => ty, + None => self.linked_type(id, code).unwrap_or(ty), + }; + let mut facts = self.ctx.guards().since(id, since, code).peekable(); if facts.peek().is_none() { return ty; } @@ -621,23 +653,50 @@ impl<'a> Infer<'a> { } } + /// `ty` as one entry of a `---@cast` line changes it. + fn cast(&self, ty: Type, entry: &CastEntry) -> Type { + match entry { + CastEntry::Replace(cast) => cast.clone(), + // Adding to a type nothing tells leaves a value that may still be anything else, as + // lua-language-server shows `string|unknown`. + CastEntry::Add(added) if ty.is_unknown() => added.clone().or_unknown(), + CastEntry::Add(added) => Type::union([ty, added.clone()]), + CastEntry::Remove(removed) => { + let removed = self.expand_aliases(removed, 0); + let removes = |part: &Type| match &removed { + Type::Union(parts) => parts.iter().any(|removed| covers(removed, part)), + removed => covers(removed, part), + }; + match self.expand_aliases(&ty, 0) { + Type::Union(parts) if parts.iter().any(removes) => { + Type::union(parts.into_iter().filter(|part| !removes(part))) + } + Type::Union(_) => ty, + one if removes(&one) => Type::Unknown, + _ => ty, + } + } + } + } + /// `ty` with the aliases it names, also in a union, replaced by what they stand for. fn expand_aliases(&self, ty: &Type, depth: u32) -> Type { - match self.resolve_alias(ty) { + let resolved = self.resolve_alias(ty); + match &resolved { Type::Union(parts) if depth < 8 => { - Type::union(parts.iter().map(|part| self.expand_aliases(part, depth + 1))) + resolved.rebuilt(parts.iter().map(|part| self.expand_aliases(part, depth + 1))) } - other => other, + _ => resolved, } } - /// The type at `offset` of a local declared with others by one call, as `err` in - /// `local ok, err = f()`, when the guards on those locals rule out some of the sets of values - /// the call returns: what its position holds in the sets that are left. - fn linked_type(&self, id: LocalId, offset: u32) -> Option { + /// The type in `code` of a local declared with others by one call, as `err` in + /// `local ok, err = f()`, when the guards on those locals that hold there rule out some of the + /// sets of values the call returns: what its position holds in the sets that are left. + fn linked_type(&self, id: LocalId, code: Span) -> Option { let guards = self.ctx.guards(); let members = guards.linked(id)?; - if !members.iter().any(|(member, _)| guards.at(*member, offset).next().is_some()) { + if !members.iter().any(|(member, _)| guards.since(*member, None, code).next().is_some()) { return None; } let Some(Decl::Local { stmt, .. }) = self.ctx.decl(self.ctx.resolution.local(id).decl.start) else { @@ -656,7 +715,7 @@ impl<'a> Infer<'a> { let is_possible = |set: &&Vec| { members.iter().all(|(member, position)| { let value = value(set, *position); - guards.at(*member, offset).all(|fact| fact.apply(&value).is_some()) + guards.since(*member, None, code).all(|fact| fact.apply(&value).is_some()) }) }; let possible: Vec<&Vec> = sets.iter().filter(is_possible).collect(); @@ -2267,7 +2326,7 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { if parts.iter().filter(|t| !matches!(t, Type::Nil)).all(Type::is_unknown) { Type::Unknown } else { - Type::union(parts) + ty.rebuilt(parts) } } Type::Map(k, v) => Type::Map(Box::new(substitute(k, generics)), Box::new(substitute(v, generics))), @@ -2354,6 +2413,14 @@ fn may_be_literal_key(key: &Type, literal: &Type) -> bool { } } +/// Whether the `-T` entry of a `---@cast` line for `removed` takes `part` out of a union: the same +/// type, or a literal or integer of the kind `removed` names. +fn covers(removed: &Type, part: &Type) -> bool { + removed == part + || (!removed.is_literal() && part.widen() == *removed) + || (*removed == Type::Number && matches!(part, Type::Integer | Type::IntLit(_) | Type::Handle(_))) +} + /// What one position holds across several lists of returned values. fn merge_values(parts: Vec) -> Type { // Merged with `nil`, a value that cannot be inferred would read as `nil`. diff --git a/crates/qbx_lua_ls/src/narrow.rs b/crates/qbx_lua_ls/src/narrow.rs index 593f8a7..9be0b8b 100644 --- a/crates/qbx_lua_ls/src/narrow.rs +++ b/crates/qbx_lua_ls/src/narrow.rs @@ -1,14 +1,16 @@ //! Type guards: what a condition tells about the locals it tests, in the code that only runs when //! it held or failed. `if not name then return end` leaves `name` holding a value for the rest of -//! its block, and `if name then ... end` for the branch. +//! its block, and `if name then ... end` for the branch. `---@cast` lines change the type of a local +//! from their line on. use qbx_lua_analysis::scope::{LocalId, Resolution, Resolved}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::{NumberValue, Span}; -use rustc_hash::FxHashMap; +use qbx_lua_syntax::{CommentKind, NumberValue, Span, Token, TokenKind}; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::infer::always_exits; +use crate::luacats::{parse_cast, CastEntry}; use crate::types::Type; /// The names `type` gives: those of Lua, and those of the vectors, quaternions and matrices CfxLua @@ -47,7 +49,10 @@ impl Fact { match ty { Type::Union(parts) => { let kept: Vec = parts.iter().filter_map(|part| self.apply(part)).collect(); - (!kept.is_empty()).then(|| Type::union(kept)) + // The `unknown` of `string|unknown` may be any value the fact holds for. + let keeps_unknown = kept.contains(&Type::Unknown); + let ty = (!kept.is_empty()).then(|| Type::union(kept))?; + Some(if keeps_unknown { ty.or_unknown() } else { ty }) } _ => self.apply_part(ty), } @@ -166,6 +171,8 @@ type Facts = Vec<(LocalId, Fact)>; #[derive(Debug, Default)] pub struct Guards { facts: FxHashMap>, + /// The locals with guards that are assigned again after their declaration. + reassigned: FxHashSet, /// The locals that one call declares together, as in `local ok, err = f()`, each with the /// position of its value among those the call returns. links: Vec>, @@ -181,13 +188,30 @@ impl Guards { type_functions: FxHashMap::default(), }; finder.visit_block(&chunk.block); - finder.guards + let mut guards = finder.guards; + let reassigned = guards.facts.keys().filter(|local| resolution.local(**local).refs.iter().any(|r| r.write)); + guards.reassigned = reassigned.copied().collect(); + guards } /// What the guards around `offset` tell about `local`. pub fn at(&self, local: LocalId, offset: u32) -> impl Iterator { - let facts = self.facts.get(&local).map(Vec::as_slice).unwrap_or_default(); - facts.iter().filter(move |(span, _)| span.start <= offset && offset < span.end).map(|(_, fact)| fact) + self.since(local, None, Span::empty(offset)) + } + + /// What the guards that hold from the start to the end of `code` tell about `local`, with the + /// start of a `cast` that holds there only those that take effect at its start or later. A + /// guard on a local that is assigned again only tells about the type a cast gives it: the value + /// it tested may have been replaced since, unless a cast that holds until the local is next + /// assigned started before the guard. + pub fn since(&self, local: LocalId, cast: Option, code: Span) -> impl Iterator { + let facts = match cast { + None if self.reassigned.contains(&local) => &[], + _ => self.facts.get(&local).map(Vec::as_slice).unwrap_or_default(), + }; + let start = cast.unwrap_or(0); + let holds = move |span: &Span| start <= span.start && span.contains(code.start) && span.contains(code.end); + facts.iter().filter(move |(span, _)| holds(span)).map(|(_, fact)| fact) } /// The locals declared by the same call as `local`, itself included, each with the position of @@ -209,12 +233,16 @@ struct Finder<'r> { } impl Finder<'_> { - /// The local `expr` names, unless something assigns to it after its declaration: a guard says - /// nothing about a value that may have been replaced since. + /// The local `expr` names. A guard on one that is assigned again after its declaration only + /// counts after a `---@cast`, see `Guards::since`. fn local(&self, expr: &Expr) -> Option { let ExprKind::Name(name) = &expr.unparen().kind else { return None }; let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) else { return None }; - (!self.resolution.local(id).refs.iter().any(|r| r.write)).then_some(id) + Some(id) + } + + fn is_reassigned(&self, local: LocalId) -> bool { + self.resolution.local(local).refs.iter().any(|r| r.write) } fn is_global(&self, name: &Name) -> bool { @@ -422,10 +450,11 @@ impl<'ast> Visitor<'ast> for Finder<'_> { self.link(names, exprs); for (name, expr) in names.iter().zip(exprs) { let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) else { continue }; - if self.resolution.local(id).refs.iter().any(|r| r.write) { + if self.is_reassigned(id) { continue; } - if let Some(checked) = self.type_call(expr) { + // `local kind = type(value)` tells nothing about what is assigned to `value` later. + if let Some(checked) = self.type_call(expr).filter(|(checked, _)| !self.is_reassigned(*checked)) { self.kinds.insert(id, checked); } else if let Some(numbers) = self.type_function(expr) { self.type_functions.insert(id, numbers); @@ -480,3 +509,158 @@ fn leaves(block: &Block) -> bool { _ => always_exits(block), } } + +/// A `---@cast` line. +#[derive(Debug)] +pub struct Cast { + /// The local it names, when one by that name is in scope. + pub local: Option, + /// What each entry does, with where its type is written. + pub entries: Vec<(CastEntry, Span)>, + /// The code it holds in: from its line to the end of the block the statement after it is in, + /// or to the end of the statement that next assigns the local. One on the last line of an `if` + /// branch holds from the end of the `if`, and one on the last line of another block nowhere. + pub span: Span, +} + +/// The `---@cast` lines of a file. +#[derive(Debug, Default)] +pub struct Casts { + casts: Vec, +} + +impl Casts { + pub fn of(source: &str, chunk: &Chunk, resolution: &Resolution) -> Self { + let mut casts = Vec::new(); + for comment in chunk.comments.iter().filter(|comment| comment.kind == CommentKind::Line) { + let text = comment.content.text(source); + let Some(line) = text.strip_prefix('-').map(str::trim_start) else { continue }; + let Some(cast) = parse_cast(line) else { continue }; + // A line without a type, as while one is being typed, changes nothing. + if cast.entries.is_empty() { + continue; + } + let base = comment.content.end - line.len() as u32; + let entries = cast.entries.into_iter(); + casts.push(Cast { + local: resolution.lookup_local_at(cast.name, comment.span.start), + entries: entries + .map(|(entry, at)| (entry, Span::new(base + at.start as u32, base + at.end as u32))) + .collect(), + span: Span::empty(comment.span.end), + }); + } + if casts.is_empty() { + return Self::default(); + } + let mut locals: Vec = casts.iter().filter_map(|cast| cast.local).collect(); + locals.sort_unstable(); + locals.dedup(); + let writes = |local: LocalId| resolution.local(local).refs.iter().filter(|r| r.write).map(|r| r.span.start); + let mut reach = Reach { + tokens: &chunk.tokens, + statements: Vec::new(), + branch_ends: FxHashMap::default(), + writes: locals.iter().flat_map(|local| writes(*local)).map(|at| (at, u32::MAX)).collect(), + }; + reach.writes.sort_unstable(); + reach.visit_block(&chunk.block); + reach.statements.sort_unstable(); + // For each local, where each write to it starts, with the earliest end of a statement that + // assigns it from that write on. + let mut assigned = FxHashMap::default(); + for local in locals { + let mut ends: Vec<(u32, u32)> = writes(local).map(|at| (at, reach.end_of_write(at))).collect(); + ends.sort_unstable(); + let mut earliest = u32::MAX; + for (_, end) in ends.iter_mut().rev() { + earliest = earliest.min(*end); + *end = earliest; + } + assigned.insert(local, ends); + } + for cast in &mut casts { + let line_end = cast.span.start; + let Some((start, end)) = reach.from(line_end) else { continue }; + let reassigned = cast.local.and_then(|local| { + let ends: &Vec<(u32, u32)> = &assigned[&local]; + ends.get(ends.partition_point(|(at, _)| *at <= line_end)).map(|(_, end)| *end) + }); + cast.span = Span::new(start, end.min(reassigned.unwrap_or(u32::MAX)).max(start)); + } + Self { casts } + } + + pub fn iter(&self) -> impl Iterator { + self.casts.iter() + } + + /// The casts of `local` that hold at `offset`, in the order of their lines. + pub fn at(&self, local: LocalId, offset: u32) -> impl Iterator { + self.casts.iter().filter(move |cast| cast.local == Some(local) && cast.span.contains(offset)) + } +} + +/// Finds where the casts of a file stop holding. +struct Reach<'a> { + tokens: &'a [Token], + /// Where each statement starts, with the end of the block it is in. + statements: Vec<(u32, u32)>, + /// Where each `elseif`, `else` and `end` of an `if` starts, with the end of that `if`. + branch_ends: FxHashMap, + /// Where each write to the local of a cast starts, in order, with the end of the innermost + /// statement it is in. + writes: Vec<(u32, u32)>, +} + +impl Reach<'_> { + /// Where a cast whose line ends at `at` starts holding, and the end of the block it holds to: + /// that of the statement after it. After the last statement of an `if` branch, a cast holds + /// from the end of the `if`, as code types what the branch leaves that way, and after that of + /// another block, such as a function or loop body, nowhere. + fn from(&self, at: u32) -> Option<(u32, u32)> { + let next = self.tokens.get(self.tokens.partition_point(|token| token.span.start < at))?; + if let Some(end) = self.branch_ends.get(&next.span.start) { + return self.from(*end); + } + if matches!(next.kind, TokenKind::End | TokenKind::Until | TokenKind::Eof) { + return None; + } + let next = self.statements.partition_point(|(start, _)| *start < at); + self.statements.get(next).map(|(_, end)| (at, *end)) + } + + /// The end of the innermost statement that the write starting at `at` is in. + fn end_of_write(&self, at: u32) -> u32 { + let index = self.writes.partition_point(|(start, _)| *start < at); + self.writes.get(index).map_or(u32::MAX, |(_, end)| *end) + } +} + +impl<'ast> Visitor<'ast> for Reach<'_> { + fn visit_block(&mut self, block: &'ast Block) { + self.statements.extend(block.stmts.iter().map(|stmt| (stmt.span.start, block.span.end))); + visit::walk_block(self, block); + } + + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + // Statements are visited before those inside them, so the innermost one is seen last. + let first = self.writes.partition_point(|(at, _)| *at < stmt.span.start); + let after = self.writes.partition_point(|(at, _)| *at < stmt.span.end); + for (_, end) in &mut self.writes[first..after] { + *end = stmt.span.end; + } + if let StmtKind::If { branches, else_block } = &stmt.kind { + let tokens = self.tokens; + let before = |offset: u32| tokens[..tokens.partition_point(|token| token.span.start < offset)].last(); + let elseifs = branches.iter().skip(1).map(|branch| branch.keyword_span.start); + let keyword = else_block.as_ref().and_then(|block| before(block.span.start)); + let keyword = keyword.filter(|token| token.kind == TokenKind::Else).map(|token| token.span.start); + let end = before(stmt.span.end).filter(|token| token.kind == TokenKind::End); + for start in elseifs.chain(keyword).chain(end.map(|token| token.span.start)) { + self.branch_ends.insert(start, stmt.span.end); + } + } + visit::walk_stmt(self, stmt); + } +} diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 0504f20..b4df83a 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::thread::JoinHandle; -use std::time::Duration; +use std::time::{Duration, Instant}; use lsp_server::{Connection, Message, Notification, Request, RequestId}; use lsp_types::Url; @@ -2079,6 +2079,243 @@ check() ); } +#[test] +fn casts_change_the_type_of_a_local_from_their_line_on() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return string|integer|nil +local function get() end + +local value = get() +print(value) -- before +---@cast value string +print(value) -- cast +local other = get() +---@cast other -? +print(other) -- without nil +---@cast other +boolean, -integer +print(other) -- added and removed +local scoped = get() +if scoped then + ---@cast scoped integer + print(scoped) -- in block +end +print(scoped) -- after block +local changed = get() +---@cast changed string +changed = changed:upper() +print(changed) -- reassigned +local guarded = get() +if type(guarded) == 'string' then + ---@cast guarded integer + print(guarded) -- cast over guard +end +---@cast guarded string? +if guarded then + print(guarded) -- guard over cast +end +local converted = get() +if type(converted) == 'number' then + converted = tostring(converted) + ---@cast converted string +end +print(converted) -- after branch +local nested = get() +if nested then + if type(nested) == 'number' then + print(nested) + ---@cast nested integer + end +end +print(nested) -- after nested branch +local branched = get() +if branched == 1 then + print(branched) + ---@cast branched boolean +elseif branched == 'a' then + print(branched) -- next branch +end +local looped = get() +for _ = 1, 2 do + print(looped) + ---@cast looped integer +end +print(looped) -- after loop +local called = get() +local function body() + print(called) + ---@cast called integer +end +body() +print(called) -- after function +local typing = get() +if not typing then return end +---@cast typing +print(typing) -- no type yet +---@cast typing, other string +print(typing) -- no name +local plain = get() +plain = get() +if plain then + print(plain) -- reassigned guard +end +local data = get() +data = get() +---@cast data string? +if data then + print(data) -- guard after cast + data = get() + print(data) -- assigned after guard +end +local function later() + print(value) -- closure +end +later() +local function untyped(x) return x end +local loose = untyped(1) +---@cast loose +string +print(loose) -- added to unknown +if loose then + print(loose) -- guarded unknown +end +---@cast loose -string +print(loose) -- removed from unknown +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("value) -- before", "value: string|integer|nil\n"), + ("value) -- cast", "value: string\n"), + ("other) -- without nil", "other: string|integer\n"), + ("other) -- added and removed", "other: string|boolean\n"), + ("scoped) -- in block", "scoped: integer\n"), + // A cast ends with its block... + ("scoped) -- after block", "scoped: string|integer|nil\n"), + // ...and once the local is assigned again. + ("changed:upper()", "changed: string\n"), + ("changed) -- reassigned", "changed: string|integer|nil\n"), + ("guarded) -- cast over guard", "guarded: integer\n"), + ("guarded) -- guard over cast", "guarded: string\n"), + // On the last line of an `if` branch, a cast types what the branch leaves for the code + // after the `if`, but not for its other branches... + ("converted) -- after branch", "converted: string\n"), + ("nested) -- after nested branch", "nested: integer\n"), + ("branched) -- next branch", "branched: \"a\"\n"), + // ...and on that of another block, it holds nowhere. + ("looped) -- after loop", "looped: string|integer|nil\n"), + ("called) -- after function", "called: string|integer|nil\n"), + // A line with no type keeps the guards before it. + ("typing) -- no type yet", "typing: string|integer\n"), + ("typing) -- no name", "typing: string|integer\n"), + // A guard on a local that is assigned again narrows the type a cast before it gives, up to + // the next assignment. + ("plain) -- reassigned guard", "plain: string|integer|nil\n"), + ("data) -- guard after cast", "data: string\n"), + ("data) -- assigned after guard", "data: string|integer|nil\n"), + ("value) -- closure", "value: string\n"), + // Adding to a type nothing tells leaves a value that may still be anything else. + ("loose) -- added to unknown", "loose: string|unknown\n"), + ("loose) -- guarded unknown", "loose: string|unknown\n"), + ("loose) -- removed from unknown", "loose: unknown\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn casts_that_add_or_remove_types_keep_the_guards_before_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return string[]|boolean|nil +local function get() end + +local items = get() +if items then + ---@cast items +number[] + print(items) -- added +end +if type(items) ~= 'boolean' then + ---@cast items -nil + print(items) -- removed +end +---@cast items +integer +print(items) -- outside guards + +local skipped = get() +if not skipped then goto skip end +---@cast skipped +number +print(skipped) -- before the label +::skip:: +print(skipped) -- after the label +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("items) -- added", "items: string[]|true|number[]\n"), + ("items) -- removed", "items: string[]\n"), + ("items) -- outside guards", "items: string[]|boolean|integer|nil\n"), + ("skipped) -- before the label", "skipped: string[]|true|number\n"), + // The `goto` reaches the label without the guard. + ("skipped) -- after the label", "skipped: string[]|boolean|number|nil\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn comparisons_read_the_types_casts_give() { + let mut client = Client::start(fixture_root()); + let text = "\ +local function untyped(value) return value end + +local added = untyped(1) +---@cast added +string +if added == 5 then end +local reassigned = 1 +reassigned = untyped(reassigned) +---@cast reassigned +string +if reassigned == 5 then end +local typed = 1 +typed = untyped(typed) +---@cast typed string +if typed == 5 then end +local branched = untyped(1) +if branched == 1 then + branched = 5 + ---@cast branched integer +elseif branched == 'a' then + print(branched) +end +"; + client.open_with(CLIENT, text); + let (line, _) = pos(text, "if typed == 5", 0); + assert_eq!( + findings(&mut client, CLIENT, &["impossible-comparison"]), + [("impossible-comparison".to_string(), line as u64, "Comparing `string` with `5` is always false".to_string())], + "a `+T` cast leaves a type that is not known as it is, and a cast on the last line of a branch \ + does not reach the next one" + ); +} + +#[test] +fn many_casts_and_writes_of_one_local_stay_fast() { + let mut client = Client::start(fixture_root()); + // Each cast holds until the next write, which is no reason to compare every cast with every + // write in every statement. + let mut text = "---@return string|integer|nil\nlocal function get() end\nlocal x = get()\n".to_string(); + text.push_str(&"---@cast x string\nprint(x)\nx = get()\n".repeat(2000)); + text.push_str("---@cast x integer\nprint(x) -- last\n"); + client.open_with(CLIENT, &text); + let (line, character) = pos(&text, "x) -- last", 0); + let started = Instant::now(); + let hover = client.hover_text(CLIENT, line, character); + let elapsed = started.elapsed(); + assert!(hover.contains("x: integer\n"), "{hover}"); + assert!(elapsed < Duration::from_secs(5), "hover took {elapsed:?}"); +} + #[test] fn returns_have_to_match_one_of_the_sets_of_values() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 0df84c6..05fccae 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -656,6 +656,70 @@ pub fn declared_generics(line: &str) -> Vec<&str> { list.split(',').filter_map(|entry| entry.trim().split([':', ' ']).next()).filter(|name| !name.is_empty()).collect() } +/// What one entry of a `@cast` line does to the type of the variable it names. +#[derive(Clone, Debug, PartialEq)] +pub enum CastEntry { + /// `T`: the variable holds a `T`. + Replace(Type), + /// `+T`, or `+?` for `nil`: it may also hold a `T`. + Add(Type), + /// `-T`, or `-?` for `nil`: it holds no `T`. + Remove(Type), +} + +/// A `@cast name T, +T, -?` line: the name it casts, and each entry with the bytes of the line its +/// type is written on. +#[derive(Clone, Debug, PartialEq)] +pub struct DocCast<'a> { + pub name: &'a str, + pub entries: Vec<(CastEntry, std::ops::Range)>, +} + +/// Reads a `@cast` doc line whose `---` prefix is removed. +pub fn parse_cast(line: &str) -> Option> { + let ("cast", rest) = split_tag(line)? else { return None }; + let mut parser = TypeParser::new(rest); + let name = parser.ident()?; + let mut entries = Vec::new(); + let mut rest = parser.rest(); + loop { + let entry = rest.trim_start(); + let (sign, body) = match entry.strip_prefix('+') { + Some(body) => (Some(true), body), + None => match entry.strip_prefix('-') { + Some(body) => (Some(false), body), + None => (None, entry), + }, + }; + let (ty, after) = match body.strip_prefix('?').filter(|_| sign.is_some()) { + Some(after) => (Type::Nil, after), + None => { + let mut parser = TypeParser::new(body); + let ty = parser.parse(); + (ty, parser.rest()) + } + }; + let written = body[..body.len() - after.len()].trim(); + if written.is_empty() { + break; + } + let start = line.len() - body.trim_start().len(); + entries.push(( + match sign { + None => CastEntry::Replace(ty), + Some(true) => CastEntry::Add(ty), + Some(false) => CastEntry::Remove(ty), + }, + start..start + written.len(), + )); + match after.trim_start().strip_prefix(',') { + Some(next) => rest = next, + None => break, + } + } + Some(DocCast { name, entries }) +} + /// The tag of a doc line and the class and alias names on it. fn type_names(line: &str) -> Option<(&str, Vec>)> { let (tag, rest) = match listed_value(line) { @@ -1133,6 +1197,30 @@ mod tests { assert!(declared_generics("@param value T").is_empty()); } + #[test] + fn reads_cast_lines() { + let line = "@cast value string?, +integer, -?, +?, - boolean # why"; + let cast = parse_cast(line).unwrap(); + assert_eq!(cast.name, "value"); + let entries: Vec<(CastEntry, &str)> = + cast.entries.into_iter().map(|(entry, range)| (entry, &line[range])).collect(); + assert_eq!( + entries, + [ + (CastEntry::Replace(Type::String.optional()), "string?"), + (CastEntry::Add(Type::Integer), "integer"), + (CastEntry::Remove(Type::Nil), "?"), + (CastEntry::Add(Type::Nil), "?"), + (CastEntry::Remove(Type::Boolean), "boolean"), + ] + ); + let cast = parse_cast("@cast Items +fun(name: string): Item").unwrap(); + assert!(matches!(&cast.entries[..], [(CastEntry::Add(Type::Fun(_)), _)])); + assert_eq!(parse_cast("@cast value").map(|cast| cast.entries.len()), Some(0)); + assert_eq!(parse_cast("@type value string"), None); + assert_eq!(parse_cast("@cast"), None); + } + #[test] fn ignores_non_type_names_in_annotations() { for line in [ diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index 5f0bea5..d875164 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -179,11 +179,20 @@ impl Type { matches!(self, Type::StringLit(_) | Type::IntLit(_) | Type::BooleanLit(_)) } + /// The union of `types`. A value of unknown type adds nothing to the others, as when one branch + /// of `a or b` cannot be inferred, except for the `unknown` that a union among `types` holds: + /// such a union says the value may be anything else, as a written `string|unknown` or a + /// `---@cast name +string` on a local of unknown type does. It goes last, as lua-language-server + /// shows it. pub fn union(types: impl IntoIterator) -> Type { let mut flat: Vec = Vec::new(); + let mut keeps_unknown = false; for ty in types { match ty { - Type::Union(inner) => inner.into_iter().for_each(|t| push_unique(&mut flat, t)), + Type::Union(inner) => { + keeps_unknown |= inner.contains(&Type::Unknown); + inner.into_iter().for_each(|t| push_unique(&mut flat, t)); + } other => push_unique(&mut flat, other), } } @@ -192,6 +201,9 @@ impl Type { } if flat.len() > 1 { flat.retain(|t| !t.is_unknown()); + if keeps_unknown { + flat.push(Type::Unknown); + } } match flat.len() { 0 => Type::Unknown, @@ -200,13 +212,28 @@ impl Type { } } + /// `self`, or a value that may be anything else: `string|unknown` for a `string`. + pub fn or_unknown(self) -> Type { + Type::union([self, Type::Union(vec![Type::Unknown])]) + } + + /// The union of `parts`, which are what became of the parts of the union `self`: it keeps the + /// `unknown` that `self` holds. + pub fn rebuilt(&self, parts: impl IntoIterator) -> Type { + let ty = Type::union(parts); + match self { + Type::Union(types) if types.contains(&Type::Unknown) => ty.or_unknown(), + _ => ty, + } + } + pub fn optional(self) -> Type { Type::union([self, Type::Nil]) } pub fn without_nil(&self) -> Type { match self { - Type::Union(types) => Type::union(types.iter().filter(|t| !matches!(t, Type::Nil)).cloned()), + Type::Union(types) => self.rebuilt(types.iter().filter(|t| !matches!(t, Type::Nil)).cloned()), other => other.clone(), } } @@ -217,7 +244,7 @@ impl Type { Type::BooleanLit(_) => Type::Boolean, Type::StringLit(_) => Type::String, Type::IntLit(_) => Type::Integer, - Type::Union(types) => Type::union(types.iter().map(Type::widen)), + Type::Union(types) => self.rebuilt(types.iter().map(Type::widen)), other => other.clone(), } } @@ -232,7 +259,7 @@ impl Type { if !mixed || types.contains(&Type::Boolean) || (has(true) && has(false)) { return self.widen(); } - Type::union(types.iter().map(|t| if matches!(t, Type::BooleanLit(_)) { t.clone() } else { t.widen() })) + self.rebuilt(types.iter().map(|t| if matches!(t, Type::BooleanLit(_)) { t.clone() } else { t.widen() })) } pub fn as_fun(&self) -> Option<&Arc> { @@ -280,7 +307,7 @@ impl Type { if parts.iter().filter(|t| !matches!(t, Type::Nil)).all(Type::is_unknown) { Type::Unknown } else { - Type::union(parts) + self.rebuilt(parts) } } Type::Fun(fun) => Type::Fun(Arc::new(fun.with_self(owner))), @@ -896,6 +923,19 @@ mod tests { } } + #[test] + fn unions_keep_the_unknown_they_hold() { + let loose = Type::union([Type::String, Type::Unknown]); + assert_eq!(loose.to_string(), "string", "an unknown value adds nothing"); + let held = Type::StringLit("a".into()).or_unknown(); + assert_eq!(held.to_string(), "\"a\"|unknown"); + assert_eq!(Type::union([held.clone(), Type::Integer]).to_string(), "\"a\"|integer|unknown"); + assert_eq!(held.widen().to_string(), "string|unknown"); + assert_eq!(held.clone().optional().without_nil().to_string(), "\"a\"|unknown"); + assert_eq!(Type::Unknown.or_unknown(), Type::Unknown); + assert_eq!(Type::Any.or_unknown(), Type::Any); + } + #[test] fn widening_and_nil_removal() { assert_eq!(parse_type("'a'|1|true").widen().to_string(), "string|integer|boolean"); diff --git a/docs/reference.md b/docs/reference.md index b66a0d5..d5d4439 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -478,7 +478,8 @@ Only clear cases count, and the rest is left alone: - Comparisons with `nil`. Annotations often leave out the `?` of a value that may be missing, and the check for it is deliberate. - A local that is assigned again after its declaration, since its declared type may not be what - it holds. + it holds, unless a `---@cast name T` types it. `---@cast name +T` does not: it adds `T` to a + type that is still not known, as it does for a local with no declared type. - Natives documented as `boolean` compared with a number. Scripts can get `1` instead of `true` from them, so `IsPedInAnyVehicle(ped) == 1` is valid. From e7728eb65208156623cc69200eb7bd53167ddd84 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:57:59 +0200 Subject: [PATCH 012/193] feat(rules): add cast-type-mismatch --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 8 +- crates/qbx_lua_ls/src/features/casts.rs | 79 ++++++++++++ .../qbx_lua_ls/src/features/class_tables.rs | 32 ++++- crates/qbx_lua_ls/src/features/comparisons.rs | 6 + crates/qbx_lua_ls/src/features/diagnostics.rs | 8 +- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/tests/lsp.rs | 113 ++++++++++++++++++ docs/reference.md | 31 ++++- docs/rules.md | 1 + 12 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/casts.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index c18cc93..fc7d506 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -34,6 +34,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "assign-type-mismatch", "return-type-mismatch", "missing-return", + "cast-type-mismatch", "no-unknown", ]; diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 0a18ba7..4961a56 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -80,6 +80,8 @@ pub const UNDECLARED_FIELD: &str = "undeclared-field"; pub const RETURN_TYPE_MISMATCH: &str = "return-type-mismatch"; /// Reported by qbx-lua-ls, which reads the `@return` annotations with the types they allow. pub const MISSING_RETURN: &str = "missing-return"; +/// Reported by qbx-lua-ls, which reads the LuaCATS types; the linter itself has no type information. +pub const CAST_TYPE_MISMATCH: &str = "cast-type-mismatch"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. pub const NO_UNKNOWN: &str = "no-unknown"; pub const CONST_REASSIGN: &str = "const-reassign"; @@ -143,6 +145,7 @@ pub static RULES: &[Rule] = &[ rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), rule(RETURN_TYPE_MISMATCH, Correctness, WARN, false, "A function returns a value of a different type than its @return annotation declares (language server only)."), rule(MISSING_RETURN, Correctness, WARN, false, "A function with a required @return value can end, or return, without it (language server only)."), + rule(CAST_TYPE_MISMATCH, Correctness, WARN, false, "A ---@cast gives a variable a type that its declared type does not take (language server only)."), rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type: none is declared and none can be inferred (language server only)."), rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), rule(SELF_ASSIGNMENT, Suspicious, WARN, false, "A variable is assigned to itself."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 87215f7..2891488 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -86,6 +86,7 @@ fn bad_resource_reports_every_rule() { | "undeclared-field" | "return-type-mismatch" | "missing-return" + | "cast-type-mismatch" | "no-unknown" | "impossible-comparison" ); diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index fc4b363..8acd179 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -56,8 +56,9 @@ Available features depend on the editor's LSP client. a `---@class (strict)` does not declare, and `return-type-mismatch` and `missing-return` for functions, including callback handlers, that do not return what their `@return` declares. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no - value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`. The - opt-in `no-unknown` reports parameters, locals and loop variables that have no type. + value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and + `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. The opt-in + `no-unknown` reports parameters, locals and loop variables that have no type. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. - QB-Core and ESX server callback completion, navigation and payload hints from local handlers. - Callback systems a resource wraps itself, declared with `---@callback`, with name completion, @@ -184,6 +185,9 @@ Casts also apply to locals that are assigned again, and only to locals. A guard took effect before a cast tells nothing about the type it gives, while one after it narrows that type. `+T` and `-T` change the type the guards around their line leave instead, so `---@cast items +number[]` inside `if items then` keeps out the `nil` of a `string[]?`. +`cast-type-mismatch` reports a cast to a type the declared type of the local does not take, +such as `---@cast count string` for an `integer`; see the +[reference](../../docs/reference.md#casts). ### Sets of returned values diff --git a/crates/qbx_lua_ls/src/features/casts.rs b/crates/qbx_lua_ls/src/features/casts.rs new file mode 100644 index 0000000..e7e8ae9 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/casts.rs @@ -0,0 +1,79 @@ +//! `cast-type-mismatch`: a `---@cast name T` that gives a local a type its declared type does not +//! take, such as `string` for an `integer`. As for `assign-type-mismatch`, each type that `T` lists +//! has to be of a kind the declared type allows, and a literal has to be one it lists. A class also +//! has to be one that the declared type names or extends, as lua-language-server checks it. Only +//! plain casts are checked, since `+T` and `-T` add to the type and take from it. + +use qbx_lua_analysis::scope::LocalId; +use qbx_lua_syntax::ast::{ExprKind, StmtKind}; +use qbx_lua_syntax::Span; + +use super::class_tables::Classes; +use super::comparisons::Declared; +use crate::infer::{Decl, Infer}; +use crate::luacats::CastEntry; +use crate::types::Type; + +/// Each type of a `---@cast` line that the type its local is declared with does not take, with the +/// message naming both. +pub fn mismatched_casts(infer: &Infer) -> Vec<(Span, String)> { + let casts = infer.ctx.casts(); + if casts.iter().next().is_none() { + return Vec::new(); + } + let classes = Classes::new(infer); + let declared = Declared::keeping_annotations(infer); + let mut out = Vec::new(); + for cast in casts.iter() { + let Some(local) = cast.local else { continue }; + let expected = declared.declaration(local); + // `local data = nil` makes room for a value that the cast tells the type of. + if expected.is_unknown() || expected == Type::Nil { + continue; + } + // A local declared with a table constructor holds a table any class may describe, as + // lua-language-server reads it. + let holds_table = declared_with_table(infer, local); + for (entry, span) in &cast.entries { + let CastEntry::Replace(ty) = entry else { continue }; + let parts = match ty { + Type::Union(parts) => parts.as_slice(), + one => std::slice::from_ref(one), + }; + let rejects = |part: &Type| { + classes.rejects(&expected, classes.file(), part) + || (!holds_table && !takes_class(&classes, &expected, part)) + }; + if parts.iter().any(rejects) { + out.push((*span, format!("Cannot convert `{expected}` to `{ty}`"))); + } + } + } + out +} + +/// Whether a local declared as `declared` may be cast to `cast` as far as classes go: each class +/// that `cast` names needs a type of `declared` that is that class or one it extends, or that is no +/// class but may hold it, like `table` or `any`. Type arguments are not compared. +fn takes_class(classes: &Classes, declared: &Type, cast: &Type) -> bool { + let file = classes.file(); + let (mut casts, mut parts) = (Vec::new(), Vec::new()); + classes.flatten(cast, file, &mut casts, 0); + classes.flatten(declared, file, &mut parts, 0); + parts.retain(|part| *part != Type::Nil); + casts.iter().all(|cast| { + let Some(class) = classes.class_of(cast, file) else { return true }; + parts.iter().any(|part| match classes.class_of(part, file) { + Some(declared) => classes.extends(&class, file, &declared), + None => !classes.rejects(part, file, cast), + }) + }) +} + +/// Whether `local` is declared with a table constructor, as `local item = {}` is. +fn declared_with_table(infer: &Infer, local: LocalId) -> bool { + let decl = infer.ctx.resolution.local(local).decl.start; + let Some(Decl::Local { stmt, index }) = infer.ctx.decl(decl) else { return false }; + let StmtKind::Local { exprs, .. } = &stmt.kind else { return false }; + exprs.get(*index).is_some_and(|value| matches!(value.unparen().kind, ExprKind::Table(_))) +} diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index f1db06b..c515618 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -237,6 +237,36 @@ impl<'a, 'b> Classes<'a, 'b> { } } + /// Whether the class `class`, as `from` sees it, is `ancestor` or extends it through its parents. + pub fn extends(&self, class: &str, from: FileId, ancestor: &str) -> bool { + self.extends_at(class, from, ancestor, &mut FxHashSet::default(), 0) + } + + fn extends_at( + &self, + class: &str, + from: FileId, + ancestor: &str, + visited: &mut FxHashSet, + depth: u32, + ) -> bool { + if class == ancestor { + return true; + } + if depth > MAX_DEPTH || !visited.insert(SmolStr::new(class)) { + return false; + } + for (file, def) in self.class_defs(class, from) { + for parent in &def.parents { + let Some(parent) = self.class_of(&Type::named(parent), file) else { continue }; + if self.extends_at(&parent, file, ancestor, visited, depth + 1) { + return true; + } + } + } + false + } + /// Whether `class` is strict as `from` sees it: a declaration says `(strict)` or `(exact)`, or /// none says `(loose)` and `by_default` holds for the file of each. pub fn is_strict(&self, class: &str, from: FileId, by_default: impl Fn(FileId) -> bool) -> bool { @@ -494,7 +524,7 @@ impl<'a, 'b> Classes<'a, 'b> { } /// The parts of a union, with the aliases it names resolved. - fn flatten(&self, ty: &Type, from: FileId, out: &mut Vec, depth: u32) { + pub(crate) fn flatten(&self, ty: &Type, from: FileId, out: &mut Vec, depth: u32) { if depth > MAX_DEPTH { out.push(Type::Unknown); return; diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index 2bb6e20..5d38801 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -119,6 +119,12 @@ impl<'a, 'b> Declared<'a, 'b> { self.infer.narrowed(id, offset, ty) } + /// The type a local is declared with, without what the guards and casts after its declaration + /// tell. + pub fn declaration(&self, id: LocalId) -> Type { + self.local_declaration(id, 0) + } + fn local_declaration(&self, id: LocalId, depth: u32) -> Type { let local = self.infer.ctx.resolution.local(id); if local.refs.iter().any(|r| r.write) && !(self.keeps_annotations && self.is_annotated(local)) { diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 3902657..9945e92 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -4,8 +4,8 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, MISSING_FIELDS, MISSING_PARAMETER, MISSING_RETURN, NO_UNKNOWN, - RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, MISSING_FIELDS, MISSING_PARAMETER, MISSING_RETURN, + NO_UNKNOWN, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; use super::assignments::mismatched_assignments; use super::callback_payloads::missing_payloads; +use super::casts::mismatched_casts; use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; use super::doc_names::undefined_doc_names; @@ -54,7 +55,7 @@ fn strict_by_default(ws: &Workspace, file: FileId) -> bool { /// linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&Workspace, &Document) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 9] = [ + let checks: [(&'static str, Check); 10] = [ (UNDEFINED_DOC_NAME, |ws, doc| { let side = ws.index.file(doc.file).and_then(|f| f.side); undefined_doc_names(&ws.index, &doc.text, &doc.chunk, side) @@ -70,6 +71,7 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< }), (RETURN_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, |infer| mismatched_returns(infer, &doc.chunk))), (MISSING_RETURN, |ws, doc| with_infer(ws, doc, |infer| missing_returns(infer, &doc.chunk))), + (CAST_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, mismatched_casts)), (MISSING_PARAMETER, |ws, doc| with_infer(ws, doc, |infer| missing_payloads(infer, &doc.chunk))), (NO_UNKNOWN, |ws, doc| with_infer(ws, doc, |infer| unknown_types(infer, &ws.lint_config.ignore_unused_prefix))), (IMPOSSIBLE_COMPARISON, |ws, doc| with_infer(ws, doc, |infer| impossible_comparisons(infer, &doc.chunk))), diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index d0c0016..06b7f63 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -1,6 +1,7 @@ pub mod assignments; pub mod assistant; pub mod callback_payloads; +pub mod casts; pub mod class_tables; pub mod code_action; pub mod comparisons; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index b4df83a..46d4dc0 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3452,6 +3452,119 @@ local cast = 5 --[[@as string]] assert!(hover.contains("local cast: string"), "{hover}"); } +#[test] +fn casts_to_types_the_declared_type_does_not_take_are_reported() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return string|integer +local function get() end + +---@type integer +local count = 1 +---@cast count string +---@cast count number +---@cast count integer? +---@param mode 'a'|'b' +local function use(mode) + ---@cast mode 'c' + ---@cast mode 'a' + ---@cast mode string +end +local value = get() +---@cast value boolean +---@cast value +boolean, -integer +local literal = 5 +---@cast literal string +local function untyped() return {} end +local decoded = untyped() +---@cast decoded string +local placeholder = nil +---@cast placeholder string? +---@cast missing string +---@diagnostic disable-next-line: cast-type-mismatch +---@cast count boolean +use(value, decoded) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("cast-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["cast-type-mismatch"]), + [ + finding("@cast count string", "Cannot convert `integer` to `string`"), + finding("@cast count integer?", "Cannot convert `integer` to `integer?`"), + finding("@cast mode 'c'", "Cannot convert `\"a\"|\"b\"` to `\"c\"`"), + finding("@cast value boolean", "Cannot convert `string|integer` to `boolean`"), + finding("@cast literal", "Cannot convert `integer` to `string`"), + ], + "types inferred from what a function returns, locals declared as `nil`, `+` and `-` entries, unknown names \ + and suppressed lines are left alone" + ); +} + +#[test] +fn casts_to_classes_the_declared_type_does_not_extend_are_reported() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Animal +---@class Test.Dog : Test.Animal +---@class Test.Puppy : Test.Dog +---@class Test.Car +---@class Test.List +---@alias Test.Pet Test.Dog|Test.Car + +---@return Test.Animal +local function getAnimal() end + +---@param animal Test.Animal? +---@param list Test.List +---@param any table +---@param named string|Test.Animal +local function use(animal, list, any, named) + ---@cast animal Test.Car + ---@cast animal Test.Puppy + ---@cast animal Test.Dog|Test.Car + ---@cast animal Test.Pet + ---@cast animal table + ---@cast list Test.List + ---@cast any Test.Car + ---@cast named Test.Car + ---@cast named Test.Dog +end + +---@param dog Test.Dog +local function back(dog) + ---@cast dog Test.Animal +end + +local returned = getAnimal() +---@cast returned Test.Car +---@type Test.Animal +local built = {} +---@cast built Test.Car +use(back, returned, built) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("cast-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["cast-type-mismatch"]), + [ + finding("@cast animal Test.Car", "Cannot convert `Test.Animal?` to `Test.Car`"), + finding("@cast animal Test.Dog|", "Cannot convert `Test.Animal?` to `Test.Dog|Test.Car`"), + finding("@cast animal Test.Pet", "Cannot convert `Test.Animal?` to `Test.Pet`"), + finding("@cast named Test.Car", "Cannot convert `string|Test.Animal` to `Test.Car`"), + finding("@cast dog", "Cannot convert `Test.Dog` to `Test.Animal`"), + finding("@cast returned", "Cannot convert `Test.Animal` to `Test.Car`"), + ], + "a class needs one the declared type names or extends, as in lua-language-server: a subclass passes, a \ + parent does not, type arguments are not compared, `table` takes any class, and so does a local declared \ + with a table constructor" + ); +} + #[test] fn a_global_declared_as_nil_takes_any_value() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index d5d4439..c028fef 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -446,6 +446,35 @@ it to `T` as in lua-language-server, which silences a check that the code knows `local count = GetValue() --[[@as integer]]` stores an `integer`. On a call, the cast types its first value. +## Casts + +A `---@cast` line changes the type of a local from its line on: `---@cast name T` makes it a `T`, +`+T` and `-T` add and take out a type, and `+?` and `-?` add and take out `nil`. qbx-lua-ls +describes where a cast holds with its +[type guards](../crates/qbx_lua_ls/README.md#casts). + +`cast-type-mismatch` reports a `---@cast name T` whose `T` the declared type of the local does not +take. The check is that of `assign-type-mismatch`, for each type `T` lists: a different kind of +value, or a literal the declared type does not list. + +```lua +---@type integer +local count = 1 +---@cast count string -- Cannot convert `integer` to `string` +---@cast count integer? -- Cannot convert `integer` to `integer?` +---@cast count number -- passes +``` + +The declared type is the `---@type` or `@param` of the local, or else that of the value it is +declared with, as for `impossible-comparison`: a literal, or what a function declares it returns. +A local whose type is only inferred, or that is assigned again and has no annotation, is not +checked, and neither is one declared as `nil`, which the cast gives its type as in +lua-language-server, nor the `+T` and `-T` entries, which change the type rather than replace it. +As in lua-language-server, a class has to be one that the declared type names or extends: a +`Test.Animal` can be cast to its subclass `Test.Dog`, but a `Test.Dog` not to `Test.Animal`, and +neither to an unrelated class. Type arguments are not compared, a declared type that is no class, +like `table` or `any`, takes any class, and so does a local declared with a table constructor. + ## Impossible comparisons `impossible-comparison` reports an `==` or `~=` whose two sides can never be equal, so that the diff --git a/docs/rules.md b/docs/rules.md index 0e2b593..85da661 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -35,6 +35,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. Reported by qbx-lua-ls only. | | `missing-return` | warning | A function with a required `@return` value can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | +| `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | | `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. Reported by qbx-lua-ls only. | | `const-reassign` | error | An assignment to a `` or `` local. | | `self-assignment` | warning | A variable is assigned to itself. | From 7d70ec5388e5b494dfa1b6dcea7d9a834aa55b18 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:46:55 +0200 Subject: [PATCH 013/193] feat(rules): add count-down-loop --- crates/qbx_lua_analysis/src/checks/flow.rs | 62 +++++++++++++- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + crates/qbx_lua_analysis/tests/fixtures.rs | 3 +- .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/client/main.lua | 4 + crates/qbx_lua_analysis/tests/rules.rs | 82 +++++++++++++++++++ crates/qbx_lua_ls/tests/lsp.rs | 23 ++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 10 files changed, 176 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index a5fc750..77dcdff 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -4,7 +4,7 @@ use qbx_lua_syntax::{NumberValue, Span}; use rustc_hash::FxHashMap; use super::{FileInput, Sink}; -use crate::diagnostic::Tag; +use crate::diagnostic::{Fix, Tag, TextEdit}; use crate::rules; pub(super) fn check(input: &FileInput, sink: &mut Sink) { @@ -95,6 +95,60 @@ impl Flow<'_, '_> { } } } + + /// `count-down-loop`: `for i = 10, 1` never runs, and `for i = #list, 1` never runs once its start + /// is above 1. Both were meant to count down. + fn count_down(&mut self, start: &Expr, limit: &Expr, step: Option<&Expr>) { + let Some(last) = number(limit) else { return }; + if step.is_some_and(|step| !number(step).is_some_and(|step| step > 0.0)) { + return; + } + let from_length = step.is_none() && last == 1.0 && starts_with_length(start); + if !from_length && !number(start).is_some_and(|first| first > last) { + return; + } + let source = self.input.source; + let text = |expr: &Expr| expr.span.text(source).split_whitespace().collect::>().join(" "); + let (first, last) = (text(start), text(limit)); + let (edit, negative) = match step { + Some(step) => { + let negative = format!("-{}", text(step)); + (TextEdit { span: step.span, new_text: negative.clone() }, negative) + } + None => { + (TextEdit { span: Span::new(limit.span.end, limit.span.end), new_text: ", -1".into() }, "-1".into()) + } + }; + let message = if from_length { + format!("the loop counts up from {first} to 1, so it never runs when {first} is greater than 1; did you mean `{first}, 1, -1`?") + } else { + format!( + "the loop never runs: it counts up from {first} to {last}; did you mean `{first}, {last}, {negative}`?" + ) + }; + let fix = Fix { title: format!("Count down with a step of {negative}"), edits: vec![edit] }; + let span = start.span.to(step.unwrap_or(limit).span); + self.sink.report_with(rules::COUNT_DOWN_LOOP, span, message, None, Some(fix)); + } +} + +/// The value of a numeric literal, negated or in parentheses. +fn number(expr: &Expr) -> Option { + match &expr.unparen().kind { + ExprKind::Number(NumberValue::Int(value)) => Some(*value as f64), + ExprKind::Number(NumberValue::Float(value)) => Some(*value), + ExprKind::Unary { op: UnOp::Neg, expr } => number(expr).map(|value| -value), + _ => None, + } +} + +/// `#list`, or arithmetic that starts with it, like `#list - 1`. +fn starts_with_length(expr: &Expr) -> bool { + match &expr.unparen().kind { + ExprKind::Unary { op: UnOp::Len, .. } => true, + ExprKind::Binary { op: BinOp::Add | BinOp::Sub, lhs, .. } => starts_with_length(lhs), + _ => false, + } } fn same_place(a: &Expr, b: &Expr) -> bool { @@ -133,9 +187,11 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { StmtKind::Do(body) => self.empty_block(body, stmt.span, "do"), StmtKind::While { body, .. } => self.empty_block(body, stmt.span, "while"), StmtKind::Repeat { body, .. } => self.empty_block(body, stmt.span, "repeat"), - StmtKind::NumericFor { body, .. } | StmtKind::GenericFor { body, .. } => { - self.empty_block(body, stmt.span, "for") + StmtKind::NumericFor { start, limit, step, body, .. } => { + self.empty_block(body, stmt.span, "for"); + self.count_down(start, limit, step.as_ref()); } + StmtKind::GenericFor { body, .. } => self.empty_block(body, stmt.span, "for"), StmtKind::If { branches, else_block } => { for (i, branch) in branches.iter().enumerate() { let end = branches diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index fc7d506..afb94ef 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -28,6 +28,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "empty-block", "unbalanced-assignments", "duplicate-index", + "count-down-loop", "missing-parameter", "undefined-doc-name", "missing-fields", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 4961a56..6b335fe 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -87,6 +87,7 @@ pub const NO_UNKNOWN: &str = "no-unknown"; pub const CONST_REASSIGN: &str = "const-reassign"; pub const SELF_ASSIGNMENT: &str = "self-assignment"; pub const SELF_COMPARISON: &str = "self-comparison"; +pub const COUNT_DOWN_LOOP: &str = "count-down-loop"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. pub const IMPOSSIBLE_COMPARISON: &str = "impossible-comparison"; pub const LOWERCASE_GLOBAL: &str = "lowercase-global"; @@ -150,6 +151,7 @@ pub static RULES: &[Rule] = &[ rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), rule(SELF_ASSIGNMENT, Suspicious, WARN, false, "A variable is assigned to itself."), rule(SELF_COMPARISON, Suspicious, WARN, false, "Both sides of a comparison are the same expression."), + rule(COUNT_DOWN_LOOP, Suspicious, WARN, true, "A numeric for loop counts up from a start above its end, or from #list to 1, without a negative step."), rule(IMPOSSIBLE_COMPARISON, Suspicious, INFO, false, "Both sides of an == or ~= have types that share no value, so the comparison always gives the same answer (language server only)."), rule(LOWERCASE_GLOBAL, Suspicious, WARN, false, "A global with a lowercase first letter is defined; this is usually a missing 'local'."), rule(IMPLICIT_GLOBAL, Suspicious, WARN, false, "A global is created from inside a function and never declared at file scope."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 2891488..32db6ee 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -147,11 +147,12 @@ fn fixes_are_applied_and_converge() { let client = reports.iter().find(|r| r.path.ends_with("client/main.lua")).unwrap(); let (fixed, applied) = apply_fixes(&client.source, &client.diagnostics); - assert_eq!(applied, 4); + assert_eq!(applied, 5); assert!(fixed.contains("\nCreateThread(function()\n while true do\n local ped = PlayerPedId()")); assert!(fixed.contains("local model = `adder`")); assert!(fixed.contains(" Wait(0)")); assert!(!fixed.contains("Citizen.")); + assert!(fixed.contains("for i = 3, 1, -1 do")); let manifest = reports.iter().find(|r| r.path.ends_with("fxmanifest.lua")).unwrap(); let (fixed, applied) = apply_fixes(&manifest.source, &manifest.diagnostics); diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 7a6588b..cb18193 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -26,6 +26,7 @@ client/main.lua:31:9 warning unreachable-code client/main.lua:33:10 error undefined-label client/main.lua:34:5 warning unreachable-code client/main.lua:37:1 warning missing-parameter +client/main.lua:39:9 warning count-down-loop fxmanifest.lua:8:1 warning manifest/unknown-directive fxmanifest.lua:14:15 warning manifest/missing-file server/main.lua:3:11 warning fivem/source-after-yield diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua index 723f649..d1dcfc3 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua @@ -35,3 +35,7 @@ RegisterNetEvent('bad:client', function(a, a) end) Notify('saved') + +for i = 3, 1 do + print(i) +end diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 94ac2de..96b05b4 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -676,3 +676,85 @@ fn values_the_linter_cannot_follow_are_not_checked() { "a forward-declared local takes the function assigned to it" ); } + +/// The findings of `code` in `source`, linted on its own, as `(line, message)`. +fn findings(source: &str, code: &str) -> Vec<(u32, String)> { + let chunk = parse(source); + let resolution = resolve(&chunk); + let summary = summarize(source, &chunk, &resolution); + let config = FileConfig::default(); + let input = FileInput { + source, + chunk: &chunk, + resolution: &resolution, + summary: &summary, + config: &config, + side: None, + resource: None, + crossrefs: None, + locale: None, + relative_path: "", + }; + let lines = qbx_lua_syntax::LineIndex::new(source); + check_file(&input) + .into_iter() + .filter(|d| d.code == code) + .map(|d| (lines.line_of(d.span.start) + 1, d.message)) + .collect() +} + +fn reported_lines(source: &str, code: &str) -> Vec { + findings(source, code).into_iter().map(|(line, _)| line).collect() +} + +#[test] +fn loops_that_count_up_past_their_end() { + let source = "local t, n, step = {}, 5, 2 +for i = 10, 1 do print(i) end +for i = 10, 1, 2 do print(i) end +for i = -1, -10 do print(i) end +for i = (3), 1.5 do print(i) end +for i = #t, 1 do print(i) end +for i = #t - 1, 1 do print(i) end +for i = 10, 1, -1 do print(i) end +for i = 1, 10 do print(i) end +for i = 1, 1 do print(i) end +for i = n, 1 do print(i) end +for i = #t, 0 do print(i) end +for i = #t, 1, -1 do print(i) end +for i = 10, 1, step do print(i) end +for i = 10, n do print(i) end"; + assert_eq!(reported_lines(source, "count-down-loop"), [2, 3, 4, 5, 6, 7]); + assert_eq!( + findings("for i = 10, 1 do print(i) end", "count-down-loop")[0].1, + "the loop never runs: it counts up from 10 to 1; did you mean `10, 1, -1`?" + ); + // `#t - 1, 1` still runs for two items or fewer, so the message names no number of items. + assert_eq!( + findings("local t = {}\nfor i = #t - 1, 1 do print(i) end", "count-down-loop")[0].1, + "the loop counts up from #t - 1 to 1, so it never runs when #t - 1 is greater than 1; did you mean `#t - 1, 1, -1`?" + ); + + let fixed = |source: &str| { + let chunk = parse(source); + let resolution = resolve(&chunk); + let summary = summarize(source, &chunk, &resolution); + let config = FileConfig::default(); + let input = FileInput { + source, + chunk: &chunk, + resolution: &resolution, + summary: &summary, + config: &config, + side: None, + resource: None, + crossrefs: None, + locale: None, + relative_path: "", + }; + qbx_lua_analysis::apply_fixes(source, &check_file(&input)).0 + }; + assert_eq!(fixed("for i = 10, 1 do print(i) end"), "for i = 10, 1, -1 do print(i) end"); + assert_eq!(fixed("for i = 10, 1, 2 do print(i) end"), "for i = 10, 1, -2 do print(i) end"); + assert_eq!(fixed("local t = {}\nfor i = #t, 1 do print(i) end"), "local t = {}\nfor i = #t, 1, -1 do print(i) end"); +} diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 46d4dc0..dce1d11 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5100,6 +5100,29 @@ fn code_actions_inlay_hints_tokens_and_folding() { assert_eq!(folds[0]["startLine"], 0); } +#[test] +fn count_down_loops_get_a_negative_step_as_a_quick_fix() { + let mut client = Client::start(fixture_root()); + let text = "local total = 0\nfor i = 3, 1 do total = total + i end\nprint(total)\n"; + client.open_with(CLIENT, text); + let found = client.diagnostics_for(CLIENT); + assert_eq!(found, [("count-down-loop".to_string(), 1)], "{found:?}"); + let uri = client.uri(CLIENT).to_string(); + let diagnostics = client.diagnostics[&uri].clone(); + let actions = client.request( + "textDocument/codeAction", + json!({ "textDocument": { "uri": uri }, "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 0 } }, "context": { "diagnostics": diagnostics } }), + ); + let fix = + actions.as_array().unwrap().iter().find(|a| a["title"] == "Count down with a step of -1").expect("quick fix"); + let edit = &fix["edit"]["changes"][&uri][0]; + assert_eq!( + (edit["range"]["start"]["line"].as_u64(), edit["range"]["start"]["character"].as_u64()), + (Some(1), Some(12)) + ); + assert_eq!(edit["newText"], ", -1"); +} + #[test] fn survives_garbage_input_while_typing() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index c028fef..6e03583 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | diff --git a/docs/rules.md b/docs/rules.md index 85da661..eccbef6 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -40,6 +40,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `const-reassign` | error | An assignment to a `` or `` local. | | `self-assignment` | warning | A variable is assigned to itself. | | `self-comparison` | warning | A comparison has the same expression on both sides. | +| `count-down-loop` | warning | A numeric `for` loop starts above its end without a negative step: a literal start above a literal end, with no step or a positive one, which never runs, or `#list, 1` or arithmetic on `#list` such as `#list - 1, 1`, with no step, which never runs once the start is above 1. Fix available. | | `impossible-comparison` | info | An `==` or `~=` compares values whose declared types share no value, such as a string with a number, or a literal with a type that does not list it, so it always gives the same answer. Comparisons with `nil` are not reported. Reported by qbx-lua-ls only. | | `lowercase-global` | warning | A global definition starts with a lowercase letter. | | `implicit-global` | warning | A function creates a global without a file-scope declaration. | From 34b7a6ccc999811e4710af29e4454003d7007b68 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:47:41 +0200 Subject: [PATCH 014/193] feat(rules): add duplicate-set-field --- crates/qbx_lua_analysis/src/checks/flow.rs | 208 +++++++++++++++++- crates/qbx_lua_analysis/src/env.rs | 15 ++ crates/qbx_lua_analysis/src/lua_ls_config.rs | 9 +- crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 8 + crates/qbx_lua_analysis/tests/rules.rs | 73 ++++++ crates/qbx_lua_ls/src/features/returns.rs | 2 +- crates/qbx_lua_ls/src/indexer.rs | 16 +- docs/reference.md | 2 +- docs/rules.md | 1 + 11 files changed, 314 insertions(+), 23 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index 77dcdff..5a07959 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -1,11 +1,14 @@ use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::{NumberValue, Span}; +use qbx_lua_syntax::{LineIndex, NumberValue, SmolStr, Span}; use rustc_hash::FxHashMap; use super::{FileInput, Sink}; use crate::diagnostic::{Fix, Tag, TextEdit}; +use crate::directives::Suppressions; +use crate::env::is_meta_file; use crate::rules; +use crate::scope::{LocalId, Resolved}; pub(super) fn check(input: &FileInput, sink: &mut Sink) { for label in input.resolution.labels.iter().filter(|l| !l.used) { @@ -20,12 +23,50 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { for name in &input.resolution.undefined_gotos { sink.report(rules::UNDEFINED_LABEL, name.span, format!("no visible label '{}' for goto", name.text)); } - Flow { input, sink }.visit_block(&input.chunk.block); + // Definition files only declare signatures, which may repeat a field. + let set_fields = sink.enabled(rules::DUPLICATE_SET_FIELD).is_some() && !is_meta_file(input.source, input.chunk); + let mut flow = Flow { + input, + sink, + block: 0, + function: 0, + blocks: 0, + set_fields: set_fields.then(FxHashMap::default), + lines: None, + suppressions: None, + }; + flow.visit_block(&input.chunk.block); } struct Flow<'a, 'b> { input: &'a FileInput<'a>, sink: &'a mut Sink<'b>, + /// The block `duplicate-set-field` compares definitions in: the main chunk, a function body or + /// one branch of an `if`, as LuaLS scopes the rule, and the code after an `if` that can return, + /// which runs only when it does not. Loops and `do` blocks share the block around them. + block: u32, + /// The block of the function body the code is in. + function: u32, + blocks: u32, + /// The functions set on table fields, by the variable and the keys they are set through. `None` + /// when the rule is off. + set_fields: Option>>>, + lines: Option, + suppressions: Option, +} + +/// The variable a table field is set through. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum Root { + Local(LocalId), + Global(SmolStr), +} + +/// The last function set on a table field in one block. +struct SetField { + block: u32, + function: u32, + span: Span, } impl Flow<'_, '_> { @@ -96,6 +137,104 @@ impl Flow<'_, '_> { } } + /// Visits code that is a block of its own for `duplicate-set-field`. + fn in_new_block(&mut self, visit: impl FnOnce(&mut Self)) { + let outer = self.block; + self.blocks += 1; + self.block = self.blocks; + visit(self); + self.block = outer; + } + + fn root(&self, name: &Name) -> Option { + match self.input.resolution.resolve_at(name.span.start)? { + Resolved::Local(id) => Some(Root::Local(id)), + Resolved::Global(_) => Some(Root::Global(name.text.clone())), + } + } + + /// The variable an `a.b.c` or `a['b']` target starts from, and the keys after it. + fn place(&self, expr: &Expr) -> Option<(Root, String)> { + let (base, key) = match &expr.kind { + ExprKind::Name(name) => return Some((self.root(name)?, String::new())), + ExprKind::Paren(inner) => return self.place(inner), + ExprKind::Field { base, name, .. } => (base, format!("s:{}", name.text)), + ExprKind::Index { base, index, .. } => match &index.kind { + ExprKind::String(key) => (base, format!("s:{key}")), + ExprKind::Number(NumberValue::Int(key)) => (base, format!("n:{key}")), + _ => return None, + }, + _ => return None, + }; + let (root, path) = self.place(base)?; + Some((root, format!("{path}.{key}"))) + } + + /// `duplicate-set-field`: `function M.f()` or `M.f = function` where the same block already set + /// a function on that field. Side guards need no check of their own: each side's definition is + /// in a branch of its `if`, or after an `if` that returns on the other side. + fn set_function(&mut self, root: Option, path: String, span: Span) { + let (Some(root), Some(set_fields)) = (root, self.set_fields.as_mut()) else { return }; + let (block, function) = (self.block, self.function); + let definitions = set_fields.entry(root).or_default().entry(path).or_default(); + let Some(earlier) = definitions.iter_mut().find(|definition| definition.block == block) else { + definitions.push(SetField { block, function, span }); + return; + }; + let earlier = std::mem::replace(&mut earlier.span, span); + if self.suppressed_at(earlier.start) { + return; + } + let line = self.line_index().line_of(earlier.start) + 1; + let shown = span.text(self.input.source); + self.sink.report( + rules::DUPLICATE_SET_FIELD, + span, + format!("'{shown}' is already defined on line {line}; this definition replaces it"), + ); + } + + fn has_set_fields(&self) -> bool { + self.set_fields.as_ref().is_some_and(|set_fields| !set_fields.is_empty()) + } + + /// A read of a field, as `local old = M.f` is before wrapping it, so a later definition does not + /// simply replace the one that was read. Reads in other functions run at another time. + fn read_field(&mut self, place: Option<(Root, String)>) { + let Some((root, path)) = place else { return }; + let function = self.function; + let set_fields = self.set_fields.as_mut().and_then(|set_fields| set_fields.get_mut(&root)); + if let Some(definitions) = set_fields.and_then(|fields| fields.get_mut(&path)) { + definitions.retain(|definition| definition.function != function); + } + } + + /// An assignment to a table, as `p = lib.points.new(b)` is, which leaves the functions set on the + /// fields of the table it replaces behind. + fn assign_table(&mut self, target: &Expr) { + let Some((root, path)) = self.place(target) else { return }; + let function = self.function; + let Some(fields) = self.set_fields.as_mut().and_then(|set_fields| set_fields.get_mut(&root)) else { return }; + for (field, definitions) in fields.iter_mut() { + if field.strip_prefix(path.as_str()).is_some_and(|rest| rest.starts_with('.')) { + definitions.retain(|definition| definition.function != function); + } + } + } + + fn line_index(&mut self) -> &LineIndex { + let source = self.input.source; + self.lines.get_or_insert_with(|| LineIndex::new(source)) + } + + /// LuaLS leaves a repeated field alone when the rule is disabled at either definition. + fn suppressed_at(&mut self, offset: u32) -> bool { + let (source, comments) = (self.input.source, &self.input.chunk.comments); + let lines = self.lines.get_or_insert_with(|| LineIndex::new(source)); + let suppressions = self.suppressions.get_or_insert_with(|| Suppressions::parse(source, comments, lines)); + suppressions.is_suppressed(rules::DUPLICATE_SET_FIELD, lines.line_of(offset)) + } + /// `count-down-loop`: `for i = 10, 1` never runs, and `for i = #list, 1` never runs once its start /// is above 1. Both were meant to count down. fn count_down(&mut self, start: &Expr, limit: &Expr, step: Option<&Expr>) { @@ -158,10 +297,25 @@ fn same_place(a: &Expr, b: &Expr) -> bool { } } +/// An `if` with a branch that ends in `return`. +fn can_return(stmt: &Stmt) -> bool { + let StmtKind::If { branches, else_block } = &stmt.kind else { return false }; + let returns = |block: &Block| matches!(block.stmts.last(), Some(Stmt { kind: StmtKind::Return(_), .. })); + branches.iter().map(|branch| &branch.block).chain(else_block).any(returns) +} + impl<'ast> Visitor<'ast> for Flow<'_, '_> { fn visit_block(&mut self, block: &'ast Block) { self.unreachable(block); - visit::walk_block(self, block); + let outer = self.block; + for stmt in &block.stmts { + self.visit_stmt(stmt); + if can_return(stmt) { + self.blocks += 1; + self.block = self.blocks; + } + } + self.block = outer; } fn visit_func_body(&mut self, func: &'ast FuncBody) { @@ -170,7 +324,12 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { self.sink.report(rules::DUPLICATE_ARGUMENT, param.span, format!("duplicate argument '{}'", param.text)); } } - visit::walk_func_body(self, func); + let outer = self.function; + self.in_new_block(|flow| { + flow.function = flow.block; + visit::walk_func_body(flow, func); + }); + self.function = outer; } fn visit_stmt(&mut self, stmt: &'ast Stmt) { @@ -183,6 +342,32 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { self.sink.report(rules::SELF_ASSIGNMENT, stmt.span, "value is assigned to itself"); } } + exprs.iter().for_each(|value| self.visit_expr(value)); + for (i, target) in targets.iter().enumerate() { + // What the target is reached through is read; the target itself is written. + match &target.kind { + ExprKind::Name(_) => {} + ExprKind::Field { base, .. } => self.visit_expr(base), + ExprKind::Index { base, index, .. } => { + self.visit_expr(base); + self.visit_expr(index); + } + _ => self.visit_expr(target), + } + if self.has_set_fields() { + self.assign_table(target); + } + if exprs.get(i).is_some_and(|value| matches!(value.kind, ExprKind::Function(_))) { + if let Some((root, path)) = self.place(target).filter(|(_, path)| !path.is_empty()) { + self.set_function(Some(root), path, target.span); + } + } + } + return; + } + StmtKind::Function { name, .. } if !name.path.is_empty() || name.method.is_some() => { + let path = name.path.iter().chain(&name.method).map(|key| format!(".s:{}", key.text)).collect(); + self.set_function(self.root(&name.base), path, name.span); } StmtKind::Do(body) => self.empty_block(body, stmt.span, "do"), StmtKind::While { body, .. } => self.empty_block(body, stmt.span, "while"), @@ -207,6 +392,14 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { let start = branches.last().map_or(stmt.span.start, |b| b.block.span.end.max(b.cond.span.end)); self.empty_block(block, Span::new(start, stmt.span.end), "else"); } + for branch in branches { + self.visit_expr(&branch.cond); + self.in_new_block(|flow| flow.visit_block(&branch.block)); + } + if let Some(block) = else_block { + self.in_new_block(|flow| flow.visit_block(block)); + } + return; } _ => {} } @@ -225,6 +418,13 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { "both sides of the comparison are the same expression", ); } + ExprKind::Field { .. } | ExprKind::Index { .. } if self.has_set_fields() => { + self.read_field(self.place(expr)) + } + ExprKind::MethodCall { base, method, .. } if self.has_set_fields() => { + let place = self.place(base).map(|(root, path)| (root, format!("{path}.s:{}", method.text))); + self.read_field(place); + } _ => {} } visit::walk_expr(self, expr); diff --git a/crates/qbx_lua_analysis/src/env.rs b/crates/qbx_lua_analysis/src/env.rs index 09cc12f..799d24c 100644 --- a/crates/qbx_lua_analysis/src/env.rs +++ b/crates/qbx_lua_analysis/src/env.rs @@ -128,6 +128,21 @@ pub fn leading_doc_lines<'a>(source: &'a str, comments: &[Comment], offset: u32) lines } +/// Whether a `---@meta` line above the first statement marks the file as a definition file. +pub fn is_meta_file(source: &str, chunk: &Chunk) -> bool { + let first_stmt = chunk.block.stmts.first().map_or(u32::MAX, |stmt| stmt.span.start); + chunk + .comments + .iter() + .take_while(|comment| comment.span.start < first_stmt) + .any(|comment| is_meta_comment(comment.span.text(source))) +} + +fn is_meta_comment(text: &str) -> bool { + let doc = text.strip_prefix("---").unwrap_or_default(); + doc.trim_start().strip_prefix("@meta").is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index afb94ef..aa5e761 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -28,6 +28,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "empty-block", "unbalanced-assignments", "duplicate-index", + "duplicate-set-field", "count-down-loop", "missing-parameter", "undefined-doc-name", @@ -264,11 +265,15 @@ mod tests { #[test] fn maps_only_codes_that_mean_the_same() { let settings = parse_luals( - r#"{ "diagnostics.disable": ["undefined-field", "deprecated", "syntax-error", "undefined-global", "missing-parameter"] }"#, + r#"{ "diagnostics.disable": ["undefined-field", "deprecated", "syntax-error", "undefined-global", "missing-parameter", "duplicate-set-field"] }"#, ); assert_eq!( settings.rules, - [("undefined-global".to_string(), Level::Off), ("missing-parameter".to_string(), Level::Off)] + [ + ("undefined-global".to_string(), Level::Off), + ("missing-parameter".to_string(), Level::Off), + ("duplicate-set-field".to_string(), Level::Off) + ] ); let targets = EQUIVALENT_CODES.iter().chain(ALIASES.iter().flat_map(|(_, codes)| codes.iter())); for code in targets { diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 6b335fe..877999b 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -66,6 +66,7 @@ pub const UNREACHABLE_CODE: &str = "unreachable-code"; pub const EMPTY_BLOCK: &str = "empty-block"; pub const UNBALANCED_ASSIGNMENTS: &str = "unbalanced-assignments"; pub const DUPLICATE_INDEX: &str = "duplicate-index"; +pub const DUPLICATE_SET_FIELD: &str = "duplicate-set-field"; pub const DUPLICATE_ARGUMENT: &str = "duplicate-argument"; pub const MISSING_PARAMETER: &str = "missing-parameter"; /// Reported by qbx-lua-ls, which indexes the LuaCATS types; the linter itself has no type information. @@ -138,6 +139,7 @@ pub static RULES: &[Rule] = &[ rule(EMPTY_BLOCK, Style, INFO, false, "A block has no statements."), rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment has more values than targets, or leaves targets without a value."), rule(DUPLICATE_INDEX, Suspicious, WARN, false, "A table constructor sets the same key twice."), + rule(DUPLICATE_SET_FIELD, Suspicious, WARN, false, "A function is assigned to the same table field twice in one block of a file, so the second replaces the first."), rule(DUPLICATE_ARGUMENT, Correctness, ERROR, false, "Two parameters of one function share a name."), rule(MISSING_PARAMETER, Correctness, WARN, false, "A function is called without an argument for a parameter its LuaCATS annotations require."), rule(UNDEFINED_DOC_NAME, Correctness, WARN, false, "A LuaCATS annotation names a type that no @class, @alias or @enum declares for the file's side (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index cb18193..6688f43 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -39,3 +39,4 @@ server/main.lua:24:16 info manifest/missing-dependency server/main.lua:24:16 hint qbox/legacy-core-object shared/config.lua:3:1 warning self-assignment shared/config.lua:4:1 warning lowercase-global +shared/config.lua:16:10 warning duplicate-set-field diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index a5ed401..25e244e 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -8,3 +8,11 @@ helper = 1 function Notify(message, duration) print(message, duration) end + +function Config.Notify(message) + print(message) +end + +function Config.Notify(message) + print('replaced', message) +end diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 96b05b4..52f15fc 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -758,3 +758,76 @@ for i = 10, n do print(i) end"; assert_eq!(fixed("for i = 10, 1, 2 do print(i) end"), "for i = 10, 1, -2 do print(i) end"); assert_eq!(fixed("local t = {}\nfor i = #t, 1 do print(i) end"), "local t = {}\nfor i = #t, 1, -1 do print(i) end"); } + +#[test] +fn functions_set_twice_on_one_field() { + let source = "local M = {} +function M.f() end +function M.f() end +M.g = function() end +function M:g() end +M['h'] = function() end +M.h = function() end +Shared = {} +function Shared.a.b() end +function Shared.a.b() end +do + function M.inDo() end +end +function M.inDo() end +local function setup() + M.inner = function() end + M.inner = function() end +end +return setup"; + assert_eq!(reported_lines(source, "duplicate-set-field"), [3, 5, 7, 10, 14, 17]); + assert_eq!( + findings(source, "duplicate-set-field")[0].1, + "'M.f' is already defined on line 2; this definition replaces it" + ); + + for quiet in [ + // Branches of an `if`, and an `if` beside the code around it, as LuaLS reads them. + "local M = {}\nif Config.Fast then\n function M.f() end\nelse\n function M.f() end\nend\nfunction M.g() end\nif Config.Fast then\n function M.g() end\nend\nreturn M", + // Each side by branch, and a shared default overridden after a guard. + "Bridge = {}\nif IsDuplicityVersion() then\n function Bridge.notify() end\nelse\n function Bridge.notify() end\nend", + "Bridge = {}\nif lib.context == 'server' then\n function Bridge.notify() end\nelse\n function Bridge.notify() end\nend", + "Bridge = {}\nfunction Bridge.notify() end\nif not IsDuplicityVersion() then return end\nfunction Bridge.notify() end", + // Code after an `if` that can return, like the code in a branch of an `if`. + "F = {}\nfunction F.Get() end\nif GetResourceState('qbx_core') ~= 'started' then return end\nfunction F.Get() end", + // A field read between the definitions, as a wrapper does, or called through. + "local M = {}\nfunction M.n() end\nlocal old = M.n\nfunction M.n(m) old(m) end\nreturn M", + "local M = {}\nfunction M.n() end\nM.n()\nfunction M.n() end\nreturn M", + "local p = {}\nfunction p:enter() end\np:enter()\nfunction p:enter() end", + // A new table in the variable, or in a field above the one set. + "local p = lib.points.new(a)\nfunction p:onEnter() end\np = lib.points.new(b)\nfunction p:onEnter() end", + "local M = { sub = {} }\nfunction M.sub.f() end\nM.sub = {}\nfunction M.sub.f() end\nreturn M", + // Other tables, other functions, and values that are not function literals. + "local A, B = {}, {}\nfunction A.f() end\nfunction B.f() end\nreturn A, B", + "local function make()\n local C = {}\n function C.f() end\n return C\nend\nlocal function other()\n local C = {}\n function C.f() end\n return C\nend\nreturn make, other", + "local M = {}\nM.x = function() end\nM.x = nil\nM.y = print\nM.y = print\nlocal f = function() end\nf = function() end\nreturn M, f", + // Definition files declare signatures. + "---@meta\nM = {}\nfunction M.f(a) end\nfunction M.f(a, b) end", + // LuaLS's directive at either definition. + "local M = {}\n---@diagnostic disable-next-line: duplicate-set-field\nfunction M.f() end\nfunction M.f() end\nreturn M", + "local M = {}\nfunction M.f() end\n---@diagnostic disable-next-line: duplicate-set-field\nfunction M.f() end\nreturn M", + ] { + assert_eq!(reported_lines(quiet, "duplicate-set-field"), Vec::::new(), "{quiet}"); + } + + // Reads and new tables in other functions run later, when the second definition has replaced + // the first, and a new table in another field changes nothing. + let source = "local M = {} +function M.n() return M.n() end +AddEventHandler('x', function() M.n() end) +function M.n() end +local p = {} +function p:a() end +local function reset() p = {} end +function p:a() end +function M.f() end +M.fx = {} +function M.f() end +return reset"; + assert_eq!(reported_lines(source, "duplicate-set-field"), [4, 8, 11]); +} diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index cb32d7a..1f6f8b1 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -9,12 +9,12 @@ //! A function that lists sets of values, as `@return false | (string, string)` does, has to return //! one of them: each `return` is compared with the set it comes closest to. +use qbx_lua_analysis::env::is_meta_file; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; use super::class_tables::Classes; -use crate::indexer::is_meta_file; use crate::infer::{always_exits, return_stmts, Infer}; use crate::types::Type; diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 91d8e5c..fefd147 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use lsp_types::{Position, Range}; use qbx_fivem_data::Side; +use qbx_lua_analysis::env::is_meta_file; use qbx_lua_analysis::scope::{Resolution, Resolved}; use qbx_lua_analysis::side_guard::SideRegions; use qbx_lua_analysis::summary::summarize; @@ -110,21 +111,6 @@ pub fn index_file( out } -/// Whether a `---@meta` line above the first statement marks the file as a definition file. -pub fn is_meta_file(source: &str, chunk: &Chunk) -> bool { - let first_stmt = chunk.block.stmts.first().map_or(u32::MAX, |stmt| stmt.span.start); - chunk - .comments - .iter() - .take_while(|comment| comment.span.start < first_stmt) - .any(|comment| is_meta_comment(comment.span.text(source))) -} - -fn is_meta_comment(text: &str) -> bool { - let doc = text.strip_prefix("---").unwrap_or_default(); - doc.trim_start().strip_prefix("@meta").is_some_and(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) -} - struct Indexer<'a> { file: FileId, source: &'a str, diff --git a/docs/reference.md b/docs/reference.md index 6e03583..c9edd24 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | diff --git a/docs/rules.md b/docs/rules.md index eccbef6..303bb5d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -27,6 +27,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `empty-block` | info | A block has no statements. | | `unbalanced-assignments` | warning | An assignment has mismatched target and value counts. | | `duplicate-index` | warning | A table constructor assigns the same key twice. | +| `duplicate-set-field` | warning | A function is assigned to the same field of the same table twice in one block of a file, so the second replaces the first. A block is the main chunk, a function body, one branch of an `if`, or the code after an `if` with a branch that returns, such as an `IsDuplicityVersion()` or `lib.context` guard. A read of the field between the two, as a wrapper has, or an assignment to the table's variable or to a field it is in, also separates them. Definitions in other files or in `---@meta` files are not compared. | | `duplicate-argument` | error | Function parameters share a name. | | `missing-parameter` | warning | A call leaves out an argument that the function's LuaCATS annotations require. qbx-lua-ls also reports `---@callback` wrapper calls whose payload leaves out a value the registered handler requires. | | `undefined-doc-name` | warning | A LuaCATS annotation names a type that no `@class`, `@alias` or `@enum` declares, or that only `(server)` or `(client)` declarations of the other side declare. Reported by qbx-lua-ls only. | From 5d64c34ab255467161ffcca3a2a1fc39fe838cb1 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:48:28 +0200 Subject: [PATCH 015/193] feat(rules): add undefined-doc-param --- crates/qbx_lua_analysis/src/checks/docs.rs | 291 ++++++++++++++++++ crates/qbx_lua_analysis/src/checks/mod.rs | 2 + crates/qbx_lua_analysis/src/env.rs | 24 +- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 3 +- .../fixtures/bad_resource/shared/config.lua | 1 + crates/qbx_lua_analysis/tests/rules.rs | 82 +++++ crates/qbx_lua_ls/src/features/doc_names.rs | 25 +- crates/qbx_luacats/src/luacats.rs | 48 +++ docs/reference.md | 2 +- docs/rules.md | 1 + 12 files changed, 456 insertions(+), 26 deletions(-) create mode 100644 crates/qbx_lua_analysis/src/checks/docs.rs diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs new file mode 100644 index 0000000..0b0c2e2 --- /dev/null +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -0,0 +1,291 @@ +//! Doc comment checks that need no other file: `@param` names that no parameter of the documented +//! function has. + +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::{Comment, LineIndex, Span}; +use qbx_luacats::luacats::{declared_name, parse_doc_lines}; +use qbx_luacats::types::{FunType, Type}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::{FileInput, Sink}; +use crate::env::{doc_blocks, is_meta_file}; +use crate::rules; +use crate::scope::{LocalId, Resolution, Resolved}; + +pub(super) fn check(input: &FileInput, sink: &mut Sink) { + let source = input.source; + let wanted = |code, tags: &[&str]| sink.enabled(code).is_some() && tags.iter().any(|tag| source.contains(tag)); + // Definition files document stubs, which need not spell out the parameters they describe. + let params = wanted(rules::UNDEFINED_DOC_PARAM, &["@param"]) && !is_meta_file(source, input.chunk); + if !params { + return; + } + let blocks: Vec = doc_blocks(source, &input.chunk.comments) + .into_iter() + .filter(|comments| comments.iter().any(|comment| comment.span.text(source).contains('@'))) + .map(|comments| DocBlock::new(source, comments)) + .collect(); + let lines = LineIndex::new(source); + undefined_params(input, &blocks, &lines, sink); +} + +/// One doc comment, and its `---` lines without the prefix. +struct DocBlock<'a> { + comments: Vec<&'a Comment>, + lines: Vec<&'a str>, +} + +impl<'a> DocBlock<'a> { + fn new(source: &'a str, comments: Vec<&'a Comment>) -> Self { + let lines: Vec<&str> = comments.iter().map(|comment| &comment.span.text(source)[3..]).collect(); + Self { comments, lines } + } + + fn tag(&self, index: usize) -> Option<&'a str> { + self.lines[index].trim_start().strip_prefix('@')?.split_whitespace().next() + } + + /// The name line `index` declares, and where it is. + fn declared(&self, index: usize) -> Option<(Span, &'a str)> { + let (offset, name) = declared_name(self.lines.get(index)?)?; + let start = self.comments[index].span.start + 3 + offset as u32; + Some((Span::new(start, start + name.len() as u32), name)) + } +} + +/// `undefined-doc-param`. A doc comment documents the functions of the statement below it, as +/// qbx-lua-ls binds it, with the functions it passes by name, and every function whose parameters +/// start on the line below it, as LuaLS does, which covers table fields and nested calls. Comment +/// lines in between do not separate them; a blank line does. A `@type fun(...)` in the comment +/// documents its parameter names too. +fn undefined_params(input: &FileInput, blocks: &[DocBlock], lines: &LineIndex, sink: &mut Sink) { + let mut code: Option = None; + for block in blocks { + // A doc comment after code on its line describes that line; only the lines below it document + // the code that follows. + let first = usize::from(!starts_line(input.source, block.comments[0].span.start)); + let params: Vec<(Span, &str)> = (first..block.lines.len()) + .filter(|&i| block.tag(i) == Some("param")) + .filter_map(|i| block.declared(i)) + .collect(); + if params.is_empty() { + continue; + } + let typed = (first..block.lines.len()) + .find(|&i| block.tag(i) == Some("type")) + .and_then(|i| parse_doc_lines(&block.lines[i..=i]).ty); + let code = code.get_or_insert_with(|| Code::of(input.chunk, input.resolution, lines)); + let last = block.comments[block.comments.len() - 1]; + let mut documented = documented_offset(input.source, &input.chunk.comments, last) + .map(|offset| code.documented(offset, lines)) + .unwrap_or_default(); + if let Some(fun) = typed.as_ref().and_then(Type::as_fun) { + documented.add_type(fun); + } + for (span, name) in params { + if documented.has(name) { + continue; + } + let message = if documented.functions == 0 { + format!("no function follows the @param '{name}' annotation") + } else { + format!("the function below has no parameter '{name}'") + }; + sink.report(rules::UNDEFINED_DOC_PARAM, span, message); + } + } +} + +fn starts_line(source: &str, offset: u32) -> bool { + source[..offset as usize].rsplit('\n').next().unwrap_or_default().trim().is_empty() +} + +/// Where the code a doc comment documents starts: the first token after it, past any other comment +/// lines. `None` when a blank line or the end of the file comes first. +fn documented_offset(source: &str, comments: &[Comment], last: &Comment) -> Option { + let mut cursor = last.span.end; + let mut next = comments.partition_point(|comment| comment.span.start < cursor); + loop { + let rest = &source[cursor as usize..]; + let code = rest.trim_start(); + let gap = &rest[..rest.len() - code.len()]; + if code.is_empty() || gap.bytes().filter(|b| *b == b'\n').count() > 1 { + return None; + } + let offset = cursor + gap.len() as u32; + match comments.get(next) { + Some(comment) if comment.span.start == offset => { + cursor = comment.span.end; + next += 1; + } + _ => return Some(offset), + } + } +} + +/// The statements of a file by where they start, its functions by the line their parameters start +/// on, and the functions its variables are given where they are declared or assigned. +struct Code<'a> { + resolution: &'a Resolution, + statements: FxHashMap, + functions: FxHashMap>, + named: FxHashMap, Vec<&'a FuncBody>>, +} + +/// A local or global variable. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Variable<'a> { + Local(LocalId), + Global(&'a str), +} + +impl<'a> Code<'a> { + fn of(chunk: &'a Chunk, resolution: &'a Resolution, lines: &LineIndex) -> Self { + let code = Code { + resolution, + statements: FxHashMap::default(), + functions: FxHashMap::default(), + named: FxHashMap::default(), + }; + let mut collector = CodeCollector { code, lines }; + collector.visit_block(&chunk.block); + collector.code + } + + fn variable(&self, name: &'a Name) -> Option> { + match self.resolution.resolve_at(name.span.start)? { + Resolved::Local(id) => Some(Variable::Local(id)), + Resolved::Global(_) => Some(Variable::Global(&name.text)), + } + } + + /// The parameters of the code at `offset`. + fn documented(&self, offset: u32, lines: &LineIndex) -> Documented<'a> { + let mut documented = Documented::default(); + if let Some(stmt) = self.statements.get(&offset) { + match &stmt.kind { + StmtKind::Function { name, .. } if name.method.is_some() => { + documented.names.insert("self"); + } + // LuaLS lets `@param` type the variables of a `for ... in` loop. + StmtKind::GenericFor { names, .. } => documented.names.extend(names.iter().map(|n| n.text.as_str())), + _ => {} + } + StatementFunctions { documented: &mut documented, code: self }.visit_stmt(stmt); + } + for func in self.functions.get(&lines.line_of(offset)).into_iter().flatten() { + documented.add(func); + } + documented + } +} + +struct CodeCollector<'a, 'l> { + code: Code<'a>, + lines: &'l LineIndex, +} + +impl<'a> CodeCollector<'a, '_> { + fn name(&mut self, name: &'a Name, func: &'a FuncBody) { + if let Some(variable) = self.code.variable(name) { + self.code.named.entry(variable).or_default().push(func); + } + } +} + +impl<'ast> Visitor<'ast> for CodeCollector<'ast, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + self.code.statements.insert(stmt.span.start, stmt); + match &stmt.kind { + StmtKind::LocalFunction { name, func } => self.name(name, func), + StmtKind::Function { name, func } if name.path.is_empty() && name.method.is_none() => { + self.name(&name.base, func) + } + StmtKind::Local { names, exprs, .. } => { + for (name, expr) in names.iter().zip(exprs) { + if let ExprKind::Function(func) = &expr.kind { + self.name(&name.name, func); + } + } + } + StmtKind::Assign { targets, exprs } => { + for (target, expr) in targets.iter().zip(exprs) { + if let (ExprKind::Name(name), ExprKind::Function(func)) = (&target.kind, &expr.kind) { + self.name(name, func); + } + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_func_body(&mut self, func: &'ast FuncBody) { + self.code.functions.entry(self.lines.line_of(func.params_span.start)).or_default().push(func); + visit::walk_func_body(self, func); + } +} + +/// The functions a statement defines or passes, also by the name of a variable it gave them, +/// outside the blocks it runs and the bodies of those functions. +struct StatementFunctions<'a, 'd> { + documented: &'d mut Documented<'a>, + code: &'d Code<'a>, +} + +impl<'ast> Visitor<'ast> for StatementFunctions<'ast, '_> { + fn visit_block(&mut self, _: &'ast Block) {} + + fn visit_func_body(&mut self, func: &'ast FuncBody) { + self.documented.add(func); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + if let ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. } = &expr.kind { + for arg in args { + let ExprKind::Name(name) = &arg.kind else { continue }; + let named = self.code.variable(name).and_then(|variable| self.code.named.get(&variable)); + for func in named.into_iter().flatten() { + self.documented.add(func); + } + } + } + visit::walk_expr(self, expr); + } +} + +#[derive(Default)] +struct Documented<'a> { + names: FxHashSet<&'a str>, + vararg: bool, + functions: usize, +} + +impl<'a> Documented<'a> { + fn add(&mut self, func: &'a FuncBody) { + self.functions += 1; + self.names.extend(func.params.iter().map(|param| param.text.as_str())); + self.vararg |= func.vararg.is_some(); + } + + /// A `@type fun(...)` beside the `@param` lines, which types what the statement below assigns. + fn add_type(&mut self, fun: &'a FunType) { + self.functions += 1; + for param in &fun.params { + match param.name.as_str() { + "..." => self.vararg = true, + name => { + self.names.insert(name); + } + } + } + } + + fn has(&self, name: &str) -> bool { + match name { + "..." => self.vararg, + _ => self.names.contains(name), + } + } +} diff --git a/crates/qbx_lua_analysis/src/checks/mod.rs b/crates/qbx_lua_analysis/src/checks/mod.rs index 814c33f..155f9e5 100644 --- a/crates/qbx_lua_analysis/src/checks/mod.rs +++ b/crates/qbx_lua_analysis/src/checks/mod.rs @@ -1,5 +1,6 @@ mod arity; mod crossfile; +mod docs; mod fivem; mod flow; mod globals; @@ -106,6 +107,7 @@ pub fn check_file(input: &FileInput) -> Vec { } locals::check(input, &mut sink); flow::check(input, &mut sink); + docs::check(input, &mut sink); globals::check(input, &mut sink); arity::check(input, &mut sink); fivem::check(input, &mut sink); diff --git a/crates/qbx_lua_analysis/src/env.rs b/crates/qbx_lua_analysis/src/env.rs index 799d24c..20e9a9c 100644 --- a/crates/qbx_lua_analysis/src/env.rs +++ b/crates/qbx_lua_analysis/src/env.rs @@ -2,7 +2,7 @@ use std::sync::OnceLock; use qbx_fivem_data::{Side, STUBS}; use qbx_lua_syntax::ast::*; -use qbx_lua_syntax::{parse, Comment, SmolStr}; +use qbx_lua_syntax::{parse, Comment, CommentKind, SmolStr}; use rustc_hash::{FxHashMap, FxHashSet}; #[derive(Debug)] @@ -128,6 +128,28 @@ pub fn leading_doc_lines<'a>(source: &'a str, comments: &[Comment], offset: u32) lines } +/// Runs of adjacent `---` line comments, each one annotation block. +pub fn doc_blocks<'c>(source: &str, comments: &'c [Comment]) -> Vec> { + let mut blocks: Vec> = Vec::new(); + let mut previous_end: Option = None; + for comment in comments { + if comment.kind != CommentKind::Line || !comment.span.text(source).starts_with("---") { + previous_end = None; + continue; + } + let adjacent = previous_end.is_some_and(|end| { + let gap = &source[end as usize..comment.span.start as usize]; + gap.bytes().filter(|b| *b == b'\n').count() <= 1 && gap.trim().is_empty() + }); + match blocks.last_mut() { + Some(block) if adjacent => block.push(comment), + _ => blocks.push(vec![comment]), + } + previous_end = Some(comment.span.end); + } + blocks +} + /// Whether a `---@meta` line above the first statement marks the file as a definition file. pub fn is_meta_file(source: &str, chunk: &Chunk) -> bool { let first_stmt = chunk.block.stmts.first().map_or(u32::MAX, |stmt| stmt.span.start); diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index aa5e761..7ddab8c 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -32,6 +32,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "count-down-loop", "missing-parameter", "undefined-doc-name", + "undefined-doc-param", "missing-fields", "assign-type-mismatch", "return-type-mismatch", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 877999b..ac7ca22 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -71,6 +71,7 @@ pub const DUPLICATE_ARGUMENT: &str = "duplicate-argument"; pub const MISSING_PARAMETER: &str = "missing-parameter"; /// Reported by qbx-lua-ls, which indexes the LuaCATS types; the linter itself has no type information. pub const UNDEFINED_DOC_NAME: &str = "undefined-doc-name"; +pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const MISSING_FIELDS: &str = "missing-fields"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. @@ -143,6 +144,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_ARGUMENT, Correctness, ERROR, false, "Two parameters of one function share a name."), rule(MISSING_PARAMETER, Correctness, WARN, false, "A function is called without an argument for a parameter its LuaCATS annotations require."), rule(UNDEFINED_DOC_NAME, Correctness, WARN, false, "A LuaCATS annotation names a type that no @class, @alias or @enum declares for the file's side (language server only)."), + rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 6688f43..f410e85 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -39,4 +39,5 @@ server/main.lua:24:16 info manifest/missing-dependency server/main.lua:24:16 hint qbox/legacy-core-object shared/config.lua:3:1 warning self-assignment shared/config.lua:4:1 warning lowercase-global -shared/config.lua:16:10 warning duplicate-set-field +shared/config.lua:12:11 warning undefined-doc-param +shared/config.lua:17:10 warning duplicate-set-field diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 25e244e..5f90daf 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -9,6 +9,7 @@ function Notify(message, duration) print(message, duration) end +---@param msg string function Config.Notify(message) print(message) end diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 52f15fc..576f6b7 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -831,3 +831,85 @@ function M.f() end return reset"; assert_eq!(reported_lines(source, "duplicate-set-field"), [4, 8, 11]); } + +#[test] +fn doc_params_name_parameters_of_the_documented_function() { + let source = "---@param undocumented integer +local function wrongName(actual) return actual end +---@param ... any +local function noVararg(x) return x end +---@param self table +function M.dot() end +---@param src number +---@param extra string +RegisterNetEvent('ev', function(src) print(src) end) +---@param source number +AddEventHandler('playerDropped', function() print(source) end) +---@param a number +local notAFunction = 5 +---@param a number + +local afterBlank = function(a) return a end +return wrongName, noVararg, notAFunction, afterBlank"; + assert_eq!(reported_lines(source, "undefined-doc-param"), [1, 3, 5, 8, 10, 12, 14]); + let messages: Vec = findings(source, "undefined-doc-param").into_iter().map(|(_, m)| m).collect(); + assert_eq!(messages[0], "the function below has no parameter 'undocumented'"); + assert_eq!(messages[5], "no function follows the @param 'a' annotation"); + + for quiet in [ + "M = {}\n---@param self table\n---@param x number\nfunction M:method(x) return self, x end", + "---@param x number\n---@param y? number\n---@param ... any\nlocal function vararg(x, y, ...) return x, y, ... end\nreturn vararg", + // The functions of the statement below, also on its later lines. + "---@param source number\n---@param data table\nlib.callback.register('name',\n function(source, data) return source, data end)", + "---@param a number\nlocal function multi(\n a\n) return a end\nreturn multi", + "---@param a number\n---@param b number\nlocal x, y = 1, function(a, b) return a + b end\nreturn x, y", + // Every function that starts on the line below, as LuaLS binds it. + "local t = {\n ---@param args table\n onSelect = function(args) return args end,\n}\nreturn t", + "---@param x number\nfoo(bar(function(x) return x end))", + "---@param b number\nreturn function(b) return b end", + // The variables of a `for ... in` loop. + "---@param k string\nfor k in pairs({}) do print(k) end", + // Comment lines between the doc comment and the code. + "---@param d number\n-- qbx-lint: disable-next-line lowercase-global\nfunction lower(d) return d end", + "---@param e number\n--[[ note ]]\nlocal function block(e) return e end\nreturn block", + // A doc comment after code describes that line, and directives inside the comment apply. + "local z = 1 ---@param q number\nreturn z", + "---@diagnostic disable-next-line: undefined-doc-param\n---@param targets table\nfunction AddTargets(...) return ... end", + "local Config = {} ---@type table\n---@param a number\nlocal function f(a) return a, Config end\nreturn f", + // The parameter names of a `@type fun(...)` in the comment. + "---@param a number\n---@type fun(a: number)\nlocal handler\nreturn handler", + "---@param b number\n---@param ... any\n---@type fun(b: number, ...: any)|nil\nlocal wrapped = wrap(print)\nreturn wrapped", + // A local or global function the statement passes by name. + "local function play(data) print(data) end\n---@param data table\nexports('Play', play)", + "local onDrop = function(reason) print(reason) end\n---@param reason string\nAddEventHandler('playerDropped', onDrop)", + "function setChannel(channel) print(channel) end\n---@param channel number\nexports('setChannel', setChannel)", + // Definition files document stubs. + "---@meta\n\n---@param ped integer\nfunction Stub(...) end", + ] { + assert_eq!(reported_lines(quiet, "undefined-doc-param"), Vec::::new(), "{quiet}"); + } + + // The lines below a doc comment after code, names a `@type fun(...)` or a passed function does + // not have, and names passed that hold no function. + let source = "local Config = {} ---@type table +---@param wrong number +local function f(a) return a, Config end +---@param c number +---@type fun(a: number) +local handler +---@param other table +exports('F', f) +local value = 1 +---@param x number +print(value) +return handler"; + assert_eq!( + findings(source, "undefined-doc-param"), + [ + (2, "the function below has no parameter 'wrong'".to_string()), + (4, "the function below has no parameter 'c'".to_string()), + (7, "the function below has no parameter 'other'".to_string()), + (10, "no function follows the @param 'x' annotation".to_string()), + ] + ); +} diff --git a/crates/qbx_lua_ls/src/features/doc_names.rs b/crates/qbx_lua_ls/src/features/doc_names.rs index 4ff9112..6fc6102 100644 --- a/crates/qbx_lua_ls/src/features/doc_names.rs +++ b/crates/qbx_lua_ls/src/features/doc_names.rs @@ -3,8 +3,9 @@ //! language server indexes the declarations, so qbx-lint registers the rule and this module reports it. use qbx_fivem_data::Side; +use qbx_lua_analysis::env::doc_blocks; use qbx_lua_syntax::ast::Chunk; -use qbx_lua_syntax::{Comment, CommentKind, Span}; +use qbx_lua_syntax::{Comment, Span}; use crate::index::Index; use crate::infer::NATIVE_HANDLE_TYPES; @@ -59,25 +60,3 @@ pub fn undefined_doc_names(index: &Index, source: &str, chunk: &Chunk, side: Opt } out } - -/// Runs of adjacent `---` line comments, each one annotation block. -fn doc_blocks<'c>(source: &str, comments: &'c [Comment]) -> Vec> { - let mut blocks: Vec> = Vec::new(); - let mut previous_end: Option = None; - for comment in comments { - if comment.kind != CommentKind::Line || !comment.span.text(source).starts_with("---") { - previous_end = None; - continue; - } - let adjacent = previous_end.is_some_and(|end| { - let gap = &source[end as usize..comment.span.start as usize]; - gap.bytes().filter(|b| *b == b'\n').count() <= 1 && gap.trim().is_empty() - }); - match blocks.last_mut() { - Some(block) if adjacent => block.push(comment), - _ => blocks.push(vec![comment]), - } - previous_end = Some(comment.span.end); - } - blocks -} diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 05fccae..ad24617 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -720,6 +720,32 @@ pub fn parse_cast(line: &str) -> Option> { Some(DocCast { name, entries }) } +/// The name a `@param`, `@field`, `@class`, `@alias` or `@enum` line declares, with the byte it +/// starts at. A field keyed like `[string]` gives its key with the brackets. +pub fn declared_name(line: &str) -> Option<(usize, &str)> { + let (tag, rest) = split_tag(line)?; + let rest = match tag { + "class" | "alias" | "enum" => split_attributes(rest).1, + "param" if rest.starts_with("...") => return Some((line.len() - rest.len(), "...")), + "param" => rest, + "field" => { + let rest = field_head(rest).1; + if let Some(key) = rest.strip_prefix('[') { + let mut parser = TypeParser::new(key); + parser.parse(); + let after = parser.rest().trim_start().strip_prefix(']')?; + let start = line.len() - rest.len(); + return Some((start, &line[start..line.len() - after.len()])); + } + rest + } + _ => return None, + }; + let mut parser = TypeParser::new(rest); + let name = parser.ident()?; + Some((line.len() - parser.rest().len() - name.len(), name)) +} + /// The tag of a doc line and the class and alias names on it. fn type_names(line: &str) -> Option<(&str, Vec>)> { let (tag, rest) = match listed_value(line) { @@ -1221,6 +1247,28 @@ mod tests { assert_eq!(parse_cast("@cast"), None); } + #[test] + fn finds_the_name_a_line_declares() { + fn name(line: &str) -> Option<&str> { + declared_name(line).map(|(start, name)| { + assert_eq!(&line[start..start + name.len()], name, "{line}"); + name + }) + } + assert_eq!(name(" @param source number"), Some("source")); + assert_eq!(name("@param name? string"), Some("name")); + assert_eq!(name("@param ... any"), Some("...")); + assert_eq!(name("@field (server) private money number"), Some("money")); + assert_eq!(name("@field public (client) hud table"), Some("hud")); + assert_eq!(name("@field [string] number"), Some("[string]")); + assert_eq!(name("@field [ 'key' ] boolean"), Some("[ 'key' ]")); + assert_eq!(name("@class (exact) Pair : Base"), Some("Pair")); + assert_eq!(name("@alias (client) Key 'E'|'F'"), Some("Key")); + assert_eq!(name("@enum (key) Jobs"), Some("Jobs")); + assert_eq!(name("@return string name"), None); + assert_eq!(name("@param"), None); + } + #[test] fn ignores_non_type_names_in_annotations() { for line in [ diff --git a/docs/reference.md b/docs/reference.md index c9edd24..69f6932 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | diff --git a/docs/rules.md b/docs/rules.md index 303bb5d..0f52e14 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -31,6 +31,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-argument` | error | Function parameters share a name. | | `missing-parameter` | warning | A call leaves out an argument that the function's LuaCATS annotations require. qbx-lua-ls also reports `---@callback` wrapper calls whose payload leaves out a value the registered handler requires. | | `undefined-doc-name` | warning | A LuaCATS annotation names a type that no `@class`, `@alias` or `@enum` declares, or that only `(server)` or `(client)` declarations of the other side declare. Reported by qbx-lua-ls only. | +| `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | From 907ae74a5175b047e231f47d107e9b517e6603bb Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:49:08 +0200 Subject: [PATCH 016/193] feat(rules): add duplicate-doc-alias --- crates/qbx_lua_analysis/src/checks/docs.rs | 74 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 5 +- .../fixtures/bad_resource/shared/config.lua | 3 + crates/qbx_lua_analysis/tests/rules.rs | 32 ++++++++ crates/qbx_luacats/src/luacats.rs | 10 +++ docs/reference.md | 2 +- docs/rules.md | 1 + 9 files changed, 121 insertions(+), 9 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index 0b0c2e2..cccd1dd 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,10 +1,11 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented -//! function has. +//! function has, and aliases and enums that one file declares twice for one side. +use qbx_fivem_data::Side; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::{Comment, LineIndex, Span}; -use qbx_luacats::luacats::{declared_name, parse_doc_lines}; +use qbx_luacats::luacats::{applies_on, declared_name, has_attribute, parse_doc_lines, DocGroup}; use qbx_luacats::types::{FunType, Type}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -18,7 +19,8 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { let wanted = |code, tags: &[&str]| sink.enabled(code).is_some() && tags.iter().any(|tag| source.contains(tag)); // Definition files document stubs, which need not spell out the parameters they describe. let params = wanted(rules::UNDEFINED_DOC_PARAM, &["@param"]) && !is_meta_file(source, input.chunk); - if !params { + let aliases = wanted(rules::DUPLICATE_DOC_ALIAS, &["@alias", "@enum"]); + if !(params || aliases) { return; } let blocks: Vec = doc_blocks(source, &input.chunk.comments) @@ -27,19 +29,26 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { .map(|comments| DocBlock::new(source, comments)) .collect(); let lines = LineIndex::new(source); - undefined_params(input, &blocks, &lines, sink); + if params { + undefined_params(input, &blocks, &lines, sink); + } + if aliases { + duplicate_aliases(&blocks, &lines, sink); + } } -/// One doc comment, and its `---` lines without the prefix. +/// One doc comment: its `---` lines without the prefix, and what they declare. struct DocBlock<'a> { comments: Vec<&'a Comment>, lines: Vec<&'a str>, + doc: DocGroup, } impl<'a> DocBlock<'a> { fn new(source: &'a str, comments: Vec<&'a Comment>) -> Self { let lines: Vec<&str> = comments.iter().map(|comment| &comment.span.text(source)[3..]).collect(); - Self { comments, lines } + let doc = parse_doc_lines(&lines); + Self { comments, lines, doc } } fn tag(&self, index: usize) -> Option<&'a str> { @@ -54,6 +63,10 @@ impl<'a> DocBlock<'a> { } } +fn line_number(lines: &LineIndex, span: Span) -> u32 { + lines.line_of(span.start) + 1 +} + /// `undefined-doc-param`. A doc comment documents the functions of the statement below it, as /// qbx-lua-ls binds it, with the functions it passes by name, and every function whose parameters /// start on the line below it, as LuaLS does, which covers table fields and nested calls. Comment @@ -289,3 +302,52 @@ impl<'a> Documented<'a> { } } } + +/// A type name a doc comment declares. +struct TypeName<'a> { + name: &'a str, + /// What declares it, as the message names it. + kind: &'static str, + side: Option, + partial: bool, + span: Span, +} + +const CLASS: &str = "a class"; + +/// `duplicate-doc-alias`: an `@alias` or `@enum` whose name the file already gives an alias or enum, +/// or any class, for a side the two share. `(partial)` on any of them allows the repeat, as in LuaLS. +fn duplicate_aliases(blocks: &[DocBlock], lines: &LineIndex, sink: &mut Sink) { + let mut names: Vec = Vec::new(); + for block in blocks { + for index in 0..block.lines.len() { + let (kind, side) = match block.tag(index) { + Some("alias") => ("an alias", block.doc.aliases.iter().find(|a| a.line == index).and_then(|a| a.side)), + Some("enum") => ("an enum", block.doc.enum_side), + Some("class") => (CLASS, block.doc.classes.iter().find(|c| c.line == index).and_then(|c| c.side)), + _ => continue, + }; + let Some((span, name)) = block.declared(index) else { continue }; + let partial = has_attribute(block.lines[index], "partial"); + names.push(TypeName { name, kind, side, partial, span }); + } + } + let partial: FxHashSet<&str> = names.iter().filter(|n| n.partial).map(|n| n.name).collect(); + for (index, declared) in names.iter().enumerate() { + if declared.kind == CLASS || partial.contains(declared.name) { + continue; + } + let clashes = |other: &&TypeName| other.name == declared.name && applies_on(other.side, declared.side); + let earlier = names[..index].iter().rev().filter(|other| other.kind != CLASS).find(clashes); + let Some(other) = earlier.or_else(|| names.iter().filter(|other| other.kind == CLASS).find(clashes)) else { + continue; + }; + let message = format!( + "'{}' is already declared as {} on line {}", + declared.name, + other.kind, + line_number(lines, other.span) + ); + sink.report(rules::DUPLICATE_DOC_ALIAS, declared.span, message); + } +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 7ddab8c..aa1a1b2 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -33,6 +33,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "missing-parameter", "undefined-doc-name", "undefined-doc-param", + "duplicate-doc-alias", "missing-fields", "assign-type-mismatch", "return-type-mismatch", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index ac7ca22..a787cf2 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -72,6 +72,7 @@ pub const MISSING_PARAMETER: &str = "missing-parameter"; /// Reported by qbx-lua-ls, which indexes the LuaCATS types; the linter itself has no type information. pub const UNDEFINED_DOC_NAME: &str = "undefined-doc-name"; pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; +pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const MISSING_FIELDS: &str = "missing-fields"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. @@ -145,6 +146,7 @@ pub static RULES: &[Rule] = &[ rule(MISSING_PARAMETER, Correctness, WARN, false, "A function is called without an argument for a parameter its LuaCATS annotations require."), rule(UNDEFINED_DOC_NAME, Correctness, WARN, false, "A LuaCATS annotation names a type that no @class, @alias or @enum declares for the file's side (language server only)."), rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), + rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index f410e85..d285712 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -39,5 +39,6 @@ server/main.lua:24:16 info manifest/missing-dependency server/main.lua:24:16 hint qbox/legacy-core-object shared/config.lua:3:1 warning self-assignment shared/config.lua:4:1 warning lowercase-global -shared/config.lua:12:11 warning undefined-doc-param -shared/config.lua:17:10 warning duplicate-set-field +shared/config.lua:13:11 warning duplicate-doc-alias +shared/config.lua:15:11 warning undefined-doc-param +shared/config.lua:20:10 warning duplicate-set-field diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 5f90daf..648341c 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -9,6 +9,9 @@ function Notify(message, duration) print(message, duration) end +---@alias NotifyType 'inform'|'error' +---@alias NotifyType 'success' + ---@param msg string function Config.Notify(message) print(message) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 576f6b7..9bdb376 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -913,3 +913,35 @@ return handler"; ] ); } + +#[test] +fn aliases_declared_twice_in_one_file() { + let source = "---@alias Dup string +---@alias Dup integer +---@class Shape +---@alias Shape string +---@enum Color +local Color = { Red = 1 } +---@alias Color string +---@alias Later string +---@class Later +return Color"; + assert_eq!(reported_lines(source, "duplicate-doc-alias"), [2, 4, 7, 8]); + let messages: Vec = findings(source, "duplicate-doc-alias").into_iter().map(|(_, m)| m).collect(); + assert_eq!(messages[0], "'Dup' is already declared as an alias on line 1"); + assert_eq!(messages[1], "'Shape' is already declared as a class on line 3"); + assert_eq!(messages[2], "'Color' is already declared as an enum on line 5"); + + for quiet in [ + // One declaration for each side. + "---@alias (server) Sided string\n---@alias (client) Sided integer", + "---@enum (server) Jobs\nlocal Jobs = { A = 1 }\n---@alias (client) Jobs string\nreturn Jobs", + // `(partial)` on any of them. + "---@class (partial) Part\n---@alias Part string", + "---@alias (partial) Part string\n---@alias Part integer", + // Classes merge their declarations. + "---@class Merged\n---@field a string\n---@class Merged\n---@field b string", + ] { + assert_eq!(reported_lines(quiet, "duplicate-doc-alias"), Vec::::new(), "{quiet}"); + } +} diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index ad24617..ecd9bed 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -746,6 +746,13 @@ pub fn declared_name(line: &str) -> Option<(usize, &str)> { Some((line.len() - parser.rest().len() - name.len(), name)) } +/// Whether the attributes of a `@class`, `@alias` or `@enum` line name `attribute`, as in +/// `@class (partial) Player`. +pub fn has_attribute(line: &str, attribute: &str) -> bool { + let Some(("class" | "alias" | "enum", rest)) = split_tag(line) else { return false }; + split_attributes(rest).0.split(',').any(|named| named.trim() == attribute) +} + /// The tag of a doc line and the class and alias names on it. fn type_names(line: &str) -> Option<(&str, Vec>)> { let (tag, rest) = match listed_value(line) { @@ -1267,6 +1274,9 @@ mod tests { assert_eq!(name("@enum (key) Jobs"), Some("Jobs")); assert_eq!(name("@return string name"), None); assert_eq!(name("@param"), None); + assert!(has_attribute("@class (partial, server) Player", "partial")); + assert!(!has_attribute("@alias Partial string", "partial")); + assert!(!has_attribute("@field (partial) x number", "partial")); } #[test] diff --git a/docs/reference.md b/docs/reference.md index 69f6932..d638c57 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | diff --git a/docs/rules.md b/docs/rules.md index 0f52e14..4946444 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -32,6 +32,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `missing-parameter` | warning | A call leaves out an argument that the function's LuaCATS annotations require. qbx-lua-ls also reports `---@callback` wrapper calls whose payload leaves out a value the registered handler requires. | | `undefined-doc-name` | warning | A LuaCATS annotation names a type that no `@class`, `@alias` or `@enum` declares, or that only `(server)` or `(client)` declarations of the other side declare. Reported by qbx-lua-ls only. | | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | +| `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | From 7889e3dbbcfe244cda52b8bdf27a5eac4ff12c2d Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:50:03 +0200 Subject: [PATCH 017/193] feat(rules): add duplicate-doc-field --- crates/qbx_lua_analysis/src/checks/docs.rs | 43 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 5 ++- .../fixtures/bad_resource/shared/config.lua | 4 ++ crates/qbx_lua_analysis/tests/rules.rs | 37 ++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 6 ++- docs/reference.md | 2 +- docs/rules.md | 1 + 9 files changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index cccd1dd..9276213 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,10 +1,10 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented -//! function has, and aliases and enums that one file declares twice for one side. +//! function has, and aliases, enums and class fields that one file declares twice for one side. use qbx_fivem_data::Side; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::{Comment, LineIndex, Span}; +use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use qbx_luacats::luacats::{applies_on, declared_name, has_attribute, parse_doc_lines, DocGroup}; use qbx_luacats::types::{FunType, Type}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -20,7 +20,8 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { // Definition files document stubs, which need not spell out the parameters they describe. let params = wanted(rules::UNDEFINED_DOC_PARAM, &["@param"]) && !is_meta_file(source, input.chunk); let aliases = wanted(rules::DUPLICATE_DOC_ALIAS, &["@alias", "@enum"]); - if !(params || aliases) { + let fields = wanted(rules::DUPLICATE_DOC_FIELD, &["@field"]); + if !(params || aliases || fields) { return; } let blocks: Vec = doc_blocks(source, &input.chunk.comments) @@ -35,6 +36,9 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { if aliases { duplicate_aliases(&blocks, &lines, sink); } + if fields { + duplicate_fields(&blocks, &lines, sink); + } } /// One doc comment: its `---` lines without the prefix, and what they declare. @@ -351,3 +355,36 @@ fn duplicate_aliases(blocks: &[DocBlock], lines: &LineIndex, sink: &mut Sink) { sink.report(rules::DUPLICATE_DOC_ALIAS, declared.span, message); } } + +/// The declarations of one field of a class, with the side each is for. +type FieldDeclarations = Vec<(Option, Span)>; + +/// `duplicate-doc-field`: a class field the file already declares for a side the two share. A +/// function field repeated under its name is another signature of it, as in LuaLS. +fn duplicate_fields(blocks: &[DocBlock], lines: &LineIndex, sink: &mut Sink) { + let mut seen: FxHashMap<(SmolStr, String), FieldDeclarations> = FxHashMap::default(); + for block in blocks { + for class in &block.doc.classes { + let named = class.fields.iter().map(|f| (f.name.to_string(), &f.ty, f.side, f.line)); + let keyed = class.indices.iter().chain(&class.literal_fields); + let keyed = keyed.map(|f| (format!("[{}]", f.key), &f.ty, f.side, f.line)); + for (key, ty, side, index) in named.chain(keyed) { + if matches!(ty, Type::Fun(_)) { + continue; + } + let Some((span, shown)) = block.declared(index) else { continue }; + let side = side.or(class.side); + let declared = seen.entry((class.name.clone(), key)).or_default(); + if let Some((_, first)) = declared.iter().find(|(other, _)| applies_on(*other, side)) { + let message = format!( + "field '{shown}' of class '{}' is already declared on line {}", + class.name, + line_number(lines, *first) + ); + sink.report(rules::DUPLICATE_DOC_FIELD, span, message); + } + declared.push((side, span)); + } + } + } +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index aa1a1b2..79b8cc2 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -34,6 +34,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "undefined-doc-name", "undefined-doc-param", "duplicate-doc-alias", + "duplicate-doc-field", "missing-fields", "assign-type-mismatch", "return-type-mismatch", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index a787cf2..19e9327 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -73,6 +73,7 @@ pub const MISSING_PARAMETER: &str = "missing-parameter"; pub const UNDEFINED_DOC_NAME: &str = "undefined-doc-name"; pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; +pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const MISSING_FIELDS: &str = "missing-fields"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. @@ -147,6 +148,7 @@ pub static RULES: &[Rule] = &[ rule(UNDEFINED_DOC_NAME, Correctness, WARN, false, "A LuaCATS annotation names a type that no @class, @alias or @enum declares for the file's side (language server only)."), rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), + rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index d285712..5d21a41 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -40,5 +40,6 @@ server/main.lua:24:16 hint qbox/legacy-core-object shared/config.lua:3:1 warning self-assignment shared/config.lua:4:1 warning lowercase-global shared/config.lua:13:11 warning duplicate-doc-alias -shared/config.lua:15:11 warning undefined-doc-param -shared/config.lua:20:10 warning duplicate-set-field +shared/config.lua:17:11 warning duplicate-doc-field +shared/config.lua:19:11 warning undefined-doc-param +shared/config.lua:24:10 warning duplicate-set-field diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 648341c..332ee7e 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -12,6 +12,10 @@ end ---@alias NotifyType 'inform'|'error' ---@alias NotifyType 'success' +---@class NotifyOptions +---@field duration integer +---@field duration number + ---@param msg string function Config.Notify(message) print(message) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 9bdb376..ea7b669 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -945,3 +945,40 @@ return Color"; assert_eq!(reported_lines(quiet, "duplicate-doc-alias"), Vec::::new(), "{quiet}"); } } + +#[test] +fn fields_declared_twice_for_one_class() { + let source = "---@class Twice +---@field a string +---@field a integer +---@field g fun()|nil +---@field g fun()|nil +---@field [string] number +---@field [string] boolean +---@field ['b'] string +---@field b number + +---@class Twice +---@field a boolean"; + assert_eq!(reported_lines(source, "duplicate-doc-field"), [3, 5, 7, 9, 12]); + assert_eq!( + findings(source, "duplicate-doc-field")[0].1, + "field 'a' of class 'Twice' is already declared on line 2" + ); + + for quiet in [ + // Repeated function fields are overloads, scoped or not. + "---@class Phone\n---@field Has fun(): boolean\n---@field Has fun(source: number): boolean", + "---@class Phone\n---@field (client) Has fun(): boolean\n---@field (server) Has fun(source: number): boolean", + "---@class Mixed\n---@field h string\n---@field h fun()", + // One declaration for each side, by field or by class. + "---@class Phone\n---@field (client) hud table\n---@field (server) hud string", + "---@class (server) Account\n---@field money number\n\n---@class (client) Account\n---@field money string", + // Other classes, including a child that narrows a parent's field. + "---@class A\n---@field x string\n---@class B\n---@field x string", + "---@class Base\n---@field x string\n---@class Child : Base\n---@field x 'a'|'b'", + "---@class Lit\n---@field [1] string\n---@field [2] string", + ] { + assert_eq!(reported_lines(quiet, "duplicate-doc-field"), Vec::::new(), "{quiet}"); + } +} diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index ecd9bed..09c2706 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -43,6 +43,8 @@ pub struct DocIndexField { pub key: Type, pub ty: Type, pub side: Option, + /// Index of the doc line the field was declared on, used to locate it in the source. + pub line: usize, } #[derive(Clone, Debug, Default, PartialEq)] @@ -505,9 +507,9 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { return true; } key @ (Type::IntLit(_) | Type::BooleanLit(_)) => { - class.literal_fields.push(DocIndexField { key, ty: value, side }); + class.literal_fields.push(DocIndexField { key, ty: value, side, line }); } - key => class.indices.push(DocIndexField { key, ty: value, side }), + key => class.indices.push(DocIndexField { key, ty: value, side, line }), } return false; } diff --git a/docs/reference.md b/docs/reference.md index d638c57..01f7093 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | diff --git a/docs/rules.md b/docs/rules.md index 4946444..e900b78 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -33,6 +33,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-doc-name` | warning | A LuaCATS annotation names a type that no `@class`, `@alias` or `@enum` declares, or that only `(server)` or `(client)` declarations of the other side declare. Reported by qbx-lua-ls only. | | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | +| `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | From e7fe661e797899d20f9cdc6058d277228d595d14 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:50:38 +0200 Subject: [PATCH 018/193] feat(rules): add invisible --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 2 + .../qbx_lua_ls/src/features/class_tables.rs | 8 +- crates/qbx_lua_ls/src/features/completion.rs | 16 +- crates/qbx_lua_ls/src/features/diagnostics.rs | 8 +- crates/qbx_lua_ls/src/features/member_refs.rs | 12 +- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/src/features/visibility.rs | 441 ++++++++++++++++++ crates/qbx_lua_ls/src/index.rs | 39 +- crates/qbx_lua_ls/src/indexer.rs | 50 +- crates/qbx_lua_ls/tests/lsp.rs | 341 ++++++++++++++ crates/qbx_luacats/src/luacats.rs | 103 +++- docs/reference.md | 51 +- docs/rules.md | 1 + 16 files changed, 1029 insertions(+), 49 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/visibility.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 79b8cc2..2e98994 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -37,6 +37,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "duplicate-doc-field", "missing-fields", "assign-type-mismatch", + "invisible", "return-type-mismatch", "missing-return", "cast-type-mismatch", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 19e9327..12a7d5f 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -80,6 +80,8 @@ pub const MISSING_FIELDS: &str = "missing-fields"; pub const ASSIGN_TYPE_MISMATCH: &str = "assign-type-mismatch"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const UNDECLARED_FIELD: &str = "undeclared-field"; +/// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. +pub const INVISIBLE: &str = "invisible"; /// Reported by qbx-lua-ls, which infers the returned values; the linter itself has no type information. pub const RETURN_TYPE_MISMATCH: &str = "return-type-mismatch"; /// Reported by qbx-lua-ls, which reads the `@return` annotations with the types they allow. @@ -152,6 +154,7 @@ pub static RULES: &[Rule] = &[ rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), + rule(INVISIBLE, Correctness, WARN, false, "A private, protected or package field or method of a LuaCATS class is used outside its class, subclasses or file (language server only)."), rule(RETURN_TYPE_MISMATCH, Correctness, WARN, false, "A function returns a value of a different type than its @return annotation declares (language server only)."), rule(MISSING_RETURN, Correctness, WARN, false, "A function with a required @return value can end, or return, without it (language server only)."), rule(CAST_TYPE_MISMATCH, Correctness, WARN, false, "A ---@cast gives a variable a type that its declared type does not take (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 32db6ee..232bfc4 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -84,6 +84,7 @@ fn bad_resource_reports_every_rule() { | "missing-fields" | "assign-type-mismatch" | "undeclared-field" + | "invisible" | "return-type-mismatch" | "missing-return" | "cast-type-mismatch" diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 8acd179..19807cb 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -55,6 +55,8 @@ Available features depend on the editor's LSP client. `---@type` or `@param`, `undeclared-field` for fields and keys a `---@class (strict)` does not declare, and `return-type-mismatch` and `missing-return` for functions, including callback handlers, that do not return what their `@return` declares. + `invisible` reports the use of fields and methods a class keeps `private`, `protected` or + `package` from outside its class, subclasses or file, and completion leaves them out there. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. The opt-in diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index c515618..490c1bd 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -175,11 +175,13 @@ impl<'a, 'b> Classes<'a, 'b> { } } - fn class_defs(&self, name: &str, from: FileId) -> Vec<(FileId, &'b ClassDef)> { + /// The classes called `name` that code in `from` sees, as `declarations` finds them. + pub(crate) fn class_defs(&self, name: &str, from: FileId) -> Vec<(FileId, &'b ClassDef)> { self.declarations(name, from).0 } - fn alias_defs(&self, name: &str, from: FileId) -> Vec<(FileId, &'b AliasDef)> { + /// The aliases and enums called `name` that code in `from` sees, as `declarations` finds them. + pub(crate) fn alias_defs(&self, name: &str, from: FileId) -> Vec<(FileId, &'b AliasDef)> { self.declarations(name, from).1 } @@ -680,6 +682,8 @@ impl<'c> Visitor<'c> for Innermost { } /// Each class-typed table constructor that leaves out required fields, with the message naming them. +/// Fields that its class keeps from the code there, like `---@field private`, are required too, as +/// in LuaLS. pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let classes = Classes::new(infer); class_tables(infer, chunk) diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index 9900a7a..be4d4d8 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -17,6 +17,7 @@ use serde_json::json; use super::class_tables::{class_table_at, named_field, Classes}; use super::expected::{expected_type, Place}; use super::hover::{event_handler_signature, event_string_context}; +use super::visibility::Scope; use super::{lua_block, markdown, with_infer}; use crate::callback_wrappers::{families, takes_function, Wrapper}; use crate::document::Document; @@ -1225,7 +1226,11 @@ fn member_items( Some(access) => infer.expr(access.base()), None => type_of_path(infer, &head[..head.len() - 1], offset), }; - let members = infer.members(&base_type); + // Members that their class keeps from the code here, like `---@field private`, are left out. + let scope = Scope::new(infer, &doc.chunk); + let base = located.member.as_ref().map(|access| access.base()); + let mut members = infer.members(&base_type); + members.retain(|m| scope.allows(&base_type, base, &m.name, offset)); let has_methods = members.iter().any(|m| m.ty.as_fun().is_some()); let mut items = Vec::new(); for m in members.iter().filter(|m| !via_colon || !has_methods || m.ty.as_fun().is_some()) { @@ -1266,14 +1271,17 @@ fn type_of_path(infer: &Infer, text: &str, offset: u32) -> Type { /// Field names of the table type a call expects, when the cursor is inside a table argument. fn expected_field_items(infer: &Infer, doc: &Document, offset: u32) -> Vec { // A table typed as a class, by `---@type`, a parameter, an assignment, `@return` or the field - // of another such table, offers the `@field`s it does not set yet. + // of another such table, offers the `@field`s it does not set yet and the code here may set. + let scope = Scope::new(infer, &doc.chunk); if let Some(found) = class_table_at(infer, &doc.chunk, offset) { let ExprKind::Table(existing) = &found.table.kind else { return Vec::new() }; let present: FxHashSet<&str> = existing.iter().filter_map(named_field).map(|(name, _)| name).collect(); + let class = Type::Named(found.class.clone(), Vec::new()); return Classes::new(infer) .fields(&found.class, found.from) .into_iter() .filter(|field| !present.contains(field.name.as_str()) && is_identifier(&field.name)) + .filter(|field| scope.allows(&class, None, &field.name, offset)) .map(|field| { let mut out = item(&field.name, CompletionItemKind::PROPERTY, 0); out.detail = Some(field.ty.to_string()); @@ -1302,10 +1310,12 @@ fn expected_field_items(infer: &Infer, doc: &Document, offset: u32) -> Vec None, }) .collect(); + let expected = param.ty.without_nil(); infer - .members(¶m.ty.without_nil()) + .members(&expected) .iter() .filter(|m| !present.contains(m.name.as_str()) && is_identifier(&m.name)) + .filter(|m| scope.allows(&expected, None, &m.name, offset)) .map(|m| { let mut out = member_item(m); out.kind = Some(CompletionItemKind::PROPERTY); diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 9945e92..07c49f2 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -4,8 +4,8 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, MISSING_FIELDS, MISSING_PARAMETER, MISSING_RETURN, - NO_UNKNOWN, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, MISSING_PARAMETER, + MISSING_RETURN, NO_UNKNOWN, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -23,6 +23,7 @@ use super::doc_names::undefined_doc_names; use super::returns::{mismatched_returns, missing_returns}; use super::strict_classes::undeclared_fields; use super::unknown_types::unknown_types; +use super::visibility::invisible_members; use super::with_infer; use crate::document::Document; use crate::index::{FileId, FileOrigin}; @@ -55,7 +56,7 @@ fn strict_by_default(ws: &Workspace, file: FileId) -> bool { /// linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&Workspace, &Document) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 10] = [ + let checks: [(&'static str, Check); 11] = [ (UNDEFINED_DOC_NAME, |ws, doc| { let side = ws.index.file(doc.file).and_then(|f| f.side); undefined_doc_names(&ws.index, &doc.text, &doc.chunk, side) @@ -69,6 +70,7 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< } with_infer(ws, doc, |infer| undeclared_fields(infer, &doc.chunk, |file| strict_by_default(ws, file))) }), + (INVISIBLE, |ws, doc| with_infer(ws, doc, |infer| invisible_members(infer, &doc.chunk))), (RETURN_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, |infer| mismatched_returns(infer, &doc.chunk))), (MISSING_RETURN, |ws, doc| with_infer(ws, doc, |infer| missing_returns(infer, &doc.chunk))), (CAST_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, mismatched_casts)), diff --git a/crates/qbx_lua_ls/src/features/member_refs.rs b/crates/qbx_lua_ls/src/features/member_refs.rs index c2ab12c..5c3a924 100644 --- a/crates/qbx_lua_ls/src/features/member_refs.rs +++ b/crates/qbx_lua_ls/src/features/member_refs.rs @@ -12,6 +12,7 @@ use crate::document::Document; use crate::index::{FileId, FileOrigin}; use crate::infer::{table_fields, Infer}; use crate::locate::string_content_span; +use crate::luacats::field_head; use crate::server::Documents; use crate::types::Type; use crate::workspace::Workspace; @@ -98,16 +99,13 @@ fn declaration_range(doc: &Document, name: &str, range: Range) -> Option if string_content_span(span, &doc.text).is_some() { return string_range(span); } - let mut rest = raw.strip_prefix("---")?.trim_start().strip_prefix("@field")?; + let rest = raw.strip_prefix("---")?.trim_start().strip_prefix("@field")?; if !rest.starts_with(char::is_whitespace) { return None; } - rest = rest.trim_start(); - for scope in ["public ", "private ", "protected ", "package "] { - if let Some(stripped) = rest.strip_prefix(scope) { - rest = stripped.trim_start(); - } - } + // Past `(client)` and `private` the way the annotation parser reads them, which takes the + // `private` of `---@field private integer` for the name. + let (_, _, mut rest) = field_head(rest.trim_start()); if let Some(index) = rest.strip_prefix('[') { rest = index.trim_start(); let tokens = lex(rest); diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index 06b7f63..d7ce7ca 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -28,6 +28,7 @@ pub mod signature; pub mod strict_classes; pub mod symbols; pub mod unknown_types; +pub mod visibility; pub mod workspace_health; use crate::document::Document; diff --git a/crates/qbx_lua_ls/src/features/visibility.rs b/crates/qbx_lua_ls/src/features/visibility.rs new file mode 100644 index 0000000..75eb021 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/visibility.rs @@ -0,0 +1,441 @@ +//! Member visibility: `---@field private name type`, and `---@private`, `---@protected` or +//! `---@package` above the function or assignment that sets a member of a class. A private member is +//! used through the table the `---@class` annotation declares, or inside a function defined on that +//! table; a protected one also through or inside its subclasses; a package one in the file that +//! declares it. `invisible` reports the uses from anywhere else, completion leaves such members out, +//! and `missing-fields` does not ask for them. Only the language server knows the classes, so +//! qbx-lint registers the rule and this module reports it. +//! +//! As in LuaLS, the nearest class that declares a name decides, and one restrictive declaration +//! there makes the member restrictive. Unlike LuaLS, functions nested in a method count as inside +//! the class, as do the functions written in the table its `---@class` annotation declares. + +use std::cell::OnceCell; +use std::sync::Arc; + +use qbx_lua_analysis::project::relative_slash_path; +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::lexer::TokenKind; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::{SmolStr, Span}; +use rustc_hash::FxHashSet; + +use super::class_tables::Classes; +use crate::index::{ClassDef, FileId, SymbolKind}; +use crate::infer::{table_fields, Decl, Infer}; +use crate::luacats::{applies_on, Visibility}; +use crate::types::Type; + +const MAX_DEPTH: u32 = 8; + +/// What keeps a member from code outside its class or file. +pub struct Restriction { + pub visibility: Visibility, + /// The class that declares the member. + pub class: SmolStr, + /// The file of the declaration that restricts it. + pub file: FileId, +} + +/// Where the code of one file may use the members of classes. +pub struct Scope<'a, 'b> { + infer: &'a Infer<'b>, + chunk: &'a Chunk, + restricted: Arc>, + /// The functions defined on the table of a class, with that class. + methods: OnceCell>, +} + +impl<'a, 'b> Scope<'a, 'b> { + pub fn new(infer: &'a Infer<'b>, chunk: &'a Chunk) -> Self { + Self { infer, chunk, restricted: infer.index.restricted_names(), methods: OnceCell::new() } + } + + /// Whether code at `offset` may use the member `name` of a value of type `owner`, which `base` + /// is when the code names it. + pub fn allows(&self, owner: &Type, base: Option<&Expr>, name: &str, offset: u32) -> bool { + self.hidden(owner, || base.and_then(|base| self.class_table(base)), name, offset).is_none() + } + + /// The restriction that keeps code at `offset` from the member `name` of a value of type + /// `owner`. `through` gives the class whose table the code uses the member through, if any. + fn hidden( + &self, + owner: &Type, + through: impl FnOnce() -> Option, + name: &str, + offset: u32, + ) -> Option { + if !self.restricted.contains(name) { + return None; + } + let mut classes = Vec::new(); + self.classes_of(owner, self.infer.ctx.file, &mut classes, 0); + let declaration = |class: &str, from: FileId| self.declaration(class, from, name, &mut FxHashSet::default(), 0); + let restrictions: Vec = + classes.iter().filter_map(|(class, from)| declaration(class, *from).flatten()).collect(); + if restrictions.is_empty() { + return None; + } + let through = through(); + restrictions.into_iter().find(|restriction| !self.sees(restriction, through.as_deref(), offset)) + } + + /// The classes a value of type `ty` may be, through unions and aliases, as `from` sees the names, + /// each with the file whose view decides its declarations. + fn classes_of(&self, ty: &Type, from: FileId, out: &mut Vec<(SmolStr, FileId)>, depth: u32) { + if depth > MAX_DEPTH { + return; + } + match ty { + Type::Union(types) => types.iter().for_each(|part| self.classes_of(part, from, out, depth + 1)), + Type::Named(name, _) => { + let classes = Classes::new(self.infer); + if classes.class_defs(name, from).is_empty() { + let aliases = classes.alias_defs(name, from); + aliases.iter().for_each(|(file, alias)| self.classes_of(&alias.ty, *file, out, depth + 1)); + } else if !out.iter().any(|(known, _)| known == name) { + out.push((name.clone(), from)); + } + } + _ => {} + } + } + + /// The declarations of the class `name` that `from` sees, as the other checks of classes read + /// them: a resource's `Player` is not ox_lib's. + fn class_defs(&self, name: &str, from: FileId) -> Vec<(FileId, &'b ClassDef)> { + Classes::new(self.infer).class_defs(name, from) + } + + /// How the nearest of `class` and its parents that declares `name` restricts it: `None` when + /// none declares it, `Some(None)` when it is public. Members set through values of the class + /// only count when they say `---@private` or the like. `from` is the file whose view of the + /// type names decides which declarations of `class` count. A class that several parents share + /// is read once. + fn declaration( + &self, + class: &str, + from: FileId, + name: &str, + visited: &mut FxHashSet, + depth: u32, + ) -> Option> { + if depth > MAX_DEPTH || !visited.insert(SmolStr::new(class)) { + return None; + } + let index = self.infer.index; + let defs = self.class_defs(class, from); + let mut declared = false; + let mut found: Option = None; + let mut note = |visibility: Visibility, file: FileId| { + declared = true; + if visibility > found.as_ref().map_or(Visibility::Public, |restriction| restriction.visibility) { + found = Some(Restriction { visibility, class: SmolStr::new(class), file }); + } + }; + for (file, def) in &defs { + let sides = def.field_sides.iter().copied().chain(std::iter::repeat(None)); + let visibility = def.field_visibility.iter().copied().chain(std::iter::repeat(Visibility::Public)); + for ((field, side), visibility) in def.fields.iter().zip(sides).zip(visibility) { + if field.name == name && applies_on(side, self.infer.side()) { + note(visibility, *file); + } + } + } + for (file, member) in index.members_named(class, name, self.infer.ctx.file) { + if !member.injected || member.visibility != Visibility::Public { + note(member.visibility, file); + } + } + if declared { + return Some(found); + } + let mut parents = defs.iter().flat_map(|(file, def)| def.parents.iter().map(move |parent| (parent, *file))); + parents.find_map(|(parent, file)| self.declaration(parent, file, name, visited, depth + 1)) + } + + /// Whether code at `offset`, using the member through the table of the class `through`, if any, + /// may use a member restricted so. + fn sees(&self, restriction: &Restriction, through: Option<&str>, offset: u32) -> bool { + let inside = |class: &str| match restriction.visibility { + Visibility::Public => true, + Visibility::Package => false, + Visibility::Private => class == restriction.class, + Visibility::Protected => { + self.inherits(class, self.infer.ctx.file, &restriction.class, &mut FxHashSet::default(), 0) + } + }; + match restriction.visibility { + Visibility::Package => restriction.file == self.infer.ctx.file, + _ => { + through.is_some_and(inside) + || self.methods().iter().any(|(span, class)| span.contains(offset) && inside(class)) + } + } + } + + /// Whether `class`, as `from` sees it, is `ancestor` or one of its subclasses. A class that + /// several parents share is read once. + fn inherits( + &self, + class: &str, + from: FileId, + ancestor: &str, + visited: &mut FxHashSet, + depth: u32, + ) -> bool { + if class == ancestor { + return true; + } + if depth >= MAX_DEPTH || !visited.insert(SmolStr::new(class)) { + return false; + } + let defs = self.class_defs(class, from); + let mut parents = defs.iter().flat_map(|(file, def)| def.parents.iter().map(move |parent| (parent, *file))); + parents.any(|(parent, file)| self.inherits(parent, file, ancestor, visited, depth + 1)) + } + + /// Whether `name` is the table a `---@class` annotation declares. A `---@type` in the same doc + /// comment types the local instead, as LuaLS reads `---@class Secret` `---@type Secret` above + /// `local s = {}`. + fn is_class_table(&self, name: &Name) -> bool { + let resolution = self.infer.ctx.resolution; + let typed = match resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => match self.infer.ctx.decl(resolution.local(id).decl.start) { + Some(Decl::Local { stmt, .. }) => self.infer.ctx.doc_at(stmt.span.start).ty.is_some(), + _ => false, + }, + _ => false, + }; + !typed && self.infer.is_class_table(name) + } + + /// The class whose table `expr` is: a name or field that a `---@class` annotation declares, like + /// `Secret` below `---@class Secret` `local Secret = {}`, or ox_lib's `lib.array`. + fn class_table(&self, expr: &Expr) -> Option { + let expr = expr.unparen(); + let declares = |base: &Expr, name: &str| { + let member = self.infer.member(&self.infer.expr(base), name); + member.is_some_and(|member| member.kind == SymbolKind::Table && matches!(member.ty, Type::Named(..))) + }; + let is_table = match &expr.kind { + ExprKind::Name(name) => self.is_class_table(name), + ExprKind::Field { base, name, .. } => declares(base, &name.text), + ExprKind::Index { base, index, .. } => index.as_string().is_some_and(|key| declares(base, key)), + _ => false, + }; + match self.infer.expr(expr).without_nil() { + Type::Named(class, _) if is_table => Some(class), + _ => None, + } + } + + /// The type of the value `function a.b:c()` sets `c` on, and the class whose table that is. + fn function_owner(&self, name: &FuncName) -> (Type, Option) { + let path = if name.method.is_some() { &name.path[..] } else { &name.path[..name.path.len().saturating_sub(1)] }; + let func_name = |path: &[Name]| FuncName { + base: name.base.clone(), + path: path.to_vec(), + method: None, + span: name.base.span, + }; + let Some((last, parents)) = path.split_last() else { + let owner = self.infer.func_name_owner_type(&func_name(&[])); + let class = match owner.without_nil() { + Type::Named(class, _) if self.is_class_table(&name.base) => Some(class), + _ => None, + }; + return (owner, class); + }; + let parent = self.infer.func_name_owner_type(&func_name(parents)); + let Some(member) = self.infer.member(&parent, &last.text) else { return (Type::Unknown, None) }; + let class = match &member.ty { + Type::Named(class, _) if member.kind == SymbolKind::Table => Some(class.clone()), + _ => None, + }; + (member.ty, class) + } + + fn methods(&self) -> &[(Span, SmolStr)] { + self.methods.get_or_init(|| { + let tokens = self.chunk.tokens.iter().rev(); + let last_token_end = tokens.filter(|token| token.kind != TokenKind::Eof).map(|token| token.span.end).next(); + let mut finder = + Methods { scope: self, last_token_end: last_token_end.unwrap_or_default(), out: Vec::new() }; + finder.visit_block(&self.chunk.block); + finder.out + }) + } +} + +impl Restriction { + fn message(&self, name: &str, infer: &Infer) -> String { + match self.visibility { + Visibility::Private => { + format!("Field `{name}` is private, it can only be accessed in class `{}`", self.class) + } + Visibility::Protected => format!( + "Field `{name}` is protected, it can only be accessed in class `{}` and its subclasses", + self.class + ), + Visibility::Package | Visibility::Public => { + let entry = infer.index.file(self.file); + let path = match (entry, entry.and_then(|entry| entry.resource).and_then(|r| infer.index.resource(r))) { + (Some(entry), Some(resource)) => relative_slash_path(&resource.root, &entry.path), + (Some(entry), None) => { + entry.path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default() + } + (None, _) => String::new(), + }; + format!("Field `{name}` can only be accessed in same file `{path}`") + } + } + } +} + +/// Collects the functions defined on the table of a class: `function Secret:m()`, `function +/// Secret.m()`, `Secret.m = function() end`, and those the table's own constructor holds. +struct Methods<'s, 'a, 'b> { + scope: &'s Scope<'a, 'b>, + /// Where the last token of the file ends. + last_token_end: u32, + out: Vec<(Span, SmolStr)>, +} + +impl Methods<'_, '_, '_> { + /// The code `func` covers. A function that the file ends in without its `end`, as a method + /// being written usually is, covers the rest of the file, where the cursor is. + fn body(&self, func: &FuncBody) -> Span { + if func.end_span.is_empty() && func.span.end >= self.last_token_end { + Span::new(func.span.start, u32::MAX) + } else { + func.span + } + } + + /// The functions that the table constructors in `exprs` hold, when a `---@class` annotation + /// above the statement at `stmt_start` declares them. + fn class_constructors(&mut self, stmt_start: u32, exprs: &[Expr]) { + let doc = self.scope.infer.ctx.doc_at(stmt_start); + let Some(class) = doc.classes.last().filter(|_| doc.ty.is_none()) else { return }; + for fields in exprs.iter().filter_map(table_fields) { + for field in fields { + let (TableField::Named { value, .. } | TableField::Keyed { value, .. }) = field else { continue }; + if let ExprKind::Function(func) = &value.unparen().kind { + self.out.push((self.body(func), class.name.clone())); + } + } + } + } +} + +impl<'c> Visitor<'c> for Methods<'_, '_, '_> { + fn visit_stmt(&mut self, stmt: &'c Stmt) { + match &stmt.kind { + StmtKind::Function { name, func } if name.method.is_some() || !name.path.is_empty() => { + if let (_, Some(class)) = self.scope.function_owner(name) { + self.out.push((self.body(func), class)); + } + } + StmtKind::Local { exprs, .. } => self.class_constructors(stmt.span.start, exprs), + StmtKind::Assign { targets, exprs } => { + self.class_constructors(stmt.span.start, exprs); + for (target, value) in targets.iter().zip(exprs) { + let ExprKind::Function(func) = &value.unparen().kind else { continue }; + let base = match &target.kind { + ExprKind::Field { base, .. } => base, + ExprKind::Index { base, index, .. } if index.as_string().is_some() => base, + _ => continue, + }; + if let Some(class) = self.scope.class_table(base) { + self.out.push((self.body(func), class)); + } + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } +} + +/// Each use of a member of a class where its visibility does not reach, with the message naming +/// who may use it: reads and writes through `.`, `:` and `["name"]`, and `function value:name()`. +/// Keys of table constructors are not uses, as in LuaLS, and neither is setting a member on the +/// table of a class, which declares that class's own member: a subclass may define the `__index` +/// its parent keeps private, as ox_lib's `function lib.ped:__index()` does. +pub fn invisible_members(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let scope = Scope::new(infer, chunk); + if scope.restricted.is_empty() { + return Vec::new(); + } + let mut finder = Finder { scope: &scope, declarations: Vec::new(), out: Vec::new() }; + finder.visit_block(&chunk.block); + finder.out +} + +struct Finder<'s, 'a, 'b> { + scope: &'s Scope<'a, 'b>, + /// The targets of assignments that set a member on the table of a class. + declarations: Vec, + out: Vec<(Span, String)>, +} + +impl Finder<'_, '_, '_> { + fn check(&mut self, owner: &Type, through: impl FnOnce() -> Option, name: &str, span: Span) { + if let Some(restriction) = self.scope.hidden(owner, through, name, span.start) { + self.out.push((span, restriction.message(name, self.scope.infer))); + } + } +} + +impl<'c> Visitor<'c> for Finder<'_, '_, '_> { + fn visit_stmt(&mut self, stmt: &'c Stmt) { + let scope = self.scope; + match &stmt.kind { + StmtKind::Function { name, .. } => { + let set = name.method.as_ref().or(name.path.last()); + if let Some(set) = set.filter(|set| scope.restricted.contains(&set.text)) { + if let (owner, None) = scope.function_owner(name) { + self.check(&owner, || None, &set.text, set.span); + } + } + } + StmtKind::Assign { targets, .. } => { + for target in targets { + let (base, name) = match &target.kind { + ExprKind::Field { base, name, .. } => (base, &name.text), + ExprKind::Index { base, index, .. } => match index.as_string() { + Some(key) => (base, key), + None => continue, + }, + _ => continue, + }; + if scope.restricted.contains(name) && scope.class_table(base).is_some() { + self.declarations.push(target.span); + } + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'c Expr) { + if self.declarations.contains(&expr.span) { + return visit::walk_expr(self, expr); + } + let access = match &expr.kind { + ExprKind::Field { base, name, .. } if !name.is_missing() => Some((base, &name.text, name.span)), + ExprKind::MethodCall { base, method, .. } => Some((base, &method.text, method.span)), + ExprKind::Index { base, index, .. } => index.as_string().map(|key| (base, key, index.span)), + _ => None, + }; + let scope = self.scope; + if let Some((base, name, span)) = access.filter(|(_, name, _)| scope.restricted.contains(*name)) { + self.check(&scope.infer.expr(base), || scope.class_table(base), name, span); + } + visit::walk_expr(self, expr); + } +} diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 157be4e..f96146d 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -6,10 +6,10 @@ use qbx_fivem_data::Side; use qbx_lua_analysis::manifest::Manifest; use qbx_lua_analysis::project::{split_import, Declaration, Declarations}; use qbx_lua_analysis::summary::FileSummary; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use smol_str::SmolStr; -use crate::luacats::{applies_on, DocIndexField}; +use crate::luacats::{applies_on, DocIndexField, Visibility}; use crate::types::{DescribedValue, FunType, Type}; pub type FileId = u32; @@ -54,6 +54,8 @@ pub struct Member { /// Set through a value typed as the class rather than on the table its `---@class` declares, /// like `abc.x = 1` below `---@type Test` `local abc`. Strict classes do not count it as declared. pub injected: bool, + /// The `---@private`, `---@protected` or `---@package` above the statement that sets it. + pub visibility: Visibility, } /// The entries of a table constructor that have no name: its array part (`key` is `None`), or its @@ -72,6 +74,8 @@ pub struct ClassDef { pub fields: Vec, /// The side each of `fields` is scoped to by `@field (server) name type`. pub field_sides: Vec>, + /// The visibility each of `fields` is declared with by `@field private name type`. + pub field_visibility: Vec, /// The values that `---|` lines list under each of `fields`, with their descriptions. pub field_values: Vec>, pub indices: Vec, @@ -268,6 +272,9 @@ pub struct Index { export_types: FxHashMap>, /// Built on first use after the files change, since every lint of a resource needs them. declarations: OnceLock>, + /// The names of the fields and methods some class keeps from other code, built on first use + /// after the files change. + restricted_names: OnceLock>>, } pub fn normalize_path(path: &Path) -> PathBuf { @@ -381,6 +388,7 @@ impl Index { fn clear_slots(&mut self, id: FileId) { self.declarations.take(); + self.restricted_names.take(); let Some(old) = self.files.get_mut(id as usize).and_then(Option::take) else { return }; remove_file_slots(&mut self.globals, old.index.globals.iter().map(|s| &s.name), id); remove_file_slots(&mut self.members, old.index.members.iter().map(|m| &m.owner), id); @@ -509,6 +517,33 @@ impl Index { .collect() } + /// The members of `owner` called `name` that code in `from` sees, with the file that sets each. + pub fn members_named(&self, owner: &str, name: &str, from: FileId) -> Vec<(FileId, &Member)> { + self.owner_slots(self.members.get(owner), owner, from) + .into_iter() + .filter_map(|(file, i)| Some((file, self.file(file)?.index.members.get(i as usize)?))) + .filter(|(_, member)| member.symbol.name == name) + .collect() + } + + /// The names of the fields and methods that some class declares `private`, `protected` or + /// `package`, so that code using none of them needs no check. + pub fn restricted_names(&self) -> Arc> { + let build = || { + let mut names = FxHashSet::default(); + for (_, file) in self.files() { + for class in &file.index.classes { + let fields = class.fields.iter().zip(&class.field_visibility); + names.extend(fields.filter(|(_, v)| **v != Visibility::Public).map(|(f, _)| f.name.clone())); + } + let members = file.index.members.iter().filter(|m| m.visibility != Visibility::Public); + names.extend(members.map(|m| m.symbol.name.clone())); + } + Arc::new(names) + }; + self.restricted_names.get_or_init(build).clone() + } + /// The array parts and `[key]` entries of the tables `owner` names, visible like its members. pub fn elements_of(&self, owner: &str, from: FileId) -> Vec<&Element> { self.owner_slots(self.elements.get(owner), owner, from) diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index fefd147..299f95d 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -198,6 +198,7 @@ impl<'a> Indexer<'a> { for mut class in doc.classes { let range = self.range(group[class.line.min(group.len() - 1)].span); let field_sides = class.fields.iter().map(|field| field.side).collect(); + let field_visibility = class.fields.iter().map(|field| field.visibility).collect(); let field_values = class.fields.iter_mut().map(|field| std::mem::take(&mut field.values)).collect(); let fields = class .fields @@ -217,6 +218,7 @@ impl<'a> Indexer<'a> { parents: class.parents, fields, field_sides, + field_visibility, field_values, indices: class.indices, literal_fields: class.literal_fields, @@ -269,9 +271,12 @@ impl<'a> Indexer<'a> { own.map_or_else(|| self.infer.is_class_table(name), Symbol::is_class_table) } - fn push_member(&mut self, owner: SmolStr, symbol: Symbol, injected: bool) { + /// Records a member set by the statement or table field whose doc comment starts at `doc_anchor`, + /// which may declare it `---@private`. + fn push_member(&mut self, owner: SmolStr, symbol: Symbol, injected: bool, doc_anchor: Option) { if self.out.members.len() < MAX_MEMBERS_PER_FILE { - self.out.members.push(Member { owner, symbol, injected }); + let visibility = doc_anchor.map(|anchor| self.ctx.doc_at(anchor).visibility).unwrap_or_default(); + self.out.members.push(Member { owner, symbol, injected, visibility }); } } @@ -381,7 +386,7 @@ impl<'a> Indexer<'a> { if symbol.kind == SymbolKind::Variable { symbol.kind = SymbolKind::Field; } - self.push_member(owner.clone(), symbol, false); + self.push_member(owner.clone(), symbol, false, Some(name.span.start)); } let elements = table_elements(fields); if !elements.array.is_empty() { @@ -515,11 +520,40 @@ impl<'a> Indexer<'a> { None => return, } }; - let injected = typed_as_class && owner_path.is_empty() && !self.is_class_table(&name.base); + let mut injected = typed_as_class && owner_path.is_empty() && !self.is_class_table(&name.base); for segment in owner_path { - owner = SmolStr::new(format!("{owner}.{}", segment.text)); + // `function lib.array:map()` below `---@class Array` `lib.array = lib.class('Array')` + // sets a method of the class on its table, as `function Array:map()` would. + owner = match self.class_table_member(&owner, &segment.text) { + Some(class) => { + injected = false; + class + } + None => SmolStr::new(format!("{owner}.{}", segment.text)), + }; + } + self.push_member(owner, symbol, injected, Some(stmt.span.start)); + } + + /// The class whose table the member `name` of `owner` is, when a `---@class` annotation declares + /// it there, preferring this file like `global_class`. The exports of a resource stay under + /// `exports.name`, where what definition files declare for them is looked up. + fn class_table_member(&self, owner: &str, name: &str) -> Option { + if owner == "exports" { + return None; + } + let own = self.out.members.iter().rev().find(|member| member.owner == owner && member.symbol.name == name); + let symbol = match own { + Some(member) => &member.symbol, + None => { + let members = self.infer.index.members_named(owner, name, self.file); + members.into_iter().map(|(_, member)| &member.symbol).find(|symbol| symbol.is_class_table())? + } + }; + match &symbol.ty { + Type::Named(class, _) if symbol.is_class_table() => Some(class.clone()), + _ => None, } - self.push_member(owner, symbol, injected); } fn assignment(&mut self, stmt: &Stmt, target: &Expr, position: usize, value: Option<&Expr>) { @@ -580,7 +614,7 @@ impl<'a> Indexer<'a> { self.out.typed_exports.push(self.out.members.len() as u32); } } - self.push_member(owner, symbol, typed_as_class && !class_table); + self.push_member(owner, symbol, typed_as_class && !class_table, Some(stmt.span.start)); } fn root_is_global(&self, expr: &Expr) -> bool { @@ -684,7 +718,7 @@ impl<'a> Indexer<'a> { literal: None, range: self.range(key.span), }; - self.push_member(owner, symbol, false); + self.push_member(owner, symbol, false, None); } /// The handler a framework or `@callback` registration passes: a function literal typed by the diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index dce1d11..4e7ecf7 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2890,6 +2890,347 @@ print(own, ownLoose, vendor, vendorStrict, api) assert_eq!(lines(&mut client), [7, 13, 15, 16], "the same once the file is open"); } +#[test] +fn private_protected_and_package_members_stay_in_their_class_or_file() { + let mut client = Client::start(fixture_root()); + const SHARED: &str = "myresource/shared/config.lua"; + let shared = "\ +---@class Test.Secret +---@field private hidden integer +---@field protected guarded integer +---@field package pkg integer +---@field shown integer +Secret = {} + +---@param others Test.Secret[] +function Secret:peek(others) + CreateThread(function() + ---@type Test.Secret + local other = others[1] + print(other.hidden) + end) + return self.hidden + self.guarded + self.pkg +end + +---@param other Test.Secret +Secret.copy = function(other) return other.hidden end + +---@private +function Secret:tidy() end + +---@protected +function Secret:prepare() end + +---@class Test.Child : Test.Secret +Child = {} + +function Child:peek() + return self.guarded, self:prepare(), self.hidden +end + +---@class Test.Counter +---@field private count integer +local Counter = { + ---@param self Test.Counter + increment = function(self) + self.count = self.count + 1 + end, +} + +---@param counter Test.Counter +local function reset(counter) + counter.count = 0 +end +print(Counter, reset) + +Classes = {} +---@class Test.Nested : Test.Secret +Classes.nested = {} + +function Classes.nested:tidy() end +Classes.nested.hidden = 1 +"; + let main = "\ +---@type Test.Secret +local secret = Secret +print(secret.shown, secret.hidden, secret.guarded, secret.pkg) +secret.hidden = 1 +secret:tidy() +secret:prepare() +print(Secret.hidden, Secret['guarded'], Child.guarded) +print(Child.hidden) +function secret:hidden() end +---@diagnostic disable-next-line: invisible +print(secret.hidden) +"; + client.open_with(SHARED, shared); + client.open_with(CLIENT, main); + let private = + |field: &str, class: &str| format!("Field `{field}` is private, it can only be accessed in class `{class}`"); + let protected = |field: &str| { + format!("Field `{field}` is protected, it can only be accessed in class `Test.Secret` and its subclasses") + }; + let finding = |line: u64, message: String| ("invisible".to_string(), line, message); + // Closures in a method, functions set on the class table and those its own constructor holds + // are inside the class; a subclass reaches protected members, not private ones, but may set its + // own on its table. + assert_eq!( + findings(&mut client, SHARED, &["invisible"]), + [finding(30, private("hidden", "Test.Secret")), finding(44, private("count", "Test.Counter"))] + ); + // The class table itself reaches its members anywhere, as LuaLS has ox_lib's `lib.array:new()`. + assert_eq!( + findings(&mut client, CLIENT, &["invisible"]), + [ + finding(2, private("hidden", "Test.Secret")), + finding(2, protected("guarded")), + finding(2, "Field `pkg` can only be accessed in same file `shared/config.lua`".into()), + finding(3, private("hidden", "Test.Secret")), + finding(4, private("tidy", "Test.Secret")), + finding(5, protected("prepare")), + finding(7, private("hidden", "Test.Secret")), + finding(8, private("hidden", "Test.Secret")), + ] + ); +} + +#[test] +fn completion_and_missing_fields_leave_out_members_the_code_cannot_use() { + let mut client = Client::start(fixture_root()); + // `|` marks where completion is asked for. + let marked = "\ +---@class Secret +---@field private hidden integer +---@field shown integer +---@type Secret +local s = { shown = 1 } +print(s.hidden) + +---@class Test.Account +---@field private balance number +---@field owner string +local Account = {} + +function Account.new() + ---@type Test.Account + local account = { | } + return account +end + +---@type Test.Account +local outside = { | } +print(outside, s.|) +"; + let text = marked.replace('|', ""); + client.open_with(CLIENT, &text); + let mut cursors = Vec::new(); + for (i, _) in marked.match_indices('|') { + let offset = i - cursors.len(); + let line = text[..offset].matches('\n').count() as u32; + let column = (offset - text[..offset].rfind('\n').map_or(0, |n| n + 1)) as u32; + cursors.push((line, column)); + } + let mut fields = |(line, column): (u32, u32)| -> Vec { + let result = client.request("textDocument/completion", client.position_params(CLIENT, line, column)); + let items = result["items"].as_array().cloned().unwrap_or_default(); + let mut labels: Vec = + items.iter().filter(|item| item["kind"] == 10).map(|item| item["label"].as_str().unwrap().into()).collect(); + labels.sort(); + labels + }; + assert_eq!(fields(cursors[0]), ["balance", "owner"], "its own methods may set the private field"); + assert_eq!(fields(cursors[1]), ["owner"]); + let (line, column) = cursors[2]; + assert_eq!(client.completion_labels(CLIENT, line, column), ["shown"], "the private field of a typed local"); + + let found = findings(&mut client, CLIENT, &["invisible", "missing-fields"]); + let found: Vec<(&str, u64, &str)> = + found.iter().map(|(code, line, message)| (code.as_str(), *line, message.as_str())).collect(); + assert_eq!( + found, + [ + ("missing-fields", 4, "Missing required fields in type `Secret`: `hidden`"), + ("invisible", 5, "Field `hidden` is private, it can only be accessed in class `Secret`"), + ("missing-fields", 14, "Missing required fields in type `Test.Account`: `balance`, `owner`"), + ("missing-fields", 19, "Missing required fields in type `Test.Account`: `balance`, `owner`"), + ], + "a private field is required wherever a table of the class is built, as in LuaLS" + ); +} + +#[test] +fn members_are_restricted_by_the_declaration_of_the_class_the_file_sees() { + let mut client = Client::start(fixture_root()); + // Another resource keeps the members of its own `Test.Vehicle` to itself. + let other = "\ +---@class Test.Vehicle +---@field private new fun(): Test.Vehicle +---@field private secret integer +Vehicle = {} +"; + client.open_with("shop/shared.lua", other); + let marked = "\ +---@class Test.Vehicle +---@field new fun(): Test.Vehicle +---@field secret integer + +---@type Test.Vehicle +local vehicle = { new = function() end } +print(vehicle.new(), vehicle.secret, vehicle.|) +"; + let (line, column) = pos(marked, "|", 0); + client.open_with(CLIENT, &marked.replace('|', "")); + let mut labels = client.completion_labels(CLIENT, line, column); + labels.sort(); + assert_eq!(labels, ["new", "secret"]); + let found = findings(&mut client, CLIENT, &["invisible", "missing-fields"]); + assert_eq!( + found, + [("missing-fields".to_string(), 5, "Missing required fields in type `Test.Vehicle`: `secret`".to_string())] + ); +} + +#[test] +fn members_are_restricted_through_parents_that_name_each_other() { + let mut client = Client::start(fixture_root()); + // Parents that lead back to a class are read once, also where none declares the member. + let text = "\ +---@class Test.Loop1 : Test.Loop2, Test.Loop3, Test.Loop4, Test.Loop5 +---@field private hidden integer +local Loop1 = {} + +---@class Test.Loop2 : Test.Loop3, Test.Loop4, Test.Loop5, Test.Loop1 +---@field protected guarded integer + +---@class Test.Loop3 : Test.Loop4, Test.Loop5, Test.Loop1, Test.Loop2 + +---@class Test.Loop4 : Test.Loop5, Test.Loop1, Test.Loop2, Test.Loop3 + +---@class Test.Loop5 : Test.Loop1, Test.Loop2, Test.Loop3, Test.Loop4 + +---@class Test.Elsewhere +---@field private unrelated integer +---@field protected kept integer + +---@type Test.Loop3 +local loop = {} +print(loop.hidden, loop.guarded, loop.unrelated) + +---@param other Test.Elsewhere +function Loop1:peek(other) + print(self.guarded, other.kept) +end +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |needle: &str, message: &str| ("invisible".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["invisible"]), + [ + finding("print(loop", "Field `hidden` is private, it can only be accessed in class `Test.Loop1`"), + finding( + "print(loop", + "Field `guarded` is protected, it can only be accessed in class `Test.Loop2` and its subclasses" + ), + finding( + "other.kept", + "Field `kept` is protected, it can only be accessed in class `Test.Elsewhere` and its subclasses" + ), + ] + ); +} + +#[test] +fn private_members_complete_in_a_method_that_is_not_closed_yet() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Counter +---@field private count integer +local Counter = {} + +---@private +function Counter:reset() end + +function Counter:increment() + local before = self."; + client.open_with(CLIENT, text); + let line = text.lines().count() as u32 - 1; + let mut labels = client.completion_labels(CLIENT, line, text.lines().last().unwrap().len() as u32); + labels.sort(); + assert_eq!(labels, ["count", "increment", "reset"], "the method runs to the end of the file"); +} + +#[test] +fn renames_fields_declared_with_a_side_or_named_like_a_visibility() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Box +---@field private integer +---@field (client) side integer +---@field private (client) both integer +local Box = {} + +function Box:read() + return self.private, self.side, self.both +end +"; + client.open_with(CLIENT, text); + let cases = [ + ("self.private", "@field private integer", "@field renamed integer"), + ("self.side", "@field (client) side", "@field (client) renamed"), + ("self.both", "@field private (client) both", "@field private (client) renamed"), + ]; + for (used, declared, renamed) in cases { + let expected = text.replace(declared, renamed).replace(used, "self.renamed"); + assert_eq!(renamed_text(&mut client, CLIENT, text, used, 5), expected, "{declared}"); + } +} + +#[test] +fn methods_set_on_a_class_table_at_a_nested_path_belong_to_the_class() { + let mut client = Client::start(fixture_root()); + const SHARED: &str = "myresource/shared/config.lua"; + let shared = "\ +Shared = {} + +---@class Test.Nested +Shared.Nested = {} + +---@private +function Shared.Nested:tidy() end + +function Shared.Nested:size() return 1 end + +function Shared.Nested:clear() + self:tidy() +end +"; + client.open_with(SHARED, shared); + client.diagnostics_for(SHARED); + let marked = "\ +---@type Test.Nested +local nested = {} +print(nested:size()) +nested:tidy() +Shared.Nested:tidy() +print(nested:|) +"; + let (line, column) = pos(marked, "|", 0); + client.open_with(CLIENT, &marked.replace('|', "")); + let mut labels = client.completion_labels(CLIENT, line, column); + labels.sort(); + assert_eq!(labels, ["clear", "size"], "the methods of the class, without the private one"); + assert_eq!(findings(&mut client, SHARED, &["invisible"]), []); + assert_eq!( + findings(&mut client, CLIENT, &["invisible"]), + [( + "invisible".to_string(), + 3, + "Field `tidy` is private, it can only be accessed in class `Test.Nested`".into() + )] + ); +} + #[test] fn no_unknown_reports_names_without_a_type_when_turned_on() { struct Fixture(PathBuf); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 09c2706..374bbc1 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -24,6 +24,20 @@ pub struct DocReturn { pub values: Vec, } +/// Where a field or method of a class may be used: `---@field private name type`, or `---@private` +/// above the function or assignment that sets it. Later variants are more restrictive. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum Visibility { + #[default] + Public, + /// In the file that declares it. + Package, + /// In the class and its subclasses. + Protected, + /// In the class only. + Private, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct DocField { pub name: SmolStr, @@ -34,6 +48,7 @@ pub struct DocField { pub line: usize, /// The side of `@field (server) name type`. pub side: Option, + pub visibility: Visibility, /// The values that the `---|` lines under it list, with their descriptions. pub values: Vec, } @@ -106,6 +121,9 @@ pub struct DocGroup { pub nodiscard: bool, pub is_meta: bool, pub callback: Option, + /// `---@private`, `---@protected` or `---@package` above the function or assignment that sets a + /// member. `---@public` changes nothing, as in LuaLS. + pub visibility: Visibility, } impl DocGroup { @@ -195,14 +213,26 @@ fn split_tag(line: &str) -> Option<(&str, &str)> { Some((&rest[..end], rest[end..].trim_start())) } -/// Removes the `public`, `private`, `protected` and `package` keywords in front of a `@field` name. -fn strip_visibility(mut rest: &str) -> &str { - for scope in ["public ", "private ", "protected ", "package "] { - if let Some(stripped) = rest.strip_prefix(scope) { - rest = stripped.trim_start(); +/// Splits the `public`, `private`, `protected` or `package` keyword off the front of a `@field` +/// name. A keyword that only one word follows, as in `---@field private integer`, is the name +/// itself, the way LuaLS reads it. +fn split_visibility(rest: &str) -> (Visibility, &str) { + let keywords = [ + ("public", Visibility::Public), + ("private", Visibility::Private), + ("protected", Visibility::Protected), + ("package", Visibility::Package), + ]; + for (keyword, visibility) in keywords { + let Some(after) = rest.strip_prefix(keyword).filter(|after| after.starts_with(char::is_whitespace)) else { + continue; + }; + let after = after.trim_start(); + if after.split_whitespace().nth(1).is_some() { + return (visibility, after); } } - rest + (Visibility::Public, rest) } /// The type on a `---| value` line, without its `>` or `+` marker. @@ -467,6 +497,9 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { "nodiscard" => group.nodiscard = true, "meta" => group.is_meta = true, "callback" => group.callback = CallbackTag::parse(rest), + "private" => group.visibility = group.visibility.max(Visibility::Private), + "protected" => group.visibility = group.visibility.max(Visibility::Protected), + "package" => group.visibility = group.visibility.max(Visibility::Package), _ => {} } } @@ -476,11 +509,12 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { } /// The text of a `@field` after its `(server)` attributes and `private` keyword, in either order, -/// with the side the attributes name. -fn field_head(rest: &str) -> (Option, &str) { +/// with the side the attributes name and the visibility the keyword gives. +pub fn field_head(rest: &str) -> (Option, Visibility, &str) { let (before, rest) = split_attributes(rest); - let (after, rest) = split_attributes(strip_visibility(rest)); - (side_attribute(before).or(side_attribute(after)), rest) + let (visibility, rest) = split_visibility(rest); + let (after, rest) = split_attributes(rest); + (side_attribute(before).or(side_attribute(after)), visibility, rest) } /// `ty`, declared for the class `class`, with `self` standing for that class. @@ -495,7 +529,7 @@ fn in_class(class: &DocClass, ty: Type) -> Type { /// last of `fields`, which `---| value` lines after it list more values of. fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { let Some(class) = group.classes.last_mut() else { return false }; - let (side, rest) = field_head(rest); + let (side, visibility, rest) = field_head(rest); if let Some(index) = rest.strip_prefix('[') { let mut parser = TypeParser::new(index); let key = in_class(class, parser.parse()); @@ -503,7 +537,7 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { let value = in_class(class, TypeParser::new(after).parse()); match key { Type::StringLit(name) => { - class.fields.push(DocField { name, ty: value, line, side, ..DocField::default() }); + class.fields.push(DocField { name, ty: value, line, side, visibility, ..DocField::default() }); return true; } key @ (Type::IntLit(_) | Type::BooleanLit(_)) => { @@ -518,15 +552,9 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { let optional = parser.rest().starts_with('?'); let mut parser = TypeParser::new(parser.rest().trim_start_matches('?')); let ty = in_class(class, parser.parse()); - let field = DocField { - name: SmolStr::new(name), - ty, - optional, - description: clean_description(parser.rest()), - line, - side, - values: Vec::new(), - }; + let description = clean_description(parser.rest()); + let field = + DocField { name: SmolStr::new(name), ty, optional, description, line, side, visibility, values: Vec::new() }; let Some(field) = add_signature(&mut class.fields, field) else { return false }; class.fields.push(field); true @@ -731,7 +759,7 @@ pub fn declared_name(line: &str) -> Option<(usize, &str)> { "param" if rest.starts_with("...") => return Some((line.len() - rest.len(), "...")), "param" => rest, "field" => { - let rest = field_head(rest).1; + let rest = field_head(rest).2; if let Some(key) = rest.strip_prefix('[') { let mut parser = TypeParser::new(key); parser.parse(); @@ -787,7 +815,7 @@ fn type_names(line: &str) -> Option<(&str, Vec>)> { } } "field" => { - let (_, rest) = field_head(rest); + let (_, _, rest) = field_head(rest); let value = match rest.strip_prefix('[') { Some(key) => names.ty(key).trim_start().strip_prefix(']'), None => skip_name(rest), @@ -1105,6 +1133,35 @@ mod tests { assert!(!applies_on(Some(Side::Server), Some(Side::Client))); } + #[test] + fn visibility_keywords_and_tags() { + use Visibility::*; + let doc = parse( + "---@class Bank\n---@field private balance number\n---@field protected (server) owner string\n---@field (client) package id integer\n---@field public name string\n---@field private integer\n---@field private private { pin: string }\n---@field private ['quoted-key'] number", + ); + let fields: Vec<(&str, Visibility, Option)> = + doc.classes[0].fields.iter().map(|f| (f.name.as_str(), f.visibility, f.side)).collect(); + assert_eq!( + fields, + [ + ("balance", Private, None), + ("owner", Protected, Some(Side::Server)), + ("id", Package, Some(Side::Client)), + ("name", Public, None), + ("private", Public, None), + ("private", Private, None), + ("quoted-key", Private, None), + ] + ); + assert_eq!(doc.classes[0].fields[4].ty.to_string(), "integer", "a keyword that one word follows is the name"); + + let visibility = |text: &str| parse(text).visibility; + assert_eq!(visibility("---@private\n---@param value string"), Private); + assert_eq!(visibility("---@package\n---@protected"), Protected, "the most restrictive tag wins"); + assert_eq!(visibility("---@public"), Public); + assert_eq!(visibility("---@param value string"), Public); + } + #[test] fn repeated_function_fields_become_signatures() { let doc = parse( diff --git a/docs/reference.md b/docs/reference.md index 01f7093..38b7911 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -368,6 +368,55 @@ classes of third-party resources stay as they are unless they say `(strict)` the class is declared in several places, one `(strict)` makes it strict, and otherwise one `(loose)` keeps it loose. +## Member visibility + +A class can keep fields and methods to itself, as in LuaLS: + +```lua +---@class Account +---@field private balance number +---@field owner string +local Account = {} + +---@private +function Account:audit() end + +function Account:deposit(amount) + self.balance = self.balance + amount +end + +---@type Account +local account = Account +print(account.balance) -- invisible +account:audit() -- invisible +``` + +`---@field private`, `protected` and `package` mark a field. `---@private`, `---@protected` and +`---@package` above `function Account:name()`, `Account.name = value` or `self.name = value` mark +what the statement sets, and `---@public` changes nothing. qbx-lua-ls reports `invisible` where +code reads, sets or calls such a member, or defines it with `function value:name()`, outside where +it may: + +- A private member is used through the table the `---@class` annotation declares, like + `Account.balance` or ox_lib's `lib.array:new()`, from anywhere, or inside a function defined on + that table: `function Account:name()`, `function Account.name()`, `Account.name = function() end` + or a function written in the table's own constructor, with the functions nested in it. +- A protected member is also used through the table of a subclass, or inside its functions. +- A package member is used in the file that declares it. + +The nearest class that declares a name decides, and one restrictive declaration there makes the +member restrictive, so a subclass that declares the name again without a keyword makes it public. +Setting a member on the table of a class declares it for that class, so a subclass may define a +member its parent keeps private, as ox_lib's `function lib.ped:__index()` does. +Keys of table constructors are not checked, and `missing-fields` asks for private and protected +fields wherever a table of the class is built, as LuaLS does. Completion leaves such members out +where the code may not use them. + +Unlike LuaLS, functions nested in a method, such as a `table.sort` comparator or a `CreateThread` +body, and functions written in the class table's constructor count as inside the class. LuaLS's +`doc.privateName`, `doc.protectedName` and `doc.packageName` settings, which make fields private by +their names, are not read. + ## Return values A function documented with `@return` has to return values of those types. That includes a diff --git a/docs/rules.md b/docs/rules.md index e900b78..d7adec6 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -37,6 +37,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | +| `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. Reported by qbx-lua-ls only. | | `missing-return` | warning | A function with a required `@return` value can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | | `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | From a09a859372339d7fec5a0c9381f32d30319b3018 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:51:23 +0200 Subject: [PATCH 019/193] feat(completion): offer the members of enums --- crates/qbx_lua_ls/README.md | 3 +- crates/qbx_lua_ls/docs/protocol.md | 19 ++- crates/qbx_lua_ls/src/features/completion.rs | 125 +++++++++++++++- crates/qbx_lua_ls/src/index.rs | 11 ++ crates/qbx_lua_ls/src/indexer.rs | 60 +++++--- crates/qbx_lua_ls/tests/lsp.rs | 142 +++++++++++++++++++ 6 files changed, 325 insertions(+), 35 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 19807cb..2ab170d 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -26,7 +26,8 @@ Available features depend on the editor's LSP client. an argument takes one. Call snippets stop in the quotes of listed strings, and end after a value that picks an overload, so the handler written next matches it. The `# description` of a value on a `---|` line under an `@alias` or `@param` comes with it. Hovers of the alias, or of the - function for its `@param`s and `@return`s, list the described values. + function for its `@param`s and `@return`s, list the described values. The members of an + `---@enum` come before its values, written as the code reaches its table, like `Colors.Red`. - The same values where a value of such a type, like `"busy"|"ready"`, `1|2|3` or `boolean`, is assigned, set as a field of a class, returned or compared with `==` or `~=`, and the function literal where a function is assigned or returned. They open on the space typed after the `=`, diff --git a/crates/qbx_lua_ls/docs/protocol.md b/crates/qbx_lua_ls/docs/protocol.md index 9e9bd49..953be2a 100644 --- a/crates/qbx_lua_ls/docs/protocol.md +++ b/crates/qbx_lua_ls/docs/protocol.md @@ -98,20 +98,25 @@ Where an argument starts, right after the `(` or `,` in front of it, completion be. String values that its parameter lists are offered quoted, with the value as filter text. Integers are offered as they are, and so are `true` and `false`, which a `boolean` lists too. A type that lists values also lists the `nil` it allows, last; a type such as `string?` lists -nothing. Snippet clients also get a function literal labelled like `function(source)`, with the -filter text `function`, when the argument takes a function. Values come from every signature that +nothing. The members of an `---@enum` that the parameter names come before its values, as +`EnumMember` items whose detail is the value, written the way the code reaches the enum's table: +`Colors.Red` for `---@enum Color` above `Colors = { Red = 1 }`, or through a `local` in scope that +holds the table, contains it or holds the module that returns it. An enum whose table no name in +scope holds, such as a `local` of another file, offers only its values, and a `(key)` enum only +its keys, which are its values. Snippet clients also get a function literal labelled like +`function(source)`, with the filter text `function`, when the argument takes a function. Values come from every signature that the arguments before it fit, with `nil` after those of all of them, and the function's parameters from the signature they fit best, so `OnAction("playerUnloaded", ` picks the `@overload` that lists `"playerUnloaded"`. Signatures that fit equally well each get an item. `(` and `,` are completion trigger characters for these items only: a request whose `context.triggerCharacter` is `(` or `,` returns nothing else, leaves out -the values of a parameter that lists no string or integer, as the many `boolean` parameters do, -and returns `null` where nothing is left and the argument takes no function. Inside a string -argument, the string values replace the string's contents. +the values of a parameter that lists no enum member, string or integer, as the many `boolean` +parameters do, and returns `null` where nothing is left and the argument takes no function. Inside +a string argument, the string values replace the string's contents. Where another value starts, right after an `=`, `==`, `~=` or `return` on its line, completion -offers the values that its type lists in the same way, and snippet clients get the function -literal where a function is stored or returned, though not where one is compared. The type is the +offers the enum members and values that its type lists in the same way, and snippet clients get +the function literal where a function is stored or returned, though not where one is compared. The type is the `---@type` above the `local` or assignment, what the assigned local or class field is declared as, the `@field` that a table typed as a class sets, the `@return` of the function, or what the other side of the comparison is declared as. A local typed by `---@type` or `@param` keeps that type diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index be4d4d8..5b0314a 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -21,7 +21,7 @@ use super::visibility::Scope; use super::{lua_block, markdown, with_infer}; use crate::callback_wrappers::{families, takes_function, Wrapper}; use crate::document::Document; -use crate::index::{EventFamily, EventKind, FileOrigin, SymbolKind}; +use crate::index::{EnumTable, EventFamily, EventKind, FileOrigin, SymbolKind}; use crate::infer::{native_fun_type, Infer, MemberInfo}; use crate::locate::{locate, string_content_span}; use crate::types::{CallbackRole, DescribedValue, FunType, Param, Type}; @@ -411,6 +411,12 @@ fn is_identifier(text: &str) -> bool { && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } +/// Whether code may write `text` after a `.`: an identifier that is no keyword, unlike the `nil` of +/// `Types['nil']`. +fn is_name(text: &str) -> bool { + is_identifier(text) && !KEYWORDS.contains(&text) +} + fn identifier_prefix(before: &str) -> &str { let start = before.rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).map_or(0, |i| i + 1); &before[start..] @@ -567,11 +573,24 @@ fn argument_items( let signatures = infer.open_call_signatures(&fun, args, via_method, site.base.span.start); let name = callee_name(site.base, site.method); let mut literals = argument_literals(infer, &signatures, argument, via_method, &name); - if typed && !literals.iter().any(|literal| matches!(literal.value, Type::StringLit(_) | Type::IntLit(_))) { + let mut members: Vec = Vec::new(); + for (signature, _) in &signatures { + let Some(param) = param_for_argument(signature, argument, via_method) else { continue }; + for member in enum_members(infer, doc, ¶m.ty, offset, quote) { + if !members.iter().any(|known| known.written == member.written) { + members.push(member); + } + } + } + let lists = !members.is_empty() + || literals.iter().any(|literal| matches!(literal.value, Type::StringLit(_) | Type::IntLit(_))); + if typed && !lists { literals.clear(); } let mut items: Vec = - literals.iter().enumerate().map(|(i, literal)| literal.written_item(i, quote, space)).collect(); + members.iter().enumerate().map(|(i, member)| member.written_item(i, space)).collect(); + let listed = literals.iter().enumerate(); + items.extend(listed.map(|(i, literal)| literal.written_item(members.len() + i, quote, space))); if !snippets { return items; } @@ -637,9 +656,12 @@ fn value_items( let space = if typed.len() == head.len() { " " } else { "" }; with_infer(ws, doc, |infer| { let Some(expected) = expected_type(infer, &doc.chunk, Place::After(at)) else { return Vec::new() }; + let members = enum_members(infer, doc, &expected.ty, offset, quote); let values = listed_values(infer, &expected.ty, &expected.values); let mut items: Vec = - values.iter().enumerate().map(|(i, value)| value.written_item(i, quote, space)).collect(); + members.iter().enumerate().map(|(i, member)| member.written_item(i, space)).collect(); + let listed = values.iter().enumerate(); + items.extend(listed.map(|(i, value)| value.written_item(members.len() + i, quote, space))); if snippets && !expected.compared { let ty = infer.resolve_alias(&expected.ty.without_nil()); items.extend(function_literal_item(&ty, &expected.ty.to_string(), space)); @@ -1026,6 +1048,101 @@ fn described(values: &[DescribedValue], value: &Type) -> Option { listed.map(|listed| listed.description.clone()) } +/// A field of the table of an `---@enum`, written as the code reaches it, like `Colors.Red`. +struct EnumMember { + written: String, + value: Type, +} + +impl EnumMember { + /// The item for the `index`th member, written after `space`. Members come before the values. + fn written_item(&self, index: usize, space: &str) -> CompletionItem { + let mut out = item(&self.written, CompletionItemKind::ENUM_MEMBER, 0); + out.sort_text = Some(format!("{index:04}")); + out.detail = Some(self.value.to_string()); + out.filter_text = Some(self.written.clone()); + out.insert_text = Some(format!("{space}{}", self.written)); + out + } +} + +/// The fields of the tables of the `---@enum`s that `ty` names, through unions and aliases, as the +/// code at `offset` reaches them: `Colors.Red` for `---@enum Color` above `local Colors = { Red = 1 }`. +/// A table that no name in scope holds, such as a local of another file, gives none, and neither +/// does a `(key)` enum, whose values are its keys. +fn enum_members(infer: &Infer, doc: &Document, ty: &Type, offset: u32, quote: char) -> Vec { + let mut tables = Vec::new(); + enum_tables(infer, ty, &mut tables, 0); + let mut out = Vec::new(); + for table in tables { + let Some(path) = path_to_table(infer, doc, &table.owner, offset) else { continue }; + for (key, value) in &table.members { + let written = match key { + Type::StringLit(name) if is_name(name) => format!("{path}.{name}"), + Type::StringLit(name) => format!("{path}[{quote}{}{quote}]", escaped_string(name, quote)), + Type::IntLit(_) | Type::BooleanLit(_) => format!("{path}[{key}]"), + _ => continue, + }; + out.push(EnumMember { written, value: value.clone() }); + } + } + out +} + +fn enum_tables(infer: &Infer, ty: &Type, out: &mut Vec>, depth: u32) { + if depth > 8 { + return; + } + match ty { + Type::Union(types) => types.iter().for_each(|part| enum_tables(infer, part, out, depth + 1)), + Type::Named(name, _) => { + // The enums the file sees, as for the names of classes: another resource's `---@enum` + // of the same name is another table. + let classes = Classes::new(infer); + if !classes.class_defs(name, infer.ctx.file).is_empty() { + return; + } + for (_, alias) in classes.alias_defs(name, infer.ctx.file) { + match &alias.table { + Some(table) if !out.iter().any(|known| known.owner == table.owner) => out.push(table.clone()), + Some(_) => {} + None => enum_tables(infer, &alias.ty, out, depth + 1), + } + } + } + _ => {} + } +} + +/// How the code at `offset` reaches the table whose fields are indexed under `owner`: a global path +/// that no local hides and the file sees, like `Colors` or `Config.Colors`, or a local in scope that +/// holds the table or one that contains it, like `Jobs` after `local Jobs = require 'shared.jobs'`. +/// A global of another resource or of the other side is no path, since `undefined-global` reports it. +fn path_to_table(infer: &Infer, doc: &Document, owner: &str, offset: u32) -> Option { + let table = Type::GlobalTable(SmolStr::new(owner)); + let root = owner.split('.').next().unwrap_or_default(); + let global = !owner.starts_with('%') + && doc.resolution.lookup_local_at(root, offset).is_none() + && !infer.index.globals_named(root, doc.file).is_empty(); + if global && type_of_path(infer, owner, offset) == table { + return Some(owner.to_string()); + } + let mut locals: Vec<_> = doc.resolution.locals_visible_at(offset).collect(); + locals.sort_by_key(|(_, local)| std::cmp::Reverse(local.visible_from)); + let mut seen = FxHashSet::default(); + for (id, local) in locals { + if local.name.is_empty() || !seen.insert(local.name.clone()) { + continue; + } + let Type::GlobalTable(held) = infer.resolve_alias(&infer.local_type_at(id, offset)) else { continue }; + let Some(rest) = owner.strip_prefix(held.as_str()) else { continue }; + if rest.is_empty() || (rest.starts_with('.') && rest[1..].split('.').all(is_name)) { + return Some(format!("{}{rest}", local.name)); + } + } + None +} + /// The values that the parameter of the argument at `arg_index` lists in `signatures`, in the /// order they are declared, with the `nil` that any of them allows last: the `"playerLoaded"` of /// `action: "playerLoaded"|"playerUnloaded"|string` or of an alias, and the `"keyPressed"` of an diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index f96146d..8690409 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -124,6 +124,17 @@ pub struct AliasDef { pub side: Option, /// The values that the `---|` lines under `@alias` list, with their descriptions. pub values: Vec, + /// The table an `@enum` declares, unless it is a `(key)` enum, whose values are the keys. + pub table: Option>, +} + +/// The table an `---@enum` annotation declares. +#[derive(Debug)] +pub struct EnumTable { + /// The owner its fields are indexed under, which the type of a name that holds it names. + pub owner: SmolStr, + /// The key of each field it is written with, and the value, in declaration order. + pub members: Vec<(Type, Type)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 299f95d..96bb510 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -12,8 +12,8 @@ use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use crate::callback_wrappers::Wrapper; use crate::index::{ - AliasDef, ClassDef, Element, EventDef, EventFamily, EventKind, FileId, FileIndex, Index, Member, NuiCallbackDef, - Symbol, SymbolKind, + AliasDef, ClassDef, Element, EnumTable, EventDef, EventFamily, EventKind, FileId, FileIndex, Index, Member, + NuiCallbackDef, Symbol, SymbolKind, }; use crate::infer::{table_elements, table_fields, FileContext, Infer}; use crate::luacats::{parse_doc_lines, DocGroup}; @@ -238,6 +238,7 @@ impl<'a> Indexer<'a> { range, side: alias.side, values: alias.values, + table: None, }); } } @@ -311,7 +312,8 @@ impl<'a> Indexer<'a> { let fields = table_fields(expr).unwrap_or_default(); kind = SymbolKind::Table; if let Some(enum_name) = &doc.enum_name { - self.enum_class(enum_name.clone(), doc.enum_keys, doc.enum_side, fields, name.span); + let (keys, side) = (doc.enum_keys, doc.enum_side); + self.enum_class(enum_name.clone(), keys, side, fields, name.span, nested_owner); } self.table_members(SmolStr::new(nested_owner), fields, table_depth + 1); Type::GlobalTable(SmolStr::new(nested_owner)) @@ -351,18 +353,35 @@ impl<'a> Indexer<'a> { (is_literal && text.len() <= 48 && !text.contains('\n')).then(|| SmolStr::new(text)) } - fn enum_class(&mut self, name: SmolStr, keys: bool, side: Option, fields: &[TableField], span: Span) { - let values: Vec = fields - .iter() - .filter_map(|f| match f { - TableField::Named { name: key, .. } if keys => Some(Type::StringLit(key.text.clone())), - TableField::Keyed { key, .. } if keys => Some(self.infer.expr(key)), - TableField::Named { value, .. } | TableField::Keyed { value, .. } => Some(self.infer.expr(value)), - _ => None, - }) - .collect(); + /// Declares the alias of an `---@enum` above a table constructor whose fields are indexed under + /// `owner`: the union of its values, or with `keys` of its keys. + fn enum_class( + &mut self, + name: SmolStr, + keys: bool, + side: Option, + fields: &[TableField], + span: Span, + owner: &str, + ) { + let mut members = Vec::new(); + for field in fields { + let (key, value) = match field { + TableField::Named { name: key, value } => (Type::StringLit(key.text.clone()), value), + TableField::Keyed { key, value } => (self.infer.expr(key), value), + _ => continue, + }; + let value = if keys { None } else { Some(self.infer.expr(value)) }; + members.push((key, value)); + } + let values = members.iter().map(|(key, value)| value.clone().unwrap_or_else(|| key.clone())); + let ty = Type::union(values); + let table = (!keys).then(|| { + let members = members.into_iter().filter_map(|(key, value)| Some((key, value?))).collect(); + Arc::new(EnumTable { owner: SmolStr::new(owner), members }) + }); let range = self.range(span); - self.out.aliases.push(AliasDef { name, ty: Type::union(values), doc: None, range, side, values: Vec::new() }); + self.out.aliases.push(AliasDef { name, ty, doc: None, range, side, values: Vec::new(), table }); } fn push_element(&mut self, owner: SmolStr, key: Option, value: Type) { @@ -432,13 +451,8 @@ impl<'a> Indexer<'a> { None => self.ctx.local_owner_key(name.name.span.start), }; if let Some(enum_name) = &doc.enum_name { - self.enum_class( - enum_name.clone(), - doc.enum_keys, - doc.enum_side, - fields, - name.name.span, - ); + let (keys, side) = (doc.enum_keys, doc.enum_side); + self.enum_class(enum_name.clone(), keys, side, fields, name.name.span, &owner); } self.table_members(owner, fields, 1); } @@ -634,10 +648,10 @@ impl<'a> Indexer<'a> { Some(fields) => { // `---@enum Name` above `return { ... }` declares the enum a module exports. let doc = self.ctx.doc_at(stmt_start); + let owner = SmolStr::new(format!("%mod{}", self.file)); if let Some(enum_name) = &doc.enum_name { - self.enum_class(enum_name.clone(), doc.enum_keys, doc.enum_side, fields, first.span); + self.enum_class(enum_name.clone(), doc.enum_keys, doc.enum_side, fields, first.span, &owner); } - let owner = SmolStr::new(format!("%mod{}", self.file)); self.table_members(owner.clone(), fields, 1); Type::GlobalTable(owner) } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 4e7ecf7..8eaf90e 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6817,6 +6817,148 @@ function SetMode(mode) end assert!(snippets.is_empty(), "{snippets:?}"); } +#[test] +fn enum_members_come_before_their_values_where_the_table_is_reachable() { + let mut client = Client::start(fixture_root()); + let defs = "\ +---@enum Color +Colors = { Red = 1, Green = 2, ['Dark Blue'] = 3 } + +---@enum (key) Key +Keys = { Alpha = 1, Beta = 2 } + +---@enum (server) Job +Jobs = { Police = 'police' } + +---@enum Size +local Sizes = { Small = 1 } +print(Sizes) + +---@class Car +---@field color Color + +---@param color Color +function Paint(color) end + +---@param key Key +function Press(key) end + +---@param job Job +function Hire(job) end + +---@param size Size +function Resize(size) end + +---@param weather Weather +function Forecast(weather) end +"; + client.open_with("myresource/shared/config.lua", defs); + client.open_with("myresource/modules/weather.lua", "---@enum Weather\nreturn { Sun = 'sun', Rain = 'rain' }\n"); + // `|` marks the cursor; `trigger` is the character typed to ask for completions. The listed items + // come back in the order their `sortText` gives, as label and inserted text. + let mut values = |file: &str, typed: &str, trigger: Option<&str>| -> Vec<(String, String)> { + let (line, column) = pos(typed, "|", 0); + client.open_with(file, &typed.replace('|', "")); + let mut params = client.position_params(file, line, column); + if let Some(trigger) = trigger { + params["context"] = json!({ "triggerKind": 2, "triggerCharacter": trigger }); + } + let result = client.request("textDocument/completion", params); + let mut items = result["items"].as_array().cloned().unwrap_or_default(); + items.retain(|item| { + let sort = item["sortText"].as_str().unwrap_or_default(); + sort.len() == 4 && sort.bytes().all(|b| b.is_ascii_digit()) + }); + items.sort_by_key(|item| item["sortText"].as_str().unwrap_or_default().to_string()); + let written = |item: &Value| item["insertText"].as_str().unwrap_or_default().to_string(); + items.iter().map(|item| (item["label"].as_str().unwrap().to_string(), written(item))).collect() + }; + let listed = |values: &[&str], space: &str| -> Vec<(String, String)> { + values.iter().map(|value| (value.to_string(), format!("{space}{value}"))).collect() + }; + let colors = ["Colors.Red", "Colors.Green", "Colors['Dark Blue']", "1", "2", "3"]; + + // Where a value of the enum's type starts: an argument, a `---@type` local, a field of a class, + // a `return` and a comparison. + assert_eq!(values(CLIENT, "Paint(|)", Some("(")), listed(&colors, "")); + assert_eq!(values(CLIENT, "---@type Color\nlocal color =|", None), listed(&colors, " ")); + assert_eq!(values(CLIENT, "---@type Color\nlocal color = |", Some(" ")), listed(&colors, "")); + assert_eq!(values(CLIENT, "---@type Car\nlocal car = { color = | }", None), listed(&colors, "")); + assert_eq!(values(CLIENT, "---@type Car\nlocal car\ncar.color = |", None), listed(&colors, "")); + assert_eq!(values(CLIENT, "---@return Color\nlocal function pick()\n\treturn |\nend", None), listed(&colors, "")); + let compared = "---@type Color\nlocal color = Colors.Red\nif color == | then end"; + assert_eq!(values(CLIENT, compared, Some(" ")), listed(&colors, "")); + + // The members are written as the code reaches the table: a local of this file, one that holds a + // module's table or contains the table, but not a local of another file or a hidden global. + let speeds = "---@enum Speed\nlocal Speeds = { Slow = 1 }\n---@param speed Speed\nlocal function go(speed) end\n"; + assert_eq!(values(CLIENT, &format!("{speeds}go(|)"), Some("(")), listed(&["Speeds.Slow", "1"], "")); + let modes = "local Config = {}\n---@enum Mode\nConfig.Modes = { On = 1 }\n---@param mode Mode\nlocal function set(mode) end\n"; + assert_eq!(values(CLIENT, &format!("{modes}set(|)"), Some("(")), listed(&["Config.Modes.On", "1"], "")); + let weather = "local Weather = require 'modules.weather'\nForecast(|)"; + assert_eq!(values(CLIENT, weather, Some("(")), listed(&["Weather.Sun", "Weather.Rain", "'sun'", "'rain'"], "")); + assert_eq!(values(CLIENT, "Resize(|)", Some("(")), listed(&["1"], "")); + assert_eq!(values(CLIENT, "local Colors = 5\nPaint(|)", Some("(")), listed(&["1", "2", "3"], "")); + + // A `(key)` enum lists its keys, which are its values, and a `(server)` one only on that side. + assert_eq!(values(CLIENT, "Press(|)", Some("(")), listed(&["'Alpha'", "'Beta'"], "")); + assert_eq!(values(SERVER, "Hire(|)", Some("(")), listed(&["Jobs.Police", "'police'"], "")); + assert_eq!(values(CLIENT, "Hire(|)", Some("(")), Vec::new()); + + // Members are enum members that show their value. + let (line, column) = pos("Paint(|)", "|", 0); + client.open_with(CLIENT, "Paint()"); + let result = client.request("textDocument/completion", client.position_params(CLIENT, line, column)); + let red = result["items"].as_array().unwrap().iter().find(|item| item["label"] == "Colors.Red").cloned(); + assert_eq!(red.map(|item| (item["kind"].clone(), item["detail"].clone())), Some((json!(20), json!("1")))); +} + +#[test] +fn enum_members_are_written_through_globals_the_file_sees_and_brackets_for_keywords() { + let mut client = Client::start(fixture_root()); + client.open_with("shop/shared.lua", "---@enum Test.Group\nGroups = { Job = 'job' }\n"); + let defs = "\ +---@enum Test.Kind +Kinds = { ['nil'] = 'nil', ['end'] = 'end', ok = 'ok' } + +---@param group Test.Group +function Join(group) end + +---@param grade Test.Grade +function Promote(grade) end + +---@param kind Test.Kind +function Sort(kind) end +"; + client.open_with("myresource/shared/config.lua", defs); + // `|` marks the cursor of a completion that `(` asks for; the listed items come back as the text + // they insert, in the order their `sortText` gives. + let mut values = |file: &str, typed: &str| -> Vec { + let (line, column) = pos(typed, "|", 0); + client.open_with(file, &typed.replace('|', "")); + let mut params = client.position_params(file, line, column); + params["context"] = json!({ "triggerKind": 2, "triggerCharacter": "(" }); + let result = client.request("textDocument/completion", params); + let mut items = result["items"].as_array().cloned().unwrap_or_default(); + items.retain(|item| { + let sort = item["sortText"].as_str().unwrap_or_default(); + sort.len() == 4 && sort.bytes().all(|b| b.is_ascii_digit()) + }); + items.sort_by_key(|item| item["sortText"].as_str().unwrap_or_default().to_string()); + items.iter().map(|item| item["insertText"].as_str().unwrap_or_default().to_string()).collect() + }; + + // The global table of an enum that another resource, or a script of the other side, declares is + // undefined here, so only its values are listed. + assert_eq!(values(CLIENT, "Join(|)"), ["'job'"]); + let grades = "---@enum Test.Grade\nGrades = { Boss = 'boss' }\nPromote(|)"; + assert_eq!(values(SERVER, grades), ["Grades.Boss", "'boss'"]); + assert_eq!(values(CLIENT, "Promote(|)"), ["'boss'"]); + + // A key that is a keyword cannot follow a `.`. + assert_eq!(values(CLIENT, "Sort(|)"), ["Kinds['nil']", "Kinds['end']", "Kinds.ok", "'nil'", "'end'", "'ok'"]); +} + #[test] fn call_snippets_leave_listed_values_to_the_list() { let defs = "\ From c619c9b10b67f1e56c995f240185193bea0a17fe Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:52:27 +0200 Subject: [PATCH 020/193] feat(definition): go to type definition --- crates/qbx_lua_ls/README.md | 3 + .../qbx_lua_ls/src/features/class_tables.rs | 6 +- crates/qbx_lua_ls/src/features/definition.rs | 162 +++++++++++++++++- crates/qbx_lua_ls/src/index.rs | 10 ++ crates/qbx_lua_ls/src/server.rs | 6 + crates/qbx_lua_ls/tests/lsp.rs | 129 ++++++++++++++ 6 files changed, 313 insertions(+), 3 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 2ab170d..f396381 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -40,6 +40,9 @@ Available features depend on the editor's LSP client. - Definitions, references and rename for locals, globals and fields, including static string keys such as `Config['name']` and supported `---@field` declarations. - Hover and definitions for the classes, aliases and enums named in LuaCATS annotations. +- Go to type definition: the `---@class`, `---@alias` or `---@enum` of a value's type, through + unions, arrays, `table` and what a function returns, and the classes an alias of a union + stands for. - Exports typed by LuaLS definitions, as `---@type PhoneExports` above `exports.phone = {}`, or `---@class qbx_core` above `exports.qbx_core = {}` with `function exports.qbx_core:GetCid()` methods: `exports.phone` and `exports['phone']` take the members of the declared type before the diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 490c1bd..38df3ee 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -146,7 +146,11 @@ impl<'a, 'b> Classes<'a, 'b> { /// sees none, those of the one resource that declares the name count; several resources make it /// ambiguous. #[allow(clippy::type_complexity)] - fn declarations(&self, name: &str, from: FileId) -> (Vec<(FileId, &'b ClassDef)>, Vec<(FileId, &'b AliasDef)>) { + pub(crate) fn declarations( + &self, + name: &str, + from: FileId, + ) -> (Vec<(FileId, &'b ClassDef)>, Vec<(FileId, &'b AliasDef)>) { let index = self.infer.index; let side = self.infer.side(); let mut classes = index.class_defs(name); diff --git a/crates/qbx_lua_ls/src/features/definition.rs b/crates/qbx_lua_ls/src/features/definition.rs index 43c52ed..268cd59 100644 --- a/crates/qbx_lua_ls/src/features/definition.rs +++ b/crates/qbx_lua_ls/src/features/definition.rs @@ -1,12 +1,18 @@ +use std::cell::OnceCell; + use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; -use qbx_lua_syntax::ast::ExprKind; +use qbx_lua_syntax::ast::{ExprKind, StmtKind}; +use qbx_lua_syntax::SmolStr; +use super::class_tables::Classes; use super::hover::{event_string_context, target_at, Target}; use super::with_infer; use crate::document::Document; use crate::index::{EventFamily, EventKind, FileId, FileOrigin}; -use crate::infer::Infer; +use crate::infer::{Decl, Infer}; use crate::locate::locate; +use crate::luacats::applies_on; +use crate::types::Type; use crate::workspace::Workspace; const REQUIRE_CALLS: &[&str] = &["require", "lib.require", "lib.load"]; @@ -114,3 +120,155 @@ pub fn definition(ws: &Workspace, doc: &Document, position: Position) -> Option< }); (!locations.is_empty()).then_some(GotoDefinitionResponse::Array(locations)) } + +/// The declarations of the type of the value under the cursor: the `---@class`, `---@alias` or +/// `---@enum` of each class and alias it names, through `?`, unions, arrays, the keys and values of +/// `table` and the arguments of generic classes, and the `---@enum` of an enum's own table. A +/// function is typed by what it returns, so `GetPlayer` in `GetPlayer()` leads to the class of the +/// player. Built-in types, the handles of natives such as `Vehicle`, which are integers, and tables +/// without a class have none. +pub fn type_definition(ws: &Workspace, doc: &Document, position: Position) -> Option { + let offset = doc.offset(position); + let locations = with_infer(ws, doc, |infer| { + let target = target_at(infer, doc, offset)?; + let origin = OnceCell::new(); + let ty = match &target { + Target::Local(id, _) => infer.local_type_at(*id, offset), + Target::Global(name, _) => infer.global_type(name), + Target::Member { info, .. } => info.ty.clone(), + Target::Type(name, _) => Type::Named(name.clone(), Vec::new()), + }; + let mut named = Vec::new(); + named_types(infer, &ty, &mut named, 0); + let classes = Classes::new(infer); + let mut locations: Vec = Vec::new(); + for named in named { + let ranges: Vec<(FileId, Range)> = match named { + Named::Type(name) => { + let (mut found_classes, mut found_aliases) = classes.declarations(&name, doc.file); + // A name the file cannot tell apart is read the way the file the value comes from + // sees it: qbx_core's `Player` for what its `GetPlayer` export returns. + if found_classes.is_empty() && found_aliases.is_empty() { + if let Some(origin) = *origin.get_or_init(|| origin_file(ws, infer, doc, &target, 0)) { + (found_classes, found_aliases) = classes.declarations(&name, origin); + } + } + // Declarations that neither sees, or that several resources declare, are all shown. + if found_classes.is_empty() && found_aliases.is_empty() { + found_classes = ws.index.class_defs(&name); + found_classes.retain(|(_, class)| applies_on(class.side, infer.side())); + found_aliases = ws.index.alias_defs(&name); + found_aliases.retain(|(_, alias)| applies_on(alias.side, infer.side())); + } + let ranges = found_classes.iter().map(|(file, class)| (*file, class.range)); + ranges.chain(found_aliases.iter().map(|(file, alias)| (*file, alias.range))).collect() + } + Named::EnumTable(owner) => { + let mut enums = ws.index.enums_of_table(&owner); + enums.retain(|(_, alias)| applies_on(alias.side, infer.side())); + if enums.iter().any(|(file, _)| ws.index.is_visible(doc.file, *file)) { + enums.retain(|(file, _)| ws.index.is_visible(doc.file, *file)); + } + enums.iter().map(|(file, alias)| (*file, alias.range)).collect() + } + }; + for found in ranges.into_iter().filter_map(|(file, range)| location(ws, file, range)) { + if !locations.contains(&found) { + locations.push(found); + } + } + } + Some(locations) + })?; + (!locations.is_empty()).then_some(GotoDefinitionResponse::Array(locations)) +} + +/// The file whose view of the type names the value under the cursor comes with: the one that +/// declares the member or global it is, or what a local is set to, like the `qbx_core` export of +/// `local player = exports.qbx_core:GetPlayer(source)`. +fn origin_file(ws: &Workspace, infer: &Infer, doc: &Document, target: &Target, depth: u32) -> Option { + match target { + Target::Member { info, .. } => info.location.map(|(file, _)| file), + Target::Global(name, _) => ws.index.globals_named(name, doc.file).first().map(|(file, _)| *file), + Target::Local(id, _) if depth < 4 => { + let local = infer.ctx.resolution.local(*id); + let Some(Decl::Local { stmt, index }) = infer.ctx.decl(local.decl.start) else { return None }; + let StmtKind::Local { exprs, .. } = &stmt.kind else { return None }; + // The last value of `local a, b = f()` gives the rest of the names theirs. + let value = exprs.get(*index).or_else(|| exprs.last())?.unparen(); + let callee = match &value.kind { + ExprKind::Call { callee, .. } => callee.unparen(), + _ => value, + }; + let span = match &callee.kind { + ExprKind::MethodCall { method, .. } => method.span, + ExprKind::Name(name) | ExprKind::Field { name, .. } => name.span, + _ => return None, + }; + origin_file(ws, infer, doc, &target_at(infer, doc, span.start)?, depth + 1) + } + _ => None, + } +} + +/// What a type names that has a declaration. +enum Named { + /// A class or alias. + Type(SmolStr), + /// The table of an `---@enum`, by the owner its fields are indexed under. + EnumTable(SmolStr), +} + +/// The classes, aliases and enum tables that `ty` names, in the order it names them. +fn named_types(infer: &Infer, ty: &Type, out: &mut Vec, depth: u32) { + if depth > 8 { + return; + } + match ty { + Type::Named(name, args) => { + if !out.iter().any(|known| matches!(known, Named::Type(known) if known == name)) { + out.push(Named::Type(name.clone())); + aliased_types(infer, name, out, depth); + } + args.iter().for_each(|arg| named_types(infer, arg, out, depth + 1)); + } + Type::GlobalTable(owner) => { + if !out.iter().any(|known| matches!(known, Named::EnumTable(known) if known == owner)) { + out.push(Named::EnumTable(owner.clone())); + } + } + Type::Union(types) | Type::Tuple(types) => { + types.iter().for_each(|part| named_types(infer, part, out, depth + 1)) + } + Type::Array(inner) | Type::Variadic(inner) => named_types(infer, inner, out, depth + 1), + Type::Map(key, value) => { + named_types(infer, key, out, depth + 1); + named_types(infer, value, out, depth + 1); + } + Type::Fun(fun) => fun.returns.iter().for_each(|ty| named_types(infer, ty, out, depth + 1)), + Type::Require(_) => { + let resolved = infer.resolve_alias(ty); + if resolved != *ty { + named_types(infer, &resolved, out, depth + 1); + } + } + _ => {} + } +} + +/// The classes and aliases that the alias `name` stands for: the one it names, or each that a union +/// or a `?` of it names, as `Dog` and `Animal` for `---@alias Pet Dog|Animal`. The types an array +/// or a table type of it holds are not its own, as lua-language-server reads them. +fn aliased_types(infer: &Infer, name: &str, out: &mut Vec, depth: u32) { + if infer.index.class(name, infer.side()).is_some() { + return; + } + let Some((_, alias)) = infer.index.alias(name, infer.side()) else { return }; + let parts = match &alias.ty { + Type::Union(parts) => parts.as_slice(), + one => std::slice::from_ref(one), + }; + for part in parts.iter().filter(|part| matches!(part, Type::Named(..))) { + named_types(infer, part, out, depth + 1); + } +} diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 8690409..13978a4 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -648,6 +648,16 @@ impl Index { self.alias_slots(name).collect() } + /// The `---@enum`s whose table has its fields indexed under `owner`, whatever side they are + /// scoped to. + pub fn enums_of_table(&self, owner: &str) -> Vec<(FileId, &AliasDef)> { + let slots = self.aliases.values().flatten(); + slots + .filter_map(|(f, i)| Some((*f, self.file(*f)?.index.aliases.get(*i as usize)?))) + .filter(|(_, alias)| alias.table.as_ref().is_some_and(|table| table.owner == owner)) + .collect() + } + pub fn class_names(&self) -> impl Iterator { self.classes.keys().chain(self.aliases.keys()) } diff --git a/crates/qbx_lua_ls/src/server.rs b/crates/qbx_lua_ls/src/server.rs index 7626c4b..1429731 100644 --- a/crates/qbx_lua_ls/src/server.rs +++ b/crates/qbx_lua_ls/src/server.rs @@ -119,6 +119,7 @@ pub fn capabilities() -> ServerCapabilities { work_done_progress_options: WorkDoneProgressOptions::default(), }), definition_provider: Some(OneOf::Left(true)), + type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)), references_provider: Some(OneOf::Left(true)), document_highlight_provider: Some(OneOf::Left(true)), document_symbol_provider: Some(OneOf::Left(true)), @@ -672,6 +673,11 @@ impl Server { let doc = self.doc(&p.text_document_position_params.text_document.uri)?; reply(definition::definition(&self.ws, doc, p.text_document_position_params.position)) } + req::GotoTypeDefinition::METHOD => { + let p: GotoDefinitionParams = params(raw)?; + let doc = self.doc(&p.text_document_position_params.text_document.uri)?; + reply(definition::type_definition(&self.ws, doc, p.text_document_position_params.position)) + } req::References::METHOD => { let p: ReferenceParams = params(raw)?; let doc = self.doc(&p.text_document_position.text_document.uri)?; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 8eaf90e..4f18981 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5274,6 +5274,135 @@ fn goes_to_namespaced_types_and_enums_in_unsaved_files() { } } +#[test] +fn goes_to_the_types_the_value_comes_with_and_to_the_enums_of_tables() { + let mut client = Client::start(fixture_root()); + // Two resources that this one does not see declare `Test.Owner`; the export of one returns it. + let shop = + "---@class Test.Owner\n\n---@return Test.Owner\nfunction GetOwner() end\n\nexports('GetOwner', GetOwner)\n"; + client.open_with("shop/server.lua", shop); + client.open_with("late/server.lua", "---@class Test.Owner\n"); + client.open_with("myresource/shared/config.lua", "---@enum Test.Color\nColors = { Red = 1 }\n"); + let text = "\ +---@enum Test.Mode +local Modes = { On = 1 } +local owner = exports.shop:GetOwner() +print(Modes, Colors, owner) +"; + client.open_with(SERVER, text); + let mut types_of = |needle: &str| -> Vec<(String, u64)> { + let (line, column) = pos(text, needle, 0); + let result = client.request("textDocument/typeDefinition", client.position_params(SERVER, line, column)); + let locations = result.as_array().cloned().unwrap_or_default(); + let file = + |uri: &str| uri.rsplit('/').take(2).collect::>().into_iter().rev().collect::>().join("/"); + locations + .iter() + .map(|location| { + (file(location["uri"].as_str().unwrap()), location["range"]["start"]["line"].as_u64().unwrap()) + }) + .collect() + }; + let at = |file: &str, line: u64| (file.to_string(), line); + assert_eq!(types_of("GetOwner()"), [at("shop/server.lua", 0)], "as the file of the export sees the name"); + assert_eq!(types_of("owner)"), [at("shop/server.lua", 0)], "a local that the export gives"); + assert_eq!(types_of("Colors,"), [at("shared/config.lua", 1)], "the global table of an enum"); + assert_eq!(types_of("Modes,"), [at("server/main.lua", 1)], "the local table of an enum"); +} + +#[test] +fn goes_to_the_declarations_of_the_types_of_values() { + let mut client = Client::start(fixture_root()); + const TYPES: &str = "myresource/shared/config.lua"; + let types = "\ +---@class Test.Player +---@field job Test.Job +Players = {} + +---@class Test.Job + +---@alias Test.Mode 'a'|'b' + +---@enum Test.Color +Colors = { Red = 1 } + +---@class Test.Box +---@field value T + +---@return Test.Player +function GetPlayer() end + +---@alias Test.Pet Test.Player|Test.Job +---@alias Test.MaybePet Test.Pet? +---@alias Test.Pets Test.Pet[] +"; + client.open_with(TYPES, types); + let text = "\ +---@type Test.Player|Test.Job +local either +---@type Test.Player[] +local list = {} +---@type table +local map = {} +---@type Test.Mode +local mode +---@type Test.Color +local color +---@type Test.Box +local box +---@type integer +local count = 1 +local vehicle = GetVehiclePedIsIn(PlayerPedId(), false) +local config = { a = 1 } +local player = GetPlayer() +---@param target Test.Player +local function use(target) return target end +---@type Test.MaybePet +local pet +---@type Test.Pets +local pets +print(either, list, map, mode, color, box, count, vehicle, config, player.job, GetPlayer(), use, pet, pets) +"; + client.open_with(CLIENT, text); + let capabilities = serde_json::to_value(qbx_lua_ls::server::capabilities()).unwrap(); + assert_eq!(capabilities["typeDefinitionProvider"], true); + let player = (TYPES, 0); + let job = (TYPES, 4); + let print_line = text.lines().count() as u32 - 1; + let print = text.lines().last().unwrap(); + let mut types_of = |line: u32, column: u32| -> Vec<(&str, u64)> { + let result = client.request("textDocument/typeDefinition", client.position_params(CLIENT, line, column)); + let locations = result.as_array().cloned().unwrap_or_default(); + locations + .iter() + .map(|location| { + assert_eq!(location["uri"], client.uri(TYPES).as_str(), "{result}"); + (TYPES, location["range"]["start"]["line"].as_u64().unwrap()) + }) + .collect() + }; + let at = |name: &str| print.find(name).unwrap() as u32 + 1; + assert_eq!(types_of(print_line, at("either")), [player, job], "each type of a union"); + assert_eq!(types_of(print_line, at("list")), [player], "the elements of an array"); + assert_eq!(types_of(print_line, at("map")), [job], "the keys and values of a table"); + assert_eq!(types_of(print_line, at("mode")), [(TYPES, 6)], "an alias"); + assert_eq!(types_of(print_line, at("color")), [(TYPES, 9)], "the table of an enum"); + assert_eq!(types_of(print_line, at("box")), [(TYPES, 11), job], "a generic class and its argument"); + assert_eq!(types_of(print_line, at("player.job") + 7), [job], "a field"); + assert_eq!(types_of(print_line, at("GetPlayer")), [player], "what a function returns"); + assert_eq!(types_of(16, 7), [player], "a local that a call gives"); + assert_eq!(types_of(18, 35), [player], "a parameter"); + assert_eq!( + types_of(print_line, at("pet,")), + [(TYPES, 18), (TYPES, 17), player, job], + "an alias and the types it stands for, through `?` and another alias" + ); + assert_eq!(types_of(print_line, at("pets)")), [(TYPES, 19)], "not the elements of an array it stands for"); + for name in ["count", "vehicle", "config"] { + assert_eq!(types_of(print_line, at(name)), [], "{name}: built-in types, native handles and plain tables"); + } +} + #[test] fn annotation_features_ignore_names_outside_type_positions() { let mut client = Client::start(fixture_root()); From a4bb69c54623c4213bd908bc7964891c9b1d02f8 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:53:16 +0200 Subject: [PATCH 021/193] feat(definition): go to implementation --- crates/qbx_lua_ls/README.md | 3 +- crates/qbx_lua_ls/src/features/definition.rs | 19 ++++++ crates/qbx_lua_ls/src/features/member_refs.rs | 18 ++++- crates/qbx_lua_ls/src/server.rs | 6 ++ crates/qbx_lua_ls/tests/lsp.rs | 66 +++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index f396381..7aa56ac 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -42,7 +42,8 @@ Available features depend on the editor's LSP client. - Hover and definitions for the classes, aliases and enums named in LuaCATS annotations. - Go to type definition: the `---@class`, `---@alias` or `---@enum` of a value's type, through unions, arrays, `table` and what a function returns, and the classes an alias of a union - stands for. + stands for. Go to implementation: where code sets a field or method, without the `---@field` + lines that declare it. - Exports typed by LuaLS definitions, as `---@type PhoneExports` above `exports.phone = {}`, or `---@class qbx_core` above `exports.qbx_core = {}` with `function exports.qbx_core:GetCid()` methods: `exports.phone` and `exports['phone']` take the members of the declared type before the diff --git a/crates/qbx_lua_ls/src/features/definition.rs b/crates/qbx_lua_ls/src/features/definition.rs index 268cd59..c3cba67 100644 --- a/crates/qbx_lua_ls/src/features/definition.rs +++ b/crates/qbx_lua_ls/src/features/definition.rs @@ -6,6 +6,7 @@ use qbx_lua_syntax::SmolStr; use super::class_tables::Classes; use super::hover::{event_string_context, target_at, Target}; +use super::member_refs::member_target; use super::with_infer; use crate::document::Document; use crate::index::{EventFamily, EventKind, FileId, FileOrigin}; @@ -272,3 +273,21 @@ fn aliased_types(infer: &Infer, name: &str, out: &mut Vec, depth: u32) { named_types(infer, part, out, depth + 1); } } + +/// Where code sets the field or method under the cursor: `function Class:name()`, `Class.name = +/// value`, `self.name = value` and the keys of table constructors, without the `---@field` lines +/// that declare it, so a method declared in a definition file leads to its body. Other names, and +/// members that no code the file sees sets, like the exports a resource registers, go to their +/// definition. +pub fn implementation(ws: &Workspace, doc: &Document, position: Position) -> Option { + let Some(target) = member_target(ws, doc, doc.offset(position)) else { return definition(ws, doc, position) }; + let locations: Vec = target + .implementations(ws, doc.file) + .into_iter() + .filter_map(|(file, range)| location(ws, file, range)) + .collect(); + if locations.is_empty() { + return definition(ws, doc, position); + } + Some(GotoDefinitionResponse::Array(locations)) +} diff --git a/crates/qbx_lua_ls/src/features/member_refs.rs b/crates/qbx_lua_ls/src/features/member_refs.rs index 5c3a924..9bcb2fe 100644 --- a/crates/qbx_lua_ls/src/features/member_refs.rs +++ b/crates/qbx_lua_ls/src/features/member_refs.rs @@ -21,6 +21,21 @@ pub struct MemberTarget { pub name: SmolStr, pub file: FileId, declarations: Vec<(FileId, Range)>, + /// The table or class whose member it is. + owner: Option, +} + +impl MemberTarget { + /// Where code that `from` sees sets the member, without the `---@field` lines that declare it. + pub fn implementations(&self, ws: &Workspace, from: FileId) -> Vec<(FileId, Range)> { + let Some(owner) = &self.owner else { return Vec::new() }; + let members = ws.index.members_of(owner, from).into_iter(); + let mut found: Vec<(FileId, Range)> = + members.filter(|(_, symbol)| symbol.name == self.name).map(|(file, symbol)| (file, symbol.range)).collect(); + found.sort_by_key(|(file, range)| (*file, range.start, range.end)); + found.dedup(); + found + } } /// The field or method under the cursor, identified by where it is defined. @@ -78,7 +93,8 @@ pub fn member_target(ws: &Workspace, doc: &Document, offset: u32) -> Option ServerCapabilities { }), definition_provider: Some(OneOf::Left(true)), type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)), + implementation_provider: Some(ImplementationProviderCapability::Simple(true)), references_provider: Some(OneOf::Left(true)), document_highlight_provider: Some(OneOf::Left(true)), document_symbol_provider: Some(OneOf::Left(true)), @@ -678,6 +679,11 @@ impl Server { let doc = self.doc(&p.text_document_position_params.text_document.uri)?; reply(definition::type_definition(&self.ws, doc, p.text_document_position_params.position)) } + req::GotoImplementation::METHOD => { + let p: GotoDefinitionParams = params(raw)?; + let doc = self.doc(&p.text_document_position_params.text_document.uri)?; + reply(definition::implementation(&self.ws, doc, p.text_document_position_params.position)) + } req::References::METHOD => { let p: ReferenceParams = params(raw)?; let doc = self.doc(&p.text_document_position.text_document.uri)?; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 4f18981..3e13b97 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5403,6 +5403,72 @@ print(either, list, map, mode, color, box, count, vehicle, config, player.job, G } } +#[test] +fn implementations_leave_out_the_annotations_that_declare_a_member() { + let mut client = Client::start(fixture_root()); + const TYPES: &str = "myresource/shared/config.lua"; + let types = "\ +---@class Test.Player +---@field name string +---@field greet fun(self: Test.Player): string +Players = {} + +function Players:greet() return 'hi' end +"; + client.open_with(TYPES, types); + // The other file reads `Players` from this one to know what `self` is. + client.diagnostics_for(TYPES); + let text = "\ +---@type Test.Player +local player = Players +local count = 1 +print(player:greet(), player.name, count) + +---@param name string +function Players:rename(name) + self.name = name +end +"; + client.open_with(CLIENT, text); + let capabilities = serde_json::to_value(qbx_lua_ls::server::capabilities()).unwrap(); + assert_eq!(capabilities["implementationProvider"], true); + let mut found = |method: &str, needle: &str, delta: u32| -> Vec<(String, u64)> { + let (line, column) = pos(text, needle, delta); + let result = client.request(method, client.position_params(CLIENT, line, column)); + let locations = result.as_array().cloned().unwrap_or_default(); + let file = + |uri: &str| uri.rsplit('/').take(2).collect::>().into_iter().rev().collect::>().join("/"); + locations + .iter() + .map(|location| { + (file(location["uri"].as_str().unwrap()), location["range"]["start"]["line"].as_u64().unwrap()) + }) + .collect() + }; + let shared = |line: u64| ("shared/config.lua".to_string(), line); + assert_eq!(found("textDocument/definition", "greet()", 0), [shared(2)], "definition picks the annotation"); + assert_eq!(found("textDocument/implementation", "greet()", 0), [shared(5)], "the method's body"); + let main = |line: u64| ("client/main.lua".to_string(), line); + assert_eq!(found("textDocument/implementation", "name, count", 0), [main(7)], "an assignment through `self`"); + assert_eq!(found("textDocument/implementation", "count)", 0), [main(2)], "a local"); +} + +#[test] +fn implementations_of_members_no_code_sets_are_their_definition() { + let mut client = Client::start(fixture_root()); + client.open_with( + "shop/server.lua", + "---@return integer\nfunction GetCount() return 1 end\n\nexports('GetCount', GetCount)\n", + ); + let text = "print(exports.shop:GetCount())\n"; + client.open_with(SERVER, text); + let (line, column) = pos(text, "GetCount", 0); + let definition = client.request("textDocument/definition", client.position_params(SERVER, line, column)); + assert!(definition.as_array().is_some_and(|locations| !locations.is_empty()), "{definition}"); + let implementation = client.request("textDocument/implementation", client.position_params(SERVER, line, column)); + assert_eq!(implementation, definition, "an export the resource registers"); +} + #[test] fn annotation_features_ignore_names_outside_type_positions() { let mut client = Client::start(fixture_root()); From b49b47c847af76b799aad57282ed17709f582b56 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:41:27 +0200 Subject: [PATCH 022/193] feat(infer): infer classes built with setmetatable and __index --- crates/qbx_lua_ls/README.md | 4 + crates/qbx_lua_ls/src/features/hover.rs | 38 ++- crates/qbx_lua_ls/src/index.rs | 44 +++ crates/qbx_lua_ls/src/indexer.rs | 77 ++++- crates/qbx_lua_ls/src/infer.rs | 327 +++++++++++++++++- crates/qbx_lua_ls/tests/lsp.rs | 435 ++++++++++++++++++++++++ docs/reference.md | 7 + 7 files changed, 915 insertions(+), 17 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 7aa56ac..68303bb 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -44,6 +44,10 @@ Available features depend on the editor's LSP client. unions, arrays, `table` and what a function returns, and the classes an alias of a union stands for. Go to implementation: where code sets a field or method, without the `---@field` lines that declare it. +- Classes written with metatables and no annotations: what `setmetatable({}, Class)` returns, and + a table given a metatable by `setmetatable`, have the fields of its `__index` table at every + level, as `Class.__index = Class` sets up, for completion, hover and navigation. A `---@class` + keeps the fields it declares. - Exports typed by LuaLS definitions, as `---@type PhoneExports` above `exports.phone = {}`, or `---@class qbx_core` above `exports.qbx_core = {}` with `function exports.qbx_core:GetCid()` methods: `exports.phone` and `exports['phone']` take the members of the declared type before the diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index bdce2fb..0962c4a 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -9,7 +9,9 @@ use qbx_lua_syntax::{CommentKind, SmolStr, Span}; use super::{lua_block, markdown, with_infer}; use crate::callback_wrappers::{source_skip, target_of, Wrapper}; use crate::document::Document; -use crate::index::{AliasDef, ClassDef, EventDef, EventFamily, EventKind, FileId, FileOrigin, SymbolKind}; +use crate::index::{ + instance_class, AliasDef, ClassDef, EventDef, EventFamily, EventKind, FileId, FileOrigin, SymbolKind, +}; use crate::indexer::{described_values, render_doc}; use crate::infer::{Decl, Infer, MemberInfo}; use crate::locate::locate; @@ -287,10 +289,7 @@ fn global_hover(ws: &Workspace, infer: &Infer, name: &str, called: Option) } pub fn member_hover(infer: &Infer, info: &MemberInfo, owner: &Type) -> String { - let owner_label = match owner { - Type::GlobalTable(path) if path.starts_with('%') => String::new(), - other => other.without_nil().to_string(), - }; + let owner_label = owner_label(owner); let is_method = info.ty.as_fun().is_some_and(|f| f.is_method); let qualified = match (owner_label.is_empty(), is_method) { (true, _) => info.name.to_string(), @@ -309,6 +308,35 @@ pub fn member_hover(infer: &Infer, info: &MemberInfo, owner: &Type) -> String { out } +/// How a member hover names the value of type `owner` the member is read from. An instance goes by +/// its class, as what `Zone:new()` returns is `Zone`, without the table it was made from, which +/// `setmetatable({ size = 1 }, Class)` keeps. A table of one file has no name to show. +fn owner_label(owner: &Type) -> String { + let owner = owner.without_nil(); + let parts = match &owner { + Type::Union(types) => types.as_slice(), + other => std::slice::from_ref(other), + }; + let is_instance = |part: &Type| match part { + Type::GlobalTable(path) => instance_class(path).is_some(), + other => matches!(other, Type::Named(..)), + }; + let label = if parts.iter().any(is_instance) { + let classes = + parts.iter().filter(|part| !matches!(part, Type::Shape(_) | Type::Table)).map(|part| match part { + Type::GlobalTable(path) => Type::GlobalTable(SmolStr::new(instance_class(path).unwrap_or(path))), + other => other.clone(), + }); + Type::union(classes) + } else { + owner + }; + match label { + Type::GlobalTable(path) if path.starts_with('%') => String::new(), + other => other.to_string(), + } +} + fn class_hover(infer: &Infer, class: &ClassDef) -> String { let mut declaration = format!("(class) {}", class.name); if !class.parents.is_empty() { diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 13978a4..4935600 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -67,6 +67,30 @@ pub struct Element { pub value: Type, } +/// The owner that the fields set on instances of the table `class` are indexed under, as `self.x` +/// is in `local self = setmetatable({}, Class)`. An instance falls back on its class for the fields +/// it lacks, and an instance of an instance shares its owner. +pub fn instance_owner(class: &str) -> SmolStr { + match instance_class(class) { + Some(_) => SmolStr::new(class), + None => SmolStr::new(format!("%inst({class})")), + } +} + +/// The class whose instances `owner` holds the fields of. A table in a field of an instance, such +/// as `%inst(Class).data`, is no instance itself. +pub fn instance_class(owner: &str) -> Option<&str> { + owner.strip_prefix("%inst(")?.strip_suffix(')') +} + +/// A metatable that `setmetatable` gives the table `owner`, as `setmetatable(Child, { __index = Base })` +/// does: the table falls back on the `__index` of `metatable` for the fields it lacks. +#[derive(Clone, Debug)] +pub struct Metatable { + pub owner: SmolStr, + pub metatable: Type, +} + #[derive(Clone, Debug)] pub struct ClassDef { pub name: SmolStr, @@ -179,6 +203,7 @@ pub struct FileIndex { pub globals: Vec, pub members: Vec, pub elements: Vec, + pub metatables: Vec, pub classes: Vec, pub aliases: Vec, pub exports: Vec, @@ -277,6 +302,7 @@ pub struct Index { globals: FxHashMap>, members: FxHashMap>, elements: FxHashMap>, + metatables: FxHashMap>, classes: FxHashMap>, aliases: FxHashMap>, /// The `members` on `exports` that declare the type of a resource's exports, by resource name. @@ -378,6 +404,9 @@ impl Index { for (i, element) in entry.index.elements.iter().enumerate() { self.elements.entry(element.owner.clone()).or_default().push((id, i as u32)); } + for (i, metatable) in entry.index.metatables.iter().enumerate() { + self.metatables.entry(metatable.owner.clone()).or_default().push((id, i as u32)); + } for (i, class) in entry.index.classes.iter().enumerate() { self.classes.entry(class.name.clone()).or_default().push((id, i as u32)); } @@ -404,6 +433,7 @@ impl Index { remove_file_slots(&mut self.globals, old.index.globals.iter().map(|s| &s.name), id); remove_file_slots(&mut self.members, old.index.members.iter().map(|m| &m.owner), id); remove_file_slots(&mut self.elements, old.index.elements.iter().map(|e| &e.owner), id); + remove_file_slots(&mut self.metatables, old.index.metatables.iter().map(|m| &m.owner), id); remove_file_slots(&mut self.classes, old.index.classes.iter().map(|c| &c.name), id); remove_file_slots(&mut self.aliases, old.index.aliases.iter().map(|a| &a.name), id); let exports = old.index.members.iter().filter(|m| m.owner == "exports"); @@ -563,6 +593,18 @@ impl Index { .collect() } + pub fn has_metatables(&self, owner: &str) -> bool { + self.metatables.contains_key(owner) + } + + /// The metatables set on the tables `owner` names, visible like its members. + pub fn metatables_of(&self, owner: &str, from: FileId) -> Vec<&Type> { + self.owner_slots(self.metatables.get(owner), owner, from) + .into_iter() + .filter_map(|(file, i)| Some(&self.file(file)?.index.metatables.get(i as usize)?.metatable)) + .collect() + } + fn owner_slots(&self, slots: Option<&Vec>, owner: &str, from: FileId) -> Vec { let Some(slots) = slots else { return Vec::new() }; let reachable = |target: FileId| { @@ -575,6 +617,8 @@ impl Index { || self.is_related(from, target) || self.imports_resource_of(from, target)) }; + // The instances of a class are seen where the class is. + let owner = instance_class(owner).unwrap_or(owner); // `%`-owners name one specific table of one file (a local or a module return), so whoever // holds a value of that type may see all of it. if owner.starts_with('%') { diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 96bb510..a9f5113 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -13,9 +13,9 @@ use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use crate::callback_wrappers::Wrapper; use crate::index::{ AliasDef, ClassDef, Element, EnumTable, EventDef, EventFamily, EventKind, FileId, FileIndex, Index, Member, - NuiCallbackDef, Symbol, SymbolKind, + Metatable, NuiCallbackDef, Symbol, SymbolKind, }; -use crate::infer::{table_elements, table_fields, FileContext, Infer}; +use crate::infer::{metatable_args, table_elements, table_fields, FileContext, Infer}; use crate::luacats::{parse_doc_lines, DocGroup}; use crate::types::{CallbackRole, DescribedValue, FunType, Type}; @@ -304,7 +304,10 @@ impl<'a> Indexer<'a> { ty.clone() } else { match value.map(|v| (&v.kind, v)) { - Some((_, expr)) if table_fields(expr).is_some_and(<[TableField]>::is_empty) => { + // An empty table that `setmetatable` gives a metatable still has fields to look up. + Some((_, expr)) + if table_fields(expr).is_some_and(<[TableField]>::is_empty) && metatable_args(expr).is_empty() => + { kind = SymbolKind::Table; Type::Table } @@ -315,8 +318,10 @@ impl<'a> Indexer<'a> { let (keys, side) = (doc.enum_keys, doc.enum_side); self.enum_class(enum_name.clone(), keys, side, fields, name.span, nested_owner); } - self.table_members(SmolStr::new(nested_owner), fields, table_depth + 1); - Type::GlobalTable(SmolStr::new(nested_owner)) + let owner = SmolStr::new(nested_owner); + self.table_members(owner.clone(), fields, table_depth + 1); + self.constructor_metatables(&owner, expr); + Type::GlobalTable(owner) } Some((ExprKind::Function(func), _)) => { kind = SymbolKind::Function; @@ -390,6 +395,57 @@ impl<'a> Indexer<'a> { } } + fn push_metatable(&mut self, owner: SmolStr, metatable: &Expr) { + if self.out.metatables.len() < MAX_MEMBERS_PER_FILE { + let metatable = self.infer.expr(metatable); + if !metatable.is_unknown() { + self.out.metatables.push(Metatable { owner, metatable }); + } + } + } + + /// The metatables that `setmetatable({ ... }, metatable)` gives the table it builds, which is + /// indexed as `owner`. + fn constructor_metatables(&mut self, owner: &SmolStr, expr: &Expr) { + for metatable in metatable_args(expr) { + self.push_metatable(owner.clone(), metatable); + } + } + + /// `setmetatable(Child, { __index = Base })` on a table that has members of its own: a top-level + /// local, a global or a field of one. A table written in the call takes the owner of the name it + /// is assigned to instead, and a `---@class` keeps the fields it declares. + fn metatable_call(&mut self, args: &[Expr]) { + let [table, metatable, ..] = args else { return }; + if table_fields(table).is_some() { + return; + } + if let Some(owner) = self.table_owner(table) { + self.push_metatable(owner, metatable); + } + } + + /// The owner that the members of the table `expr` names are indexed under, unless a `---@class` + /// declares it. A local names a table of its own only when it is declared with one at the top of + /// the file; others hold tables, such as an instance that has the type of its class. + fn table_owner(&self, expr: &Expr) -> Option { + if let Some(path) = expr.dotted_path().filter(|_| self.root_is_global(expr)) { + if matches!(path.as_str(), "_ENV" | "_G") { + return None; + } + let class = match &expr.kind { + ExprKind::Name(root) => self.global_class(&root.text), + _ => None, + }; + let typed_as_class = class.is_some() || matches!(self.infer.expr(expr), Type::Named(..)); + return (!typed_as_class).then(|| SmolStr::new(path)); + } + let ExprKind::Name(name) = &expr.kind else { return None }; + let Some(Resolved::Local(id)) = self.ctx.resolution.resolve_at(name.span.start) else { return None }; + let own = self.ctx.local_owner_key(self.ctx.resolution.local(id).decl.start); + matches!(self.infer.expr(expr).without_nil(), Type::GlobalTable(owner) if owner == own).then_some(own) + } + fn table_members(&mut self, owner: SmolStr, fields: &[TableField], depth: u32) { let fields = &fields[..fields.len().min(MAX_TABLE_FIELDS)]; for field in fields { @@ -448,7 +504,13 @@ impl<'a> Indexer<'a> { let doc = self.ctx.doc_at(stmt.span.start); let owner = match doc.classes.last() { Some(class) => class.name.clone(), - None => self.ctx.local_owner_key(name.name.span.start), + None => { + let owner = self.ctx.local_owner_key(name.name.span.start); + if let Some(expr) = exprs.get(i) { + self.constructor_metatables(&owner, expr); + } + owner + } }; if let Some(enum_name) = &doc.enum_name { let (keys, side) = (doc.enum_keys, doc.enum_side); @@ -653,6 +715,7 @@ impl<'a> Indexer<'a> { self.enum_class(enum_name.clone(), doc.enum_keys, doc.enum_side, fields, first.span, &owner); } self.table_members(owner.clone(), fields, 1); + self.constructor_metatables(&owner, first); Type::GlobalTable(owner) } None => self.infer.expr(first).widen(), @@ -866,6 +929,8 @@ impl<'a> Indexer<'a> { } } else if path == "AddStateBagChangeHandler" { self.state_key(first); + } else if path == "setmetatable" { + self.metatable_call(args); } } self.expr(callee); diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index a4acda4..489b638 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -12,7 +12,7 @@ use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::callback_wrappers::{self, Wrapper}; -use crate::index::{FileId, Index, SymbolKind}; +use crate::index::{instance_class, instance_owner, FileId, Index, SymbolKind}; use crate::locate::statement_at; use crate::luacats::{applies_on, parse_doc_lines, CastEntry, DocGroup}; use crate::narrow::{Casts, Guards}; @@ -131,6 +131,11 @@ pub struct FileContext<'a> { call_anchors: FxHashMap, /// Where each table constructor that a declared type may describe is written, by its start. tables: FxHashMap>, + /// The `table.__index = value` assignments of the file, by the table and the value. + index_writes: Vec<(&'a Expr, &'a Expr)>, + /// The metatables that `setmetatable(name, metatable)` calls give each local, with where each + /// call ends. + set_metatables: FxHashMap>, docs: RefCell>>, guards: OnceCell, casts: OnceCell, @@ -143,8 +148,16 @@ impl<'a> FileContext<'a> { decls: FxHashMap::default(), call_anchors: FxHashMap::default(), tables: FxHashMap::default(), + index_writes: Vec::new(), + set_metatables: Vec::new(), }; collector.block(&chunk.block); + let mut set_metatables: FxHashMap> = FxHashMap::default(); + for (name, call_end, metatable) in collector.set_metatables { + if let Some(Resolved::Local(id)) = resolution.resolve_at(name.span.start) { + set_metatables.entry(id).or_default().push((call_end, metatable)); + } + } Self { file, source, @@ -153,6 +166,8 @@ impl<'a> FileContext<'a> { decls: collector.decls, call_anchors: collector.call_anchors, tables: collector.tables, + index_writes: collector.index_writes, + set_metatables, docs: RefCell::new(FxHashMap::default()), guards: OnceCell::new(), casts: OnceCell::new(), @@ -262,6 +277,8 @@ struct DeclCollector<'a> { decls: FxHashMap>, call_anchors: FxHashMap, tables: FxHashMap>, + index_writes: Vec<(&'a Expr, &'a Expr)>, + set_metatables: Vec<(&'a Name, u32, &'a Expr)>, } impl<'a> DeclCollector<'a> { @@ -306,6 +323,19 @@ impl<'a> DeclCollector<'a> { } StmtKind::Assign { targets, exprs } => { targets.iter().for_each(|e| self.expr(e, None)); + for (target, value) in targets.iter().zip(exprs) { + match &target.kind { + ExprKind::Field { base, name, .. } if name.text == "__index" => { + self.index_writes.push((base, value)); + } + ExprKind::Index { base, index, .. } + if index.as_string().is_some_and(|key| key == "__index") => + { + self.index_writes.push((base, value)); + } + _ => {} + } + } for (index, expr) in exprs.iter().enumerate() { self.value(expr, anchor, Expected::Value { stmt, index }); } @@ -358,6 +388,13 @@ impl<'a> DeclCollector<'a> { match &expr.kind { ExprKind::Function(func) => self.func(func, doc_anchor, None), ExprKind::Call { callee, args, .. } => { + if let (ExprKind::Name(callee), [Expr { kind: ExprKind::Name(name), .. }, metatable, ..]) = + (&callee.kind, &args[..]) + { + if callee.text == "setmetatable" { + self.set_metatables.push((name, expr.span.end, metatable)); + } + } self.expr(callee, None); self.call_args(expr, args, doc_anchor); } @@ -455,7 +492,16 @@ pub struct Infer<'a> { /// The sets of values returned by the call of each `local a, b = f()` statement read so far, /// by the start of the statement. linked_sets: RefCell>>, + /// The tables that each owner falls back on through the `__index` of its metatables, by owner. + fallbacks: RefCell>, + /// The owners whose fallbacks are being looked up, which a metatable chain that loops back stops at. + following: RefCell>, + /// What the `__index` of the metatable passed to each `setmetatable` call gives, by the start of + /// that argument. + passed_indexes: RefCell>, depth: Cell, + /// Set when `guarded` cuts a lookup short at `MAX_DEPTH`, so that what it found is not cached. + truncated: Cell, } /// Natives call their handles `Vehicle`, `Ped` and so on. They are integers, and resources (ox_lib, @@ -495,7 +541,11 @@ impl<'a> Infer<'a> { locals: RefCell::new(FxHashMap::default()), in_progress: RefCell::new(FxHashSet::default()), linked_sets: RefCell::new(FxHashMap::default()), + fallbacks: RefCell::new(FxHashMap::default()), + following: RefCell::new(FxHashSet::default()), + passed_indexes: RefCell::new(FxHashMap::default()), depth: Cell::new(0), + truncated: Cell::new(false), } } @@ -511,6 +561,7 @@ impl<'a> Infer<'a> { fn guarded(&self, f: impl FnOnce() -> T) -> T { if self.depth.get() >= MAX_DEPTH { + self.truncated.set(true); return T::default(); } self.depth.set(self.depth.get() + 1); @@ -519,6 +570,16 @@ impl<'a> Infer<'a> { out } + /// What `f` gives, and whether it is complete: a lookup that `MAX_DEPTH` cut short may find + /// more when it starts from a shallower point, so it is not cached. + fn complete(&self, f: impl FnOnce() -> T) -> (T, bool) { + let outer = self.truncated.replace(false); + let out = f(); + let truncated = self.truncated.get(); + self.truncated.set(outer || truncated); + (out, !truncated) + } + pub fn expr(&self, expr: &Expr) -> Type { self.expr_multi(expr).into_iter().next().unwrap_or_default() } @@ -608,7 +669,48 @@ impl<'a> Infer<'a> { /// the read change it, without what the guards around the read rule out, such as the `nil` of a /// `string?` after `if not name then return end`. pub fn local_type_at(&self, id: LocalId, offset: u32) -> Type { - self.narrowed(id, offset, self.local_type(id)) + self.narrowed(id, offset, self.with_metatables(id, offset, self.local_type(id))) + } + + /// `ty`, the type of the local `id`, with what the `setmetatable` calls on the local make of it + /// where it is read at `offset`: `setmetatable(obj, Class)` makes `obj` an instance of `Class`. + /// For a table class that holds before the call too, so the fields a constructor sets on `obj` + /// first are those of its instances. A `---@class` is a type that `obj` takes only once the + /// call gives it, so what is set on `obj` before stays out of the class. + fn with_metatables(&self, id: LocalId, offset: u32, ty: Type) -> Type { + let Some(metatables) = self.ctx.set_metatables.get(&id) else { return ty }; + // The index gives a table of its own the metatables set on it. + if self.is_own_table(id, &ty) { + return ty; + } + metatables.iter().fold(ty, |ty, (call_end, metatable)| { + let index = self.passed_index(metatable); + if offset < *call_end && matches!(index, Type::Named(..)) { + return ty; + } + self.instance_type(ty, index, false) + }) + } + + /// Whether `ty`, the type of the local `id`, is the table the local is declared with, as the + /// top-level `local Base = {}` has, rather than one it holds, as an instance has the type of its + /// class. + fn is_own_table(&self, id: LocalId, ty: &Type) -> bool { + let own = self.ctx.local_owner_key(self.ctx.resolution.local(id).decl.start); + matches!(ty.without_nil(), Type::GlobalTable(owner) if owner == own) + } + + /// Whether `expr`, of type `ty`, names a table of its own, which the index gives the metatables + /// that `setmetatable` sets on it: a top-level local declared with a table, or a global or a + /// field of one that holds its own table rather than an instance of another. + fn names_own_table(&self, expr: &Expr, ty: &Type) -> bool { + if let ExprKind::Name(name) = &expr.kind { + if let Some(Resolved::Local(id)) = self.ctx.resolution.resolve_at(name.span.start) { + return self.is_own_table(id, ty); + } + } + let path = expr.dotted_path(); + matches!(ty.without_nil(), Type::GlobalTable(owner) if path.is_some_and(|path| owner == path)) } /// `ty`, the type of the local `id`, as the casts before a read at `offset` change it and @@ -841,8 +943,15 @@ impl<'a> Infer<'a> { } if let Some(expr) = exprs.get(index) { let is_last = index + 1 == exprs.len(); - if top_level && table_fields(expr).is_some() { - return Type::GlobalTable(self.ctx.local_owner_key(names[index].name.span.start)); + if let Some(fields) = table_fields(expr).filter(|_| top_level) { + let own = Type::GlobalTable(self.ctx.local_owner_key(names[index].name.span.start)); + // `setmetatable({}, Class)` makes an instance of a `---@class`, which is that class, + // and one built with fields keeps those. + let metatable = metatable_args(expr).first().map(|mt| self.expr(mt)); + if let Some(class @ Type::Named(..)) = metatable.map(|mt| self.metatable_index(&mt)) { + return if fields.is_empty() { class } else { Type::union([own, class]) }; + } + return own; } let ty = if is_last { self.expr_multi(expr).into_iter().next().unwrap_or_default() } else { self.expr(expr) }; @@ -1161,7 +1270,11 @@ impl<'a> Infer<'a> { let mut keys = Vec::new(); let mut values = Vec::new(); if !array_only { - for member in self.members(&Type::GlobalTable(owner.clone())) { + // `pairs` visits the table's own fields, not those its metatable's `__index` gives. + let mut members = Vec::new(); + self.guarded(|| self.own_members(owner, None, &mut members)); + let mut seen = FxHashSet::default(); + for member in members.into_iter().filter(|m| seen.insert(m.name.clone())) { keys.push(Type::String); values.push(member.ty); } @@ -1372,6 +1485,117 @@ impl<'a> Infer<'a> { Type::Shape(Arc::new(shape)) } + /// What `setmetatable(t, metatable)` makes of a table of type `own`: a table that also has the + /// fields of `index`, the tables the `__index` of `metatable` gives, as an instance has the + /// methods of its class. A table with members of its own (`own_table`) is indexed with its + /// metatables instead, and a declared type is kept. + fn instance_type(&self, own: Type, index: Type, own_table: bool) -> Type { + if index.is_unknown() || own_table { + return own; + } + let bare = own.without_nil(); + let parts = match &bare { + Type::Union(types) => types.as_slice(), + other => std::slice::from_ref(other), + }; + if parts.iter().any(|part| matches!(part, Type::Named(..) | Type::Require(_) | Type::Exports(_))) { + return own; + } + // An instance of a `---@class` is that class. The fields set on an instance of a table class + // are its own, and it falls back on its class for the others. + let class = match index { + Type::Named(..) => index, + other => instances_of(other), + }; + // An empty table holds nothing of its own. + if matches!(bare, Type::Table | Type::Unknown) { + return class; + } + // The fields the table holds itself come first, as Lua finds them before those of its + // metatable. The class goes before the tables the value was an instance of so far, so that + // the fields set on it are those of its new instances. + let (held, owners): (Vec<&Type>, Vec<&Type>) = + parts.iter().partition(|part| !matches!(part, Type::GlobalTable(_))); + Type::union(held.into_iter().cloned().chain([class]).chain(owners.into_iter().cloned())) + } + + /// The tables that the `__index` of the metatable passed to a `setmetatable` call as `metatable` + /// gives. + fn passed_index(&self, metatable: &Expr) -> Type { + if let Some(index) = self.passed_indexes.borrow().get(&metatable.span.start) { + return index.clone(); + } + // A metatable read from the table it is set on, as in `setmetatable(t, t)`, stops here. + self.passed_indexes.borrow_mut().insert(metatable.span.start, Type::Unknown); + let (index, complete) = self.complete(|| self.metatable_index(&self.expr(metatable))); + if complete { + self.passed_indexes.borrow_mut().insert(metatable.span.start, index.clone()); + } else { + self.passed_indexes.borrow_mut().remove(&metatable.span.start); + } + index + } + + /// The tables that the `__index` of a metatable of type `metatable` gives, whose fields a table + /// with that metatable falls back on. A function there decides them at runtime, so it gives none. + fn metatable_index(&self, metatable: &Type) -> Type { + let mut found = Vec::new(); + self.guarded(|| self.raw_index(metatable, &mut found, 0)); + let parts = found.into_iter().flat_map(|ty| match ty { + Type::Union(types) => types, + other => vec![other], + }); + Type::union(parts.filter(|ty| match ty { + Type::GlobalTable(_) | Type::Shape(_) | Type::Require(_) => true, + Type::Named(name, _) => self.index.class(name, self.side).is_some(), + _ => false, + })) + } + + /// The values of the `__index` field of a metatable of type `metatable`, read from the metatable + /// itself as Lua reads it, not through a metatable of its own. + fn raw_index(&self, metatable: &Type, out: &mut Vec, depth: u32) { + if depth > 8 { + return; + } + match self.resolve_alias(metatable) { + Type::Union(types) => { + for part in types.iter().filter(|t| !matches!(t, Type::Nil)) { + self.raw_index(part, out, depth + 1); + } + } + Type::Shape(shape) => out.extend(shape.fields.iter().filter(|f| f.name == "__index").map(|f| f.ty.clone())), + Type::GlobalTable(owner) => { + let written = self.written_index(&owner); + if !written.is_empty() { + out.extend(written); + return; + } + let mut members = Vec::new(); + self.own_members(&owner, Some("__index"), &mut members); + out.extend(members.into_iter().map(|m| m.ty)); + } + ty @ Type::Named(..) => out.extend(self.members_matching(&ty, Some("__index")).into_iter().map(|m| m.ty)), + _ => {} + } + } + + /// The values this file writes to the `__index` field of the table `owner`: in the constructor + /// of a top-level local, or with `table.__index = value`, as `self.__index = self` in a method + /// does. The index does not hold them yet while the file is indexed for the first time. + fn written_index(&self, owner: &str) -> Vec { + let fields = self.local_table_fields(owner).unwrap_or_default(); + let in_constructor = fields.iter().filter_map(|field| match field { + TableField::Named { name, value } if name.text == "__index" => Some(value), + TableField::Keyed { key, value } if key.as_string().is_some_and(|key| key == "__index") => Some(value), + _ => None, + }); + let table = Type::GlobalTable(SmolStr::new(owner)); + let assigned = + self.ctx.index_writes.iter().filter(|(base, _)| self.expr(base) == table).map(|(_, value)| *value); + in_constructor.chain(assigned).map(|value| self.expr(value)).collect() + } + fn binary(&self, op: BinOp, lhs: &Expr, rhs: &Expr) -> Type { match op { BinOp::Concat => Type::String, @@ -1460,7 +1684,17 @@ impl<'a> Infer<'a> { return only(Type::Require(path.clone()), false); } } - (Some("setmetatable"), Some(arg)) => return only(self.expr(arg), false), + (Some("setmetatable"), Some(arg)) => { + let own = self.expr(arg); + let ty = match args.get(1) { + Some(metatable) => { + let own_table = self.names_own_table(arg, &own); + self.instance_type(own, self.passed_index(metatable), own_table) + } + None => own, + }; + return only(ty, false); + } (Some("tostring"), _) => return only(Type::String, true), (Some("tonumber"), _) => return only(Type::Number.optional(), true), _ => {} @@ -2204,7 +2438,65 @@ impl<'a> Infer<'a> { out } + /// The members of the tables `owner` names, and of the tables they fall back on through the + /// `__index` of their metatables for the fields they lack themselves. fn owner_members(&self, owner: &str, filter: Option<&str>, out: &mut Vec) { + let start = out.len(); + self.own_members(owner, filter, out); + let is_instance = instance_class(owner).is_some(); + if filter.is_some() && out.len() > start && !is_instance { + return; + } + let fallback = self.fallback(owner); + if fallback.is_unknown() || !self.following.borrow_mut().insert(SmolStr::new(owner)) { + return; + } + let mut own: FxHashMap = FxHashMap::default(); + for (i, member) in out.iter().enumerate().skip(start) { + own.entry(member.name.clone()).or_insert(i); + } + let inherited = self.guarded(|| self.members_matching(&fallback, filter)); + self.following.borrow_mut().remove(owner); + for member in inherited { + match own.get(&member.name) { + // The methods of a class set the fields of its instances through `self`, so a field + // that both set takes the more specific type, as `self.item = nil` in a constructor + // does not hide the `Item` that `Holder:set(item)` stores. + Some(&i) if is_instance && member.ty.specificity() > out[i].ty.specificity() => out[i] = member, + Some(_) => {} + None => out.push(member), + } + } + } + + /// The tables that `owner` falls back on: its class for an instance, or the `__index` of the + /// metatables that `setmetatable` gives it. + fn fallback(&self, owner: &str) -> Type { + // The instances of a class fall back on the class. + if let Some(class) = instance_class(owner) { + return Type::GlobalTable(SmolStr::new(class)); + } + if !self.index.has_metatables(owner) { + return Type::Unknown; + } + if let Some(ty) = self.fallbacks.borrow().get(owner) { + return ty.clone(); + } + let metatables = self.index.metatables_of(owner, self.ctx.file); + if metatables.is_empty() || !self.following.borrow_mut().insert(SmolStr::new(owner)) { + return Type::Unknown; + } + let (ty, complete) = + self.complete(|| Type::union(metatables.into_iter().map(|metatable| self.metatable_index(metatable)))); + self.following.borrow_mut().remove(owner); + if complete { + self.fallbacks.borrow_mut().insert(SmolStr::new(owner), ty.clone()); + } + ty + } + + /// The members set on the tables `owner` names themselves. + fn own_members(&self, owner: &str, filter: Option<&str>, out: &mut Vec) { for (file, symbol) in self.index.members_of(owner, self.ctx.file) { if filter.is_none_or(|f| f == symbol.name) { let mut member = member_from_symbol(file, symbol); @@ -2362,6 +2654,29 @@ pub fn table_fields(expr: &Expr) -> Option<&[TableField]> { } } +/// The instances of the tables in `index`, where each table a class is indexed as becomes the owner +/// of its instances' own fields. +fn instances_of(index: Type) -> Type { + match index { + Type::GlobalTable(owner) => Type::GlobalTable(instance_owner(&owner)), + Type::Union(types) => Type::union(types.into_iter().map(instances_of)), + other => other, + } +} + +/// The metatables that the `setmetatable({...}, mt)` calls of a table-valued initialiser give the +/// table it builds. +pub fn metatable_args(expr: &Expr) -> Vec<&Expr> { + let mut out = Vec::new(); + let mut current = expr.unparen(); + while let ExprKind::Call { callee, args, .. } = ¤t.kind { + let (Some("setmetatable"), Some(table)) = (callee.dotted_path().as_deref(), args.first()) else { break }; + out.extend(args.get(1)); + current = table.unparen(); + } + out +} + /// The entries of a table constructor that have no name: the array part `ipairs` visits, which /// takes in `[n]` keys that continue it as in `{ [1] = 'a', [2] = 'b' }`, and the other `[key]`s. pub struct TableElements<'a> { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 3e13b97..14fc2ff 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4865,6 +4865,441 @@ sleep(1) assert!(hover.contains("(async) local function sleep(ms: integer)"), "{hover}"); } +#[test] +fn tables_with_a_metatable_have_the_members_of_its_index() { + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + if let (Ok(root), Ok(temp)) = (self.0.canonicalize(), std::env::temp_dir().canonicalize()) { + if root.parent() == Some(temp.as_path()) { + let _ = std::fs::remove_dir_all(root); + } + } + } + } + let fixture = Fixture(std::env::temp_dir().join(format!( + "qbx-metatables-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(), + ))); + let write = |relative: &str, text: &str| { + let path = fixture.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + }; + write( + "fxmanifest.lua", + "fx_version 'cerulean'\ngame 'gta5'\nshared_script 'shared/*.lua'\nclient_scripts { 'client/*.lua' }\n", + ); + write( + "shared/vehicle.lua", + "\ +Vehicle = {} +Vehicle.__index = Vehicle + +function Vehicle.new(plate) + local self = setmetatable({}, Vehicle) + self.plate = plate + self.speed = 0 + return self +end +", + ); + // A method another file adds to the global class table. + write("client/honk.lua", "function Vehicle:honk() end\n"); + // Global classes whose constructors set `self.__index = self`, one inheriting from the other. + write( + "client/zones.lua", + "\ +Zone = {} + +function Zone:new() + local zone = { size = 1 } + setmetatable(zone, self) + self.__index = self + return zone +end + +function Zone:contains() + return true +end + +function Zone:resize(size) + self.size = size +end + +BoxZone = {} +setmetatable(BoxZone, { __index = Zone }) + +function BoxZone:new() + local zone = Zone:new() + zone.width = 2 + setmetatable(zone, self) + self.__index = self + return zone +end + +function BoxZone:corners() end +", + ); + let text = "\ +local Base = {} +Base.__index = Base + +function Base.new() + return setmetatable({}, Base) +end + +function Base:greet() end + +local Child = setmetatable({}, { __index = Base }) +Child.__index = Child + +function Child.new() + local self = setmetatable({}, Child) + self.level = 1 + return self +end + +function Child:wave() end + +local Other = {} +Other.__index = Other +setmetatable(Other, Base) + +local Animal = {} + +function Animal:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +function Animal:speak() end + +local function make() + local made = {} + setmetatable(made, Animal) + return made +end + +---@class Point +---@field x number +local Point = {} +Point.__index = Point + +function Point.new() + return setmetatable({}, Point) +end + +---@class Bar +---@field name string +local Bar = setmetatable({}, { __index = Base }) + +---@class Thing +---@field name string + +local Holder = {} +Holder.__index = Holder + +function Holder.new() + local self = setmetatable({}, Holder) + self.item = nil + return self +end + +---@param thing Thing +function Holder:set(thing) + self.item = thing +end + +local Looped = {} +Looped.__index = Looped +setmetatable(Looped, Looped) + +function Looped:loop() end + +local Left = {} +local Right = {} +setmetatable(Left, { __index = Right }) +setmetatable(Right, { __index = Left }) + +function Left:left() end +function Right:right() end + +local Late = {} +local late = setmetatable({}, Late) +Late.__index = Late + +function Late:later() end + +local base = Base.new() +local child = Child.new() +local other = setmetatable({}, Other) +local dog = Animal:new() +local built = make() +local point = Point.new() +local bar = Bar +local vehicle = Vehicle.new('abc') +local literal = setmetatable({}, { __index = { hello = 1 } }) +local proxy = setmetatable({}, { __index = function(_, key) return key end }) +local hello = literal.hello +local level = child.level +local missing = proxy.anything +local zone = Zone:new() +local zoneSize = zone.size +local box = BoxZone:new() +local inside = box:contains() +local childClass = Child +local vehicleClass = Vehicle +local animal = Animal +local held = Holder.new().item +---@type Thing +local typed = Holder.new().item +local looped = setmetatable({}, Looped) +local loopedMissing = looped.nothing +local fromRight = Left.right +local fromLeft = Right.left +local neither = Left.neither +function dog:bark() end +base:greet() +child:greet() +child:wave() +vehicle:honk() +print(other, built, point, bar, hello, level, missing, zone, zoneSize, inside, childClass, vehicleClass, animal) +print(held, typed, late, looped, loopedMissing, fromRight, fromLeft, neither) +"; + write("client/main.lua", text); + let main = "client/main.lua"; + let mut client = Client::start(fixture.0.clone()); + client.open_with(main, text); + + let cases: [(&str, &[&str], &[&str]); 24] = [ + ("local base =", &["greet: function", "new: function"], &[]), + // Each level of `__index`: `Child`'s own methods, and `Base`'s through its metatable. + ("local child =", &["wave: function", "greet: function", "level: integer"], &[]), + // `setmetatable(Other, Base)` gives `Other` the methods of `Base`. + ("local other =", &["greet: function"], &[]), + // `setmetatable(o, self)` inside `Animal:new`, and on a local before it is returned. A method + // defined on an instance is one of the instances, not of the class. + ("local dog =", &["speak: function", "bark: function"], &[]), + ("local built =", &["speak: function"], &[]), + // An instance of a `---@class` is that class, and the class keeps its own fields. + ("local point =", &["local point: Point"], &[]), + ("local bar =", &["local bar: Bar", "name: string"], &["greet"]), + ("local vehicle =", &["honk: function", "speed: integer"], &[]), + ("local hello =", &["local hello: integer"], &[]), + ("local level =", &["local level: integer"], &[]), + // An `__index` function decides the fields at runtime. + ("local missing =", &["local missing: unknown"], &[]), + // `self.__index = self` in the constructor of a global class, and in its subclass's. Making + // an instance of `Zone` one of `BoxZone` leaves `Zone` as it is, also for the fields set on + // the instance before. The `size` the instance holds shows over the one `Zone:resize` sets. + ("local zone =", &["contains: function", "size: integer"], &["corners", "width", "size: unknown"]), + ("local box =", &["corners: function", "contains: function", "width: integer"], &[]), + ("local inside =", &["local inside: boolean"], &[]), + // The fields a constructor sets on the new table belong to the instances, not to the class. + ("local childClass =", &["wave: function", "greet: function"], &["level"]), + ("local vehicleClass =", &["honk: function"], &["plate", "speed"]), + ("local animal =", &["speak: function"], &["bark"]), + // A method sets the field of an instance through `self`, so the `nil` the constructor sets + // first does not hide it. + ("local held =", &["local held: Thing"], &[]), + // A metatable that is its own `__index`, and two tables falling back on each other. + ("local looped =", &["loop: function"], &[]), + ("local loopedMissing =", &["local loopedMissing: unknown"], &[]), + ("local fromRight =", &["local function fromRight"], &[]), + ("local fromLeft =", &["local function fromLeft"], &[]), + ("local neither =", &["local neither: unknown"], &[]), + // An `__index` set after the call. + ("local late =", &["later: function"], &[]), + ]; + for (needle, expected, absent) in cases { + let (l, c) = pos(text, needle, 6); + let hover = client.hover_text(main, l, c); + for expected in expected { + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + for absent in absent { + assert!(!hover.contains(absent), "{needle}: unexpected {absent:?} in {hover}"); + } + } + // A field of an instance is named as one of its class, not of the table it was made from. + let (l, c) = pos(text, "zone.size", 5); + let hover = client.hover_text(main, l, c); + assert!(hover.contains("(field) Zone.size: integer"), "{hover}"); + let mismatches: Vec<(String, u64)> = + client.diagnostics_for(main).into_iter().filter(|(code, _)| code == "assign-type-mismatch").collect(); + assert!(mismatches.is_empty(), "`typed` takes the `Thing` that `Holder:set` stores: {mismatches:?}"); + + let (l, c) = pos(text, "child:wave", 6); + let labels = client.completion_labels(main, l, c); + for method in ["new", "wave", "greet"] { + assert!(labels.iter().any(|label| label == method), "{method} in {labels:?}"); + } + + // Where a location is, as (file, line). + let places = |result: &Value| -> Vec<(String, u64)> { + let mut places: Vec<(String, u64)> = result + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .map(|location| { + let uri = location["uri"].as_str().unwrap(); + (uri.rsplit('/').next().unwrap().to_string(), location["range"]["start"]["line"].as_u64().unwrap()) + }) + .collect(); + places.sort(); + places + }; + let line = |needle: &str| u64::from(pos(text, needle, 0).0); + let (l, c) = pos(text, "child:greet", 6); + let definition = client.request("textDocument/definition", client.position_params(main, l, c)); + assert_eq!(places(&definition), [("main.lua".to_string(), line("function Base:greet"))]); + let (l, c) = pos(text, "vehicle:honk", 8); + let definition = client.request("textDocument/definition", client.position_params(main, l, c)); + assert_eq!(places(&definition), [("honk.lua".to_string(), 0)]); + let (l, c) = pos(text, "child.level", 6); + let definition = client.request("textDocument/definition", client.position_params(main, l, c)); + assert_eq!(places(&definition), [("main.lua".to_string(), line("self.level = 1"))]); + + let greets = + ["function Base:greet", "base:greet", "child:greet"].map(|needle| ("main.lua".to_string(), line(needle))); + let (l, c) = pos(text, "Base:greet", 5); + let mut params = client.position_params(main, l, c); + params["context"] = json!({ "includeDeclaration": true }); + assert_eq!(places(&client.request("textDocument/references", params)), greets); + let mut params = client.position_params(main, l, c); + params["newName"] = json!("hello"); + let edit = client.request("textDocument/rename", params); + let edits = edit["changes"][client.uri(main).as_str()].as_array().cloned().unwrap_or_default(); + let mut renamed: Vec = edits.iter().map(|e| e["range"]["start"]["line"].as_u64().unwrap()).collect(); + renamed.sort(); + assert_eq!(renamed, greets.map(|(_, line)| line), "{edit}"); +} + +#[test] +fn tables_become_values_of_a_class_metatable_at_the_call() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class (exact) Meta.Foo +---@field a number +local Foo = {} +Foo.__index = Foo + +function Foo:get() return self.a end + +local function fromFields() + local self = { a = 1, b = 2 } + self.extra = 3 + return setmetatable(self, Foo) +end + +local function filledFirst() + local o = {} + o.a = 'one' + o.tmp = true + setmetatable(o, Foo) + o.a = 2 + return o +end + +local function readFirst() + local p = { b = 1 } + local early = p.b + return setmetatable(p, Foo), early +end + +---@class (exact) Meta.Indexed +---@field x number +local Indexed = {} + +local function viaIndex() + local self = setmetatable({}, { __index = Indexed }) + self.y = 1 + local t = { w = 1 } + setmetatable(t, { __index = Indexed }) + t.q = 2 + return self, t +end + +local made = fromFields() +local filled = filledFirst() +local built, early = readFirst() +local literal = setmetatable({ c = 1 }, Foo) +local plain = setmetatable({}, Foo) +local fromMade = made.b +---@param foo Meta.Foo +local function use(foo) return foo.a end +print(viaIndex, made, filled, built, early, literal, plain, fromMade, use) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + // What is set on a table before `setmetatable` gives it a class is no field of the class, and a + // table built with fields of its own keeps them, so it is no value of the class alone. An empty + // table is one from the call on. + assert_eq!( + undeclared_fields(&mut client, CLIENT), + [(line("self.y = 1"), "Field `y` is not declared in strict class `Meta.Indexed`".to_string())] + ); + let mismatches: Vec<(String, u64)> = + client.diagnostics_for(CLIENT).into_iter().filter(|(code, _)| code == "assign-type-mismatch").collect(); + assert!(mismatches.is_empty(), "{mismatches:?}"); + + let cases: [(&str, u32, &[&str]); 7] = [ + ("local made =", 6, &["b: integer", "get: function"]), + ("local early = p.b", 6, &["local early: integer"]), + ("local literal =", 6, &["c: integer", "get: function"]), + ("local plain =", 6, &["local plain: Meta.Foo"]), + ("local fromMade =", 6, &["local fromMade: integer"]), + ("made.b", 5, &["(field) Meta.Foo.b: integer"]), + ("---@param foo Meta.Foo", 14, &["(class) Meta.Foo", "a: number"]), + ]; + for (needle, delta, expected) in cases { + let (l, c) = pos(text, needle, delta); + let hover = client.hover_text(CLIENT, l, c); + for expected in expected { + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + } + let (l, c) = pos(text, "---@param foo Meta.Foo", 14); + let class = client.hover_text(CLIENT, l, c); + for absent in ["extra", "tmp", "b: integer", "a: string"] { + assert!(!class.contains(absent), "the class takes nothing set before the call: {absent:?} in {class}"); + } +} + +#[test] +fn deep_metatable_chains_leave_shallower_lookups_whole() { + // Each table falls back on the one before, deeper than a lookup follows. + let mut chain = String::from("local C1 = {}\nC1.v = 1\n"); + for i in 2..=40 { + chain.push_str(&format!("local C{i} = setmetatable({{}}, {{ __index = C{} }})\n", i - 1)); + } + let reads: String = (1..40).map(|i| format!("---@type string\nlocal s{i} = C{i}.v\n")).collect(); + let mut client = Client::start(fixture_root()); + client.open_with(CLIENT, &format!("{chain}{reads}")); + // Reading the deepest table first stops at the depth limit, which leaves the tables it passed + // whole for the reads after it. + client.open_with(SERVER, &format!("{chain}---@type string\nlocal deep = C40.v\n{reads}")); + let mut mismatched = |file: &str| -> Vec { + client.diagnostics_for(file); + let uri = client.uri(file).to_string(); + client.diagnostics[&uri] + .as_array() + .unwrap() + .iter() + .filter(|d| d["code"] == "assign-type-mismatch") + .map(|d| d["message"].as_str().unwrap().to_string()) + .filter(|message| !message.contains("`deep`")) + .collect() + }; + let resolved = mismatched(CLIENT); + assert!(resolved.len() >= 10, "{resolved:?}"); + assert_eq!(mismatched(SERVER), resolved); +} + #[test] fn hover_expands_aliases_and_lists_members_only_for_tables() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 38b7911..3d62254 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -337,6 +337,13 @@ above; fields set through values of the class do not count. A `[string]` index, `---@field [string] any`, takes any name, and so does a parent that is no class, like the `table` of `Name : table`. +A table that `setmetatable` gives a metatable whose `__index` is the class table, as +`setmetatable(obj, Test)` does after `Test.__index = Test`, is a value of the class from the call +on: `obj.more = 1` after the call is reported, and so is `self.more = 1` after +`local self = setmetatable({}, Test)`. What is set on `obj` before the call is not. A table built +with fields of its own, like `setmetatable({ id = 1 }, Test)`, keeps them, so its fields are not +checked against the class. + Other keys need an index of their type. With only `---@field [string] number`, `abc[1]`, `abc[1] = x` and array entries such as `{ 'a' }` are reported, since the class has no `integer` keys. A key held in a string variable, as in `abc[key]`, may name a field and is not reported. From 2a2b85e6368062e586358e8e79b86036ee7cae88 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:02:49 +0200 Subject: [PATCH 023/193] fix(luacats): read class parents as types --- crates/qbx_lua_ls/tests/lsp.rs | 39 ++++++++++++++++++- crates/qbx_luacats/src/luacats.rs | 62 +++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 14fc2ff..4808f62 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -848,6 +848,33 @@ local shared = left.wins assert!(hover.contains("[string]: number,\n [integer]: boolean,"), "{hover}"); } +#[test] +fn a_class_parent_written_as_a_union_is_its_first_type() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.First +---@field first string + +---@class Test.Second +---@field second string + +---@class Test.Either : Test.First | Test.Second + +---@type Test.Either +local either = {} + +local first = either.first +local second = either.second +"; + client.open_with(CLIENT, text); + // As lua-language-server reads it, and as the parent was read before parents were types. + for (needle, expected) in [("local first", "first: string"), ("local second", "second: unknown")] { + let (l, c) = pos(text, needle, 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn hover_infers_loop_variables_of_top_level_tables() { let mut client = Client::start(fixture_root()); @@ -2768,6 +2795,11 @@ function Strict:greet() end ---@class (strict) Test.StrictOpen ---@field [string] any +---@class Test.Pair +---@field left L + +---@class (strict) Test.StrictPair : Test.Pair + ---@type Test.Strict local abc = { test = '2543', @@ -2786,6 +2818,9 @@ local child = { test = 'x', extra = 1, greet = function() end, missing = 2 } ---@type Test.StrictOpen local open = { anything = 1 } +---@type Test.StrictPair +local pair = { left = 'a', right = 1 } + abc.test = 'y' abc.injected = true abc['quoted'] = 1 @@ -2802,7 +2837,7 @@ end loose.other = 1 ---@diagnostic disable-next-line: undeclared-field abc.skipped = 1 -print(abc, loose, child, open) +print(abc, loose, child, open, pair) "; client.open_with(CLIENT, text); let line = |needle: &str| pos(text, needle, 0).0 as u64; @@ -2813,6 +2848,8 @@ print(abc, loose, child, open) (line("other = true"), message("other", "Test.Strict")), (line("nope = 1"), message("nope", "Test.StrictInner")), (line("missing = 2"), message("missing", "Test.StrictChild")), + // The `integer` of `Test.Pair` is a type argument, not a parent. + (line("right = 1"), message("right", "Test.StrictPair")), (line("abc.injected"), message("injected", "Test.Strict")), (line("abc['quoted']"), message("quoted", "Test.Strict")), (line("abc.helper"), message("helper", "Test.Strict")), diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 374bbc1..77222bc 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -358,19 +358,10 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { match tag { "class" => { let (attributes, rest) = split_attributes(rest); - let (head, parents) = rest.split_once(':').unwrap_or((rest, "")); - let name = head.split_whitespace().next().unwrap_or("").split('<').next().unwrap_or(""); - if name.is_empty() { - continue; - } - let parents = parents - .split(',') - .filter_map(|p| p.split_whitespace().next()) - .map(|p| SmolStr::new(p.split('<').next().unwrap_or(p))) - .collect(); + let Some((name, _, after)) = class_head(rest) else { continue }; group.classes.push(DocClass { name: SmolStr::new(name), - parents, + parents: after.trim_start().strip_prefix(':').map(class_parents).unwrap_or_default(), description: description.join("\n").trim().to_string(), line: index, side: side_attribute(attributes), @@ -508,6 +499,39 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { group } +/// The name that a `@class` line declares, the list of type parameters after it, as `T, K` for +/// `Pair`, and the text after both. +fn class_head(rest: &str) -> Option<(&str, &str, &str)> { + let mut parser = TypeParser::new(rest); + let name = parser.ident()?; + let (params, after) = type_params(parser.rest()); + Some((name, params, after)) +} + +/// The list of type parameters that the text after a declared name starts with, as `T, K` for +/// ` : Base`, and the text after it. Without parameters, the list is empty. +fn type_params(after_name: &str) -> (&str, &str) { + after_name.strip_prefix('<').and_then(|list| list.split_once('>')).unwrap_or(("", after_name)) +} + +/// The parents a `@class` line lists after its `:`, by name: `Pair` for `Pair`. A +/// parent that is no class name, such as `{ [number]: T }` or `table`, is named by its +/// type, and the description after the list names none. A union such as `B1 | C1` names its first +/// type, as lua-language-server reads it. +fn class_parents(text: &str) -> Vec { + let parents = TypeParser::new(text).parse_list().into_iter().map(|parent| match parent { + Type::Union(types) => types.into_iter().find(|ty| *ty != Type::Nil).unwrap_or_default(), + parent => parent, + }); + parents + .filter(|parent| !parent.is_unknown()) + .map(|parent| match parent { + Type::Named(name, _) => name, + other => SmolStr::new(other.to_string()), + }) + .collect() +} + /// The text of a `@field` after its `(server)` attributes and `private` keyword, in either order, /// with the side the attributes name and the visibility the keyword gives. pub fn field_head(rest: &str) -> (Option, Visibility, &str) { @@ -1060,6 +1084,22 @@ mod tests { assert_eq!(format!("[{}] {}", index.key, index.ty), "[integer] any"); } + #[test] + fn class_parents_are_read_as_types() { + let parents = |text: &str| parse(text).classes[0].parents.clone(); + assert_eq!( + parents("---@class Entry : Pair, Base"), + ["Pair", "Base"], + "a comma between type arguments separates no parents" + ); + assert_eq!(parents("---@class Array : OxClass, { [number]: T }"), ["OxClass", "{ [number]: T }"]); + assert_eq!(parents("---@class Cache : table"), ["table"]); + assert_eq!(parents("---@class Child : Base the child, with a comma"), ["Base"]); + assert_eq!(parents("---@class Either : B1 | C1, Base"), ["B1", "Base"], "a union names its first type"); + assert!(parents("---@class Plain").is_empty()); + assert!(parents("---@class Linked https://example.com").is_empty(), "a colon in the description lists none"); + } + #[test] fn class_and_enum_attributes() { let doc = parse("---@class (partial) Player : Entity"); From 8fc0e3807215d71e9ca5c7cfed5d7f66f95f27d4 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:09:21 +0200 Subject: [PATCH 024/193] feat(infer): read generic classes through their type arguments --- crates/qbx_lua_ls/README.md | 4 +- crates/qbx_lua_ls/src/features/casts.rs | 4 +- .../qbx_lua_ls/src/features/class_tables.rs | 140 +++++---- crates/qbx_lua_ls/src/features/comparisons.rs | 4 +- crates/qbx_lua_ls/src/features/completion.rs | 2 +- crates/qbx_lua_ls/src/features/expected.rs | 10 +- crates/qbx_lua_ls/src/features/hover.rs | 24 +- .../qbx_lua_ls/src/features/strict_classes.rs | 2 +- crates/qbx_lua_ls/src/index.rs | 5 + crates/qbx_lua_ls/src/indexer.rs | 6 +- crates/qbx_lua_ls/src/infer.rs | 171 ++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 271 ++++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 95 +++--- docs/reference.md | 9 + 14 files changed, 601 insertions(+), 146 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 68303bb..6870a9e 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -331,7 +331,9 @@ and custom requests. ## Limits - Types support completion, hover and navigation. The server does not check assignment types - or provide full control-flow narrowing or generic inference. + or provide full control-flow narrowing. Generic functions and classes take the types their + arguments give them, but the constraints of their parameters, such as the `table` of + `---@generic T: table`, are not checked. - Field references depend on the inferred owner type. Computed keys and fields reached through unknown types may not be found; known declarations that cannot be edited safely prevent rename. - Formatting applies to whole documents. Range formatting is not implemented. diff --git a/crates/qbx_lua_ls/src/features/casts.rs b/crates/qbx_lua_ls/src/features/casts.rs index e7e8ae9..eba97cc 100644 --- a/crates/qbx_lua_ls/src/features/casts.rs +++ b/crates/qbx_lua_ls/src/features/casts.rs @@ -62,9 +62,9 @@ fn takes_class(classes: &Classes, declared: &Type, cast: &Type) -> bool { classes.flatten(declared, file, &mut parts, 0); parts.retain(|part| *part != Type::Nil); casts.iter().all(|cast| { - let Some(class) = classes.class_of(cast, file) else { return true }; + let Some((class, _)) = classes.class_of(cast, file) else { return true }; parts.iter().any(|part| match classes.class_of(part, file) { - Some(declared) => classes.extends(&class, file, &declared), + Some((declared, _)) => classes.extends(&class, file, &declared), None => !classes.rejects(part, file, cast), }) }) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 38df3ee..d50af98 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -18,7 +18,7 @@ use qbx_lua_syntax::{NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; -use crate::infer::Infer; +use crate::infer::{class_bindings, substitute, Infer}; use crate::luacats::applies_on; use crate::types::{DescribedValue, Type}; @@ -27,6 +27,8 @@ const MAX_DEPTH: u32 = 8; /// A table constructor and the class it has to be. pub struct ClassTable<'c> { pub class: SmolStr, + /// The type arguments the class is given, as the `string` of `List`. + pub args: Vec, pub table: &'c Expr, /// The file whose view of the type names decided the class. pub from: FileId, @@ -204,41 +206,54 @@ impl<'a, 'b> Classes<'a, 'b> { } } - /// The class a value of type `ty` must be, as `from` sees the names, looking through `?` and - /// aliases. - pub fn class_of(&self, ty: &Type, from: FileId) -> Option { + /// The class a value of type `ty` must be, with the type arguments it is given, as `from` sees + /// the names, looking through `?` and aliases. + pub fn class_of(&self, ty: &Type, from: FileId) -> Option<(SmolStr, Vec)> { match self.resolve(&ty.without_nil(), from, 0).without_nil() { - Type::Named(name, _) if !self.class_defs(&name, from).is_empty() => Some(name), + Type::Named(name, args) if !self.class_defs(&name, from).is_empty() => Some((name, args)), _ => None, } } - /// The `@field`s of `class` and its parents, leaving out those scoped to the other side. The - /// first declaration of a name wins, so a class can narrow a field of its parent. - pub fn fields(&self, class: &str, from: FileId) -> Vec { + /// The `@field`s of `class`, given the type arguments `args`, and of its parents, leaving out + /// those scoped to the other side. The first declaration of a name wins, so a class can narrow + /// a field of its parent. A class that several parents share is read once, and so is one that + /// names itself as a parent with other type arguments. + pub fn fields(&self, class: &str, args: &[Type], from: FileId) -> Vec { let mut out = Vec::new(); - self.collect_fields(class, from, &mut out, 0); + self.collect_fields(class, args, from, &mut out, &mut FxHashSet::default(), 0); out } - fn collect_fields(&self, class: &str, from: FileId, out: &mut Vec, depth: u32) { - if depth > MAX_DEPTH { + fn collect_fields( + &self, + class: &str, + args: &[Type], + from: FileId, + out: &mut Vec, + visited: &mut FxHashSet, + depth: u32, + ) { + if depth > MAX_DEPTH || !visited.insert(SmolStr::new(class)) { return; } let defs = self.class_defs(class, from); + let bindings = class_bindings(&defs, args); for (file, def) in &defs { let sides = def.field_sides.iter().copied().chain(std::iter::repeat(None)); for (i, (field, side)) in def.fields.iter().zip(sides).enumerate() { if applies_on(side, self.infer.side()) && !out.iter().any(|seen| seen.name == field.name) { - let (name, ty, doc) = (field.name.clone(), field.ty.clone(), field.doc.clone()); + let (name, ty, doc) = (field.name.clone(), substitute(&field.ty, &bindings), field.doc.clone()); let values = def.field_values.get(i).cloned().unwrap_or_default(); out.push(ClassField { name, ty, doc, values, file: *file }); } } } for (file, def) in &defs { - for parent in &def.parents { - self.collect_fields(parent, *file, out, depth + 1); + for parent in &def.parent_types { + if let Type::Named(parent, args) = substitute(parent, &bindings) { + self.collect_fields(&parent, &args, *file, out, visited, depth + 1); + } } } } @@ -263,8 +278,8 @@ impl<'a, 'b> Classes<'a, 'b> { return false; } for (file, def) in self.class_defs(class, from) { - for parent in &def.parents { - let Some(parent) = self.class_of(&Type::named(parent), file) else { continue }; + for parent in &def.parent_types { + let Some((parent, _)) = self.class_of(parent, file) else { continue }; if self.extends_at(&parent, file, ancestor, visited, depth + 1) { return true; } @@ -281,30 +296,40 @@ impl<'a, 'b> Classes<'a, 'b> { || (!defs.is_empty() && defs.iter().all(|(file, def)| def.strict.is_none() && by_default(*file))) } - /// The fields of `class` and its parents that are keyed by an integer or boolean literal, like - /// `---@field [1] number`, with their values and the files that declare them. The first - /// declaration of a key wins, as with named fields. - fn literal_fields(&self, class: &str, from: FileId) -> Vec<(Type, Type, FileId)> { + /// The fields of `class`, given the type arguments `args`, and of its parents that are keyed by + /// an integer or boolean literal, like `---@field [1] number`, with their values and the files + /// that declare them. The first declaration of a key wins, as with named fields. + fn literal_fields(&self, class: &str, args: &[Type], from: FileId) -> Vec<(Type, Type, FileId)> { let mut out = Vec::new(); - self.collect_literal_fields(class, from, &mut out, 0); + self.collect_literal_fields(class, args, from, &mut out, 0); out } - fn collect_literal_fields(&self, class: &str, from: FileId, out: &mut Vec<(Type, Type, FileId)>, depth: u32) { + fn collect_literal_fields( + &self, + class: &str, + args: &[Type], + from: FileId, + out: &mut Vec<(Type, Type, FileId)>, + depth: u32, + ) { if depth > MAX_DEPTH { return; } let defs = self.class_defs(class, from); + let bindings = class_bindings(&defs, args); for (file, def) in &defs { for (key, value) in def.literal_fields(self.infer.side()) { if !out.iter().any(|(seen, ..)| seen == key) { - out.push((key.clone(), value.clone(), *file)); + out.push((key.clone(), substitute(value, &bindings), *file)); } } } for (file, def) in &defs { - for parent in &def.parents { - self.collect_literal_fields(parent, *file, out, depth + 1); + for parent in &def.parent_types { + if let Type::Named(parent, args) = substitute(parent, &bindings) { + self.collect_literal_fields(&parent, &args, *file, out, depth + 1); + } } } } @@ -318,17 +343,21 @@ impl<'a, 'b> Classes<'a, 'b> { pub fn declares(&self, class: &str, from: FileId, key: &Key) -> bool { let named = match key { Key::Name(name) => { - self.fields(class, from).iter().any(|field| field.name == *name) + self.fields(class, &[], from).iter().any(|field| field.name == *name) || self.members_declare(class, from, name, 0) } Key::Typed(ty) => self.kinds(ty, self.file(), 0).is_none_or(|kinds| kinds & kind::STRING != 0), }; + let literal_fields = || self.literal_fields(class, &[], from); let literal = match key { - Key::Typed(ty) if ty.is_literal() => self.literal_fields(class, from).iter().any(|(key, ..)| key == ty), - Key::Typed(ty) => self.literal_fields(class, from).iter().any(|(key, _, file)| self.takes(key, *file, ty)), + Key::Typed(ty) if ty.is_literal() => literal_fields().iter().any(|(key, ..)| key == ty), + Key::Typed(ty) => literal_fields().iter().any(|(key, _, file)| self.takes(key, *file, ty)), Key::Name(_) => false, }; - named || literal || self.index_for(class, from, &key.ty(), 0).is_some() || self.has_open_parent(class, from, 0) + named + || literal + || self.index_for(class, &[], from, &key.ty(), 0).is_some() + || self.has_open_parent(class, from, 0) } /// Whether the table that the `---@class` annotation of `class` or a parent declares sets `name`. @@ -351,37 +380,44 @@ impl<'a, 'b> Classes<'a, 'b> { || defs.iter().any(|(file, def)| def.parents.iter().any(|p| self.has_open_parent(p, *file, depth + 1))) } - /// The type that `key` of a `class` table holds, with the file whose view of the type names it - /// is read with: the `@field` of a name or of a literal like `[1]`, or else the value of an - /// index that takes the key. A key such as `integer` that may be several fields has no one type. - pub fn field_type(&self, class: &str, from: FileId, key: &Key) -> Option<(Type, FileId)> { + /// The type that `key` of a `class` table holds, given the type arguments `args` of the class, + /// with the file whose view of the type names it is read with: the `@field` of a name or of a + /// literal like `[1]`, or else the value of an index that takes the key. A key such as `integer` + /// that may be several fields has no one type. + pub fn field_type(&self, class: &str, args: &[Type], from: FileId, key: &Key) -> Option<(Type, FileId)> { let field = match key { Key::Name(name) => { - self.fields(class, from).into_iter().find(|field| field.name == *name).map(|f| (f.ty, f.file)) + self.fields(class, args, from).into_iter().find(|field| field.name == *name).map(|f| (f.ty, f.file)) } Key::Typed(ty) if ty.is_literal() => { - self.literal_fields(class, from).into_iter().find(|(key, ..)| key == ty).map(|(_, v, file)| (v, file)) + let mut fields = self.literal_fields(class, args, from).into_iter(); + fields.find(|(key, ..)| key == ty).map(|(_, v, file)| (v, file)) } Key::Typed(_) => None, }; - field.or_else(|| self.index_for(class, from, &key.ty(), 0)) + field.or_else(|| self.index_for(class, args, from, &key.ty(), 0)) } - /// The value type of the first index of `class` or a parent that takes keys of type `key`, - /// inferred in this file, with the file that declares it. - fn index_for(&self, class: &str, from: FileId, key: &Type, depth: u32) -> Option<(Type, FileId)> { + /// The value type of the first index of `class`, given the type arguments `args`, or of a parent + /// that takes keys of type `key`, inferred in this file, with the file that declares it. + fn index_for(&self, class: &str, args: &[Type], from: FileId, key: &Type, depth: u32) -> Option<(Type, FileId)> { if depth > MAX_DEPTH { return None; } let defs = self.class_defs(class, from); + let bindings = class_bindings(&defs, args); let own = defs.iter().find_map(|(file, def)| { let mut indices = def.indices(self.infer.side()).into_iter(); - let (_, value) = indices.find(|(index_key, _)| self.takes(index_key, *file, key))?; - Some((value.clone(), *file)) + let (_, value) = + indices.find(|(index_key, _)| self.takes(&substitute(index_key, &bindings), *file, key))?; + Some((substitute(value, &bindings), *file)) }); own.or_else(|| { - let mut parents = defs.iter().flat_map(|(file, def)| def.parents.iter().map(move |p| (p, *file))); - parents.find_map(|(parent, file)| self.index_for(parent, file, key, depth + 1)) + let mut parents = defs.iter().flat_map(|(file, def)| def.parent_types.iter().map(move |p| (p, *file))); + parents.find_map(|(parent, file)| match substitute(parent, &bindings) { + Type::Named(parent, args) => self.index_for(&parent, &args, file, key, depth + 1), + _ => None, + }) }) } @@ -696,7 +732,7 @@ pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let ExprKind::Table(fields) = &found.table.kind else { return None }; let given: Vec<&str> = fields.iter().filter_map(named_field).map(|(name, _)| name).collect(); let missing: Vec = classes - .fields(&found.class, found.from) + .fields(&found.class, &found.args, found.from) .iter() .filter(|field| !classes.admits_nil(&field.ty, field.file) && !given.contains(&field.name.as_str())) .map(|field| format!("`{}`", field.name)) @@ -714,8 +750,8 @@ pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { pub fn mismatched_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let classes = Classes::new(infer); let mut out = Vec::new(); - let mut check = |class: &str, from: FileId, key: &Key, value: &Expr| { - let Some((ty, file)) = classes.field_type(class, from, key) else { return }; + let mut check = |class: &str, args: &[Type], from: FileId, key: &Key, value: &Expr| { + let Some((ty, file)) = classes.field_type(class, args, from, key) else { return }; let given = classes.value_type(value); if classes.rejects(&ty, file, &given) { let shown = if classes.literal_mismatch(&ty, file, &given) { given } else { given.widen() }; @@ -730,14 +766,14 @@ pub fn mismatched_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let ExprKind::Table(fields) = &found.table.kind else { continue }; for (key, _, value) in entries(infer, fields) { if let Some(value) = value { - check(&found.class, found.from, &key, value); + check(&found.class, &found.args, found.from, &key, value); } } } for access in accesses(infer, chunk, false) { // `value.field = nil` clears a field only when its type allows `nil`, as `string?` does. - if let (Some(value), Some(class)) = (access.value, classes.class_of(&access.owner, classes.file())) { - check(&class, classes.file(), &access.key, value); + if let (Some(value), Some((class, args))) = (access.value, classes.class_of(&access.owner, classes.file())) { + check(&class, &args, classes.file(), &access.key, value); } } out @@ -759,12 +795,12 @@ impl<'c> Finder<'_, '_, 'c> { fn table(&mut self, expected: &Type, expr: &'c Expr, from: FileId, depth: u32) { let table = expr.unparen(); let ExprKind::Table(fields) = &table.kind else { return }; - let Some(class) = self.classes.class_of(expected, from) else { return }; - self.out.push(ClassTable { class: class.clone(), table, from }); + let Some((class, args)) = self.classes.class_of(expected, from) else { return }; + self.out.push(ClassTable { class: class.clone(), args: args.clone(), table, from }); if depth < MAX_DEPTH { for (key, _, value) in entries(self.classes.infer, fields) { let Some(value) = value.filter(|value| is_table(value)) else { continue }; - if let Some((ty, file)) = self.classes.field_type(&class, from, &key) { + if let Some((ty, file)) = self.classes.field_type(&class, &args, from, &key) { self.table(&ty, value, file, depth + 1); } } diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index 5d38801..c86c35d 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -197,8 +197,8 @@ impl<'a, 'b> Declared<'a, 'b> { fn field(&self, base: &Expr, name: &str, depth: u32) -> Type { let base = self.declared(base, depth + 1); let from = self.classes.file(); - let Some(class) = self.classes.class_of(&base, from) else { return Type::Unknown }; - self.classes.field_type(&class, from, &Key::Name(name)).map(|(ty, _)| ty).unwrap_or_default() + let Some((class, args)) = self.classes.class_of(&base, from) else { return Type::Unknown }; + self.classes.field_type(&class, &args, from, &Key::Name(name)).map(|(ty, _)| ty).unwrap_or_default() } /// What `call` returns when its function declares it. A native documented as `boolean` can diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index 5b0314a..52aacd0 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -1395,7 +1395,7 @@ fn expected_field_items(infer: &Infer, doc: &Document, offset: u32) -> Vec = existing.iter().filter_map(named_field).map(|(name, _)| name).collect(); let class = Type::Named(found.class.clone(), Vec::new()); return Classes::new(infer) - .fields(&found.class, found.from) + .fields(&found.class, &found.args, found.from) .into_iter() .filter(|field| !present.contains(field.name.as_str()) && is_identifier(&field.name)) .filter(|field| scope.allows(&class, None, &field.name, offset)) diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index dec6119..cfed2cc 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -98,9 +98,9 @@ impl Finder<'_, '_> { let tables = class_tables(self.infer, self.infer.ctx.chunk); let found = tables.into_iter().find(|found| found.table.span == table.span)?; let classes = Classes::new(self.infer); - let (ty, _) = classes.field_type(&found.class, found.from, key)?; + let (ty, _) = classes.field_type(&found.class, &found.args, found.from, key)?; let values = match key { - Key::Name(name) => field_values(&classes, &found.class, found.from, name), + Key::Name(name) => field_values(&classes, &found.class, &found.args, found.from, name), Key::Typed(_) => Vec::new(), }; Some(ExpectedValue::stored(ty, values)) @@ -154,7 +154,7 @@ impl Finder<'_, '_> { let classes = Classes::new(self.infer); let from = classes.file(); match classes.class_of(&self.infer.expr(base), from) { - Some(class) => field_values(&classes, &class, from, name), + Some((class, args)) => field_values(&classes, &class, &args, from, name), None => Vec::new(), } } @@ -174,8 +174,8 @@ impl Finder<'_, '_> { } /// The described values of the `@field` called `name` of `class`, or of a parent, as `from` sees it. -fn field_values(classes: &Classes, class: &str, from: FileId, name: &str) -> Vec { - let mut fields = classes.fields(class, from).into_iter(); +fn field_values(classes: &Classes, class: &str, args: &[Type], from: FileId, name: &str) -> Vec { + let mut fields = classes.fields(class, args, from).into_iter(); fields.find(|field| field.name == name).map(|field| field.values).unwrap_or_default() } diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 0962c4a..cb0ddff 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -15,7 +15,7 @@ use crate::index::{ use crate::indexer::{described_values, render_doc}; use crate::infer::{Decl, Infer, MemberInfo}; use crate::locate::locate; -use crate::luacats::{applies_on, type_name_at}; +use crate::luacats::{applies_on, own_type, type_name_at}; use crate::types::{CallbackRole, DescribedValue, Type}; use crate::workspace::Workspace; @@ -110,8 +110,9 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O } // The fields are those of the value when it is not nil; the `?` still says it may be. let optional = if *ty != bare && matches!(ty, Type::Union(types) if types.contains(&Type::Nil)) { "?" } else { "" }; + // A class with its type arguments, as `List`. let label = match &bare { - Type::Named(class, _) => format!("{class}{optional} "), + Type::Named(..) => format!("{bare}{optional} "), _ => String::new(), }; let mut out = format!("{prefix}{name}: {label}{{"); @@ -207,6 +208,14 @@ fn alias_definition(name: &str, alias: &AliasDef) -> String { format!("type {name} ={}", described_values(&values)) } +/// The name of a class with the type parameters it declares, as `List`. +fn with_params(name: &str, generics: &[SmolStr]) -> String { + match generics { + [] => name.to_string(), + generics => format!("{name}<{}>", generics.join(", ")), + } +} + /// `offset` is where the name is written, which decides what the guards around it rule out. fn local_hover(infer: &Infer, id: LocalId, offset: u32, called: Option) -> String { let local = infer.ctx.resolution.local(id); @@ -333,16 +342,19 @@ fn owner_label(owner: &Type) -> String { }; match label { Type::GlobalTable(path) if path.starts_with('%') => String::new(), + // `Promise:New` of any `Promise`, as lua-language-server names it. + Type::Named(class, _) => class.to_string(), other => other.to_string(), } } fn class_hover(infer: &Infer, class: &ClassDef) -> String { - let mut declaration = format!("(class) {}", class.name); - if !class.parents.is_empty() { - declaration.push_str(&format!(" : {}", class.parents.join(", "))); + let mut declaration = format!("(class) {}", with_params(&class.name, &class.generics)); + if !class.parent_types.is_empty() { + let parents: Vec = class.parent_types.iter().map(Type::to_string).collect(); + declaration.push_str(&format!(" : {}", parents.join(", "))); } - let members = infer.members(&Type::Named(class.name.clone(), Vec::new())); + let members = infer.members(&own_type(&class.name, &class.generics)); let mut fields: Vec = members.iter().map(|member| format!("{}: {}", member.name, member.ty)).collect(); fields.extend(class.literal_fields(infer.side()).map(|(key, value)| format!("[{key}]: {value}"))); let indices = class.indices(infer.side()); diff --git a/crates/qbx_lua_ls/src/features/strict_classes.rs b/crates/qbx_lua_ls/src/features/strict_classes.rs index 886bcbd..5ec049f 100644 --- a/crates/qbx_lua_ls/src/features/strict_classes.rs +++ b/crates/qbx_lua_ls/src/features/strict_classes.rs @@ -32,7 +32,7 @@ pub fn undeclared_fields(infer: &Infer, chunk: &Chunk, by_default: impl Fn(FileI } let file = classes.file(); for access in accesses(infer, chunk, true).into_iter().filter(|access| !access.on_class_table) { - let Some(class) = classes.class_of(&access.owner, file) else { continue }; + let Some((class, _)) = classes.class_of(&access.owner, file) else { continue }; if classes.is_strict(&class, file, &by_default) && !classes.declares(&class, file, &access.key) { out.push((access.span, message(&access.key, &class))); } diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 4935600..5226ccb 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -94,7 +94,12 @@ pub struct Metatable { #[derive(Clone, Debug)] pub struct ClassDef { pub name: SmolStr, + /// The type parameters of `@class List`, which a reference such as `List` binds. + pub generics: Vec, pub parents: Vec, + /// Each of `parents` as written: a class with its type arguments, as `List`, or a table + /// type such as `{ [number]: T }` or `table`. + pub parent_types: Vec, pub fields: Vec, /// The side each of `fields` is scoped to by `@field (server) name type`. pub field_sides: Vec>, diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index a9f5113..d1063f4 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -16,7 +16,7 @@ use crate::index::{ Metatable, NuiCallbackDef, Symbol, SymbolKind, }; use crate::infer::{metatable_args, table_elements, table_fields, FileContext, Infer}; -use crate::luacats::{parse_doc_lines, DocGroup}; +use crate::luacats::{own_type, parse_doc_lines, DocGroup}; use crate::types::{CallbackRole, DescribedValue, FunType, Type}; const MAX_TABLE_DEPTH: u32 = 4; @@ -215,7 +215,9 @@ impl<'a> Indexer<'a> { .collect(); self.out.classes.push(ClassDef { name: class.name, + generics: class.generics, parents: class.parents, + parent_types: class.parent_types, fields, field_sides, field_visibility, @@ -299,7 +301,7 @@ impl<'a> Indexer<'a> { self.table_members(class.name.clone(), fields, table_depth + 1); } kind = SymbolKind::Table; - Type::Named(class.name.clone(), Vec::new()) + own_type(&class.name, &class.generics) } else if let Some(ty) = doc.type_at(position) { ty.clone() } else { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 489b638..4b999d0 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -12,9 +12,9 @@ use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::callback_wrappers::{self, Wrapper}; -use crate::index::{instance_class, instance_owner, FileId, Index, SymbolKind}; +use crate::index::{instance_class, instance_owner, ClassDef, FileId, Index, SymbolKind}; use crate::locate::statement_at; -use crate::luacats::{applies_on, parse_doc_lines, CastEntry, DocGroup}; +use crate::luacats::{applies_on, own_type, parse_doc_lines, CastEntry, DocGroup}; use crate::narrow::{Casts, Guards}; use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeParser}; @@ -932,7 +932,7 @@ impl<'a> Infer<'a> { let StmtKind::Local { names, exprs, in_unpack } = &stmt.kind else { return Type::Unknown }; let doc = self.ctx.doc_at(stmt.span.start); if let Some(class) = doc.classes.last() { - return Type::Named(class.name.clone(), Vec::new()); + return own_type(&class.name, &class.generics); } if let Some(ty) = doc.type_at(index) { return ty.clone(); @@ -1227,19 +1227,20 @@ impl<'a> Infer<'a> { let index = shape.indices.iter().filter(|(key, _)| !array_only || is_integer_key(key)).cloned(); fields.chain(array).chain(index).unzip() } - Type::Named(ref name, _) => { + Type::Named(ref name, ref args) => { let class = self.index.class(name, self.side).map(|(_, c)| c); + let bindings = self.class_bindings_of(name, args); let index = class .into_iter() .flat_map(|c| c.indices(self.side)) .filter(|(key, _)| !array_only || is_integer_key(key)) - .map(|(key, value)| (key.clone(), value.clone())); + .map(|(key, value)| (substitute(key, &bindings), substitute(value, &bindings))); // `---@field [1] number` fields, with their keys shown as `integer` rather than `1|2|3`. let literal_fields = class .into_iter() .flat_map(|c| c.literal_fields(self.side)) .filter(|(key, _)| !array_only || is_integer_key(key)) - .map(|(key, value)| (key.widen(), value.clone())); + .map(|(key, value)| (key.widen(), substitute(value, &bindings))); // An instance holds its data; the methods its class provides are not visited. let fields = self .members(&resolved) @@ -1335,6 +1336,14 @@ impl<'a> Infer<'a> { current } + /// The type parameters of the class `name` bound to the type arguments `args`, as a declaration + /// of it for this side lists them: `---@class (client) Box` binds `U`. + fn class_bindings_of(&self, name: &str, args: &[Type]) -> Vec<(SmolStr, Type)> { + let mut defs = self.index.class_defs(name); + defs.retain(|(_, def)| applies_on(def.side, self.side)); + class_bindings(&defs, args) + } + fn module_type(&self, path: &str) -> Option { let file = self.index.resolve_require(path, self.ctx.file)?; self.index.file(file)?.index.module_return.clone() @@ -1371,18 +1380,19 @@ impl<'a> Infer<'a> { let indices = shape.indices.iter().filter(|(key, _)| self.index_takes(key, key_ty, 0)); Type::union(array.chain(indices.map(|(_, value)| value)).cloned()) } - Type::Named(name, _) => { + Type::Named(name, args) => { let Some((_, class)) = self.index.class(&name, self.side) else { return Type::Unknown }; let key_ty = self.resolve_alias(key_ty); - let index = self.class_index_value(&name, &key_ty, &mut FxHashSet::default(), 0); + let index = self.class_index_value(&name, &args, &key_ty, &mut FxHashSet::default(), 0); + let bindings = self.class_bindings_of(&name, &args); // `employee[1]` reads the `---@field [1] number` of its key, and `employee[i]` any // literal-keyed field that `i` may name. if key_ty.is_literal() { let field = class.literal_fields(self.side).find(|(key, _)| **key == key_ty); - return field.map_or(index, |(_, value)| value.clone()); + return field.map_or(index, |(_, value)| substitute(value, &bindings)); } let fields = class.literal_fields(self.side).filter(|(key, _)| may_be_literal_key(&key_ty, key)); - Type::union(fields.map(|(_, value)| value.clone()).chain([index])) + Type::union(fields.map(|(_, value)| substitute(value, &bindings)).chain([index])) } // `list[i]` on a table whose array part the index or a top-level local's constructor holds. Type::GlobalTable(owner) @@ -1416,22 +1426,34 @@ impl<'a> Infer<'a> { } } - /// What a key of type `key` reads from an instance of `class` through the nearest indices of - /// the class or its parents that take it. A class that several parents share is read once. - fn class_index_value(&self, class: &str, key: &Type, visited: &mut FxHashSet, depth: u32) -> Type { + /// What a key of type `key` reads from an instance of `class`, given the type arguments `args`, + /// through the nearest indices of the class or its parents that take it. A class that several + /// parents share is read once. + fn class_index_value( + &self, + class: &str, + args: &[Type], + key: &Type, + visited: &mut FxHashSet, + depth: u32, + ) -> Type { if depth > 8 || !visited.insert(SmolStr::new(class)) { return Type::Unknown; } let mut defs = self.index.class_defs(class); defs.retain(|(_, def)| applies_on(def.side, self.side)); + let bindings = class_bindings(&defs, args); let indices = defs.iter().flat_map(|(_, def)| def.indices(self.side)); - let taking = indices.filter(|(index, _)| self.index_takes(index, key, 0)); - let own = Type::union(taking.map(|(_, value)| value.clone())); + let taking = indices.filter(|(index, _)| self.index_takes(&substitute(index, &bindings), key, 0)); + let own = Type::union(taking.map(|(_, value)| substitute(value, &bindings))); if !own.is_unknown() { return own; } - let parents = defs.iter().flat_map(|(_, def)| &def.parents); - Type::union(parents.map(|parent| self.class_index_value(parent, key, visited, depth + 1))) + let parents = defs.iter().flat_map(|(_, def)| &def.parent_types).map(|parent| substitute(parent, &bindings)); + Type::union(parents.map(|parent| match parent { + Type::Named(parent, args) => self.class_index_value(&parent, &args, key, visited, depth + 1), + _ => Type::Unknown, + })) } /// The field names a key can be: `'male'`, or every name of a `"male"|"female"` loop variable or @@ -1643,9 +1665,14 @@ impl<'a> Infer<'a> { return Some((fun.clone(), None)); } match self.resolve_alias(&ty) { - Type::Named(name, _) => { + Type::Named(name, args) => { let call = self.index.class(&name, self.side).and_then(|(_, c)| c.call.clone()); - call.filter(|f| applies_on(f.side, self.side)).map(|f| (f, None)) + let call = call.filter(|f| applies_on(f.side, self.side))?; + let bindings = self.class_bindings_of(&name, &args); + match bindings.is_empty() { + true => Some((call, None)), + false => Some((Arc::new(substitute_fun(&call, &bindings)), None)), + } } _ => None, } @@ -1845,6 +1872,16 @@ impl<'a> Infer<'a> { } _ => {} }, + // `List` takes the `string` of a `List`. + Type::Named(name, params) if !params.is_empty() => { + if let Type::Named(given, args) = self.resolve_alias(&arg.without_nil()) { + if given == *name { + for (param, arg) in params.iter().zip(&args) { + self.unify(fun, param, arg, returned, bound, depth + 1); + } + } + } + } Type::Array(inner) => { self.unify(fun, inner, &self.key_value_types(arg, true).1, returned, bound, depth + 1); } @@ -1907,7 +1944,8 @@ impl<'a> Infer<'a> { /// Whether an argument passed for `param` can bind a generic of `fun`, as far as `unify` looks. fn can_bind(&self, fun: &FunType, param: &Type) -> bool { match param { - Type::Named(name, args) => args.is_empty() && self.is_generic(fun, name), + Type::Named(name, args) if args.is_empty() => self.is_generic(fun, name), + Type::Named(_, args) => args.iter().any(|arg| self.can_bind(fun, arg)), Type::NameOf(name) => self.is_generic(fun, name), Type::Array(inner) => self.can_bind(fun, inner), Type::Map(key, value) => self.can_bind(fun, key) || self.can_bind(fun, value), @@ -2316,8 +2354,8 @@ impl<'a> Infer<'a> { Type::union(values.map(|(_, value)| value.clone())) } Type::Map(index, value) if self.index_takes(index, key, 0) => (**value).clone(), - Type::Named(name, _) if self.index.class(name, self.side).is_some() => { - self.class_index_value(name, key, &mut FxHashSet::default(), depth + 1) + Type::Named(name, args) if self.index.class(name, self.side).is_some() => { + self.class_index_value(name, args, key, &mut FxHashSet::default(), depth + 1) } Type::Named(..) | Type::Require(_) => match self.resolve_alias(ty) { resolved if resolved != *ty => self.index_value(&resolved, key, depth + 1), @@ -2356,7 +2394,7 @@ impl<'a> Infer<'a> { }); } } - Type::Named(name, _) => self.class_members(name, filter, &mut out, 0), + Type::Named(name, args) => self.class_members(name, args, filter, &mut out, &mut FxHashSet::default(), 0), Type::GlobalTable(owner) => self.owner_members(owner, filter, &mut out), Type::String | Type::StringLit(_) => { let library = self.global_type("string"); @@ -2525,8 +2563,20 @@ impl<'a> Infer<'a> { } } - fn class_members(&self, name: &str, filter: Option<&str>, out: &mut Vec, depth: u32) { - if depth > 8 { + /// The members of the class or alias `name` given the type arguments `args`, with those of its + /// parents: the fields of a class and those set on the table its `---@class` declares. A class + /// that several parents share is read once, and so is one that names itself as a parent with + /// other type arguments, as `---@class Tree : Tree` does. + fn class_members( + &self, + name: &str, + args: &[Type], + filter: Option<&str>, + out: &mut Vec, + visited: &mut FxHashSet, + depth: u32, + ) { + if depth > 8 || !visited.insert(SmolStr::new(name)) { return; } let mut defs = self.index.class_defs(name); @@ -2537,6 +2587,7 @@ impl<'a> Infer<'a> { } return; } + let own = out.len(); for (file, class) in &defs { let sides = class.field_sides.iter().copied().chain(std::iter::repeat(None)); for (field, side) in class.fields.iter().zip(sides) { @@ -2546,9 +2597,15 @@ impl<'a> Infer<'a> { } } self.owner_members(name, filter, out); - for (_, class) in defs { - for parent in &class.parents { - self.class_members(parent, filter, out, depth + 1); + let bindings = class_bindings(&defs, args); + if !bindings.is_empty() { + for member in &mut out[own..] { + member.ty = substitute(&member.ty, &bindings); + } + } + for parent in defs.iter().flat_map(|(_, class)| &class.parent_types) { + if let Type::Named(parent, args) = substitute(parent, &bindings) { + self.class_members(&parent, &args, filter, out, visited, depth + 1); } } } @@ -2597,7 +2654,28 @@ fn member_from_symbol(file: FileId, symbol: &crate::index::Symbol) -> MemberInfo } } -fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { +/// What the type parameters of a class stand for in a reference that gives them `args`: `string` +/// for the `T` of `List`. A parameter left without an argument stays itself, as the `T` +/// of a `---@type List` does, which lua-language-server shows as ``: a field of that type takes +/// any value but is still required, and a method's own `@generic T` binds from the call. The table +/// a `---@class` declares, and `self` in its methods, keep the parameters too, being `List`. +/// Binding `T` to itself would change nothing. +pub(crate) fn generic_bindings(params: &[SmolStr], args: &[Type]) -> Vec<(SmolStr, Type)> { + let itself = + |param: &SmolStr, arg: &Type| matches!(arg, Type::Named(name, args) if name == param && args.is_empty()); + params.iter().cloned().zip(args.iter().cloned()).filter(|(param, arg)| !itself(param, arg)).collect() +} + +/// The type parameters of a class bound to `args`, as the declaration of it among `defs` that +/// lists them declares them. +pub(crate) fn class_bindings(defs: &[(FileId, &ClassDef)], args: &[Type]) -> Vec<(SmolStr, Type)> { + match defs.iter().find(|(_, def)| !def.generics.is_empty()) { + Some((_, def)) => generic_bindings(&def.generics, args), + None => Vec::new(), + } +} + +pub(crate) fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { if generics.is_empty() { return ty.clone(); } @@ -2622,18 +2700,7 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { } } Type::Map(k, v) => Type::Map(Box::new(substitute(k, generics)), Box::new(substitute(v, generics))), - Type::Fun(fun) => Type::Fun(Arc::new(FunType { - params: fun.params.iter().map(|p| Param { ty: substitute(&p.ty, generics), ..p.clone() }).collect(), - returns: fun.returns.iter().map(|t| substitute(t, generics)).collect(), - return_sets: fun - .return_sets - .iter() - .map(|set| set.iter().map(|t| substitute(t, generics)).collect()) - .collect(), - is_method: fun.is_method, - returns_inferred: fun.returns_inferred, - ..FunType::default() - })), + Type::Fun(fun) => Type::Fun(Arc::new(substitute_fun(fun, generics))), Type::Shape(shape) => Type::Shape(Arc::new(Shape { fields: shape.fields.iter().map(|f| ShapeField { ty: substitute(&f.ty, generics), ..f.clone() }).collect(), array: shape.array.as_ref().map(|t| substitute(t, generics)), @@ -2643,6 +2710,28 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { } } +/// `fun` with `generics` bound in its parameters, returned values and overloads, as a method of +/// `List` read from a `List`. The names it binds are no longer generics of `fun`, even +/// when `fun` declares one of them with `@generic` too, as lua-language-server binds them. +fn substitute_fun(fun: &FunType, generics: &[(SmolStr, Type)]) -> FunType { + let types = |types: &[Type]| types.iter().map(|t| substitute(t, generics)).collect(); + let unbound = |name: &&SmolStr| !generics.iter().any(|(bound, _)| bound == *name); + FunType { + params: fun.params.iter().map(|p| Param { ty: substitute(&p.ty, generics), ..p.clone() }).collect(), + returns: types(&fun.returns), + return_values: fun.return_values.clone(), + return_sets: fun.return_sets.iter().map(|set| types(set)).collect(), + returns_inferred: fun.returns_inferred, + is_method: fun.is_method, + lists_receiver: fun.lists_receiver, + generics: fun.generics.iter().filter(unbound).cloned().collect(), + overloads: fun.overloads.iter().map(|overload| Arc::new(substitute_fun(overload, generics))).collect(), + side: fun.side, + callback: fun.callback.clone(), + is_async: fun.is_async, + } +} + /// The constructor behind a table-valued initialiser, looking through `setmetatable({...}, mt)`. pub fn table_fields(expr: &Expr) -> Option<&[TableField]> { match &expr.unparen().kind { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 4808f62..a9e1a16 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4784,6 +4784,277 @@ local reassigned = first(changing) assert_eq!(declare, Some("The function can reach its end without returning, but `@return` requires `T`")); } +#[test] +fn generic_classes_bind_their_type_parameters() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.List +---@field first T +---@field items T[] +---@field getter fun(): T +---@field pick fun(self: Test.List, i: integer): T? +---@field [integer] T +---@field next? self +local List = {} + +---@return T +function List:get() return self.first end + +---@return self +function List:chain() return self end + +---@generic U +---@param f fun(value: T): U +---@return Test.List +function List:map(f) end + +---@generic T +---@param value T +---@return T +function List:id(value) return value end + +---@generic T +---@param kind `T` +---@return Test.List +function List:of(kind) end + +function List:inside() + local own = self.first +end + +---@class Test.Strings : Test.List + +---@class Test.Pair +---@field left L +---@field right R + +---@class Test.Swap : Test.Pair + +---@class Test.Mid : Test.List + +---@class Test.Leaf : Test.Mid + +---@class Test.Factory +---@overload fun(): T + +---@class Test.Tree : Test.Tree +---@field node T + +---@generic T +---@param from Test.List +---@return T +local function firstOf(from) end + +---@type Test.List +local list = {} +---@type Test.Strings +local strings = {} +---@type Test.List> +local nested = {} +---@type Test.List +local bare = {} +---@type Test.Pair +local half = {} +---@type Test.Pair +local extra = {} +---@type Test.Swap +local swap = {} +---@type Test.Leaf +local leaf = {} +---@type Test.Factory +local factory = nil +---@type Test.Tree +local tree = {} + +local first = list.first +local items = list.items +local got = list.getter() +local picked = list:pick(1) +local indexed = list[1] +local returned = list:get() +local following = list.next +local chained = list:chain().first +local mapped = list:map(function(value) return 1 end) +local same = list:id(1) +local fromTable = List:id(1) +local ofKind = List:of('integer').first +local inherited = strings:get() +local inner = nested.first.first +local unbound = bare.first +local bareSame = bare:id(1) +local missing = half.right +local ignored = extra.right +local swapped = swap.left +local deep = leaf.first +local made = factory() +local bound = firstOf(list) +local node = tree.node +for _, each in ipairs(list.items) do print(each) end +list.first = 2 +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("local list", "list: Test.List {"), + ("local first", "first: string"), + ("local items", "items: string[]"), + ("local got", "got: string"), + ("local picked", "picked: string?"), + ("local indexed", "indexed: string"), + ("local returned", "returned: string"), + // `self` keeps the type arguments of the value it is read from. + ("local following", "following: Test.List? {"), + ("local chained", "chained: string"), + ("local mapped", "mapped: Test.List {"), + ("value) return 1", "value: string"), + // A method's own `@generic T` is the `T` of the class, as lua-language-server binds it. + ("local same", "same: string"), + // On the class table the method binds its own `T`, from the value or the class it names. + ("local fromTable", "fromTable: integer"), + ("local ofKind", "ofKind: integer"), + ("local inherited", "inherited: string"), + ("local inner", "inner: integer"), + // Without type arguments the parameters stay themselves, as lua-language-server shows them, + // and as the class table and `self` in its methods keep them: `list.first = 2` gives them + // no type, and a method's own `@generic T` binds from the call. + ("local unbound", "unbound: T"), + ("local bareSame", "bareSame: integer"), + ("local own", "own: T"), + ("local List", "List: Test.List {"), + ("local missing", "missing: R"), + ("local ignored", "ignored: integer"), + ("local swapped", "swapped: integer"), + ("local deep", "deep: boolean[]"), + ("local made", "made: integer"), + ("local bound", "bound: string"), + // A class that names itself as a parent with other type arguments is read once. + ("local node", "node: integer\n"), + ("each in", "each: string"), + ] { + let delta = if needle.starts_with("local ") { 6 } else { 0 }; + let (l, c) = pos(text, needle, delta); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let (l, c) = pos(text, "Test.Swap : Test.Pair"), "{hover}"); + // A method is named by its class alone, as lua-language-server names it. + for (needle, expected) in + [("list:get", "function Test.List:get(): string"), ("List:get", "function Test.List:get(): T")] + { + let (l, c) = pos(text, needle, 5); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + + let completed = format!("{text}local _ = list."); + client.change(CLIENT, 2, &completed); + let line = completed.matches('\n').count() as u32; + let result = client.request("textDocument/completion", client.position_params(CLIENT, line, 15)); + let items = result["items"].as_array().cloned().unwrap_or_default(); + let detail = |label: &str| { + let item = items.iter().find(|item| item["label"] == label); + item.and_then(|item| item["detail"].as_str()).unwrap_or_default().to_string() + }; + assert_eq!(detail("first"), "string", "{result}"); + assert_eq!(detail("items"), "string[]", "{result}"); +} + +#[test] +fn generic_classes_bind_the_type_parameters_their_side_declares() { + let mut client = Client::start(fixture_root()); + client.open_with( + "myresource/shared/config.lua", + "\ +---@class (server) Test.SidedBox +---@field held T +---@field [1] T +---@overload fun(): T + +---@class (client) Test.SidedBox +---@field held U +---@field [1] U +---@overload fun(): U +", + ); + let text = "\ +---@type Test.SidedBox +local box = nil +local held = box.held +local first = box[1] +local made = box() +for _, each in pairs(box) do print(each) end +"; + for file in [CLIENT, SERVER] { + client.open_with(file, text); + for (needle, expected) in [ + ("local held", "held: string"), + ("local first", "first: string"), + ("local made", "made: string"), + ("each)", "each: string"), + ] { + let delta = if needle.starts_with("local ") { 6 } else { 0 }; + let (l, c) = pos(text, needle, delta); + let hover = client.hover_text(file, l, c); + assert!(hover.contains(expected), "{file} {needle}: expected {expected:?} in {hover}"); + } + } +} + +#[test] +fn generic_classes_check_fields_with_their_type_arguments() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Holder +---@field value T +---@field label string + +---@class Test.Pair +---@field left L +---@field right R + +---@type Test.Holder +local holder = { value = 1, label = 'a' } +holder.value = 2 + +---@type Test.Pair +local half = { left = 'a' } + +---@type Test.Holder +local bare = { label = 'a' } + +---@return integer +local function count() return holder.value end + +if holder.value == 1 then end +print(count, half, bare) +"; + client.open_with(CLIENT, text); + let codes = ["assign-type-mismatch", "missing-fields", "return-type-mismatch", "impossible-comparison"]; + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |code: &str, needle: &str, message: &str| (code.to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &codes), + [ + finding("assign-type-mismatch", "value = 1", "Cannot assign `integer` to field `value` of type `string`"), + finding( + "assign-type-mismatch", + "holder.value = 2", + "Cannot assign `integer` to field `value` of type `string`" + ), + finding("missing-fields", "half = {", "Missing required fields in type `Test.Pair`: `right`"), + finding("missing-fields", "bare = {", "Missing required fields in type `Test.Holder`: `value`"), + finding( + "return-type-mismatch", + "return holder", + "Cannot return `string` as return value #1 of type `integer`" + ), + finding("impossible-comparison", "if holder", "Comparing `string` with `1` is always false"), + ], + "`right` of `Test.Pair` and `value` of a `Test.Holder` without arguments take any value, but are required" + ); +} + #[test] fn self_in_doc_types_is_the_class_of_the_table_a_function_is_defined_on() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 77222bc..6730fc2 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -65,7 +65,12 @@ pub struct DocIndexField { #[derive(Clone, Debug, Default, PartialEq)] pub struct DocClass { pub name: SmolStr, + /// The type parameters of `@class List`, which a reference such as `List` binds. + pub generics: Vec, pub parents: Vec, + /// Each of `parents` as written: a class with its type arguments, as `List`, or a table + /// type such as `{ [number]: T }` or `table`. + pub parent_types: Vec, pub fields: Vec, /// General indices, retained per declaration so each side can select its own. pub indices: Vec, @@ -358,10 +363,13 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { match tag { "class" => { let (attributes, rest) = split_attributes(rest); - let Some((name, _, after)) = class_head(rest) else { continue }; + let Some((name, params, after)) = class_head(rest) else { continue }; + let parents = after.trim_start().strip_prefix(':').map(class_parents).unwrap_or_default(); group.classes.push(DocClass { name: SmolStr::new(name), - parents: after.trim_start().strip_prefix(':').map(class_parents).unwrap_or_default(), + generics: generic_names(params).into_iter().map(SmolStr::new).collect(), + parents: parents.iter().map(parent_name).collect(), + parent_types: parents, description: description.join("\n").trim().to_string(), line: index, side: side_attribute(attributes), @@ -376,7 +384,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { } "overload" if !group.classes.is_empty() && !group.has_function_tags() => { if let (Some(fun), Some(class)) = (overload(rest), group.classes.last_mut()) { - let owner = Type::Named(class.name.clone(), Vec::new()); + let owner = own_type(&class.name, &class.generics); class.call = Some(if fun.mentions_self() { Arc::new(fun.with_self(&owner)) } else { fun }); } } @@ -514,22 +522,29 @@ fn type_params(after_name: &str) -> (&str, &str) { after_name.strip_prefix('<').and_then(|list| list.split_once('>')).unwrap_or(("", after_name)) } -/// The parents a `@class` line lists after its `:`, by name: `Pair` for `Pair`. A -/// parent that is no class name, such as `{ [number]: T }` or `table`, is named by its -/// type, and the description after the list names none. A union such as `B1 | C1` names its first +/// The names in a list of type parameters, without their constraints: `T` and `K` of `T: table, K`. +fn generic_names(list: &str) -> Vec<&str> { + list.split(',').filter_map(|entry| entry.trim().split([':', ' ']).next()).filter(|name| !name.is_empty()).collect() +} + +/// The parents a `@class` line lists after its `:`, read as types: `Pair` stays one +/// parent, and the description after the list is none. A union such as `B1 | C1` names its first /// type, as lua-language-server reads it. -fn class_parents(text: &str) -> Vec { +fn class_parents(text: &str) -> Vec { let parents = TypeParser::new(text).parse_list().into_iter().map(|parent| match parent { Type::Union(types) => types.into_iter().find(|ty| *ty != Type::Nil).unwrap_or_default(), parent => parent, }); - parents - .filter(|parent| !parent.is_unknown()) - .map(|parent| match parent { - Type::Named(name, _) => name, - other => SmolStr::new(other.to_string()), - }) - .collect() + parents.filter(|parent| !parent.is_unknown()).collect() +} + +/// The name of a parent: `Pair` for `Pair`. A parent that is no class name, such +/// as `{ [number]: T }` or `table`, is named by its type. +fn parent_name(parent: &Type) -> SmolStr { + match parent { + Type::Named(name, _) => name.clone(), + other => SmolStr::new(other.to_string()), + } } /// The text of a `@field` after its `(server)` attributes and `private` keyword, in either order, @@ -544,11 +559,18 @@ pub fn field_head(rest: &str) -> (Option, Visibility, &str) { /// `ty`, declared for the class `class`, with `self` standing for that class. fn in_class(class: &DocClass, ty: Type) -> Type { match ty.mentions_self() { - true => ty.with_self(&Type::Named(class.name.clone(), Vec::new())), + true => ty.with_self(&own_type(&class.name, &class.generics)), false => ty, } } +/// The type of the table that `---@class List` declares, and of `self` in its fields and +/// methods: `List`, which keeps the type parameters of the class, as a class named without +/// type arguments elsewhere does. +pub fn own_type(name: &SmolStr, generics: &[SmolStr]) -> Type { + Type::Named(name.clone(), generics.iter().map(|param| Type::Named(param.clone(), Vec::new())).collect()) +} + /// Reads a `@field` into the latest class of `group`. True when it adds a field of its own, the /// last of `fields`, which `---| value` lines after it list more values of. fn parse_field(rest: &str, line: usize, group: &mut DocGroup) -> bool { @@ -696,18 +718,11 @@ pub fn referenced_type_names(line: &str) -> Vec<(usize, &str)> { /// `@class Pair`. pub fn declared_generics(line: &str) -> Vec<&str> { let Some((tag, rest)) = split_tag(line) else { return Vec::new() }; - let list = match tag { - "generic" => rest, - "class" => { - let head = split_attributes(rest).1.split(':').next().unwrap_or_default(); - match head.split_once('<').and_then(|(_, params)| params.split_once('>')) { - Some((params, _)) => params, - None => return Vec::new(), - } - } - _ => return Vec::new(), - }; - list.split(',').filter_map(|entry| entry.trim().split([':', ' ']).next()).filter(|name| !name.is_empty()).collect() + match tag { + "generic" => generic_names(rest), + "class" => class_head(split_attributes(rest).1).map_or_else(Vec::new, |(_, params, _)| generic_names(params)), + _ => Vec::new(), + } } /// What one entry of a `@cast` line does to the type of the variable it names. @@ -815,13 +830,10 @@ fn type_names(line: &str) -> Option<(&str, Vec>)> { }; let mut names = TypeNames { line, found: Vec::new() }; match tag { + // Generic parameters such as the `T` of `Child` refer to no class or alias. "class" => { - let (_, rest) = split_attributes(rest); - names.declared(rest); - // Parsing the head as a type skips generic parameters such as the `T` of `Child`. - let mut head = TypeParser::new(rest); - head.parse(); - if let Some(parents) = head.rest().trim_start().strip_prefix(':') { + let after = names.declared(split_attributes(rest).1).map(|after| type_params(after).1); + if let Some(parents) = after.and_then(|after| after.trim_start().strip_prefix(':')) { names.types(parents); } } @@ -1100,6 +1112,21 @@ mod tests { assert!(parents("---@class Linked https://example.com").is_empty(), "a colon in the description lists none"); } + #[test] + fn generic_classes() { + let doc = parse( + "---@class (exact) Pair : Base, { [number]: L }\n---@field left L\n---@field next self?\n---@overload fun(): self", + ); + let class = &doc.classes[0]; + assert_eq!((class.name.as_str(), class.generics.as_slice()), ("Pair", ["L", "R"].map(SmolStr::new).as_slice())); + assert_eq!(class.parents, ["Base", "{ [number]: L }"]); + let parents: Vec = class.parent_types.iter().map(Type::to_string).collect(); + assert_eq!(parents, ["Base", "{ [number]: L }"], "parents keep their type arguments"); + assert_eq!(class.fields[1].ty.to_string(), "Pair?", "`self` keeps the type parameters"); + assert_eq!(Type::Fun(class.call.clone().unwrap()).to_string(), "fun(): Pair"); + assert!(parse("---@class Plain : Base").classes[0].generics.is_empty()); + } + #[test] fn class_and_enum_attributes() { let doc = parse("---@class (partial) Player : Entity"); @@ -1279,6 +1306,7 @@ mod tests { "@alias Value Gar^age", "@alias (server) Gar^age string", "@alias (client) Value Gar^age", + "@class Pair : Gar^age", "@field (server) value? Gar^age", "@field private (client) value Gar^age", "@overload (server) fun(): Gar^age", @@ -1325,6 +1353,7 @@ mod tests { fn lists_declared_generic_parameters() { assert_eq!(declared_generics("@generic T, K: table, V"), ["T", "K", "V"]); assert_eq!(declared_generics("@class (exact) Pair : Base"), ["L", "R"]); + assert_eq!(declared_generics("@class Pair: Base"), ["L", "R"]); assert!(declared_generics("@class Plain : Base").is_empty()); assert!(declared_generics("@param value T").is_empty()); } diff --git a/docs/reference.md b/docs/reference.md index 3d62254..83a2ca4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -369,6 +369,15 @@ Index types also decide what a field without its own `@field` holds, for every c strict ones: with `---@field [string] number`, `abc.other` is a `number`, and `{ other = true }` and `abc.other = true` are an `assign-type-mismatch`. +A generic class gives its fields the type arguments a value is declared with. With +`---@class List` and `---@field first T`, `first` of a `---@type List` local is a +`string`, so `{ first = 1 }` is an `assign-type-mismatch`. A parent passes its type arguments on, +as in `---@class Names : List`. A parameter left without an argument, like the `R` of +`Pair` for `Pair`, stays `R`, and so do all of them where the class is named without +arguments, as in `---@type List`, just as the table the `---@class` annotation declares and `self` +in its methods keep them, being `List`. A field of such a type takes any value but must still +be given, and a method's own `---@generic T` binds from the call. + With `strict_classes = true`, every class is strict unless it is marked `(loose)`. That default only covers classes declared in workspace files outside `exclude` and `ignore_diagnostics`, so the classes of third-party resources stay as they are unless they say `(strict)` themselves. When one From 394aadb9ee823694db272574cd9401073ea7f65f Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:10:02 +0200 Subject: [PATCH 025/193] feat(infer): resolve generic aliases with their type arguments --- crates/qbx_lua_ls/README.md | 4 +- .../qbx_lua_ls/src/features/class_tables.rs | 24 ++++++--- crates/qbx_lua_ls/src/features/definition.rs | 16 +++--- crates/qbx_lua_ls/src/features/hover.rs | 16 ++++-- crates/qbx_lua_ls/src/index.rs | 3 ++ crates/qbx_lua_ls/src/indexer.rs | 12 ++++- crates/qbx_lua_ls/src/infer.rs | 34 ++++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 50 ++++++++++++++++++- crates/qbx_luacats/src/luacats.rs | 50 +++++++++++++++---- docs/reference.md | 13 +++-- 10 files changed, 178 insertions(+), 44 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 6870a9e..d42d498 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -331,8 +331,8 @@ and custom requests. ## Limits - Types support completion, hover and navigation. The server does not check assignment types - or provide full control-flow narrowing. Generic functions and classes take the types their - arguments give them, but the constraints of their parameters, such as the `table` of + or provide full control-flow narrowing. Generic functions, classes and aliases take the types + their arguments give them, but the constraints of their parameters, such as the `table` of `---@generic T: table`, are not checked. - Field references depend on the inferred owner type. Computed keys and fields reached through unknown types may not be found; known declarations that cannot be edited safely prevent rename. diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index d50af98..880758c 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -18,7 +18,7 @@ use qbx_lua_syntax::{NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; -use crate::infer::{class_bindings, substitute, Infer}; +use crate::infer::{class_bindings, expanded_bindings, substitute, Infer}; use crate::luacats::applies_on; use crate::types::{DescribedValue, Type}; @@ -191,14 +191,16 @@ impl<'a, 'b> Classes<'a, 'b> { self.declarations(name, from).1 } - /// `ty`, or the type of the one alias it names as `from` sees it. + /// `ty`, or the type of the one alias it names as `from` sees it, with the type arguments of a + /// generic alias in place of its parameters. fn resolve(&self, ty: &Type, from: FileId, depth: u32) -> Type { match ty { - Type::Named(name, args) - if args.is_empty() && depth < MAX_DEPTH && self.class_defs(name, from).is_empty() => - { + Type::Named(name, args) if depth < MAX_DEPTH && self.class_defs(name, from).is_empty() => { match self.alias_defs(name, from).as_slice() { - [(file, alias)] => self.resolve(&alias.ty, *file, depth + 1), + [(file, alias)] => { + let ty = substitute(&alias.ty, &expanded_bindings(&alias.generics, args)); + self.resolve(&ty, *file, depth + 1) + } _ => ty.clone(), } } @@ -506,13 +508,19 @@ impl<'a, 'b> Classes<'a, 'b> { Type::Function | Type::Fun(_) => kind::FUNCTION, Type::Thread | Type::Userdata => kind::OTHER, Type::Variadic(inner) => return self.kinds(inner, from, depth + 1), - Type::Named(name, _) => { + Type::Named(name, args) => { let classes = self.class_defs(name, from); let aliases = self.alias_defs(name, from); match (classes.is_empty(), aliases.as_slice()) { // Classes describe tables, and also userdata such as `vector3`. (false, []) => kind::TABLE | kind::OTHER, - (true, [(file, alias)]) => return self.kinds(&alias.ty, *file, depth + 1), + (true, [(file, alias)]) => { + let bindings = expanded_bindings(&alias.generics, args); + return match bindings.is_empty() { + true => self.kinds(&alias.ty, *file, depth + 1), + false => self.kinds(&substitute(&alias.ty, &bindings), *file, depth + 1), + }; + } // Unknown, a generic, or declared as more than one thing. _ => return None, } diff --git a/crates/qbx_lua_ls/src/features/definition.rs b/crates/qbx_lua_ls/src/features/definition.rs index c3cba67..177a9f2 100644 --- a/crates/qbx_lua_ls/src/features/definition.rs +++ b/crates/qbx_lua_ls/src/features/definition.rs @@ -10,7 +10,7 @@ use super::member_refs::member_target; use super::with_infer; use crate::document::Document; use crate::index::{EventFamily, EventKind, FileId, FileOrigin}; -use crate::infer::{Decl, Infer}; +use crate::infer::{expanded_bindings, substitute, Decl, Infer}; use crate::locate::locate; use crate::luacats::applies_on; use crate::types::Type; @@ -229,7 +229,7 @@ fn named_types(infer: &Infer, ty: &Type, out: &mut Vec, depth: u32) { Type::Named(name, args) => { if !out.iter().any(|known| matches!(known, Named::Type(known) if known == name)) { out.push(Named::Type(name.clone())); - aliased_types(infer, name, out, depth); + aliased_types(infer, name, args, out, depth); } args.iter().for_each(|arg| named_types(infer, arg, out, depth + 1)); } @@ -257,15 +257,17 @@ fn named_types(infer: &Infer, ty: &Type, out: &mut Vec, depth: u32) { } } -/// The classes and aliases that the alias `name` stands for: the one it names, or each that a union -/// or a `?` of it names, as `Dog` and `Animal` for `---@alias Pet Dog|Animal`. The types an array -/// or a table type of it holds are not its own, as lua-language-server reads them. -fn aliased_types(infer: &Infer, name: &str, out: &mut Vec, depth: u32) { +/// The classes and aliases that the alias `name`, given the type arguments `args`, stands for: the +/// one it names, or each that a union or a `?` of it names, as `Dog` and `Animal` for +/// `---@alias Pet Dog|Animal`. The types an array or a table type of it holds are not its own, as +/// lua-language-server reads them. +fn aliased_types(infer: &Infer, name: &str, args: &[Type], out: &mut Vec, depth: u32) { if infer.index.class(name, infer.side()).is_some() { return; } let Some((_, alias)) = infer.index.alias(name, infer.side()) else { return }; - let parts = match &alias.ty { + let ty = substitute(&alias.ty, &expanded_bindings(&alias.generics, args)); + let parts = match &ty { Type::Union(parts) => parts.as_slice(), one => std::slice::from_ref(one), }; diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index cb0ddff..5da2f8c 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -110,9 +110,11 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O } // The fields are those of the value when it is not nil; the `?` still says it may be. let optional = if *ty != bare && matches!(ty, Type::Union(types) if types.contains(&Type::Nil)) { "?" } else { "" }; - // A class with its type arguments, as `List`. + // A class with its type arguments, as `List`, or the types a value of several may be, + // as `number|ArrayLike`, unless one is a table whose fields only the overview names. let label = match &bare { Type::Named(..) => format!("{bare}{optional} "), + Type::Union(types) if !types.iter().any(is_table_value) => format!("{ty} "), _ => String::new(), }; let mut out = format!("{prefix}{name}: {label}{{"); @@ -135,6 +137,12 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O out } +/// Whether `ty` is a table that a hover shows by its fields alone, such as `{ name: string }` or +/// the table a local holds, rather than by a name. +fn is_table_value(ty: &Type) -> bool { + matches!(ty, Type::Shape(_) | Type::GlobalTable(_) | Type::Require(_) | Type::Exports(_)) +} + /// A table the index holds with only integer keys, such as `local list = { 'a', 'b' }` or /// `Config.Items = { 'a', 'b' }`, is shown as `string[]` rather than as a bare `table`. fn shown_type(infer: &Infer, ty: &Type) -> Type { @@ -180,7 +188,8 @@ fn alias_expansions<'a>(infer: &Infer<'a>, ty: &Type) -> Vec<(SmolStr, &'a Alias continue; } let Some((_, alias)) = infer.index.alias(name, infer.side()) else { continue }; - if table_part(infer, &alias.ty, 0).is_unknown() { + // `Result` of `---@alias Result T|nil` stands for `string?`, which is no table. + if table_part(infer, part, 0).is_unknown() { out.push((name.clone(), alias)); } } @@ -190,6 +199,7 @@ fn alias_expansions<'a>(infer: &Infer<'a>, ty: &Type) -> Vec<(SmolStr, &'a Alias /// An alias as written out in a hover: `type Mode = "fast"|"slow"`, or with each value on a line of /// its own when the `---|` lines that list them describe any. fn alias_definition(name: &str, alias: &AliasDef) -> String { + let name = with_params(name, &alias.generics); if alias.values.iter().all(|listed| listed.description.is_empty()) { return format!("type {name} = {}", alias.ty); } @@ -208,7 +218,7 @@ fn alias_definition(name: &str, alias: &AliasDef) -> String { format!("type {name} ={}", described_values(&values)) } -/// The name of a class with the type parameters it declares, as `List`. +/// The name of a class or alias with the type parameters it declares, as `List`. fn with_params(name: &str, generics: &[SmolStr]) -> String { match generics { [] => name.to_string(), diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 5226ccb..14ef2b8 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -146,6 +146,9 @@ impl ClassDef { #[derive(Clone, Debug)] pub struct AliasDef { pub name: SmolStr, + /// The type parameters of `@alias Box { value: T }`, which a reference such as + /// `Box` binds. + pub generics: Vec, pub ty: Type, pub doc: Option>, pub range: Range, diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index d1063f4..e7f9eeb 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -235,6 +235,7 @@ impl<'a> Indexer<'a> { let range = self.range(group[alias.line.min(group.len() - 1)].span); self.out.aliases.push(AliasDef { name: alias.name, + generics: alias.generics, ty: alias.ty, doc: (!alias.description.is_empty()).then(|| Arc::from(alias.description.as_str())), range, @@ -388,7 +389,16 @@ impl<'a> Indexer<'a> { Arc::new(EnumTable { owner: SmolStr::new(owner), members }) }); let range = self.range(span); - self.out.aliases.push(AliasDef { name, ty, doc: None, range, side, values: Vec::new(), table }); + self.out.aliases.push(AliasDef { + name, + generics: Vec::new(), + ty, + doc: None, + range, + side, + values: Vec::new(), + table, + }); } fn push_element(&mut self, owner: SmolStr, key: Option, value: Type) { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 4b999d0..fe26f98 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1320,9 +1320,10 @@ impl<'a> Infer<'a> { let mut current = ty.clone(); for _ in 0..8 { match ¤t { - Type::Named(name, _) if self.index.class(name, self.side).is_none() => { + // `Box` is the type of `---@alias Box { value: T }` with `integer` for `T`. + Type::Named(name, args) if self.index.class(name, self.side).is_none() => { match self.index.alias(name, self.side) { - Some((_, alias)) => current = alias.ty.clone(), + Some((_, alias)) => current = substitute(&alias.ty, &expanded_bindings(&alias.generics, args)), None => break, } } @@ -1872,14 +1873,25 @@ impl<'a> Infer<'a> { } _ => {} }, - // `List` takes the `string` of a `List`. + // `List` takes the `string` of a `List`, and `Result` of + // `---@alias Result T|nil` what `T|nil` takes, unless it is given a `Result`. Type::Named(name, params) if !params.is_empty() => { - if let Type::Named(given, args) = self.resolve_alias(&arg.without_nil()) { - if given == *name { + let given = match arg.without_nil() { + Type::Named(given, args) if given == *name => Type::Named(given, args), + other => self.resolve_alias(&other), + }; + match given { + Type::Named(given, args) if given == *name => { for (param, arg) in params.iter().zip(&args) { self.unify(fun, param, arg, returned, bound, depth + 1); } } + _ => { + let expanded = self.resolve_alias(param); + if expanded != *param { + self.unify(fun, &expanded, arg, returned, bound, depth + 1); + } + } } } Type::Array(inner) => { @@ -2583,7 +2595,8 @@ impl<'a> Infer<'a> { defs.retain(|(_, class)| applies_on(class.side, self.side)); if defs.is_empty() { if let Some((_, alias)) = self.index.alias(name, self.side) { - out.extend(self.guarded(|| self.members_matching(&alias.ty, filter))); + let ty = substitute(&alias.ty, &expanded_bindings(&alias.generics, args)); + out.extend(self.guarded(|| self.members_matching(&ty, filter))); } return; } @@ -2666,6 +2679,15 @@ pub(crate) fn generic_bindings(params: &[SmolStr], args: &[Type]) -> Vec<(SmolSt params.iter().cloned().zip(args.iter().cloned()).filter(|(param, arg)| !itself(param, arg)).collect() } +/// What the type parameters of an alias stand for where its type is read in place, as `Box` +/// reads the `{ value: T }` of `---@alias Box { value: T }`: those `generic_bindings` binds, +/// with a parameter left without an argument unknown, as lua-language-server reads the `value` of +/// a `---@type Box`. +pub(crate) fn expanded_bindings(params: &[SmolStr], args: &[Type]) -> Vec<(SmolStr, Type)> { + let missing = params.iter().skip(args.len()).map(|param| (param.clone(), Type::Unknown)); + generic_bindings(params, args).into_iter().chain(missing).collect() +} + /// The type parameters of a class bound to `args`, as the declaration of it among `defs` that /// lists them declares them. pub(crate) fn class_bindings(defs: &[(FileId, &ClassDef)], args: &[Type]) -> Vec<(SmolStr, Type)> { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index a9e1a16..8bf4338 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2494,6 +2494,7 @@ function AddItem(item) end local hidden ---@alias Mode 'a'|Unknown +---@alias Boxed { value: V, kind: BoxKind } print(get, hidden) ---@param kind WheelKind @@ -2524,7 +2525,7 @@ local function check(kind, expected) end (line as u64, column as u64, format!("Undefined type or alias `{name}`")) }; // Handle types, stub classes, generics, `self`, `@see` and suppressed lines are not reported. - assert_eq!(found, [at("Spot"), at("Missing"), at("Unknown")]); + assert_eq!(found, [at("Spot"), at("Missing"), at("Unknown"), at("BoxKind")]); } #[test] @@ -5055,6 +5056,53 @@ print(count, half, bare) ); } +#[test] +fn generic_aliases_bind_their_type_parameters() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Box { value: T } +---@alias Test.Result T|nil + +---@generic T +---@param result Test.Result +---@return T +local function unwrap(result) end + +---@param target number|Test.Box +local function send(target) return target end + +---@type Test.Box +local box = {} +---@type Test.Box +local anyBox = {} +---@type Test.Result +local result = nil + +local boxed = box.value +local unboxed = anyBox.value +local unwrapped = unwrap('x') +print(result, send) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("local boxed", "boxed: integer"), + // Without type arguments the parameters of an alias are unknown, as lua-language-server + // reads them. + ("local unboxed", "unboxed: unknown"), + // A generic function binds `T` through the alias of its parameter. + ("local unwrapped", "unwrapped: string"), + // A value of several types keeps them while the fields of the table among them follow. + (" target end", "target: number|Test.Box {\n value: integer,"), + ("local result", "type Test.Result = T?"), + ("Test.Box", "type Test.Box = { value: T }"), + ] { + let delta = if needle.starts_with("local ") { 6 } else { 1 }; + let (l, c) = pos(text, needle, delta); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn self_in_doc_types_is_the_class_of_the_table_a_function_is_defined_on() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 6730fc2..c5dec2c 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -90,6 +90,9 @@ pub struct DocClass { #[derive(Clone, Debug, Default, PartialEq)] pub struct DocAlias { pub name: SmolStr, + /// The type parameters of `@alias Box { value: T }`, which a reference such as + /// `Box` binds. + pub generics: Vec, pub ty: Type, pub description: String, pub line: usize, @@ -391,12 +394,11 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { "overload" => group.overloads.extend(overload(rest)), "alias" => { let (attributes, rest) = split_attributes(rest); - let mut parser = TypeParser::new(rest); - let Some(name) = parser.ident() else { continue }; - parser.skip_ws(); - let ty = if parser.rest().trim().is_empty() { Type::Unknown } else { parser.parse() }; + let Some((name, params, after)) = class_head(rest) else { continue }; + let ty = if after.trim().is_empty() { Type::Unknown } else { TypeParser::new(after).parse() }; group.aliases.push(DocAlias { name: SmolStr::new(name), + generics: generic_names(params).into_iter().map(SmolStr::new).collect(), ty, description: description.join("\n").trim().to_string(), line: index, @@ -507,8 +509,8 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { group } -/// The name that a `@class` line declares, the list of type parameters after it, as `T, K` for -/// `Pair`, and the text after both. +/// The name that a `@class` or `@alias` line declares, the list of type parameters after it, as +/// `T, K` for `Pair`, and the text after both. fn class_head(rest: &str) -> Option<(&str, &str, &str)> { let mut parser = TypeParser::new(rest); let name = parser.ident()?; @@ -714,13 +716,15 @@ pub fn referenced_type_names(line: &str) -> Vec<(usize, &str)> { } } -/// The generic parameters a doc line declares: the `T, K` of `@generic T, K: table` or of -/// `@class Pair`. +/// The generic parameters a doc line declares: the `T, K` of `@generic T, K: table`, of +/// `@class Pair` or of `@alias Pair [T, K]`. pub fn declared_generics(line: &str) -> Vec<&str> { let Some((tag, rest)) = split_tag(line) else { return Vec::new() }; match tag { "generic" => generic_names(rest), - "class" => class_head(split_attributes(rest).1).map_or_else(Vec::new, |(_, params, _)| generic_names(params)), + "class" | "alias" => { + class_head(split_attributes(rest).1).map_or_else(Vec::new, |(_, params, _)| generic_names(params)) + } _ => Vec::new(), } } @@ -838,8 +842,8 @@ fn type_names(line: &str) -> Option<(&str, Vec>)> { } } "alias" => { - if let Some(rest) = names.declared(split_attributes(rest).1) { - names.types(rest); + if let Some(after) = names.declared(split_attributes(rest).1) { + names.types(type_params(after).1); } } "enum" => { @@ -1127,6 +1131,24 @@ mod tests { assert!(parse("---@class Plain : Base").classes[0].generics.is_empty()); } + #[test] + fn generic_aliases() { + let doc = parse( + "---@alias Result T|nil\n---@alias (server) Box { value: T, key: K }\n---@alias Plain string", + ); + let aliases: Vec<(String, Vec, String)> = + doc.aliases.iter().map(|a| (a.name.to_string(), a.generics.clone(), a.ty.to_string())).collect(); + assert_eq!( + aliases, + [ + ("Result".into(), vec!["T".into()], "T?".into()), + ("Box".into(), vec!["T".into(), "K".into()], "{ value: T, key: K }".into()), + ("Plain".into(), vec![], "string".into()), + ] + ); + assert_eq!(doc.aliases[1].side, Some(Side::Server)); + } + #[test] fn class_and_enum_attributes() { let doc = parse("---@class (partial) Player : Entity"); @@ -1306,6 +1328,8 @@ mod tests { "@alias Value Gar^age", "@alias (server) Gar^age string", "@alias (client) Value Gar^age", + "@alias Box { value: T, garage: Gar^age }", + "@alias Gar^age T|nil", "@class Pair : Gar^age", "@field (server) value? Gar^age", "@field private (client) value Gar^age", @@ -1341,6 +1365,7 @@ mod tests { } assert_eq!(names("@class Garage : Base, Other"), ["Base", "Other"]); assert_eq!(names("@alias Mode Kind|'a'"), ["Kind"]); + assert_eq!(names("@alias Box { value: T, kind: Kind }"), ["T", "Kind"]); assert_eq!(names("@enum Jobs"), Vec::<&str>::new()); assert_eq!(names("@param cb fun(point: Point): Garage?"), ["Point", "Garage"]); assert_eq!(names("@field (server) spots table"), ["Spot"]); @@ -1354,6 +1379,8 @@ mod tests { assert_eq!(declared_generics("@generic T, K: table, V"), ["T", "K", "V"]); assert_eq!(declared_generics("@class (exact) Pair : Base"), ["L", "R"]); assert_eq!(declared_generics("@class Pair: Base"), ["L", "R"]); + assert_eq!(declared_generics("@alias (server) Box { value: T }"), ["T"]); + assert!(declared_generics("@alias Plain string").is_empty()); assert!(declared_generics("@class Plain : Base").is_empty()); assert!(declared_generics("@param value T").is_empty()); } @@ -1426,6 +1453,7 @@ mod tests { "@type str^ing", "@generic Gar^age: string", "@class Child: Parent", + "@alias Box { value: Garage }", "@class (Gar^age) Child", "@enum (Gar^age) Mode", "@alias (Gar^age) Mode string", diff --git a/docs/reference.md b/docs/reference.md index 83a2ca4..4568a1f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -372,11 +372,14 @@ strict ones: with `---@field [string] number`, `abc.other` is a `number`, and `{ A generic class gives its fields the type arguments a value is declared with. With `---@class List` and `---@field first T`, `first` of a `---@type List` local is a `string`, so `{ first = 1 }` is an `assign-type-mismatch`. A parent passes its type arguments on, -as in `---@class Names : List`. A parameter left without an argument, like the `R` of -`Pair` for `Pair`, stays `R`, and so do all of them where the class is named without -arguments, as in `---@type List`, just as the table the `---@class` annotation declares and `self` -in its methods keep them, being `List`. A field of such a type takes any value but must still -be given, and a method's own `---@generic T` binds from the call. +as in `---@class Names : List`, and a generic `---@alias Box { value: T }` reads +`Box` the same way. A parameter left without an argument, like the `R` of `Pair` +for `Pair`, stays `R`, and so do all of them where the class is named without arguments, +as in `---@type List`, just as the table the `---@class` annotation declares and `self` in its +methods keep them, being `List`. A field of such a type takes any value but must still be +given, and a method's own `---@generic T` binds from the call. An alias named without its type +arguments, as in `---@type Box`, reads its parameters as unknown instead, as lua-language-server +does. With `strict_classes = true`, every class is strict unless it is marked `(loose)`. That default only covers classes declared in workspace files outside `exclude` and `ignore_diagnostics`, so the From ccade41496879503a2efb99ec721f38b0e6fba34 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:10:42 +0200 Subject: [PATCH 026/193] feat(infer): read table types named as class parents --- .../qbx_lua_ls/src/features/class_tables.rs | 69 +++++++--- crates/qbx_lua_ls/src/infer.rs | 120 ++++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 81 ++++++++++++ docs/reference.md | 9 +- 4 files changed, 233 insertions(+), 46 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 880758c..c04abb6 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -18,7 +18,7 @@ use qbx_lua_syntax::{NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; -use crate::infer::{class_bindings, expanded_bindings, substitute, Infer}; +use crate::infer::{bound_parents, class_bindings, expanded_bindings, substitute, Infer}; use crate::luacats::applies_on; use crate::types::{DescribedValue, Type}; @@ -217,10 +217,11 @@ impl<'a, 'b> Classes<'a, 'b> { } } - /// The `@field`s of `class`, given the type arguments `args`, and of its parents, leaving out - /// those scoped to the other side. The first declaration of a name wins, so a class can narrow - /// a field of its parent. A class that several parents share is read once, and so is one that - /// names itself as a parent with other type arguments. + /// The `@field`s of `class`, given the type arguments `args`, and of its parents, with the fields + /// of a table type it names as a parent, leaving out those scoped to the other side. The first + /// declaration of a name wins, so a class can narrow a field of its parent. A class that several + /// parents share is read once, and so is one that names itself as a parent with other type + /// arguments. pub fn fields(&self, class: &str, args: &[Type], from: FileId) -> Vec { let mut out = Vec::new(); self.collect_fields(class, args, from, &mut out, &mut FxHashSet::default(), 0); @@ -251,11 +252,18 @@ impl<'a, 'b> Classes<'a, 'b> { } } } - for (file, def) in &defs { - for parent in &def.parent_types { - if let Type::Named(parent, args) = substitute(parent, &bindings) { - self.collect_fields(&parent, &args, *file, out, visited, depth + 1); + for (file, parent) in bound_parents(&defs, args) { + match parent { + Type::Named(parent, args) => self.collect_fields(&parent, &args, file, out, visited, depth + 1), + Type::Shape(shape) => { + for field in &shape.fields { + if !out.iter().any(|seen| seen.name == field.name) { + let ty = if field.optional { field.ty.clone().optional() } else { field.ty.clone() }; + out.push(ClassField { name: field.name.clone(), ty, doc: None, values: Vec::new(), file }); + } + } } + _ => {} } } } @@ -339,9 +347,11 @@ impl<'a, 'b> Classes<'a, 'b> { /// Whether `class` or a parent takes `key`. A name is taken by an `@field` for this side, by a /// field set on the table a `---@class` declares, like `function Test:greet()`, or by an index /// such as `[string]`; a literal like `1` by its `---@field [1] number`; another key by an index - /// of its type, or by a literal-keyed field it may be, as `integer` may be `1`. A parent that is - /// no class, like the `table` of `Name : table`, takes anything, and so does a key - /// whose type is not known well enough to rule out a field name. + /// of its type, or by a literal-keyed field it may be, as `integer` may be `1`. A table type + /// named as a parent, like `{ [string]: any }` or `table`, takes what its fields + /// and indices take. A parent that is neither a class nor such a table type, like a plain + /// `table`, takes anything, and so does a key whose type is not known well enough to rule out a + /// field name. pub fn declares(&self, class: &str, from: FileId, key: &Key) -> bool { let named = match key { Key::Name(name) => { @@ -374,12 +384,19 @@ impl<'a, 'b> Classes<'a, 'b> { || self.class_defs(class, from).iter().any(parents_declare) } - /// Whether `class` or one of its ancestors names a parent that is no class. + /// Whether `class` or one of its ancestors names a parent that is neither a class nor a table + /// type with fields or indices, such as a plain `table`. fn has_open_parent(&self, class: &str, from: FileId, depth: u32) -> bool { let defs = self.class_defs(class, from); + let is_open = |parent: &Type, file: FileId| match parent { + Type::Named(parent, _) => self.has_open_parent(parent, file, depth + 1), + Type::Shape(shape) => shape.fields.is_empty() && shape.array.is_none() && shape.indices.is_empty(), + Type::Map(..) | Type::Array(_) => false, + _ => true, + }; defs.is_empty() || depth > MAX_DEPTH - || defs.iter().any(|(file, def)| def.parents.iter().any(|p| self.has_open_parent(p, *file, depth + 1))) + || defs.iter().any(|(file, def)| def.parent_types.iter().any(|parent| is_open(parent, *file))) } /// The type that `key` of a `class` table holds, given the type arguments `args` of the class, @@ -401,7 +418,8 @@ impl<'a, 'b> Classes<'a, 'b> { } /// The value type of the first index of `class`, given the type arguments `args`, or of a parent - /// that takes keys of type `key`, inferred in this file, with the file that declares it. + /// that takes keys of type `key`, inferred in this file, with the file that declares it. A table + /// type named as a parent, like `{ [number]: T }` or `table`, counts as one. fn index_for(&self, class: &str, args: &[Type], from: FileId, key: &Type, depth: u32) -> Option<(Type, FileId)> { if depth > MAX_DEPTH { return None; @@ -415,14 +433,29 @@ impl<'a, 'b> Classes<'a, 'b> { Some((substitute(value, &bindings), *file)) }); own.or_else(|| { - let mut parents = defs.iter().flat_map(|(file, def)| def.parent_types.iter().map(move |p| (p, *file))); - parents.find_map(|(parent, file)| match substitute(parent, &bindings) { + bound_parents(&defs, args).into_iter().find_map(|(file, parent)| match parent { Type::Named(parent, args) => self.index_for(&parent, &args, file, key, depth + 1), - _ => None, + table => self.table_index_for(&table, file, key), }) }) } + /// The value type of the first entry of the table type `ty`, as `from` sees it, that takes keys + /// of type `key`: its array part, a `[key]` entry, or the values of `table` or `V[]`. + fn table_index_for(&self, ty: &Type, from: FileId, key: &Type) -> Option<(Type, FileId)> { + let entries: Vec<(&Type, &Type)> = match ty { + Type::Shape(shape) => { + let array = shape.array.iter().map(|value| (&Type::Integer, value)); + array.chain(shape.indices.iter().map(|(index, value)| (index, value))).collect() + } + Type::Map(index, value) => vec![(&**index, &**value)], + Type::Array(value) => vec![(&Type::Integer, &**value)], + _ => Vec::new(), + }; + let (_, value) = entries.into_iter().find(|(index, _)| self.takes(index, from, key))?; + Some((value.clone(), from)) + } + /// Whether an index keyed by `index_key`, as `from` sees it, takes keys of type `key`. fn takes(&self, index_key: &Type, from: FileId, key: &Type) -> bool { match (self.kinds(index_key, from, 0), self.kinds(key, self.file(), 0)) { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index fe26f98..cb20299 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1229,13 +1229,11 @@ impl<'a> Infer<'a> { } Type::Named(ref name, ref args) => { let class = self.index.class(name, self.side).map(|(_, c)| c); - let bindings = self.class_bindings_of(name, args); - let index = class - .into_iter() - .flat_map(|c| c.indices(self.side)) - .filter(|(key, _)| !array_only || is_integer_key(key)) - .map(|(key, value)| (substitute(key, &bindings), substitute(value, &bindings))); + let mut indices = Vec::new(); + self.class_indices(name, args, &mut indices, &mut FxHashSet::default(), 0); + let index = indices.into_iter().filter(|(key, _)| !array_only || is_integer_key(key)); // `---@field [1] number` fields, with their keys shown as `integer` rather than `1|2|3`. + let bindings = self.class_bindings_of(name, args); let literal_fields = class .into_iter() .flat_map(|c| c.literal_fields(self.side)) @@ -1264,6 +1262,41 @@ impl<'a> Infer<'a> { (Type::union(keys), Type::union(values)) } + /// The `[key]` entries of the class `name`, given the type arguments `args`, and of its parents, + /// with those of a table type it names as a parent: the `[number]: T` of `{ [number]: T }`, or + /// the keys and values of `table`. A class that several parents share is + /// read once. + fn class_indices( + &self, + name: &str, + args: &[Type], + out: &mut Vec<(Type, Type)>, + visited: &mut FxHashSet, + depth: u32, + ) { + if depth > 8 || !visited.insert(SmolStr::new(name)) { + return; + } + let mut defs = self.index.class_defs(name); + defs.retain(|(_, def)| applies_on(def.side, self.side)); + let bindings = class_bindings(&defs, args); + for (key, value) in defs.iter().flat_map(|(_, def)| def.indices(self.side)) { + out.push((substitute(key, &bindings), substitute(value, &bindings))); + } + for (_, parent) in bound_parents(&defs, args) { + match parent { + Type::Named(parent, args) => self.class_indices(&parent, &args, out, visited, depth + 1), + Type::Shape(shape) => { + out.extend(shape.array.iter().map(|value| (Type::Integer, value.clone()))); + out.extend(shape.indices.iter().cloned()); + } + Type::Map(key, value) => out.push((*key, *value)), + Type::Array(value) => out.push((Type::Integer, *value)), + _ => {} + } + } + } + /// Keys and values of a table the index holds: its named fields are members, its array part and /// other keys are elements. A top-level local of this file reads those from its constructor /// instead, which is current even while the index still has the file's previous text. @@ -1376,11 +1409,7 @@ impl<'a> Infer<'a> { _ => Type::union(items), }, // The array part and the `[key]` entries that take a key of its type. - Type::Shape(shape) => { - let array = shape.array.iter().filter(|_| self.index_takes(&Type::Integer, key_ty, 0)); - let indices = shape.indices.iter().filter(|(key, _)| self.index_takes(key, key_ty, 0)); - Type::union(array.chain(indices.map(|(_, value)| value)).cloned()) - } + shape @ Type::Shape(_) => self.table_index_value(&shape, key_ty), Type::Named(name, args) => { let Some((_, class)) = self.index.class(&name, self.side) else { return Type::Unknown }; let key_ty = self.resolve_alias(key_ty); @@ -1428,8 +1457,9 @@ impl<'a> Infer<'a> { } /// What a key of type `key` reads from an instance of `class`, given the type arguments `args`, - /// through the nearest indices of the class or its parents that take it. A class that several - /// parents share is read once. + /// through the nearest indices of the class or its parents that take it, including those of a + /// table type it names as a parent, like `{ [number]: T }`. A class that several parents share + /// is read once. fn class_index_value( &self, class: &str, @@ -1450,13 +1480,30 @@ impl<'a> Infer<'a> { if !own.is_unknown() { return own; } - let parents = defs.iter().flat_map(|(_, def)| &def.parent_types).map(|parent| substitute(parent, &bindings)); + let parents = bound_parents(&defs, args).into_iter().map(|(_, parent)| parent); Type::union(parents.map(|parent| match parent { Type::Named(parent, args) => self.class_index_value(&parent, &args, key, visited, depth + 1), - _ => Type::Unknown, + table => self.table_index_value(&table, key), })) } + /// What a key of type `key` reads from a value of the table type `ty`: the array part and the + /// `[key]` entries of `{ [string]: integer, 'a' }` that take it, or the values of + /// `table` or `integer[]`. + fn table_index_value(&self, ty: &Type, key: &Type) -> Type { + let takes = |index: &Type| self.index_takes(index, key, 0); + match ty { + Type::Shape(shape) => { + let array = shape.array.iter().filter(|_| takes(&Type::Integer)); + let indices = shape.indices.iter().filter(|(index, _)| takes(index)); + Type::union(array.chain(indices.map(|(_, value)| value)).cloned()) + } + Type::Map(index, value) if takes(index) => (**value).clone(), + Type::Array(value) if takes(&Type::Integer) => (**value).clone(), + _ => Type::Unknown, + } + } + /// The field names a key can be: `'male'`, or every name of a `"male"|"female"` loop variable or /// alias. False when any part of the key is not a string literal. fn literal_keys(&self, key: &Type, depth: u32, out: &mut Vec) -> bool { @@ -2576,9 +2623,10 @@ impl<'a> Infer<'a> { } /// The members of the class or alias `name` given the type arguments `args`, with those of its - /// parents: the fields of a class and those set on the table its `---@class` declares. A class - /// that several parents share is read once, and so is one that names itself as a parent with - /// other type arguments, as `---@class Tree : Tree` does. + /// parents: the fields of a class, those set on the table its `---@class` declares, and the + /// fields of a table type it names as a parent, like `{ name: string }`. A class that several + /// parents share is read once, and so is one that names itself as a parent with other type + /// arguments, as `---@class Tree : Tree` does. fn class_members( &self, name: &str, @@ -2616,9 +2664,11 @@ impl<'a> Infer<'a> { member.ty = substitute(&member.ty, &bindings); } } - for parent in defs.iter().flat_map(|(_, class)| &class.parent_types) { - if let Type::Named(parent, args) = substitute(parent, &bindings) { - self.class_members(&parent, &args, filter, out, visited, depth + 1); + for (_, parent) in bound_parents(&defs, args) { + match parent { + Type::Named(parent, args) => self.class_members(&parent, &args, filter, out, visited, depth + 1), + shape @ Type::Shape(_) => out.extend(self.guarded(|| self.members_matching(&shape, filter))), + _ => {} } } } @@ -2691,10 +2741,30 @@ pub(crate) fn expanded_bindings(params: &[SmolStr], args: &[Type]) -> Vec<(SmolS /// The type parameters of a class bound to `args`, as the declaration of it among `defs` that /// lists them declares them. pub(crate) fn class_bindings(defs: &[(FileId, &ClassDef)], args: &[Type]) -> Vec<(SmolStr, Type)> { - match defs.iter().find(|(_, def)| !def.generics.is_empty()) { - Some((_, def)) => generic_bindings(&def.generics, args), - None => Vec::new(), - } + generic_bindings(class_params(defs), args) +} + +/// The type parameters of a class, as the declaration of it among `defs` that lists them declares +/// them. +fn class_params<'d>(defs: &[(FileId, &'d ClassDef)]) -> &'d [SmolStr] { + defs.iter().find(|(_, def)| !def.generics.is_empty()).map_or(&[], |(_, def)| &def.generics) +} + +/// The parents that the declarations of a class among `defs` name, with the file of each, given +/// the type arguments `args` of the class: `Parent` for the `Parent` of a +/// `Child`. A table type named as a parent, like the `{ [number]: T }` of +/// `---@class Array : { [number]: T }`, is read in place as an alias is, so a parameter without +/// an argument is unknown in it, as lua-language-server reads `array[1]` of a `---@type Array`. +pub(crate) fn bound_parents(defs: &[(FileId, &ClassDef)], args: &[Type]) -> Vec<(FileId, Type)> { + let params = class_params(defs); + let (named, table) = (generic_bindings(params, args), expanded_bindings(params, args)); + let parents = defs.iter().flat_map(|(file, def)| def.parent_types.iter().map(move |parent| (*file, parent))); + parents + .map(|(file, parent)| match parent { + Type::Named(..) => (file, substitute(parent, &named)), + other => (file, substitute(other, &table)), + }) + .collect() } pub(crate) fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 8bf4338..9360d7f 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5103,6 +5103,87 @@ print(result, send) } } +#[test] +fn table_types_named_as_parents_pass_their_fields_and_indices_on() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Array : { [number]: T } + +---@alias Test.ArrayLike Test.Array | { [number]: T } + +---@class Test.Dict : table + +---@class Test.Flags : table + +---@class Test.Point : { x: number, y?: number } + +---@class (strict) Test.Named : table + +---@class (strict) Test.Open : table + +---@class Test.Indexed +---@field [number] T + +---@type Test.Array +local array = {} +---@type Test.Array +local anyArray = {} +---@type Test.Indexed +local anyIndexed = {} +---@type Test.ArrayLike +local arrayLike = {} +---@type Test.Dict +local dict = {} +---@type Test.Flags +local flags = {} +---@type Test.Point +local point = {} +---@type Test.Named +local named = { anything = 1, [1] = 2 } +---@type Test.Open +local open = { anything = 1, [1] = 2 } + +local element = array[1] +local anyElement = anyArray[1] +local anyIndexedElement = anyIndexed[1] +local likeElement = arrayLike[1] +local counted = dict.anything +local flagged = flags.anything +local x = point.x +local y = point.y +for _, item in ipairs(array) do print(item) end +for key, count in pairs(dict) do print(key, count) end +print(named, open) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("local element", "element: string"), + // Without type arguments a table type named as a parent holds unknown values, as + // lua-language-server reads it, while an index of the class keeps its parameter. So the + // `Test.Array` of ox_lib's `ArrayLike` adds nothing to the `integer` of its other part. + ("local anyElement", "anyElement: unknown"), + ("local anyIndexedElement", "anyIndexedElement: T"), + ("local likeElement", "likeElement: integer\n"), + ("local counted", "counted: integer"), + ("local flagged", "flagged: boolean"), + ("local x", "x: number"), + ("local y", "y: number?"), + ("item) end", "item: string"), + ("key, count", "key: string"), + ("count) end", "count: integer"), + ("Test.Array", "(class) Test.Array : { [number]: T }"), + ] { + let delta = if needle.starts_with("local ") { 6 } else { 1 }; + let (l, c) = pos(text, needle, delta); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + // `table` takes string keys only, while a plain `table` takes any. + let line = pos(text, "[1] = 2", 0).0 as u64; + let message = "Field `[1]` is not declared in strict class `Test.Named`".to_string(); + assert_eq!(findings(&mut client, CLIENT, &["undeclared-field"]), [("undeclared-field".to_string(), line, message)]); +} + #[test] fn self_in_doc_types_is_the_class_of_the_table_a_function_is_defined_on() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 4568a1f..0c86b67 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -334,8 +334,10 @@ such as a `---@type` local, a parameter or `self`, and for fields that code read `abc.nope` or `abc:nope()`. A field counts as declared when an `@field` of the class or one of its parents names it, or when it is set on the table the `---@class` annotation declares, like `greet` above; fields set through values of the class do not count. A `[string]` index, such as -`---@field [string] any`, takes any name, and so does a parent that is no class, like the `table` -of `Name : table`. +`---@field [string] any`, takes any name. A table type named as a parent, like `{ id: integer }` or +`table`, declares its fields and indices for the class, so `Name : table` +takes any name too, while a parent that is neither a class nor such a table type, like a plain +`table`, takes any key. A table that `setmetatable` gives a metatable whose `__index` is the class table, as `setmetatable(obj, Test)` does after `Test.__index = Test`, is a value of the class from the call @@ -379,7 +381,8 @@ as in `---@type List`, just as the table the `---@class` annotation declares and methods keep them, being `List`. A field of such a type takes any value but must still be given, and a method's own `---@generic T` binds from the call. An alias named without its type arguments, as in `---@type Box`, reads its parameters as unknown instead, as lua-language-server -does. +does, and so does a table type that a class names as a parent, like the `{ [number]: T }` of +`---@class Array : { [number]: T }` for a `---@type Array`. With `strict_classes = true`, every class is strict unless it is marked `(loose)`. That default only covers classes declared in workspace files outside `exclude` and `ignore_diagnostics`, so the From 225a85caac53e2e06a4d8a878c09482596f753b3 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:21:48 +0200 Subject: [PATCH 027/193] feat(rules): add redundant-parameter --- crates/qbx_lua_analysis/src/checks/arity.rs | 78 +++++++--- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + crates/qbx_lua_analysis/src/signature.rs | 42 +++++- crates/qbx_lua_analysis/src/summary.rs | 10 +- .../tests/fixtures/bad_resource.expected | 3 +- .../fixtures/bad_resource/client/main.lua | 1 + crates/qbx_lua_analysis/tests/rules.rs | 73 ++++++++- crates/qbx_lua_ls/README.md | 4 +- .../src/features/callback_payloads.rs | 141 ++++++++++++------ crates/qbx_lua_ls/src/features/diagnostics.rs | 13 +- crates/qbx_lua_ls/tests/lsp.rs | 27 +++- docs/reference.md | 31 ++-- docs/rules.md | 1 + 14 files changed, 337 insertions(+), 90 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/arity.rs b/crates/qbx_lua_analysis/src/checks/arity.rs index 8aefc5d..f2f20b8 100644 --- a/crates/qbx_lua_analysis/src/checks/arity.rs +++ b/crates/qbx_lua_analysis/src/checks/arity.rs @@ -13,13 +13,13 @@ use crate::project::ResourceEnv; use crate::rules; use crate::scope::{LocalId, LocalKind, Resolved}; use crate::side_guard::SideRegions; -use crate::signature::{documented, global_key, member_path, Requirement}; +use crate::signature::{defined, global_key, member_path, most_arguments, undocumented, Requirement}; /// Every value assigned to a local or to a field of a local table, by local and dotted field path. type LocalDefs = FxHashMap<(LocalId, SmolStr), Vec>>>; pub(super) fn check(input: &FileInput, sink: &mut Sink) { - if sink.enabled(rules::MISSING_PARAMETER).is_none() { + if sink.enabled(rules::MISSING_PARAMETER).is_none() && sink.enabled(rules::REDUNDANT_PARAMETER).is_none() { return; } let own_env; @@ -56,12 +56,14 @@ impl LocalCollector<'_, '_> { self.defs.entry((id, SmolStr::new(fields.join(".")))).or_default().push(signature); } + /// The signature of a function `value`, and `None` for other values, which may be any function. fn signature(&self, stmt: &Stmt, value: Option<&Expr>, single: bool) -> Option> { match value.map(|e| &e.kind) { // A doc comment above `local a, b = ...` does not say which value it describes. Some(ExprKind::Function(func)) if single => { - documented(self.input.source, &self.input.chunk.comments, stmt.span.start, func, false) + Some(defined(self.input.source, &self.input.chunk.comments, stmt.span.start, func, false)) } + Some(ExprKind::Function(func)) => Some(undocumented(func, false)), _ => None, } } @@ -73,7 +75,7 @@ impl<'ast> Visitor<'ast> for LocalCollector<'_, '_> { StmtKind::LocalFunction { name, func } => { if let Some(id) = self.local(name) { let signature = - documented(self.input.source, &self.input.chunk.comments, stmt.span.start, func, false); + Some(defined(self.input.source, &self.input.chunk.comments, stmt.span.start, func, false)); self.add(id, &[], signature); } } @@ -91,13 +93,13 @@ impl<'ast> Visitor<'ast> for LocalCollector<'_, '_> { StmtKind::Function { name, func } => { if let Some(id) = self.local(&name.base) { let fields: Vec<&str> = name.path.iter().chain(&name.method).map(|n| n.text.as_str()).collect(); - let signature = documented( + let signature = Some(defined( self.input.source, &self.input.chunk.comments, stmt.span.start, func, name.method.is_some(), - ); + )); self.add(id, &fields, signature); } } @@ -168,9 +170,8 @@ impl Calls<'_, '_, '_> { span: Span, display: &str, ) { - let Some(passed) = passed_count(args, 0) else { return }; let side = self.regions.effective(call.span.start, self.input.side); - let defs: Vec>> = match self.input.resolution.resolve_at(root.span.start) { + let defs: Vec>> = match self.input.resolution.resolve_at(root.span.start) { Some(Resolved::Local(id)) => { // Parameters and loop variables start out with a value this file does not know. let kind = self.input.resolution.local(id).kind; @@ -178,7 +179,7 @@ impl Calls<'_, '_, '_> { return; } match self.locals.get(&(id, SmolStr::new(fields.join(".")))) { - Some(defs) => defs.iter().map(Option::as_ref).collect(), + Some(defs) => defs.clone(), None => return, } } @@ -186,26 +187,37 @@ impl Calls<'_, '_, '_> { Some(Resolved::Global(_)) if self.env.opaque => return, Some(Resolved::Global(_)) => { let Some(path) = global_key(&root.text, fields) else { return }; - self.env.function_defs(&path, side).collect() + self.env.function_defs(&path, side).map(|def| def.cloned()).collect() } None => return, }; + // A value other than a function literal may be any function. + let Some(defs) = defs.into_iter().collect::>>() else { return }; + // Any definition or overload may be the one that runs, unless the overload is scoped to the + // other side. + let signatures: Vec<&FunType> = defs + .iter() + .flat_map(|fun| { + let overloads = fun.overloads.iter().filter(|overload| applies_on(overload.side, side)); + std::iter::once(fun).chain(overloads).map(|fun| fun.as_ref()) + }) + .collect(); + if signatures.is_empty() { + return; + } + self.missing(&signatures, args, via_colon, span, display); + self.redundant(&signatures, args, via_colon, display); + } - // Any definition or overload that accepts fewer arguments may be the one that runs, unless - // the overload is scoped to the other side. + /// `missing-parameter`, for the signature that needs the fewest arguments. + fn missing(&mut self, signatures: &[&FunType], args: &[Expr], via_colon: bool, span: Span, display: &str) { + let Some(passed) = passed_count(args, 0) else { return }; let env = self.env; let alias = |name: &str| env.alias(name); - let mut least: Option = None; - for def in defs { - let Some(fun) = def else { return }; - let overloads = fun.overloads.iter().filter(|overload| applies_on(overload.side, side)); - for candidate in std::iter::once(fun).chain(overloads) { - let requirement = Requirement::of(candidate, via_colon, &alias); - if least.as_ref().is_none_or(|least| requirement.arguments < least.arguments) { - least = Some(requirement); - } - } - } + let least = signatures + .iter() + .map(|fun| Requirement::of(fun, via_colon, &alias)) + .min_by_key(|requirement| requirement.arguments); let Some(least) = least.filter(|least| passed < least.arguments) else { return }; let missing = match least.first_missing(passed) { Some(param) => format!("'{}' ({})", param.name, param.ty), @@ -221,4 +233,24 @@ impl Calls<'_, '_, '_> { ), ); } + + /// `redundant-parameter`, for the signature that takes the most arguments, at the arguments + /// after them. A call or `...` among those counts as one argument. + fn redundant(&mut self, signatures: &[&FunType], args: &[Expr], via_colon: bool, display: &str) { + let mut most = 0; + for fun in signatures { + let Some(count) = most_arguments(fun, via_colon) else { return }; + most = most.max(count); + } + let (Some(first), Some(last)) = (args.get(most), args.last()) else { return }; + let takes = match most { + 0 => "takes none".to_string(), + most => format!("takes at most {most}"), + }; + self.sink.report( + rules::REDUNDANT_PARAMETER, + first.span.to(last.span), + format!("'{display}' is called with {} argument{}, but {takes}", args.len(), plural(args.len())), + ); + } } diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 2e98994..61e906d 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -31,6 +31,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "duplicate-set-field", "count-down-loop", "missing-parameter", + "redundant-parameter", "undefined-doc-name", "undefined-doc-param", "duplicate-doc-alias", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 12a7d5f..aff1190 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -69,6 +69,7 @@ pub const DUPLICATE_INDEX: &str = "duplicate-index"; pub const DUPLICATE_SET_FIELD: &str = "duplicate-set-field"; pub const DUPLICATE_ARGUMENT: &str = "duplicate-argument"; pub const MISSING_PARAMETER: &str = "missing-parameter"; +pub const REDUNDANT_PARAMETER: &str = "redundant-parameter"; /// Reported by qbx-lua-ls, which indexes the LuaCATS types; the linter itself has no type information. pub const UNDEFINED_DOC_NAME: &str = "undefined-doc-name"; pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; @@ -147,6 +148,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_SET_FIELD, Suspicious, WARN, false, "A function is assigned to the same table field twice in one block of a file, so the second replaces the first."), rule(DUPLICATE_ARGUMENT, Correctness, ERROR, false, "Two parameters of one function share a name."), rule(MISSING_PARAMETER, Correctness, WARN, false, "A function is called without an argument for a parameter its LuaCATS annotations require."), + rule(REDUNDANT_PARAMETER, Correctness, WARN, false, "A function is called with more arguments than it has parameters, so the extra values are lost."), rule(UNDEFINED_DOC_NAME, Correctness, WARN, false, "A LuaCATS annotation names a type that no @class, @alias or @enum declares for the file's side (language server only)."), rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), diff --git a/crates/qbx_lua_analysis/src/signature.rs b/crates/qbx_lua_analysis/src/signature.rs index f2706ae..798e626 100644 --- a/crates/qbx_lua_analysis/src/signature.rs +++ b/crates/qbx_lua_analysis/src/signature.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use qbx_lua_syntax::ast::{Expr, ExprKind, FuncBody, Name}; use qbx_lua_syntax::{Comment, SmolStr}; -use qbx_luacats::luacats::parse_doc_lines; +use qbx_luacats::luacats::{parse_doc_lines, DocGroup}; use qbx_luacats::types::{FunType, Param, Type}; use crate::env::leading_doc_lines; @@ -35,6 +35,19 @@ pub fn documented( Some(Arc::new(doc.fun_type(&names, func.vararg.is_some(), is_method))) } +/// The signature of the function `func`: the one the doc comment above `stmt_start` gives, or else +/// its parameters, which take any value. +pub fn defined(source: &str, comments: &[Comment], stmt_start: u32, func: &FuncBody, is_method: bool) -> Arc { + documented(source, comments, stmt_start, func, is_method).unwrap_or_else(|| undocumented(func, is_method)) +} + +/// The parameters of `func`, which take any value, as the signature of a function without a doc +/// comment. +pub fn undocumented(func: &FuncBody, is_method: bool) -> Arc { + let names: Vec = func.params.iter().map(|p| p.text.clone()).collect(); + Arc::new(DocGroup::default().fun_type(&names, func.vararg.is_some(), is_method)) +} + /// The variable an `a.b.c` or `a['b']` chain starts from, and the field names after it. pub fn member_path(expr: &Expr) -> Option<(&Name, Vec<&str>)> { let (base, field) = match &expr.kind { @@ -155,6 +168,15 @@ impl<'f> Requirement<'f> { } } +/// How many arguments a call made with `:` (`via_colon`) or `.` can pass to `fun`, with positions +/// counted as `Requirement::of` counts them; `None` when its `...` takes any number. +pub fn most_arguments(fun: &FunType, via_colon: bool) -> Option { + if fun.params.last().is_some_and(|p| p.name == "...") { + return None; + } + Some((usize::from(fun.is_method) + fun.params.len()).saturating_sub(usize::from(via_colon))) +} + #[cfg(test)] mod tests { use super::*; @@ -216,6 +238,24 @@ mod tests { assert_eq!(Requirement::of(&fun, false, &|_| None).first_missing(0).unwrap().name, "b"); } + fn most(source: &str, via_colon: bool) -> Option { + let chunk = parse(source); + let stmt = chunk.block.stmts.last().unwrap(); + let StmtKind::Function { name, func } = &stmt.kind else { panic!("not a function statement") }; + most_arguments(&defined(source, &chunk.comments, stmt.span.start, func, name.method.is_some()), via_colon) + } + + #[test] + fn most_arguments_count_every_parameter() { + assert_eq!(most("function f(a, b) end", false), Some(2), "undocumented parameters count"); + assert_eq!(most("---@param a string\nfunction f(a, b) end", false), Some(2)); + assert_eq!(most("function f(a, ...) end", false), None); + assert_eq!(most("---@type fun(a: string, ...: any)\nfunction f(a, ...) end", false), None); + assert_eq!(most("function M:m(a) end", true), Some(1)); + assert_eq!(most("function M:m(a) end", false), Some(2), "self is passed explicitly"); + assert_eq!(most("function M.f(a) end", true), Some(0), "the receiver fills 'a'"); + } + #[test] fn undocumented_functions_have_no_signature() { assert!(signature("function f(a, b) end").is_none()); diff --git a/crates/qbx_lua_analysis/src/summary.rs b/crates/qbx_lua_analysis/src/summary.rs index 124379f..153f461 100644 --- a/crates/qbx_lua_analysis/src/summary.rs +++ b/crates/qbx_lua_analysis/src/summary.rs @@ -6,7 +6,7 @@ use qbx_lua_syntax::{Comment, SmolStr, Span}; use qbx_luacats::types::{FunType, Type}; use crate::scope::{GlobalRefKind, Resolution, Resolved, MAIN_CHUNK}; -use crate::signature::{doc_aliases, documented, global_key, member_path}; +use crate::signature::{defined, doc_aliases, global_key, member_path, undocumented}; #[derive(Clone, Debug)] pub struct GlobalDef { @@ -21,7 +21,8 @@ pub struct GlobalDef { pub struct FunctionDef { /// Dotted path of the global or field, e.g. `Notify` or `Utils.round`. pub path: SmolStr, - /// `None` when the value is not a function, or its doc comment says nothing about its parameters. + /// `None` when the value is not a function. A function whose doc comment says nothing about its + /// parameters has them without types. pub signature: Option>, } @@ -134,7 +135,7 @@ impl<'ast> Visitor<'ast> for FieldDefs<'_> { let fields: Vec<&str> = name.path.iter().chain(&name.method).map(|n| n.text.as_str()).collect(); if let Some(path) = global_key(&name.base.text, &fields) { let signature = - documented(self.source, self.comments, stmt.span.start, func, name.method.is_some()); + Some(defined(self.source, self.comments, stmt.span.start, func, name.method.is_some())); self.functions.push(FunctionDef { path, signature }); } } @@ -147,8 +148,9 @@ impl<'ast> Visitor<'ast> for FieldDefs<'_> { // A doc comment above `a, b = ...` does not say which value it describes. let signature = match exprs.get(index).map(|e| &e.kind) { Some(ExprKind::Function(func)) if targets.len() == 1 => { - documented(self.source, self.comments, stmt.span.start, func, false) + Some(defined(self.source, self.comments, stmt.span.start, func, false)) } + Some(ExprKind::Function(func)) => Some(undocumented(func, false)), _ => None, }; self.functions.push(FunctionDef { path, signature }); diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 5d21a41..686ae91 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -26,7 +26,8 @@ client/main.lua:31:9 warning unreachable-code client/main.lua:33:10 error undefined-label client/main.lua:34:5 warning unreachable-code client/main.lua:37:1 warning missing-parameter -client/main.lua:39:9 warning count-down-loop +client/main.lua:38:23 warning redundant-parameter +client/main.lua:40:9 warning count-down-loop fxmanifest.lua:8:1 warning manifest/unknown-directive fxmanifest.lua:14:15 warning manifest/missing-file server/main.lua:3:11 warning fivem/source-after-yield diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua index d1dcfc3..9d2e1e2 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/client/main.lua @@ -35,6 +35,7 @@ RegisterNetEvent('bad:client', function(a, a) end) Notify('saved') +Notify('saved', 5000, 'success') for i = 3, 1 do print(i) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index ea7b669..013fa0c 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -507,6 +507,17 @@ const OPAQUE: &str = "FXAP"; /// The `missing-parameter` messages for `source`, a `side` script of a resource whose other scripts /// are `others` (side, source). fn missing_parameters(source: &str, side: Option, others: &[(Option, &str)]) -> Vec { + call_messages("missing-parameter", source, side, others) +} + +/// The `redundant-parameter` messages for `source`, as `missing_parameters` finds them. +fn redundant_parameters(source: &str, side: Option, others: &[(Option, &str)]) -> Vec { + call_messages("redundant-parameter", source, side, others) +} + +/// The messages of `code` for `source`, a `side` script of a resource whose other scripts are +/// `others` (side, source). +fn call_messages(code: &str, source: &str, side: Option, others: &[(Option, &str)]) -> Vec { let chunk = parse(source); let resolution = resolve(&chunk); let summary = summarize(source, &chunk, &resolution); @@ -541,7 +552,7 @@ fn missing_parameters(source: &str, side: Option, others: &[(Option, locale: None, relative_path: "", }; - check_file(&input).into_iter().filter(|d| d.code == "missing-parameter").map(|d| d.message).collect() + check_file(&input).into_iter().filter(|d| d.code == code).map(|d| d.message).collect() } #[test] @@ -655,6 +666,66 @@ fn overloads_scoped_to_the_other_side_do_not_excuse_a_call() { assert!(missing_parameters(local, Some(Side::Server), &[]).is_empty()); } +#[test] +fn calls_with_more_arguments_than_parameters() { + let redundant = |source: &str| redundant_parameters(source, None, &[]); + assert_eq!( + redundant("---@param n number\nlocal function f(n) end\nf(1, 2, 3)"), + ["'f' is called with 3 arguments, but takes at most 1"] + ); + assert_eq!( + redundant("local function noop() end\nnoop(function() end)"), + ["'noop' is called with 1 argument, but takes none"], + "undocumented functions take their parameters" + ); + let two = "local function two(a, b) return a, b end\n"; + assert_eq!( + redundant(&format!("{two}two(1, 2, two(3, 4))")), + ["'two' is called with 3 arguments, but takes at most 2"], + "a call after the parameters counts as one argument" + ); + + let methods = "local M = {}\nfunction M:m(x) end\nfunction M.f(x) end\n"; + let method = |call: &str| redundant(&format!("{methods}{call}")); + assert_eq!(method("M:m(1, 2)"), ["'M:m' is called with 2 arguments, but takes at most 1"]); + assert_eq!(method("M.m(M, 1, 2)"), ["'M.m' is called with 3 arguments, but takes at most 2"]); + assert_eq!(method("M:f(1)"), ["'M:f' is called with 1 argument, but takes none"], "the receiver fills 'x'"); + assert!(method("M.m(M, 1)").is_empty()); + + for source in [ + &format!("{two}two(1, two(2, 3))"), + "local function f(a, ...) end\nf(1, 2, 3)", + "---@param ... number\nlocal function f(...) end\nf(1, 2, 3)", + "---@param a number\n---@overload fun(a: number, b: string)\nlocal function f(a) end\nf(1, 'x')", + "local function f(a) end\nf = print\nf(1, 2)", + "local function f(a) end\n---@diagnostic disable-next-line: redundant-parameter\nf(1, 2)", + "SetEntityCoords(PlayerPedId(), 1.0, 2.0, 3.0, false, false, false, true, 1)", + ] { + assert_eq!(redundant(source), Vec::::new(), "{source}"); + } +} + +#[test] +fn the_definition_that_takes_the_most_arguments_decides() { + let client = (Some(Side::Client), "---@param message string\nfunction Notify(message) end"); + let server = (Some(Side::Server), "function Notify(source, message) end"); + let both = [client, server]; + assert_eq!( + redundant_parameters("Notify(1, 'hi')", Some(Side::Client), &both), + ["'Notify' is called with 2 arguments, but takes at most 1"] + ); + assert!(redundant_parameters("Notify(1, 'hi')", Some(Side::Server), &both).is_empty()); + assert!( + redundant_parameters("Notify(1, 'hi')", Some(Side::Shared), &both).is_empty(), + "shared code may reach the server definition" + ); + + let local = + "---@param a string\n---@overload (server) fun(a: string, b: number)\nlocal function f(a) end\nf('x', 1)"; + assert_eq!(redundant_parameters(local, Some(Side::Client), &[]).len(), 1); + assert!(redundant_parameters(local, Some(Side::Server), &[]).is_empty()); +} + #[test] fn values_the_linter_cannot_follow_are_not_checked() { let missing = |source: &str| missing_parameters(source, Some(Side::Client), &[]); diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index d42d498..7574e19 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -73,8 +73,8 @@ Available features depend on the editor's LSP client. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. - QB-Core and ESX server callback completion, navigation and payload hints from local handlers. - Callback systems a resource wraps itself, declared with `---@callback`, with name completion, - payload hints, response types and `missing-parameter` for payloads that leave out a value the - handler requires. + payload hints, response types, and `missing-parameter` and `redundant-parameter` for payloads + that leave out a value the handler requires or pass more than it takes. - References on event and callback names: every registration and trigger of the name. Go to definition on a registration returns the registration itself, so editors such as VS Code show the calls that trigger it. diff --git a/crates/qbx_lua_ls/src/features/callback_payloads.rs b/crates/qbx_lua_ls/src/features/callback_payloads.rs index 99fd40a..01cd389 100644 --- a/crates/qbx_lua_ls/src/features/callback_payloads.rs +++ b/crates/qbx_lua_ls/src/features/callback_payloads.rs @@ -1,10 +1,11 @@ -//! `missing-parameter` for the payload of a `---@callback await` or `trigger` wrapper call: the -//! values passed in its `...` go to the handler registered under the name it passes, so they have -//! to cover that handler's required parameters, after the player a handler outside the client -//! receives first. qbx-lint checks the wrapper's own parameters, but only the language server -//! knows which handler a name reaches. +//! `missing-parameter` and `redundant-parameter` for the payload of a `---@callback await` or +//! `trigger` wrapper call: the values passed in its `...` go to the handler registered under the +//! name it passes, so they have to cover that handler's required parameters, after the player a +//! handler outside the client receives first, and the handler has to take them all. qbx-lint +//! checks the wrapper's own parameters, but only the language server knows which handler a name +//! reaches. -use qbx_lua_analysis::signature::Requirement; +use qbx_lua_analysis::signature::{most_arguments, Requirement}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; @@ -14,9 +15,21 @@ use crate::index::{EventFamily, EventKind}; use crate::infer::Infer; use crate::types::{CallbackRole, FunType}; -/// Each call to an `await` or `trigger` wrapper whose payload leaves out a parameter the handler -/// requires, with the message naming it. -pub fn missing_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { +/// A call to an `await` or `trigger` wrapper that names a handler. +pub struct Payload<'c> { + /// The callback name the call passes. + pub name: &'c str, + /// Where the call names the wrapper. + pub span: Span, + /// The values passed in the wrapper's `...`. + pub args: &'c [Expr], + /// The parameters of each handler registered under the name, without the player a handler + /// outside the client receives first. + pub handlers: Vec, +} + +/// Each call of `chunk` to an `await` or `trigger` wrapper whose name reaches a handler. +pub fn payloads<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { let has_handlers = infer.index.events().any(|(_, event)| { matches!(event.family, EventFamily::Custom(_)) && event.kind == EventKind::Callback && event.handler.is_some() }); @@ -28,13 +41,79 @@ pub fn missing_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { calls.out } -struct Calls<'a, 'b> { +/// Each call to an `await` or `trigger` wrapper whose payload leaves out a parameter the handler +/// requires, with the message naming it. Any of the handlers may be the one that runs, so the one +/// that needs the fewest values decides. +pub fn missing_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let mut out = Vec::new(); + for payload in payloads(infer, chunk) { + // A call or `...` at the end passes as many values as it gives. + if payload.args.last().is_some_and(Expr::is_multi_value) { + continue; + } + let passed = payload.args.len(); + let target = callback_wrappers::target_of(infer.side_at(payload.span.start)); + let alias = |name: &str| infer.index.alias(name, target).map(|(_, alias)| &alias.ty); + let Some(least) = payload + .handlers + .iter() + .map(|handler| Requirement::of(handler, false, &alias)) + .min_by_key(|requirement| requirement.arguments) + .filter(|least| passed < least.arguments) + else { + continue; + }; + let Some(param) = least.first_missing(passed) else { continue }; + let message = format!( + "Callback '{}' is called with {passed} argument{}, but needs {}; '{}' ({}) will be nil", + payload.name, + plural(passed), + least.arguments, + param.name, + param.ty + ); + out.push((payload.span, message)); + } + out +} + +/// Each call to an `await` or `trigger` wrapper whose payload passes more values than the handler +/// takes, at the values it does not take. The handler that takes the most decides. +pub fn redundant_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let mut out = Vec::new(); + 'payloads: for payload in payloads(infer, chunk) { + let mut most = 0; + for handler in &payload.handlers { + let Some(count) = most_arguments(handler, false) else { continue 'payloads }; + most = most.max(count); + } + let (Some(first), Some(last)) = (payload.args.get(most), payload.args.last()) else { continue }; + let passed = payload.args.len(); + let message = format!( + "Callback '{}' is called with {passed} argument{}, but its handler takes at most {most}", + payload.name, + plural(passed) + ); + out.push((first.span.to(last.span), message)); + } + out +} + +fn plural(count: usize) -> &'static str { + if count == 1 { + "" + } else { + "s" + } +} + +struct Calls<'a, 'b, 'c> { infer: &'a Infer<'b>, - out: Vec<(Span, String)>, + out: Vec>, } -impl<'ast> Visitor<'ast> for Calls<'_, '_> { - fn visit_expr(&mut self, expr: &'ast Expr) { +impl<'c> Visitor<'c> for Calls<'_, '_, 'c> { + fn visit_expr(&mut self, expr: &'c Expr) { match &expr.kind { ExprKind::Call { callee, args, .. } => { let span = match &callee.kind { @@ -51,8 +130,8 @@ impl<'ast> Visitor<'ast> for Calls<'_, '_> { } } -impl Calls<'_, '_> { - fn check(&mut self, call: &Expr, base: &Expr, method: Option<&Name>, args: &[Expr], span: Span) { +impl<'c> Calls<'_, '_, 'c> { + fn check(&mut self, call: &Expr, base: &Expr, method: Option<&Name>, args: &'c [Expr], span: Span) { // Without a literal name there is no handler to find, and most calls pass none. if !args.iter().any(|arg| arg.as_string().is_some()) { return; @@ -61,16 +140,10 @@ impl Calls<'_, '_> { let Some(wrapper) = Wrapper::of(&fun, method.is_some()) else { return }; let Some(payload) = wrapper.payload.filter(|_| wrapper.tag.role != CallbackRole::Register) else { return }; let Some(name) = args.get(wrapper.arg(wrapper.name)).and_then(Expr::as_string) else { return }; - // A call or `...` at the end passes as many values as it gives. - let Some(passed) = - args.get(wrapper.arg(payload)..).filter(|rest| !rest.last().is_some_and(Expr::is_multi_value)) - else { - return; - }; - let passed = passed.len(); + let Some(args) = args.get(wrapper.arg(payload)..) else { return }; let target = callback_wrappers::target_of(self.infer.side_at(call.span.start)); - let payloads: Vec = callback_wrappers::handlers(self.infer.index, &wrapper.family(), name, target) + let handlers: Vec = callback_wrappers::handlers(self.infer.index, &wrapper.family(), name, target) .into_iter() .filter_map(|(_, event)| { let handler = event.handler.as_deref()?; @@ -78,24 +151,8 @@ impl Calls<'_, '_> { Some(FunType { params, ..FunType::default() }) }) .collect(); - // Any of the handlers may be the one that runs, so the one that needs the fewest decides. - let alias = |name: &str| self.infer.index.alias(name, target).map(|(_, alias)| &alias.ty); - let Some(least) = payloads - .iter() - .map(|payload| Requirement::of(payload, false, &alias)) - .min_by_key(|requirement| requirement.arguments) - .filter(|least| passed < least.arguments) - else { - return; - }; - let Some(param) = least.first_missing(passed) else { return }; - let message = format!( - "Callback '{name}' is called with {passed} argument{}, but needs {}; '{}' ({}) will be nil", - if passed == 1 { "" } else { "s" }, - least.arguments, - param.name, - param.ty - ); - self.out.push((span, message)); + if !handlers.is_empty() { + self.out.push(Payload { name, span, args, handlers }); + } } } diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 07c49f2..c4e0e35 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,7 +5,7 @@ use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, MISSING_PARAMETER, - MISSING_RETURN, NO_UNKNOWN, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + MISSING_RETURN, NO_UNKNOWN, REDUNDANT_PARAMETER, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -15,7 +15,7 @@ use qbx_lua_syntax::Span; use serde::{Deserialize, Serialize}; use super::assignments::mismatched_assignments; -use super::callback_payloads::missing_payloads; +use super::callback_payloads::{missing_payloads, redundant_payloads}; use super::casts::mismatched_casts; use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; @@ -51,12 +51,12 @@ fn strict_by_default(ws: &Workspace, file: FileId) -> bool { } /// Findings for rules qbx-lint registers but cannot check, because they need the LuaCATS types only -/// the server indexes, and the part of `missing-parameter` that depends on the handler a -/// `@callback` wrapper call reaches. Inline suppression comments apply to them as they do to the -/// linter's own. +/// the server indexes, and the parts of `missing-parameter` and `redundant-parameter` that depend on +/// the handler a `@callback` wrapper call reaches. Inline suppression comments apply to them as they +/// do to the linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&Workspace, &Document) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 11] = [ + let checks: [(&'static str, Check); 12] = [ (UNDEFINED_DOC_NAME, |ws, doc| { let side = ws.index.file(doc.file).and_then(|f| f.side); undefined_doc_names(&ws.index, &doc.text, &doc.chunk, side) @@ -75,6 +75,7 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< (MISSING_RETURN, |ws, doc| with_infer(ws, doc, |infer| missing_returns(infer, &doc.chunk))), (CAST_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, mismatched_casts)), (MISSING_PARAMETER, |ws, doc| with_infer(ws, doc, |infer| missing_payloads(infer, &doc.chunk))), + (REDUNDANT_PARAMETER, |ws, doc| with_infer(ws, doc, |infer| redundant_payloads(infer, &doc.chunk))), (NO_UNKNOWN, |ws, doc| with_infer(ws, doc, |infer| unknown_types(infer, &ws.lint_config.ignore_unused_prefix))), (IMPOSSIBLE_COMPARISON, |ws, doc| with_infer(ws, doc, |infer| impossible_comparisons(infer, &doc.chunk))), ]; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 9360d7f..d0fdc42 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1423,7 +1423,7 @@ local counted = AwaitServerCallback('countClamps', 'ABC 123') } #[test] -fn callback_payloads_need_what_their_handler_requires() { +fn callback_payloads_need_what_their_handler_requires_and_takes() { const SHARED: &str = "myresource/shared/config.lua"; let mut client = Client::start(fixture_root()); client.open_with( @@ -1491,6 +1491,11 @@ AwaitServerCallback('untyped') local price = Shop:await('shop:price') AwaitServerCallback('unknown') print(sum, one, both, price) +AwaitServerCallback('add', 1, 2, 3) +AwaitServerCallback('add', 1, 2, GetValues()) +AwaitServerCallback('add', 1, GetValues()) +AwaitServerCallback('untyped', 1, 2) +TriggerServerCallback('notify', function() end, 'hi', 5, true) "; client.open_with(CLIENT, text); let line = |needle: &str| pos(text, needle, 0).0 as u64; @@ -1517,6 +1522,26 @@ print(sum, one, both, price) ], "the player the server passes first, optional and undocumented parameters, open-ended calls and unknown names pass" ); + let redundant = + |needle: &str, message: &str| ("redundant-parameter".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["redundant-parameter"]), + [ + redundant( + "AwaitServerCallback('add', 1, 2, 3)", + "Callback 'add' is called with 3 arguments, but its handler takes at most 2" + ), + redundant( + "AwaitServerCallback('add', 1, 2, GetValues())", + "Callback 'add' is called with 3 arguments, but its handler takes at most 2" + ), + redundant( + "TriggerServerCallback('notify'", + "Callback 'notify' is called with 3 arguments, but its handler takes at most 2" + ), + ], + "a call after the handler's parameters counts as one value, and undocumented parameters take one each" + ); } #[test] diff --git a/docs/reference.md b/docs/reference.md index 0c86b67..2792e1a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -270,6 +270,16 @@ decides. An `@overload (server) fun(...)` or `@overload (client) fun(...)` only on that side, as decided by the script's manifest side and any `IsDuplicityVersion()` or `lib.context` guard around the call; shared code counts both. +`redundant-parameter` reports the arguments a call passes beyond the parameters of the function, +whose values are lost. Every parameter counts, documented or not, and a `...` takes any number of +arguments. Here the signature that takes the most arguments decides. A call or `...` among the +extra arguments counts as one: + +```lua +local function noop() end +noop(function() end) -- 'noop' is called with 1 argument, but takes none +``` + A function `@field` that repeats the name of an unscoped one is another signature of it, as LuaLS reads it, rather than a second field: a call picks the signature its arguments fit, and hover shows the descriptions of both. Repeated with `(server)` or `(client)`, the signature only applies on @@ -287,15 +297,16 @@ A method defined with `:` and called with `.` needs `self` as its first argument several definitions, for example a client and a server `Notify`, a call is compared with those its side can reach, and is not checked when any of them is not a function. A `:` call passes its receiver as the first argument whatever that parameter is named, so `function Locale.new(_, opts)` -called as `Locale:new(opts)` receives both. Calls whose last argument -is another call or `...` pass an unknown number of arguments and are skipped, as are natives, -runtime functions, exports, and methods of objects returned by calls, such as +called as `Locale:new(opts)` receives both. Calls whose last argument is another call or `...` +pass an unknown number of arguments, so `missing-parameter` skips them. Neither rule checks +natives, runtime functions, exports, or methods of objects returned by calls, such as `GetPlayer(source):setJob(job)`. qbx-lua-ls also checks the payload of calls to `---@callback await` and `trigger` wrappers. The values passed in the wrapper's `...` go to the handler registered under the name the call passes, -on the other side, so they have to cover that handler's required parameters. A handler registered -outside the client receives the calling player first, so that parameter is not counted: +on the other side, so they have to cover that handler's required parameters, and +`redundant-parameter` reports those it does not take. A handler registered outside the client +receives the calling player first, so that parameter is not counted: ```lua ---@param num1 number @@ -306,7 +317,8 @@ RegisterServerCallback('add', function(source, num1, num2) ... end) AwaitServerCallback('add') ``` -When several handlers are registered under the name, the one that needs the fewest values decides. +When several handlers are registered under the name, the one that needs the fewest values decides, +and for `redundant-parameter` the one that takes the most. ## Strict classes @@ -659,8 +671,9 @@ complete knowledge of its globals or locale usage. Readable scripts are still an Unknown-export checks are suppressed for opaque resources and resources with non-Lua scripts. An opaque resource may also handle events named with its `resource:` prefix, so a wrong-side diagnostic is suppressed when the missing handler could be in that resource. Computed export -registrations similarly prevent a complete list of exports. `missing-parameter` does not check -calls to globals in an opaque resource, since an encrypted script may define them differently. +registrations similarly prevent a complete list of exports. `missing-parameter` and +`redundant-parameter` do not check calls to globals in an opaque resource, since an encrypted +script may define them differently. Read-only linting uses replacement characters for invalid UTF-8 bytes. `--fix`, `fmt`, and `fmt --check` report an encoding error for such source, and do not rewrite it. Convert its encoding diff --git a/docs/rules.md b/docs/rules.md index d7adec6..03f13ef 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -30,6 +30,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-set-field` | warning | A function is assigned to the same field of the same table twice in one block of a file, so the second replaces the first. A block is the main chunk, a function body, one branch of an `if`, or the code after an `if` with a branch that returns, such as an `IsDuplicityVersion()` or `lib.context` guard. A read of the field between the two, as a wrapper has, or an assignment to the table's variable or to a field it is in, also separates them. Definitions in other files or in `---@meta` files are not compared. | | `duplicate-argument` | error | Function parameters share a name. | | `missing-parameter` | warning | A call leaves out an argument that the function's LuaCATS annotations require. qbx-lua-ls also reports `---@callback` wrapper calls whose payload leaves out a value the registered handler requires. | +| `redundant-parameter` | warning | A call passes more arguments than the function has parameters, documented or not, so the extra values are lost. qbx-lua-ls also reports `---@callback` wrapper calls whose payload passes more values than the registered handler takes. | | `undefined-doc-name` | warning | A LuaCATS annotation names a type that no `@class`, `@alias` or `@enum` declares, or that only `(server)` or `(client)` declarations of the other side declare. Reported by qbx-lua-ls only. | | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | From b4333a24e99473d89c7285eb9efa081cfc85b213 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:21:48 +0200 Subject: [PATCH 028/193] feat(rules): add param-type-mismatch --- crates/qbx_fivem_data/stubs/cfx.lua | 25 +- crates/qbx_fivem_data/stubs/lua54.lua | 24 +- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 3 +- crates/qbx_lua_ls/src/features/arguments.rs | 299 ++++++++++++++++++ .../src/features/callback_payloads.rs | 8 +- crates/qbx_lua_ls/src/features/comparisons.rs | 8 +- crates/qbx_lua_ls/src/features/diagnostics.rs | 66 ++-- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/src/infer.rs | 76 ++++- crates/qbx_lua_ls/tests/lsp.rs | 259 +++++++++++++++ crates/qbx_luacats/src/types.rs | 38 ++- docs/reference.md | 57 +++- docs/rules.md | 1 + 16 files changed, 806 insertions(+), 64 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/arguments.rs diff --git a/crates/qbx_fivem_data/stubs/cfx.lua b/crates/qbx_fivem_data/stubs/cfx.lua index 00865f5..6122696 100644 --- a/crates/qbx_fivem_data/stubs/cfx.lua +++ b/crates/qbx_fivem_data/stubs/cfx.lua @@ -515,36 +515,47 @@ function promise:next(onFulfilled, onRejected) end ---@operator unm: quat ---@operator len: number ----Creates a vector2. +---Creates a vector2. Its components can also come from vectors, as in `vector2(v3)`, from a +---table, or from one number for all of them. ---@param x number ---@param y number ---@return vector2 v +---@overload fun(...: number|vector2|vector3|vector4|table): vector2 ---@nodiscard function vector2(x, y) end ----Creates a vector3. +---Creates a vector3. Its components can also come from vectors, as in `vector3(xy, z)`, from a +---table, or from one number for all of them. ---@param x number ---@param y number ---@param z number ---@return vector3 v +---@overload fun(...: number|vector2|vector3|vector4|table): vector3 ---@nodiscard function vector3(x, y, z) end ----Creates a vector4. +---Creates a vector4. Its components can also come from vectors, as in `vector4(coords, heading)`, from a +---table, or from one number for all of them. ---@param x number ---@param y number ---@param z number ---@param w number ---@return vector4 v +---@overload fun(...: number|vector2|vector3|vector4|table): vector4 ---@nodiscard function vector4(x, y, z, w) end ----Creates a quaternion. Note that the scalar part `w` comes first. +---Creates a quaternion. Note that the scalar part `w` comes first. It also takes an angle in degrees +---and the axis to rotate around, two directions to rotate the first into the second, or Euler +---angles in radians. ---@param w number ---@param x number ---@param y number ---@param z number ---@return quat q +---@overload fun(angle: number, axis: vector3): quat +---@overload fun(from: vector3, to: vector3): quat +---@overload fun(euler: vector3): quat ---@nodiscard function quat(w, x, y, z) end @@ -552,6 +563,7 @@ function quat(w, x, y, z) end ---@param x number ---@param y number ---@return vector2 v +---@overload fun(...: number|vector2|vector3|vector4|table): vector2 ---@nodiscard function vec2(x, y) end @@ -560,6 +572,7 @@ function vec2(x, y) end ---@param y number ---@param z number ---@return vector3 v +---@overload fun(...: number|vector2|vector3|vector4|table): vector3 ---@nodiscard function vec3(x, y, z) end @@ -569,6 +582,7 @@ function vec3(x, y, z) end ---@param z number ---@param w number ---@return vector4 v +---@overload fun(...: number|vector2|vector3|vector4|table): vector4 ---@nodiscard function vec4(x, y, z, w) end @@ -589,7 +603,8 @@ function vec(...) end function norm(v) end ---Computes the Jenkins one-at-a-time hash of `str`, the same value game natives use for model and asset names. ----@param str string +---An integer is returned as it is. +---@param str string|integer ---@return integer hash ---@nodiscard function joaat(str) end diff --git a/crates/qbx_fivem_data/stubs/lua54.lua b/crates/qbx_fivem_data/stubs/lua54.lua index a1f4a57..29b0b55 100644 --- a/crates/qbx_fivem_data/stubs/lua54.lua +++ b/crates/qbx_fivem_data/stubs/lua54.lua @@ -844,7 +844,7 @@ function package.searchpath(name, path, sep, rep) end string = {} ---Returns the numeric codes of the bytes `s[i]` through `s[j]`; both default to `i` = 1. ----@param s string +---@param s string|number ---@param i? integer ---@param j? integer ---@return integer ... @@ -865,7 +865,7 @@ function string.char(...) end function string.dump(f, strip) end ---Finds the first match of `pattern` in `s` starting at `init`. Returns start and end indices plus any captures, or nil. `plain` disables pattern matching. ----@param s string +---@param s string|number ---@param pattern string ---@param init? integer ---@param plain? boolean @@ -876,14 +876,14 @@ function string.dump(f, strip) end function string.find(s, pattern, init, plain) end ---Builds a string from a printf-style format and its arguments. Supports `%q` for Lua-readable quoting and `%s` via `tostring`. ----@param s string +---@param s string|number ---@param ... any ---@return string result ---@nodiscard function string.format(s, ...) end ---Returns an iterator that yields the captures (or whole match) of each successive match of `pattern` in `s`, starting at `init`. ----@param s string +---@param s string|number ---@param pattern string ---@param init? integer ---@return fun(): string, ...string iterator @@ -891,7 +891,7 @@ function string.format(s, ...) end function string.gmatch(s, pattern, init) end ---Returns a copy of `s` where matches of `pattern` (at most `n`) are replaced by `repl`, which may be a string, a lookup table or a function. Also returns the number of matches. ----@param s string +---@param s string|number ---@param pattern string ---@param repl string|number|table|fun(...: string): any ---@param n? integer @@ -901,19 +901,19 @@ function string.gmatch(s, pattern, init) end function string.gsub(s, pattern, repl, n) end ---Returns the length of `s` in bytes. ----@param s string +---@param s string|number ---@return integer length ---@nodiscard function string.len(s) end ---Returns a copy of `s` with uppercase letters converted to lowercase. ----@param s string +---@param s string|number ---@return string result ---@nodiscard function string.lower(s) end ---Returns the captures of the first match of `pattern` in `s` (or the whole match when there are none), or nil. ----@param s string +---@param s string|number ---@param pattern string ---@param init? integer ---@return any ... @@ -935,7 +935,7 @@ function string.pack(fmt, v1, ...) end function string.packsize(fmt) end ---Returns `n` copies of `s` joined by `sep`. ----@param s string +---@param s string|number ---@param n integer ---@param sep? string ---@return string result @@ -943,13 +943,13 @@ function string.packsize(fmt) end function string.rep(s, n, sep) end ---Returns `s` with its bytes in reverse order. ----@param s string +---@param s string|number ---@return string result ---@nodiscard function string.reverse(s) end ---Returns the substring from `i` to `j` (default -1, the end). Negative indices count from the end. ----@param s string +---@param s string|number ---@param i integer ---@param j? integer ---@return string result @@ -965,7 +965,7 @@ function string.sub(s, i, j) end function string.unpack(fmt, s, pos) end ---Returns a copy of `s` with lowercase letters converted to uppercase. ----@param s string +---@param s string|number ---@return string result ---@nodiscard function string.upper(s) end diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 61e906d..ae075a5 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -39,6 +39,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "missing-fields", "assign-type-mismatch", "invisible", + "param-type-mismatch", "return-type-mismatch", "missing-return", "cast-type-mismatch", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index aff1190..c62c8c7 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -79,6 +79,8 @@ pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; pub const MISSING_FIELDS: &str = "missing-fields"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const ASSIGN_TYPE_MISMATCH: &str = "assign-type-mismatch"; +/// Reported by qbx-lua-ls, which reads the LuaCATS types; the linter itself has no type information. +pub const PARAM_TYPE_MISMATCH: &str = "param-type-mismatch"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const UNDECLARED_FIELD: &str = "undeclared-field"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. @@ -155,6 +157,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), + rule(PARAM_TYPE_MISMATCH, Correctness, WARN, false, "A call passes an argument of a different type than its parameter's LuaCATS annotation declares (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), rule(INVISIBLE, Correctness, WARN, false, "A private, protected or package field or method of a LuaCATS class is used outside its class, subclasses or file (language server only)."), rule(RETURN_TYPE_MISMATCH, Correctness, WARN, false, "A function returns a value of a different type than its @return annotation declares (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 232bfc4..c2e0942 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -83,6 +83,7 @@ fn bad_resource_reports_every_rule() { | "undefined-doc-name" | "missing-fields" | "assign-type-mismatch" + | "param-type-mismatch" | "undeclared-field" | "invisible" | "return-type-mismatch" diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 7574e19..b6e4e7a 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -61,7 +61,8 @@ Available features depend on the editor's LSP client. - Diagnostics and quick fixes with resource and client/server context, plus LuaCATS type checks: `missing-fields` and `assign-type-mismatch` for tables and assignments that leave out required fields of their class or store a value of the wrong type, in a field or in a variable typed with - `---@type` or `@param`, `undeclared-field` for fields and keys + `---@type` or `@param`, `param-type-mismatch` for arguments of the wrong type, `undeclared-field` + for fields and keys a `---@class (strict)` does not declare, and `return-type-mismatch` and `missing-return` for functions, including callback handlers, that do not return what their `@return` declares. `invisible` reports the use of fields and methods a class keeps `private`, `protected` or diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs new file mode 100644 index 0000000..b8fb780 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -0,0 +1,299 @@ +//! `param-type-mismatch`: a call passes an argument that its parameter does not take, such as a +//! string for `number`. As for `assign-type-mismatch`, the argument has to be a different kind of +//! value, or a literal the parameter does not list, and as for `impossible-comparison` only +//! declared types count: what inference reads from assigned values is left out. +//! +//! A call is compared with every function it may run: each member of that name on its side, such +//! as the client and the server `lib.notify`, or each global of that name its file sees, with their +//! `@overload`s for its side. It passes when a signature that takes as many arguments as it passes +//! takes each of them; only when none takes that many do the others count. The payload of a +//! `---@callback` wrapper call is compared with the handlers registered under its name. +//! +//! Natives are left out: the runtime converts their arguments, so `0` passes for a `boolean`, a +//! string for a hash and a vector for three floats. `nil` is never a mismatch, as annotations often +//! leave out the `?` of a parameter that code skips, and neither is `false`, which FiveM code passes +//! to skip a parameter of exports and events, whose arguments are serialized. Parameters typed with a +//! generic of the function called are left out, as the arguments of the call bind them. The function +//! a callee passes to a callback, such as `resolve` of `fun(resolve: fun(value: T))`, takes what the +//! other arguments of that call declare for its generics, as the `boolean` of +//! `Promise:New('boolean', function(resolve) end)` for a `` `T` ``; a generic they leave unbound +//! takes any value. + +use std::sync::Arc; + +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_analysis::signature::{most_arguments, Requirement}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::{SmolStr, Span}; + +use super::callback_payloads::Payload; +use super::class_tables::Classes; +use super::comparisons::Declared; +use crate::index::{FileId, FileOrigin}; +use crate::infer::{Infer, NATIVE_HANDLE_TYPES}; +use crate::types::{FunType, Param, Type}; + +/// Each argument whose parameter does not take it, with the message naming both types, also among +/// the `payloads` of the wrapper calls of `chunk`. +pub fn mismatched_arguments(infer: &Infer, chunk: &Chunk, payloads: &[Payload]) -> Vec<(Span, String)> { + let mut calls = Calls { infer, declared: Declared::new(infer), classes: Classes::new(infer), out: Vec::new() }; + calls.visit_block(&chunk.block); + for payload in payloads { + let values: Vec = payload.args.iter().map(|arg| calls.value(arg)).collect(); + let handlers: Vec<&FunType> = payload.handlers.iter().collect(); + calls.compare(&handlers, false, payload.args, &values); + } + calls.out +} + +struct Calls<'a, 'b> { + infer: &'a Infer<'b>, + declared: Declared<'a, 'b>, + classes: Classes<'a, 'b>, + out: Vec<(Span, String)>, +} + +impl<'c> Visitor<'c> for Calls<'_, '_> { + fn visit_expr(&mut self, expr: &'c Expr) { + match &expr.kind { + ExprKind::Call { callee, args, .. } => self.check(expr, callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => self.check(expr, base, Some(method), args), + _ => {} + } + visit::walk_expr(self, expr); + } +} + +impl Calls<'_, '_> { + fn check(&mut self, call: &Expr, base: &Expr, method: Option<&Name>, args: &[Expr]) { + if args.is_empty() || (method.is_none() && self.declared.is_native(base, 0)) { + return; + } + // In `require 'name' --[[@as T]]`, the cast after the argument is that of the call. + if args.last().is_some_and(|arg| arg.span.end == call.span.end) + && self.infer.cast_after(call.span.end).is_some() + { + return; + } + let via_colon = method.is_some(); + let at = base.span.start; + let signatures: Vec> = self + .definitions(base, method, at) + .iter() + .flat_map(|fun| self.infer.call_signatures(fun, args, via_colon, at).0) + .collect(); + if signatures.is_empty() { + return; + } + let values: Vec = args.iter().map(|arg| self.value(arg)).collect(); + let signatures: Vec<&FunType> = signatures.iter().map(Arc::as_ref).collect(); + self.compare(&signatures, via_colon, args, &values); + } + + /// Every function a call of `base`, or of its `method`, at `at` may run: each member of that + /// name defined on the side of the call, each global of that name its file sees, or else the + /// function the callee holds. The exports proxy drops the receiver of `exports.name:Fn()`, so + /// those calls pass their arguments to the parameters that the function lists. + /// + /// In an escrowed resource, an encrypted script may define the globals a call reaches + /// differently, so a call through a global is left out unless only the runtime stubs define what + /// it reaches, or it goes through the exports proxy. + fn definitions(&self, base: &Expr, method: Option<&Name>, at: u32) -> Vec> { + let infer = self.infer; + let side = infer.side_at(at); + let reaches = |file: FileId| match (infer.index.file(file).and_then(|f| f.side), side) { + (Some(defined), Some(call)) => defined.is_available_on(call), + _ => true, + }; + let hidden = in_escrowed_resource(infer) && global_root(infer, base).is_some_and(|root| root.text != "exports"); + let known = |file: Option| { + !hidden || file.and_then(|file| infer.index.file(file)).is_some_and(|f| f.origin == FileOrigin::Stub) + }; + let members = |owner: &Expr, name: &str| -> Vec> { + let owner = infer.expr(owner); + let exported = matches!(owner, Type::Exports(Some(_))); + let found: Vec<_> = infer + .members_named(&owner, name) + .into_iter() + .filter(|member| member.location.as_ref().is_none_or(|(file, _)| reaches(*file))) + .collect(); + if !found.iter().all(|member| known(member.location.as_ref().map(|(file, _)| *file))) { + return Vec::new(); + } + found + .into_iter() + .filter_map(|member| member.ty.as_fun().cloned()) + .map(|fun| if exported { without_receiver(&fun) } else { fun }) + .collect() + }; + let held = || infer.callee_fun(base, method).map(|(fun, _)| fun).into_iter().collect(); + match (method, &base.unparen().kind) { + (Some(method), _) => members(base, &method.text), + (None, ExprKind::Field { base: owner, name, .. }) => members(owner, &name.text), + (None, ExprKind::Index { base: owner, index, .. }) if index.as_string().is_some() => { + members(owner, index.as_string().map(SmolStr::as_str).unwrap_or_default()) + } + (None, ExprKind::Name(name)) => match infer.ctx.resolution.resolve_at(name.span.start) { + Some(Resolved::Global(_)) => { + let globals = infer.index.globals_named(&name.text, infer.ctx.file); + let globals: Vec<_> = globals.into_iter().filter(|(file, _)| reaches(*file)).collect(); + if !globals.iter().all(|(file, _)| known(Some(*file))) { + return Vec::new(); + } + globals.into_iter().filter_map(|(_, symbol)| symbol.ty.as_fun().cloned()).collect() + } + // A parameter whose function the callee of its own function gives takes what the callee + // declares, with the generics that the other arguments of that call declare. + Some(Resolved::Local(id)) => { + let declared = Declared::new(infer); + let callback = infer.declared_callback_param(id, |arg| declared.of(arg)); + callback.and_then(|ty| infer.fun_of(&ty)).map_or_else(held, |fun| vec![fun]) + } + _ => held(), + }, + _ => held(), + } + } + + /// The declared type of an argument, or the type a `--[[@as T]]` right after it gives it. + fn value(&self, arg: &Expr) -> Type { + self.infer.cast_after(arg.span.end).unwrap_or_else(|| self.declared.of(arg)) + } + + /// Reports the arguments that the signature closest to taking them does not take, unless one of + /// them takes them all. Only the signatures that take as many arguments as the call passes count, + /// unless none does. + fn compare(&mut self, signatures: &[&FunType], via_colon: bool, args: &[Expr], values: &[Type]) { + let infer = self.infer; + let alias = |name: &str| infer.index.alias(name, infer.side()).map(|(_, alias)| &alias.ty); + // A call or `...` at the end passes as many values as it gives. + let open_ended = args.last().is_some_and(Expr::is_multi_value); + let takes_count = |fun: &FunType| { + most_arguments(fun, via_colon).is_none_or(|most| args.len() <= most) + && (open_ended || args.len() >= Requirement::of(fun, via_colon, &alias).arguments) + }; + let mut candidates: Vec<&FunType> = signatures.iter().copied().filter(|fun| takes_count(fun)).collect(); + if candidates.is_empty() { + candidates = signatures.to_vec(); + } + let mut closest: Option> = None; + for fun in candidates { + let found = self.mismatches(fun, via_colon, args, values); + if found.is_empty() { + return; + } + if closest.as_ref().is_none_or(|closest| found.len() < closest.len()) { + closest = Some(found); + } + } + self.out.extend(closest.unwrap_or_default()); + } + + /// Each of `args` that the parameter of `fun` it goes to does not take. A `:` call passes its + /// receiver to the first parameter of a function not defined with `:`, as Lua does, and a `.` + /// call passes the `self` of one defined with `:` first. + fn mismatches(&self, fun: &FunType, via_colon: bool, args: &[Expr], values: &[Type]) -> Vec<(Span, String)> { + let (skip_params, skip_args) = match (via_colon, fun.is_method) { + (true, false) => (1, 0), + (false, true) => (0, 1), + _ => (0, 0), + }; + let params = fun.params.get(skip_params..).unwrap_or_default(); + let rest = params.last().filter(|param| param.name == "..."); + let mut out = Vec::new(); + for (i, (arg, given)) in args.iter().zip(values).enumerate().skip(skip_args) { + let param = match params.get(i - skip_args) { + Some(param) if param.name != "..." => param, + _ => match rest { + Some(rest) => rest, + None => break, + }, + }; + if let Some(message) = self.mismatch(fun, param, given) { + out.push((arg.span, message)); + } + } + out + } + + /// The message for a value of type `given` that `param` does not take. + fn mismatch(&self, fun: &FunType, param: &Param, given: &Type) -> Option { + let expected = match ¶m.ty { + Type::Variadic(inner) => inner, + ty => ty, + }; + let given = given.without_nil(); + let is_generic = |name: &str| fun.generics.iter().any(|generic| generic == name); + // Resources declare classes such as `Vehicle` that share the name of a native handle, which a + // parameter of that type may well mean. + let is_handle = |name: &str| NATIVE_HANDLE_TYPES.contains(&name); + if matches!(given, Type::Unknown | Type::Nil | Type::BooleanLit(false)) + || mentions(expected, &is_generic) + || names(expected, &is_handle) + { + return None; + } + let from = self.classes.file(); + if !self.classes.rejects(expected, from, &given) { + return None; + } + let shown = if self.classes.literal_mismatch(expected, from, &given) { given } else { given.widen() }; + Some(format!("Cannot assign `{shown}` to parameter `{}` of type `{expected}`", param.name)) + } +} + +/// Whether the file being checked belongs to an escrowed resource. +fn in_escrowed_resource(infer: &Infer) -> bool { + let resource = infer.index.file(infer.ctx.file).and_then(|file| file.resource); + resource.and_then(|id| infer.index.resource(id)).is_some_and(|resource| resource.escrowed) +} + +/// The global that the path `expr` starts with, as `Utils` of `Utils.round`. +fn global_root<'e>(infer: &Infer, expr: &'e Expr) -> Option<&'e Name> { + match &expr.unparen().kind { + ExprKind::Name(name) => { + matches!(infer.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))).then_some(name) + } + ExprKind::Field { base, .. } | ExprKind::Index { base, .. } => global_root(infer, base), + _ => None, + } +} + +/// `fun` as called through the exports proxy, which passes the values after the receiver of a `:` +/// call, and drops the first value of a `.` call: like a function defined with `:`, unless it lists +/// `self` itself. +fn without_receiver(fun: &Arc) -> Arc { + let plain = |fun: &FunType| { + let lists_self = fun.params.first().is_some_and(|param| param.name == "self"); + FunType { is_method: !lists_self, ..fun.clone() } + }; + let mut out = plain(fun); + out.overloads = fun.overloads.iter().map(|overload| Arc::new(plain(overload))).collect(); + Arc::new(out) +} + +/// Whether a named type whose name `matches` is part of `ty`, as `T` is of `T[]`. +fn mentions(ty: &Type, matches: &impl Fn(&str) -> bool) -> bool { + match ty { + Type::Named(name, args) => matches(name) || args.iter().any(|arg| mentions(arg, matches)), + Type::Array(inner) | Type::Variadic(inner) => mentions(inner, matches), + Type::Map(key, value) => mentions(key, matches) || mentions(value, matches), + Type::Tuple(parts) | Type::Union(parts) => parts.iter().any(|part| mentions(part, matches)), + Type::Shape(shape) => shape.fields.iter().any(|field| mentions(&field.ty, matches)), + Type::Fun(fun) => { + fun.params.iter().any(|param| mentions(¶m.ty, matches)) + || fun.returns.iter().any(|ty| mentions(ty, matches)) + } + _ => false, + } +} + +/// Whether `ty` is a named type whose name `matches`, or a union with one. +fn names(ty: &Type, matches: &impl Fn(&str) -> bool) -> bool { + match ty { + Type::Named(name, args) => args.is_empty() && matches(name), + Type::Union(parts) => parts.iter().any(|part| names(part, matches)), + _ => false, + } +} diff --git a/crates/qbx_lua_ls/src/features/callback_payloads.rs b/crates/qbx_lua_ls/src/features/callback_payloads.rs index 01cd389..7b23bf4 100644 --- a/crates/qbx_lua_ls/src/features/callback_payloads.rs +++ b/crates/qbx_lua_ls/src/features/callback_payloads.rs @@ -44,9 +44,9 @@ pub fn payloads<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { /// Each call to an `await` or `trigger` wrapper whose payload leaves out a parameter the handler /// requires, with the message naming it. Any of the handlers may be the one that runs, so the one /// that needs the fewest values decides. -pub fn missing_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { +pub fn missing_payloads(infer: &Infer, payloads: &[Payload]) -> Vec<(Span, String)> { let mut out = Vec::new(); - for payload in payloads(infer, chunk) { + for payload in payloads { // A call or `...` at the end passes as many values as it gives. if payload.args.last().is_some_and(Expr::is_multi_value) { continue; @@ -79,9 +79,9 @@ pub fn missing_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { /// Each call to an `await` or `trigger` wrapper whose payload passes more values than the handler /// takes, at the values it does not take. The handler that takes the most decides. -pub fn redundant_payloads(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { +pub fn redundant_payloads(payloads: &[Payload]) -> Vec<(Span, String)> { let mut out = Vec::new(); - 'payloads: for payload in payloads(infer, chunk) { + 'payloads: for payload in payloads { let mut most = 0; for handler in &payload.handlers { let Some(count) = most_arguments(handler, false) else { continue 'payloads }; diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index c86c35d..8b7c7a1 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -79,7 +79,11 @@ impl<'a, 'b> Declared<'a, 'b> { ExprKind::Call { .. } | ExprKind::MethodCall { .. } => { self.returned(expr).and_then(|values| values.into_iter().next()).unwrap_or_default() } - ExprKind::Unary { op: UnOp::Neg, expr: operand } => self.declared(operand, depth + 1).widen(), + // `-'5'` is a number, and a table with an `__unm` metamethod gives anything. + ExprKind::Unary { op: UnOp::Neg, expr: operand } => match self.declared(operand, depth + 1).widen() { + ty @ (Type::Number | Type::Integer) => ty, + _ => Type::Unknown, + }, // `a or b` is either of them, and `a and b` may be what `a` holds. ExprKind::Binary { op: BinOp::And | BinOp::Or, .. } => Type::Unknown, // Vectors, and tables with metamethods, give other values than numbers. @@ -221,7 +225,7 @@ impl<'a, 'b> Declared<'a, 'b> { /// Whether `callee` names a native, or a local that holds one as `local IsCamActive = IsCamActive` /// does. - fn is_native(&self, callee: &Expr, depth: u32) -> bool { + pub fn is_native(&self, callee: &Expr, depth: u32) -> bool { let ExprKind::Name(name) = &callee.unparen().kind else { return false }; let ctx = self.infer.ctx; let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(name.span.start) else { diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index c4e0e35..5b9d7cb 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -1,3 +1,4 @@ +use std::cell::OnceCell; use std::path::Path; use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; @@ -5,7 +6,8 @@ use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, MISSING_PARAMETER, - MISSING_RETURN, NO_UNKNOWN, REDUNDANT_PARAMETER, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + MISSING_RETURN, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, + UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -14,8 +16,9 @@ use qbx_lua_analysis::{ use qbx_lua_syntax::Span; use serde::{Deserialize, Serialize}; +use super::arguments::mismatched_arguments; use super::assignments::mismatched_assignments; -use super::callback_payloads::{missing_payloads, redundant_payloads}; +use super::callback_payloads::{missing_payloads, payloads, redundant_payloads, Payload}; use super::casts::mismatched_casts; use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; @@ -24,9 +27,9 @@ use super::returns::{mismatched_returns, missing_returns}; use super::strict_classes::undeclared_fields; use super::unknown_types::unknown_types; use super::visibility::invisible_members; -use super::with_infer; use crate::document::Document; use crate::index::{FileId, FileOrigin}; +use crate::infer::{FileContext, Infer}; use crate::workspace::Workspace; pub const SOURCE: &str = "qbx-lint"; @@ -50,41 +53,60 @@ fn strict_by_default(ws: &Workspace, file: FileId) -> bool { && ws.index.file(file).is_some_and(|f| f.origin == FileOrigin::Workspace && !is_silenced(ws, &f.path)) } +/// What the type checks of one file share: one inference, whose cache of the types of locals each +/// check fills for the next, and the `@callback` wrapper calls the file makes. +struct CheckInput<'a> { + ws: &'a Workspace, + doc: &'a Document, + infer: &'a Infer<'a>, + payloads: OnceCell>>, +} + +impl<'a> CheckInput<'a> { + fn payloads(&self) -> &[Payload<'a>] { + self.payloads.get_or_init(|| payloads(self.infer, &self.doc.chunk)) + } +} + /// Findings for rules qbx-lint registers but cannot check, because they need the LuaCATS types only /// the server indexes, and the parts of `missing-parameter` and `redundant-parameter` that depend on /// the handler a `@callback` wrapper call reaches. Inline suppression comments apply to them as they /// do to the linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { - type Check = fn(&Workspace, &Document) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 12] = [ - (UNDEFINED_DOC_NAME, |ws, doc| { - let side = ws.index.file(doc.file).and_then(|f| f.side); - undefined_doc_names(&ws.index, &doc.text, &doc.chunk, side) + type Check = fn(&CheckInput) -> Vec<(Span, String)>; + let checks: [(&'static str, Check); 13] = [ + (UNDEFINED_DOC_NAME, |input| { + let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); + undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) }), - (MISSING_FIELDS, |ws, doc| with_infer(ws, doc, |infer| missing_fields(infer, &doc.chunk))), - (ASSIGN_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, |infer| mismatched_assignments(infer, &doc.chunk))), - (UNDECLARED_FIELD, |ws, doc| { + (MISSING_FIELDS, |input| missing_fields(input.infer, &input.doc.chunk)), + (ASSIGN_TYPE_MISMATCH, |input| mismatched_assignments(input.infer, &input.doc.chunk)), + (PARAM_TYPE_MISMATCH, |input| mismatched_arguments(input.infer, &input.doc.chunk, input.payloads())), + (UNDECLARED_FIELD, |input| { // Without a strict class there is nothing to find, so no assignment needs its type inferred. - if !ws.lint_config.strict_classes && !ws.index.has_strict_class() { + if !input.ws.lint_config.strict_classes && !input.ws.index.has_strict_class() { return Vec::new(); } - with_infer(ws, doc, |infer| undeclared_fields(infer, &doc.chunk, |file| strict_by_default(ws, file))) + undeclared_fields(input.infer, &input.doc.chunk, |file| strict_by_default(input.ws, file)) }), - (INVISIBLE, |ws, doc| with_infer(ws, doc, |infer| invisible_members(infer, &doc.chunk))), - (RETURN_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, |infer| mismatched_returns(infer, &doc.chunk))), - (MISSING_RETURN, |ws, doc| with_infer(ws, doc, |infer| missing_returns(infer, &doc.chunk))), - (CAST_TYPE_MISMATCH, |ws, doc| with_infer(ws, doc, mismatched_casts)), - (MISSING_PARAMETER, |ws, doc| with_infer(ws, doc, |infer| missing_payloads(infer, &doc.chunk))), - (REDUNDANT_PARAMETER, |ws, doc| with_infer(ws, doc, |infer| redundant_payloads(infer, &doc.chunk))), - (NO_UNKNOWN, |ws, doc| with_infer(ws, doc, |infer| unknown_types(infer, &ws.lint_config.ignore_unused_prefix))), - (IMPOSSIBLE_COMPARISON, |ws, doc| with_infer(ws, doc, |infer| impossible_comparisons(infer, &doc.chunk))), + (INVISIBLE, |input| invisible_members(input.infer, &input.doc.chunk)), + (RETURN_TYPE_MISMATCH, |input| mismatched_returns(input.infer, &input.doc.chunk)), + (MISSING_RETURN, |input| missing_returns(input.infer, &input.doc.chunk)), + (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), + (MISSING_PARAMETER, |input| missing_payloads(input.infer, input.payloads())), + (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), + (NO_UNKNOWN, |input| unknown_types(input.infer, &input.ws.lint_config.ignore_unused_prefix)), + (IMPOSSIBLE_COMPARISON, |input| impossible_comparisons(input.infer, &input.doc.chunk)), ]; + let ctx = FileContext::new(doc.file, &doc.text, &doc.chunk, &doc.resolution); + let infer = Infer::new(&ctx, &ws.index); + let input = CheckInput { ws, doc, infer: &infer, payloads: OnceCell::new() }; let suppressions = Suppressions::parse(&doc.text, &doc.chunk.comments, &doc.lines); let mut out = Vec::new(); for (code, check) in checks { let Some(severity) = config.severity(code) else { continue }; out.extend( - check(ws, doc) + check(&input) .into_iter() .filter(|(span, _)| !suppressions.is_suppressed(code, doc.lines.line_of(span.start))) .map(|(span, message)| qbx_lua_analysis::Diagnostic { diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index d7ce7ca..1b3abd2 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -1,3 +1,4 @@ +pub mod arguments; pub mod assignments; pub mod assistant; pub mod callback_payloads; diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index cb20299..31db0fd 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -67,6 +67,15 @@ impl<'e> CallArgs<'e> { Self { exprs, types: exprs.iter().map(|_| OnceCell::new()).collect(), open: false } } + /// `exprs` with the types `ty` gives them, leaving the function literals unknown. + fn typed(exprs: &'e [Expr], ty: impl Fn(&Expr) -> Type) -> Self { + let typed = |expr: &Expr| match expr.unparen().kind { + ExprKind::Function(_) => Type::Unknown, + _ => ty(expr), + }; + Self { exprs, types: exprs.iter().map(|expr| OnceCell::from(typed(expr))).collect(), open: false } + } + fn ty(&self, infer: &Infer, index: usize) -> &Type { self.types[index].get_or_init(|| infer.expr(&self.exprs[index])) } @@ -601,7 +610,7 @@ impl<'a> Infer<'a> { /// The type that a `--[[@as T]]` or `---@as T` comment casts the expression ending at `end` to, /// when nothing but spaces stands between them. - fn cast_after(&self, end: u32) -> Option { + pub fn cast_after(&self, end: u32) -> Option { let comments = &self.ctx.chunk.comments; let comment = comments.get(comments.partition_point(|c| c.span.start < end))?; let between = self.ctx.source.get(end as usize..comment.span.start as usize)?; @@ -888,7 +897,7 @@ impl<'a> Infer<'a> { let local = self.ctx.resolution.local(id); if local.kind == LocalKind::ImplicitSelf { return match self.ctx.decl(local.decl.start) { - Some(Decl::SelfParam { name }) => self.func_name_owner_type(name), + Some(Decl::SelfParam { name }) => self.self_type(name), _ => Type::Unknown, }; } @@ -918,7 +927,7 @@ impl<'a> Infer<'a> { } expected.as_ref().and_then(|e| self.expected_param(e, *index)).unwrap_or_default() } - Decl::SelfParam { name } => self.func_name_owner_type(name), + Decl::SelfParam { name } => self.self_type(name), Decl::NumericFor => Type::Number, Decl::GenericFor { stmt, index } => self.for_in_type(stmt, *index), } @@ -1005,6 +1014,35 @@ impl<'a> Infer<'a> { Some(if param.optional { param.ty.optional() } else { param.ty }) } + /// The type the callee of a call declares for the parameter `id` of a function passed to it, with + /// the generics of the callee bound from the types `declared` gives its other arguments: `resolve` + /// of `fun(resolve: fun(value: T))` takes a `boolean` for `promise('boolean', ...)` whose first + /// parameter is a `` `T` ``. A generic they leave unbound is unknown: the values the call passes + /// bind it otherwise, which tells nothing declared. `None` when a `@param` line types the + /// parameter. + pub fn declared_callback_param(&self, id: LocalId, declared: impl Fn(&Expr) -> Type) -> Option { + let local = self.ctx.resolution.local(id); + let Some(Decl::Param { func, index, doc_anchor, expected: Some(Expected::Arg { call, arg_index }) }) = + self.ctx.decl(local.decl.start) + else { + return None; + }; + let name = &func.params[*index].text; + if self.ctx.doc_at(doc_anchor.unwrap_or(call.span.start)).params.iter().any(|p| p.name == *name) { + return None; + } + if (*index < 2 && self.on_cache_param(call).is_some()) || self.triggered_returns(call, *arg_index).is_some() { + return None; + } + let (callee, args, via_method) = self.call_parts(call)?; + let (skip_params, skip_args) = callee.call_offsets(via_method); + let callback = self.fun_of(&callee.params.get((arg_index + skip_params).checked_sub(skip_args)?)?.ty)?; + let param = callback.params.get(*index)?; + let args = CallArgs::typed(args.exprs, declared); + let ty = substitute(¶m.ty, &self.bind_generics(&callee, &args, via_method, false)); + Some(if param.optional { ty.optional() } else { ty }) + } + /// `TriggerCallback('name', function(response) end)` receives what the handler of `name` /// returns, when argument `arg_index` of `call` is that function. fn triggered_returns(&self, call: &Expr, arg_index: usize) -> Option> { @@ -1081,7 +1119,7 @@ impl<'a> Infer<'a> { } /// The function type `ty` describes, through aliases and the `nil` of `fun()?`. - fn fun_of(&self, ty: &Type) -> Option> { + pub fn fun_of(&self, ty: &Type) -> Option> { match self.resolve_alias(ty) { Type::Fun(fun) => Some(fun), Type::Union(types) => types.iter().find_map(|part| self.guarded(|| self.fun_of(part))), @@ -1153,6 +1191,18 @@ impl<'a> Infer<'a> { statement_at(self.ctx.chunk, anchor).map_or(Type::Unknown, |stmt| self.doc_self(stmt, func)) } + /// The type of the `self` of a function defined with `:`: a value of the table it is defined on, + /// or a string for a method added to the `string` library, which strings call. + fn self_type(&self, name: &FuncName) -> Type { + let on_strings = name.base.text == "string" + && name.path.is_empty() + && matches!(self.ctx.resolution.resolve_at(name.base.span.start), Some(Resolved::Global(_))); + if on_strings { + return Type::String; + } + self.func_name_owner_type(name) + } + fn for_in_type(&self, stmt: &Stmt, index: usize) -> Type { let StmtKind::GenericFor { exprs, .. } = &stmt.kind else { return Type::Unknown }; let Some(first) = exprs.first() else { return Type::Unknown }; @@ -2386,6 +2436,11 @@ impl<'a> Infer<'a> { values } + /// Every member of `ty` called `name`, as the definitions a call of it may reach. + pub fn members_named(&self, ty: &Type, name: &str) -> Vec { + self.guarded(|| self.members_matching(ty, Some(name))) + } + pub fn member(&self, ty: &Type, name: &str) -> Option { self.guarded(|| { let mut found = self.members_matching(ty, Some(name)); @@ -2806,14 +2861,19 @@ pub(crate) fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { /// `List` read from a `List`. The names it binds are no longer generics of `fun`, even /// when `fun` declares one of them with `@generic` too, as lua-language-server binds them. fn substitute_fun(fun: &FunType, generics: &[(SmolStr, Type)]) -> FunType { - let types = |types: &[Type]| types.iter().map(|t| substitute(t, generics)).collect(); + let types = |types: &[Type]| -> Vec { types.iter().map(|t| substitute(t, generics)).collect() }; let unbound = |name: &&SmolStr| !generics.iter().any(|(bound, _)| bound == *name); + let returns = types(&fun.returns); + let return_sets: Vec> = fun.return_sets.iter().map(|set| types(set)).collect(); + // Values bound from the arguments of a call tell what it passes today, not what it may, + // as the values that a generic function returns do. + let bound = returns != fun.returns || return_sets != fun.return_sets; FunType { params: fun.params.iter().map(|p| Param { ty: substitute(&p.ty, generics), ..p.clone() }).collect(), - returns: types(&fun.returns), + returns, return_values: fun.return_values.clone(), - return_sets: fun.return_sets.iter().map(|set| types(set)).collect(), - returns_inferred: fun.returns_inferred, + return_sets, + returns_inferred: fun.returns_inferred || bound, is_method: fun.is_method, lists_receiver: fun.lists_receiver, generics: fun.generics.iter().filter(unbound).cloned().collect(), diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index d0fdc42..3b55220 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1496,6 +1496,7 @@ AwaitServerCallback('add', 1, 2, GetValues()) AwaitServerCallback('add', 1, GetValues()) AwaitServerCallback('untyped', 1, 2) TriggerServerCallback('notify', function() end, 'hi', 5, true) +AwaitServerCallback('add', 'one', 2) "; client.open_with(CLIENT, text); let line = |needle: &str| pos(text, needle, 0).0 as u64; @@ -1542,6 +1543,14 @@ TriggerServerCallback('notify', function() end, 'hi', 5, true) ], "a call after the handler's parameters counts as one value, and undocumented parameters take one each" ); + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [( + "param-type-mismatch".to_string(), + line("AwaitServerCallback('add', 'one', 2)"), + "Cannot assign `string` to parameter `num1` of type `number`".to_string() + )] + ); } #[test] @@ -3969,6 +3978,256 @@ use(back, returned, built) ); } +#[test] +fn arguments_their_parameters_do_not_take_are_reported() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param n number +local function needsNumber(n) end +---@param mode 'a'|'b' +---@param ... string +local function pick(mode, ...) end +local Box = {} +---@param size integer +function Box:resize(size) end +---@param id number +---@overload fun(name: string, label: string) +local function find(id) end +---@return string +local function name() return 'x' end +---@generic T +---@param list T[] +local function first(list) return list[1] end +---@param v string|number +local function either(v) + if type(v) == 'number' then needsNumber(v) end +end +local mode = 'c' +---@type string? +local maybe +Settings = { Count = 'five' } +function string:shout() return string.upper(self) end + +needsNumber('str') +needsNumber(5) +pick('c') +pick('a', 'x', 2) +pick(mode) +Box:resize('big') +Box.resize(Box, 'large') +find('name', 'label') +find('alone') +find(1, 'label') +needsNumber(nil) +needsNumber(false) +needsNumber(maybe) +needsNumber(Settings.Count) +needsNumber(name() --[[@as number]]) +needsNumber(name()) +local rest = string.sub(123, 2) +local head = first(5) +SetEntityHeading(PlayerPedId(), 'north') +local position = vector4(vector3(1, 2, 3), 4.0) +---@diagnostic disable-next-line: param-type-mismatch +needsNumber('suppressed') +print(either, rest, head, position) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [ + finding("needsNumber('str')", "Cannot assign `string` to parameter `n` of type `number`"), + finding("pick('c')", "Cannot assign `\"c\"` to parameter `mode` of type `\"a\"|\"b\"`"), + finding("pick('a', 'x', 2)", "Cannot assign `integer` to parameter `...` of type `string`"), + finding("Box:resize('big')", "Cannot assign `string` to parameter `size` of type `integer`"), + finding("Box.resize(Box, 'large')", "Cannot assign `string` to parameter `size` of type `integer`"), + finding("find('alone')", "Cannot assign `string` to parameter `id` of type `number`"), + finding("find(1, 'label')", "Cannot assign `integer` to parameter `name` of type `string`"), + finding("needsNumber(maybe)", "Cannot assign `string` to parameter `n` of type `number`"), + finding("needsNumber(name())", "Cannot assign `string` to parameter `n` of type `number`"), + ], + "a call passes when a signature that takes as many arguments takes them all; nil, false, values \ + inferred from assignments, casts, type guards, generics, natives, the string a string method is called on and suppressed lines pass" + ); +} + +#[test] +fn arguments_are_compared_with_the_definitions_the_side_of_the_call_reaches() { + let mut client = Client::start(fixture_root()); + client.open_with("myresource/shared/config.lua", "Lib = {}\n"); + let client_text = "\ +---@param data table +function Lib.notify(data) end + +---@param text string +---@param kind string +exports('Notify', function(text, kind) end) + +Lib.notify({ title = 'saved' }) +Lib.notify('saved') +exports.myresource:Notify('saved', 'success') +exports.myresource:Notify('saved', 5) +"; + let server_text = "\ +---@param source number +---@param data table +function Lib.notify(source, data) end + +Lib.notify(1, { title = 'saved' }) +Lib.notify('saved') +"; + client.open_with(CLIENT, client_text); + client.open_with(SERVER, server_text); + let finding = |text: &str, needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [ + finding(client_text, "Lib.notify('saved')", "Cannot assign `string` to parameter `data` of type `table`"), + finding(client_text, "Notify('saved', 5)", "Cannot assign `integer` to parameter `kind` of type `string`"), + ], + "the exports proxy passes the arguments of a `:` call without the receiver" + ); + assert_eq!( + findings(&mut client, SERVER, &["param-type-mismatch"]), + [finding(server_text, "Lib.notify('saved')", "Cannot assign `string` to parameter `source` of type `number`")], + "the client definition does not run on the server" + ); +} + +#[test] +fn callbacks_take_the_generics_that_declared_arguments_bind() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param n number +local function needsNumber(n) end +---@generic T +---@param _type `T` +---@param handler fun(resolve: fun(value: T), reject: fun(reason: string)) +local function promise(_type, handler) end +---@generic T +---@param value T +---@param handler fun(set: fun(value: T), get: fun(): T) +local function watch(value, handler) end +Settings = { Count = 'five' } + +promise('boolean', function(resolve, reject) + resolve(true) + reject(5) +end) +watch(true, function(set, get) + set(1) + needsNumber(get()) +end) +watch(Settings.Count, function(set) set(1) end) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [ + finding("reject(5)", "Cannot assign `integer` to parameter `reason` of type `string`"), + finding("set(1)\n", "Cannot assign `integer` to parameter `value` of type `boolean`"), + ], + "a function the callee passes takes what the other arguments declare for its generics; a generic \ + bound from an inferred value takes any value, and what such a function returns passes" + ); +} + +#[test] +fn unknown_beside_other_types_takes_any_value() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param card unknown|string +local function present(card) end +---@param value unknown? +local function store(value) end +---@type unknown|string +local anything = {} +---@return unknown|string +local function describe() return {} end + +present({}) +store(5) +print(anything, describe) +"; + client.open_with(CLIENT, text); + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch", "assign-type-mismatch", "return-type-mismatch"]), + [] + ); + for (needle, expected) in [ + ("present(card)", "present(card: string|unknown)"), + ("store(value)", "store(value?: unknown?)"), + ("anything = {}", "anything: string|unknown"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn literals_in_locals_count_by_their_kind_and_negation_by_its_operand() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param s string +local function needsString(s) end +---@param n number +local function needsNumber(n) end +---@param mode 'fast'|'slow' +local function run(mode) end +local count = 5 +local mode = 'dev' + +needsNumber(-'5') +needsString(-count) +needsString(count) +run(mode) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str| { + let message = "Cannot assign `integer` to parameter `s` of type `string`".to_string(); + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [finding("needsString(-count)"), finding("needsString(count)")], + "a negated string is a number, and a literal stored in a local passes for the literals of its kind" + ); +} + +#[test] +fn arguments_of_calls_to_globals_of_escrowed_resources_are_not_checked() { + let mut client = Client::start(fixture_root()); + let text = "\ +exports('Open', function() end) + +---@param amount number +function VaultDeposit(amount) end + +VaultDeposit('all') +VaultDeposit(1, 2) +print(math.floor('x')) +"; + client.open_with("vault/open.lua", text); + assert_eq!( + findings(&mut client, "vault/open.lua", &["param-type-mismatch", "redundant-parameter"]), + [( + "param-type-mismatch".to_string(), + pos(text, "math.floor", 0).0 as u64, + "Cannot assign `string` to parameter `x` of type `number`".to_string() + )], + "the encrypted script of vault may define `VaultDeposit` differently, but not the runtime's functions" + ); +} + #[test] fn a_global_declared_as_nil_takes_any_value() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index d875164..920f04e 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -565,20 +565,32 @@ impl<'a> TypeParser<'a> { self.pos = self.src.len(); return Type::Unknown; } - let mut types = vec![self.postfix()]; + let mut types = Vec::new(); + let mut lists_unknown = false; loop { + let start = self.pos; + types.push(self.postfix()); + lists_unknown |= self.wrote_unknown(start); self.skip_ws(); - if self.peek() == b'|' { - self.pos += 1; - types.push(self.postfix()); - } else { + if self.peek() != b'|' { break; } + self.pos += 1; } self.depth -= 1; + // A written `unknown` takes any value, also beside other types: `unknown|string` is no + // `string`, and is shown as `string|unknown`, as lua-language-server shows it. + if lists_unknown && types.len() > 1 { + return Type::union(types).or_unknown(); + } Type::union(types) } + /// Whether the text parsed since `start` is the type `unknown`. + fn wrote_unknown(&self, start: usize) -> bool { + self.src.get(start..self.pos).is_some_and(|text| text.trim() == "unknown") + } + /// The sets of values a function returns, written `false | (string, string)`: alternatives /// separated by `|`, where a parenthesized list holds the values of one set. `None`, leaving the /// parser where it was, when no alternative lists several values, so the text is a plain type. @@ -650,14 +662,16 @@ impl<'a> TypeParser<'a> { } fn postfix(&mut self) -> Type { + let start = self.pos; let mut ty = self.primary(); loop { if self.rest().starts_with("[]") { self.pos += 2; ty = Type::Array(Box::new(ty)); } else if self.peek() == b'?' { + // `unknown?` takes any value, where an optional `unknown` would only be `nil`. + ty = if self.wrote_unknown(start) { Type::Nil.or_unknown() } else { ty.optional() }; self.pos += 1; - ty = ty.optional(); } else { return ty; } @@ -904,6 +918,18 @@ mod tests { assert_eq!(parser.rest().trim(), "the value to use"); } + #[test] + fn unknown_beside_other_types_takes_anything() { + assert_eq!(roundtrip("unknown"), "unknown"); + assert_eq!(roundtrip("unknown|string"), "string|unknown"); + assert_eq!(roundtrip("string | unknown"), "string|unknown"); + assert_eq!(roundtrip("unknown?"), "unknown?"); + assert_eq!(roundtrip("unknown[]?"), "unknown[]?"); + assert_eq!(roundtrip("fun(value: unknown|nil)"), "fun(value: unknown?)"); + assert_eq!(roundtrip("unknownType|string"), "unknownType|string"); + assert_eq!(roundtrip("unknown|any"), "any"); + } + #[test] fn malformed_types_do_not_hang() { for text in [ diff --git a/docs/reference.md b/docs/reference.md index 2792e1a..46abb79 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -320,6 +320,53 @@ AwaitServerCallback('add') When several handlers are registered under the name, the one that needs the fewest values decides, and for `redundant-parameter` the one that takes the most. +`param-type-mismatch` compares each argument with the type of its parameter, as +`assign-type-mismatch` compares a value with the type of its variable: a different kind of value, +or a literal the type does not list, is reported. + +```lua +---@param mode "fast" | "slow" +---@param count integer +local function run(mode, count) end + +run("instant", 1) -- Cannot assign `"instant"` to parameter `mode` of type `"fast"|"slow"` +run("fast", "2") -- Cannot assign `string` to parameter `count` of type `integer` +``` + +A parameter typed `any` or `unknown` takes any value, also beside other types, as in +`unknown|string`. + +It checks every call to a function whose parameters have types, also runtime functions, exports, +methods of classes and fields typed `fun(...)`. A call is compared with each definition the side +of the call reaches and with their `@overload`s, and passes when a signature that takes as many +arguments as it passes takes each of them; when none takes that many, any of them may. A `:` call +passes its receiver as the first argument, as in Lua, except through `exports`, whose proxy drops +it. The payload of a `---@callback` wrapper call is compared with the handlers registered under its +name. + +An argument has a type when something declares it, as for `impossible-comparison`: a literal, an +operator, an annotation, a stub, or the value a local that is never assigned again is declared +with. A literal stored in such a local counts by its kind only: `local mode = 'dev'` is a setting to +change, so it passes for `"fast" | "slow"`, while `local count = 5` is still no `string`. A +`--[[@as T]]` right after an argument casts it. Only clear cases count, and the rest is left alone: + +- Natives. The runtime converts their arguments, so `0` and `1` pass for a `boolean`, a string for + a hash, a number for a string, and a vector for three floats. +- `nil`, written out or as the `?` of a `string?`, which is checked as a `string`. Annotations often + leave out the `?` of a parameter that code skips. +- `false`, which FiveM code passes to skip a parameter, as in `AddItem(source, item, 1, false, info)`, + since exports and events serialize their arguments. +- Types inferred from assigned values, such as that of `Config.Value = ''`. +- Parameters typed with a generic of the function called: the arguments of the call bind it, which + declares nothing. A function that a callee passes to a callback, such as `resolve` of + `fun(resolve: fun(value: T))`, takes what the other arguments of that call declare for the + generic, as the `boolean` of `Promise:New('boolean', function(resolve) end)` for a `` `T` ``, and + any value for a generic they leave unbound. The values such a function returns are left out. +- Parameters typed with the name of a native handle such as `Vehicle`, which resources also declare + as classes. +- Calls through globals in an opaque resource, such as an escrowed one; see + [Escrowed, obfuscated and mixed-language resources](#escrowed-obfuscated-and-mixed-language-resources). + ## Strict classes A strict class takes only the fields it declares. Mark one with `(strict)`, or LuaLS's `(exact)`, @@ -671,9 +718,11 @@ complete knowledge of its globals or locale usage. Readable scripts are still an Unknown-export checks are suppressed for opaque resources and resources with non-Lua scripts. An opaque resource may also handle events named with its `resource:` prefix, so a wrong-side diagnostic is suppressed when the missing handler could be in that resource. Computed export -registrations similarly prevent a complete list of exports. `missing-parameter` and -`redundant-parameter` do not check calls to globals in an opaque resource, since an encrypted -script may define them differently. +registrations similarly prevent a complete list of exports. `missing-parameter`, +`redundant-parameter` and `param-type-mismatch` do not check calls to globals in an opaque +resource, or to fields of global tables, such as `Utils.round()`, since an encrypted script may +define them differently. `param-type-mismatch` still checks the runtime's own functions, such as +`math.floor`, and calls through `exports`. Read-only linting uses replacement characters for invalid UTF-8 bytes. `--fix`, `fmt`, and `fmt --check` report an encoding error for such source, and do not rewrite it. Convert its encoding diff --git a/docs/rules.md b/docs/rules.md index 03f13ef..0c70a11 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -37,6 +37,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | +| `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, or a literal the type does not list. Natives, `nil` and `false` are not checked. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | | `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. Reported by qbx-lua-ls only. | From b9139dd6c3ee1f435dc361b4bf53387bdccbc807 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:21:48 +0200 Subject: [PATCH 029/193] feat(rules): add redundant-return-value --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 9 +-- crates/qbx_lua_ls/src/features/diagnostics.rs | 9 +-- crates/qbx_lua_ls/src/features/returns.rs | 51 ++++++++++++-- crates/qbx_lua_ls/tests/lsp.rs | 69 +++++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 8 +++ docs/reference.md | 18 +++-- docs/rules.md | 1 + 10 files changed, 149 insertions(+), 21 deletions(-) diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index ae075a5..8c7851d 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -42,6 +42,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "param-type-mismatch", "return-type-mismatch", "missing-return", + "redundant-return-value", "cast-type-mismatch", "no-unknown", ]; diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index c62c8c7..2c4cc10 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -89,6 +89,8 @@ pub const INVISIBLE: &str = "invisible"; pub const RETURN_TYPE_MISMATCH: &str = "return-type-mismatch"; /// Reported by qbx-lua-ls, which reads the `@return` annotations with the types they allow. pub const MISSING_RETURN: &str = "missing-return"; +/// Reported by qbx-lua-ls, which reads the `@return` annotations of every function. +pub const REDUNDANT_RETURN_VALUE: &str = "redundant-return-value"; /// Reported by qbx-lua-ls, which reads the LuaCATS types; the linter itself has no type information. pub const CAST_TYPE_MISMATCH: &str = "cast-type-mismatch"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. @@ -162,6 +164,7 @@ pub static RULES: &[Rule] = &[ rule(INVISIBLE, Correctness, WARN, false, "A private, protected or package field or method of a LuaCATS class is used outside its class, subclasses or file (language server only)."), rule(RETURN_TYPE_MISMATCH, Correctness, WARN, false, "A function returns a value of a different type than its @return annotation declares (language server only)."), rule(MISSING_RETURN, Correctness, WARN, false, "A function with a required @return value can end, or return, without it (language server only)."), + rule(REDUNDANT_RETURN_VALUE, Correctness, WARN, false, "A function returns more values than its @return annotations declare (language server only)."), rule(CAST_TYPE_MISMATCH, Correctness, WARN, false, "A ---@cast gives a variable a type that its declared type does not take (language server only)."), rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type: none is declared and none can be inferred (language server only)."), rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index c2e0942..f859138 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -88,6 +88,7 @@ fn bad_resource_reports_every_rule() { | "invisible" | "return-type-mismatch" | "missing-return" + | "redundant-return-value" | "cast-type-mismatch" | "no-unknown" | "impossible-comparison" diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index b6e4e7a..8322800 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -63,10 +63,11 @@ Available features depend on the editor's LSP client. fields of their class or store a value of the wrong type, in a field or in a variable typed with `---@type` or `@param`, `param-type-mismatch` for arguments of the wrong type, `undeclared-field` for fields and keys - a `---@class (strict)` does not declare, and `return-type-mismatch` and `missing-return` for - functions, including callback handlers, that do not return what their `@return` declares. - `invisible` reports the use of fields and methods a class keeps `private`, `protected` or - `package` from outside its class, subclasses or file, and completion leaves them out there. + a `---@class (strict)` does not declare, and `return-type-mismatch`, `missing-return` and + `redundant-return-value` for functions, including callback handlers, that do not return what + their `@return` declares. `invisible` reports the use of fields and methods a class keeps + `private`, `protected` or `package` from outside its class, subclasses or file, and completion + leaves them out there. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. The opt-in diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 5b9d7cb..f9b8e72 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -6,8 +6,8 @@ use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, MISSING_PARAMETER, - MISSING_RETURN, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, - UNDEFINED_DOC_NAME, + MISSING_RETURN, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, + UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -23,7 +23,7 @@ use super::casts::mismatched_casts; use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; use super::doc_names::undefined_doc_names; -use super::returns::{mismatched_returns, missing_returns}; +use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; use super::unknown_types::unknown_types; use super::visibility::invisible_members; @@ -74,7 +74,7 @@ impl<'a> CheckInput<'a> { /// do to the linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 13] = [ + let checks: [(&'static str, Check); 14] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -92,6 +92,7 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< (INVISIBLE, |input| invisible_members(input.infer, &input.doc.chunk)), (RETURN_TYPE_MISMATCH, |input| mismatched_returns(input.infer, &input.doc.chunk)), (MISSING_RETURN, |input| missing_returns(input.infer, &input.doc.chunk)), + (REDUNDANT_RETURN_VALUE, |input| redundant_returns(input.infer, &input.doc.chunk)), (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), (MISSING_PARAMETER, |input| missing_payloads(input.infer, input.payloads())), (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index 1f6f8b1..2d55910 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -1,8 +1,9 @@ -//! `return-type-mismatch` and `missing-return`: a function documented with `@return` has to return -//! values of those types. A value of a different kind, or a literal the type does not list, is a -//! mismatch; a `return` with fewer values than the required ones, or a body that can run past its -//! end, is missing one. A value is required unless its type allows `nil`. An empty body is missing -//! its values too, except in a `---@meta` file, whose functions only declare their signatures. +//! `return-type-mismatch`, `missing-return` and `redundant-return-value`: a function documented with +//! `@return` has to return values of those types. A value of a different kind, or a literal the type +//! does not list, is a mismatch; a `return` with fewer values than the required ones, or a body that +//! can run past its end, is missing one; a `return` with more values than declared returns values +//! nobody expects. A value is required unless its type allows `nil`. An empty body is missing its +//! values too, except in a `---@meta` file, whose functions only declare their signatures. //! Returned tables typed as a class are checked like other class tables, by `missing-fields`, //! `assign-type-mismatch` and `undeclared-field`. //! @@ -65,7 +66,7 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let is_meta = is_meta_file(infer.ctx.source, chunk); let mut out = Vec::new(); for function in documented(infer, chunk) { - let Documented { func, returns, sets } = &function; + let Documented { func, returns, sets, .. } = &function; let required = required_values(&classes, returns); if (required == 0 && sets.is_empty()) || (is_meta && func.body.stmts.is_empty()) { continue; @@ -97,6 +98,35 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { out } +/// Each `return` that passes more values than its function's `@return` declares, at the values it +/// does not declare. A trailing `...T` takes any number of values, and of several sets, or of the +/// signatures `@overload` adds, the longest decides. Only the values written out count: a call or +/// `...` at the end may give none, or more than it needs to, as `return text:gsub(...)` passes on +/// the count of replacements. +pub fn redundant_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let is_open = |types: &[Type]| matches!(types.last(), Some(Type::Variadic(_))); + let mut out = Vec::new(); + for Documented { func, returns, sets, overloads } in documented(infer, chunk) { + let declared: Vec<&Vec> = std::iter::once(&returns).chain(&sets).chain(&overloads).collect(); + if declared.iter().any(|types| is_open(types)) { + continue; + } + let most = declared.iter().map(|types| types.len()).max().unwrap_or(0); + for (_, exprs) in return_stmts(&func.body) { + let open_ended = exprs.last().is_some_and(Expr::is_multi_value); + let written = exprs.len() - usize::from(open_ended); + let (Some(first), Some(last)) = (exprs.get(most), exprs.last()) else { continue }; + if written <= most { + continue; + } + let returned = if open_ended { format!("at least {}", values(written)) } else { values(written) }; + let message = format!("`@return` allows at most {}, but this returns {returned}", values(most)); + out.push((first.span.to(last.span), message)); + } + } + out +} + /// `ty` written as code: in backticks, unless it is already written in them, as `` `T` `` is. fn code(ty: &Type) -> String { match ty { @@ -132,6 +162,8 @@ struct Documented<'c> { returns: Vec, /// The sets of values of `@return false | (string, string)`, which `returns` then merges. sets: Vec>, + /// The values each `@overload` returns, or each set of them it lists. + overloads: Vec>, } impl Documented<'_> { @@ -163,11 +195,16 @@ impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { fn visit_stmt(&mut self, stmt: &'c Stmt) { for (doc, functions) in self.infer.ctx.function_docs(stmt) { if !doc.returns.is_empty() { + let overloads: Vec> = doc + .overloads + .iter() + .flat_map(|overload| std::iter::once(&overload.returns).chain(&overload.return_sets).cloned()) + .collect(); let documented = |func| { let ty = |ty: &Type| self.infer.doc_type_for(stmt, func, ty); let returns = doc.returns.iter().map(|r| ty(&r.ty)).collect(); let sets = doc.return_sets.iter().map(|set| set.iter().map(ty).collect()).collect(); - Documented { func, returns, sets } + Documented { func, returns, sets, overloads: overloads.clone() } }; self.out.extend(functions.into_iter().map(documented)); } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 3b55220..b94c4d9 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4748,6 +4748,75 @@ local kept = RegisterServerCallback('test:kept', function(source) return 2 end) assert_eq!(findings(&mut client, SERVER, &[missing]).len(), 1, "`---@meta` only counts above the first statement"); } +#[test] +fn returns_with_more_values_than_declared_are_reported() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return integer +local function one() return 1, 2 end + +---@return boolean ok +---@return string? err +local function two() return true, nil, 3, 4 end + +---@return integer, ...string +local function rest() return 1, 'a', 'b' end + +---@return number? ... +local function scalars() return 1, 2, 3 end + +---@return integer first ... +local function described() return 1, 2 end + +---@return false | (string, string) +local function name() return 'Joe', 'Doe', 'Smith' end + +---@param a? string +---@return integer +---@overload fun(a: string): integer, string +local function both(a) return 1, a end + +---@return string +local function escape(text) return text:gsub('%%', '%%%%') end + +---@return integer +local function tail() return 1, print() end + +---@return integer +local function more() return 1, 2, one() end + +---@return integer +local function quiet() + ---@diagnostic disable-next-line: redundant-return-value + return 1, 2 +end + +local function plain() return 1, 2, 3 end + +---@return boolean +RegisterNetEvent('test:check', function() return true, 'extra' end) + +print(rest, scalars, described, name, both, escape, tail, more, quiet, plain) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("redundant-return-value".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["redundant-return-value"]), + [ + finding("local function one()", "`@return` allows at most 1 value, but this returns 2 values"), + finding("local function two()", "`@return` allows at most 2 values, but this returns 4 values"), + finding("local function described()", "`@return` allows at most 1 value, but this returns 2 values"), + finding("local function name()", "`@return` allows at most 2 values, but this returns 3 values"), + finding("local function more()", "`@return` allows at most 1 value, but this returns at least 2 values"), + finding("return true, 'extra'", "`@return` allows at most 1 value, but this returns 2 values"), + ], + "a trailing `...T` or `T ...`, the longest set of values or `@overload`, what a call at the end gives, \ + undocumented functions and suppressed lines pass, while a `...` after the name of a value describes it" + ); +} + #[test] fn hover_binds_generics_from_arguments_and_callbacks() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index c5dec2c..ea1e2e2 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -458,6 +458,14 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { let ty = parser.parse(); parser.skip_ws(); let remainder = parser.rest(); + // `...` in place of the name, as in `@return number? ...`, returns any number of + // such values, as LuaLS reads it. + let (ty, remainder) = match remainder.strip_prefix("...") { + Some(after) if after.is_empty() || after.starts_with([' ', '\t', ',']) => { + (Type::Variadic(Box::new(ty)), after) + } + _ => (ty, remainder), + }; let word = remainder.split(|c: char| !(c.is_alphanumeric() || c == '_')).next().unwrap_or_default(); let after = &remainder[word.len()..]; let named = !word.is_empty() && (after.is_empty() || after.starts_with([' ', '\t', ','])); diff --git a/docs/reference.md b/docs/reference.md index 46abb79..9a6e124 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -508,10 +508,16 @@ function written in a table, whose doc comment goes above its field. qbx-lua-ls - `return-type-mismatch` for a returned value that clearly is not of its `@return` type: a different kind of value, such as `return 5` for `---@return string`, or a literal the type does - not list. A trailing `---@return ...string` covers every further value. + not list. A trailing `---@return ...string`, or `---@return string ...`, covers every further + value. - `missing-return` for a `return` with fewer values than the function requires, and at the `end` of a function whose body can run past it without returning. A value is required unless its type allows `nil`, as `string?`, `string|nil` or `any` do. +- `redundant-return-value` for the values a `return` passes beyond those `@return` declares. A + trailing `...T` takes any number of values, and an `@overload` that declares more values allows + them. Only the values written out count: a call or `...` at the end may give none, so + `return 1, print()` passes for `---@return integer`, and `return text:gsub(...)` for + `---@return string` although `gsub` also returns a count. A body finishes without running past its end when it ends in `return`, `error(...)`, an `if`/`else` whose branches all finish, or a loop such as `while true do` that only a `return` @@ -528,10 +534,10 @@ local function GetName() ... end ``` Each `return` then has to be one of the sets: `return false` and `return "Joe", "Doe"` are, while -`return "Joe"` is a `missing-return` and `return 1, "Doe"` a `return-type-mismatch`. A `return` is -compared with the set it comes closest to. This notation is a qbx extension of LuaCATS, which -qbx-lua-ls also uses to narrow the locals a call declares; see its -[type guards](../crates/qbx_lua_ls/README.md#type-guards). +`return "Joe"` is a `missing-return`, `return 1, "Doe"` a `return-type-mismatch` and +`return "Joe", "Doe", "Smith"` a `redundant-return-value`. A `return` is compared with the set it +comes closest to. This notation is a qbx extension of LuaCATS, which qbx-lua-ls also uses to narrow +the locals a call declares; see its [type guards](../crates/qbx_lua_ls/README.md#type-guards). Only clear cases count. Values whose type is not known are skipped, and so is a local that is assigned again after its declaration, since its declared type may not be what it holds. The same diff --git a/docs/rules.md b/docs/rules.md index 0c70a11..d78acd8 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -42,6 +42,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. Reported by qbx-lua-ls only. | | `missing-return` | warning | A function with a required `@return` value can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | +| `redundant-return-value` | warning | A `return` passes more values than the function's `@return` annotations declare. A trailing `...T` takes any number. Reported by qbx-lua-ls only. | | `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | | `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. Reported by qbx-lua-ls only. | | `const-reassign` | error | An assignment to a `` or `` local. | From 7debbc4771bfef28b7df90361154c9b958549914 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:21:48 +0200 Subject: [PATCH 030/193] feat(rules): add discard-returns --- crates/qbx_fivem_data/stubs/lua54.lua | 1 - crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 12 +- crates/qbx_lua_ls/src/features/arguments.rs | 132 +++++++++--------- crates/qbx_lua_ls/src/features/diagnostics.rs | 10 +- crates/qbx_lua_ls/src/features/discards.rs | 53 +++++++ crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/src/infer.rs | 1 + crates/qbx_lua_ls/tests/lsp.rs | 71 ++++++++++ crates/qbx_luacats/src/luacats.rs | 4 +- crates/qbx_luacats/src/types.rs | 2 + docs/reference.md | 30 +++- docs/rules.md | 1 + 15 files changed, 239 insertions(+), 84 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/discards.rs diff --git a/crates/qbx_fivem_data/stubs/lua54.lua b/crates/qbx_fivem_data/stubs/lua54.lua index 29b0b55..67a77f4 100644 --- a/crates/qbx_fivem_data/stubs/lua54.lua +++ b/crates/qbx_fivem_data/stubs/lua54.lua @@ -897,7 +897,6 @@ function string.gmatch(s, pattern, init) end ---@param n? integer ---@return string result ---@return integer count ----@nodiscard function string.gsub(s, pattern, repl, n) end ---Returns the length of `s` in bytes. diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 8c7851d..eea7590 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -43,6 +43,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "return-type-mismatch", "missing-return", "redundant-return-value", + "discard-returns", "cast-type-mismatch", "no-unknown", ]; diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 2c4cc10..110f2c5 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -91,6 +91,8 @@ pub const RETURN_TYPE_MISMATCH: &str = "return-type-mismatch"; pub const MISSING_RETURN: &str = "missing-return"; /// Reported by qbx-lua-ls, which reads the `@return` annotations of every function. pub const REDUNDANT_RETURN_VALUE: &str = "redundant-return-value"; +/// Reported by qbx-lua-ls, which reads the `@nodiscard` annotations of every function. +pub const DISCARD_RETURNS: &str = "discard-returns"; /// Reported by qbx-lua-ls, which reads the LuaCATS types; the linter itself has no type information. pub const CAST_TYPE_MISMATCH: &str = "cast-type-mismatch"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. @@ -165,6 +167,7 @@ pub static RULES: &[Rule] = &[ rule(RETURN_TYPE_MISMATCH, Correctness, WARN, false, "A function returns a value of a different type than its @return annotation declares (language server only)."), rule(MISSING_RETURN, Correctness, WARN, false, "A function with a required @return value can end, or return, without it (language server only)."), rule(REDUNDANT_RETURN_VALUE, Correctness, WARN, false, "A function returns more values than its @return annotations declare (language server only)."), + rule(DISCARD_RETURNS, Correctness, WARN, false, "A call drops the values of a function whose @nodiscard annotation requires using them (language server only)."), rule(CAST_TYPE_MISMATCH, Correctness, WARN, false, "A ---@cast gives a variable a type that its declared type does not take (language server only)."), rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type: none is declared and none can be inferred (language server only)."), rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index f859138..13034ba 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -89,6 +89,7 @@ fn bad_resource_reports_every_rule() { | "return-type-mismatch" | "missing-return" | "redundant-return-value" + | "discard-returns" | "cast-type-mismatch" | "no-unknown" | "impossible-comparison" diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 8322800..50c670b 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -62,12 +62,12 @@ Available features depend on the editor's LSP client. `missing-fields` and `assign-type-mismatch` for tables and assignments that leave out required fields of their class or store a value of the wrong type, in a field or in a variable typed with `---@type` or `@param`, `param-type-mismatch` for arguments of the wrong type, `undeclared-field` - for fields and keys - a `---@class (strict)` does not declare, and `return-type-mismatch`, `missing-return` and - `redundant-return-value` for functions, including callback handlers, that do not return what - their `@return` declares. `invisible` reports the use of fields and methods a class keeps - `private`, `protected` or `package` from outside its class, subclasses or file, and completion - leaves them out there. + for fields and keys a `---@class (strict)` does not declare, `return-type-mismatch`, + `missing-return` and `redundant-return-value` for functions, including callback handlers, that + do not return what their `@return` declares, and `discard-returns` for calls that drop the + values of a `@nodiscard` function. `invisible` reports the use of fields and methods a class + keeps `private`, `protected` or `package` from outside its class, subclasses or file, and + completion leaves them out there. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. The opt-in diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs index b8fb780..c895d5d 100644 --- a/crates/qbx_lua_ls/src/features/arguments.rs +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -78,8 +78,7 @@ impl Calls<'_, '_> { } let via_colon = method.is_some(); let at = base.span.start; - let signatures: Vec> = self - .definitions(base, method, at) + let signatures: Vec> = definitions(self.infer, base, method) .iter() .flat_map(|fun| self.infer.call_signatures(fun, args, via_colon, at).0) .collect(); @@ -91,71 +90,6 @@ impl Calls<'_, '_> { self.compare(&signatures, via_colon, args, &values); } - /// Every function a call of `base`, or of its `method`, at `at` may run: each member of that - /// name defined on the side of the call, each global of that name its file sees, or else the - /// function the callee holds. The exports proxy drops the receiver of `exports.name:Fn()`, so - /// those calls pass their arguments to the parameters that the function lists. - /// - /// In an escrowed resource, an encrypted script may define the globals a call reaches - /// differently, so a call through a global is left out unless only the runtime stubs define what - /// it reaches, or it goes through the exports proxy. - fn definitions(&self, base: &Expr, method: Option<&Name>, at: u32) -> Vec> { - let infer = self.infer; - let side = infer.side_at(at); - let reaches = |file: FileId| match (infer.index.file(file).and_then(|f| f.side), side) { - (Some(defined), Some(call)) => defined.is_available_on(call), - _ => true, - }; - let hidden = in_escrowed_resource(infer) && global_root(infer, base).is_some_and(|root| root.text != "exports"); - let known = |file: Option| { - !hidden || file.and_then(|file| infer.index.file(file)).is_some_and(|f| f.origin == FileOrigin::Stub) - }; - let members = |owner: &Expr, name: &str| -> Vec> { - let owner = infer.expr(owner); - let exported = matches!(owner, Type::Exports(Some(_))); - let found: Vec<_> = infer - .members_named(&owner, name) - .into_iter() - .filter(|member| member.location.as_ref().is_none_or(|(file, _)| reaches(*file))) - .collect(); - if !found.iter().all(|member| known(member.location.as_ref().map(|(file, _)| *file))) { - return Vec::new(); - } - found - .into_iter() - .filter_map(|member| member.ty.as_fun().cloned()) - .map(|fun| if exported { without_receiver(&fun) } else { fun }) - .collect() - }; - let held = || infer.callee_fun(base, method).map(|(fun, _)| fun).into_iter().collect(); - match (method, &base.unparen().kind) { - (Some(method), _) => members(base, &method.text), - (None, ExprKind::Field { base: owner, name, .. }) => members(owner, &name.text), - (None, ExprKind::Index { base: owner, index, .. }) if index.as_string().is_some() => { - members(owner, index.as_string().map(SmolStr::as_str).unwrap_or_default()) - } - (None, ExprKind::Name(name)) => match infer.ctx.resolution.resolve_at(name.span.start) { - Some(Resolved::Global(_)) => { - let globals = infer.index.globals_named(&name.text, infer.ctx.file); - let globals: Vec<_> = globals.into_iter().filter(|(file, _)| reaches(*file)).collect(); - if !globals.iter().all(|(file, _)| known(Some(*file))) { - return Vec::new(); - } - globals.into_iter().filter_map(|(_, symbol)| symbol.ty.as_fun().cloned()).collect() - } - // A parameter whose function the callee of its own function gives takes what the callee - // declares, with the generics that the other arguments of that call declare. - Some(Resolved::Local(id)) => { - let declared = Declared::new(infer); - let callback = infer.declared_callback_param(id, |arg| declared.of(arg)); - callback.and_then(|ty| infer.fun_of(&ty)).map_or_else(held, |fun| vec![fun]) - } - _ => held(), - }, - _ => held(), - } - } - /// The declared type of an argument, or the type a `--[[@as T]]` right after it gives it. fn value(&self, arg: &Expr) -> Type { self.infer.cast_after(arg.span.end).unwrap_or_else(|| self.declared.of(arg)) @@ -243,6 +177,70 @@ impl Calls<'_, '_> { } } +/// Every function a call of `base`, or of its `method`, may run: each member of that name defined +/// on the side of the call, each global of that name its file sees, or else the function the callee +/// holds. The exports proxy drops the receiver of `exports.name:Fn()`, so those calls pass their +/// arguments to the parameters that the function lists. +/// +/// In an escrowed resource, an encrypted script may define the globals a call reaches differently, +/// so a call through a global is left out unless only the runtime stubs define what it reaches, or +/// it goes through the exports proxy. +pub fn definitions(infer: &Infer, base: &Expr, method: Option<&Name>) -> Vec> { + let side = infer.side_at(base.span.start); + let reaches = |file: FileId| match (infer.index.file(file).and_then(|f| f.side), side) { + (Some(defined), Some(call)) => defined.is_available_on(call), + _ => true, + }; + let hidden = in_escrowed_resource(infer) && global_root(infer, base).is_some_and(|root| root.text != "exports"); + let known = |file: Option| { + !hidden || file.and_then(|file| infer.index.file(file)).is_some_and(|f| f.origin == FileOrigin::Stub) + }; + let members = |owner: &Expr, name: &str| -> Vec> { + let owner = infer.expr(owner); + let exported = matches!(owner, Type::Exports(Some(_))); + let found: Vec<_> = infer + .members_named(&owner, name) + .into_iter() + .filter(|member| member.location.as_ref().is_none_or(|(file, _)| reaches(*file))) + .collect(); + if !found.iter().all(|member| known(member.location.as_ref().map(|(file, _)| *file))) { + return Vec::new(); + } + found + .into_iter() + .filter_map(|member| member.ty.as_fun().cloned()) + .map(|fun| if exported { without_receiver(&fun) } else { fun }) + .collect() + }; + let held = || infer.callee_fun(base, method).map(|(fun, _)| fun).into_iter().collect(); + match (method, &base.unparen().kind) { + (Some(method), _) => members(base, &method.text), + (None, ExprKind::Field { base: owner, name, .. }) => members(owner, &name.text), + (None, ExprKind::Index { base: owner, index, .. }) if index.as_string().is_some() => { + members(owner, index.as_string().map(SmolStr::as_str).unwrap_or_default()) + } + (None, ExprKind::Name(name)) => match infer.ctx.resolution.resolve_at(name.span.start) { + Some(Resolved::Global(_)) => { + let globals = infer.index.globals_named(&name.text, infer.ctx.file); + let globals: Vec<_> = globals.into_iter().filter(|(file, _)| reaches(*file)).collect(); + if !globals.iter().all(|(file, _)| known(Some(*file))) { + return Vec::new(); + } + globals.into_iter().filter_map(|(_, symbol)| symbol.ty.as_fun().cloned()).collect() + } + // A parameter whose function the callee of its own function gives takes what the callee + // declares, with the generics that the other arguments of that call declare. + Some(Resolved::Local(id)) => { + let declared = Declared::new(infer); + let callback = infer.declared_callback_param(id, |arg| declared.of(arg)); + callback.and_then(|ty| infer.fun_of(&ty)).map_or_else(held, |fun| vec![fun]) + } + _ => held(), + }, + _ => held(), + } +} + /// Whether the file being checked belongs to an escrowed resource. fn in_escrowed_resource(infer: &Infer) -> bool { let resource = infer.index.file(infer.ctx.file).and_then(|file| file.resource); diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index f9b8e72..0699168 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,9 +5,9 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, MISSING_PARAMETER, - MISSING_RETURN, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, - UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, + MISSING_PARAMETER, MISSING_RETURN, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, + RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -22,6 +22,7 @@ use super::callback_payloads::{missing_payloads, payloads, redundant_payloads, P use super::casts::mismatched_casts; use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; +use super::discards::discarded_returns; use super::doc_names::undefined_doc_names; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; @@ -74,7 +75,7 @@ impl<'a> CheckInput<'a> { /// do to the linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 14] = [ + let checks: [(&'static str, Check); 15] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -93,6 +94,7 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< (RETURN_TYPE_MISMATCH, |input| mismatched_returns(input.infer, &input.doc.chunk)), (MISSING_RETURN, |input| missing_returns(input.infer, &input.doc.chunk)), (REDUNDANT_RETURN_VALUE, |input| redundant_returns(input.infer, &input.doc.chunk)), + (DISCARD_RETURNS, |input| discarded_returns(input.infer, &input.doc.chunk)), (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), (MISSING_PARAMETER, |input| missing_payloads(input.infer, input.payloads())), (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), diff --git a/crates/qbx_lua_ls/src/features/discards.rs b/crates/qbx_lua_ls/src/features/discards.rs new file mode 100644 index 0000000..697c2ff --- /dev/null +++ b/crates/qbx_lua_ls/src/features/discards.rs @@ -0,0 +1,53 @@ +//! `discard-returns`: a call on a line of its own drops the values of a function marked +//! `@nodiscard`, such as `tostring(value)` or `vector3(x, y, z)`, which then does nothing. Of a +//! function with `@overload`s, the signature the call picks decides, as for LuaLS: an overload is +//! not marked, so `math.random()` may warm up the generator. A call that may run a function without +//! the tag, such as one of several definitions, is left alone. + +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; + +use super::arguments::definitions; +use crate::infer::Infer; + +/// Each call statement of `chunk` whose function is marked `@nodiscard`, with the message naming it. +pub fn discarded_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let mut finder = Finder { infer, out: Vec::new() }; + finder.visit_block(&chunk.block); + finder.out +} + +struct Finder<'a, 'b> { + infer: &'a Infer<'b>, + out: Vec<(Span, String)>, +} + +impl<'c> Visitor<'c> for Finder<'_, '_> { + fn visit_stmt(&mut self, stmt: &'c Stmt) { + if let StmtKind::Expr(call) = &stmt.kind { + let callee = match &call.kind { + ExprKind::Call { callee, args, .. } => Some((&**callee, None, args)), + ExprKind::MethodCall { base, method, args, .. } => Some((&**base, Some(method), args)), + _ => None, + }; + if let Some((base, method, args)) = callee { + let functions = definitions(self.infer, base, method); + let marked = |fun| { + let (signatures, picked) = self.infer.call_signatures(fun, args, method.is_some(), base.span.start); + signatures[picked].nodiscard + }; + if !functions.is_empty() && functions.iter().all(marked) { + let name = match (method, base.dotted_path()) { + (Some(method), Some(owner)) => format!("`{owner}:{}`", method.text), + (Some(method), None) => format!("`{}`", method.text), + (None, Some(path)) => format!("`{path}`"), + (None, None) => "this function".to_string(), + }; + self.out.push((call.span, format!("The values that {name} returns cannot be discarded"))); + } + } + } + visit::walk_stmt(self, stmt); + } +} diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index 1b3abd2..0bb6f2f 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -9,6 +9,7 @@ pub mod comparisons; pub mod completion; pub mod definition; pub mod diagnostics; +pub mod discards; pub mod doc_names; pub mod event_call; pub mod expected; diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 31db0fd..12cf0f3 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -2880,6 +2880,7 @@ fn substitute_fun(fun: &FunType, generics: &[(SmolStr, Type)]) -> FunType { overloads: fun.overloads.iter().map(|overload| Arc::new(substitute_fun(overload, generics))).collect(), side: fun.side, callback: fun.callback.clone(), + nodiscard: fun.nodiscard, is_async: fun.is_async, } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index b94c4d9..bec1cd5 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4748,6 +4748,77 @@ local kept = RegisterServerCallback('test:kept', function(source) return 2 end) assert_eq!(findings(&mut client, SERVER, &[missing]).len(), 1, "`---@meta` only counts above the first statement"); } +#[test] +fn values_of_nodiscard_functions_have_to_be_used() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@nodiscard +---@return integer +local function important() return 1 end +local Store = {} +---@nodiscard +---@return string +function Store:key() return 'k' end +local function plain() return 1 end + +important() +local kept = important() +Store:key() +plain() +tostring(5) +local text = 'abc' +text:upper() +text:gsub('%w+', print) +math.random() +math.random(5) +math.random(1, 10) +print(important(), kept) +---@diagnostic disable-next-line: discard-returns +important() +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("discard-returns".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["discard-returns"]), + [ + finding("important()\nlocal kept", "The values that `important` returns cannot be discarded"), + finding("Store:key()\nplain", "The values that `Store:key` returns cannot be discarded"), + finding("tostring(5)", "The values that `tostring` returns cannot be discarded"), + finding("text:upper()", "The values that `text:upper` returns cannot be discarded"), + finding("math.random(1, 10)", "The values that `math.random` returns cannot be discarded"), + ], + "values that are used, functions without `@nodiscard`, `@overload`s, which are not marked, and \ + suppressed lines pass" + ); +} + +#[test] +fn discarded_values_of_globals_of_escrowed_resources_are_not_reported() { + let mut client = Client::start(fixture_root()); + let text = "\ +exports('Open', function() end) + +---@nodiscard +---@return integer +function VaultCount() return 1 end + +VaultCount() +tostring(5) +"; + client.open_with("vault/open.lua", text); + assert_eq!( + findings(&mut client, "vault/open.lua", &["discard-returns"]), + [( + "discard-returns".to_string(), + pos(text, "tostring(5)", 0).0 as u64, + "The values that `tostring` returns cannot be discarded".to_string() + )], + "the encrypted script of vault may define `VaultCount` differently, but not the runtime's functions" + ); +} + #[test] fn returns_with_more_values_than_declared_are_reported() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index ea1e2e2..7f4e9db 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -200,6 +200,7 @@ impl DocGroup { overloads, side: None, callback: self.callback.clone(), + nodiscard: self.nodiscard, is_async: self.is_async, } } @@ -922,7 +923,7 @@ mod tests { #[test] fn function_docs() { let doc = parse( - "---Spawns a vehicle.\n---Second line.\n---@param model string|integer the model\n---@param coords? vector4\n---@param ... any extra\n---@return integer netId # network id\n---@return string? err\n---@deprecated use other\n---@async", + "---Spawns a vehicle.\n---Second line.\n---@param model string|integer the model\n---@param coords? vector4\n---@param ... any extra\n---@return integer netId # network id\n---@return string? err\n---@deprecated use other\n---@async\n---@nodiscard", ); assert_eq!(doc.description, "Spawns a vehicle.\nSecond line."); assert_eq!(doc.params.len(), 3); @@ -936,6 +937,7 @@ mod tests { assert!(doc.is_async); let fun = doc.fun_type(&["model".into(), "coords".into()], true, false); + assert!(fun.nodiscard); assert_eq!( fun.signature("spawn"), "function spawn(model: string|integer, coords?: vector4, ...: any): integer, string?" diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index 920f04e..b0cf057 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -84,6 +84,8 @@ pub struct FunType { pub side: Option, /// What a function tagged `@callback` does with the callback names passed to it. pub callback: Option, + /// `@nodiscard`: a call has to use the values the function returns. + pub nodiscard: bool, /// `async fun(...)` or `---@async`: the function may yield, so it runs in a coroutine. pub is_async: bool, } diff --git a/docs/reference.md b/docs/reference.md index 9a6e124..7f70027 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -544,6 +544,26 @@ assigned again after its declaration, since its declared type may not be what it applies to the values `assign-type-mismatch` checks, where `nil` is a value like any other: `abc.field = nil` needs a field type that allows it, such as `string?` or `string|nil`. +`discard-returns` reports a call on a line of its own whose function is marked `@nodiscard`, as +its values are what it is called for. The runtime stubs mark the functions that only compute a +value, such as `tostring`, `math.floor`, `json.encode` and `vector3`: + +```lua +---@nodiscard +---@return integer +local function count() ... end + +count() -- The values that `count` returns cannot be discarded +``` + +A call that may run one of several functions, such as a client and a server definition, is only +reported when each of them is marked. Of a function with `@overload`s, the signature the call picks +decides, as for LuaLS: an overload is not marked, so `math.random()`, which warms up the generator, +passes, while `math.random(1, 10)` is reported. `string.gsub` is not marked either, since with a +function as `repl` it loops over the matches. Calls through globals in an opaque resource, such as +an escrowed one, are not checked; see +[Escrowed, obfuscated and mixed-language resources](#escrowed-obfuscated-and-mixed-language-resources). + ## Typed variables `assign-type-mismatch` also covers variables whose type is declared. A `local` or an assignment @@ -725,10 +745,10 @@ Unknown-export checks are suppressed for opaque resources and resources with non An opaque resource may also handle events named with its `resource:` prefix, so a wrong-side diagnostic is suppressed when the missing handler could be in that resource. Computed export registrations similarly prevent a complete list of exports. `missing-parameter`, -`redundant-parameter` and `param-type-mismatch` do not check calls to globals in an opaque -resource, or to fields of global tables, such as `Utils.round()`, since an encrypted script may -define them differently. `param-type-mismatch` still checks the runtime's own functions, such as -`math.floor`, and calls through `exports`. +`redundant-parameter`, `param-type-mismatch` and `discard-returns` do not check calls to globals in +an opaque resource, or to fields of global tables, such as `Utils.round()`, since an encrypted +script may define them differently. `param-type-mismatch` and `discard-returns` still check the +runtime's own functions, such as `math.floor`, and calls through `exports`. Read-only linting uses replacement characters for invalid UTF-8 bytes. `--fix`, `fmt`, and `fmt --check` report an encoding error for such source, and do not rewrite it. Convert its encoding diff --git a/docs/rules.md b/docs/rules.md index d78acd8..96dfcab 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -43,6 +43,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. Reported by qbx-lua-ls only. | | `missing-return` | warning | A function with a required `@return` value can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | | `redundant-return-value` | warning | A `return` passes more values than the function's `@return` annotations declare. A trailing `...T` takes any number. Reported by qbx-lua-ls only. | +| `discard-returns` | warning | A call on a line of its own drops the values of a function marked `@nodiscard`, such as `tostring(value)`. Reported by qbx-lua-ls only. | | `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | | `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. Reported by qbx-lua-ls only. | | `const-reassign` | error | An assignment to a `` or `` local. | From 50a3d172efe514ec015ea84bf1b2e483a6284d8b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:10:13 +0200 Subject: [PATCH 031/193] feat(infer): narrow locals that a condition reads from --- crates/qbx_lua_ls/README.md | 4 ++- crates/qbx_lua_ls/src/narrow.rs | 30 +++++++++++++++++++ crates/qbx_lua_ls/tests/lsp.rs | 53 +++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 50c670b..4a66319 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -136,7 +136,9 @@ print(name) -- string ``` Guards are `name`, comparisons with `nil`, `true`, `false`, a string or an integer, `type(name)` -and `math.type(name)` compared with a name they give, and those joined by `and`, `or` and `not`. +and `math.type(name)` compared with a name they give, reads from the local, and those joined by +`and`, `or` and `not`. A read such as `name.job`, `name?.job`, `name[key]` or `name:get()` is only +true when `name` holds a value, and so are `name?.job == "police"` and `name?.job ~= nil`. They apply to the branches of an `if` or `elseif`, to the code after an `if` whose other branches all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the right side of `and` and `or`, and to the code after `assert(name)`. A local that is assigned again after its diff --git a/crates/qbx_lua_ls/src/narrow.rs b/crates/qbx_lua_ls/src/narrow.rs index 9be0b8b..b4e29d0 100644 --- a/crates/qbx_lua_ls/src/narrow.rs +++ b/crates/qbx_lua_ls/src/narrow.rs @@ -241,6 +241,21 @@ impl Finder<'_> { Some(id) } + /// The local that the fields, indexes and calls of `expr` are read from, as `data` in + /// `data?.job.name` and `data:get()`. A value read through it means that it holds one. + fn read_from(&self, expr: &Expr) -> Option { + let expr = expr.unparen(); + let base = match &expr.kind { + ExprKind::Field { base, .. } | ExprKind::Index { base, .. } | ExprKind::MethodCall { base, .. } => base, + ExprKind::Call { callee, .. } if !matches!(callee.kind, ExprKind::Name(_)) => callee, + _ => return None, + }; + match &base.unparen().kind { + ExprKind::Name(_) => self.local(base), + _ => self.read_from(base), + } + } + fn is_reassigned(&self, local: LocalId) -> bool { self.resolution.local(local).refs.iter().any(|r| r.write) } @@ -319,6 +334,21 @@ impl Finder<'_> { if let Some((local, kind)) = self.kind_check(lhs, rhs).or_else(|| self.kind_check(rhs, lhs)) { out.push((local, if equal { Fact::Kind(kind) } else { Fact::NotKind(kind) })); } + // `data?.job == 'police'` and `data?.job ~= nil` read a value from `data`. + for (side, other) in [(lhs, rhs), (rhs, lhs)] { + if let (Some(local), Some(value)) = (self.read_from(side), literal(other)) { + if equal != (value == Type::Nil) { + out.push((local, Fact::Truthy)); + } + } + } + } + // `data?.job`, `data.job`, `data[key]` and `data:get()` are only true when `data` holds a + // value: `?.` gives `nil` for a `nil` one, and the others raise an error. + _ if holds => { + if let Some(local) = self.read_from(cond) { + out.push((local, Fact::Truthy)); + } } _ => {} } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index bec1cd5..ae4ae03 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1926,6 +1926,59 @@ local copy = name } } +#[test] +fn guards_that_read_from_a_local_narrow_it() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Reader +---@field job string? +---@field get fun(self: Reader): string + +---@param data Reader? +---@param other Reader? +---@param third Reader? +---@param fourth Reader? +local function read(data, other, third, fourth) + if data?.job then + print(data) -- safe field + end + if other?.job == 'police' then + print(other) -- compared field + end + if third:get() then + print(third) -- method + end + if not fourth?.job then + print(fourth) -- not read + return + end + print(fourth) -- after read + if data?.job == nil then + print(data) -- compared with nil + end + if data?.job ~= nil then + print(data) -- not nil + end +end +read() +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("data) -- safe field", "data: Reader {"), + ("other) -- compared field", "other: Reader {"), + ("third) -- method", "third: Reader {"), + // A false `fourth?.job` may come from a `nil` field. + ("fourth) -- not read", "fourth: Reader? {"), + ("fourth) -- after read", "fourth: Reader {"), + ("data) -- compared with nil", "data: Reader? {"), + ("data) -- not nil", "data: Reader {"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn guards_pick_the_set_of_values_a_call_returned() { let mut client = Client::start(fixture_root()); From c1f6aa46283d216f154a9abb52255bee457c8e4c Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:10:25 +0200 Subject: [PATCH 032/193] feat(rules): add need-check-nil --- crates/qbx_fivem_data/stubs/lua54.lua | 4 +- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/src/features/arguments.rs | 178 +++++---- crates/qbx_lua_ls/src/features/comparisons.rs | 60 ++- crates/qbx_lua_ls/src/features/diagnostics.rs | 39 +- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/src/features/nil_checks.rs | 356 ++++++++++++++++++ crates/qbx_lua_ls/src/infer.rs | 11 +- crates/qbx_lua_ls/tests/lsp.rs | 304 +++++++++++++++ docs/reference.md | 80 +++- docs/rules.md | 1 + 13 files changed, 939 insertions(+), 100 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/nil_checks.rs diff --git a/crates/qbx_fivem_data/stubs/lua54.lua b/crates/qbx_fivem_data/stubs/lua54.lua index 67a77f4..2974842 100644 --- a/crates/qbx_fivem_data/stubs/lua54.lua +++ b/crates/qbx_fivem_data/stubs/lua54.lua @@ -310,8 +310,8 @@ function debug.gethook(co) end ---Returns a table describing a function or a stack level. `what` selects which groups of fields get filled in. ---@param f integer|function ---@param what? string ----@return debuginfo? info ----@overload fun(thread: thread, f: integer|function, what?: string): debuginfo? +---@return debuginfo info +---@overload fun(thread: thread, f: integer|function, what?: string): debuginfo ---@nodiscard function debug.getinfo(f, what) end diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index eea7590..af57434 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -46,6 +46,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "discard-returns", "cast-type-mismatch", "no-unknown", + "need-check-nil", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 110f2c5..b8a1423 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -103,6 +103,8 @@ pub const SELF_COMPARISON: &str = "self-comparison"; pub const COUNT_DOWN_LOOP: &str = "count-down-loop"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. pub const IMPOSSIBLE_COMPARISON: &str = "impossible-comparison"; +/// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. +pub const NEED_CHECK_NIL: &str = "need-check-nil"; pub const LOWERCASE_GLOBAL: &str = "lowercase-global"; pub const IMPLICIT_GLOBAL: &str = "implicit-global"; pub const BUILTIN_OVERWRITE: &str = "builtin-overwrite"; @@ -175,6 +177,7 @@ pub static RULES: &[Rule] = &[ rule(SELF_COMPARISON, Suspicious, WARN, false, "Both sides of a comparison are the same expression."), rule(COUNT_DOWN_LOOP, Suspicious, WARN, true, "A numeric for loop counts up from a start above its end, or from #list to 1, without a negative step."), rule(IMPOSSIBLE_COMPARISON, Suspicious, INFO, false, "Both sides of an == or ~= have types that share no value, so the comparison always gives the same answer (language server only)."), + rule(NEED_CHECK_NIL, Correctness, WARN, false, "A local whose declared type allows nil or false is indexed, called, used in arithmetic, concatenation, # or an ordering comparison, or given as a for bound without a check (language server only)."), rule(LOWERCASE_GLOBAL, Suspicious, WARN, false, "A global with a lowercase first letter is defined; this is usually a missing 'local'."), rule(IMPLICIT_GLOBAL, Suspicious, WARN, false, "A global is created from inside a function and never declared at file scope."), rule(BUILTIN_OVERWRITE, Suspicious, WARN, false, "A runtime global or native is overwritten."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 13034ba..60990b2 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -93,6 +93,7 @@ fn bad_resource_reports_every_rule() { | "cast-type-mismatch" | "no-unknown" | "impossible-comparison" + | "need-check-nil" ); assert!(exercised || covered_elsewhere, "rule {} is not exercised by the bad_resource fixture", rule.code); } diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs index c895d5d..9e4f16c 100644 --- a/crates/qbx_lua_ls/src/features/arguments.rs +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -67,27 +67,13 @@ impl<'c> Visitor<'c> for Calls<'_, '_> { impl Calls<'_, '_> { fn check(&mut self, call: &Expr, base: &Expr, method: Option<&Name>, args: &[Expr]) { - if args.is_empty() || (method.is_none() && self.declared.is_native(base, 0)) { - return; - } - // In `require 'name' --[[@as T]]`, the cast after the argument is that of the call. - if args.last().is_some_and(|arg| arg.span.end == call.span.end) - && self.infer.cast_after(call.span.end).is_some() - { - return; - } - let via_colon = method.is_some(); - let at = base.span.start; - let signatures: Vec> = definitions(self.infer, base, method) - .iter() - .flat_map(|fun| self.infer.call_signatures(fun, args, via_colon, at).0) - .collect(); + let signatures = signatures(self.infer, &self.declared, call, base, method, args); if signatures.is_empty() { return; } let values: Vec = args.iter().map(|arg| self.value(arg)).collect(); let signatures: Vec<&FunType> = signatures.iter().map(Arc::as_ref).collect(); - self.compare(&signatures, via_colon, args, &values); + self.compare(&signatures, method.is_some(), args, &values); } /// The declared type of an argument, or the type a `--[[@as T]]` right after it gives it. @@ -99,20 +85,8 @@ impl Calls<'_, '_> { /// them takes them all. Only the signatures that take as many arguments as the call passes count, /// unless none does. fn compare(&mut self, signatures: &[&FunType], via_colon: bool, args: &[Expr], values: &[Type]) { - let infer = self.infer; - let alias = |name: &str| infer.index.alias(name, infer.side()).map(|(_, alias)| &alias.ty); - // A call or `...` at the end passes as many values as it gives. - let open_ended = args.last().is_some_and(Expr::is_multi_value); - let takes_count = |fun: &FunType| { - most_arguments(fun, via_colon).is_none_or(|most| args.len() <= most) - && (open_ended || args.len() >= Requirement::of(fun, via_colon, &alias).arguments) - }; - let mut candidates: Vec<&FunType> = signatures.iter().copied().filter(|fun| takes_count(fun)).collect(); - if candidates.is_empty() { - candidates = signatures.to_vec(); - } let mut closest: Option> = None; - for fun in candidates { + for fun in candidates(self.infer, signatures, via_colon, args) { let found = self.mismatches(fun, via_colon, args, values); if found.is_empty() { return; @@ -124,59 +98,111 @@ impl Calls<'_, '_> { self.out.extend(closest.unwrap_or_default()); } - /// Each of `args` that the parameter of `fun` it goes to does not take. A `:` call passes its - /// receiver to the first parameter of a function not defined with `:`, as Lua does, and a `.` - /// call passes the `self` of one defined with `:` first. + /// Each of `args` that the parameter of `fun` it goes to does not take. fn mismatches(&self, fun: &FunType, via_colon: bool, args: &[Expr], values: &[Type]) -> Vec<(Span, String)> { - let (skip_params, skip_args) = match (via_colon, fun.is_method) { - (true, false) => (1, 0), - (false, true) => (0, 1), - _ => (0, 0), - }; - let params = fun.params.get(skip_params..).unwrap_or_default(); - let rest = params.last().filter(|param| param.name == "..."); - let mut out = Vec::new(); - for (i, (arg, given)) in args.iter().zip(values).enumerate().skip(skip_args) { - let param = match params.get(i - skip_args) { - Some(param) if param.name != "..." => param, - _ => match rest { - Some(rest) => rest, - None => break, - }, - }; - if let Some(message) = self.mismatch(fun, param, given) { - out.push((arg.span, message)); - } - } - out + let passed = args.iter().zip(values).enumerate(); + passed + .filter_map(|(i, (arg, given))| { + let message = mismatch(&self.classes, fun, param_for(fun, via_colon, i)?, given)?; + Some((arg.span, message)) + }) + .collect() } +} - /// The message for a value of type `given` that `param` does not take. - fn mismatch(&self, fun: &FunType, param: &Param, given: &Type) -> Option { - let expected = match ¶m.ty { - Type::Variadic(inner) => inner, - ty => ty, - }; - let given = given.without_nil(); - let is_generic = |name: &str| fun.generics.iter().any(|generic| generic == name); - // Resources declare classes such as `Vehicle` that share the name of a native handle, which a - // parameter of that type may well mean. - let is_handle = |name: &str| NATIVE_HANDLE_TYPES.contains(&name); - if matches!(given, Type::Unknown | Type::Nil | Type::BooleanLit(false)) - || mentions(expected, &is_generic) - || names(expected, &is_handle) - { - return None; - } - let from = self.classes.file(); - if !self.classes.rejects(expected, from, &given) { - return None; - } - let shown = if self.classes.literal_mismatch(expected, from, &given) { given } else { given.widen() }; - Some(format!("Cannot assign `{shown}` to parameter `{}` of type `{expected}`", param.name)) +/// The signatures that a call of `base`, or of its `method`, with `args` may use: those of each +/// function `definitions` finds, with their `@overload`s for the side of the call. None for a call +/// without arguments, for a native, whose arguments the runtime converts, and for +/// `require 'name' --[[@as T]]`, where the cast after the argument is that of the call. +pub fn signatures( + infer: &Infer, + declared: &Declared, + call: &Expr, + base: &Expr, + method: Option<&Name>, + args: &[Expr], +) -> Vec> { + if args.is_empty() || (method.is_none() && declared.is_native(base, 0)) { + return Vec::new(); + } + if args.last().is_some_and(|arg| arg.span.end == call.span.end) && infer.cast_after(call.span.end).is_some() { + return Vec::new(); + } + let via_colon = method.is_some(); + let at = base.span.start; + definitions(infer, base, method).iter().flat_map(|fun| infer.call_signatures(fun, args, via_colon, at).0).collect() +} + +/// The `signatures` that take as many arguments as a call passes with `args`, or all of them when +/// none does. +pub fn candidates<'f>(infer: &Infer, signatures: &[&'f FunType], via_colon: bool, args: &[Expr]) -> Vec<&'f FunType> { + let alias = |name: &str| infer.index.alias(name, infer.side()).map(|(_, alias)| &alias.ty); + // A call or `...` at the end passes as many values as it gives. + let open_ended = args.last().is_some_and(Expr::is_multi_value); + let takes_count = |fun: &FunType| { + most_arguments(fun, via_colon).is_none_or(|most| args.len() <= most) + && (open_ended || args.len() >= Requirement::of(fun, via_colon, &alias).arguments) + }; + let candidates: Vec<&FunType> = signatures.iter().copied().filter(|fun| takes_count(fun)).collect(); + if candidates.is_empty() { + signatures.to_vec() + } else { + candidates + } +} + +/// The parameter of `fun` that argument `index` of a call goes to. A `:` call passes its receiver to +/// the first parameter of a function not defined with `:`, as Lua does, and a `.` call passes the +/// `self` of one defined with `:` first, which no parameter lists. +pub fn param_for(fun: &FunType, via_colon: bool, index: usize) -> Option<&Param> { + let (skip_params, skip_args) = match (via_colon, fun.is_method) { + (true, false) => (1, 0), + (false, true) => (0, 1), + _ => (0, 0), + }; + let params = fun.params.get(skip_params..).unwrap_or_default(); + match params.get(index.checked_sub(skip_args)?) { + Some(param) if param.name != "..." => Some(param), + _ => params.last().filter(|param| param.name == "..."), } } +/// The type of the values `param` takes, one by one for a `...`. +pub fn expected(param: &Param) -> &Type { + match ¶m.ty { + Type::Variadic(inner) => inner, + ty => ty, + } +} + +/// The message for a value of type `given` that `param` of `fun` does not take. +pub fn mismatch(classes: &Classes, fun: &FunType, param: &Param, given: &Type) -> Option { + let expected = expected(param); + let given = given.without_nil(); + let is_generic = |name: &str| fun.generics.iter().any(|generic| generic == name); + // Resources declare classes such as `Vehicle` that share the name of a native handle, which a + // parameter of that type may well mean. + let is_handle = |name: &str| NATIVE_HANDLE_TYPES.contains(&name); + if matches!(given, Type::Unknown | Type::Nil | Type::BooleanLit(false)) + || mentions(expected, &is_generic) + || names(expected, &is_handle) + { + return None; + } + let from = classes.file(); + if !classes.rejects(expected, from, &given) { + return None; + } + let shown = if classes.literal_mismatch(expected, from, &given) { given } else { given.widen() }; + Some(format!("Cannot assign `{shown}` to parameter `{}` of type `{expected}`", param.name)) +} + +/// Whether `param` takes `nil`: it is optional, or its type allows `nil` or any value, as a generic +/// does. +pub fn takes_nil(classes: &Classes, param: &Param) -> bool { + param.optional || !classes.rejects(expected(param), classes.file(), &Type::Nil) +} + /// Every function a call of `base`, or of its `method`, may run: each member of that name defined /// on the side of the call, each global of that name its file sees, or else the function the callee /// holds. The exports proxy drops the receiver of `exports.name:Fn()`, so those calls pass their diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index 8b7c7a1..628774f 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -9,11 +9,14 @@ //! as well: annotations often leave out the `?` of a value that may be missing, and the check for //! it is deliberate. +use std::cell::{OnceCell, RefCell}; + use qbx_fivem_data::native; use qbx_lua_analysis::scope::{Local, LocalId, Resolved}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; +use rustc_hash::FxHashMap; use super::class_tables::{Classes, Key}; use super::unknown_types::has_param_line; @@ -42,11 +45,21 @@ pub struct Declared<'a, 'b> { classes: Classes<'a, 'b>, /// Whether a local that is assigned again keeps the type its annotation gives it. keeps_annotations: bool, + /// Where the expression that each `--[[@as T]]` casts starts, by where it ends. + cast_starts: OnceCell>, + /// The types the locals read so far are declared with. + declarations: RefCell>, } impl<'a, 'b> Declared<'a, 'b> { pub fn new(infer: &'a Infer<'b>) -> Self { - Self { infer, classes: Classes::new(infer), keeps_annotations: false } + Self { + infer, + classes: Classes::new(infer), + keeps_annotations: false, + cast_starts: OnceCell::new(), + declarations: RefCell::default(), + } } /// Takes the `---@type` or `@param` of a local at its word, also where the local is assigned @@ -65,6 +78,10 @@ impl<'a, 'b> Declared<'a, 'b> { if depth > MAX_DEPTH { return Type::Unknown; } + // `local node = self.tail --[[@as LruNode]]` declares what the field holds there. + if let Some(cast) = self.cast(expr) { + return cast; + } let expr = expr.unparen(); match &expr.kind { ExprKind::Name(name) => match self.infer.ctx.resolution.resolve_at(name.span.start) { @@ -108,11 +125,26 @@ impl<'a, 'b> Declared<'a, 'b> { } } + /// The type that a `--[[@as T]]` after `expr` casts it to. The comment casts the whole + /// expression before it, so `s` keeps its own type in `'id:' .. s --[[@as string]]`. + fn cast(&self, expr: &Expr) -> Option { + let cast = self.infer.cast_after(expr.span.end)?; + let starts = self.cast_starts.get_or_init(|| { + let mut finder = CastTargets { infer: self.infer, starts: FxHashMap::default() }; + finder.visit_block(&self.infer.ctx.chunk.block); + finder.starts + }); + (starts.get(&expr.span.end) == Some(&expr.span.start)).then_some(cast) + } + /// The type of a local where it is read at `offset`. One that is assigned again after its /// declaration is `unknown`, since its declared type may not be what it holds, unless a /// `---@cast` line types it. A `+T` entry adds to a type, so one that is not known stays so. fn local(&self, id: LocalId, offset: u32, depth: u32) -> Type { - let ty = self.local_declaration(id, depth); + let ty = match depth { + 0 => self.declaration(id), + _ => self.local_declaration(id, depth), + }; let typed = || { let mut casts = self.infer.ctx.casts().at(id, offset); casts.any(|cast| cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_)))) @@ -126,7 +158,12 @@ impl<'a, 'b> Declared<'a, 'b> { /// The type a local is declared with, without what the guards and casts after its declaration /// tell. pub fn declaration(&self, id: LocalId) -> Type { - self.local_declaration(id, 0) + if let Some(ty) = self.declarations.borrow().get(&id) { + return ty.clone(); + } + let ty = self.local_declaration(id, 0); + self.declarations.borrow_mut().insert(id, ty.clone()); + ty } fn local_declaration(&self, id: LocalId, depth: u32) -> Type { @@ -250,3 +287,20 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { visit::walk_expr(self, expr); } } + +/// Finds the expressions that `--[[@as T]]` comments cast: of those that end where one starts, +/// the outermost. +struct CastTargets<'a, 'b> { + infer: &'a Infer<'b>, + starts: FxHashMap, +} + +impl<'c> Visitor<'c> for CastTargets<'_, '_> { + fn visit_expr(&mut self, expr: &'c Expr) { + if self.infer.cast_after(expr.span.end).is_some() { + let start = self.starts.entry(expr.span.end).or_insert(expr.span.start); + *start = (*start).min(expr.span.start); + } + visit::walk_expr(self, expr); + } +} diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 0699168..f7c1484 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -6,8 +6,8 @@ use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, - MISSING_PARAMETER, MISSING_RETURN, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, - RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, + REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -24,6 +24,7 @@ use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; use super::discards::discarded_returns; use super::doc_names::undefined_doc_names; +use super::nil_checks::{unchecked_nils, UncheckedNils}; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; use super::unknown_types::unknown_types; @@ -61,12 +62,19 @@ struct CheckInput<'a> { doc: &'a Document, infer: &'a Infer<'a>, payloads: OnceCell>>, + /// The inline comments that suppress rules in the file. + suppressions: Suppressions, } impl<'a> CheckInput<'a> { fn payloads(&self) -> &[Payload<'a>] { self.payloads.get_or_init(|| payloads(self.infer, &self.doc.chunk)) } + + /// Whether an inline comment suppresses `code` where `span` starts. + fn is_suppressed(&self, code: &str, span: Span) -> bool { + self.suppressions.is_suppressed(code, self.doc.lines.line_of(span.start)) + } } /// Findings for rules qbx-lint registers but cannot check, because they need the LuaCATS types only @@ -75,7 +83,7 @@ impl<'a> CheckInput<'a> { /// do to the linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 15] = [ + let checks: [(&'static str, Check); 16] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -100,27 +108,24 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), (NO_UNKNOWN, |input| unknown_types(input.infer, &input.ws.lint_config.ignore_unused_prefix)), (IMPOSSIBLE_COMPARISON, |input| impossible_comparisons(input.infer, &input.doc.chunk)), + (NEED_CHECK_NIL, |input| { + let UncheckedNils { reads, arguments } = unchecked_nils(input.infer, &input.doc.chunk); + // lua-language-server reports a value that may be nil passed for a parameter as a + // `param-type-mismatch`, so code written for it suppresses that rule there. + let suppressed = |(span, _): &(Span, String)| input.is_suppressed(PARAM_TYPE_MISMATCH, *span); + reads.into_iter().chain(arguments.into_iter().filter(|finding| !suppressed(finding))).collect() + }), ]; let ctx = FileContext::new(doc.file, &doc.text, &doc.chunk, &doc.resolution); let infer = Infer::new(&ctx, &ws.index); - let input = CheckInput { ws, doc, infer: &infer, payloads: OnceCell::new() }; let suppressions = Suppressions::parse(&doc.text, &doc.chunk.comments, &doc.lines); + let input = CheckInput { ws, doc, infer: &infer, payloads: OnceCell::new(), suppressions }; let mut out = Vec::new(); for (code, check) in checks { let Some(severity) = config.severity(code) else { continue }; - out.extend( - check(&input) - .into_iter() - .filter(|(span, _)| !suppressions.is_suppressed(code, doc.lines.line_of(span.start))) - .map(|(span, message)| qbx_lua_analysis::Diagnostic { - code, - severity, - span, - message, - tag: None, - fix: None, - }), - ); + out.extend(check(&input).into_iter().filter(|(span, _)| !input.is_suppressed(code, *span)).map( + |(span, message)| qbx_lua_analysis::Diagnostic { code, severity, span, message, tag: None, fix: None }, + )); } out } diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index 0bb6f2f..20d89dc 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -18,6 +18,7 @@ pub mod hover; pub mod inlay; pub mod member_refs; mod native_argument; +pub mod nil_checks; pub mod nui_resource; pub mod on_type; pub mod reference; diff --git a/crates/qbx_lua_ls/src/features/nil_checks.rs b/crates/qbx_lua_ls/src/features/nil_checks.rs new file mode 100644 index 0000000..99a7c3c --- /dev/null +++ b/crates/qbx_lua_ls/src/features/nil_checks.rs @@ -0,0 +1,356 @@ +//! `need-check-nil`: a local that may hold `nil` or `false` where code reads a field of it, calls +//! it, uses it as a key it stores a value under, does arithmetic, concatenation, `#` or a `<`, `<=`, +//! `>` or `>=` comparison with it, or gives it as a bound of a numeric `for`, all of which raise an +//! error for such a value. `name:upper()` after `local name = GetName()`, for a function declared +//! to return `string?`, is one. A local that may hold `nil` is also reported where a call passes it +//! for a parameter that does not take `nil` in any signature the call may use, as +//! `param-type-mismatch` reads them; natives are left out, as their arguments are. lua-language-server +//! reports those arguments as `param-type-mismatch`, so suppressing that rule silences them too. +//! +//! As for `impossible-comparison`, only declared types count, whatever declares them: an +//! annotation of the local, or the `@return` or `@field` of a function, class or stub. The type is +//! the one the guards and casts around the read leave. Guards that leave no value of it keep it whole, +//! but a `nil` or `false` that they rule out stays out: `round` is no `nil` in +//! `round and (round == true or i < round)`, whatever else it holds. The rest is left alone: +//! +//! - Fields and the values of calls, which guards do not narrow. +//! - Locals that are assigned again, unless a `---@cast` types them: a guard tells nothing about +//! the value an assignment replaces, and `name = name or 'none'` is how code fills in a `nil`. +//! - A value that a guard on another value of the same call covers, as `err` of +//! `local fn, err = load(code)` after `if not fn then`: annotations declare such values one by one. +//! - What a call gives for `source` alone, as `ESX.GetPlayerFromId(source)`: lookups of the player +//! whose event or callback runs only miss one that has just left. +//! - Reads after one that always runs before them, which raises the error first, and reads after +//! one reported in the same function, which points at the same missing check. + +use qbx_lua_analysis::scope::{FuncId, LocalId, LocalKind, Resolved}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; +use rustc_hash::FxHashSet; + +use super::arguments::{candidates, expected, mismatch, param_for, signatures, takes_nil}; +use super::class_tables::Classes; +use super::comparisons::Declared; +use crate::infer::{Decl, Infer}; +use crate::types::{FunType, Param, Type}; + +/// What `need-check-nil` finds in a file, each with the message naming the type. +pub struct UncheckedNils { + /// Each read of a local that may hold `nil` or `false` there. + pub reads: Vec<(Span, String)>, + /// Each local that may hold `nil` where a call passes it for a parameter that does not take it. + pub arguments: Vec<(Span, String)>, +} + +/// The reads and arguments of locals that may hold a missing value there. +pub fn unchecked_nils(infer: &Infer, chunk: &Chunk) -> UncheckedNils { + let mut finder = Finder { + infer, + declared: Declared::new(infer), + checked: Vec::new(), + sometimes: 0, + until: chunk.block.span.end, + reported: FxHashSet::default(), + out: Vec::new(), + passed: Vec::new(), + }; + finder.visit_block(&chunk.block); + UncheckedNils { reads: finder.out, arguments: finder.passed } +} + +struct Finder<'a, 'b> { + infer: &'a Infer<'b>, + declared: Declared<'a, 'b>, + /// The locals that a reported read showed to hold a value, each with the code that runs after it. + checked: Vec<(LocalId, Span)>, + /// How many of the parts around the expression being visited only run sometimes: the right + /// sides of `and` and `or`, the conditions of `elseif`, and that of a `repeat` whose body can + /// `break` out of it. + sometimes: u32, + /// Where the code that a read in the statement being visited runs before ends: at the end of + /// its block, or at a label a `goto` can reach without the read. + until: u32, + /// The locals reported so far, each with the function the read is in. + reported: FxHashSet<(LocalId, FuncId)>, + out: Vec<(Span, String)>, + /// The arguments reported, apart from the reads. + passed: Vec<(Span, String)>, +} + +impl Finder<'_, '_> { + /// Reports `operand` when it names a local that may hold `nil` or `false` there. + fn check(&mut self, operand: &Expr) { + self.check_value(operand, None); + } + + /// Reports `operand` when it names a local that may hold `nil` or `false` there, or only `nil` + /// when a call passes it for `param`, which does not take it. + fn check_value(&mut self, operand: &Expr, param: Option<&Param>) { + let ExprKind::Name(name) = &operand.unparen().kind else { return }; + let ctx = self.infer.ctx; + let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(name.span.start) else { return }; + let local = ctx.resolution.local(id); + let at = name.span.start; + // `self` is the value a method is called on. + if local.kind == LocalKind::ImplicitSelf { + return; + } + if self.checked.iter().any(|(checked, span)| *checked == id && span.contains(at)) { + return; + } + let ty = self.declared.of(operand.unparen()); + let reaches = |value: &Type| !self.infer.rules_out(id, at, value); + let missing = missing_value(&self.infer.expand_aliases(&ty, 0), reaches); + let Some(missing) = missing.filter(|missing| param.is_none() || *missing == "nil") else { return }; + // `local vehicle, coords = lib.getClosestVehicle(...)` declares both as optional, and the + // guard on `vehicle` is the check for `coords` as well. + let guards = ctx.guards(); + let linked = guards.linked(id).unwrap_or_default(); + if linked.iter().any(|(other, _)| *other != id && guards.at(*other, at).next().is_some()) { + return; + } + let assigned = local.refs.iter().any(|r| r.write); + if !assigned && self.is_lookup_of_source(id) { + return; + } + let func = local.refs.iter().find(|r| r.span.start == at).map_or(local.func, |r| r.func); + if !self.reported.insert((id, func)) { + return; + } + // A call passes the missing value on, which raises no error there. + if self.sometimes == 0 && !assigned && param.is_none() { + self.checked.push((id, Span::new(name.span.end, self.until.max(name.span.end)))); + } + match param { + Some(param) => self.passed.push(( + name.span, + format!( + "`{}` may be nil, which parameter `{}` of type `{}` does not take: its type here is `{ty}`", + name.text, + param.name, + expected(param) + ), + )), + None => self.out.push((name.span, format!("`{}` may be {missing}: its type here is `{ty}`", name.text))), + } + } + + /// Reports the locals among `args` that may hold `nil` where each signature the call may use + /// passes them for a parameter that does not take `nil`. An argument of another type than the + /// parameter takes is left to `param-type-mismatch`. + fn check_arguments(&mut self, call: &Expr, base: &Expr, method: Option<&Name>, args: &[Expr]) { + let infer = self.infer; + let via_colon = method.is_some(); + let classes = Classes::new(infer); + let mut resolved = None; + for (index, arg) in args.iter().enumerate() { + if !self.may_be_nil(arg) { + continue; + } + let found = resolved.get_or_insert_with(|| signatures(infer, &self.declared, call, base, method, args)); + let found: Vec<&FunType> = found.iter().map(AsRef::as_ref).collect(); + let given = self.declared.of(arg); + let params: Option> = candidates(infer, &found, via_colon, args) + .into_iter() + .map(|fun| { + let param = param_for(fun, via_colon, index)?; + let refuses = !takes_nil(&classes, param) && mismatch(&classes, fun, param, &given).is_none(); + refuses.then_some(param) + }) + .collect(); + if let Some(param) = params.as_ref().and_then(|params| params.first()) { + self.check_value(arg, Some(param)); + } + } + } + + /// Whether `arg` names a local whose declared type allows `nil` there. + fn may_be_nil(&self, arg: &Expr) -> bool { + let ExprKind::Name(name) = &arg.unparen().kind else { return false }; + matches!(self.infer.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Local(_))) + && missing_value(&self.infer.expand_aliases(&self.declared.of(arg), 0), |_| true) == Some("nil") + } + + fn sometimes(&mut self, visit: impl FnOnce(&mut Self)) { + self.sometimes += 1; + visit(self); + self.sometimes -= 1; + } + + /// Whether `id` holds what a call gives for `source` alone, as after + /// `local player = exports.qbx_core:GetPlayer(src)` with `local src = source`. + fn is_lookup_of_source(&self, id: LocalId) -> bool { + let Some(value) = self.value_of(id) else { return false }; + match &value.unparen().kind { + ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. } => { + matches!(args.as_slice(), [arg] if self.is_source(arg)) + } + _ => false, + } + } + + /// Whether `expr` is `source`, or a local that is never assigned again and holds it. + fn is_source(&self, expr: &Expr) -> bool { + let ExprKind::Name(name) = &expr.unparen().kind else { return false }; + if name.text == "source" { + return true; + } + let ctx = self.infer.ctx; + let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(name.span.start) else { return false }; + !ctx.resolution.local(id).refs.iter().any(|r| r.write) && self.value_of(id).is_some_and(|v| self.is_source(v)) + } + + /// The value that the `local` statement declaring `id` gives it: the expression in its + /// position, or the call that ends the list. + fn value_of(&self, id: LocalId) -> Option<&Expr> { + let ctx = self.infer.ctx; + let Some(Decl::Local { stmt, index }) = ctx.decl(ctx.resolution.local(id).decl.start) else { return None }; + let StmtKind::Local { exprs, in_unpack: false, .. } = &stmt.kind else { return None }; + exprs.get(*index).or_else(|| exprs.last().filter(|last| last.is_call())) + } +} + +/// What `ty` allows that raises the error and `reaches` the read: `nil`, or else `false`. `None` when +/// it allows neither, or nothing else, as for a local declared as `nil`. +fn missing_value(ty: &Type, reaches: impl Fn(&Type) -> bool) -> Option<&'static str> { + let parts = match ty { + Type::Union(parts) => parts.as_slice(), + one => std::slice::from_ref(one), + }; + if parts.iter().all(|part| matches!(part, Type::Nil | Type::BooleanLit(false))) { + return None; + } + let allows = |value: Type| parts.contains(&value) && reaches(&value); + if allows(Type::Nil) { + Some("nil") + } else if allows(Type::BooleanLit(false)) { + Some("false") + } else { + None + } +} + +/// Whether `op` raises an error for a `nil` or `false` operand. `==` and `~=` compare any values. +fn needs_value(op: BinOp) -> bool { + matches!( + op, + BinOp::Add + | BinOp::Sub + | BinOp::Mul + | BinOp::Div + | BinOp::IDiv + | BinOp::Mod + | BinOp::Pow + | BinOp::Concat + | BinOp::Lt + | BinOp::Le + | BinOp::Gt + | BinOp::Ge + | BinOp::BAnd + | BinOp::BOr + | BinOp::BXor + | BinOp::Shl + | BinOp::Shr + ) +} + +/// Whether `block`, the body of a loop, has a `break` that leaves that loop. +fn breaks(block: &Block) -> bool { + block.stmts.iter().any(|stmt| match &stmt.kind { + StmtKind::Break => true, + StmtKind::Do(body) => breaks(body), + StmtKind::If { branches, else_block } => { + branches.iter().any(|branch| breaks(&branch.block)) || else_block.as_ref().is_some_and(breaks) + } + _ => false, + }) +} + +impl<'c> Visitor<'c> for Finder<'_, '_> { + fn visit_block(&mut self, block: &'c Block) { + // A block, a function body included, runs apart from the statement around it. + let outer = (std::mem::take(&mut self.sometimes), self.until); + for (index, stmt) in block.stmts.iter().enumerate() { + let label = block.stmts[index + 1..].iter().find(|next| matches!(next.kind, StmtKind::Label(_))); + self.until = label.map_or(block.span.end, |label| label.span.start); + self.visit_stmt(stmt); + } + (self.sometimes, self.until) = outer; + } + + fn visit_stmt(&mut self, stmt: &'c Stmt) { + match &stmt.kind { + // Storing a value under a `nil` key raises an error, while reading one gives `nil`. + StmtKind::Assign { targets, .. } => { + for target in targets { + if let ExprKind::Index { index, .. } = &target.kind { + self.check(index); + } + } + } + StmtKind::CompoundAssign { op, expr, .. } if needs_value(*op) => self.check(expr), + StmtKind::NumericFor { start, limit, step, .. } => { + for bound in [Some(start), Some(limit), step.as_ref()].into_iter().flatten() { + self.check(bound); + } + } + // Only the first condition of an `if` always runs. + StmtKind::If { branches, else_block } => { + for (index, branch) in branches.iter().enumerate() { + if index == 0 { + self.visit_expr(&branch.cond); + } else { + self.sometimes(|finder| finder.visit_expr(&branch.cond)); + } + self.visit_block(&branch.block); + } + if let Some(block) = else_block { + self.visit_block(block); + } + return; + } + // A `break` leaves the loop without running the condition. + StmtKind::Repeat { body, cond } => { + self.visit_block(body); + if breaks(body) { + self.sometimes(|finder| finder.visit_expr(cond)); + } else { + self.visit_expr(cond); + } + return; + } + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'c Expr) { + match &expr.kind { + // `?.`, `?[` and `?:` give `nil` for a `nil` value. + ExprKind::Field { base, safe: false, .. } + | ExprKind::Index { base, safe: false, .. } + | ExprKind::MethodCall { base, safe: false, .. } => self.check(base), + ExprKind::Call { callee, .. } => self.check(callee), + ExprKind::Unary { op: UnOp::Len | UnOp::Neg | UnOp::BNot, expr: operand } => self.check(operand), + ExprKind::Binary { op, lhs, rhs, .. } if needs_value(*op) => { + self.check(lhs); + self.check(rhs); + } + // The right side of `and` and `or` only runs for some values of the left one. + ExprKind::Binary { op: BinOp::And | BinOp::Or, lhs, rhs, .. } => { + self.visit_expr(lhs); + self.sometimes(|finder| finder.visit_expr(rhs)); + return; + } + _ => {} + } + visit::walk_expr(self, expr); + // A call passes its arguments once it has read them all. + match &expr.kind { + ExprKind::Call { callee, args, .. } => self.check_arguments(expr, callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => self.check_arguments(expr, base, Some(method), args), + _ => {} + } + } +} diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 12cf0f3..0e1494a 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -764,6 +764,15 @@ impl<'a> Infer<'a> { } } + /// Whether the guards after the last cast that holds where `id` is read at `offset` rule out + /// `value`, also where they rule out every value of its type, which `narrowed` keeps whole then: + /// `round` is no `nil` in `round and (round == true or i < round)`, whatever else it holds. + pub fn rules_out(&self, id: LocalId, offset: u32, value: &Type) -> bool { + let since = self.ctx.casts().at(id, offset).last().map(|cast| cast.span.start); + let mut facts = self.ctx.guards().since(id, since, Span::empty(offset)); + facts.try_fold(value.clone(), |left, fact| fact.assume(&left)).is_none_or(|left| left != *value) + } + /// `ty` as one entry of a `---@cast` line changes it. fn cast(&self, ty: Type, entry: &CastEntry) -> Type { match entry { @@ -791,7 +800,7 @@ impl<'a> Infer<'a> { } /// `ty` with the aliases it names, also in a union, replaced by what they stand for. - fn expand_aliases(&self, ty: &Type, depth: u32) -> Type { + pub fn expand_aliases(&self, ty: &Type, depth: u32) -> Type { let resolved = self.resolve_alias(ty); match &resolved { Type::Union(parts) if depth < 8 => { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index ae4ae03..0cdbb0f 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3559,6 +3559,310 @@ check() ); } +#[test] +fn reads_of_locals_that_may_be_nil_are_reported() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Holder +---@field name string +---@field job string? + +---@return Probe.Holder? +local function GetHolder() end + +---@return number? vehicle +---@return vector3? coords +local function GetClosest() end + +---@return string? +local function GetName() end + +---@param id integer +---@param label? string +---@param done? fun() +---@param list string[]? +---@param state false|string +local function handle(id, label, done, list, state) + local holder = GetHolder() + print(holder.name) + print(holder.job) + print(label:upper()) + done() + print(#list) + print(state:upper()) + local amount = tonumber('5') + print(amount + 1) + local key = GetHolder() + local seen = {} + seen[key] = true + local branch = GetHolder() + if id > 1 then + print(branch.name) + end + local function afterBranch() return branch.name end + local always = GetHolder() + print(always.name) + local function afterRead() return always.job end + local twice = GetHolder() + if id > 2 then print(twice.name) else print(twice.job) end + local count = tonumber('5') + if count > 0 then print(count * 2) end + local limit = tonumber('5') + for i = 1, limit do print(i) end + local equal = tonumber('5') + print(equal == 0) + local suffix = GetName() + print('id:' .. suffix --[[@as string]]) + local looped = GetName() + repeat + if id > 3 then break end + until looped:upper() + local function afterLoop() return looped:lower() end + local ended = GetName() + repeat until ended:upper() + local function afterEnd() return ended:lower() end + + local guarded = GetHolder() + if not guarded then return end + print(guarded.name) + local inside = GetHolder() + if inside then print(inside.name) end + local either = GetHolder() + print(either and either.name) + local asserted = GetHolder() + assert(asserted) + print(asserted.name) + local safe = GetHolder() + print(safe?.name) + if safe?.job then print(safe.name) end + local cast = GetHolder() + ---@cast cast -? + print(cast.name) + local forced = GetHolder() --[[@as Probe.Holder]] + print(forced.name) + local filled = GetHolder() + filled = filled or { name = 'none' } + print(filled.name) + local vehicle, coords = GetClosest() + if not vehicle then return end + print(coords.x) + print(guarded.job:upper()) + print(GetHolder().name) + local quiet = GetHolder() + ---@diagnostic disable-next-line: need-check-nil + print(quiet.name) +end +handle(1) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |needle: &str, message: &str| ("need-check-nil".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [ + finding("holder.name", "`holder` may be nil: its type here is `Probe.Holder?`"), + finding("label:upper", "`label` may be nil: its type here is `string?`"), + finding("done()", "`done` may be nil: its type here is `(fun())?`"), + finding("#list", "`list` may be nil: its type here is `string[]?`"), + finding("state:upper", "`state` may be false: its type here is `false|string`"), + finding("amount + 1", "`amount` may be nil: its type here is `number?`"), + finding("seen[key]", "`key` may be nil: its type here is `Probe.Holder?`"), + finding("print(branch.name)", "`branch` may be nil: its type here is `Probe.Holder?`"), + // The read inside the `if` does not always run. + finding("return branch.name", "`branch` may be nil: its type here is `Probe.Holder?`"), + finding("print(always.name)", "`always` may be nil: its type here is `Probe.Holder?`"), + finding("print(twice.name)", "`twice` may be nil: its type here is `Probe.Holder?`"), + finding("count > 0", "`count` may be nil: its type here is `number?`"), + finding("1, limit", "`limit` may be nil: its type here is `number?`"), + // The cast is of the whole concatenation. + finding("'id:' .. suffix", "`suffix` may be nil: its type here is `string?`"), + finding("until looped", "`looped` may be nil: its type here is `string?`"), + // The `break` leaves the loop without running the condition. + finding("return looped", "`looped` may be nil: its type here is `string?`"), + finding("until ended", "`ended` may be nil: its type here is `string?`"), + ], + "reads that guards, casts, an earlier read or a guarded value of the same call cover are left alone, \ + and so are reads after one reported in the same function, locals assigned again, fields and the \ + values of calls" + ); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "off" } } } } }), + ); + assert!(findings(&mut client, CLIENT, &["need-check-nil"]).is_empty(), "the rule can be turned off"); +} + +#[test] +fn nil_checks_leave_alone_values_the_code_relies_on() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Member +---@field name string + +---@param id integer +---@return Probe.Member? +local function GetMember(id) end + +---@param id integer +---@param slot integer +---@return Probe.Member? +local function GetSlot(id, slot) end + +local shared = GetMember(1) + +local function first() + print(shared.name) +end + +RegisterNetEvent('probe:event', function(target) + local src = source + local member = GetMember(source) + print(member.name) + local same = GetMember(src) + print(same.name) + local other = GetMember(target) + print(other.name) + local slot = GetSlot(source, 1) + print(slot.name) +end) +first() +"; + client.open_with(CLIENT, text); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), + ); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |needle: &str, message: &str| ("need-check-nil".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [ + // A local declared outside any function is checked like any other. + finding("print(shared.name)", "`shared` may be nil: its type here is `Probe.Member?`"), + finding("print(other.name)", "`other` may be nil: its type here is `Probe.Member?`"), + finding("print(slot.name)", "`slot` may be nil: its type here is `Probe.Member?`"), + ], + "what a call gives for `source` alone is left alone" + ); +} + +#[test] +fn nil_checks_leave_alone_missing_values_the_guards_rule_out() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param round? boolean +---@param mode? 'open'|'close' +---@param label? string +local function scale(round, mode, label) + local i = 0 + print(round and (round == true or i < round)) + if mode and mode ~= 'open' and mode ~= 'close' then print(mode:upper()) end + if label ~= 'x' and label ~= 'y' then print(label:upper()) end +end +scale() +"; + client.open_with(CLIENT, text); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), + ); + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [( + "need-check-nil".to_string(), + pos(text, "label:upper", 0).0 as u64, + "`label` may be nil: its type here is `string?`".to_string() + )], + "guards that rule out every value of the type keep it whole, but not the `nil` they rule out" + ); +} + +#[test] +fn nil_checks_report_values_passed_for_parameters_that_do_not_take_nil() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Target +---@field name string + +---@return Probe.Target? +local function GetTarget() end + +---@return number? +local function GetHandle() end + +---@param target Probe.Target +local function greet(target) end +---@param target? Probe.Target +local function maybeGreet(target) end +---@param label Probe.Target|nil +local function show(label) end +---@generic T +---@param value T +local function keep(value) end +---@param n number +local function needsNumber(n) end +---@param target Probe.Target +---@overload fun(target: nil) +local function either(target) end +local Box = {} +---@param target Probe.Target +function Box:put(target) end + +local function run() + local missing = GetTarget() + greet(missing) + local optional = GetTarget() + maybeGreet(optional) + local nilable = GetTarget() + show(nilable) + local generic = GetTarget() + keep(generic) + local wrong = GetTarget() + needsNumber(wrong) + local overloaded = GetTarget() + either(overloaded) + local stored = GetTarget() + Box:put(stored) + local guarded = GetTarget() + if guarded then greet(guarded) end + local read = GetTarget() + print(read.name) + greet(read) + local handle = GetHandle() + SetEntityHeading(handle, 1.0) + local printed = GetTarget() + print(printed) + local suppressed = GetTarget() + ---@diagnostic disable-next-line: param-type-mismatch + greet(suppressed) +end +run() +"; + client.open_with(CLIENT, text); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), + ); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |needle: &str, message: &str| ("need-check-nil".to_string(), line(needle), message.to_string()); + let passed = |local: &str| { + format!( + "`{local}` may be nil, which parameter `target` of type `Probe.Target` does not take: its type here is \ + `Probe.Target?`" + ) + }; + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [ + finding("greet(missing)", &passed("missing")), + finding("Box:put(stored)", &passed("stored")), + finding("print(read.name)", "`read` may be nil: its type here is `Probe.Target?`"), + ], + "parameters that are optional or take nil, any value or a generic, overloads that take nil, guards, \ + earlier reads, natives, arguments of another type and suppressed `param-type-mismatch` are left alone" + ); +} + #[test] fn rule_levels_of_the_config_file_win_over_the_client_settings() { struct Fixture(PathBuf); diff --git a/docs/reference.md b/docs/reference.md index 7f70027..d5699db 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -100,7 +100,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch` and `no-unknown`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown` and `need-check-nil`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -702,6 +702,84 @@ later assignments are not followed. Names that start with `ignore_unused_prefix` not reported. To check only your own resources, set the level in an `[[overrides]]` entry instead of `[rules]`. +## Nil checks + +`need-check-nil` reports a local that may hold `nil` where code indexes it, calls it, stores a value +under it as a key, uses it in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, +or gives it as a bound of a numeric `for`, all of which raise an error for a missing value, as +TypeScript and lua-language-server do. `==` and `~=` compare any values and are not reported. To +turn it off: + +```toml +[rules] +"need-check-nil" = "off" +``` + +```lua +---@return string? +local function GetName() end + +local name = GetName() +print(name:upper()) -- `name` may be nil: its type here is `string?` +print(name:lower()) -- the read above raises the error first +``` + +A local may hold `nil` when its declared type allows it: its `---@type` or `@param`, or the +`@return` or `@field` of the function or class its value comes from, whoever declares them, +stubs and other resources included. `false`, as in `false|string`, counts as well. The +[type guards](../crates/qbx_lua_ls/README.md#type-guards) and casts around the read narrow the +type first, so `if not name then return end`, `if name then`, `name and name:upper()`, +`assert(name)` and `---@cast name -?` all check it, as does a condition that reads from it, such as +`if player?.job then` for `player`. `?.`, `?[` and `?:` give `nil` for a missing value and are not +reported. Guards that together rule out every value of the type, as +`round and (round == true or i < round)` does for a `boolean?`, keep the type whole, but the `nil` +that `round and` rules out stays out. + +A local that may hold `nil` is reported as well where a call passes it for a parameter that does not +take `nil`, in every signature the call may use as `param-type-mismatch` reads them. A parameter +takes `nil` when it is optional, as `label?`, or its type allows it or any value, as `string?`, +`any` or a generic do. Natives are left out, and so is an argument of another type than the +parameter takes, which `param-type-mismatch` reports: + +```lua +---@param target Player +local function greet(target) end + +local player = GetPlayer() -- Player? +greet(player) -- `player` may be nil, which parameter `target` of type `Player` does not take: ... +``` + +lua-language-server reports such an argument as a `param-type-mismatch`, so a +`---@diagnostic disable` comment for that rule silences it too. + +Only clear cases count, and the rest is left alone: + +- Fields and the values of calls, which guards do not narrow: + `if self.target then self.target:kill() end` would be reported otherwise. +- A local that is assigned again after its declaration, unless a `---@cast name T` types it. + `name = name or 'none'` is how code fills in a missing value. +- A value of a call that a guard on another of its values covers. After + `local vehicle, coords = lib.getClosestVehicle(pos)` and `if not vehicle then return end`, + `coords` is not reported: annotations declare such values one by one, rather than as + [sets of values](../crates/qbx_lua_ls/README.md#sets-of-returned-values). +- What a call gives for `source` alone, or for a local that holds it, as + `ESX.GetPlayerFromId(source)` or `exports.qbx_core:GetPlayer(src)` after `local src = source`: + lookups of the player whose event or callback runs only miss one that has just left. + `ESX.GetPlayerFromId(target)` is reported. +- Reads after one reported in the same function, which points at the same missing check. +- Reads after one that always runs before them. Once `name:upper()` has run without an error, + `name` held a value, so the reads after it in its block are not reported, including those in + functions defined there. A read inside an `if` branch, on the right of `and` and `or`, or in the + condition of a `repeat` loop that a `break` can leave, does not count for the code after it. + +Guards only narrow the code after them, so a read inside a function defined before the guard is +reported, as `return name:upper()` is in a `local function` above `if not name then return end`: +the function may run before the guard does. lua-language-server reports it as well. + +Unlike lua-language-server, qbx-lua-ls also checks arithmetic, concatenation, `#`, `<`, `<=`, `>`, +`>=` and `for` bounds, leaves alone the lookups of `source` above, and reports a local once per +function rather than at every read. + ## Events, exports, and locales The CLI first collects event registrations and exports from the resources being analyzed, then diff --git a/docs/rules.md b/docs/rules.md index 96dfcab..d7551d1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -51,6 +51,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `self-comparison` | warning | A comparison has the same expression on both sides. | | `count-down-loop` | warning | A numeric `for` loop starts above its end without a negative step: a literal start above a literal end, with no step or a positive one, which never runs, or `#list, 1` or arithmetic on `#list` such as `#list - 1, 1`, with no step, which never runs once the start is above 1. Fix available. | | `impossible-comparison` | info | An `==` or `~=` compares values whose declared types share no value, such as a string with a number, or a literal with a type that does not list it, so it always gives the same answer. Comparisons with `nil` are not reported. Reported by qbx-lua-ls only. | +| `need-check-nil` | warning | A local whose declared type allows `nil` or `false`, such as the `string?` a function's `@return` declares, is indexed, called, used as a key to store a value under, used in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, given as a numeric `for` bound, or passed for a parameter that does not take `nil`, with no guard or cast ruling the missing value out. A local is reported once per function. What a call gives for `source` alone is not reported. Reported by qbx-lua-ls only. | | `lowercase-global` | warning | A global definition starts with a lowercase letter. | | `implicit-global` | warning | A function creates a global without a file-scope declaration. | | `builtin-overwrite` | warning | Code overwrites a known runtime global or native. | From 56a6a02e2f3fbabccd1fd316c8bd4ffbe3421f06 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:22:34 +0200 Subject: [PATCH 033/193] fix(luacats): pass over plain comments and directives inside doc blocks, as LuaLS does --- crates/qbx_lua_analysis/src/env.rs | 47 ++++++++--- crates/qbx_lua_analysis/src/signature.rs | 27 +++--- crates/qbx_lua_analysis/tests/rules.rs | 9 ++ crates/qbx_lua_ls/src/indexer.rs | 15 ++-- crates/qbx_lua_ls/tests/lsp.rs | 100 +++++++++++++++++++++++ 5 files changed, 168 insertions(+), 30 deletions(-) diff --git a/crates/qbx_lua_analysis/src/env.rs b/crates/qbx_lua_analysis/src/env.rs index 20e9a9c..8511ca1 100644 --- a/crates/qbx_lua_analysis/src/env.rs +++ b/crates/qbx_lua_analysis/src/env.rs @@ -106,7 +106,9 @@ pub fn builtins() -> &'static Builtins { }) } -/// The `---` doc lines directly above `offset`, top to bottom, with the `---` prefix removed. +/// The `---` doc lines directly above `offset`, top to bottom, with the `---` prefix removed. Like +/// LuaLS, other comments on lines of their own are passed over, so a `-- qbx-lint:` directive +/// between the docs and the code keeps them; a blank line or code ends the block. pub fn leading_doc_lines<'a>(source: &'a str, comments: &[Comment], offset: u32) -> Vec<&'a str> { let mut lines = Vec::new(); let mut cursor = offset; @@ -116,37 +118,40 @@ pub fn leading_doc_lines<'a>(source: &'a str, comments: &[Comment], offset: u32) if gap.bytes().filter(|b| *b == b'\n').count() > 1 || !gap.trim().is_empty() { break; } - let text = comment.span.text(source); - let Some(doc) = text.strip_prefix("---") else { break }; if !source[..comment.span.start as usize].rsplit('\n').next().unwrap_or("").trim().is_empty() { break; } - lines.push(doc); + if let Some(doc) = comment.span.text(source).strip_prefix("---") { + lines.push(doc); + } cursor = comment.span.start; } lines.reverse(); lines } -/// Runs of adjacent `---` line comments, each one annotation block. +/// Runs of adjacent `---` line comments, each one annotation block. Like LuaLS, other comments +/// inside a run are passed over; a blank line or code ends it. pub fn doc_blocks<'c>(source: &str, comments: &'c [Comment]) -> Vec> { let mut blocks: Vec> = Vec::new(); + let mut block: Vec<&Comment> = Vec::new(); let mut previous_end: Option = None; for comment in comments { - if comment.kind != CommentKind::Line || !comment.span.text(source).starts_with("---") { - previous_end = None; - continue; - } let adjacent = previous_end.is_some_and(|end| { let gap = &source[end as usize..comment.span.start as usize]; gap.bytes().filter(|b| *b == b'\n').count() <= 1 && gap.trim().is_empty() }); - match blocks.last_mut() { - Some(block) if adjacent => block.push(comment), - _ => blocks.push(vec![comment]), + if !adjacent && !block.is_empty() { + blocks.push(std::mem::take(&mut block)); + } + if comment.kind == CommentKind::Line && comment.span.text(source).starts_with("---") { + block.push(comment); } previous_end = Some(comment.span.end); } + if !block.is_empty() { + blocks.push(block); + } blocks } @@ -206,4 +211,22 @@ mod tests { let g = chunk.block.stmts[2].span.start; assert!(leading_doc_lines(source, &chunk.comments, g).is_empty()); } + + #[test] + fn doc_lines_pass_over_other_comments_on_their_own_lines() { + let source = "---@param a number\n-- qbx-lint: disable-next-line lowercase-global\nfunction lower(a) end\n\ + ---@param b number\n-- one\n-- two\nlocal function plain(b) end\n\ + ---@param c number\n--[[ block\ncomment ]]\nlocal function block(c) end\n\ + --- first\n-- note\n---@param d number\nlocal function middle(d) end\n\ + ---@param e number\n-- one\n\nlocal function blank(e) end\n\ + ---@param f number\nlocal x = 1 -- trailing\nlocal function trailing(f) end"; + let chunk = parse(source); + let docs = |index: usize| leading_doc_lines(source, &chunk.comments, chunk.block.stmts[index].span.start); + assert_eq!(docs(0), ["@param a number"], "a qbx-lint directive"); + assert_eq!(docs(1), ["@param b number"], "plain comments"); + assert_eq!(docs(2), ["@param c number"], "a block comment"); + assert_eq!(docs(3), [" first", "@param d number"], "a plain comment inside the docs"); + assert!(docs(4).is_empty(), "a blank line after a plain comment"); + assert!(docs(6).is_empty(), "a comment after code"); + } } diff --git a/crates/qbx_lua_analysis/src/signature.rs b/crates/qbx_lua_analysis/src/signature.rs index 798e626..015eeaf 100644 --- a/crates/qbx_lua_analysis/src/signature.rs +++ b/crates/qbx_lua_analysis/src/signature.rs @@ -70,7 +70,8 @@ pub fn global_key(root: &str, fields: &[&str]) -> Option { (!parts.is_empty()).then(|| SmolStr::new(parts.join("."))) } -/// The `@alias` declarations of every `---` comment block in a file, with their types. +/// The `@alias` declarations of every `---` comment block in a file, with their types. Like LuaLS, +/// other comments inside a block are passed over; a blank line or code ends it. pub fn doc_aliases(source: &str, comments: &[Comment]) -> Vec<(SmolStr, Type)> { let mut aliases = Vec::new(); if !source.contains("@alias") { @@ -92,16 +93,10 @@ pub fn doc_aliases(source: &str, comments: &[Comment]) -> Vec<(SmolStr, Type)> { if !adjacent { flush(&mut block); } - match comment.span.text(source).strip_prefix("---") { - Some(line) => { - block.push(line); - previous_end = Some(comment.span.end); - } - None => { - flush(&mut block); - previous_end = None; - } + if let Some(line) = comment.span.text(source).strip_prefix("---") { + block.push(line); } + previous_end = Some(comment.span.end); } flush(&mut block); aliases @@ -279,6 +274,18 @@ mod tests { assert!(!may_be_nil(&named("Player"), &alias)); } + #[test] + fn alias_blocks_pass_over_other_comments() { + let source = "---@alias Mode\n-- note\n---| 'a'\n---| 'b'\n\n---@alias Wide\n--[[ note ]]\n---| string\n---| number\n\n---@alias Strict number\n\n-- note\n---| nil"; + let chunk = parse(source); + let aliases = doc_aliases(source, &chunk.comments); + let alias = |name: &str| aliases.iter().find(|(n, _)| n == name).map(|(_, ty)| ty); + let named = |name: &str| Type::Named(SmolStr::new(name), Vec::new()); + assert!(!may_be_nil(&named("Mode"), &alias), "a plain comment keeps the values"); + assert!(!may_be_nil(&named("Wide"), &alias), "a block comment keeps the values"); + assert!(!may_be_nil(&named("Strict"), &alias), "a blank line ends the alias"); + } + #[test] fn alias_cycles_end() { let aliases = [("A", Type::Named("B".into(), Vec::new())), ("B", Type::Named("A".into(), Vec::new()))]; diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 013fa0c..477f001 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -573,6 +573,15 @@ fn documented_parameters_need_an_argument() { ); assert!(missing(&format!("{opt_first}f(nil, 1)")).is_empty()); + // As in LuaLS, the docs reach the function past a directive; only a blank line detaches them. + let directive = + "---@param level string\n-- qbx-lint: disable-next-line lowercase-global\nfunction infoprint(level) end\n"; + assert_eq!( + missing(&format!("{directive}infoprint()")), + ["'infoprint' is called with 0 arguments, but needs 1; 'level' (string) will be nil"] + ); + assert!(missing("---@param level string\n-- note\n\nlocal function f(level) end\nf()").is_empty()); + // The cases below behave as in LuaLS: nothing that may be nil or is left undocumented is required. for source in [ "function Plain(a, b) end\nPlain(1)", diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index e7f9eeb..21ce33a 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -165,25 +165,24 @@ impl<'a> Indexer<'a> { matches!(self.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) } - /// Groups adjacent `---` comments and records the classes and aliases they declare. + /// Groups adjacent `---` comments and records the classes and aliases they declare. Like LuaLS, + /// other comments between them are passed over; a blank line or code ends the group. fn doc_comments(&mut self, comments: &[Comment]) { let mut group: Vec<&Comment> = Vec::new(); + let mut previous_end: Option = None; for comment in comments { - let text = comment.span.text(self.source); - let adjacent = group.last().is_some_and(|prev: &&Comment| { - let gap = &self.source[prev.span.end as usize..comment.span.start as usize]; + let adjacent = previous_end.is_some_and(|end| { + let gap = &self.source[end as usize..comment.span.start as usize]; gap.bytes().filter(|b| *b == b'\n').count() <= 1 && gap.trim().is_empty() }); if !adjacent { self.flush_doc_group(&group); group.clear(); } - if text.starts_with("---") { + if comment.span.text(self.source).starts_with("---") { group.push(comment); - } else { - self.flush_doc_group(&group); - group.clear(); } + previous_end = Some(comment.span.end); } self.flush_doc_group(&group); } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 0cdbb0f..c1a0855 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1126,6 +1126,106 @@ onShop('closed', function(closedShop) end) } } +#[test] +fn hover_reads_docs_past_other_comments() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param level string +-- qbx-lint: disable-next-line lowercase-global +function infoprint(level, ...) end + +--- Spawns a vehicle. +---@param model string +-- The model may also be a hash. +---@return number +local function spawn(model) end + +---@param detached number +-- note + +local function blank(detached) end + +local spawned = spawn('adder') + +---@class Probe.Plain +-- note +---@field size number + +---@class Probe.Blank + +---@field size number + +---@alias Probe.Mode +--[[ note ]] +---| 'on' +---| 'off' + +---@type Probe.Plain +local plain = {} +local plainSize = plain.size +---@type Probe.Blank +local blanked = {} +local blankSize = blanked.size +---@type Probe.Mode +local modeValue +"; + client.open_with(CLIENT, text); + let cases = [ + ("infoprint(level", "level: string"), + ("spawn(model)", "spawn(model: string): number"), + ("spawn(model)", "Spawns a vehicle."), + ("spawned =", "spawned: number"), + ("plainSize =", "plainSize: number"), + ("modeValue", "type Probe.Mode = \"on\"|\"off\""), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + // A blank line detaches the docs. + for (needle, unexpected) in [("blank(detached)", "detached: number"), ("blankSize =", "blankSize: number")] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(!hover.contains(unexpected), "{needle}: unexpected {unexpected:?} in {hover}"); + } +} + +#[test] +fn generics_reach_annotations_past_other_comments() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@generic Plain +-- note +---@param x Plain +local function idPlain(x) return x end + +---@generic Block +--[[ note ]] +---@param y Block +local function idBlock(y) return y end + +---@generic Blank + +---@param z Blank +local function idBlank(z) return z end + +print(idPlain, idBlock, idBlank) +"; + client.open_with(CLIENT, text); + client.diagnostics_for(CLIENT); + let uri = client.uri(CLIENT).to_string(); + let found: Vec<(u64, String)> = client.diagnostics[&uri] + .as_array() + .unwrap() + .iter() + .filter(|d| d["code"] == "undefined-doc-name") + .map(|d| (d["range"]["start"]["line"].as_u64().unwrap(), d["message"].as_str().unwrap().into())) + .collect(); + let (line, _) = pos(text, "z Blank", 0); + assert_eq!(found, [(line as u64, "Undefined type or alias `Blank`".to_string())], "as in LuaLS"); +} + #[test] fn calls_show_the_overload_they_pick() { let mut client = Client::start(fixture_root()); From beb0a48db638d2981b12fce28df799f31739f5b7 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:23:09 +0200 Subject: [PATCH 034/193] fix(index): keep members set through self before their global is indexed Type `self` in a method of a global from the globals and members its own file declares, and index open documents again while doing so changes the globals of a document indexed after them. --- crates/qbx_lua_ls/src/indexer.rs | 43 ++++++++++++++- crates/qbx_lua_ls/src/server.rs | 37 ++++++++++++- crates/qbx_lua_ls/tests/lsp.rs | 92 +++++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 21ce33a..3b9dd09 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -274,6 +274,47 @@ impl<'a> Indexer<'a> { own.map_or_else(|| self.infer.is_class_table(name), Symbol::is_class_table) } + /// The type of `self` in `function Players:rename()` or `function Players.admin:promote()` when + /// this file declares `Players`, which `Infer` only reads from the index and so misses until the + /// file is part of it. + fn own_self_type(&self, expr: &Expr) -> Option { + let ExprKind::Name(name) = &expr.kind else { return None }; + let Some(Resolved::Local(id)) = self.ctx.resolution.resolve_at(name.span.start) else { return None }; + let Some(crate::infer::Decl::SelfParam { name: method }) = + self.ctx.decl(self.ctx.resolution.local(id).decl.start) + else { + return None; + }; + if !self.is_global(&method.base) { + return None; + } + let global = self.out.globals.iter().rev().find(|s| s.name == method.base.text)?; + Some(method.path.iter().fold(global.ty.clone(), |ty, segment| self.own_member_type(&ty, &segment.text))) + } + + /// The type of the field `name` of `ty`, preferring what this file sets. A plain table whose + /// fields this file sets stands for them, as `Infer` reads it once the file is indexed. + fn own_member_type(&self, ty: &Type, name: &str) -> Type { + let owner = self.owner_of(ty); + let own = owner.as_ref().and_then(|owner| { + self.out.members.iter().rev().find(|member| member.owner == *owner && member.symbol.name == name) + }); + let member = match own { + Some(member) => member.symbol.ty.clone(), + None => self.infer.member(ty, name).map(|member| member.ty).unwrap_or_default(), + }; + let nested = owner.map(|owner| SmolStr::new(format!("{owner}.{name}"))); + match nested { + Some(nested) + if matches!(member, Type::Table | Type::Unknown) + && self.out.members.iter().any(|member| member.owner == nested) => + { + Type::GlobalTable(nested) + } + _ => member, + } + } + /// Records a member set by the statement or table field whose doc comment starts at `doc_anchor`, /// which may declare it `---@private`. fn push_member(&mut self, owner: SmolStr, symbol: Symbol, injected: bool, doc_anchor: Option) { @@ -682,7 +723,7 @@ impl<'a> Indexer<'a> { } } _ => { - let ty = self.infer.expr(base); + let ty = self.own_self_type(base).unwrap_or_else(|| self.infer.expr(base)); match self.owner_of(&ty) { Some(owner) => (owner, matches!(ty.without_nil(), Type::Named(..))), None => return, diff --git a/crates/qbx_lua_ls/src/server.rs b/crates/qbx_lua_ls/src/server.rs index 399720c..15ce053 100644 --- a/crates/qbx_lua_ls/src/server.rs +++ b/crates/qbx_lua_ls/src/server.rs @@ -23,6 +23,9 @@ pub type Documents = FxHashMap; /// Opening the file shows everything; the workspace overview only needs to say "look here". const MAX_PROBLEMS_PER_CLOSED_FILE: usize = 100; +/// How often a flush may index the same open document, so that globals typed from each other +/// cannot keep it going. +const MAX_INDEX_PASSES: usize = 4; type AnyResult = Result>; @@ -332,9 +335,13 @@ impl Server { fn flush_index(&mut self) { self.diagnostics_pending |= !self.dirty.is_empty(); let mut relink = false; - // Calls to a `---@callback` wrapper are only recognized once the wrapper is indexed. + // The same order whatever the paths hash to. Calls to a `---@callback` wrapper are only + // recognized once the wrapper is indexed. let mut dirty: Vec = std::mem::take(&mut self.dirty).into_iter().collect(); + dirty.sort(); dirty.sort_by_key(|uri| !self.docs.get(uri).is_some_and(|doc| doc.text.contains("@callback"))); + // The documents in the order they were indexed, with whether that changed their globals. + let mut indexed = Vec::new(); for uri in dirty { if let Some(doc) = self.docs.get_mut(&uri) { // A full scan reallocates file IDs, including the reserved slots for manifests. @@ -342,13 +349,39 @@ impl Server { let is_source = !qbx_lua_analysis::project::is_not_source(doc.text.as_bytes()); if !doc.is_manifest() && is_source && !self.ws.lint_config.is_excluded(&doc.path) { relink |= self.ws.index.file(doc.file).is_none(); - doc.file = self.ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution); + let changed = self.index_document(&uri); + indexed.push((uri, changed)); } } } if relink { self.ws.link_imports(); } + // Each document was indexed against the others as the index held them, so those before the + // last one whose globals changed may have missed a global it declares, like the `Players` + // that types `self` in `function Players:rename()`. Indexed again, they move to the end, and + // the globals they change in turn may be what the others missed. + for _ in 1..MAX_INDEX_PASSES { + let stale = indexed.iter().rposition(|(_, changed)| *changed).unwrap_or(0); + if stale == 0 { + break; + } + indexed.rotate_left(stale); + let count = indexed.len(); + for (uri, changed) in &mut indexed[count - stale..] { + *changed = self.index_document(uri); + } + } + } + + /// Indexes an open document, telling whether that changed the globals it declares. + fn index_document(&mut self, uri: &Url) -> bool { + let Some(doc) = self.docs.get_mut(uri) else { return false }; + let before = self.ws.index.file(doc.file).map(|file| file.index.globals.clone()).unwrap_or_default(); + doc.file = self.ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution); + let after = self.ws.index.file(doc.file).map_or(&[][..], |file| &file.index.globals[..]); + before.len() != after.len() + || before.iter().zip(after).any(|(old, new)| old.name != new.name || old.ty != new.ty) } fn publish_dirty(&mut self) { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index c1a0855..97723af 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -7156,6 +7156,39 @@ print(either, list, map, mode, color, box, count, vehicle, config, player.job, G } } +#[test] +fn members_set_through_self_belong_to_the_class_of_a_global_from_the_same_file() { + let mut client = Client::start(fixture_root()); + // A new file, so the index knows nothing of `Players` when the file is first indexed. + const PLAYERS: &str = "myresource/shared/players.lua"; + let text = "\ +---@class Test.Player +Players = {} +Players.admin = {} + +---@param name string +function Players:rename(name) + self.nickname = name +end + +function Players.admin:promote() + self.level = 1 +end + +function Players.guest:greet() + self.greeted = true +end + +print(Players.nickname, Players.admin.level, Players.guest.greeted) +"; + client.open_with(PLAYERS, text); + for (needle, line) in [("nickname,", 6), ("level,", 10), ("greeted)", 14)] { + let (l, c) = pos(text, needle, 0); + let result = client.request("textDocument/definition", client.position_params(PLAYERS, l, c)); + assert_eq!(result[0]["range"]["start"]["line"], line, "{needle}: {result}"); + } +} + #[test] fn implementations_leave_out_the_annotations_that_declare_a_member() { let mut client = Client::start(fixture_root()); @@ -7169,8 +7202,6 @@ Players = {} function Players:greet() return 'hi' end "; client.open_with(TYPES, types); - // The other file reads `Players` from this one to know what `self` is. - client.diagnostics_for(TYPES); let text = "\ ---@type Test.Player local player = Players @@ -7222,6 +7253,63 @@ fn implementations_of_members_no_code_sets_are_their_definition() { assert_eq!(implementation, definition, "an export the resource registers"); } +#[test] +fn members_set_through_self_belong_to_the_class_of_a_global_from_a_document_that_changed_with_them() { + let mut client = Client::start(fixture_root()); + // Each file declares the global whose method the other one writes through `self`, so the file + // indexed first cannot type `self` from the index in either order. + const ALPHA: &str = "myresource/shared/alpha.lua"; + const BETA: &str = "myresource/shared/beta.lua"; + let alpha = "\ +---@class Test.Alpha +Alphas = {} + +function Betas:touch() + self.fromAlpha = true +end +"; + let beta = "\ +---@class Test.Beta +Betas = {} + +function Alphas:touch() + self.fromBeta = true +end +"; + client.open_with(ALPHA, alpha); + client.open_with(BETA, beta); + let text = "print(Alphas.fromBeta, Betas.fromAlpha)\n"; + client.open_with(CLIENT, text); + for (needle, file) in [("fromBeta", BETA), ("fromAlpha", ALPHA)] { + let (l, c) = pos(text, needle, 0); + let result = client.request("textDocument/definition", client.position_params(CLIENT, l, c)); + assert_eq!(result[0]["uri"], client.uri(file).as_str(), "{needle}: {result}"); + assert_eq!(result[0]["range"]["start"]["line"], 4, "{needle}: {result}"); + } +} + +#[test] +fn members_set_through_self_belong_to_the_class_of_a_global_typed_from_another_document() { + let mut client = Client::start(fixture_root()); + // Documents are indexed in the order of their paths: `Mid` before the `Base` it is typed from, + // and the method on `Mid` before `Mid` is indexed again, so it takes a third pass. + let files = [ + ("myresource/shared/chain_a.lua", "Mid = Base\n"), + ("myresource/shared/chain_b.lua", "---@class Test.Base\nBase = {}\n"), + ( + "myresource/shared/chain_c.lua", + "function Mid:touch()\n self.touched = true\nend\n\nprint(Base.touched)\n", + ), + ]; + for (file, text) in files { + client.open_with(file, text); + } + let (file, text) = files[2]; + let (l, c) = pos(text, "touched)", 0); + let result = client.request("textDocument/definition", client.position_params(file, l, c)); + assert_eq!(result[0]["range"]["start"]["line"], 1, "{result}"); +} + #[test] fn annotation_features_ignore_names_outside_type_positions() { let mut client = Client::start(fixture_root()); From 65bb49bbc061997f1aaa6af64476802a96109fcf Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:35:12 +0200 Subject: [PATCH 035/193] fix(index): index closed files again when the globals they read change Record the globals each file reads. When a saved or closed document, or a file changed or deleted on disk, declares a global differently, index the closed files that read it from a file they can see again, and then the readers of what that changes, whatever order the changes came in. --- crates/qbx_lua_ls/src/callback_wrappers.rs | 7 +- crates/qbx_lua_ls/src/index.rs | 380 +++++++++++++++++-- crates/qbx_lua_ls/src/indexer.rs | 60 +-- crates/qbx_lua_ls/src/infer.rs | 7 +- crates/qbx_lua_ls/src/server.rs | 103 ++++- crates/qbx_lua_ls/src/workspace.rs | 106 +++++- crates/qbx_lua_ls/tests/workspace_refresh.rs | 200 +++++++++- 7 files changed, 774 insertions(+), 89 deletions(-) diff --git a/crates/qbx_lua_ls/src/callback_wrappers.rs b/crates/qbx_lua_ls/src/callback_wrappers.rs index 73ac734..f8c6c3e 100644 --- a/crates/qbx_lua_ls/src/callback_wrappers.rs +++ b/crates/qbx_lua_ls/src/callback_wrappers.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use qbx_fivem_data::Side; use smol_str::SmolStr; -use crate::index::{EventDef, EventFamily, EventKind, FileId, Index}; +use crate::index::{EventDef, EventFamily, FileId, Index}; use crate::types::{CallbackRole, CallbackTag, FunType, Param, Type}; /// Where a call to a `@callback` wrapper passes each part, as indexes into the wrapper's parameters @@ -101,8 +101,9 @@ pub fn handlers<'i>( target: Option, ) -> Vec<(FileId, &'i EventDef)> { index - .events() - .filter(|(_, e)| e.name == name && e.family == *family && e.kind == EventKind::Callback && e.handler.is_some()) + .callbacks_named(name) + .into_iter() + .filter(|(_, e)| e.family == *family && e.handler.is_some()) .filter(|(_, e)| !matches!((target, e.side), (Some(target), Some(side)) if !side.is_available_on(target))) .collect() } diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 14ef2b8..7a3cd54 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -15,6 +16,89 @@ use crate::types::{DescribedValue, FunType, Type}; pub type FileId = u32; pub type ResourceId = u32; +/// What indexing a file looks up in the index. The types the file's entry holds may come from it, +/// so the file is indexed again when another file declares it differently. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Read { + /// A global, by name. + Global(SmolStr), + /// The members set on the tables an owner names, or the ones a name names. + Members(SmolStr, Option), + /// Whether the tables an owner names have members, and their array parts, `[key]` entries, + /// metatables and the `---@enum` whose table they are. + Table(SmolStr), + /// The classes, aliases and enums of a name. + Type(SmolStr), + /// The exports a resource registers, by its name as `exports_key` spells it, or the one of them + /// that a name names. + Exports(SmolStr, Option), + /// The handlers registered for a callback name. + Callback(SmolStr), + /// What the module at a path, normalized, returns. + Module(PathBuf), +} + +/// What each file declares differently than when the files that read it were indexed. +pub type Changes = FxHashMap>; + +/// The name of a resource in `Read::Exports`. Resources are found by name whatever its case, as +/// `exports.QBX_Core` finds `qbx_core`, also before any of their files is indexed. +fn exports_key(resource: &str) -> SmolStr { + SmolStr::new(resource.to_ascii_lowercase()) +} + +/// Adds to `out` the keys under which `before` and `after` hold declarations that differ by `same`: +/// some in one and not the other, or in another order. +fn changed_groups( + out: &mut FxHashSet, + before: impl Iterator, + after: impl Iterator, + same: impl Fn(&T, &T) -> bool, +) { + let (before, after) = (grouped(before), grouped(after)); + let differ = |old: &[T], new: &[T]| old.len() != new.len() || old.iter().zip(new).any(|(a, b)| !same(a, b)); + for (read, entries) in &before { + if after.get(read).is_none_or(|new| differ(entries, new)) { + out.insert(read.clone()); + } + } + out.extend(after.into_keys().filter(|read| !before.contains_key(read))); +} + +fn grouped(entries: impl Iterator) -> FxHashMap> { + let mut groups: FxHashMap> = FxHashMap::default(); + for (read, entry) in entries { + groups.entry(read).or_default().push(entry); + } + groups +} + +/// Whether two declarations of a class tell the same about its values, wherever they are written +/// and whatever their docs say. +fn same_class(a: &ClassDef, b: &ClassDef) -> bool { + fn fields(class: &ClassDef) -> Vec<(&SmolStr, SymbolKind, &Type)> { + class.fields.iter().map(|field| (&field.name, field.kind, &field.ty)).collect() + } + fn indices(fields: &[DocIndexField]) -> Vec<(&Type, &Type, Option)> { + fields.iter().map(|field| (&field.key, &field.ty, field.side)).collect() + } + (a.generics == b.generics && a.parent_types == b.parent_types && a.side == b.side && a.strict == b.strict) + && (fields(a) == fields(b) && a.field_sides == b.field_sides && a.field_visibility == b.field_visibility) + && (indices(&a.indices) == indices(&b.indices) && indices(&a.literal_fields) == indices(&b.literal_fields)) + && a.call == b.call +} + +/// Whether two declarations of an alias or enum tell the same about its values, wherever they are +/// written and whatever their docs say. +fn same_alias(a: &AliasDef, b: &AliasDef) -> bool { + let (table_a, table_b) = (a.table.as_deref(), b.table.as_deref()); + let same_table = match (table_a, table_b) { + (Some(x), Some(y)) => x.owner == y.owner && x.members == y.members, + (x, y) => x.is_none() && y.is_none(), + }; + a.generics == b.generics && a.ty == b.ty && a.side == b.side && same_table +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SymbolKind { Function, @@ -231,10 +315,13 @@ pub struct FileIndex { /// Where the file applies when it is a definition file outside any resource. pub definition_scope: DefinitionScope, pub summary: FileSummary, + /// What indexing the file looked up in the index, sorted. Its types and members may come from + /// it, so it is indexed again when those declarations change. + pub reads: Vec, } /// The scripts a definition file outside any resource is written for. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct DefinitionScope { /// The side its path names, as `server_vehicle.lua` or a `client` folder do. pub side: Option, @@ -320,6 +407,8 @@ pub struct Index { /// The names of the fields and methods some class keeps from other code, built on first use /// after the files change. restricted_names: OnceLock>>, + /// What the lookups made while a file is indexed read, as `reads_while` collects it. + reads: RefCell>>, } pub fn normalize_path(path: &Path) -> PathBuf { @@ -401,8 +490,9 @@ impl Index { id } - pub fn set_file(&mut self, id: FileId, entry: FileEntry) { - self.clear_slots(id); + /// Puts `entry` in the index as the file `id`, returning the entry it replaces. + pub fn set_file(&mut self, id: FileId, entry: FileEntry) -> Option { + let replaced = self.clear_slots(id); for (i, symbol) in entry.index.globals.iter().enumerate() { self.globals.entry(symbol.name.clone()).or_default().push((id, i as u32)); } @@ -432,12 +522,13 @@ impl Index { } } self.files[id as usize] = Some(entry); + replaced } - fn clear_slots(&mut self, id: FileId) { + fn clear_slots(&mut self, id: FileId) -> Option { self.declarations.take(); self.restricted_names.take(); - let Some(old) = self.files.get_mut(id as usize).and_then(Option::take) else { return }; + let old = self.files.get_mut(id as usize).and_then(Option::take)?; remove_file_slots(&mut self.globals, old.index.globals.iter().map(|s| &s.name), id); remove_file_slots(&mut self.members, old.index.members.iter().map(|m| &m.owner), id); remove_file_slots(&mut self.elements, old.index.elements.iter().map(|e| &e.owner), id); @@ -446,16 +537,195 @@ impl Index { remove_file_slots(&mut self.aliases, old.index.aliases.iter().map(|a| &a.name), id); let exports = old.index.members.iter().filter(|m| m.owner == "exports"); remove_file_slots(&mut self.export_types, exports.map(|m| &m.symbol.name), id); + Some(old) } - pub fn remove_file(&mut self, path: &Path) { - let Some(id) = self.file_id(path) else { return }; - self.clear_slots(id); + /// Takes the file at `path` out of the index, returning its entry. + pub fn remove_file(&mut self, path: &Path) -> Option { + let id = self.file_id(path)?; + let removed = self.clear_slots(id); for resource in &mut self.resources { resource.files.retain(|f| *f != id); resource.imports.retain(|(f, _)| *f != id); } self.files[id as usize] = None; + removed + } + + /// Runs `index`, which indexes a file, and returns what the lookups it makes read, sorted. + pub fn reads_while(&self, index: impl FnOnce() -> T) -> (T, Vec) { + /// Puts back what was collected before, also when indexing panics and the server goes on, + /// so that later lookups are not collected. + struct Restore<'a> { + reads: &'a RefCell>>, + outer: Option>>, + } + impl Drop for Restore<'_> { + fn drop(&mut self) { + if let Some(outer) = self.outer.take() { + self.reads.replace(outer); + } + } + } + let outer = self.reads.replace(Some(FxHashSet::default())); + let mut restore = Restore { reads: &self.reads, outer: Some(outer) }; + let out = index(); + let reads = self.reads.replace(restore.outer.take().flatten()).unwrap_or_default(); + let mut reads: Vec = reads.into_iter().collect(); + reads.sort_unstable(); + (out, reads) + } + + /// Notes that a lookup reads `read`, while a file is being indexed. + fn note(&self, read: impl FnOnce() -> Read) { + if let Some(reads) = self.reads.borrow_mut().as_mut() { + reads.insert(read()); + } + } + + /// What `after` declares differently than `before`, as the files that read it recorded it. A + /// file that is new or gone declares everything differently. + pub fn changes<'a>(&self, before: Option<&'a FileEntry>, after: Option<&'a FileEntry>) -> FxHashSet { + let mut out = FxHashSet::default(); + // Where a file applies decides who sees all of it. + let scope = |entry: &'a FileEntry| { + (entry.resource, entry.side, entry.index.meta, &entry.index.definition_scope, entry.origin) + }; + match (before, after) { + (Some(old), Some(new)) if scope(old) != scope(new) => { + self.differences(before, None, &mut out); + self.differences(None, after, &mut out); + } + _ => self.differences(before, after, &mut out), + } + out + } + + /// Adds to `out` what `after` declares differently than `before`, each kind of declaration under + /// the keys its lookups record. + fn differences<'a>(&self, before: Option<&'a FileEntry>, after: Option<&'a FileEntry>, out: &mut FxHashSet) { + let globals = |entry: Option<&'a FileEntry>| { + let globals = entry.into_iter().flat_map(|entry| &entry.index.globals); + globals.map(|symbol| (Read::Global(symbol.name.clone()), &symbol.ty)) + }; + changed_groups(out, globals(before), globals(after), |a, b| a == b); + let members = |entry: Option<&'a FileEntry>| { + entry.into_iter().flat_map(|entry| &entry.index.members).map(|member| { + let Member { owner, symbol, injected, visibility } = member; + let read = Read::Members(owner.clone(), Some(symbol.name.clone())); + (read, (symbol.kind, &symbol.ty, *injected, *visibility)) + }) + }; + let mut named = FxHashSet::default(); + changed_groups(&mut named, members(before), members(after), |a, b| a == b); + // Whoever reads every member of the tables reads each of them. + let every = named.iter().filter_map(|read| match read { + Read::Members(owner, _) => Some(Read::Members(owner.clone(), None)), + _ => None, + }); + out.extend(every); + out.extend(named); + // Tables that come to have members, or no longer have any, unless other files set members + // on them. + let owners = |entry: Option<&'a FileEntry>| -> FxHashSet<&'a SmolStr> { + entry.into_iter().flat_map(|entry| &entry.index.members).map(|member| &member.owner).collect() + }; + let (old, new) = (owners(before), owners(after)); + let file = after.or(before).and_then(|entry| self.file_id(&entry.path)); + let elsewhere = |owner: &SmolStr| { + self.members.get(owner).is_some_and(|slots| slots.iter().any(|(slot, _)| Some(*slot) != file)) + }; + let emptied = old.symmetric_difference(&new).filter(|owner| !elsewhere(owner)); + out.extend(emptied.map(|owner| Read::Table((*owner).clone()))); + let elements = |entry: Option<&'a FileEntry>| { + let elements = entry.into_iter().flat_map(|entry| &entry.index.elements); + elements.map(|element| (Read::Table(element.owner.clone()), (&element.key, &element.value))) + }; + changed_groups(out, elements(before), elements(after), |a, b| a == b); + let metatables = |entry: Option<&'a FileEntry>| { + let metatables = entry.into_iter().flat_map(|entry| &entry.index.metatables); + metatables.map(|metatable| (Read::Table(metatable.owner.clone()), &metatable.metatable)) + }; + changed_groups(out, metatables(before), metatables(after), |a, b| a == b); + let classes = |entry: Option<&'a FileEntry>| { + let classes = entry.into_iter().flat_map(|entry| &entry.index.classes); + classes.map(|class| (Read::Type(class.name.clone()), class)) + }; + changed_groups(out, classes(before), classes(after), |a, b| same_class(a, b)); + let aliases = |entry: Option<&'a FileEntry>| { + let aliases = entry.into_iter().flat_map(|entry| &entry.index.aliases); + aliases.map(|alias| (Read::Type(alias.name.clone()), alias)) + }; + changed_groups(out, aliases(before), aliases(after), |a, b| same_alias(a, b)); + // An `---@enum` table is found by the owner of its fields. + let enums = |entry: Option<&'a FileEntry>| { + let aliases = entry.into_iter().flat_map(|entry| &entry.index.aliases); + aliases.filter_map(|alias| Some((Read::Table(alias.table.as_ref()?.owner.clone()), alias))) + }; + changed_groups(out, enums(before), enums(after), |a, b| same_alias(a, b)); + let exports = |entry: Option<&'a FileEntry>| { + let resource = entry.and_then(|entry| self.resource(entry.resource?)).map(|r| exports_key(&r.name)); + let exports = entry.into_iter().flat_map(|entry| &entry.index.exports); + exports.filter_map(move |export| { + Some((Read::Exports(resource.clone()?, Some(export.name.clone())), &export.ty)) + }) + }; + let mut exported = FxHashSet::default(); + changed_groups(&mut exported, exports(before), exports(after), |a, b| a == b); + // Whoever reads every export of the resource reads each of them. + let every = exported.iter().filter_map(|read| match read { + Read::Exports(resource, _) => Some(Read::Exports(resource.clone(), None)), + _ => None, + }); + out.extend(every); + out.extend(exported); + let callbacks = |entry: Option<&'a FileEntry>| { + let events = entry.into_iter().flat_map(|entry| &entry.index.events); + let callbacks = events.filter(|event| event.kind == EventKind::Callback); + callbacks.map(|event| (Read::Callback(event.name.clone()), (&event.family, event.side, &event.handler))) + }; + changed_groups(out, callbacks(before), callbacks(after), |a, b| a == b); + let module = |entry: Option<&'a FileEntry>| { + let returned = entry.and_then(|entry| Some((entry, entry.index.module_return.as_ref()?))); + returned.map(|(entry, ty)| (Read::Module(normalize_path(&entry.path)), ty)).into_iter() + }; + changed_groups(out, module(before), module(after), |a, b| a == b); + } + + /// The files that read what a file in `changed` declares differently and see that file, the file + /// itself included, unless they are in `indexed`: indexed again, in order, since that file was. + pub fn readers_of(&self, changed: &Changes, indexed: &FxHashSet) -> FxHashSet { + let mut declarers: FxHashMap<&Read, Vec> = FxHashMap::default(); + for (file, reads) in changed { + for read in reads { + declarers.entry(read).or_default().push(*file); + } + } + // A file reads what it declares itself, and one indexed after the file that changed it reads + // what it declares now. + let missed = |reader: FileId, file: FileId, read: &Read| { + reader == file || (!(indexed.contains(&reader) && reader > file) && self.reads_from(reader, file, read)) + }; + let reads = |reader: FileId, read: &Read| { + declarers.get(read).is_some_and(|files| files.iter().any(|file| missed(reader, *file, read))) + }; + self.files() + .filter(|(reader, file)| file.index.reads.iter().any(|read| reads(*reader, read))) + .map(|(reader, _)| reader) + .collect() + } + + /// Whether what `declarer` declares under `read` may reach code in `reader`, as the lookups + /// that read it find declarations. Classes, exports, callbacks and modules cross resources. + fn reads_from(&self, reader: FileId, declarer: FileId, read: &Read) -> bool { + match read { + Read::Global(_) => self.is_visible(reader, declarer), + Read::Members(owner, _) | Read::Table(owner) => { + let owner = instance_class(owner).unwrap_or(owner); + owner.starts_with('%') || self.classes.contains_key(owner) || self.reaches(reader, declarer) + } + _ => true, + } } /// Whether symbols of `target` are in scope for code in `from`. @@ -541,6 +811,7 @@ impl Index { } pub fn globals_named(&self, name: &str, from: FileId) -> Vec<(FileId, &Symbol)> { + self.note(|| Read::Global(SmolStr::new(name))); let mut found = self.visible_first(self.globals.get(name), from, |f, i| f.index.globals.get(i as usize)); self.prefer_own(from, &mut found, |(file, _)| *file); found @@ -549,15 +820,24 @@ impl Index { /// Members are looked up per resource rather than per file: libraries such as ox_lib load the /// files that extend their table lazily, so importing one file makes all of them reachable. pub fn members_of(&self, owner: &str, from: FileId) -> Vec<(FileId, &Symbol)> { + self.member_entries(owner, None, from).into_iter().map(|(file, member)| (file, &member.symbol)).collect() + } + + /// The members of `owner` that code in `from` sees, as `members_of` finds them, with what the + /// statements that set them tell about them. With a `name`, those called so. + pub fn member_entries(&self, owner: &str, name: Option<&str>, from: FileId) -> Vec<(FileId, &Member)> { + self.note(|| Read::Members(SmolStr::new(owner), name.map(SmolStr::new))); self.owner_slots(self.members.get(owner), owner, from) .into_iter() - .filter_map(|(file, i)| Some((file, &self.file(file)?.index.members.get(i as usize)?.symbol))) + .filter_map(|(file, i)| Some((file, self.file(file)?.index.members.get(i as usize)?))) + .filter(|(_, member)| name.is_none_or(|name| member.symbol.name == name)) .collect() } /// The members of the class `owner` set on the table its `---@class` declares, like /// `function Test:greet()`, leaving out those set through values typed as the class. pub fn declared_members_of(&self, owner: &str, from: FileId) -> Vec<&Symbol> { + self.note(|| Read::Members(SmolStr::new(owner), None)); self.owner_slots(self.members.get(owner), owner, from) .into_iter() .filter_map(|(file, i)| self.file(file)?.index.members.get(i as usize)) @@ -568,11 +848,7 @@ impl Index { /// The members of `owner` called `name` that code in `from` sees, with the file that sets each. pub fn members_named(&self, owner: &str, name: &str, from: FileId) -> Vec<(FileId, &Member)> { - self.owner_slots(self.members.get(owner), owner, from) - .into_iter() - .filter_map(|(file, i)| Some((file, self.file(file)?.index.members.get(i as usize)?))) - .filter(|(_, member)| member.symbol.name == name) - .collect() + self.member_entries(owner, Some(name), from) } /// The names of the fields and methods that some class declares `private`, `protected` or @@ -595,6 +871,7 @@ impl Index { /// The array parts and `[key]` entries of the tables `owner` names, visible like its members. pub fn elements_of(&self, owner: &str, from: FileId) -> Vec<&Element> { + self.note(|| Read::Table(SmolStr::new(owner))); self.owner_slots(self.elements.get(owner), owner, from) .into_iter() .filter_map(|(file, i)| self.file(file)?.index.elements.get(i as usize)) @@ -602,11 +879,13 @@ impl Index { } pub fn has_metatables(&self, owner: &str) -> bool { + self.note(|| Read::Table(SmolStr::new(owner))); self.metatables.contains_key(owner) } /// The metatables set on the tables `owner` names, visible like its members. pub fn metatables_of(&self, owner: &str, from: FileId) -> Vec<&Type> { + self.note(|| Read::Table(SmolStr::new(owner))); self.owner_slots(self.metatables.get(owner), owner, from) .into_iter() .filter_map(|(file, i)| Some(&self.file(file)?.index.metatables.get(i as usize)?.metatable)) @@ -615,16 +894,6 @@ impl Index { fn owner_slots(&self, slots: Option<&Vec>, owner: &str, from: FileId) -> Vec { let Some(slots) = slots else { return Vec::new() }; - let reachable = |target: FileId| { - let sides_match = match (self.file(from).and_then(|f| f.side), self.file(target).and_then(|f| f.side)) { - (Some(a), Some(b)) => b.is_available_on(a), - _ => true, - }; - sides_match - && (self.is_visible(from, target) - || self.is_related(from, target) - || self.imports_resource_of(from, target)) - }; // The instances of a class are seen where the class is. let owner = instance_class(owner).unwrap_or(owner); // `%`-owners name one specific table of one file (a local or a module return), so whoever @@ -643,7 +912,7 @@ impl Index { if in_scope.iter().any(|(f, _)| is_own(*f)) { return in_scope; } - let visible: Vec = slots.iter().copied().filter(|(f, _)| reachable(*f)).collect(); + let visible: Vec = slots.iter().copied().filter(|(f, _)| self.reaches(from, *f)).collect(); // Classes travel between resources through exports and events, so their members are looked // up everywhere; plain tables of unrelated resources are not. if !visible.is_empty() || !self.classes.contains_key(owner) { @@ -652,6 +921,19 @@ impl Index { slots.clone() } + /// Whether the members that `target` sets on a table may complete those of the same table in + /// `from`: on its side, in a file it sees, of its resource or of a resource it imports from. + fn reaches(&self, from: FileId, target: FileId) -> bool { + let sides_match = match (self.file(from).and_then(|f| f.side), self.file(target).and_then(|f| f.side)) { + (Some(a), Some(b)) => b.is_available_on(a), + _ => true, + }; + sides_match + && (self.is_visible(from, target) + || self.is_related(from, target) + || self.imports_resource_of(from, target)) + } + fn imports_resource_of(&self, from: FileId, target: FileId) -> bool { let (Some(resource), Some(target_resource)) = (self.resource_of(from), self.file(target).and_then(|f| f.resource)) @@ -662,15 +944,18 @@ impl Index { } pub fn has_members(&self, owner: &str) -> bool { + self.note(|| Read::Table(SmolStr::new(owner))); self.members.contains_key(owner) } fn class_slots(&self, name: &str) -> impl Iterator { + self.note(|| Read::Type(SmolStr::new(name))); let slots = self.classes.get(name).into_iter().flatten(); slots.filter_map(|(f, i)| Some((*f, self.file(*f)?.index.classes.get(*i as usize)?))) } fn alias_slots(&self, name: &str) -> impl Iterator { + self.note(|| Read::Type(SmolStr::new(name))); let slots = self.aliases.get(name).into_iter().flatten(); slots.filter_map(|(f, i)| Some((*f, self.file(*f)?.index.aliases.get(*i as usize)?))) } @@ -703,6 +988,7 @@ impl Index { /// The `---@enum`s whose table has its fields indexed under `owner`, whatever side they are /// scoped to. pub fn enums_of_table(&self, owner: &str) -> Vec<(FileId, &AliasDef)> { + self.note(|| Read::Table(SmolStr::new(owner))); let slots = self.aliases.values().flatten(); slots .filter_map(|(f, i)| Some((*f, self.file(*f)?.index.aliases.get(*i as usize)?))) @@ -725,18 +1011,22 @@ impl Index { /// `exports['phone'] = {}`, that code in `from` sees. Exports cross resources, so these are /// looked up in every file whose side fits, like registered exports are. pub fn declared_exports(&self, from: FileId) -> Vec<(FileId, &Symbol)> { + self.note(|| Read::Members(SmolStr::new("exports"), None)); self.export_type_slots(self.export_types.values().flatten(), from) } /// The types declared for the exports of `resource`, as `declared_exports` finds them. pub fn declared_exports_of(&self, resource: &str, from: FileId) -> Vec<(FileId, &Symbol)> { + self.note(|| Read::Members(SmolStr::new("exports"), Some(SmolStr::new(resource)))); self.export_type_slots(self.export_types.get(resource).into_iter().flatten(), from) } /// What the files that describe the exports of `resource` set on them one by one, as /// `function exports.qbx_core:GetCid(source) end` does below `exports.qbx_core = {}`. pub fn declared_export_members(&self, resource: &str, from: FileId) -> Vec<(FileId, &Symbol)> { - let slots = self.members.get(format!("exports.{resource}").as_str()).into_iter().flatten(); + let owner = format!("exports.{resource}"); + self.note(|| Read::Members(SmolStr::new(&owner), None)); + let slots = self.members.get(owner.as_str()).into_iter().flatten(); let describes = |(file, _): &&Slot| self.file(*file).is_some_and(|entry| entry.describes_exports_of(resource)); self.export_type_slots(slots.filter(describes), from) } @@ -773,13 +1063,16 @@ impl Index { self.declarations.get_or_init(build).clone() } - pub fn exports_of(&self, resource: &str) -> Vec<(FileId, &Symbol)> { + /// The exports that `resource` registers, or with a `name` those registered under it. + pub fn exports_of(&self, resource: &str, name: Option<&str>) -> Vec<(FileId, &Symbol)> { + self.note(|| Read::Exports(exports_key(resource), name.map(SmolStr::new))); let Some((_, entry)) = self.resource_by_name(resource) else { return Vec::new() }; entry .files .iter() .filter_map(|id| Some((*id, self.file(*id)?))) .flat_map(|(id, file)| file.index.exports.iter().map(move |s| (id, s))) + .filter(|(_, export)| name.is_none_or(|name| export.name == name)) .collect() } @@ -787,6 +1080,12 @@ impl Index { self.files().flat_map(|(id, file)| file.index.events.iter().map(move |e| (id, e))) } + /// The registrations of the callback `name`, whatever their family. + pub fn callbacks_named(&self, name: &str) -> Vec<(FileId, &EventDef)> { + self.note(|| Read::Callback(SmolStr::new(name))); + self.events().filter(|(_, event)| event.name == name && event.kind == EventKind::Callback).collect() + } + /// Resolves a `require` argument the way ox_lib does: dotted or slashed, relative to the /// resource root, optionally prefixed with `@resource`. pub fn resolve_require(&self, module: &str, from: FileId) -> Option { @@ -799,8 +1098,29 @@ impl Index { }; let relative = if module.contains('/') { module.to_string() } else { module.replace('.', "/") }; let relative = relative.trim_end_matches(".lua"); - [format!("{relative}.lua"), format!("{relative}/init.lua")] - .iter() - .find_map(|candidate| self.file_id(&resource.root.join(candidate))) + // A file indexed later at a path tried here would change what the module is. + [format!("{relative}.lua"), format!("{relative}/init.lua")].iter().find_map(|candidate| { + let path = resource.root.join(candidate); + self.note(|| Read::Module(normalize_path(&path))); + self.file_id(&path) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookups_after_indexing_that_panicked_are_not_collected() { + let index = Index::default(); + let indexing = std::panic::AssertUnwindSafe(|| { + index.reads_while(|| { + index.globals_named("Config", 0); + panic!("indexing failed"); + }) + }); + assert!(std::panic::catch_unwind(indexing).is_err()); + assert!(index.reads.borrow().is_none()); } } diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 3b9dd09..e5b6c42 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -13,7 +13,7 @@ use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use crate::callback_wrappers::Wrapper; use crate::index::{ AliasDef, ClassDef, Element, EnumTable, EventDef, EventFamily, EventKind, FileId, FileIndex, Index, Member, - Metatable, NuiCallbackDef, Symbol, SymbolKind, + Metatable, NuiCallbackDef, Read, Symbol, SymbolKind, }; use crate::infer::{metatable_args, table_elements, table_fields, FileContext, Infer}; use crate::luacats::{own_type, parse_doc_lines, DocGroup}; @@ -84,33 +84,47 @@ pub fn index_file( index: &Index, side: Option, ) -> FileIndex { - let ctx = FileContext::new(file, source, chunk, resolution); - // The index may not hold this file yet, so its side is passed along. - let infer = Infer::with_side(&ctx, index, side); - let lines = LineIndex::new(source); - let mut indexer = Indexer { - file, - source, - lines: &lines, - ctx: &ctx, - infer: &infer, - side, - regions: SideRegions::of(source, chunk), - nui_globals: crate::nui_callbacks::NuiGlobals::of(&ctx), - out: FileIndex::default(), - depth: 0, - }; - indexer.doc_comments(&chunk.comments); - indexer.block(&chunk.block); - if let Some(stmt @ Stmt { kind: StmtKind::Return(exprs), .. }) = chunk.block.stmts.last() { - indexer.module_return(stmt.span.start, exprs); - } - let mut out = indexer.out; + let (mut out, reads) = index.reads_while(|| { + let ctx = FileContext::new(file, source, chunk, resolution); + // The index may not hold this file yet, so its side is passed along. + let infer = Infer::with_side(&ctx, index, side); + let lines = LineIndex::new(source); + let mut indexer = Indexer { + file, + source, + lines: &lines, + ctx: &ctx, + infer: &infer, + side, + regions: SideRegions::of(source, chunk), + nui_globals: crate::nui_callbacks::NuiGlobals::of(&ctx), + out: FileIndex::default(), + depth: 0, + }; + indexer.doc_comments(&chunk.comments); + indexer.block(&chunk.block); + if let Some(stmt @ Stmt { kind: StmtKind::Return(exprs), .. }) = chunk.block.stmts.last() { + indexer.module_return(stmt.span.start, exprs); + } + indexer.out + }); out.summary = summarize(source, chunk, resolution); out.meta = is_meta_file(source, chunk); + // The globals the file reads anywhere, natives aside, count too: the code that uses them is + // checked again when they change, even where its index entry does not hold their types. + out.reads = reads; + out.reads.extend(global_reads(resolution).into_iter().map(Read::Global)); + out.reads.sort_unstable(); + out.reads.dedup(); out } +/// The globals a file reads, natives aside. +fn global_reads(resolution: &Resolution) -> Vec { + let reads = resolution.globals.iter().filter(|global| !global.is_definition()).map(|global| global.name.clone()); + reads.filter(|name| qbx_fivem_data::native(name).is_none()).collect() +} + struct Indexer<'a> { file: FileId, source: &'a str, diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 0e1494a..121c5db 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -2588,8 +2588,8 @@ impl<'a> Infer<'a> { } } let declared: FxHashSet = out.iter().map(|m| m.name.clone()).collect(); - for (file, symbol) in self.index.exports_of(resource) { - if wanted(&symbol.name) && !declared.contains(&symbol.name) { + for (file, symbol) in self.index.exports_of(resource, filter) { + if !declared.contains(&symbol.name) { out.push(member_from_symbol(file, symbol)); } } @@ -2658,7 +2658,8 @@ impl<'a> Infer<'a> { /// The members set on the tables `owner` names themselves. fn own_members(&self, owner: &str, filter: Option<&str>, out: &mut Vec) { - for (file, symbol) in self.index.members_of(owner, self.ctx.file) { + for (file, entry) in self.index.member_entries(owner, filter, self.ctx.file) { + let symbol = &entry.symbol; if filter.is_none_or(|f| f == symbol.name) { let mut member = member_from_symbol(file, symbol); if matches!(member.ty, Type::Table | Type::Unknown) { diff --git a/crates/qbx_lua_ls/src/server.rs b/crates/qbx_lua_ls/src/server.rs index 15ce053..0fab234 100644 --- a/crates/qbx_lua_ls/src/server.rs +++ b/crates/qbx_lua_ls/src/server.rs @@ -16,16 +16,13 @@ use crate::features::{ code_action, completion, definition, diagnostics, folding, hover, inlay, on_type, reference, references, semantic_tokens, signature, symbols, }; -use crate::index::{normalize_path, FileOrigin}; -use crate::workspace::{uri_to_path, Workspace}; +use crate::index::{normalize_path, Changes, FileOrigin}; +use crate::workspace::{uri_to_path, Workspace, MAX_INDEX_PASSES}; pub type Documents = FxHashMap; /// Opening the file shows everything; the workspace overview only needs to say "look here". const MAX_PROBLEMS_PER_CLOSED_FILE: usize = 100; -/// How often a flush may index the same open document, so that globals typed from each other -/// cannot keep it going. -const MAX_INDEX_PASSES: usize = 4; type AnyResult = Result>; @@ -89,6 +86,13 @@ pub struct Server { workspace_stale: bool, /// Resources to re-lint after a save or close, which is much cheaper than the whole workspace. stale_resources: FxHashSet>, + /// Closed files to re-lint besides those of `stale_resources`. + stale_files: FxHashSet, + /// What open documents declare differently than when the files that read it were indexed. + /// Those files follow once a document is saved or closed, rather than on every edit. + pending: Changes, + /// A document was saved or closed since the readers of `pending` were last indexed. + sync_readers: bool, next_request_id: i32, } @@ -230,6 +234,9 @@ impl Server { workspace_reported: FxHashSet::default(), workspace_stale: true, stale_resources: FxHashSet::default(), + stale_files: FxHashSet::default(), + pending: Changes::default(), + sync_readers: false, next_request_id: 0, } } @@ -332,7 +339,42 @@ impl Server { } /// Open documents are reparsed on every edit but only reindexed once something needs the index. + /// The files that read what they declare follow once one of them is saved or closed. fn flush_index(&mut self) { + self.index_dirty(); + for _ in 0..MAX_INDEX_PASSES { + if !std::mem::take(&mut self.sync_readers) || self.pending.is_empty() { + break; + } + let stale = self.ws.index.readers_of(&std::mem::take(&mut self.pending), &FxHashSet::default()); + let refreshed = self.ws.refresh_readers(stale, &self.open_files(), MAX_INDEX_PASSES); + let reached = refreshed.reached; + self.diagnostics_pending |= !reached.is_empty(); + // The readers are checked again, and so are the resources of those that now declare + // something differently, whose other files may read it. + self.stale_files.extend(reached.iter().copied()); + let changed = refreshed.changed.iter().filter_map(|id| self.ws.index.file(*id)); + self.stale_resources.extend(changed.map(|file| file.resource)); + // Open documents among the readers are indexed from their text, and what that changes is + // passed on in turn. + let open = self + .docs + .iter() + .filter(|(_, doc)| self.ws.index.file_id(&doc.path).is_some_and(|id| reached.contains(&id))); + let readers: Vec = open.map(|(uri, _)| uri.clone()).collect(); + self.sync_readers = !readers.is_empty(); + self.dirty.extend(readers); + self.index_dirty(); + } + } + + /// The files of the open documents. + fn open_files(&self) -> FxHashSet { + self.docs.values().filter_map(|doc| self.ws.index.file_id(&doc.path)).collect() + } + + /// Indexes the documents edited since the index last held them. + fn index_dirty(&mut self) { self.diagnostics_pending |= !self.dirty.is_empty(); let mut relink = false; // The same order whatever the paths hash to. Calls to a `---@callback` wrapper are only @@ -340,7 +382,7 @@ impl Server { let mut dirty: Vec = std::mem::take(&mut self.dirty).into_iter().collect(); dirty.sort(); dirty.sort_by_key(|uri| !self.docs.get(uri).is_some_and(|doc| doc.text.contains("@callback"))); - // The documents in the order they were indexed, with whether that changed their globals. + // The documents in the order they were indexed, with whether that changed what they declare. let mut indexed = Vec::new(); for uri in dirty { if let Some(doc) = self.docs.get_mut(&uri) { @@ -358,9 +400,9 @@ impl Server { self.ws.link_imports(); } // Each document was indexed against the others as the index held them, so those before the - // last one whose globals changed may have missed a global it declares, like the `Players` + // last one whose declarations changed may have missed one of them, like the `Players` // that types `self` in `function Players:rename()`. Indexed again, they move to the end, and - // the globals they change in turn may be what the others missed. + // what they change in turn may be what the others missed. for _ in 1..MAX_INDEX_PASSES { let stale = indexed.iter().rposition(|(_, changed)| *changed).unwrap_or(0); if stale == 0 { @@ -374,14 +416,16 @@ impl Server { } } - /// Indexes an open document, telling whether that changed the globals it declares. + /// Indexes an open document, telling whether that changed what it declares. fn index_document(&mut self, uri: &Url) -> bool { let Some(doc) = self.docs.get_mut(uri) else { return false }; - let before = self.ws.index.file(doc.file).map(|file| file.index.globals.clone()).unwrap_or_default(); - doc.file = self.ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution); - let after = self.ws.index.file(doc.file).map_or(&[][..], |file| &file.index.globals[..]); - before.len() != after.len() - || before.iter().zip(after).any(|(old, new)| old.name != new.name || old.ty != new.ty) + let mut changes = Changes::default(); + doc.file = self.ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution, &mut changes); + let changed = !changes.is_empty(); + for (file, reads) in changes { + self.pending.entry(file).or_default().extend(reads); + } + changed } fn publish_dirty(&mut self) { @@ -401,7 +445,8 @@ impl Server { fn publish_workspace(&mut self) { let everything = std::mem::take(&mut self.workspace_stale); let scope = std::mem::take(&mut self.stale_resources); - if !everything && scope.is_empty() { + let stale_files = std::mem::take(&mut self.stale_files); + if !everything && scope.is_empty() && stale_files.is_empty() { return; } let in_scope = |resource: Option| everything || scope.contains(&resource); @@ -410,8 +455,10 @@ impl Server { let mut targets: Vec<(Url, PathBuf, Option)> = Vec::new(); if enabled { let in_workspace = |path: &std::path::Path| self.ws.roots.iter().any(|root| path.starts_with(root)); - let files = - self.ws.index.files().filter(|(_, f)| f.origin == FileOrigin::Workspace && in_scope(f.resource)); + let stale = |id: crate::index::FileId, file: &crate::index::FileEntry| { + in_scope(file.resource) || stale_files.contains(&id) + }; + let files = self.ws.index.files().filter(|(id, f)| f.origin == FileOrigin::Workspace && stale(*id, f)); for (id, file) in files { targets.push((file.uri.clone(), file.path.clone(), Some(id))); } @@ -573,13 +620,16 @@ impl Server { } self.mark_resource_stale(&uri); self.dirty.insert(uri); + self.sync_readers = true; } notif::DidCloseTextDocument::METHOD => { let Ok(params) = serde_json::from_value::(params) else { return }; let uri = params.text_document.uri; if let Some(doc) = self.docs.remove(&uri) { if !doc.is_manifest() { - self.ws.index_path(&doc.path, FileOrigin::Workspace, None); + // What the document declared without saving is gone again for its readers too. + self.ws.index_path_tracked(&doc.path, FileOrigin::Workspace, &mut self.pending); + self.sync_readers = true; } } self.dirty.remove(&uri); @@ -621,6 +671,16 @@ impl Server { // files each resource imports. let mut relink = false; let mut config_changed = false; + // The files that read what a changed file declares follow it, whatever order the changes came + // in. The readers of what a deleted file declared can only be found while it is indexed. + let deleted: Changes = changes + .iter() + .filter(|change| change.typ == FileChangeType::DELETED) + .filter_map(|change| self.ws.index.file_id(&uri_to_path(&change.uri)?)) + .filter_map(|id| Some((id, self.ws.index.changes(Some(self.ws.index.file(id)?), None)))) + .collect(); + let mut stale = self.ws.index.readers_of(&deleted, &FxHashSet::default()); + let mut changed = Changes::default(); for change in changes { let Some(path) = uri_to_path(&change.uri) else { continue }; if path.extension().is_some_and(|e| e == "cfg") { @@ -636,7 +696,7 @@ impl Server { self.ws.index.remove_file(&path); } else if path.extension().is_some_and(|e| e == "lua") { if !self.docs.contains_key(&change.uri) { - self.ws.index_path(&path, FileOrigin::Workspace, None); + self.ws.index_path_tracked(&path, FileOrigin::Workspace, &mut changed); } relink |= change.typ == FileChangeType::CREATED; } @@ -651,6 +711,8 @@ impl Server { if relink || config_changed { self.ws.link_imports(); } + stale.extend(self.ws.index.readers_of(&changed, &FxHashSet::default())); + self.ws.refresh_readers(stale, &self.open_files(), MAX_INDEX_PASSES); self.workspace_stale = true; self.dirty.extend(self.docs.keys().cloned()); } @@ -886,6 +948,8 @@ impl Server { "qbx/reindex" => { qbx_lua_analysis::startup::clear_cache(); let stats = self.ws.scan(); + // File IDs start over, and the scan indexed every file against the others. + self.pending.clear(); self.log_config_notes(); self.dirty.extend(self.docs.keys().cloned()); // Restore unsaved text before any request or closed-file diagnostic can see the @@ -895,6 +959,7 @@ impl Server { self.dirty.extend(self.docs.keys().cloned()); self.flush_index(); self.stale_resources.clear(); + self.stale_files.clear(); self.workspace_stale = true; Ok(json!({ "files": stats.files, "resources": stats.resources, "millis": stats.millis as u64 })) } diff --git a/crates/qbx_lua_ls/src/workspace.rs b/crates/qbx_lua_ls/src/workspace.rs index 206e02a..318fc52 100644 --- a/crates/qbx_lua_ls/src/workspace.rs +++ b/crates/qbx_lua_ls/src/workspace.rs @@ -14,11 +14,26 @@ use qbx_lua_syntax::{parse, SmolStr}; use rustc_hash::FxHashSet; use crate::index::{ - normalize_path, DefinitionScope, FileEntry, FileId, FileIndex, FileOrigin, Index, ResourceEntry, ResourceId, + normalize_path, Changes, DefinitionScope, FileEntry, FileId, FileIndex, FileOrigin, Index, Read, ResourceEntry, + ResourceId, }; use crate::indexer::index_file; const MAX_INDEXED_FILE_BYTES: u64 = 2 * 1024 * 1024; +/// How often one change may index the same files again, so that globals typed from each other +/// cannot keep it going. +pub const MAX_INDEX_PASSES: usize = 4; + +/// A file just indexed, with the entry the index held for it before. +type Indexed = (FileId, Option); + +/// The files that `refresh_readers` reached, open ones included. +#[derive(Default)] +pub struct Refreshed { + pub reached: FxHashSet, + /// Those of them that now declare something differently. + pub changed: FxHashSet, +} #[derive(Default)] pub struct Workspace { @@ -233,8 +248,13 @@ impl Workspace { /// Indexes `path`, reading it from disk unless `text` (an open document) is given. pub fn index_path(&mut self, path: &Path, origin: FileOrigin, text: Option<&str>) -> bool { + self.read_and_index(path, origin, text).is_some() + } + + /// Indexes `path` like `index_path`, returning its file and the entry the index held for it. + fn read_and_index(&mut self, path: &Path, origin: FileOrigin, text: Option<&str>) -> Option { if self.lint_config.is_excluded(path) { - return false; + return None; } let owned; let source = match text { @@ -242,7 +262,7 @@ impl Workspace { None => { let too_large = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_INDEXED_FILE_BYTES); if too_large { - return false; + return None; } match read_source(path) { Ok(text) => { @@ -251,17 +271,60 @@ impl Workspace { } Err(_) => { self.mark_escrowed(path); - return false; + return None; } } } }; let chunk = parse(source); let resolution = resolve(&chunk); - self.index_parsed(path, origin, source, &chunk, &resolution); + Some(self.index_entry(path, origin, source, &chunk, &resolution)) + } + + /// Indexes `path` from disk like `index_path`, adding what it now declares differently to + /// `changes`. + pub fn index_path_tracked(&mut self, path: &Path, origin: FileOrigin, changes: &mut Changes) -> bool { + let Some((id, replaced)) = self.read_and_index(path, origin, None) else { return false }; + record(changes, id, self.changed(id, replaced.as_ref())); true } + /// What the file `id` declares differently than `replaced`, the entry the index held for it + /// before. + fn changed(&self, id: FileId, replaced: Option<&FileEntry>) -> FxHashSet { + self.index.changes(replaced, self.index.file(id)) + } + + /// Indexes the closed files in `stale` again, and then the files that read what they now + /// declare differently, themselves included, until nothing changes or after `passes` passes, + /// so that the types those files inferred from those declarations follow them. Files in `open` + /// are left to their documents. + pub fn refresh_readers(&mut self, stale: FxHashSet, open: &FxHashSet, passes: usize) -> Refreshed { + let mut refreshed = Refreshed::default(); + let mut stale: Vec = stale.into_iter().collect(); + for _ in 0..passes { + stale.sort_unstable(); + let mut changed = Changes::default(); + let mut indexed = FxHashSet::default(); + for id in stale.drain(..) { + let Some(file) = self.index.file(id).filter(|file| file.origin != FileOrigin::Stub) else { continue }; + refreshed.reached.insert(id); + if !open.contains(&id) { + let (path, origin) = (file.path.clone(), file.origin); + if self.index_path_tracked(&path, origin, &mut changed) { + indexed.insert(id); + } + } + } + refreshed.changed.extend(changed.keys()); + stale.extend(self.index.readers_of(&changed, &indexed)); + if stale.is_empty() { + break; + } + } + refreshed + } + fn mark_escrowed(&mut self, unreadable: &Path) { if !unreadable.is_file() { return; @@ -280,6 +343,19 @@ impl Workspace { chunk: &qbx_lua_syntax::ast::Chunk, resolution: &qbx_lua_analysis::scope::Resolution, ) -> FileId { + self.index_entry(path, origin, source, chunk, resolution).0 + } + + /// Indexes a parsed file like `index_parsed`, returning its file and the entry the index held + /// for it. + fn index_entry( + &mut self, + path: &Path, + origin: FileOrigin, + source: &str, + chunk: &qbx_lua_syntax::ast::Chunk, + resolution: &qbx_lua_analysis::scope::Resolution, + ) -> Indexed { let id = self.index.allocate(path); let (resource, side) = self.side_and_resource(path); let origin = self.index.file(id).map_or(origin, |f| f.origin); @@ -295,27 +371,30 @@ impl Workspace { if file.defines_for_all() { file.index.definition_scope = self.definition_scope(path); } - self.index.set_file(id, file); - id + (id, self.index.set_file(id, file)) } /// Indexes a document open in the editor. Its symbols are inferred with what the index held for /// the file, so when it declares other classes or aliases now, as one indexed for the first time /// does, a second pass reads them, as the scan's second pass does for every file, and the - /// methods of `---@class Name` `Name = {}` see the class. + /// methods of `---@class Name` `Name = {}` see the class. What it now declares differently is + /// added to `changes`. pub fn index_document( &mut self, path: &Path, source: &str, chunk: &qbx_lua_syntax::ast::Chunk, resolution: &qbx_lua_analysis::scope::Resolution, + changes: &mut Changes, ) -> FileId { let id = self.index.allocate(path); let declared = |ws: &Self| ws.index.file(id).map(|file| declared_types(&file.index)).unwrap_or_default(); let before = declared(self); - self.index_parsed(path, FileOrigin::Workspace, source, chunk, resolution); + let (_, replaced) = self.index_entry(path, FileOrigin::Workspace, source, chunk, resolution); + record(changes, id, self.changed(id, replaced.as_ref())); if declared(self) != before { - self.index_parsed(path, FileOrigin::Workspace, source, chunk, resolution); + let (_, replaced) = self.index_entry(path, FileOrigin::Workspace, source, chunk, resolution); + record(changes, id, self.changed(id, replaced.as_ref())); } id } @@ -437,6 +516,13 @@ impl Workspace { } } +/// Adds what the file `id` declares differently, `changed`, to `changes`. +fn record(changes: &mut Changes, id: FileId, changed: FxHashSet) { + if !changed.is_empty() { + changes.entry(id).or_default().extend(changed); + } +} + /// The classes and aliases that a file declares. Its globals and members are left out, since typing /// a new `Config.Foo` changes them on every key. fn declared_types(index: &FileIndex) -> Vec<(&'static str, SmolStr)> { diff --git a/crates/qbx_lua_ls/tests/workspace_refresh.rs b/crates/qbx_lua_ls/tests/workspace_refresh.rs index c2cbead..03eb5dd 100644 --- a/crates/qbx_lua_ls/tests/workspace_refresh.rs +++ b/crates/qbx_lua_ls/tests/workspace_refresh.rs @@ -360,7 +360,7 @@ fn open_documents_alone_are_indexed_again_for_the_classes_they_declare() { ); let doc = Document::new(path_to_uri(&path), path, 1, text); let file = match document { - true => ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution), + true => ws.index_document(&doc.path, &doc.text, &doc.chunk, &doc.resolution, &mut Default::default()), false => ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution), }; let index = &ws.index.file(file).unwrap().index; @@ -412,3 +412,201 @@ fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { let symbols = client.request("workspace/symbol", json!({"query": "ScratchOnly"})); assert!(symbols.as_array().unwrap().is_empty(), "{symbols}"); } + +/// The file names and lines `textDocument/definition` finds for the `needle` in `text` of `uri`. +fn definitions(client: &mut Client, uri: &Url, text: &str, needle: &str) -> Vec<(String, u64)> { + let offset = text.find(needle).unwrap(); + let line = text[..offset].matches('\n').count(); + let character = offset - text[..offset].rfind('\n').map_or(0, |i| i + 1); + let result = client.request( + "textDocument/definition", + json!({"textDocument": {"uri": uri}, "position": {"line": line, "character": character}}), + ); + let locations = result.as_array().cloned().unwrap_or_default(); + let place = |location: &Value| { + let file = location["uri"].as_str().unwrap().rsplit('/').next().unwrap().to_string(); + (file, location["range"]["start"]["line"].as_u64().unwrap()) + }; + locations.iter().map(place).collect() +} + +#[test] +fn closed_files_follow_the_globals_of_documents_once_saved_or_closed() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_scripts { 'types.lua', 'methods.lua' }\nclient_script 'client.lua'\n"); + fixture.write("demo/types.lua", "---@class Test.Player\nPlayers = {}\n"); + fixture.write("demo/methods.lua", "function Players:rename(name)\n self.nickname = name\nend\n"); + fixture.write("demo/client.lua", ""); + let mut client = Client::start(&fixture.0); + let types = path_to_uri(&fixture.0.join("demo/types.lua")); + let caller = path_to_uri(&fixture.0.join("demo/client.lua")); + let text = + "---@type Test.User\nlocal user\n---@type Test.Admin\nlocal admin\nprint(user.nickname, admin.nickname)\n"; + client.open(&types, "---@class Test.Player\nPlayers = {}\n"); + client.open(&caller, text); + let methods = || vec![("methods.lua".to_string(), 1)]; + let change = |client: &Client, version: i32, class: &str| { + let text = format!("---@class {class}\nPlayers = {{}}\n"); + client.notify( + "textDocument/didChange", + json!({"textDocument": {"uri": types, "version": version}, "contentChanges": [{"text": text}]}), + ); + }; + + change(&client, 2, "Test.User"); + assert_eq!(definitions(&mut client, &caller, text, "nickname,"), [], "closed files wait for a save"); + fixture.write("demo/types.lua", "---@class Test.User\nPlayers = {}\n"); + client.notify("textDocument/didSave", json!({"textDocument": {"uri": types}})); + assert_eq!(definitions(&mut client, &caller, text, "nickname,"), methods()); + + // Saving any document passes on what the others changed without saving. + change(&client, 3, "Test.Admin"); + client.notify("textDocument/didSave", json!({"textDocument": {"uri": caller}})); + assert_eq!(definitions(&mut client, &caller, text, "nickname)"), methods()); + + // Closing without saving takes the declarations back to the file on disk. + client.notify("textDocument/didClose", json!({"textDocument": {"uri": types}})); + assert_eq!(definitions(&mut client, &caller, text, "nickname,"), methods()); + assert_eq!(definitions(&mut client, &caller, text, "nickname)"), []); +} + +#[test] +fn closed_files_follow_globals_that_change_on_disk_in_any_order() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script '*.lua'\n"); + fixture.write("demo/query.lua", ""); + let mut client = Client::start(&fixture.0); + client.request("qbx/status", Value::Null); + // The method on `Mid` is indexed before `Mid`, which is indexed before the `Base` it is typed from. + let files = [ + ("demo/c.lua", "function Mid:touch()\n self.touched = true\nend\n"), + ("demo/a.lua", "Mid = Base\n"), + ("demo/b.lua", "---@class Test.Base\nBase = {}\n"), + ]; + for (file, text) in files { + fixture.write(file, text); + } + let changes: Vec = + files.iter().map(|(file, _)| json!({"uri": path_to_uri(&fixture.0.join(file)), "type": 1})).collect(); + client.notify("workspace/didChangeWatchedFiles", json!({"changes": changes})); + let query = path_to_uri(&fixture.0.join("demo/query.lua")); + let text = "print(Base.touched)\n"; + client.open(&query, text); + assert_eq!(definitions(&mut client, &query, text, "touched"), [("c.lua".to_string(), 1)]); + + // Without `Base`, neither `Mid` nor the method on it has a class any more: the method makes `Mid` + // a table of its own, as a fresh scan reads it. + let deleted = path_to_uri(&fixture.0.join("demo/b.lua")); + std::fs::remove_file(fixture.0.join("demo/b.lua")).unwrap(); + client.notify("workspace/didChangeWatchedFiles", json!({"changes": [{"uri": deleted, "type": 3}]})); + let symbols = client.request("workspace/symbol", json!({"query": "touched"})); + assert_eq!(symbols[0]["containerName"], "Mid", "{symbols}"); + let fresh = Client::start(&fixture.0).request("workspace/symbol", json!({"query": "touched"})); + assert_eq!(symbols, fresh); +} + +#[test] +fn closed_files_follow_what_they_declare_themselves_once_changed_on_disk() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script '*.lua'\n"); + fixture.write("demo/init.lua", "Util = {}\n"); + fixture.write("demo/utils.lua", "function Util.Key()\n return 1\nend\n"); + fixture.write("demo/query.lua", ""); + let mut client = Client::start(&fixture.0); + client.request("qbx/status", Value::Null); + // `Key` returns what `String` beside it returns, which the file is indexed without at first. + let utils = "function Util.Key()\n return Util.String()\nend\n\nfunction Util.String()\n return ''\nend\n"; + fixture.write("demo/utils.lua", utils); + let changed = path_to_uri(&fixture.0.join("demo/utils.lua")); + client.notify("workspace/didChangeWatchedFiles", json!({"changes": [{"uri": changed, "type": 2}]})); + let query = path_to_uri(&fixture.0.join("demo/query.lua")); + let text = "Value = Util.Key()\n"; + client.open(&query, text); + assert_eq!(hover(&mut client, &query, text, "Value"), "(global) Value: string"); +} + +/// The first line `textDocument/hover` shows for the `needle` in `text` of `uri`. +fn hover(client: &mut Client, uri: &Url, text: &str, needle: &str) -> String { + let offset = text.find(needle).unwrap(); + let line = text[..offset].matches('\n').count(); + let character = offset - text[..offset].rfind('\n').map_or(0, |i| i + 1); + let result = client.request( + "textDocument/hover", + json!({"textDocument": {"uri": uri}, "position": {"line": line, "character": character}}), + ); + let value = result["contents"]["value"].as_str().unwrap_or_default(); + value.lines().find(|line| !line.is_empty() && !line.starts_with("```")).unwrap_or_default().to_string() +} + +#[test] +fn closed_files_follow_the_classes_members_exports_and_modules_they_read() { + let fixture = Fixture::new(); + let types = "---@class Test.Store\n---@field admin Test.Admin\n\n---@class Test.Admin\n\n---@class Test.User\n"; + fixture.write("demo/fxmanifest.lua", "shared_scripts { 'types.lua', 'players.lua', 'readers.lua', 'query.lua' }\n"); + fixture.write("demo/types.lua", types); + fixture.write("demo/players.lua", "---@type Test.Store\nStore = {}\nPlayers = {}\nPlayers.admin = 5\n"); + fixture.write("demo/mod.lua", "return { value = 1 }\n"); + fixture.write("shop/fxmanifest.lua", "server_script 'server.lua'\n"); + fixture.write("shop/server.lua", "exports('GetThing', function() return 1 end)\n"); + // Resources are found by name whatever its case. + let readers = "CurrentAdmin = Store.admin\nBoss = Players.admin\nThing = exports.Shop:GetThing()\n"; + fixture.write("demo/readers.lua", &format!("{readers}Value = require('mod').value\n")); + fixture.write("demo/query.lua", ""); + let mut client = Client::start(&fixture.0); + let query = path_to_uri(&fixture.0.join("demo/query.lua")); + let text = "print(CurrentAdmin, Boss, Thing, Value)\n"; + client.open(&query, text); + let hovers = |client: &mut Client| { + ["CurrentAdmin", "Boss", "Thing", "Value"].map(|global| hover(client, &query, text, global)) + }; + assert_eq!( + hovers(&mut client), + [ + "(global) CurrentAdmin: Test.Admin", + "(global) Boss: integer", + "(global) Thing: integer", + "(global) Value: integer" + ] + ); + + // What the readers stored follows the `@field` of a class, the export of another resource and + // what a module returns, none of them a global. + let changed = [ + ("demo/types.lua", types.replace("admin Test.Admin", "admin Test.User")), + ("shop/server.lua", "exports('GetThing', function() return 'thing' end)\n".to_string()), + ("demo/mod.lua", "return { value = 'value' }\n".to_string()), + ]; + for (file, text) in &changed { + fixture.write(file, text); + } + let changes: Vec = + changed.iter().map(|(file, _)| json!({"uri": path_to_uri(&fixture.0.join(file)), "type": 2})).collect(); + client.notify("workspace/didChangeWatchedFiles", json!({"changes": changes})); + assert_eq!( + hovers(&mut client), + [ + "(global) CurrentAdmin: Test.User", + "(global) Boss: integer", + "(global) Thing: string", + "(global) Value: string" + ] + ); + // Also when the export alone changes. + fixture.write("shop/server.lua", "exports('GetThing', function() return true end)\n"); + let server = path_to_uri(&fixture.0.join("shop/server.lua")); + client.notify("workspace/didChangeWatchedFiles", json!({"changes": [{"uri": server, "type": 2}]})); + assert_eq!(hover(&mut client, &query, text, "Thing"), "(global) Thing: boolean"); + + // A member that a document sets follows once it is saved. + let players = path_to_uri(&fixture.0.join("demo/players.lua")); + let saved = "---@type Test.Store\nStore = {}\nPlayers = {}\nPlayers.admin = 'admin'\n"; + client.open(&players, "---@type Test.Store\nStore = {}\nPlayers = {}\nPlayers.admin = 5\n"); + client.notify( + "textDocument/didChange", + json!({"textDocument": {"uri": players, "version": 2}, "contentChanges": [{"text": saved}]}), + ); + assert_eq!(hover(&mut client, &query, text, "Boss"), "(global) Boss: integer", "closed files wait for a save"); + fixture.write("demo/players.lua", saved); + client.notify("textDocument/didSave", json!({"textDocument": {"uri": players}})); + assert_eq!(hover(&mut client, &query, text, "Boss"), "(global) Boss: string"); +} From 6b3b8e260c3361243cbf187775b7f8c4190ec392 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:08:17 +0200 Subject: [PATCH 036/193] fix(ls): read a `@type` below a `@class` as typing a value of the class --- crates/qbx_lua_ls/src/features/assignments.rs | 2 +- .../qbx_lua_ls/src/features/class_tables.rs | 4 +- crates/qbx_lua_ls/src/features/expected.rs | 2 +- crates/qbx_lua_ls/src/features/visibility.rs | 24 +--- crates/qbx_lua_ls/src/indexer.rs | 4 +- crates/qbx_lua_ls/src/infer.rs | 9 +- crates/qbx_lua_ls/tests/lsp.rs | 127 ++++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 23 ++++ 8 files changed, 165 insertions(+), 30 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/assignments.rs b/crates/qbx_lua_ls/src/features/assignments.rs index 9fc4c54..525f37d 100644 --- a/crates/qbx_lua_ls/src/features/assignments.rs +++ b/crates/qbx_lua_ls/src/features/assignments.rs @@ -36,7 +36,7 @@ impl Finder<'_, '_> { /// declares the class rather than a value of it. fn stmt_type(&self, stmt: &Stmt, index: usize) -> Option { let doc = self.infer.ctx.doc_at(stmt.span.start); - doc.type_at(index).filter(|_| doc.classes.is_empty()).cloned() + doc.type_at(index).filter(|_| doc.declared_class().is_none()).cloned() } /// The type a local is declared with: the `---@type` above its `local` statement, or its diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index c04abb6..d8f990f 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -866,7 +866,7 @@ impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { StmtKind::Local { exprs, .. } if exprs.iter().any(is_table) => { let doc = self.classes.infer.ctx.doc_at(stmt.span.start); // `---@class Name` above a table declares the class rather than an instance of it. - if doc.classes.is_empty() { + if doc.declared_class().is_none() { for (index, expr) in exprs.iter().enumerate() { if let Some(ty) = doc.type_at(index) { self.top_table(ty, expr); @@ -876,7 +876,7 @@ impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { } StmtKind::Assign { targets, exprs } if exprs.iter().any(is_table) => { let doc = self.classes.infer.ctx.doc_at(stmt.span.start); - if doc.classes.is_empty() { + if doc.declared_class().is_none() { let values = targets.iter().zip(exprs).enumerate(); for (index, (target, expr)) in values.filter(|(_, (_, expr))| is_table(expr)) { let declared = doc.type_at(index).cloned(); diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index cfed2cc..0fe8eaa 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -196,7 +196,7 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { if let Some(index) = exprs.iter().position(|expr| self.is_value(expr)) { let doc = self.infer.ctx.doc_at(stmt.span.start); // `---@class Name` above a table declares the class rather than an instance of it. - if doc.classes.is_empty() { + if doc.declared_class().is_none() { let values = doc.type_values_at(index).to_vec(); self.found = doc.type_at(index).map(|ty| ExpectedValue::stored(ty.clone(), values)); } diff --git a/crates/qbx_lua_ls/src/features/visibility.rs b/crates/qbx_lua_ls/src/features/visibility.rs index 75eb021..c8eae6c 100644 --- a/crates/qbx_lua_ls/src/features/visibility.rs +++ b/crates/qbx_lua_ls/src/features/visibility.rs @@ -14,7 +14,6 @@ use std::cell::OnceCell; use std::sync::Arc; use qbx_lua_analysis::project::relative_slash_path; -use qbx_lua_analysis::scope::Resolved; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::lexer::TokenKind; use qbx_lua_syntax::visit::{self, Visitor}; @@ -23,7 +22,7 @@ use rustc_hash::FxHashSet; use super::class_tables::Classes; use crate::index::{ClassDef, FileId, SymbolKind}; -use crate::infer::{table_fields, Decl, Infer}; +use crate::infer::{table_fields, Infer}; use crate::luacats::{applies_on, Visibility}; use crate::types::Type; @@ -197,21 +196,6 @@ impl<'a, 'b> Scope<'a, 'b> { parents.any(|(parent, file)| self.inherits(parent, file, ancestor, visited, depth + 1)) } - /// Whether `name` is the table a `---@class` annotation declares. A `---@type` in the same doc - /// comment types the local instead, as LuaLS reads `---@class Secret` `---@type Secret` above - /// `local s = {}`. - fn is_class_table(&self, name: &Name) -> bool { - let resolution = self.infer.ctx.resolution; - let typed = match resolution.resolve_at(name.span.start) { - Some(Resolved::Local(id)) => match self.infer.ctx.decl(resolution.local(id).decl.start) { - Some(Decl::Local { stmt, .. }) => self.infer.ctx.doc_at(stmt.span.start).ty.is_some(), - _ => false, - }, - _ => false, - }; - !typed && self.infer.is_class_table(name) - } - /// The class whose table `expr` is: a name or field that a `---@class` annotation declares, like /// `Secret` below `---@class Secret` `local Secret = {}`, or ox_lib's `lib.array`. fn class_table(&self, expr: &Expr) -> Option { @@ -221,7 +205,7 @@ impl<'a, 'b> Scope<'a, 'b> { member.is_some_and(|member| member.kind == SymbolKind::Table && matches!(member.ty, Type::Named(..))) }; let is_table = match &expr.kind { - ExprKind::Name(name) => self.is_class_table(name), + ExprKind::Name(name) => self.infer.is_class_table(name), ExprKind::Field { base, name, .. } => declares(base, &name.text), ExprKind::Index { base, index, .. } => index.as_string().is_some_and(|key| declares(base, key)), _ => false, @@ -244,7 +228,7 @@ impl<'a, 'b> Scope<'a, 'b> { let Some((last, parents)) = path.split_last() else { let owner = self.infer.func_name_owner_type(&func_name(&[])); let class = match owner.without_nil() { - Type::Named(class, _) if self.is_class_table(&name.base) => Some(class), + Type::Named(class, _) if self.infer.is_class_table(&name.base) => Some(class), _ => None, }; return (owner, class); @@ -319,7 +303,7 @@ impl Methods<'_, '_, '_> { /// above the statement at `stmt_start` declares them. fn class_constructors(&mut self, stmt_start: u32, exprs: &[Expr]) { let doc = self.scope.infer.ctx.doc_at(stmt_start); - let Some(class) = doc.classes.last().filter(|_| doc.ty.is_none()) else { return }; + let Some(class) = doc.declared_class() else { return }; for fields in exprs.iter().filter_map(table_fields) { for field in fields { let (TableField::Named { value, .. } | TableField::Keyed { value, .. }) = field else { continue }; diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index e5b6c42..9f25c69 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -351,7 +351,7 @@ impl<'a> Indexer<'a> { ) -> Symbol { let doc = self.ctx.doc_at(doc_anchor); let mut kind = SymbolKind::Variable; - let ty = if let Some(class) = doc.classes.last() { + let ty = if let Some(class) = doc.declared_class() { if let Some(fields) = value.and_then(table_fields) { self.table_members(class.name.clone(), fields, table_depth + 1); } @@ -568,7 +568,7 @@ impl<'a> Indexer<'a> { for (i, name) in names.iter().enumerate() { if let Some(fields) = exprs.get(i).and_then(table_fields) { let doc = self.ctx.doc_at(stmt.span.start); - let owner = match doc.classes.last() { + let owner = match doc.declared_class() { Some(class) => class.name.clone(), None => { let owner = self.ctx.local_owner_key(name.name.span.start); diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 121c5db..f1ab75c 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -949,7 +949,7 @@ impl<'a> Infer<'a> { fn local_stmt_type(&self, stmt: &Stmt, index: usize, top_level: bool, reassigned: bool) -> Type { let StmtKind::Local { names, exprs, in_unpack } = &stmt.kind else { return Type::Unknown }; let doc = self.ctx.doc_at(stmt.span.start); - if let Some(class) = doc.classes.last() { + if let Some(class) = doc.declared_class() { return own_type(&class.name, &class.generics); } if let Some(ty) = doc.type_at(index) { @@ -1085,9 +1085,10 @@ impl<'a> Infer<'a> { Some(substitute(param, &self.bind_generics(&fun, &args, via_method, false))) } Expected::Value { stmt, index } => { - // `---@class Name` above a table declares the class rather than a value of it. + // `---@class Name` above a table declares the class rather than a value of it, + // unless a `---@type` follows it. let doc = self.ctx.doc_at(stmt.span.start); - doc.type_at(index).filter(|_| doc.classes.is_empty()).cloned() + doc.type_at(index).filter(|_| doc.declared_class().is_none()).cloned() } Expected::Field { table, key } => { let at = self.ctx.tables.get(&table.span.start)?; @@ -1141,7 +1142,7 @@ impl<'a> Infer<'a> { pub fn is_class_table(&self, name: &Name) -> bool { match self.ctx.resolution.resolve_at(name.span.start) { Some(Resolved::Local(id)) => match self.ctx.decl(self.ctx.resolution.local(id).decl.start) { - Some(Decl::Local { stmt, .. }) => !self.ctx.doc_at(stmt.span.start).classes.is_empty(), + Some(Decl::Local { stmt, .. }) => self.ctx.doc_at(stmt.span.start).declared_class().is_some(), _ => false, }, _ => self.index.globals_named(&name.text, self.ctx.file).iter().any(|(_, symbol)| symbol.is_class_table()), diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 97723af..a0a192f 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2943,6 +2943,71 @@ local account = { name = 'Ann' } assert_eq!(missing(&mut client, SERVER), [5]); } +#[test] +fn a_type_below_the_class_types_the_table_as_a_value_of_it() { + let mut client = Client::start(fixture_root()); + // LuaLS binds a `---@type` that follows the `---@class` to the statement, and the class to + // nothing. In the other order the class still declares the table. + let text = "\ +---@class Test.Secret +---@field shown integer +---@field hidden string +---@type Test.Secret +local secret = { shown = 'one' } +secret = 5 + +---@class Test.GlobalSecret +---@field shown integer +---@type Test.GlobalSecret +TestGlobalSecret = {} + +---@class Test.Shape +---@type Test.Secret +local shaped = { shown = 1, hidden = 'x' } + +---@type Test.Secret +---@class Test.Declared +---@field name string +local declared = {} +declared = 5 + +---@class Test.Handlers +---@field onCount fun(count: integer) +---@type Test.Handlers +local handlers = { onCount = function(count) end } +print(secret, shaped, declared, handlers) +"; + client.open_with(CLIENT, text); + client.diagnostics_for(CLIENT); + let uri = client.uri(CLIENT).to_string(); + let mut found: Vec<(u64, String)> = client.diagnostics[&uri] + .as_array() + .unwrap() + .iter() + .filter(|d| d["code"] == "missing-fields" || d["code"] == "assign-type-mismatch") + .map(|d| (d["range"]["start"]["line"].as_u64().unwrap(), d["message"].as_str().unwrap().into())) + .collect(); + found.sort(); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + assert_eq!( + found, + [ + (line("local secret"), "Cannot assign `string` to field `shown` of type `integer`".into()), + (line("local secret"), "Missing required fields in type `Test.Secret`: `hidden`".into()), + (line("secret = 5"), "Cannot assign `integer` to `secret` of type `Test.Secret`".into()), + (line("TestGlobalSecret = {}"), "Missing required fields in type `Test.GlobalSecret`: `shown`".into()), + ] + ); + let (l, c) = pos(text, "shaped =", 0); + assert!(client.hover_text(CLIENT, l, c).contains("local shaped: Test.Secret")); + let (l, c) = pos(text, "declared =", 0); + assert!(client.hover_text(CLIENT, l, c).contains("local declared: Test.Declared")); + // The functions such a table holds take the parameters its fields declare. + let (l, c) = pos(text, "count) end", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("count: integer"), "{hover}"); +} + /// The `undeclared-field` findings of `file` as (line, message), in source order. fn undeclared_fields(client: &mut Client, file: &str) -> Vec<(u64, String)> { client.diagnostics_for(file); @@ -3047,6 +3112,37 @@ print(abc, loose, child, open, pair) ); } +#[test] +fn strict_classes_check_a_table_typed_below_the_class() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class (strict) Test.StrictSecret +---@field shown integer +---@type Test.StrictSecret +local secret = { shown = 1, other = true } +secret.extra = 1 +function secret:reveal() end + +---@type Test.StrictSecret +local another = { shown = 2 } +another.extra = 2 +print(secret, another) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let message = |field: &str| format!("Field `{field}` is not declared in strict class `Test.StrictSecret`"); + assert_eq!( + undeclared_fields(&mut client, CLIENT), + [ + (line("other = true"), message("other")), + (line("secret.extra"), message("extra")), + (line("secret:reveal"), message("reveal")), + (line("another.extra"), message("extra")), + ], + "`secret` is a value of the class, so what it is given declares nothing" + ); +} + #[test] fn strict_classes_setting_makes_the_workspace_classes_strict() { struct Fixture(PathBuf); @@ -3283,6 +3379,37 @@ print(outside, s.|) ); } +#[test] +fn a_type_above_a_class_leaves_its_table_the_class_table() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type table +---@class Probe.Vault +---@field private key string +local Vault = { + ---@param vault Probe.Vault + read = function(vault) return vault.key end, +} +print(Vault.key) + +---@class Probe.Safe +---@field private code string +---@type Probe.Safe +local safe = { code = 'x' } +print(safe.code) +"; + client.open_with(CLIENT, text); + assert_eq!( + findings(&mut client, CLIENT, &["invisible"]), + [( + "invisible".to_string(), + pos(text, "print(safe.code)", 0).0 as u64, + "Field `code` is private, it can only be accessed in class `Probe.Safe`".to_string() + )], + "a `@type` above the `@class` keeps the table and its functions those of the class; one below types a value" + ); +} + #[test] fn members_are_restricted_by_the_declaration_of_the_class_the_file_sees() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 7f4e9db..a1a7a6f 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -119,6 +119,8 @@ pub struct DocGroup { /// The values that the `---|` lines under `@type` list for its last type, with their /// descriptions. pub type_values: Vec, + /// Index of the doc line of `@type`. + pub ty_line: usize, pub enum_name: Option, pub enum_keys: bool, pub enum_side: Option, @@ -139,6 +141,17 @@ impl DocGroup { !self.params.is_empty() || !self.returns.is_empty() || !self.overloads.is_empty() } + /// The class whose table the statement below is: the last `@class`, unless a `@type` line + /// follows it. LuaLS then binds the `@type` to the statement and the `@class` to nothing, so + /// `local s = {}` below `---@class Secret` `---@type Secret` is a value of the class. + pub fn declared_class(&self) -> Option<&DocClass> { + let class = self.classes.last()?; + match self.ty { + Some(_) if self.ty_line > class.line => None, + _ => Some(class), + } + } + /// The `@type` of the name at `index` of the statement below. A line that lists several types /// gives each name its own, and one type only the first name, as LuaLS binds it. pub fn type_at(&self, index: usize) -> Option<&Type> { @@ -493,6 +506,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { group.ty = types.next(); group.ty_rest = types.collect(); listing = Some(Listing::Type); + group.ty_line = index; } "generic" => { let names = rest.split(',').filter_map(|g| g.trim().split([':', ' ']).next()).filter(|g| !g.is_empty()); @@ -1159,6 +1173,15 @@ mod tests { assert_eq!(doc.aliases[1].side, Some(Side::Server)); } + #[test] + fn a_type_after_the_class_types_the_statement_instead() { + let declared = |text: &str| parse(text).declared_class().map(|class| class.name.to_string()); + assert_eq!(declared("---@class Secret\n---@field shown integer"), Some("Secret".into())); + assert_eq!(declared("---@class Secret\n---@field shown integer\n---@type Secret"), None); + assert_eq!(declared("---@type Other\n---@class Secret"), Some("Secret".into())); + assert_eq!(declared("---@type Other"), None); + } + #[test] fn class_and_enum_attributes() { let doc = parse("---@class (partial) Player : Entity"); From 14ab4062d844c1261ddefaaf3d500ac177bf662c Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:59:17 +0200 Subject: [PATCH 037/193] fix(infer): call values whose type is an alias of a function type --- crates/qbx_lua_ls/src/infer.rs | 19 ++++++------ crates/qbx_lua_ls/tests/lsp.rs | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index f1ab75c..edce433 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1100,16 +1100,15 @@ impl<'a> Infer<'a> { /// The signature that `call` uses, with its arguments and whether it calls a method. fn call_parts<'e>(&self, call: &'e Expr) -> Option<(Arc, CallArgs<'e>, bool)> { - let (fun, args, via_method) = match &call.kind { - ExprKind::Call { callee, args, .. } => (self.expr(callee).as_fun().cloned(), args, false), - ExprKind::MethodCall { base, method, args, .. } => { - let member = self.member(&self.expr(base), &method.text); - (member.and_then(|m| m.ty.as_fun().cloned()), args, true) - } + let (base, method, args) = match &call.kind { + ExprKind::Call { callee, args, .. } => (callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => (base, Some(method), args), _ => return None, }; + let via_method = method.is_some(); + let (fun, _) = self.callee_fun(base, method)?; let args = CallArgs::new(args); - let fun = self.signature_for(&fun?, &args, via_method, call.span.start); + let fun = self.signature_for(&fun, &args, via_method, call.span.start); Some((fun, args, via_method)) } @@ -1765,12 +1764,12 @@ impl<'a> Infer<'a> { match method { Some(method) => { let member = self.member(&self.expr(base), &method.text)?; - Some((member.ty.as_fun()?.clone(), Some(member))) + Some((self.fun_of(&member.ty)?, Some(member))) } None => { let ty = self.expr(base); - if let Some(fun) = ty.as_fun() { - return Some((fun.clone(), None)); + if let Some(fun) = self.fun_of(&ty) { + return Some((fun, None)); } match self.resolve_alias(&ty) { Type::Named(name, args) => { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index a0a192f..35a01ce 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6779,6 +6779,60 @@ end } } +#[test] +fn calls_through_function_aliases_use_their_signature() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Check fun(value: string): boolean +---@alias Each fun(list: string[], cb: fun(item: string)) +---@alias Handler fun(id: integer, name: string) +---@alias Run fun(self: Checker, cb: Handler) + +---@type Check +local check = nil +---@type Check? +local maybeCheck = nil +---@type Each +local forEach = nil + +---@class Checker +---@field check Check +---@field run Run +local Checker = {} + +---@param cb Handler +local function on(cb) end + +local checked = check('a') +local maybeChecked = maybeCheck('a') +local fieldChecked = Checker.check('a') +forEach({}, function(item) end) +on(function(handlerId, handlerName) end) +Checker:run(function(runId) end) +"; + client.open_with(CLIENT, text); + let cases = [ + ("checked =", "checked: boolean"), + ("maybeChecked", "maybeChecked: boolean"), + ("fieldChecked", "fieldChecked: boolean"), + // A function passed to a call takes its parameters from the alias of the callee or of the parameter. + ("item)", "item: string"), + ("handlerId", "handlerId: integer"), + ("handlerName", "handlerName: string"), + ("runId", "runId: integer"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + + let (l, c) = pos(text, "check('a'", 6); + let result = client.request("textDocument/signatureHelp", client.position_params(CLIENT, l, c)); + assert_eq!(result["signatures"][0]["label"], "check(value: string): boolean", "{result}"); + assert_eq!(result["activeParameter"], 0); +} + #[test] fn cfxlua_extends_the_standard_libraries() { let mut client = Client::start(fixture_root()); From ffab6ab3437e4750263119b2fb4813c94595b710 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:01:57 +0200 Subject: [PATCH 038/193] fix(infer): line up calls through exports with the parameters the proxy passes The exports proxy drops the first value of a call, so `exports.res:Fn(a, b)` and `exports.res.Fn(x, a, b)` both pass `a, b`. Registered exports read through `exports.` are now called the way a function declared with `:` is, instead of skipping their first parameter on `:` calls. --- crates/qbx_lua_ls/src/features/arguments.rs | 24 +---- crates/qbx_lua_ls/src/infer.rs | 19 +++- crates/qbx_lua_ls/tests/lsp.rs | 107 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs index 9e4f16c..a213207 100644 --- a/crates/qbx_lua_ls/src/features/arguments.rs +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -205,8 +205,8 @@ pub fn takes_nil(classes: &Classes, param: &Param) -> bool { /// Every function a call of `base`, or of its `method`, may run: each member of that name defined /// on the side of the call, each global of that name its file sees, or else the function the callee -/// holds. The exports proxy drops the receiver of `exports.name:Fn()`, so those calls pass their -/// arguments to the parameters that the function lists. +/// holds. The functions a resource registers with `exports` come as the exports proxy calls them, +/// which drops the receiver of `exports.name:Fn()`. /// /// In an escrowed resource, an encrypted script may define the globals a call reaches differently, /// so a call through a global is left out unless only the runtime stubs define what it reaches, or @@ -223,7 +223,6 @@ pub fn definitions(infer: &Infer, base: &Expr, method: Option<&Name>) -> Vec Vec> { let owner = infer.expr(owner); - let exported = matches!(owner, Type::Exports(Some(_))); let found: Vec<_> = infer .members_named(&owner, name) .into_iter() @@ -232,11 +231,7 @@ pub fn definitions(infer: &Infer, base: &Expr, method: Option<&Name>) -> Vec(infer: &Infer, expr: &'e Expr) -> Option<&'e Name> { } } -/// `fun` as called through the exports proxy, which passes the values after the receiver of a `:` -/// call, and drops the first value of a `.` call: like a function defined with `:`, unless it lists -/// `self` itself. -fn without_receiver(fun: &Arc) -> Arc { - let plain = |fun: &FunType| { - let lists_self = fun.params.first().is_some_and(|param| param.name == "self"); - FunType { is_method: !lists_self, ..fun.clone() } - }; - let mut out = plain(fun); - out.overloads = fun.overloads.iter().map(|overload| Arc::new(plain(overload))).collect(); - Arc::new(out) -} - /// Whether a named type whose name `matches` is part of `ty`, as `T` is of `T[]`. fn mentions(ty: &Type, matches: &impl Fn(&str) -> bool) -> bool { match ty { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index edce433..95f3a87 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -2590,7 +2590,8 @@ impl<'a> Infer<'a> { let declared: FxHashSet = out.iter().map(|m| m.name.clone()).collect(); for (file, symbol) in self.index.exports_of(resource, filter) { if !declared.contains(&symbol.name) { - out.push(member_from_symbol(file, symbol)); + let member = member_from_symbol(file, symbol); + out.push(MemberInfo { ty: called_through_exports(member.ty), ..member }); } } } @@ -2770,6 +2771,22 @@ fn indexed_field(name: &str, ty: Type) -> Option { }) } +/// A function registered with `exports('Name', fn)` as calls through `exports.` see it. +/// The proxy drops the first value of the call, so `exports.res:Fn(a, b)` and `exports.res.Fn(x, a, b)` +/// both pass `a, b`, lined up with the parameters the way a function declared with `:` takes them. +fn called_through_exports(ty: Type) -> Type { + match ty { + Type::Fun(fun) => Type::Fun(proxied(&fun)), + Type::Union(types) => Type::Union(types.into_iter().map(called_through_exports).collect()), + other => other, + } +} + +fn proxied(fun: &FunType) -> Arc { + let overloads = fun.overloads.iter().map(|overload| proxied(overload)).collect(); + Arc::new(FunType { is_method: true, lists_receiver: false, overloads, ..fun.clone() }) +} + fn member_from_symbol(file: FileId, symbol: &crate::index::Symbol) -> MemberInfo { MemberInfo { name: symbol.name.clone(), diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 35a01ce..c3a1561 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -7554,6 +7554,113 @@ fn trigger_calls_show_the_parameters_of_the_handler() { assert!(labels.contains(&"item:") && labels.contains(&"amount:"), "{labels:?}"); } +#[test] +fn calls_through_exports_pass_what_follows_the_first_value() { + fn hint_labels(client: &mut Client, relative: &str) -> Vec { + let hints = client.request( + "textDocument/inlayHint", + json!({ "textDocument": { "uri": client.uri(relative) }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 40, "character": 0 } } }), + ); + hints.as_array().unwrap().iter().filter_map(|hint| hint["label"].as_str().map(str::to_owned)).collect() + } + const MYLIB: &str = "[core]/mylib/server.lua"; + let mut client = Client::start(fixture_root()); + let mylib = client.open(MYLIB); + let registered = " +---@param text string +---@param notifyType string +---@param duration integer +local function Notify(text, notifyType, duration) end +exports('Notify', Notify) + +---@param cb fun(player: integer) +exports('OnReady', function(cb) end) +"; + client.change(MYLIB, 2, &format!("{mylib}{registered}")); + let text = "exports.mylib:Notify('hello', 'error') +exports['mylib'].Notify(nil, 'hello', 'error') +exports.mylib:OnReady(function(player) end) +"; + client.open_with(SERVER, text); + assert_eq!( + hint_labels(&mut client, SERVER), + ["text:", "notifyType:", "text:", "notifyType:"], + "the proxy drops the receiver of a `:` call and the first argument of a `.` call" + ); + + let (l, c) = pos(text, "'error')", 1); + let result = client.request("textDocument/signatureHelp", client.position_params(SERVER, l, c)); + assert_eq!(result["signatures"][0]["label"], "Notify(text: string, notifyType: string, duration: integer)"); + assert_eq!(result["activeParameter"], 1); + let (l, c) = pos(text, "nil, 'hello', 'error'", 15); + let result = client.request("textDocument/signatureHelp", client.position_params(SERVER, l, c)); + assert_eq!(result["activeParameter"], 1); + + let (l, c) = pos(text, "player)", 0); + let player = client.hover_text(SERVER, l, c); + assert!(player.contains("player: integer"), "a function argument takes the type of its parameter: {player}"); + + // Declared types keep lining up as declared: `fun(self, ...)` fields skip `self`, and methods + // declared on `exports.` with `:` do not list it. + let root = declared_exports_root(); + let mut client = Client::start_with_library(root.join("workspace"), &root.join("types")); + let text = "local cid = exports.qbx_core:GetCid(1) +local rang = exports.tablet:Ring(2) +print(cid, rang) +"; + client.open_with("app/server.lua", text); + assert_eq!(hint_labels(&mut client, "app/server.lua"), ["source:", "times:"]); +} + +#[test] +fn arguments_through_exports_go_to_the_parameters_the_proxy_passes() { + const MYLIB: &str = "[core]/mylib/server.lua"; + let mut client = Client::start(fixture_root()); + let mylib = client.open(MYLIB); + let registered = " +---@param self string +---@param count integer +local function Track(self, count) end +exports('Track', Track) +"; + client.change(MYLIB, 2, &format!("{mylib}{registered}")); + let text = "exports.mylib:Track('id', 2) +exports.mylib:Track(5) +exports['mylib'].Track(nil, 'id', 'two') +"; + client.open_with(SERVER, text); + let finding = |needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, SERVER, &["param-type-mismatch"]), + [ + finding("Track(5)", "Cannot assign `integer` to parameter `self` of type `string`"), + finding("'two'", "Cannot assign `string` to parameter `count` of type `integer`"), + ], + "a first parameter named `self` takes the first value the proxy passes, as hover and signature help show" + ); + + let root = declared_exports_root(); + let mut client = Client::start_with_library(root.join("workspace"), &root.join("types")); + let text = "local cid = exports.qbx_core:GetCid('one') +local rang = exports.tablet:Ring('twice') +print(cid, rang) +"; + client.open_with("app/server.lua", text); + let finding = |needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, "app/server.lua", &["param-type-mismatch"]), + [ + finding("GetCid", "Cannot assign `string` to parameter `source` of type `number`"), + finding("Ring", "Cannot assign `string` to parameter `times` of type `number`"), + ], + "declared types line up as declared" + ); +} + #[test] fn publishes_lint_diagnostics_with_resource_context() { let mut client = Client::start(fixture_root()); From 698b3251d7805661904134ec1afed5357b7dff04 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:59:21 +0200 Subject: [PATCH 039/193] docs: say a literal in a local counts by its kind in impossible-comparison --- docs/reference.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index d5699db..f1eb0e5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -652,14 +652,16 @@ A side has a type when something declares it: a literal, an operator such as `no local that is never assigned again has the type of the value it is declared with, so `local state = GetState()` is checked like the call, and inside a [type guard](../crates/qbx_lua_ls/README.md#type-guards) the type the guard narrows it to: the right -side of `class ~= 13 or class ~= 14` only runs when `class` is `13`, so it is always true. +side of `class ~= 13 or class ~= 14` only runs when `class` is `13`, so it is always true. A +literal stored in such a local counts by its kind only: `local mode = 'dev'` is a setting to +change, so `mode == 'prod'` passes, while `mode == false` is still reported as a `string` compared +with `false`. Only clear cases count, and the rest is left alone: - Types that are inferred from assigned values. `Config.Webhook = ''` tells what the config holds - today, not that `Config.Webhook == false` cannot be true once someone edits it, and - `local mode = 'dev'` is a setting like it. The same goes for what an undocumented function - returns, and for the value of `a or b` and `a and b`. + today, not that `Config.Webhook == false` cannot be true once someone edits it. The same goes + for what an undocumented function returns, and for the value of `a or b` and `a and b`. - Comparisons with `nil`. Annotations often leave out the `?` of a value that may be missing, and the check for it is deliberate. - A local that is assigned again after its declaration, since its declared type may not be what From 41d84e8a56e0d6780721c533103c363443349310 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:25:14 +0200 Subject: [PATCH 040/193] fix(rules): read the parameters of callbacks as their callee declares them --- crates/qbx_lua_ls/src/features/arguments.rs | 13 +++-- crates/qbx_lua_ls/src/features/comparisons.rs | 37 ++++++++++-- crates/qbx_lua_ls/src/infer.rs | 37 +++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 57 ++++++++++++++++++- docs/reference.md | 16 ++++-- 5 files changed, 132 insertions(+), 28 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs index a213207..bf90b22 100644 --- a/crates/qbx_lua_ls/src/features/arguments.rs +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -14,8 +14,8 @@ //! leave out the `?` of a parameter that code skips, and neither is `false`, which FiveM code passes //! to skip a parameter of exports and events, whose arguments are serialized. Parameters typed with a //! generic of the function called are left out, as the arguments of the call bind them. The function -//! a callee passes to a callback, such as `resolve` of `fun(resolve: fun(value: T))`, takes what the -//! other arguments of that call declare for its generics, as the `boolean` of +//! a callee passes to a callback, such as `resolve` of `fun(resolve: fun(value: T))`, takes and +//! returns what the other arguments of that call declare for its generics, as the `boolean` of //! `Promise:New('boolean', function(resolve) end)` for a `` `T` ``; a generic they leave unbound //! takes any value. @@ -250,11 +250,14 @@ pub fn definitions(infer: &Infer, base: &Expr, method: Option<&Name>) -> Vec { let declared = Declared::new(infer); - let callback = infer.declared_callback_param(id, |arg| declared.of(arg)); - callback.and_then(|ty| infer.fun_of(&ty)).map_or_else(held, |fun| vec![fun]) + match infer.declared_callback_param(id, |arg| declared.of(arg)) { + Some(callback) => infer.fun_of(&callback).into_iter().collect(), + None => held(), + } } _ => held(), }, diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index 628774f..2311bb9 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -4,10 +4,11 @@ //! `"active"|"busy"|"ready"`. //! //! A side only has a type that something declares: a literal, an operator, an annotation, a stub -//! or a native. What inference reads from assigned values is left out, since `Config.Webhook = ''` -//! tells what is stored today and not that `false` never is. Comparisons with `nil` are left alone -//! as well: annotations often leave out the `?` of a value that may be missing, and the check for -//! it is deliberate. +//! or a native, also the callee of a call for the parameters of a function passed to it. What +//! inference reads from assigned values is left out, since `Config.Webhook = ''` tells what is +//! stored today and not that `false` never is. Comparisons with `nil` are left alone as well: +//! annotations often leave out the `?` of a value that may be missing, and the check for it is +//! deliberate. use std::cell::{OnceCell, RefCell}; @@ -20,7 +21,7 @@ use rustc_hash::FxHashMap; use super::class_tables::{Classes, Key}; use super::unknown_types::has_param_line; -use crate::infer::{Decl, Infer}; +use crate::infer::{Decl, Expected, Infer}; use crate::luacats::CastEntry; use crate::types::Type; @@ -174,12 +175,21 @@ impl<'a, 'b> Declared<'a, 'b> { match self.infer.ctx.decl(local.decl.start) { Some(Decl::Local { stmt, index }) => self.local_value(id, stmt, *index, depth), Some(Decl::Param { .. }) if has_param_line(self.infer, local) => self.infer.local_type(id), + Some(Decl::Param { expected: Some(Expected::Arg { .. }), .. }) => self.callback_param(id, depth), Some(Decl::LocalFunction { .. } | Decl::SelfParam { .. }) => self.infer.local_type(id), Some(Decl::NumericFor) => Type::Number, _ => Type::Unknown, } } + /// What the callee of a call declares for the parameter `id` of a function passed to it, with the + /// generics bound from what the other arguments of the call declare: `value` of + /// `onValue('Player', function(value) end)` is a `Player` for `fun(value: T)` and a `` `T` ``. + fn callback_param(&self, id: LocalId, depth: u32) -> Type { + let declared = |arg: &Expr| self.declared(arg, depth + 1); + self.infer.declared_callback_param(id, declared).unwrap_or_default() + } + /// Whether a `---@type` above its declaration or a `@param` line types the local. fn is_annotated(&self, local: &Local) -> bool { match self.infer.ctx.decl(local.decl.start) { @@ -245,7 +255,7 @@ impl<'a, 'b> Declared<'a, 'b> { /// What `call` returns when its function declares it. A native documented as `boolean` can /// give scripts `1` instead of `true`, so code compares its result with either. fn returned(&self, call: &Expr) -> Option> { - let values = self.infer.declared_returns(call)?; + let values = self.infer.declared_returns(call).or_else(|| self.callback_returns(call))?; if !self.is_native_call(call) { return Some(values); } @@ -256,6 +266,21 @@ impl<'a, 'b> Declared<'a, 'b> { Some(values.into_iter().map(as_returned).collect()) } + /// What a call of a parameter returns when the callee of its function declares the function it + /// holds: `get()` gives a `boolean` for `get` of `fun(get: fun(): T)` with `T` bound to one. The + /// generics that the other arguments of that call declare bind values that are declared too. + fn callback_returns(&self, call: &Expr) -> Option> { + let ExprKind::Call { callee, args, .. } = &call.kind else { return None }; + let ExprKind::Name(name) = &callee.unparen().kind else { return None }; + let Some(Resolved::Local(id)) = self.infer.ctx.resolution.resolve_at(name.span.start) else { return None }; + if !matches!(self.infer.ctx.decl(self.infer.ctx.resolution.local(id).decl.start), Some(Decl::Param { .. })) { + return None; + } + let fun = self.infer.fun_of(&self.declaration(id))?; + let fun = self.infer.call_signature(&fun, args, false, call.span.start); + fun.generics.is_empty().then(|| fun.returns.clone()) + } + fn is_native_call(&self, call: &Expr) -> bool { matches!(&call.kind, ExprKind::Call { callee, .. } if self.is_native(callee, 0)) } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 95f3a87..002de72 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1027,8 +1027,9 @@ impl<'a> Infer<'a> { /// the generics of the callee bound from the types `declared` gives its other arguments: `resolve` /// of `fun(resolve: fun(value: T))` takes a `boolean` for `promise('boolean', ...)` whose first /// parameter is a `` `T` ``. A generic they leave unbound is unknown: the values the call passes - /// bind it otherwise, which tells nothing declared. `None` when a `@param` line types the - /// parameter. + /// bind it otherwise, which tells nothing declared. So is the parameter when the signatures that + /// the call fits equally well, such as two `@overload`s for the same action, type it differently. + /// `None` when a `@param` line types the parameter. pub fn declared_callback_param(&self, id: LocalId, declared: impl Fn(&Expr) -> Type) -> Option { let local = self.ctx.resolution.local(id); let Some(Decl::Param { func, index, doc_anchor, expected: Some(Expected::Arg { call, arg_index }) }) = @@ -1043,13 +1044,31 @@ impl<'a> Infer<'a> { if (*index < 2 && self.on_cache_param(call).is_some()) || self.triggered_returns(call, *arg_index).is_some() { return None; } - let (callee, args, via_method) = self.call_parts(call)?; - let (skip_params, skip_args) = callee.call_offsets(via_method); - let callback = self.fun_of(&callee.params.get((arg_index + skip_params).checked_sub(skip_args)?)?.ty)?; - let param = callback.params.get(*index)?; - let args = CallArgs::typed(args.exprs, declared); - let ty = substitute(¶m.ty, &self.bind_generics(&callee, &args, via_method, false)); - Some(if param.optional { ty.optional() } else { ty }) + let (base, method, exprs) = match &call.kind { + ExprKind::Call { callee, args, .. } => (callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => (base, Some(method), args), + _ => return None, + }; + let via_method = method.is_some(); + let (fun, _) = self.callee_fun(base, method)?; + let signatures = self.signatures_at(&fun, call.span.start); + let inferred = CallArgs::new(exprs); + let fits: Vec = signatures.iter().map(|signature| self.fit(signature, &inferred, via_method)).collect(); + let best = fits.iter().max().copied()?; + let args = CallArgs::typed(exprs, declared); + let mut types = signatures.iter().zip(&fits).filter(|(_, fit)| **fit == best).map(|(callee, _)| { + let (skip_params, skip_args) = callee.call_offsets(via_method); + let callback = self.fun_of(&callee.params.get((arg_index + skip_params).checked_sub(skip_args)?)?.ty)?; + let param = callback.params.get(*index)?; + let ty = substitute(¶m.ty, &self.bind_generics(callee, &args, via_method, false)); + Some(if param.optional { ty.optional() } else { ty }) + }); + let first = types.next()?; + if types.all(|ty| ty == first) { + first + } else { + Some(Type::Unknown) + } } /// `TriggerCallback('name', function(response) end)` receives what the handler of `name` diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index c3a1561..f902160 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4718,9 +4718,62 @@ watch(Settings.Count, function(set) set(1) end) [ finding("reject(5)", "Cannot assign `integer` to parameter `reason` of type `string`"), finding("set(1)\n", "Cannot assign `integer` to parameter `value` of type `boolean`"), + finding("needsNumber(get())", "Cannot assign `boolean` to parameter `n` of type `number`"), ], - "a function the callee passes takes what the other arguments declare for its generics; a generic \ - bound from an inferred value takes any value, and what such a function returns passes" + "a function the callee passes takes and returns what the other arguments declare for its generics; \ + a generic bound from an inferred value takes any value" + ); +} + +#[test] +fn parameters_of_callbacks_have_the_types_their_callee_declares() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Player +---@field name string +---@param n number +local function needsNumber(n) end +---@generic T +---@param kind `T` +---@param cb fun(value: T) +local function onValue(kind, cb) end +---@param cb fun(value: string) +local function onString(cb) end +---@param action string +---@param handler fun(...) +---@overload fun(action: 'updated', handler: fun(player: Probe.Player)) +---@overload fun(action: 'updated', handler: fun(source: number, player: Probe.Player)) +local function onAction(action, handler) end +Settings = { Kind = 'Probe.Player' } + +onValue('Probe.Player', function(value) needsNumber(value) end) +onValue('boolean', function(value) needsNumber(value) end) +onValue(Settings.Kind, function(value) needsNumber(value) end) +onString(function(value) needsNumber(value) end) +---@param value number +onString(function(value) needsNumber(value) end) +onString(function(value) + if value == 5 then print(value) end +end) +onAction('updated', function(source) needsNumber(source) end) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |code: &str, needle: &str, message: &str| (code.to_string(), line(needle), message.to_string()); + let mismatch = |needle: &str, given: &str| { + let message = format!("Cannot assign `{given}` to parameter `n` of type `number`"); + finding("param-type-mismatch", needle, &message) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch", "impossible-comparison"]), + [ + mismatch("onValue('Probe.Player'", "Probe.Player"), + mismatch("onValue('boolean'", "boolean"), + mismatch("onString(function(value) needs", "string"), + finding("impossible-comparison", "value == 5", "Comparing `string` with `5` is always false"), + ], + "a parameter takes what the callee declares for it, with the generics the other arguments declare, \ + unless a `@param` line above the call types it or overloads that fit as well type it differently" ); } diff --git a/docs/reference.md b/docs/reference.md index f1eb0e5..5258c5d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -359,9 +359,9 @@ change, so it passes for `"fast" | "slow"`, while `local count = 5` is still no - Types inferred from assigned values, such as that of `Config.Value = ''`. - Parameters typed with a generic of the function called: the arguments of the call bind it, which declares nothing. A function that a callee passes to a callback, such as `resolve` of - `fun(resolve: fun(value: T))`, takes what the other arguments of that call declare for the - generic, as the `boolean` of `Promise:New('boolean', function(resolve) end)` for a `` `T` ``, and - any value for a generic they leave unbound. The values such a function returns are left out. + `fun(resolve: fun(value: T))`, takes and returns what the other arguments of that call declare + for the generic, as the `boolean` of `Promise:New('boolean', function(resolve) end)` for a + `` `T` ``, and any value for a generic they leave unbound. - Parameters typed with the name of a native handle such as `Vehicle`, which resources also declare as classes. - Calls through globals in an opaque resource, such as an escrowed one; see @@ -649,6 +649,9 @@ if type(value) == "tabel" then end -- Comparing `lua_type` with `"tabel"` A side has a type when something declares it: a literal, an operator such as `not` or `..`, a `@param`, `@type` or `@return`, a `@field` of a class, an alias or enum, a stub or a native. A +parameter of a function passed to a call has the type the callee declares for it, with the generics +that the other arguments declare: `value` is a `Player` in +`onValue('Player', function(value) end)` for `---@param cb fun(value: T)` and a `` `T` ``. A local that is never assigned again has the type of the value it is declared with, so `local state = GetState()` is checked like the call, and inside a [type guard](../crates/qbx_lua_ls/README.md#type-guards) the type the guard narrows it to: the right @@ -726,9 +729,10 @@ print(name:upper()) -- `name` may be nil: its type here is `string?` print(name:lower()) -- the read above raises the error first ``` -A local may hold `nil` when its declared type allows it: its `---@type` or `@param`, or the -`@return` or `@field` of the function or class its value comes from, whoever declares them, -stubs and other resources included. `false`, as in `false|string`, counts as well. The +A local may hold `nil` when its declared type allows it: its `---@type` or `@param`, the +`@return` or `@field` of the function or class its value comes from, or for a parameter of a +function passed to a call, what the callee declares for it, whoever declares them, stubs and other +resources included. `false`, as in `false|string`, counts as well. The [type guards](../crates/qbx_lua_ls/README.md#type-guards) and casts around the read narrow the type first, so `if not name then return end`, `if name then`, `name and name:upper()`, `assert(name)` and `---@cast name -?` all check it, as does a condition that reads from it, such as From dccbeaa486d3d0d8e2c8e909cf269f9a5975bea7 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:25:15 +0200 Subject: [PATCH 041/193] test(lsp): drop a leftover assertion message --- crates/qbx_lua_ls/tests/lsp.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index f902160..e38caf1 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2828,11 +2828,7 @@ print(abc, make, nested, untyped) items }; let field = |name: &str, ty: &str| (name.to_string(), ty.to_string()); - assert_eq!( - fields(cursors[0]), - [field("count", "integer?"), field("inner", "Test.Inner"), field("test", "string")], - "your example" - ); + assert_eq!(fields(cursors[0]), [field("count", "integer?"), field("inner", "Test.Inner"), field("test", "string")]); assert_eq!(fields(cursors[1]), [field("count", "integer?"), field("inner", "Test.Inner")], "`test` is set already"); assert_eq!(fields(cursors[2]), [field("label", "string")], "a field typed as a class"); assert!(fields(cursors[3]).is_empty(), "`extra` is no field of Test, so its table is not typed"); From 75b96a0b04cd5a50081d8f184acd7431c9653c4b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:38:37 +0200 Subject: [PATCH 042/193] fix(rules): let a class index of string literals take only those keys --- .../qbx_lua_ls/src/features/class_tables.rs | 20 ++++++++- crates/qbx_lua_ls/tests/lsp.rs | 41 +++++++++++++++++++ docs/reference.md | 9 ++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index d8f990f..c3aa184 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -456,9 +456,25 @@ impl<'a, 'b> Classes<'a, 'b> { Some((value.clone(), from)) } - /// Whether an index keyed by `index_key`, as `from` sees it, takes keys of type `key`. + /// Whether an index keyed by `index_key`, as `from` sees it, takes keys of type `key`: one of a + /// kind it takes, and when both are literals, the same. An index of `'a'|'b'`, or of an alias of + /// them, takes each of the two. fn takes(&self, index_key: &Type, from: FileId, key: &Type) -> bool { - match (self.kinds(index_key, from, 0), self.kinds(key, self.file(), 0)) { + self.takes_at(index_key, from, key, 0) + } + + fn takes_at(&self, index_key: &Type, from: FileId, key: &Type, depth: u32) -> bool { + if depth > MAX_DEPTH { + return true; + } + let (index_key, key) = (self.resolve(index_key, from, depth), self.resolve(key, self.file(), depth)); + if let Type::Union(parts) = &index_key { + return parts.iter().any(|part| self.takes_at(part, from, &key, depth + 1)); + } + if index_key.is_literal() && key.is_literal() { + return index_key == key; + } + match (self.kinds(&index_key, from, 0), self.kinds(&key, self.file(), 0)) { (Some(wanted), Some(got)) => wanted & got != 0, _ => true, } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index e38caf1..42feadf 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4300,6 +4300,47 @@ for i = 1, 2 do print(tuple[i]) end ); } +#[test] +fn strict_classes_take_only_the_keys_a_literal_index_lists() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Keys 'a'|'b' + +---@class (exact) Test.Keyed +---@field [Test.Keys] integer + +---@class (exact) Test.Listed +---@field ['x'|'y'] string + +---@type Test.Keyed +local keyed = { a = 1, c = 2 } +keyed.b = 'no' +keyed.d = 3 +print(keyed.a, keyed.e) + +---@type Test.Listed +local listed = { x = 'a', z = 'b' } +local name = 'y' +print(listed[name]) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |code: &str, needle: &str, message: &str| (code.to_string(), line(needle), message.to_string()); + let mismatch = "assign-type-mismatch"; + let undeclared = "undeclared-field"; + assert_eq!( + findings(&mut client, CLIENT, &[mismatch, undeclared]), + [ + finding(undeclared, "c = 2", "Field `c` is not declared in strict class `Test.Keyed`"), + finding(mismatch, "keyed.b", "Cannot assign `string` to field `b` of type `integer`"), + finding(undeclared, "keyed.d", "Field `d` is not declared in strict class `Test.Keyed`"), + finding(undeclared, "keyed.e", "Field `e` is not declared in strict class `Test.Keyed`"), + finding(undeclared, "z = 'b'", "Field `z` is not declared in strict class `Test.Listed`"), + ], + "an index of string literals, written out or through an alias, takes only those names" + ); +} + #[test] fn values_assigned_to_typed_variables_are_checked() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 5258c5d..a82c19c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -393,10 +393,11 @@ such as a `---@type` local, a parameter or `self`, and for fields that code read `abc.nope` or `abc:nope()`. A field counts as declared when an `@field` of the class or one of its parents names it, or when it is set on the table the `---@class` annotation declares, like `greet` above; fields set through values of the class do not count. A `[string]` index, such as -`---@field [string] any`, takes any name. A table type named as a parent, like `{ id: integer }` or -`table`, declares its fields and indices for the class, so `Name : table` -takes any name too, while a parent that is neither a class nor such a table type, like a plain -`table`, takes any key. +`---@field [string] any`, takes any name, and an index of string literals, such as +`---@field ['a'|'b'] integer` or an alias of them, takes those names. A table type named as a +parent, like `{ id: integer }` or `table`, declares its fields and indices for the +class, so `Name : table` takes any name too, while a parent that is neither a class +nor such a table type, like a plain `table`, takes any key. A table that `setmetatable` gives a metatable whose `__index` is the class table, as `setmetatable(obj, Test)` does after `Test.__index = Test`, is a value of the class from the call From 02d13daa8291cc965a04e77f2ad54a54044ed0d4 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:45:42 +0200 Subject: [PATCH 043/193] fix(rules): check calls of locals and fields typed as functions with ---@type --- crates/qbx_lua_analysis/src/checks/arity.rs | 66 ++++++++++++++++++--- crates/qbx_lua_analysis/tests/rules.rs | 33 +++++++++++ docs/reference.md | 2 + 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/arity.rs b/crates/qbx_lua_analysis/src/checks/arity.rs index f2f20b8..734467c 100644 --- a/crates/qbx_lua_analysis/src/checks/arity.rs +++ b/crates/qbx_lua_analysis/src/checks/arity.rs @@ -3,12 +3,13 @@ use std::sync::Arc; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::{SmolStr, Span}; -use qbx_luacats::luacats::applies_on; -use qbx_luacats::types::FunType; +use qbx_luacats::luacats::{applies_on, parse_doc_lines}; +use qbx_luacats::types::{FunType, Type}; use rustc_hash::FxHashMap; use super::crossfile::{passed_count, plural}; use super::{FileInput, Sink}; +use crate::env::leading_doc_lines; use crate::project::ResourceEnv; use crate::rules; use crate::scope::{LocalId, LocalKind, Resolved}; @@ -18,6 +19,12 @@ use crate::signature::{defined, global_key, member_path, most_arguments, undocum /// Every value assigned to a local or to a field of a local table, by local and dotted field path. type LocalDefs = FxHashMap<(LocalId, SmolStr), Vec>>>; +/// The function type that a `---@type` gives a local or a field of a local table, by local and +/// dotted field path. It holds whatever value is assigned, as in LuaLS. +type DeclaredDefs = FxHashMap<(LocalId, SmolStr), Arc>; + +const MAX_ALIAS_DEPTH: u8 = 8; + pub(super) fn check(input: &FileInput, sink: &mut Sink) { if sink.enabled(rules::MISSING_PARAMETER).is_none() && sink.enabled(rules::REDUNDANT_PARAMETER).is_none() { return; @@ -32,16 +39,24 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { &own_env } }; - let mut collector = LocalCollector { input, defs: LocalDefs::default() }; + let mut collector = LocalCollector { input, env, defs: LocalDefs::default(), declared: DeclaredDefs::default() }; collector.visit_block(&input.chunk.block); - let mut calls = - Calls { input, env, locals: collector.defs, regions: SideRegions::of(input.source, input.chunk), sink }; + let mut calls = Calls { + input, + env, + locals: collector.defs, + declared: collector.declared, + regions: SideRegions::of(input.source, input.chunk), + sink, + }; calls.visit_block(&input.chunk.block); } struct LocalCollector<'i, 'a> { input: &'i FileInput<'a>, + env: &'i ResourceEnv, defs: LocalDefs, + declared: DeclaredDefs, } impl LocalCollector<'_, '_> { @@ -56,6 +71,20 @@ impl LocalCollector<'_, '_> { self.defs.entry((id, SmolStr::new(fields.join(".")))).or_default().push(signature); } + /// Records the function type that the `---@type` above `stmt` gives its name at `index`, when + /// it gives one. + fn declare(&mut self, stmt: &Stmt, index: usize, id: LocalId, fields: &[&str]) { + let lines = leading_doc_lines(self.input.source, &self.input.chunk.comments, stmt.span.start); + if !lines.iter().any(|line| line.contains("@type")) { + return; + } + let doc = parse_doc_lines(&lines); + let alias = |name: &str| self.env.alias(name); + if let Some(fun) = doc.type_at(index).and_then(|ty| function_type(ty, &alias, 0)) { + self.declared.insert((id, SmolStr::new(fields.join("."))), fun); + } + } + /// The signature of a function `value`, and `None` for other values, which may be any function. fn signature(&self, stmt: &Stmt, value: Option<&Expr>, single: bool) -> Option> { match value.map(|e| &e.kind) { @@ -82,6 +111,7 @@ impl<'ast> Visitor<'ast> for LocalCollector<'_, '_> { StmtKind::Local { names, exprs, .. } => { for (index, name) in names.iter().enumerate() { let Some(id) = self.local(&name.name) else { continue }; + self.declare(stmt, index, id, &[]); // `local f` holds nil until it is assigned, which the assignment records. if index >= exprs.len() && !exprs.last().is_some_and(Expr::is_multi_value) { continue; @@ -107,6 +137,7 @@ impl<'ast> Visitor<'ast> for LocalCollector<'_, '_> { for (index, target) in targets.iter().enumerate() { let Some((root, fields)) = member_path(target) else { continue }; if let Some(id) = self.local(root) { + self.declare(stmt, index, id, &fields); let signature = self.signature(stmt, exprs.get(index), targets.len() == 1); self.add(id, &fields, signature); } @@ -129,6 +160,7 @@ struct Calls<'i, 'a, 's> { input: &'i FileInput<'a>, env: &'i ResourceEnv, locals: LocalDefs, + declared: DeclaredDefs, regions: SideRegions, sink: &'i mut Sink<'s>, } @@ -178,9 +210,11 @@ impl Calls<'_, '_, '_> { if !matches!(kind, LocalKind::Local | LocalKind::LocalFunction) { return; } - match self.locals.get(&(id, SmolStr::new(fields.join(".")))) { - Some(defs) => defs.clone(), - None => return, + let key = (id, SmolStr::new(fields.join("."))); + match (self.declared.get(&key), self.locals.get(&key)) { + (Some(declared), _) => vec![Some(declared.clone())], + (None, Some(defs)) => defs.clone(), + (None, None) => return, } } // An encrypted script of the resource may define the global differently. @@ -254,3 +288,19 @@ impl Calls<'_, '_, '_> { ); } } + +/// The function type `ty` names, through aliases and the `nil` of `fun()?`. A union of several +/// function types names none. +fn function_type<'t>(ty: &Type, alias: &impl Fn(&str) -> Option<&'t Type>, depth: u8) -> Option> { + match ty { + Type::Fun(fun) => Some(fun.clone()), + Type::Union(types) => match types.iter().filter(|t| !matches!(t, Type::Nil)).collect::>()[..] { + [only] => function_type(only, alias, depth), + _ => None, + }, + Type::Named(name, args) if args.is_empty() && depth < MAX_ALIAS_DEPTH => { + function_type(alias(name)?, alias, depth + 1) + } + _ => None, + } +} diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 477f001..4ea22a6 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -596,6 +596,39 @@ fn documented_parameters_need_an_argument() { } } +#[test] +fn locals_typed_as_functions_need_their_arguments() { + let missing = |source: &str| missing_parameters(source, None, &[]); + let handler = "---@alias Handler fun(a: integer, b: integer)\n"; + let needs_b = "is called with 1 argument, but needs 2; 'b' (integer) will be nil"; + for (source, called) in [ + ("---@type fun(a: integer, b: integer)\nlocal f = SomeGlobal\nf(1)", "f"), + (&format!("{handler}---@type Handler\nlocal f = function(a, b) end\nf(1)"), "f"), + (&format!("{handler}---@type Handler?\nlocal f\nf(1)"), "f"), + (&format!("{handler}---@type Handler|nil\nlocal f\nf = SomeGlobal\nf(1)"), "f"), + ("local M = {}\n---@type fun(a: integer, b: integer)\nM.cb = nil\nM.cb(1)", "M.cb"), + ] { + assert_eq!(missing(source), [format!("'{called}' {needs_b}")], "{source}"); + } + assert_eq!( + missing("---@type fun(a: integer, b?: integer)\nlocal f\nf()"), + ["'f' is called with 0 arguments, but needs 1; 'a' (integer) will be nil"] + ); + for source in [ + "---@type fun(a?: integer)\nlocal f\nf()", + "---@type fun(a: integer)|fun()\nlocal f\nf()", + "---@type table\nlocal f = {}\nf()", + "---@alias Unknown Missing\n---@type Unknown\nlocal f\nf()", + ] { + assert_eq!(missing(source), Vec::::new(), "{source}"); + } + assert_eq!( + redundant_parameters("---@type fun(a: integer)\nlocal f = function(...) end\nf(1, 2)", None, &[]), + ["'f' is called with 2 arguments, but takes at most 1"], + "the declared type holds over the value it is given" + ); +} + #[test] fn methods_count_self_the_way_they_are_called() { let missing = |call: &str| { diff --git a/docs/reference.md b/docs/reference.md index a82c19c..779109b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -289,6 +289,8 @@ functions are kept as they are. Calls are checked when the function is: - a local function, or a local that is only ever assigned functions; +- a local, or a field of a local table, that a `---@type fun(...)` above its declaration or an + assignment types, also through an `@alias` or with `?`, whatever value it is given; - a global function defined by a script on the caller's side, or by one of its imports; - a field of a global table, or of a local table in the same file, that a `function Utils.round(x)` or `function Utils:round(x)` statement or an assignment defines. From 5642716d42b7c57848aeb4db48150b12127d396b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:01:45 +0200 Subject: [PATCH 044/193] feat(rules): check the entries of tables typed as shapes, arrays and table --- .../qbx_lua_ls/src/features/class_tables.rs | 130 +++++++++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 71 ++++++++++ docs/reference.md | 10 ++ docs/rules.md | 2 +- 4 files changed, 195 insertions(+), 18 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index c3aa184..5af6687 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -2,8 +2,10 @@ //! and assignments, arguments to class-typed parameters, `return` in a function documented with //! `@return`, and the tables such fields hold. With the fields that code sets on and reads from //! values of a class, they drive `missing-fields`, `assign-type-mismatch`, `undeclared-field` and -//! the completion of field names. Only the language server knows the classes, so qbx-lint -//! registers the rules and this module reports them. +//! the completion of field names. Table constructors typed as a table type, like a shape +//! `{ value: string }`, `string[]` or `table`, are found in the same places for +//! `assign-type-mismatch`. Only the language server knows the classes, so qbx-lint registers the +//! rules and this module reports them. //! //! Type names are global, and resources may declare the same one differently: ox_fuel's //! `@class State` is not qbx_vehicles' `@enum State`. A name is looked up the way the file that @@ -17,6 +19,7 @@ use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::{NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; +use super::comparisons::Declared; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; use crate::infer::{bound_parents, class_bindings, expanded_bindings, substitute, Infer}; use crate::luacats::applies_on; @@ -217,6 +220,29 @@ impl<'a, 'b> Classes<'a, 'b> { } } + /// The table type a value of type `ty` must be, as `from` sees the names, looking through `?` + /// and aliases: a shape like `{ value: string }`, an array like `string[]`, or a + /// `table`. + pub fn table_type_of(&self, ty: &Type, from: FileId) -> Option { + match self.resolve(&ty.without_nil(), from, 0).without_nil() { + ty @ (Type::Shape(_) | Type::Array(_) | Type::Map(..)) => Some(ty), + _ => None, + } + } + + /// The type that `key` of a table of the table type `ty`, as `from` sees it, holds: the field + /// of a shape that a name names, or else the value of its array part or of an index that takes + /// the key. + pub fn table_field_type(&self, ty: &Type, from: FileId, key: &Key) -> Option<(Type, FileId)> { + if let (Type::Shape(shape), Key::Name(name)) = (ty, key) { + if let Some(field) = shape.fields.iter().find(|field| field.name == *name) { + let ty = if field.optional { field.ty.clone().optional() } else { field.ty.clone() }; + return Some((ty, from)); + } + } + self.table_index_for(ty, from, &key.ty()) + } + /// The `@field`s of `class`, given the type arguments `args`, and of its parents, with the fields /// of a table type it names as a parent, leaving out those scoped to the other side. The first /// declaration of a name wins, so a class can narrow a field of its parent. A class that several @@ -642,11 +668,36 @@ pub fn class_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> function_returns: Vec::new(), documented: FxHashMap::default(), out: Vec::new(), + table_types: None, }; finder.visit_block(&chunk.block); finder.out } +/// A table constructor typed as a table type rather than a class: a shape like `{ value: string }`, +/// an array like `string[]`, or a `table`, also through an alias such as `Box`. +pub struct TypedTable<'c> { + /// The table type, with the aliases that name it resolved. + pub ty: Type, + pub table: &'c Expr, + /// The file whose view of the type names decided the type. + pub from: FileId, +} + +/// The table constructors of `chunk` typed as a class, as `class_tables` finds them, together with +/// those typed as a table type, and the class-typed ones that tables of such types hold. +fn typed_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> (Vec>, Vec>) { + let mut finder = Finder { + classes: Classes::new(infer), + function_returns: Vec::new(), + documented: FxHashMap::default(), + out: Vec::new(), + table_types: Some((Declared::keeping_annotations(infer), Vec::new())), + }; + finder.visit_block(&chunk.block); + (finder.out, finder.table_types.map(|(_, typed)| typed).unwrap_or_default()) +} + /// A field set or read outside a table constructor. pub struct Access<'c> { /// The type of the value whose field it is. @@ -800,15 +851,16 @@ pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { .collect() } -/// Each value that a class-typed table constructor or an assignment stores in a field of a class -/// that does not take it, such as `test = 1` for `---@field test string` or `other = true` for -/// `---@field [string] number`, with the message naming both types. Only clear cases count: a -/// different kind of value, or a literal the field does not list. +/// Each value that a typed table constructor or an assignment stores in a field that does not take +/// it, such as `test = 1` for `---@field test string` or `other = true` for `---@field [string] +/// number`, with the message naming both types. Tables typed as a shape, an array or a +/// `table` are checked the same way, but only classes are checked in assignments. Only clear +/// cases count: a different kind of value, or a literal the field does not list. pub fn mismatched_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let classes = Classes::new(infer); let mut out = Vec::new(); - let mut check = |class: &str, args: &[Type], from: FileId, key: &Key, value: &Expr| { - let Some((ty, file)) = classes.field_type(class, args, from, key) else { return }; + let mut check = |field: Option<(Type, FileId)>, key: &Key, value: &Expr| { + let Some((ty, file)) = field else { return }; let given = classes.value_type(value); if classes.rejects(&ty, file, &given) { let shown = if classes.literal_mismatch(&ty, file, &given) { given } else { given.widen() }; @@ -819,18 +871,27 @@ pub fn mismatched_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { out.push((value.span, format!("Cannot assign `{shown}` to {target} of type `{ty}`"))); } }; - for found in class_tables(infer, chunk) { + let (class_tables, typed_tables) = typed_tables(infer, chunk); + for found in class_tables { + let ExprKind::Table(fields) = &found.table.kind else { continue }; + for (key, _, value) in entries(infer, fields) { + if let Some(value) = value { + check(classes.field_type(&found.class, &found.args, found.from, &key), &key, value); + } + } + } + for found in typed_tables { let ExprKind::Table(fields) = &found.table.kind else { continue }; for (key, _, value) in entries(infer, fields) { if let Some(value) = value { - check(&found.class, &found.args, found.from, &key, value); + check(classes.table_field_type(&found.ty, found.from, &key), &key, value); } } } for access in accesses(infer, chunk, false) { // `value.field = nil` clears a field only when its type allows `nil`, as `string?` does. if let (Some(value), Some((class, args))) = (access.value, classes.class_of(&access.owner, classes.file())) { - check(&class, &args, classes.file(), &access.key, value); + check(classes.field_type(&class, &args, classes.file(), &access.key), &access.key, value); } } out @@ -844,30 +905,65 @@ struct Finder<'a, 'b, 'c> { /// parameter list. documented: FxHashMap>, out: Vec>, + /// When tables typed as table types are looked for too, the reader of declared types that + /// assignments without a `---@type` are typed by, and the tables found. + table_types: Option<(Declared<'a, 'b>, Vec>)>, } impl<'c> Finder<'_, '_, 'c> { /// Records `expr` when it is a table constructor and `expected`, named as `from` sees it, is a - /// class, then the constructors it holds for fields that are classes too. + /// class, or a table type when those are looked for, then the constructors it holds for fields + /// of such types. fn table(&mut self, expected: &Type, expr: &'c Expr, from: FileId, depth: u32) { let table = expr.unparen(); let ExprKind::Table(fields) = &table.kind else { return }; - let Some((class, args)) = self.classes.class_of(expected, from) else { return }; - self.out.push(ClassTable { class: class.clone(), args: args.clone(), table, from }); + if let Some((class, args)) = self.classes.class_of(expected, from) { + self.out.push(ClassTable { class: class.clone(), args: args.clone(), table, from }); + if depth < MAX_DEPTH { + for (key, _, value) in entries(self.classes.infer, fields) { + let Some(value) = value.filter(|value| is_table(value)) else { continue }; + if let Some((ty, file)) = self.classes.field_type(&class, &args, from, &key) { + self.table(&ty, value, file, depth + 1); + } + } + } + return; + } + if self.table_types.is_none() { + return; + } + let Some(ty) = self.classes.table_type_of(expected, from) else { return }; if depth < MAX_DEPTH { for (key, _, value) in entries(self.classes.infer, fields) { let Some(value) = value.filter(|value| is_table(value)) else { continue }; - if let Some((ty, file)) = self.classes.field_type(&class, &args, from, &key) { - self.table(&ty, value, file, depth + 1); + if let Some((field, file)) = self.classes.table_field_type(&ty, from, &key) { + self.table(&field, value, file, depth + 1); } } } + if let Some((_, typed)) = &mut self.table_types { + typed.push(TypedTable { ty, table, from }); + } } /// A table this file's code builds, typed by a name this file sees. fn top_table(&mut self, expected: &Type, expr: &'c Expr) { self.table(expected, expr, self.classes.file(), 0); } + + /// The type that an assignment to `target` without a `---@type` gives a table: that of a + /// class the target holds, or else the table type it is declared with, as its own `---@type` + /// or `@param` declares it. What the target was assigned before only tells what it held then. + fn assigned_type(&self, target: &Expr) -> Type { + let ty = self.classes.infer.expr(target); + if self.classes.class_of(&ty, self.classes.file()).is_some() { + return ty; + } + match &self.table_types { + Some((declared, _)) => declared.of(target), + None => ty, + } + } } impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { @@ -896,7 +992,7 @@ impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { let values = targets.iter().zip(exprs).enumerate(); for (index, (target, expr)) in values.filter(|(_, (_, expr))| is_table(expr)) { let declared = doc.type_at(index).cloned(); - let expected = declared.unwrap_or_else(|| self.classes.infer.expr(target)); + let expected = declared.unwrap_or_else(|| self.assigned_type(target)); self.top_table(&expected, expr); } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 42feadf..9d0e9ff 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4341,6 +4341,77 @@ print(listed[name]) ); } +#[test] +fn tables_typed_as_table_types_check_their_fields() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Box { value: T } +---@class Test.Named +---@field name string + +---@type { value: string, count?: integer } +local shape = { value = 1, count = 'x', extra = true } +---@type Test.Box +local box = { value = 1 } +---@type Test.Box +local open = { value = 1 } +---@type { inner: { n: integer }, named: Test.Named } +local nested = { inner = { n = 'x' }, named = { name = 1 } } +---@type string[] +local list = { 'a', 2 } +---@type table +local map = { a = 'x', [1] = 'y' } +---@type { [1]: string, [2]: integer } +local pair = { 1, 'x' } +---@type { mode: 'a'|'b' }? +local mode = { mode = 'c' } +---@type { value: string }|{ value: integer } +local either = { value = true } + +---@param options { id: integer } +local function use(options) end +use({ id = 'x' }) + +---@return { id: integer } +local function make() return { id = 'x' } end + +---@type { value: string } +local later +later = { value = 1 } +local inferred = { value = 1 } +inferred = { value = 'x' } +print(shape, box, open, nested, list, map, pair, mode, either, make, later, inferred) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("assign-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + let mut found = findings(&mut client, CLIENT, &["assign-type-mismatch"]); + found.sort(); + let mut expected = vec![ + finding("value = 1, count", "Cannot assign `integer` to field `value` of type `string`"), + finding("count = 'x'", "Cannot assign `string` to field `count` of type `integer?`"), + finding("local box = { value = 1 }", "Cannot assign `integer` to field `value` of type `string`"), + finding("n = 'x'", "Cannot assign `string` to field `n` of type `integer`"), + finding("name = 1", "Cannot assign `integer` to field `name` of type `string`"), + finding("'a', 2", "Cannot assign `integer` to field `[2]` of type `string`"), + finding("a = 'x'", "Cannot assign `string` to field `a` of type `integer`"), + finding("1, 'x'", "Cannot assign `integer` to field `[1]` of type `string`"), + finding("1, 'x'", "Cannot assign `string` to field `[2]` of type `integer`"), + finding("mode = 'c'", "Cannot assign `\"c\"` to field `mode` of type `\"a\"|\"b\"`"), + finding("use({ id", "Cannot assign `string` to field `id` of type `integer`"), + finding("make() return { id", "Cannot assign `string` to field `id` of type `integer`"), + finding("later = {", "Cannot assign `integer` to field `value` of type `string`"), + ]; + expected.sort(); + assert_eq!( + found, expected, + "shapes, aliases of them, arrays and `table` check what their tables hold, and the class tables \ + they hold; an alias without its type argument, a union of table types and a table that only replaces \ + an inferred one are not checked" + ); +} + #[test] fn values_assigned_to_typed_variables_are_checked() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 779109b..2431e56 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -605,6 +605,16 @@ it to `T` as in lua-language-server, which silences a check that the code knows `local count = GetValue() --[[@as integer]]` stores an `integer`. On a call, the cast types its first value. +A table constructor typed as a table type rather than a class is checked like a class table, by +the type of each entry: a shape such as `{ value: string, count?: integer }`, an alias of one, +generic ones such as `Box` for `---@alias Box { value: T }` included, an array such as +`string[]`, or a `table`. That holds wherever a class table is checked: under a `---@type`, +as an argument, as a returned value, and in the fields of another typed table, also for the class +tables a shape holds. An assignment without a `---@type` checks a table against the type its +target is declared with, not one inferred from what the target held before. As in +lua-language-server, such a table may leave fields out and set fields its type does not name, and +a union of table types is not checked. + ## Casts A `---@cast` line changes the type of a local from its line on: `---@cast name T` makes it a `T`, diff --git a/docs/rules.md b/docs/rules.md index d7551d1..59eed49 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -36,7 +36,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | -| `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | +| `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Tables typed as a shape, such as `{ value: string }`, an array or a `table` are checked by the types of their entries. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, or a literal the type does not list. Natives, `nil` and `false` are not checked. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | | `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | From 7ea22d45aba536a85400dbd0c8c7ea0e5ab32be7 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:13:19 +0200 Subject: [PATCH 045/193] feat(rules): check what functions written as a typed function return --- crates/qbx_fivem_data/stubs/lua54.lua | 2 +- crates/qbx_lua_ls/README.md | 3 +- crates/qbx_lua_ls/src/features/returns.rs | 88 +++++++++++++++-- crates/qbx_lua_ls/src/infer.rs | 95 +++++++++++++++++++ crates/qbx_lua_ls/tests/lsp.rs | 110 ++++++++++++++++++++++ docs/reference.md | 15 +++ docs/rules.md | 6 +- 7 files changed, 304 insertions(+), 15 deletions(-) diff --git a/crates/qbx_fivem_data/stubs/lua54.lua b/crates/qbx_fivem_data/stubs/lua54.lua index 2974842..028c793 100644 --- a/crates/qbx_fivem_data/stubs/lua54.lua +++ b/crates/qbx_fivem_data/stubs/lua54.lua @@ -159,7 +159,7 @@ function select(index, ...) end ---@field __call? fun(t: table, ...: any): ...any ---@field __tostring? fun(t: table): string ---@field __len? fun(t: table): integer ----@field __pairs? fun(t: table): function, table, any +---@field __pairs? fun(t: table): function, any, any ---@field __close? fun(t: table, err: any) ---@field __gc? fun(t: table) ---@field __unm? fun(a: table): any diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 4a66319..989199f 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -64,7 +64,8 @@ Available features depend on the editor's LSP client. `---@type` or `@param`, `param-type-mismatch` for arguments of the wrong type, `undeclared-field` for fields and keys a `---@class (strict)` does not declare, `return-type-mismatch`, `missing-return` and `redundant-return-value` for functions, including callback handlers, that - do not return what their `@return` declares, and `discard-returns` for calls that drop the + do not return what their `@return`, or the function type they are passed as, declares, and + `discard-returns` for calls that drop the values of a `@nodiscard` function. `invisible` reports the use of fields and methods a class keeps `private`, `protected` or `package` from outside its class, subclasses or file, and completion leaves them out there. diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index 2d55910..0fe87ad 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -9,11 +9,18 @@ //! //! A function that lists sets of values, as `@return false | (string, string)` does, has to return //! one of them: each `return` is compared with the set it comes closest to. +//! +//! A function literal without `@return` gets the same checks from the function type it is written +//! as, as lua-language-server gives them: the `fun(n: integer): string` of the parameter it is +//! passed for, of the `---@type` of the statement it is the value of, or of the field of a typed +//! table it is written in. use qbx_lua_analysis::env::is_meta_file; +use qbx_lua_analysis::scope::Resolved; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; +use rustc_hash::FxHashSet; use super::class_tables::Classes; use crate::infer::{always_exits, return_stmts, Infer}; @@ -71,6 +78,7 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { if (required == 0 && sets.is_empty()) || (is_meta && func.body.stmts.is_empty()) { continue; } + let declarer = function.declarer(); for (stmt, exprs) in return_stmts(&func.body) { // With several sets of values, the one this `return` comes closest to decides. let required = match sets.is_empty() { @@ -82,14 +90,15 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { }; if is_short(exprs, required) { let message = - format!("`@return` requires {}, but this returns {}", values(required), values(exprs.len())); + format!("{declarer} requires {}, but this returns {}", values(required), values(exprs.len())); out.push((stmt.span, message)); } } if required > 0 && !always_exits(&func.body) { let types: Vec = returns[..required].iter().map(code).collect(); let message = format!( - "The function can reach its end without returning, but `@return` requires {}", + "The function can reach its end without returning, but {} requires {}", + declarer.to_lowercase(), types.join(", ") ); out.push((func.end_span, message)); @@ -106,8 +115,9 @@ pub fn missing_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { pub fn redundant_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let is_open = |types: &[Type]| matches!(types.last(), Some(Type::Variadic(_))); let mut out = Vec::new(); - for Documented { func, returns, sets, overloads } in documented(infer, chunk) { - let declared: Vec<&Vec> = std::iter::once(&returns).chain(&sets).chain(&overloads).collect(); + for function in documented(infer, chunk) { + let Documented { func, returns, sets, overloads, .. } = &function; + let declared: Vec<&Vec> = std::iter::once(returns).chain(sets).chain(overloads).collect(); if declared.iter().any(|types| is_open(types)) { continue; } @@ -120,7 +130,12 @@ pub fn redundant_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { continue; } let returned = if open_ended { format!("at least {}", values(written)) } else { values(written) }; - let message = format!("`@return` allows at most {}, but this returns {returned}", values(most)); + let message = match function.handler && most == 0 { + true => format!("Event handlers' return values are discarded, but this returns {returned}"), + false => { + format!("{} allows at most {}, but this returns {returned}", function.declarer(), values(most)) + } + }; out.push((first.span.to(last.span), message)); } } @@ -155,7 +170,7 @@ fn expected_at(returns: &[Type], index: usize) -> Option<&Type> { } } -/// A function documented with `@return`. +/// A function documented with `@return`, or written as a value of a declared function type. struct Documented<'c> { func: &'c FuncBody, /// The type of each value it returns. @@ -164,9 +179,23 @@ struct Documented<'c> { sets: Vec>, /// The values each `@overload` returns, or each set of them it lists. overloads: Vec>, + /// The values come from the function type the function is written as, not from `@return`. + typed: bool, + /// It is the handler passed to `AddEventHandler` or `RegisterNetEvent`, whose returned values + /// the event system discards. + handler: bool, } impl Documented<'_> { + /// What declares the values, as a message names it. + fn declarer(&self) -> &'static str { + if self.typed { + "Its function type" + } else { + "`@return`" + } + } + /// The types a `return` passing `values` is compared with: `returns`, or of several sets the /// one that takes the most of the values, then one the `return` is not short for, then the one /// nearest in length. @@ -179,9 +208,11 @@ impl Documented<'_> { } } -/// The functions of `chunk` documented with `@return`. +/// The functions of `chunk` documented with `@return`, and the function literals without it that a +/// declared function type describes. fn documented<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { - let mut finder = Finder { infer, out: Vec::new() }; + let mut finder = + Finder { infer, out: Vec::new(), with_returns: FxHashSet::default(), handlers: FxHashSet::default() }; finder.visit_block(&chunk.block); finder.out } @@ -189,6 +220,20 @@ fn documented<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { struct Finder<'a, 'b, 'c> { infer: &'a Infer<'b>, out: Vec>, + /// The functions documented with `@return`, by the start of their parameter list. + with_returns: FxHashSet, + /// The function literals passed as event handlers, by the start of their parameter list. + handlers: FxHashSet, +} + +impl Finder<'_, '_, '_> { + /// Whether `callee` is the runtime's `AddEventHandler` or `RegisterNetEvent`, not a local of + /// that name. + fn registers_event(&self, callee: &Expr) -> bool { + let ExprKind::Name(name) = &callee.unparen().kind else { return false }; + matches!(name.text.as_str(), "AddEventHandler" | "RegisterNetEvent") + && matches!(self.infer.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + } } impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { @@ -200,15 +245,38 @@ impl<'c> Visitor<'c> for Finder<'_, '_, 'c> { .iter() .flat_map(|overload| std::iter::once(&overload.returns).chain(&overload.return_sets).cloned()) .collect(); - let documented = |func| { + let documented = |func: &'c FuncBody| { let ty = |ty: &Type| self.infer.doc_type_for(stmt, func, ty); let returns = doc.returns.iter().map(|r| ty(&r.ty)).collect(); let sets = doc.return_sets.iter().map(|set| set.iter().map(ty).collect()).collect(); - Documented { func, returns, sets, overloads: overloads.clone() } + Documented { func, returns, sets, overloads: overloads.clone(), typed: false, handler: false } }; + self.with_returns.extend(functions.iter().map(|func| func.params_span.start)); self.out.extend(functions.into_iter().map(documented)); } } visit::walk_stmt(self, stmt); } + + fn visit_expr(&mut self, expr: &'c Expr) { + if let ExprKind::Call { callee, args, .. } = &expr.kind { + if self.registers_event(callee) { + let handlers = args.iter().filter_map(|arg| match &arg.unparen().kind { + ExprKind::Function(func) => Some(func.params_span.start), + _ => None, + }); + self.handlers.extend(handlers); + } + } + if let ExprKind::Function(func) = &expr.kind { + if !self.with_returns.contains(&func.params_span.start) { + if let Some(fun) = self.infer.declared_fun_type(func) { + let (returns, sets) = (fun.returns.clone(), fun.return_sets.clone()); + let handler = self.handlers.contains(&func.params_span.start); + self.out.push(Documented { func, returns, sets, overloads: Vec::new(), typed: true, handler }); + } + } + } + visit::walk_expr(self, expr); + } } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 002de72..df00264 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -140,6 +140,9 @@ pub struct FileContext<'a> { call_anchors: FxHashMap, /// Where each table constructor that a declared type may describe is written, by its start. tables: FxHashMap>, + /// Where each function literal that a declared type may describe is written, by the start of + /// its parameter list. + functions: FxHashMap>, /// The `table.__index = value` assignments of the file, by the table and the value. index_writes: Vec<(&'a Expr, &'a Expr)>, /// The metatables that `setmetatable(name, metatable)` calls give each local, with where each @@ -157,6 +160,7 @@ impl<'a> FileContext<'a> { decls: FxHashMap::default(), call_anchors: FxHashMap::default(), tables: FxHashMap::default(), + functions: FxHashMap::default(), index_writes: Vec::new(), set_metatables: Vec::new(), }; @@ -175,6 +179,7 @@ impl<'a> FileContext<'a> { decls: collector.decls, call_anchors: collector.call_anchors, tables: collector.tables, + functions: collector.functions, index_writes: collector.index_writes, set_metatables, docs: RefCell::new(FxHashMap::default()), @@ -286,6 +291,7 @@ struct DeclCollector<'a> { decls: FxHashMap>, call_anchors: FxHashMap, tables: FxHashMap>, + functions: FxHashMap>, index_writes: Vec<(&'a Expr, &'a Expr)>, set_metatables: Vec<(&'a Name, u32, &'a Expr)>, } @@ -298,6 +304,9 @@ impl<'a> DeclCollector<'a> { } fn func(&mut self, func: &'a FuncBody, doc_anchor: Option, expected: Option>) { + if let Some(expected) = expected { + self.functions.insert(func.params_span.start, expected); + } for (index, param) in func.params.iter().enumerate() { self.decls.insert(param.span.start, Decl::Param { func, index, doc_anchor, expected }); } @@ -1071,6 +1080,92 @@ impl<'a> Infer<'a> { } } + /// The function type that something declares the function literal `func` has to be: the + /// parameter of the call it is passed to, the `---@type` of the statement it is the value of, + /// or the field of a typed table it is written in. A value it returns whose type names a + /// generic of the callee or of the type itself is `any`, as only the arguments of a call bind + /// those, which declares nothing. `None` when nothing declares one, or when the call fits + /// several signatures, which may take different functions. + pub fn declared_fun_type(&self, func: &FuncBody) -> Option> { + let expected = self.ctx.functions.get(&func.params_span.start)?; + let (ty, callee_generics) = match *expected { + Expected::Arg { call, arg_index } => { + let (base, method, args) = match &call.kind { + ExprKind::Call { callee, args, .. } => (callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => (base, Some(method), args), + _ => return None, + }; + let via_method = method.is_some(); + let (callee, _) = self.callee_fun(base, method)?; + let args = CallArgs::new(args); + let signatures = self.signatures_at(&callee, call.span.start); + let mut fitting = + signatures.iter().filter(|signature| self.fit(signature, &args, via_method) != Fit::No); + let (Some(signature), None) = (fitting.next(), fitting.next()) else { return None }; + let (skip_params, skip_args) = signature.call_offsets(via_method); + let param = signature.params.get((arg_index + skip_params).checked_sub(skip_args)?)?; + (param.ty.clone(), signature.generics.clone()) + } + _ => (self.expected_type(expected)?, Vec::new()), + }; + let fun = self.sole_fun(&ty)?; + let generics = FunType { + generics: callee_generics.into_iter().chain(fun.generics.iter().cloned()).collect(), + ..FunType::default() + }; + let declared = |types: &[Type]| -> Vec { + types.iter().map(|ty| if self.names_generic(&generics, ty, 0) { Type::Any } else { ty.clone() }).collect() + }; + Some(Arc::new(FunType { + returns: declared(&fun.returns), + return_sets: fun.return_sets.iter().map(|set| declared(set)).collect(), + overloads: Vec::new(), + ..(*fun).clone() + })) + } + + /// The one function type that `ty` describes, through aliases and beside other kinds of value, + /// as in `fun()?` or `table|fun()`. `None` when it lists several, like the two signatures of a + /// searcher that returns `function` or `nil, string`, which a function may be either of. + fn sole_fun(&self, ty: &Type) -> Option> { + match self.expand_aliases(ty, 0) { + Type::Fun(fun) => Some(fun), + Type::Union(parts) => { + let mut funs = parts.into_iter().filter_map(|part| match part { + Type::Fun(fun) => Some(fun), + _ => None, + }); + match (funs.next(), funs.next()) { + (Some(fun), None) => Some(fun), + _ => None, + } + } + _ => None, + } + } + + /// Whether `ty` names one of the generics of `fun`. + fn names_generic(&self, fun: &FunType, ty: &Type, depth: u32) -> bool { + if depth > 8 { + return false; + } + let names = |ty: &Type| self.names_generic(fun, ty, depth + 1); + match ty { + Type::Named(name, args) => (args.is_empty() && self.is_generic(fun, name)) || args.iter().any(names), + Type::NameOf(_) => true, + Type::Array(inner) | Type::Variadic(inner) => names(inner), + Type::Map(key, value) => names(key) || names(value), + Type::Tuple(types) | Type::Union(types) => types.iter().any(names), + Type::Fun(inner) => inner.returns.iter().any(names) || inner.params.iter().any(|param| names(¶m.ty)), + Type::Shape(shape) => { + shape.fields.iter().any(|field| names(&field.ty)) + || shape.array.as_ref().is_some_and(names) + || shape.indices.iter().any(|(key, value)| names(key) || names(value)) + } + _ => false, + } + } + /// `TriggerCallback('name', function(response) end)` receives what the handler of `name` /// returns, when argument `arg_index` of `call` is that function. fn triggered_returns(&self, call: &Expr, arg_index: usize) -> Option> { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 9d0e9ff..0afa218 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5633,6 +5633,116 @@ print(rest, scalars, described, name, both, escape, tail, more, quiet, plain) ); } +#[test] +fn functions_written_as_a_typed_function_return_its_values() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param cb fun(n: integer): string +local function withCb(cb) return cb end +withCb(function(n) return n end) +withCb(function(n) return 'a', 2 end) +withCb(function(n) end) +withCb(function(n) return tostring(n) end) + +---@param cb (fun(): string)? +local function optional(cb) end +optional(function() return 1 end) + +---@param cb fun() +local function none(cb) end +none(function() return 1 end) + +---@generic T +---@param cb fun(): T +---@return T +local function generic(cb) return cb() end +generic(function() return 1 end) + +---@param cb fun(): string +---@overload fun(cb: fun(): integer) +local function overloaded(cb) end +overloaded(function() return 1 end) + +---@alias Test.Searcher +---| fun(name: string): function +---| fun(name: string): nil, string +---@param searcher Test.Searcher +local function search(searcher) end +search(function(name) return nil, name end) + +---@type fun(): string +local typed = function() return 3 end + +---@class Test.Options +---@field onDone fun(): boolean + +---@param options Test.Options +local function open(options) end +open({ onDone = function() return 'no' end }) + +---@param cb fun(): string +local function documented(cb) end +---@return integer +documented(function() return 1 end) + +---@param cb function +local function untyped(cb) end +untyped(function() return 1 end) + +AddEventHandler('test:local', function() return 5 end) +RegisterNetEvent('test:net', function() return 'x', 1 end) +print(typed) +"; + client.open_with(CLIENT, text); + let codes = ["return-type-mismatch", "missing-return", "redundant-return-value"]; + let finding = |code: &str, needle: &str, message: &str| { + (code.to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + let mut found = findings(&mut client, CLIENT, &codes); + found.sort(); + let mut expected = vec![ + finding("return-type-mismatch", "return n end", "Cannot return `integer` as return value #1 of type `string`"), + finding( + "redundant-return-value", + "return 'a', 2", + "Its function type allows at most 1 value, but this returns 2 values", + ), + finding( + "missing-return", + "withCb(function(n) end)", + "The function can reach its end without returning, but its function type requires `string`", + ), + finding( + "return-type-mismatch", + "optional(function", + "Cannot return `integer` as return value #1 of type `string`", + ), + finding( + "redundant-return-value", + "none(function", + "Its function type allows at most no values, but this returns 1 value", + ), + finding("return-type-mismatch", "return 3 end", "Cannot return `integer` as return value #1 of type `string`"), + finding("return-type-mismatch", "return 'no'", "Cannot return `string` as return value #1 of type `boolean`"), + finding( + "redundant-return-value", + "return 5 end", + "Event handlers' return values are discarded, but this returns 1 value", + ), + finding( + "redundant-return-value", + "return 'x', 1", + "Event handlers' return values are discarded, but this returns 2 values", + ), + ]; + expected.sort(); + assert_eq!( + found, expected, + "the type of the parameter, `---@type` or field a function is written as declares its values, unless a \ + generic, several fitting signatures, a union of function types or its own `@return` decides them" + ); +} + #[test] fn hover_binds_generics_from_arguments_and_callbacks() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 2431e56..e8501ae 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -522,6 +522,21 @@ function written in a table, whose doc comment goes above its field. qbx-lua-ls `return 1, print()` passes for `---@return integer`, and `return text:gsub(...)` for `---@return string` although `gsub` also returns a count. +A function without `@return` gets the same checks from the function type it is written as, as in +lua-language-server: the `fun(n: integer): string` of the parameter it is passed for, of the +`---@type` above the statement it is the value of, or of the field of a typed table it is written +in. A `fun()` that lists no values takes none, so `return true` in a handler passed for +`fun(...: any)` is a `redundant-return-value`. A value whose type names a generic of the callee +or of the function type is not checked, since only the arguments of a call bind it, and nothing +is checked when the call fits several signatures or the type lists several function types. + +```lua +---@param cb fun(n: integer): string +local function withCallback(cb) end + +withCallback(function(n) return n end) -- Cannot return `integer` as return value #1 of type `string` +``` + A body finishes without running past its end when it ends in `return`, `error(...)`, an `if`/`else` whose branches all finish, or a loop such as `while true do` that only a `return` leaves. An empty body runs past its end too, except in a definition file marked `---@meta`, whose diff --git a/docs/rules.md b/docs/rules.md index 59eed49..fbfc44e 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -40,9 +40,9 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, or a literal the type does not list. Natives, `nil` and `false` are not checked. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | | `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | -| `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. Reported by qbx-lua-ls only. | -| `missing-return` | warning | A function with a required `@return` value can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | -| `redundant-return-value` | warning | A `return` passes more values than the function's `@return` annotations declare. A trailing `...T` takes any number. Reported by qbx-lua-ls only. | +| `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. A function without `@return` is checked against the function type it is written as, such as the `fun(): string` of the parameter it is passed for. Reported by qbx-lua-ls only. | +| `missing-return` | warning | A function with a required `@return` value, or written as a function type that returns one, can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | +| `redundant-return-value` | warning | A `return` passes more values than the function's `@return` annotations, or the function type it is written as, declare. A trailing `...T` takes any number. Reported by qbx-lua-ls only. | | `discard-returns` | warning | A call on a line of its own drops the values of a function marked `@nodiscard`, such as `tostring(value)`. Reported by qbx-lua-ls only. | | `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | | `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. Reported by qbx-lua-ls only. | From 792dd74310ee4c57b799f61c7d87231858f46d0b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:43:07 +0200 Subject: [PATCH 046/193] feat(infer): type operations on class values by their @operator lines --- crates/qbx_lua_ls/README.md | 4 + crates/qbx_lua_ls/src/index.rs | 28 +++++- crates/qbx_lua_ls/src/indexer.rs | 1 + crates/qbx_lua_ls/src/infer.rs | 138 +++++++++++++++++++++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 63 ++++++++++++++ crates/qbx_luacats/src/luacats.rs | 66 ++++++++++++++ 6 files changed, 285 insertions(+), 15 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 989199f..56be885 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -48,6 +48,10 @@ Available features depend on the editor's LSP client. a table given a metatable by `setmetatable`, have the fields of its `__index` table at every level, as `Class.__index = Class` sets up, for completion, hover and navigation. A `---@class` keeps the fields it declares. +- The `---@operator` lines of a class type what operations on its values give, as + `---@operator add(Vec): Vec` makes `a + b` a `Vec`, for an operand of the type the line names, + on either side. A class does not take the operators of its parents, as Lua looks a metamethod up + in the metatable itself, and `@operator call` types calling a value when no `@overload` does. - Exports typed by LuaLS definitions, as `---@type PhoneExports` above `exports.phone = {}`, or `---@class qbx_core` above `exports.qbx_core = {}` with `function exports.qbx_core:GetCid()` methods: `exports.phone` and `exports['phone']` take the members of the declared type before the diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 7a3cd54..4bf1a02 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -10,7 +10,7 @@ use qbx_lua_analysis::summary::FileSummary; use rustc_hash::{FxHashMap, FxHashSet}; use smol_str::SmolStr; -use crate::luacats::{applies_on, DocIndexField, Visibility}; +use crate::luacats::{applies_on, DocIndexField, DocOperator, Visibility}; use crate::types::{DescribedValue, FunType, Type}; pub type FileId = u32; @@ -29,6 +29,8 @@ pub enum Read { Table(SmolStr), /// The classes, aliases and enums of a name. Type(SmolStr), + /// The operators that classes declare with `@operator`, whichever classes they are. + Operators, /// The exports a resource registers, by its name as `exports_key` spells it, or the one of them /// that a name names. Exports(SmolStr, Option), @@ -85,7 +87,7 @@ fn same_class(a: &ClassDef, b: &ClassDef) -> bool { (a.generics == b.generics && a.parent_types == b.parent_types && a.side == b.side && a.strict == b.strict) && (fields(a) == fields(b) && a.field_sides == b.field_sides && a.field_visibility == b.field_visibility) && (indices(&a.indices) == indices(&b.indices) && indices(&a.literal_fields) == indices(&b.literal_fields)) - && a.call == b.call + && (a.call == b.call && a.operators == b.operators) } /// Whether two declarations of an alias or enum tell the same about its values, wherever they are @@ -196,6 +198,8 @@ pub struct ClassDef { /// literal, with their values, in declaration order. pub literal_fields: Vec, pub call: Option>, + /// The `@operator` lines of the class, in declaration order. + pub operators: Vec, pub doc: Option>, pub range: Range, /// The side of `@class (server) Name`. @@ -407,6 +411,9 @@ pub struct Index { /// The names of the fields and methods some class keeps from other code, built on first use /// after the files change. restricted_names: OnceLock>>, + /// The operators some class declares with `@operator`, built on first use after the files + /// change. + operator_names: OnceLock>>, /// What the lookups made while a file is indexed read, as `reads_while` collects it. reads: RefCell>>, } @@ -528,6 +535,7 @@ impl Index { fn clear_slots(&mut self, id: FileId) -> Option { self.declarations.take(); self.restricted_names.take(); + self.operator_names.take(); let old = self.files.get_mut(id as usize).and_then(Option::take)?; remove_file_slots(&mut self.globals, old.index.globals.iter().map(|s| &s.name), id); remove_file_slots(&mut self.members, old.index.members.iter().map(|m| &m.owner), id); @@ -663,6 +671,11 @@ impl Index { aliases.filter_map(|alias| Some((Read::Table(alias.table.as_ref()?.owner.clone()), alias))) }; changed_groups(out, enums(before), enums(after), |a, b| same_alias(a, b)); + let operators = |entry: Option<&'a FileEntry>| { + let classes = entry.into_iter().flat_map(|entry| &entry.index.classes); + classes.flat_map(|class| &class.operators).map(|operator| (Read::Operators, &operator.name)) + }; + changed_groups(out, operators(before), operators(after), |a, b| a == b); let exports = |entry: Option<&'a FileEntry>| { let resource = entry.and_then(|entry| self.resource(entry.resource?)).map(|r| exports_key(&r.name)); let exports = entry.into_iter().flat_map(|entry| &entry.index.exports); @@ -851,6 +864,17 @@ impl Index { self.member_entries(owner, Some(name), from) } + /// The operators, such as `add` or `concat`, that some class declares with `@operator`, so + /// that operations no class declares need no lookup. + pub fn operator_names(&self) -> Arc> { + self.note(|| Read::Operators); + let build = || { + let classes = self.files().flat_map(|(_, file)| &file.index.classes); + Arc::new(classes.flat_map(|class| &class.operators).map(|operator| operator.name.clone()).collect()) + }; + self.operator_names.get_or_init(build).clone() + } + /// The names of the fields and methods that some class declares `private`, `protected` or /// `package`, so that code using none of them needs no check. pub fn restricted_names(&self) -> Arc> { diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 9f25c69..e543934 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -238,6 +238,7 @@ impl<'a> Indexer<'a> { indices: class.indices, literal_fields: class.literal_fields, call: class.call, + operators: class.operators, doc: (!class.description.is_empty()).then(|| Arc::from(class.description.as_str())), range, side: class.side, diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index df00264..148ba96 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -14,7 +14,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::callback_wrappers::{self, Wrapper}; use crate::index::{instance_class, instance_owner, ClassDef, FileId, Index, SymbolKind}; use crate::locate::statement_at; -use crate::luacats::{applies_on, own_type, parse_doc_lines, CastEntry, DocGroup}; +use crate::luacats::{applies_on, own_type, parse_doc_lines, CastEntry, DocGroup, DocOperator}; use crate::narrow::{Casts, Guards}; use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeParser}; @@ -663,15 +663,7 @@ impl<'a> Infer<'a> { ExprKind::Index { base, index, .. } => self.index_expr(base, index), ExprKind::Table(fields) => self.table(fields), ExprKind::Binary { op, lhs, rhs, .. } => self.binary(*op, lhs, rhs), - ExprKind::Unary { op, expr } => match op { - UnOp::Not => Type::Boolean, - UnOp::Len => match self.expr(expr) { - Type::Named(name, _) if name.starts_with("vector") => Type::Number, - _ => Type::Integer, - }, - UnOp::BNot => Type::Integer, - UnOp::Neg => self.expr(expr).widen(), - }, + ExprKind::Unary { op, expr } => self.unary(*op, expr), ExprKind::Call { .. } | ExprKind::MethodCall { .. } | ExprKind::Error => Type::Unknown, } } @@ -1839,9 +1831,85 @@ impl<'a> Infer<'a> { in_constructor.chain(assigned).map(|value| self.expr(value)).collect() } + fn unary(&self, op: UnOp, operand: &Expr) -> Type { + let name = match op { + UnOp::Not => return Type::Boolean, + UnOp::Len => "len", + UnOp::BNot => "bnot", + UnOp::Neg => "unm", + }; + let ty = match op { + UnOp::BNot if !self.index.operator_names().contains(name) => return Type::Integer, + _ => self.expr(operand).widen(), + }; + if let Some(result) = self.operator(&ty, name, None) { + return result; + } + match op { + UnOp::Len => match ty { + Type::Named(name, _) if name.starts_with("vector") => Type::Number, + _ => Type::Integer, + }, + UnOp::Neg => ty, + _ => Type::Integer, + } + } + + /// What the `@operator name` lines of the class of `ty` give for an operand of type `operand`, + /// or with `None` for an operation without one: the first whose operand is of that type, or + /// else takes it, and `unknown` when none does. `None` when `ty` is no class or its class + /// declares no such operator. A class does not take the operators of its parents, as Lua looks + /// a metamethod up in the metatable itself. + fn operator(&self, ty: &Type, name: &str, operand: Option<&Type>) -> Option { + let Type::Named(class, args) = self.resolve_alias(&ty.without_nil()) else { return None }; + let mut defs = self.index.class_defs(&class); + defs.retain(|(_, def)| applies_on(def.side, self.side)); + let operators: Vec<&DocOperator> = + defs.iter().flat_map(|(_, def)| &def.operators).filter(|operator| operator.name == name).collect(); + if operators.is_empty() { + return None; + } + let bindings = class_bindings(&defs, &args); + let declared = |operator: &DocOperator| operator.operand.as_ref().map(|ty| substitute(ty, &bindings)); + let any = FunType::default(); + let takes = |declared: &Type, given: &Type| match ( + self.value_kinds(&any, declared, true, 0), + self.value_kinds(&any, given, false, 0), + ) { + (Some(taken), Some(given)) => taken & given != 0, + _ => true, + }; + let found = match operand { + Some(given) => operators.iter().find(|operator| declared(operator).as_ref() == Some(given)).or_else(|| { + operators.iter().find(|operator| declared(operator).is_none_or(|declared| takes(&declared, given))) + }), + None => operators.first(), + }; + Some(found.map_or(Type::Unknown, |operator| substitute(&operator.result, &bindings))) + } + + /// What `left op right` gives through the `@operator name` lines of the class of `left`, or + /// else of `right`, as lua-language-server reads `1 + v`. `unknown` when a class declares the + /// operator for no operand of the other's type, and `None` when neither declares it. + fn binary_operator(&self, name: &str, left: &Type, right: &Type) -> Option { + let from_left = self.operator(left, name, Some(right)); + if from_left.as_ref().is_some_and(|ty| !ty.is_unknown()) { + return from_left; + } + match self.operator(right, name, Some(left)) { + Some(ty) if !ty.is_unknown() => Some(ty), + from_right => from_left.or(from_right), + } + } + fn binary(&self, op: BinOp, lhs: &Expr, rhs: &Expr) -> Type { + // Operands are only inferred for an operator that some class declares. + let operator = |name: &str| match self.index.operator_names().contains(name) { + true => self.binary_operator(name, &self.expr(lhs).widen(), &self.expr(rhs).widen()), + false => None, + }; match op { - BinOp::Concat => Type::String, + BinOp::Concat => operator("concat").unwrap_or(Type::String), BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => Type::Boolean, // `a and b` is `b` whenever `a` holds a value, so an unknown `b` leaves it unknown. BinOp::And => match self.expr(rhs).widen() { @@ -1856,10 +1924,34 @@ impl<'a> Infer<'a> { }; Type::union([left, self.expr(rhs).widen()]) } - BinOp::BAnd | BinOp::BOr | BinOp::BXor | BinOp::Shl | BinOp::Shr | BinOp::IDiv => Type::Integer, + BinOp::BAnd | BinOp::BOr | BinOp::BXor | BinOp::Shl | BinOp::Shr | BinOp::IDiv => { + let name = match op { + BinOp::BAnd => "band", + BinOp::BOr => "bor", + BinOp::BXor => "bxor", + BinOp::Shl => "shl", + BinOp::Shr => "shr", + _ => "idiv", + }; + operator(name).unwrap_or(Type::Integer) + } BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow => { let (left, right) = (self.expr(lhs).widen(), self.expr(rhs).widen()); let is_vector = |t: &Type| matches!(t, Type::Named(n, _) if n.starts_with("vector") || n == "quat"); + let name = match op { + BinOp::Add => "add", + BinOp::Sub => "sub", + BinOp::Mul => "mul", + BinOp::Div => "div", + BinOp::Mod => "mod", + _ => "pow", + }; + // The stubs do not list every operand a vector takes, such as the number of + // `vector3(1, 2, 3) + 1`. + match self.binary_operator(name, &left, &right) { + Some(ty) if !ty.is_unknown() || !(is_vector(&left) || is_vector(&right)) => return ty, + _ => {} + } if is_vector(&left) { left } else if is_vector(&right) { @@ -1888,7 +1980,10 @@ impl<'a> Infer<'a> { match self.resolve_alias(&ty) { Type::Named(name, args) => { let call = self.index.class(&name, self.side).and_then(|(_, c)| c.call.clone()); - let call = call.filter(|f| applies_on(f.side, self.side))?; + let call = match call.filter(|f| applies_on(f.side, self.side)) { + Some(call) => call, + None => self.call_operator(&name)?, + }; let bindings = self.class_bindings_of(&name, &args); match bindings.is_empty() { true => Some((call, None)), @@ -1901,6 +1996,23 @@ impl<'a> Infer<'a> { } } + /// The function that the first `@operator call(T): R` line of the class `name` makes of its + /// values, taking a `T` and returning an `R`, as lua-language-server reads it. An `@overload` + /// of the class comes first. + fn call_operator(&self, name: &str) -> Option> { + let defs = self.index.class_defs(name); + let mut operators = + defs.iter().filter(|(_, def)| applies_on(def.side, self.side)).flat_map(|(_, def)| &def.operators); + let operator = operators.find(|operator| operator.name == "call")?; + let params = + operator.operand.iter().map(|ty| Param { name: "value".into(), ty: ty.clone(), ..Param::default() }); + Some(Arc::new(FunType { + params: params.collect(), + returns: vec![operator.result.clone()], + ..FunType::default() + })) + } + fn call(&self, base: &Expr, method: Option<&Name>, args: &[Expr]) -> Vec { self.call_values(base, method, args).values } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 0afa218..bf04b39 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5967,6 +5967,69 @@ local inferred = value:inferred() assert_methods_return(&mut client, edited, &text, "Test.Edited"); } +#[test] +fn class_operators_type_the_operations_on_their_values() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Vec +---@operator add(Test.Vec): Test.Vec +---@operator add(number): string +---@operator unm: Test.Vec +---@operator concat(string): Test.Vec +---@operator len: integer +---@operator band(Test.Vec): Test.Vec +---@operator call(integer): boolean + +---@class Test.Sub : Test.Vec + +---@class Test.Callable +---@overload fun(): string +---@operator call: integer + +---@type Test.Vec +local v +---@type Test.Vec? +local maybe +---@type Test.Sub +local sub +---@type Test.Callable +local callable +local sum = v + v +local scaled = v + 2 +local swapped = 2 + v +local missing = v + true +local negated = -v +local joined = v .. 'x' +local count = #v +local masked = v & v +local called = v(1) +local optional = maybe + v +local inherited = sub + sub +local overloaded = callable() +local moved = vector3(1, 2, 3) + 1 +"; + client.open_with(CLIENT, text); + for (name, expected) in [ + ("sum", "Test.Vec"), + ("scaled", "string"), + ("swapped", "string"), + ("missing", "unknown"), + ("negated", "Test.Vec"), + ("joined", "Test.Vec"), + ("count", "integer"), + ("masked", "Test.Vec"), + ("called", "boolean"), + ("optional", "Test.Vec"), + ("inherited", "number"), + ("overloaded", "string"), + ("moved", "vector3"), + ] { + let (l, c) = pos(text, &format!("local {name}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(&format!("{name}: {expected}")), "{name}: expected {expected:?} in {hover}"); + } +} + #[test] fn backtick_generics_bind_the_type_a_string_names() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index a1a7a6f..addb850 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -78,6 +78,8 @@ pub struct DocClass { /// literal, with their values, in declaration order. pub literal_fields: Vec, pub call: Option>, + /// The `@operator` lines of the class, in declaration order. + pub operators: Vec, pub description: String, pub line: usize, /// The side of `@class (server) Name`. @@ -87,6 +89,16 @@ pub struct DocClass { pub strict: Option, } +/// `---@operator add(Vec): Vec`: what an operation on a value of the class gives, for an operand of +/// the given type. A unary operation such as `unm` or `len`, and `call` without a list, take none. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocOperator { + /// The metamethod name without its `__`, such as `add`, `concat`, `unm` or `call`. + pub name: SmolStr, + pub operand: Option, + pub result: Type, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct DocAlias { pub name: SmolStr, @@ -358,6 +370,27 @@ fn overload(rest: &str) -> Option> { } } +/// An `@operator name(operand): result` or `@operator name: result` line. +fn operator(rest: &str) -> Option { + let rest = rest.trim_start(); + let end = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).unwrap_or(rest.len()); + let (name, after) = rest.split_at(end); + if name.is_empty() { + return None; + } + let (operand, after) = match after.trim_start().strip_prefix('(') { + Some(inner) if inner.trim_start().starts_with(')') => (None, inner.trim_start()[1..].trim_start()), + Some(inner) => { + let mut parser = TypeParser::new(inner); + let operand = parser.parse(); + (Some(operand), parser.rest().trim_start().strip_prefix(')')?) + } + None => (None, after), + }; + let result = after.trim_start().strip_prefix(':').map(|result| TypeParser::new(result).parse()).unwrap_or_default(); + Some(DocOperator { name: SmolStr::new(name), operand, result }) +} + /// Parses the `---` lines of one contiguous doc comment; every line has its `---` prefix removed. pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { let mut group = DocGroup::default(); @@ -406,6 +439,16 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { } } "overload" => group.overloads.extend(overload(rest)), + "operator" => { + if let (Some(operator), Some(class)) = (operator(rest), group.classes.last_mut()) { + let operator = DocOperator { + operand: operator.operand.map(|ty| in_class(class, ty)), + result: in_class(class, operator.result), + ..operator + }; + class.operators.push(operator); + } + } "alias" => { let (attributes, rest) = split_attributes(rest); let Some((name, params, after)) = class_head(rest) else { continue }; @@ -1155,6 +1198,29 @@ mod tests { assert!(parse("---@class Plain : Base").classes[0].generics.is_empty()); } + #[test] + fn class_operators() { + let doc = parse( + "---@class Vec\n---@operator add(Vec): Vec\n---@operator unm: self\n---@operator call(): integer\n---@operator mul (number) : Vec # scales", + ); + let operators: Vec<(String, Option, String)> = doc.classes[0] + .operators + .iter() + .map(|op| (op.name.to_string(), op.operand.as_ref().map(Type::to_string), op.result.to_string())) + .collect(); + assert_eq!( + operators, + [ + ("add".into(), Some("Vec".into()), "Vec".into()), + ("unm".into(), None, "Vec".into()), + ("call".into(), None, "integer".into()), + ("mul".into(), Some("number".into()), "Vec".into()), + ], + "`self` is the class, and `()` takes no operand" + ); + assert!(parse("---@operator add(Vec): Vec").classes.is_empty(), "an operator outside a class declares nothing"); + } + #[test] fn generic_aliases() { let doc = parse( From 44554e38d7be5cac6acfccf48058b683119a45c2 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:02:51 +0200 Subject: [PATCH 047/193] fix(infer): give a field that several assignments set the union of their values --- crates/qbx_lua_ls/src/index.rs | 7 ++- crates/qbx_lua_ls/src/indexer.rs | 8 ++-- crates/qbx_lua_ls/src/infer.rs | 74 ++++++++++++++++++++++++++++++-- crates/qbx_lua_ls/tests/lsp.rs | 60 ++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 4bf1a02..4cee993 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -142,6 +142,9 @@ pub struct Member { pub injected: bool, /// The `---@private`, `---@protected` or `---@package` above the statement that sets it. pub visibility: Visibility, + /// A `---@type` or `---@class` above the statement or field that sets it declares its type, so + /// what other code stores in it does not change that type. + pub typed: bool, } /// The entries of a table constructor that have no name: its array part (`key` is `None`), or its @@ -619,9 +622,9 @@ impl Index { changed_groups(out, globals(before), globals(after), |a, b| a == b); let members = |entry: Option<&'a FileEntry>| { entry.into_iter().flat_map(|entry| &entry.index.members).map(|member| { - let Member { owner, symbol, injected, visibility } = member; + let Member { owner, symbol, injected, visibility, typed } = member; let read = Read::Members(owner.clone(), Some(symbol.name.clone())); - (read, (symbol.kind, &symbol.ty, *injected, *visibility)) + (read, (symbol.kind, &symbol.ty, *injected, *visibility, *typed)) }) }; let mut named = FxHashSet::default(); diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index e543934..5e928e5 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -331,11 +331,13 @@ impl<'a> Indexer<'a> { } /// Records a member set by the statement or table field whose doc comment starts at `doc_anchor`, - /// which may declare it `---@private`. + /// which may declare it `---@private` or give its type. fn push_member(&mut self, owner: SmolStr, symbol: Symbol, injected: bool, doc_anchor: Option) { if self.out.members.len() < MAX_MEMBERS_PER_FILE { - let visibility = doc_anchor.map(|anchor| self.ctx.doc_at(anchor).visibility).unwrap_or_default(); - self.out.members.push(Member { owner, symbol, injected, visibility }); + let doc = doc_anchor.map(|anchor| self.ctx.doc_at(anchor)); + let visibility = doc.as_ref().map(|doc| doc.visibility).unwrap_or_default(); + let typed = doc.is_some_and(|doc| doc.ty.is_some() || !doc.classes.is_empty()); + self.out.members.push(Member { owner, symbol, injected, visibility, typed }); } } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 148ba96..e018fd9 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -23,6 +23,9 @@ const MAX_SHAPE_FIELDS: usize = 96; /// An undocumented function that returns more different sets of values than this keeps only what /// each position holds across them. const MAX_RETURN_SETS: usize = 4; +/// How many of the literal values that several assignments store in a field a hover lists, as +/// lua-language-server lists the first few: each entry of a large data table sets one. +const MAX_MERGED_LITERALS: usize = 5; /// Bits for the kinds of Lua value a type allows, used to pick the `@overload` a call fits. mod kind { @@ -495,6 +498,9 @@ pub struct MemberInfo { pub literal: Option, pub kind: SymbolKind, pub location: Option<(FileId, Range)>, + /// The type is inferred from the value that an assignment without `---@type` stores, so other + /// assignments to the same field may store values of other types. + pub inferred: bool, } pub struct Infer<'a> { @@ -2682,6 +2688,9 @@ impl<'a> Infer<'a> { if found.is_empty() { return indexed_field(name, self.index_value(ty, &Type::StringLit(SmolStr::new(name)), 0)); } + if let Some(merged) = merged_assignments(&found.iter().collect::>()) { + return Some(merged); + } let best = (0..found.len()).max_by_key(|i| (found[*i].ty.specificity(), std::cmp::Reverse(*i)))?; Some(found.swap_remove(best)) }) @@ -2714,11 +2723,23 @@ impl<'a> Infer<'a> { } } + /// The members of `ty`, one for each name: the first found, or the values that several + /// assignments store in it, as `member` gives them. pub fn members(&self, ty: &Type) -> Vec { - let mut members = self.guarded(|| self.members_matching(ty, None)); - let mut seen = FxHashSet::default(); - members.retain(|m| seen.insert(m.name.clone())); - members + let members = self.guarded(|| self.members_matching(ty, None)); + let mut names: Vec<&SmolStr> = Vec::new(); + let mut named: FxHashMap<&SmolStr, Vec<&MemberInfo>> = FxHashMap::default(); + for member in &members { + named + .entry(&member.name) + .or_insert_with(|| { + names.push(&member.name); + Vec::new() + }) + .push(member); + } + let first = |found: &[&MemberInfo]| merged_assignments(found).unwrap_or_else(|| found[0].clone()); + names.into_iter().map(|name| first(&named[name])).collect() } fn members_matching(&self, ty: &Type, filter: Option<&str>) -> Vec { @@ -2740,6 +2761,7 @@ impl<'a> Infer<'a> { literal: None, kind: SymbolKind::Field, location: None, + inferred: false, }); } } @@ -2767,6 +2789,7 @@ impl<'a> Infer<'a> { literal: None, kind: SymbolKind::Table, location: None, + inferred: false, }); } // Resources the workspace lacks, such as escrowed ones, may still have typed exports. @@ -2790,6 +2813,7 @@ impl<'a> Infer<'a> { literal: None, kind: SymbolKind::Table, location: None, + inferred: false, }); } } @@ -2889,6 +2913,7 @@ impl<'a> Infer<'a> { let symbol = &entry.symbol; if filter.is_none_or(|f| f == symbol.name) { let mut member = member_from_symbol(file, symbol); + member.inferred = !entry.typed && matches!(symbol.kind, SymbolKind::Field | SymbolKind::Variable); if matches!(member.ty, Type::Table | Type::Unknown) { let nested = format!("{owner}.{}", symbol.name); if self.index.has_members(&nested) { @@ -2909,6 +2934,7 @@ impl<'a> Infer<'a> { literal: None, kind: SymbolKind::Table, location: None, + inferred: false, }); } } @@ -2984,6 +3010,44 @@ fn add_signature(fun: &mut Arc, next: &Arc) { fun.overloads.extend(next.overloads.iter().cloned()); } +/// A field that several assignments without a `---@type` set, as `T.state = 0` and +/// `self.state = 'running'` do, which holds any of the values they store besides the `nil` that +/// clears it, as lua-language-server reads it. `None` unless every one of `found` is such an +/// assignment, and two or more of them store a value, none of them a function. +fn merged_assignments(found: &[&MemberInfo]) -> Option { + if found.len() < 2 || !found.iter().all(|member| member.inferred) { + return None; + } + let stored: Vec<&MemberInfo> = found.iter().copied().filter(|member| member.ty != Type::Nil).collect(); + if stored.len() < 2 || stored.iter().any(|member| member.ty.as_fun().is_some()) { + return None; + } + // `1` and `1.5` store numbers, not `integer|number`. A value that may be anything, like the + // `args[1]` of an untyped command handler, tells no more about the field than one of unknown + // type does. + let has_number = stored.iter().any(|member| matches!(member.ty.without_nil(), Type::Number)); + let all_any = stored.iter().all(|member| member.ty == Type::Any); + let types = stored + .iter() + .map(|member| member.ty.clone()) + .filter(|ty| !(has_number && *ty == Type::Integer) && (all_any || *ty != Type::Any)); + let mut literals: Vec = Vec::new(); + for literal in stored.iter().map(|member| member.literal.clone()) { + match literal { + Some(literal) if !literals.contains(&literal) => literals.push(literal), + Some(_) => {} + None => { + literals.clear(); + break; + } + } + } + let more = if literals.len() > MAX_MERGED_LITERALS { "|..." } else { "" }; + literals.truncate(MAX_MERGED_LITERALS); + let literal = (!literals.is_empty()).then(|| SmolStr::new(format!("{}{more}", literals.join("|")))); + Some(MemberInfo { ty: Type::union(types), literal, ..stored[0].clone() }) +} + /// The field `name` that an index gives the value `ty`, unless the value is unknown. fn indexed_field(name: &str, ty: Type) -> Option { (!ty.is_unknown()).then(|| MemberInfo { @@ -2994,6 +3058,7 @@ fn indexed_field(name: &str, ty: Type) -> Option { literal: None, kind: SymbolKind::Field, location: None, + inferred: false, }) } @@ -3022,6 +3087,7 @@ fn member_from_symbol(file: FileId, symbol: &crate::index::Symbol) -> MemberInfo literal: symbol.literal.clone(), kind: symbol.kind, location: Some((file, symbol.range)), + inferred: false, } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index bf04b39..1847a18 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5967,6 +5967,66 @@ local inferred = value:inferred() assert_methods_return(&mut client, edited, &text, "Test.Edited"); } +#[test] +fn fields_set_in_several_places_hold_each_value() { + let mut client = Client::start(fixture_root()); + let text = "\ +local Machine = {} +Machine.state = 0 +function Machine:start() + self.state = 'running' +end +Machine.ratio = 1 +Machine.ratio = 1.5 +Machine.cleared = 1 +Machine.cleared = nil +---@type string +Machine.named = nil +Machine.named = 5 +Machine.handler = nil +Machine.handler = function() end +Machine.reason = 'closed' +function Machine.close(...) Machine.reason = ... end +Machine.size = 1 +Machine.size = 2 +Machine.size = 3 +Machine.size = 4 +Machine.size = 5 +Machine.size = 6 + +---@class Test.Holder +local Holder = {} +Holder.__index = Holder +function Holder.new() + local self = setmetatable({}, Holder) + self.item = nil + return self +end +---@param item Test.Holder +function Holder:set(item) + self.item = item +end +print(Machine.state, Machine.ratio, Machine.cleared, Machine.named, Machine.handler, Machine.reason, Holder.new().item, Machine.size) +"; + client.open_with(CLIENT, text); + let print = pos(text, "print(", 0).0; + let line = text.lines().nth(print as usize).unwrap(); + for (needle, expected) in [ + ("Machine.state", "state: integer|string = 0|'running'"), + ("Machine.size", "size: integer = 1|2|3|4|5|...\n"), + ("Machine.ratio", "ratio: number = 1|1.5"), + ("Machine.cleared", "cleared: integer = 1"), + ("Machine.named", "named: string"), + ("Machine.handler", "function handler()"), + ("Machine.reason", "reason: string"), + ("new().item", "item: Test.Holder"), + ] { + let column = (line.find(needle).unwrap() + needle.len() - 1) as u32; + let hover = client.hover_text(CLIENT, print, column); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn class_operators_type_the_operations_on_their_values() { let mut client = Client::start(fixture_root()); From 4e643c798a7cc668baa6251b5a66026a9d65d644 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:31 +0200 Subject: [PATCH 048/193] fix(inlay): name the parameters of the overload a call picks --- crates/qbx_lua_ls/src/features/inlay.rs | 4 +++- crates/qbx_lua_ls/tests/lsp.rs | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_ls/src/features/inlay.rs b/crates/qbx_lua_ls/src/features/inlay.rs index 5a09b57..22cae9c 100644 --- a/crates/qbx_lua_ls/src/features/inlay.rs +++ b/crates/qbx_lua_ls/src/features/inlay.rs @@ -39,7 +39,9 @@ impl Hints<'_, '_> { .then(|| event_call(self.ws, self.doc, self.infer, base, args, native.as_deref())) .flatten() .or_else(|| wrapper_call(self.ws, self.infer, native.as_deref()?, method.is_some(), args, base.span.start)); - let Some(fun) = event.map(|e| e.fun.into()).or(native) else { return }; + // A call names the parameters of the `@overload` it picks, as hover and signature help show. + let picked = native.map(|fun| self.infer.call_signature(&fun, args, method.is_some(), base.span.start)); + let Some(fun) = event.map(|e| e.fun.into()).or(picked) else { return }; let (skip_params, skip_args) = fun.call_offsets(method.is_some()); for (arg, param) in args.iter().skip(skip_args).zip(fun.params.iter().skip(skip_params)) { if !is_literal(arg) || param.name == "..." || param.name.is_empty() || !self.range.contains(arg.span.start) diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 1847a18..86c0106 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1305,6 +1305,16 @@ local numbered = findInput(1) assert_eq!(result["signatures"][1]["activeParameter"], 2); // The declared signature passes the same argument to its `...`. assert_eq!(result["signatures"][0]["activeParameter"], 1); + + // Inlay hints name the parameters of the signature the call picks. + let (first, _) = pos(text, "EmitInput('jobUpdated'", 0); + let (last, _) = pos(text, "local numbered", 0); + let hints = client.request( + "textDocument/inlayHint", + json!({ "textDocument": { "uri": client.uri(CLIENT) }, "range": { "start": { "line": first, "character": 0 }, "end": { "line": last + 1, "character": 0 } } }), + ); + let labels: Vec<&str> = hints.as_array().unwrap().iter().map(|h| h["label"].as_str().unwrap()).collect(); + assert_eq!(labels, ["action:", "job:", "oldJob:", "label:", "name:", "id:"]); } #[test] From 2582c7c8713bb45de6c23300f79f815cd821b007 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:32 +0200 Subject: [PATCH 049/193] fix(signature): show the docs of the local a call names, not those of a global --- crates/qbx_lua_ls/src/features/signature.rs | 49 +++++++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 26 +++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/signature.rs b/crates/qbx_lua_ls/src/features/signature.rs index b9d6d5a..ca1c36f 100644 --- a/crates/qbx_lua_ls/src/features/signature.rs +++ b/crates/qbx_lua_ls/src/features/signature.rs @@ -1,10 +1,13 @@ use lsp_types::{Documentation, ParameterInformation, ParameterLabel, Position, SignatureHelp, SignatureInformation}; use qbx_fivem_data::native_docs; -use qbx_lua_syntax::ast::ExprKind; +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::{ExprKind, Name, StmtKind}; use super::event_call::{event_call, wrapper_call}; use super::{markdown, with_infer}; use crate::document::Document; +use crate::indexer::render_doc; +use crate::infer::{Decl, Infer}; use crate::locate::locate; use crate::types::FunType; use crate::workspace::Workspace; @@ -38,13 +41,8 @@ pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Opt let mut documentation = member.and_then(|m| m.doc).map(|d| d.to_string()); if documentation.is_none() { - if let ExprKind::Name(global) = &site.base.kind { - documentation = ws - .index - .globals_named(&global.text, doc.file) - .into_iter() - .find_map(|(_, s)| s.doc.as_ref().map(|d| d.to_string())) - .or_else(|| native_docs(&global.text)); + if let ExprKind::Name(name) = &site.base.kind { + documentation = name_doc(ws, doc, infer, name); } } @@ -69,6 +67,41 @@ pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Opt }) } +/// The docs of the function that `name` calls: those of the global or native it names, or the doc +/// comment of the local it names. A local that takes a global, as `local GetEntityCoords = +/// GetEntityCoords` does, and has no doc comment of its own shows the docs of that global. +fn name_doc(ws: &Workspace, doc: &Document, infer: &Infer, name: &Name) -> Option { + let global = match doc.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => { + let (stmt, value) = match infer.ctx.decl(doc.resolution.local(id).decl.start)? { + Decl::LocalFunction { stmt, .. } => (stmt, None), + Decl::Local { stmt, index } => match &stmt.kind { + StmtKind::Local { exprs, .. } => (stmt, exprs.get(*index)), + _ => return None, + }, + _ => return None, + }; + if let Some(own) = render_doc(&infer.ctx.doc_at(stmt.span.start)) { + return Some(own.to_string()); + } + match value.map(|value| &value.kind) { + Some(ExprKind::Name(global)) + if matches!(doc.resolution.resolve_at(global.span.start), Some(Resolved::Global(_))) => + { + global + } + _ => return None, + } + } + _ => name, + }; + ws.index + .globals_named(&global.text, doc.file) + .into_iter() + .find_map(|(_, s)| s.doc.as_ref().map(|d| d.to_string())) + .or_else(|| native_docs(&global.text)) +} + /// One signature of a call, with the parameter that the argument at index `argument` is passed to. fn information( fun: &FunType, diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 86c0106..9b51063 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1317,6 +1317,32 @@ local numbered = findInput(1) assert_eq!(labels, ["action:", "job:", "oldJob:", "label:", "name:", "id:"]); } +#[test] +fn signature_help_shows_the_docs_of_the_function_a_local_holds() { + let mut client = Client::start(fixture_root()); + let text = "\ +local each = function(a, b) end +each(1, 2) +---Counts the values. +---@param n integer +local function count(n) end +count(1) +local GetEntityCoords = GetEntityCoords +GetEntityCoords(1) +"; + client.open_with(CLIENT, text); + // The cursor goes after the `(` of each call. + let mut documentation = |call: &str| { + let (l, c) = pos(text, call, call.find('(').unwrap() as u32 + 1); + let result = client.request("textDocument/signatureHelp", client.position_params(CLIENT, l, c)); + result["signatures"][0]["documentation"]["value"].as_str().map(str::to_string) + }; + assert_eq!(documentation("each(1, 2)"), None, "not the docs of the global `each` the local hides"); + assert_eq!(documentation("count(1)").as_deref(), Some("Counts the values.")); + let native = documentation("GetEntityCoords(1)").unwrap_or_default(); + assert!(native.contains("coordinates"), "a local holding a native keeps its docs: {native}"); +} + #[test] fn side_scoped_annotations_follow_the_side_of_the_code() { const SHARED: &str = "myresource/shared/config.lua"; From 0e4b508cf56a50c1ecac8a255621b5af5b7cae4a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:33 +0200 Subject: [PATCH 050/193] fix(infer): read class fields from the declarations of the file's own resource --- crates/qbx_lua_analysis/src/signature.rs | 4 +- crates/qbx_lua_ls/src/features/casts.rs | 4 +- .../qbx_lua_ls/src/features/class_tables.rs | 47 +-- crates/qbx_lua_ls/src/features/comparisons.rs | 2 +- crates/qbx_lua_ls/src/features/completion.rs | 2 +- crates/qbx_lua_ls/src/features/definition.rs | 4 +- crates/qbx_lua_ls/src/features/expected.rs | 2 +- crates/qbx_lua_ls/src/features/hover.rs | 2 +- .../qbx_lua_ls/src/features/strict_classes.rs | 4 +- crates/qbx_lua_ls/src/features/visibility.rs | 9 +- crates/qbx_lua_ls/src/index.rs | 113 +++++++- crates/qbx_lua_ls/src/indexer.rs | 16 +- crates/qbx_lua_ls/src/infer.rs | 62 +++- crates/qbx_lua_ls/tests/lsp.rs | 82 ++++++ crates/qbx_luacats/src/luacats.rs | 4 +- crates/qbx_luacats/src/types.rs | 267 +++++++++++++++++- 16 files changed, 560 insertions(+), 64 deletions(-) diff --git a/crates/qbx_lua_analysis/src/signature.rs b/crates/qbx_lua_analysis/src/signature.rs index 015eeaf..52a2bdb 100644 --- a/crates/qbx_lua_analysis/src/signature.rs +++ b/crates/qbx_lua_analysis/src/signature.rs @@ -267,7 +267,7 @@ mod tests { let names: Vec<&str> = aliases.iter().map(|(name, _)| name.as_str()).collect(); assert_eq!(names, ["Maybe", "Mode", "Wrapped"]); let alias = |name: &str| aliases.iter().find(|(n, _)| n == name).map(|(_, ty)| ty); - let named = |name: &str| Type::Named(SmolStr::new(name), Vec::new()); + let named = |name: &str| Type::Named(name.into(), Vec::new()); assert!(may_be_nil(&named("Maybe"), &alias)); assert!(may_be_nil(&named("Wrapped"), &alias)); assert!(!may_be_nil(&named("Mode"), &alias)); @@ -280,7 +280,7 @@ mod tests { let chunk = parse(source); let aliases = doc_aliases(source, &chunk.comments); let alias = |name: &str| aliases.iter().find(|(n, _)| n == name).map(|(_, ty)| ty); - let named = |name: &str| Type::Named(SmolStr::new(name), Vec::new()); + let named = |name: &str| Type::Named(name.into(), Vec::new()); assert!(!may_be_nil(&named("Mode"), &alias), "a plain comment keeps the values"); assert!(!may_be_nil(&named("Wide"), &alias), "a block comment keeps the values"); assert!(!may_be_nil(&named("Strict"), &alias), "a blank line ends the alias"); diff --git a/crates/qbx_lua_ls/src/features/casts.rs b/crates/qbx_lua_ls/src/features/casts.rs index eba97cc..e4ed2e4 100644 --- a/crates/qbx_lua_ls/src/features/casts.rs +++ b/crates/qbx_lua_ls/src/features/casts.rs @@ -62,9 +62,9 @@ fn takes_class(classes: &Classes, declared: &Type, cast: &Type) -> bool { classes.flatten(declared, file, &mut parts, 0); parts.retain(|part| *part != Type::Nil); casts.iter().all(|cast| { - let Some((class, _)) = classes.class_of(cast, file) else { return true }; + let Some((class, _, view)) = classes.class_of(cast, file) else { return true }; parts.iter().any(|part| match classes.class_of(part, file) { - Some((declared, _)) => classes.extends(&class, file, &declared), + Some((declared, ..)) => classes.extends(&class, view, &declared), None => !classes.rejects(part, file, cast), }) }) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 5af6687..8659f53 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -194,28 +194,33 @@ impl<'a, 'b> Classes<'a, 'b> { self.declarations(name, from).1 } - /// `ty`, or the type of the one alias it names as `from` sees it, with the type arguments of a - /// generic alias in place of its parameters. + /// `ty`, or the type of the one alias it names as `from` sees it, or the file that wrote the + /// name as `Infer::view_of` finds it, with the type arguments of a generic alias in place of its + /// parameters. fn resolve(&self, ty: &Type, from: FileId, depth: u32) -> Type { - match ty { - Type::Named(name, args) if depth < MAX_DEPTH && self.class_defs(name, from).is_empty() => { - match self.alias_defs(name, from).as_slice() { - [(file, alias)] => { - let ty = substitute(&alias.ty, &expanded_bindings(&alias.generics, args)); - self.resolve(&ty, *file, depth + 1) - } - _ => ty.clone(), - } + let Type::Named(name, args) = ty else { return ty.clone() }; + let from = self.infer.view_of(name, from); + if depth >= MAX_DEPTH || !self.class_defs(name, from).is_empty() { + return ty.clone(); + } + match self.alias_defs(name, from).as_slice() { + [(file, alias)] => { + let ty = substitute(&alias.ty, &expanded_bindings(&alias.generics, args)); + self.resolve(&ty, *file, depth + 1) } _ => ty.clone(), } } - /// The class a value of type `ty` must be, with the type arguments it is given, as `from` sees - /// the names, looking through `?` and aliases. - pub fn class_of(&self, ty: &Type, from: FileId) -> Option<(SmolStr, Vec)> { + /// The class a value of type `ty` must be, with the type arguments it is given, looking through + /// `?` and aliases, and the file whose view of the names decides its declarations: `from`, or + /// the file that wrote the name, as `Infer::view_of` finds it. + pub fn class_of(&self, ty: &Type, from: FileId) -> Option<(SmolStr, Vec, FileId)> { match self.resolve(&ty.without_nil(), from, 0).without_nil() { - Type::Named(name, args) if !self.class_defs(&name, from).is_empty() => Some((name, args)), + Type::Named(name, args) => { + let view = self.infer.view_of(&name, from); + (!self.class_defs(&name, view).is_empty()).then_some((name.text, args, view)) + } _ => None, } } @@ -315,8 +320,8 @@ impl<'a, 'b> Classes<'a, 'b> { } for (file, def) in self.class_defs(class, from) { for parent in &def.parent_types { - let Some((parent, _)) = self.class_of(parent, file) else { continue }; - if self.extends_at(&parent, file, ancestor, visited, depth + 1) { + let Some((parent, _, view)) = self.class_of(parent, file) else { continue }; + if self.extends_at(&parent, view, ancestor, visited, depth + 1) { return true; } } @@ -890,8 +895,10 @@ pub fn mismatched_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { } for access in accesses(infer, chunk, false) { // `value.field = nil` clears a field only when its type allows `nil`, as `string?` does. - if let (Some(value), Some((class, args))) = (access.value, classes.class_of(&access.owner, classes.file())) { - check(classes.field_type(&class, &args, classes.file(), &access.key), &access.key, value); + if let (Some(value), Some((class, args, view))) = + (access.value, classes.class_of(&access.owner, classes.file())) + { + check(classes.field_type(&class, &args, view, &access.key), &access.key, value); } } out @@ -917,7 +924,7 @@ impl<'c> Finder<'_, '_, 'c> { fn table(&mut self, expected: &Type, expr: &'c Expr, from: FileId, depth: u32) { let table = expr.unparen(); let ExprKind::Table(fields) = &table.kind else { return }; - if let Some((class, args)) = self.classes.class_of(expected, from) { + if let Some((class, args, from)) = self.classes.class_of(expected, from) { self.out.push(ClassTable { class: class.clone(), args: args.clone(), table, from }); if depth < MAX_DEPTH { for (key, _, value) in entries(self.classes.infer, fields) { diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index 2311bb9..ab18847 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -248,7 +248,7 @@ impl<'a, 'b> Declared<'a, 'b> { fn field(&self, base: &Expr, name: &str, depth: u32) -> Type { let base = self.declared(base, depth + 1); let from = self.classes.file(); - let Some((class, args)) = self.classes.class_of(&base, from) else { return Type::Unknown }; + let Some((class, args, from)) = self.classes.class_of(&base, from) else { return Type::Unknown }; self.classes.field_type(&class, &args, from, &Key::Name(name)).map(|(ty, _)| ty).unwrap_or_default() } diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index 52aacd0..6da734a 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -1393,7 +1393,7 @@ fn expected_field_items(infer: &Infer, doc: &Document, offset: u32) -> Vec = existing.iter().filter_map(named_field).map(|(name, _)| name).collect(); - let class = Type::Named(found.class.clone(), Vec::new()); + let class = Type::Named(found.class.clone().into(), Vec::new()); return Classes::new(infer) .fields(&found.class, &found.args, found.from) .into_iter() diff --git a/crates/qbx_lua_ls/src/features/definition.rs b/crates/qbx_lua_ls/src/features/definition.rs index 177a9f2..f940236 100644 --- a/crates/qbx_lua_ls/src/features/definition.rs +++ b/crates/qbx_lua_ls/src/features/definition.rs @@ -137,7 +137,7 @@ pub fn type_definition(ws: &Workspace, doc: &Document, position: Position) -> Op Target::Local(id, _) => infer.local_type_at(*id, offset), Target::Global(name, _) => infer.global_type(name), Target::Member { info, .. } => info.ty.clone(), - Target::Type(name, _) => Type::Named(name.clone(), Vec::new()), + Target::Type(name, _) => Type::Named(name.clone().into(), Vec::new()), }; let mut named = Vec::new(); named_types(infer, &ty, &mut named, 0); @@ -228,7 +228,7 @@ fn named_types(infer: &Infer, ty: &Type, out: &mut Vec, depth: u32) { match ty { Type::Named(name, args) => { if !out.iter().any(|known| matches!(known, Named::Type(known) if known == name)) { - out.push(Named::Type(name.clone())); + out.push(Named::Type(name.text.clone())); aliased_types(infer, name, args, out, depth); } args.iter().for_each(|arg| named_types(infer, arg, out, depth + 1)); diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index 0fe8eaa..733e93d 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -154,7 +154,7 @@ impl Finder<'_, '_> { let classes = Classes::new(self.infer); let from = classes.file(); match classes.class_of(&self.infer.expr(base), from) { - Some((class, args)) => field_values(&classes, &class, &args, from, name), + Some((class, args, view)) => field_values(&classes, &class, &args, view, name), None => Vec::new(), } } diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 5da2f8c..09c91ad 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -190,7 +190,7 @@ fn alias_expansions<'a>(infer: &Infer<'a>, ty: &Type) -> Vec<(SmolStr, &'a Alias let Some((_, alias)) = infer.index.alias(name, infer.side()) else { continue }; // `Result` of `---@alias Result T|nil` stands for `string?`, which is no table. if table_part(infer, part, 0).is_unknown() { - out.push((name.clone(), alias)); + out.push((name.text.clone(), alias)); } } out diff --git a/crates/qbx_lua_ls/src/features/strict_classes.rs b/crates/qbx_lua_ls/src/features/strict_classes.rs index 5ec049f..1a15fde 100644 --- a/crates/qbx_lua_ls/src/features/strict_classes.rs +++ b/crates/qbx_lua_ls/src/features/strict_classes.rs @@ -32,8 +32,8 @@ pub fn undeclared_fields(infer: &Infer, chunk: &Chunk, by_default: impl Fn(FileI } let file = classes.file(); for access in accesses(infer, chunk, true).into_iter().filter(|access| !access.on_class_table) { - let Some((class, _)) = classes.class_of(&access.owner, file) else { continue }; - if classes.is_strict(&class, file, &by_default) && !classes.declares(&class, file, &access.key) { + let Some((class, _, view)) = classes.class_of(&access.owner, file) else { continue }; + if classes.is_strict(&class, view, &by_default) && !classes.declares(&class, view, &access.key) { out.push((access.span, message(&access.key, &class))); } } diff --git a/crates/qbx_lua_ls/src/features/visibility.rs b/crates/qbx_lua_ls/src/features/visibility.rs index c8eae6c..aedba8a 100644 --- a/crates/qbx_lua_ls/src/features/visibility.rs +++ b/crates/qbx_lua_ls/src/features/visibility.rs @@ -91,11 +91,12 @@ impl<'a, 'b> Scope<'a, 'b> { Type::Union(types) => types.iter().for_each(|part| self.classes_of(part, from, out, depth + 1)), Type::Named(name, _) => { let classes = Classes::new(self.infer); + let from = self.infer.view_of(name, from); if classes.class_defs(name, from).is_empty() { let aliases = classes.alias_defs(name, from); aliases.iter().for_each(|(file, alias)| self.classes_of(&alias.ty, *file, out, depth + 1)); } else if !out.iter().any(|(known, _)| known == name) { - out.push((name.clone(), from)); + out.push((name.text.clone(), from)); } } _ => {} @@ -211,7 +212,7 @@ impl<'a, 'b> Scope<'a, 'b> { _ => false, }; match self.infer.expr(expr).without_nil() { - Type::Named(class, _) if is_table => Some(class), + Type::Named(class, _) if is_table => Some(class.text), _ => None, } } @@ -228,7 +229,7 @@ impl<'a, 'b> Scope<'a, 'b> { let Some((last, parents)) = path.split_last() else { let owner = self.infer.func_name_owner_type(&func_name(&[])); let class = match owner.without_nil() { - Type::Named(class, _) if self.infer.is_class_table(&name.base) => Some(class), + Type::Named(class, _) if self.infer.is_class_table(&name.base) => Some(class.text), _ => None, }; return (owner, class); @@ -236,7 +237,7 @@ impl<'a, 'b> Scope<'a, 'b> { let parent = self.infer.func_name_owner_type(&func_name(parents)); let Some(member) = self.infer.member(&parent, &last.text) else { return (Type::Unknown, None) }; let class = match &member.ty { - Type::Named(class, _) if member.kind == SymbolKind::Table => Some(class.clone()), + Type::Named(class, _) if member.kind == SymbolKind::Table => Some(class.text.clone()), _ => None, }; (member.ty, class) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 4cee993..6fbab5b 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -84,21 +84,36 @@ fn same_class(a: &ClassDef, b: &ClassDef) -> bool { fn indices(fields: &[DocIndexField]) -> Vec<(&Type, &Type, Option)> { fields.iter().map(|field| (&field.key, &field.ty, field.side)).collect() } + fn types(class: &ClassDef) -> impl Iterator { + let indices = class.indices.iter().chain(&class.literal_fields).flat_map(|field| [&field.key, &field.ty]); + let operators = class.operators.iter().flat_map(|operator| operator.operand.iter().chain([&operator.result])); + class.fields.iter().map(|field| &field.ty).chain(&class.parent_types).chain(indices).chain(operators) + } (a.generics == b.generics && a.parent_types == b.parent_types && a.side == b.side && a.strict == b.strict) && (fields(a) == fields(b) && a.field_sides == b.field_sides && a.field_visibility == b.field_visibility) && (indices(&a.indices) == indices(&b.indices) && indices(&a.literal_fields) == indices(&b.literal_fields)) && (a.call == b.call && a.operators == b.operators) + && (same_origins(types(a), types(b)) && a.call.iter().zip(&b.call).all(|(x, y)| x.same_origins(y))) } /// Whether two declarations of an alias or enum tell the same about its values, wherever they are /// written and whatever their docs say. fn same_alias(a: &AliasDef, b: &AliasDef) -> bool { + fn members(table: &EnumTable) -> impl Iterator { + table.members.iter().flat_map(|(key, value)| [key, value]) + } let (table_a, table_b) = (a.table.as_deref(), b.table.as_deref()); let same_table = match (table_a, table_b) { - (Some(x), Some(y)) => x.owner == y.owner && x.members == y.members, + (Some(x), Some(y)) => x.owner == y.owner && x.members == y.members && same_origins(members(x), members(y)), (x, y) => x.is_none() && y.is_none(), }; - a.generics == b.generics && a.ty == b.ty && a.side == b.side && same_table + a.generics == b.generics && a.ty == b.ty && a.ty.same_origins(&b.ty) && a.side == b.side && same_table +} + +/// Whether types that are equal one by one name their classes and aliases after the same files, so +/// that the declarations of the same resource stand for them: `==` leaves that out. +fn same_origins<'t>(a: impl IntoIterator, b: impl IntoIterator) -> bool { + a.into_iter().zip(b).all(|(a, b)| a.same_origins(b)) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -252,7 +267,7 @@ pub struct AliasDef { } /// The table an `---@enum` annotation declares. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct EnumTable { /// The owner its fields are indexed under, which the type of a name that holds it names. pub owner: SmolStr, @@ -327,6 +342,50 @@ pub struct FileIndex { pub reads: Vec, } +impl FileIndex { + /// Gives the class and alias names in the types the entry holds that have no origin the file + /// `file`, which wrote them. Those it took from the entries of other files keep theirs. + pub fn set_origin(&mut self, file: FileId) { + let fun = |fun: &mut Arc| { + let mut ty = Type::Fun(fun.clone()); + ty.set_origin(file); + if let Type::Fun(tagged) = ty { + *fun = tagged; + } + }; + let symbols = + self.globals.iter_mut().chain(&mut self.exports).chain(self.members.iter_mut().map(|m| &mut m.symbol)); + symbols.for_each(|symbol| symbol.ty.set_origin(file)); + for element in &mut self.elements { + element.key.iter_mut().for_each(|key| key.set_origin(file)); + element.value.set_origin(file); + } + self.metatables.iter_mut().for_each(|metatable| metatable.metatable.set_origin(file)); + for class in &mut self.classes { + class.fields.iter_mut().for_each(|field| field.ty.set_origin(file)); + class.parent_types.iter_mut().for_each(|parent| parent.set_origin(file)); + for field in class.indices.iter_mut().chain(&mut class.literal_fields) { + field.key.set_origin(file); + field.ty.set_origin(file); + } + class.call.iter_mut().for_each(fun); + for operator in &mut class.operators { + operator.operand.iter_mut().for_each(|operand| operand.set_origin(file)); + operator.result.set_origin(file); + } + } + for alias in &mut self.aliases { + alias.ty.set_origin(file); + if let Some(table) = &mut alias.table { + let members = &mut Arc::make_mut(table).members; + members.iter_mut().flat_map(|(key, value)| [key, value]).for_each(|ty| ty.set_origin(file)); + } + } + self.events.iter_mut().filter_map(|event| event.handler.as_mut()).for_each(fun); + self.module_return.iter_mut().for_each(|ty| ty.set_origin(file)); + } +} + /// The scripts a definition file outside any resource is written for. #[derive(Clone, Debug, Default, PartialEq)] pub struct DefinitionScope { @@ -619,7 +678,7 @@ impl Index { let globals = entry.into_iter().flat_map(|entry| &entry.index.globals); globals.map(|symbol| (Read::Global(symbol.name.clone()), &symbol.ty)) }; - changed_groups(out, globals(before), globals(after), |a, b| a == b); + changed_groups(out, globals(before), globals(after), |a, b| a == b && a.same_origins(b)); let members = |entry: Option<&'a FileEntry>| { entry.into_iter().flat_map(|entry| &entry.index.members).map(|member| { let Member { owner, symbol, injected, visibility, typed } = member; @@ -628,7 +687,7 @@ impl Index { }) }; let mut named = FxHashSet::default(); - changed_groups(&mut named, members(before), members(after), |a, b| a == b); + changed_groups(&mut named, members(before), members(after), |a, b| a == b && a.1.same_origins(b.1)); // Whoever reads every member of the tables reads each of them. let every = named.iter().filter_map(|read| match read { Read::Members(owner, _) => Some(Read::Members(owner.clone(), None)), @@ -652,12 +711,14 @@ impl Index { let elements = entry.into_iter().flat_map(|entry| &entry.index.elements); elements.map(|element| (Read::Table(element.owner.clone()), (&element.key, &element.value))) }; - changed_groups(out, elements(before), elements(after), |a, b| a == b); + changed_groups(out, elements(before), elements(after), |a, b| { + a == b && same_origins(a.0.iter().chain([a.1]), b.0.iter().chain([b.1])) + }); let metatables = |entry: Option<&'a FileEntry>| { let metatables = entry.into_iter().flat_map(|entry| &entry.index.metatables); metatables.map(|metatable| (Read::Table(metatable.owner.clone()), &metatable.metatable)) }; - changed_groups(out, metatables(before), metatables(after), |a, b| a == b); + changed_groups(out, metatables(before), metatables(after), |a, b| a == b && a.same_origins(b)); let classes = |entry: Option<&'a FileEntry>| { let classes = entry.into_iter().flat_map(|entry| &entry.index.classes); classes.map(|class| (Read::Type(class.name.clone()), class)) @@ -687,7 +748,7 @@ impl Index { }) }; let mut exported = FxHashSet::default(); - changed_groups(&mut exported, exports(before), exports(after), |a, b| a == b); + changed_groups(&mut exported, exports(before), exports(after), |a, b| a == b && a.same_origins(b)); // Whoever reads every export of the resource reads each of them. let every = exported.iter().filter_map(|read| match read { Read::Exports(resource, _) => Some(Read::Exports(resource.clone(), None)), @@ -700,12 +761,14 @@ impl Index { let callbacks = events.filter(|event| event.kind == EventKind::Callback); callbacks.map(|event| (Read::Callback(event.name.clone()), (&event.family, event.side, &event.handler))) }; - changed_groups(out, callbacks(before), callbacks(after), |a, b| a == b); + changed_groups(out, callbacks(before), callbacks(after), |a, b| { + a == b && a.2.iter().zip(b.2).all(|(x, y)| x.same_origins(y)) + }); let module = |entry: Option<&'a FileEntry>| { let returned = entry.and_then(|entry| Some((entry, entry.index.module_return.as_ref()?))); returned.map(|(entry, ty)| (Read::Module(normalize_path(&entry.path)), ty)).into_iter() }; - changed_groups(out, module(before), module(after), |a, b| a == b); + changed_groups(out, module(before), module(after), |a, b| a == b && a.same_origins(b)); } /// The files that read what a file in `changed` declares differently and see that file, the file @@ -1150,4 +1213,34 @@ mod tests { assert!(std::panic::catch_unwind(indexing).is_err()); assert!(index.reads.borrow().is_none()); } + + #[test] + fn a_class_name_written_in_another_file_is_declared_differently() { + // `Mid = Base`, with the `Item` of `Base` written in the file `origin`. + let entry = |origin: u32| { + let mut ty = Type::named("Item"); + ty.set_origin(origin); + let mid = Symbol { + name: "Mid".into(), + kind: SymbolKind::Variable, + ty, + doc: None, + deprecated: false, + literal: None, + range: Range::default(), + }; + FileEntry { + path: PathBuf::from("mid.lua"), + uri: Url::parse("file:///mid.lua").unwrap(), + origin: FileOrigin::Workspace, + resource: None, + side: None, + index: FileIndex { globals: vec![mid], ..FileIndex::default() }, + } + }; + let index = Index::default(); + assert!(index.changes(Some(&entry(1)), Some(&entry(1))).is_empty()); + let changed = index.changes(Some(&entry(1)), Some(&entry(2))); + assert_eq!(changed, FxHashSet::from_iter([Read::Global("Mid".into())])); + } } diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 5e928e5..c0c4b45 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -116,6 +116,9 @@ pub fn index_file( out.reads.extend(global_reads(resolution).into_iter().map(Read::Global)); out.reads.sort_unstable(); out.reads.dedup(); + // The class names this file wrote stand for the declarations it sees, also where other resources + // read its types. + out.set_origin(file); out } @@ -262,7 +265,7 @@ impl<'a> Indexer<'a> { fn owner_of(&self, ty: &Type) -> Option { match ty { - Type::Named(name, _) => Some(name.clone()), + Type::Named(name, _) => Some(name.text.clone()), Type::GlobalTable(owner) => Some(owner.clone()), Type::Union(types) => { let owners: Vec = types.iter().filter_map(|t| self.owner_of(t)).collect(); @@ -277,7 +280,7 @@ impl<'a> Indexer<'a> { fn global_class(&self, name: &str) -> Option { let own = self.out.globals.iter().rev().find(|s| s.name == name).map(|s| s.ty.clone()); match own.unwrap_or_else(|| self.infer.global_type(name)) { - Type::Named(class, _) => Some(class), + Type::Named(class, _) => Some(class.text), _ => None, } } @@ -696,7 +699,7 @@ impl<'a> Indexer<'a> { } }; match &symbol.ty { - Type::Named(class, _) if symbol.is_class_table() => Some(class.clone()), + Type::Named(class, _) if symbol.is_class_table() => Some(class.text.clone()), _ => None, } } @@ -734,8 +737,11 @@ impl<'a> Indexer<'a> { ExprKind::Name(root) => self.global_class(&root.text), _ => None, }; - match own_class.map(|class| Type::Named(class, Vec::new())).unwrap_or_else(|| self.infer.expr(base)) { - Type::Named(class, _) => (class, true), + match own_class + .map(|class| Type::Named(class.into(), Vec::new())) + .unwrap_or_else(|| self.infer.expr(base)) + { + Type::Named(class, _) => (class.text, true), _ => (SmolStr::new(path), false), } } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index e018fd9..b98ad9f 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -16,7 +16,7 @@ use crate::index::{instance_class, instance_owner, ClassDef, FileId, Index, Symb use crate::locate::statement_at; use crate::luacats::{applies_on, own_type, parse_doc_lines, CastEntry, DocGroup, DocOperator}; use crate::narrow::{Casts, Guards}; -use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeParser}; +use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeName, TypeParser}; const MAX_DEPTH: u32 = 24; const MAX_SHAPE_FIELDS: usize = 96; @@ -2190,9 +2190,9 @@ impl<'a> Infer<'a> { if depth > 8 || arg.is_unknown() { return; } - let mut bind = |name: &SmolStr, ty: Type| { + let mut bind = |name: &str, ty: Type| { if !bound.iter().any(|(n, _)| n == name) { - bound.push((name.clone(), ty)); + bound.push((SmolStr::new(name), ty)); } }; match param { @@ -2940,6 +2940,25 @@ impl<'a> Infer<'a> { } } + /// The file whose view of the class name `name`, read in `from`, decides which declarations it + /// stands for: the file that wrote it, when the type comes from the index entry of a file of + /// another resource that `from` does not import, or else `from`. Definition files and stubs + /// outside resources describe their classes for every resource, and an imported file runs as + /// part of the resource that imports it, so the names they write decide nothing. + pub fn view_of(&self, name: &TypeName, from: FileId) -> FileId { + let resource = |file: FileId| self.index.file(file).and_then(|entry| entry.resource); + match name.origin { + Some(origin) + if resource(origin).is_some() + && resource(origin) != resource(from) + && !self.index.is_visible(from, origin) => + { + origin + } + _ => from, + } + } + /// The members of the class or alias `name` given the type arguments `args`, with those of its /// parents: the fields of a class, those set on the table its `---@class` declares, and the /// fields of a table type it names as a parent, like `{ name: string }`. A class that several @@ -2947,14 +2966,14 @@ impl<'a> Infer<'a> { /// arguments, as `---@class Tree : Tree` does. fn class_members( &self, - name: &str, + name: &TypeName, args: &[Type], filter: Option<&str>, out: &mut Vec, visited: &mut FxHashSet, depth: u32, ) { - if depth > 8 || !visited.insert(SmolStr::new(name)) { + if depth > 8 || !visited.insert(name.text.clone()) { return; } let mut defs = self.index.class_defs(name); @@ -2966,6 +2985,34 @@ impl<'a> Infer<'a> { } return; } + // Resources may declare classes of the same name differently. When the resource of the file + // whose view decides, as `view_of` finds it, describes the class in its `---@meta` files, its + // scripts for the side of that file or the modules it loads through `files` or `require`, + // those declarations give the members, and otherwise all of them do: the `Player` that + // `exports.qbx_core:GetPlayer()` gives is qbx_core's. A declaration that only names the + // class, as `---@class Player` above code that uses those players does, describes nothing. + let view = self.view_of(name, self.ctx.file); + let entry = |file: FileId| self.index.file(file); + let (resource, side) = entry(view).map_or((None, None), |entry| (entry.resource, entry.side)); + let own = |file: FileId| { + file == view + || entry(file).is_some_and(|other| { + resource.is_some() + && other.resource == resource + && (other.index.meta + || other.side.is_none_or(|theirs| side.is_none_or(|ours| theirs.is_available_on(ours)))) + }) + }; + let describes = |class: &ClassDef| { + !class.fields.is_empty() + || !class.indices.is_empty() + || !class.literal_fields.is_empty() + || !class.parent_types.is_empty() + || class.call.is_some() + }; + if defs.iter().any(|(file, class)| own(*file) && describes(class)) { + defs.retain(|(file, _)| own(*file)); + } let own = out.len(); for (file, class) in &defs { let sides = class.field_sides.iter().copied().chain(std::iter::repeat(None)); @@ -3145,9 +3192,8 @@ pub(crate) fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { if generics.is_empty() { return ty.clone(); } - let bound = |name: &SmolStr| { - generics.iter().find(|(n, _)| n == name).map_or_else(|| ty.clone(), |(_, bound)| bound.clone()) - }; + let bound = + |name: &str| generics.iter().find(|(n, _)| n == name).map_or_else(|| ty.clone(), |(_, bound)| bound.clone()); match ty { Type::Named(name, args) if args.is_empty() => bound(name), // A returned `` `T` `` is the class that the string passed for it names. diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 9b51063..17d4d0c 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3524,6 +3524,88 @@ end ); } +#[test] +fn class_members_come_from_the_declarations_of_their_resource() { + let mut client = Client::start(fixture_root()); + let shop = "---@class Test.Shape\n---@field other integer\n\n---@class Test.Box\n---@field depth integer\n"; + client.open_with("shop/shared.lua", shop); + let marked = "\ +---@class Test.Shape +---@field width integer +local Shape = {} + +---@type Test.Shape +local shape +print(shape.other, shape.|) +"; + let (line, column) = pos(marked, "|", 0); + let text = marked.replace('|', ""); + client.open_with(CLIENT, &text); + assert_eq!(client.completion_labels(CLIENT, line, column), ["width"], "not the field of `shop`'s class"); + let (l, c) = pos(&text, "other", 0); + assert!(!client.hover_text(CLIENT, l, c).contains("integer")); + // A client script does not describe what the server scripts of its resource see, but a module + // that it loads through `files` or `require` does, as a `---@meta` file does. + let marked = "---@type Test.Shape\nlocal shape\nprint(shape.|)\n"; + let (line, column) = pos(marked, "|", 0); + client.open_with(SERVER, &marked.replace('|', "")); + let mut labels = client.completion_labels(SERVER, line, column); + labels.sort(); + assert_eq!(labels, ["other", "width"]); + let marked = "---@type Test.Box\nlocal box\nprint(box.|)\n"; + let (line, column) = pos(marked, "|", 0); + client.open_with(SERVER, &marked.replace('|', "")); + let module = "---@class Test.Box\n---@field size integer\n"; + client.open_with("myresource/modules/box.lua", module); + assert_eq!(client.completion_labels(SERVER, line, column), ["size"]); + client.change("myresource/modules/box.lua", 2, &format!("---@meta\n{module}")); + assert_eq!(client.completion_labels(SERVER, line, column), ["size"]); + + // A resource that does not describe the class reads all declarations, also when it names the + // class itself or imports one of them: a value of the class may come from another resource, + // as through its exports. + let marked = "---@class Test.Shape object from elsewhere\n\n---@type Test.Shape\nlocal shape\nprint(shape.|)\n"; + let (line, column) = pos(marked, "|", 0); + client.open_with("late/server.lua", &marked.replace('|', "")); + let mut labels = client.completion_labels("late/server.lua", line, column); + labels.sort(); + assert_eq!(labels, ["other", "width"]); + let mylib = client.open("[core]/mylib/init.lua"); + client.change("[core]/mylib/init.lua", 2, &format!("{mylib}\n---@class Test.Player\n---@field handle integer\n")); + client.open_with("shop/server.lua", "---@class Test.Player\n---@field PlayerData table\n"); + let marked = "---@type Test.Player\nlocal player\nprint(player.|)\n"; + let (line, column) = pos(marked, "|", 0); + client.open_with(SERVER, &marked.replace('|', "")); + let mut labels = client.completion_labels(SERVER, line, column); + labels.sort(); + assert_eq!(labels, ["PlayerData", "handle"]); +} + +#[test] +fn class_values_from_another_resource_keep_its_declarations() { + let mut client = Client::start(fixture_root()); + client.open_with("shop/shared.lua", "---@class Test.Crate\n---@field label string\n"); + client.open_with( + "shop/server.lua", + "---@return Test.Crate\nlocal function newCrate() end\nexports('NewCrate', newCrate)\n", + ); + // This resource declares a class of the same name differently, which its own values have. + let text = "\ +---@class Test.Crate +---@field weight integer + +local crate = exports.shop:NewCrate() +---@type Test.Crate +local own +print(crate., own.) +"; + client.open_with(SERVER, text); + let (line, column) = pos(text, "crate.,", 6); + assert_eq!(client.completion_labels(SERVER, line, column), ["label"], "the crate comes from shop"); + let (line, column) = pos(text, "own.)", 4); + assert_eq!(client.completion_labels(SERVER, line, column), ["weight"]); +} + #[test] fn private_members_complete_in_a_method_that_is_not_closed_yet() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index addb850..14f64a3 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -610,7 +610,7 @@ fn class_parents(text: &str) -> Vec { /// as `{ [number]: T }` or `table`, is named by its type. fn parent_name(parent: &Type) -> SmolStr { match parent { - Type::Named(name, _) => name.clone(), + Type::Named(name, _) => name.text.clone(), other => SmolStr::new(other.to_string()), } } @@ -636,7 +636,7 @@ fn in_class(class: &DocClass, ty: Type) -> Type { /// methods: `List`, which keeps the type parameters of the class, as a class named without /// type arguments elsewhere does. pub fn own_type(name: &SmolStr, generics: &[SmolStr]) -> Type { - Type::Named(name.clone(), generics.iter().map(|param| Type::Named(param.clone(), Vec::new())).collect()) + Type::Named(name.into(), generics.iter().map(|param| Type::Named(param.into(), Vec::new())).collect()) } /// Reads a `@field` into the latest class of `group`. True when it adds a field of its own, the diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index b0cf057..5d0d596 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -23,7 +23,7 @@ pub enum Type { IntLit(i64), /// A native handle such as `Vehicle` or `Hash`: an integer that keeps its name for display. Handle(SmolStr), - Named(SmolStr, Vec), + Named(TypeName, Vec), Array(Box), Map(Box, Box), Tuple(Vec), @@ -42,6 +42,141 @@ pub enum Type { NameOf(SmolStr), } +/// The name of a class or alias as a type writes it, with the file whose doc comment wrote it once +/// the language server's index holds the type: resources may declare classes of the same name +/// differently, and the file tells which resource's declarations the name stands for. Names compare, +/// order and print as their text alone. +#[derive(Clone, Debug, Default)] +pub struct TypeName { + pub text: SmolStr, + /// The file that wrote the name, as the language server numbers its files. + pub origin: Option, +} + +impl TypeName { + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::ops::Deref for TypeName { + type Target = str; + + fn deref(&self) -> &str { + &self.text + } +} + +impl AsRef for TypeName { + fn as_ref(&self) -> &str { + &self.text + } +} + +impl std::borrow::Borrow for TypeName { + fn borrow(&self) -> &str { + &self.text + } +} + +impl PartialEq for TypeName { + fn eq(&self, other: &Self) -> bool { + self.text == other.text + } +} + +impl Eq for TypeName {} + +impl std::hash::Hash for TypeName { + fn hash(&self, state: &mut H) { + self.text.hash(state); + } +} + +impl PartialOrd for TypeName { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TypeName { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.text.cmp(&other.text) + } +} + +impl PartialEq for TypeName { + fn eq(&self, other: &str) -> bool { + self.text == other + } +} + +impl PartialEq<&str> for TypeName { + fn eq(&self, other: &&str) -> bool { + self.text == *other + } +} + +impl PartialEq for TypeName { + fn eq(&self, other: &SmolStr) -> bool { + self.text == *other + } +} + +impl PartialEq for SmolStr { + fn eq(&self, other: &TypeName) -> bool { + *self == other.text + } +} + +impl PartialEq for str { + fn eq(&self, other: &TypeName) -> bool { + self == other.text + } +} + +impl PartialEq for &str { + fn eq(&self, other: &TypeName) -> bool { + *self == other.text + } +} + +impl fmt::Display for TypeName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.text) + } +} + +impl From for TypeName { + fn from(text: SmolStr) -> Self { + Self { text, origin: None } + } +} + +impl From<&str> for TypeName { + fn from(text: &str) -> Self { + Self { text: SmolStr::new(text), origin: None } + } +} + +impl From<&SmolStr> for TypeName { + fn from(text: &SmolStr) -> Self { + Self { text: text.clone(), origin: None } + } +} + +impl From for SmolStr { + fn from(name: TypeName) -> Self { + name.text + } +} + +impl From<&TypeName> for SmolStr { + fn from(name: &TypeName) -> Self { + name.text.clone() + } +} + #[derive(Clone, Debug, PartialEq, Default)] pub struct Param { pub name: SmolStr, @@ -140,6 +275,70 @@ pub struct Shape { pub indices: Vec<(Type, Type)>, } +impl Shape { + fn set_origin(&mut self, origin: u32) { + self.fields.iter_mut().for_each(|field| field.ty.set_origin(origin)); + self.array.iter_mut().for_each(|ty| ty.set_origin(origin)); + for (key, value) in &mut self.indices { + key.set_origin(origin); + value.set_origin(origin); + } + } + + fn lacks_origin(&self) -> bool { + self.fields.iter().any(|field| field.ty.lacks_origin()) + || self.array.as_ref().is_some_and(Type::lacks_origin) + || self.indices.iter().any(|(key, value)| key.lacks_origin() || value.lacks_origin()) + } + + fn origins(&self, out: &mut Vec>) { + self.fields.iter().for_each(|field| field.ty.origins(out)); + self.array.iter().for_each(|ty| ty.origins(out)); + for (key, value) in &self.indices { + key.origins(out); + value.origins(out); + } + } +} + +impl FunType { + /// Gives the class and alias names in the signature that have no origin the file `origin`, as + /// `Type::set_origin` does. + pub fn set_origin(&mut self, origin: u32) { + self.params.iter_mut().for_each(|param| param.ty.set_origin(origin)); + self.returns.iter_mut().for_each(|ty| ty.set_origin(origin)); + self.return_sets.iter_mut().flatten().for_each(|ty| ty.set_origin(origin)); + for overload in &mut self.overloads { + if overload.lacks_origin() { + Arc::make_mut(overload).set_origin(origin); + } + } + } + + fn lacks_origin(&self) -> bool { + self.params.iter().any(|param| param.ty.lacks_origin()) + || self.returns.iter().any(Type::lacks_origin) + || self.return_sets.iter().flatten().any(Type::lacks_origin) + || self.overloads.iter().any(|overload| overload.lacks_origin()) + } + + /// Whether `other`, a signature equal to this one, names its classes and aliases after the same + /// files, which `==` leaves out. + pub fn same_origins(&self, other: &FunType) -> bool { + let (mut ours, mut theirs) = (Vec::new(), Vec::new()); + self.origins(&mut ours); + other.origins(&mut theirs); + ours == theirs + } + + fn origins(&self, out: &mut Vec>) { + self.params.iter().for_each(|param| param.ty.origins(out)); + self.returns.iter().for_each(|ty| ty.origins(out)); + self.return_sets.iter().flatten().for_each(|ty| ty.origins(out)); + self.overloads.iter().for_each(|overload| overload.origins(out)); + } +} + impl Type { pub fn named(name: &str) -> Type { match name { @@ -156,7 +355,69 @@ impl Type { "true" => Type::BooleanLit(true), "false" => Type::BooleanLit(false), "unknown" => Type::Unknown, - _ => Type::Named(SmolStr::new(name), Vec::new()), + _ => Type::Named(name.into(), Vec::new()), + } + } + + /// Gives the class and alias names in the type that have no origin the file `origin`, as the + /// language server does for the types that the index entry of that file holds. + pub fn set_origin(&mut self, origin: u32) { + match self { + Type::Named(name, args) => { + name.origin.get_or_insert(origin); + args.iter_mut().for_each(|arg| arg.set_origin(origin)); + } + Type::Array(inner) | Type::Variadic(inner) => inner.set_origin(origin), + Type::Map(key, value) => { + key.set_origin(origin); + value.set_origin(origin); + } + Type::Tuple(types) | Type::Union(types) => types.iter_mut().for_each(|ty| ty.set_origin(origin)), + // What other types share is only copied when it has a name to give the origin. + Type::Fun(fun) if fun.lacks_origin() => Arc::make_mut(fun).set_origin(origin), + Type::Shape(shape) if shape.lacks_origin() => Arc::make_mut(shape).set_origin(origin), + _ => {} + } + } + + /// Whether some class or alias name in the type has no origin. + fn lacks_origin(&self) -> bool { + match self { + Type::Named(name, args) => name.origin.is_none() || args.iter().any(Type::lacks_origin), + Type::Array(inner) | Type::Variadic(inner) => inner.lacks_origin(), + Type::Map(key, value) => key.lacks_origin() || value.lacks_origin(), + Type::Tuple(types) | Type::Union(types) => types.iter().any(Type::lacks_origin), + Type::Fun(fun) => fun.lacks_origin(), + Type::Shape(shape) => shape.lacks_origin(), + _ => false, + } + } + + /// Whether `other`, a type equal to this one, names its classes and aliases after the same + /// files, which `==` leaves out. + pub fn same_origins(&self, other: &Type) -> bool { + let (mut ours, mut theirs) = (Vec::new(), Vec::new()); + self.origins(&mut ours); + other.origins(&mut theirs); + ours == theirs + } + + /// The files that the class and alias names in the type were written in, in order. + fn origins(&self, out: &mut Vec>) { + match self { + Type::Named(name, args) => { + out.push(name.origin); + args.iter().for_each(|arg| arg.origins(out)); + } + Type::Array(inner) | Type::Variadic(inner) => inner.origins(out), + Type::Map(key, value) => { + key.origins(out); + value.origins(out); + } + Type::Tuple(types) | Type::Union(types) => types.iter().for_each(|ty| ty.origins(out)), + Type::Fun(fun) => fun.origins(out), + Type::Shape(shape) => shape.origins(out), + _ => {} } } @@ -782,7 +1043,7 @@ impl<'a> TypeParser<'a> { Type::Map(Box::new(key), Box::new(value)) } ("table", 1) => Type::Array(Box::new(args.pop().unwrap_or_default())), - _ => Type::Named(SmolStr::new(name), args), + _ => Type::Named(name.into(), args), } } From 27d583e78049d9ccccc812db3460720600709643 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:33 +0200 Subject: [PATCH 051/193] fix(infer): call the @overload of a class that a method field holds --- crates/qbx_lua_ls/src/infer.rs | 38 +++++++++++++++++----------------- crates/qbx_lua_ls/tests/lsp.rs | 23 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index b98ad9f..0a8068c 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -1976,28 +1976,12 @@ impl<'a> Infer<'a> { match method { Some(method) => { let member = self.member(&self.expr(base), &method.text)?; - Some((self.fun_of(&member.ty)?, Some(member))) + let fun = self.fun_of(&member.ty).or_else(|| self.class_call(&member.ty))?; + Some((fun, Some(member))) } None => { let ty = self.expr(base); - if let Some(fun) = self.fun_of(&ty) { - return Some((fun, None)); - } - match self.resolve_alias(&ty) { - Type::Named(name, args) => { - let call = self.index.class(&name, self.side).and_then(|(_, c)| c.call.clone()); - let call = match call.filter(|f| applies_on(f.side, self.side)) { - Some(call) => call, - None => self.call_operator(&name)?, - }; - let bindings = self.class_bindings_of(&name, &args); - match bindings.is_empty() { - true => Some((call, None)), - false => Some((Arc::new(substitute_fun(&call, &bindings)), None)), - } - } - _ => None, - } + Some((self.fun_of(&ty).or_else(|| self.class_call(&ty))?, None)) } } } @@ -2019,6 +2003,22 @@ impl<'a> Infer<'a> { })) } + /// The `@overload` that a value of class `ty` is called with, as `lib.array:new()` calls the + /// `ArrayConstructor` its `new` field holds, or else the function its `@operator call` makes of it. + fn class_call(&self, ty: &Type) -> Option> { + let Type::Named(name, args) = self.resolve_alias(ty) else { return None }; + let call = self.index.class(&name, self.side).and_then(|(_, c)| c.call.clone()); + let call = match call.filter(|f| applies_on(f.side, self.side)) { + Some(call) => call, + None => self.call_operator(&name)?, + }; + let bindings = self.class_bindings_of(&name, &args); + match bindings.is_empty() { + true => Some(call), + false => Some(Arc::new(substitute_fun(&call, &bindings))), + } + } + fn call(&self, base: &Expr, method: Option<&Name>, args: &[Expr]) -> Vec { self.call_values(base, method, args).values } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 17d4d0c..e7c623d 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -1126,6 +1126,29 @@ onShop('closed', function(closedShop) end) } } +#[test] +fn methods_holding_a_class_call_its_overload() { + let mut client = Client::start(fixture_root()); + // ox_lib's `lib.array:new()` calls the `ArrayConstructor` that its `new` field holds. + let text = "\ +---@class Test.Array : { [number]: integer } +---@field private new Test.ArrayConstructor +local Array = {} + +---@class Test.ArrayConstructor +---@overload fun(self: Test.Array, ...: integer): Test.Array + +local list = Array:new(1, 2) +local same = Array.new(Array, 1, 2) +"; + client.open_with(CLIENT, text); + for name in ["list", "same"] { + let (l, c) = pos(text, &format!("local {name}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(&format!("local {name}: Test.Array")), "{hover}"); + } +} + #[test] fn hover_reads_docs_past_other_comments() { let mut client = Client::start(fixture_root()); From 439f9d73ff3ce8c9d581e57d13f7322a56584b67 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:36 +0200 Subject: [PATCH 052/193] fix(infer): read self in the type of a local as unknown --- crates/qbx_lua_ls/src/infer.rs | 20 +++++++++++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 0a8068c..febb1bf 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -959,8 +959,8 @@ impl<'a> Infer<'a> { if let Some(class) = doc.declared_class() { return own_type(&class.name, &class.generics); } - if let Some(ty) = doc.type_at(index) { - return ty.clone(); + if let Some(ty) = local_annotation(&doc, index) { + return ty; } if *in_unpack { let base = exprs.first().map(|e| self.expr(e)).unwrap_or_default(); @@ -1200,7 +1200,13 @@ impl<'a> Infer<'a> { // `---@class Name` above a table declares the class rather than a value of it, // unless a `---@type` follows it. let doc = self.ctx.doc_at(stmt.span.start); - doc.type_at(index).filter(|_| doc.declared_class().is_none()).cloned() + if doc.declared_class().is_some() { + return None; + } + match &stmt.kind { + StmtKind::Local { .. } => local_annotation(&doc, index), + _ => doc.type_at(index).cloned(), + } } Expected::Field { table, key } => { let at = self.ctx.tables.get(&table.span.start)?; @@ -3431,6 +3437,14 @@ fn field_start(source: &str, field: &TableField) -> u32 { } } +/// The `---@type` that `doc` gives the name at `index` of the `local` statement below it. A local +/// is no member of a class, so a `self` in the type stands for nothing and is unknown, as in +/// lua-language-server. +fn local_annotation(doc: &DocGroup, index: usize) -> Option { + let ty = doc.type_at(index)?; + Some(if ty.mentions_self() { ty.with_self(&Type::Unknown) } else { ty.clone() }) +} + /// The functions a statement defines under its doc comment: `function f()`, `local function f()`, /// function literals assigned directly, `local f = function()` or `M.f = function()`, and those /// passed to the call the statement makes or assigns, `RegisterServerCallback('name', function()` diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index e7c623d..3b61fbb 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6830,6 +6830,36 @@ local ran = run(function() return 1 end) assert!(run.contains("local function run(job: async fun(): integer)"), "{run}"); } +#[test] +fn self_in_the_type_of_a_local_is_unknown() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type fun(): self +local make +local made = make() + +---@class Test.Maker +local Maker = {} + +function Maker:build() + ---@type self + local me + return me +end +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("make\n", "local function make(): unknown"), + ("made", "local made: unknown"), + // A local in a method is no member of the class either, as lua-language-server reads it. + ("me\n", "local me: unknown"), + ] { + let (l, c) = pos(text, &format!("local {needle}"), 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn functions_tagged_async_show_it_in_hover() { let mut client = Client::start(fixture_root()); From eb2ca9eda99b81a05f63de5f5f5d59c889c298ea Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:37 +0200 Subject: [PATCH 053/193] fix(infer): type the parameters of functions defined for a declared field --- crates/qbx_lua_ls/src/infer.rs | 55 ++++++++++++++++++++++++++++++++-- crates/qbx_lua_ls/tests/lsp.rs | 45 ++++++++++++++++++++++++++++ docs/reference.md | 7 +++-- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index febb1bf..1a255de 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -115,8 +115,12 @@ pub enum Decl<'a> { pub enum Expected<'a> { /// Argument `arg_index` of a call, which has the type of the parameter it is passed for. Arg { call: &'a Expr, arg_index: usize }, - /// Value `index` of a `local` statement or an assignment, typed by the `---@type` above it. + /// Value `index` of a `local` statement or an assignment, typed by the `---@type` above it, or + /// for a function assigned to a field of a class, by the `@field` it implements. Value { stmt: &'a Stmt, index: usize }, + /// The function that `function a.b.c()` or `function a.b:c()` defines, typed by the `@field` + /// `c` of the class `a.b` is. + Member { stmt: &'a Stmt }, /// The value of the field `key` of a table constructor, which has the type of that field of the /// table. Field { table: &'a Expr, key: FieldKey<'a> }, @@ -340,7 +344,7 @@ impl<'a> DeclCollector<'a> { if name.method.is_some() { self.decls.insert(func.params_span.start, Decl::SelfParam { name }); } - self.func(func, anchor, None); + self.func(func, anchor, Some(Expected::Member { stmt })); } StmtKind::Assign { targets, exprs } => { targets.iter().for_each(|e| self.expr(e, None)); @@ -1205,9 +1209,19 @@ impl<'a> Infer<'a> { } match &stmt.kind { StmtKind::Local { .. } => local_annotation(&doc, index), + StmtKind::Assign { exprs, .. } => { + doc.type_at(index).cloned().or_else(|| match &exprs.get(index)?.kind { + ExprKind::Function(func) => self.field_signature(stmt, func), + _ => None, + }) + } _ => doc.type_at(index).cloned(), } } + Expected::Member { stmt } => match &stmt.kind { + StmtKind::Function { func, .. } => self.field_signature(stmt, func), + _ => None, + }, Expected::Field { table, key } => { let at = self.ctx.tables.get(&table.span.start)?; let table = self.guarded(|| self.expected_type(at))?; @@ -1216,6 +1230,43 @@ impl<'a> Infer<'a> { } } + /// The `fun(...)` that `func`, which `stmt` puts in a field of a table, implements: the type the + /// class of the table declares for the field with `@field`. It counts when `func` takes as many + /// parameters as it lists, besides the `self` that a method declared with `:` takes itself. + fn field_signature(&self, stmt: &Stmt, func: &FuncBody) -> Option { + let (name, is_method) = match &stmt.kind { + StmtKind::Function { name, .. } => { + (&name.method.as_ref().or(name.path.last())?.text, name.method.is_some()) + } + StmtKind::Assign { targets, exprs } => { + let defines = |expr: &Expr| matches!(&expr.kind, ExprKind::Function(f) if std::ptr::eq(&**f, func)); + match &targets.get(exprs.iter().position(defines)?)?.kind { + ExprKind::Field { name, .. } => (&name.text, false), + ExprKind::Index { index, .. } => (index.as_string()?, false), + _ => return None, + } + } + _ => return None, + }; + let fun = self.fun_of(&self.declared_field(&self.doc_self(stmt, func), name)?)?; + let skip = usize::from(is_method && fun.params.first().is_some_and(|param| param.name == "self")); + let takes = func.params.len() + usize::from(func.vararg.is_some()); + (fun.params.len() == skip + takes) + .then(|| Type::Fun(Arc::new(FunType { params: fun.params[skip..].to_vec(), ..(*fun).clone() }))) + } + + /// The type of the `@field` called `name` that the class `owner` is, or a parent, declares, + /// leaving out what code sets on the tables of the class. + fn declared_field(&self, owner: &Type, name: &str) -> Option { + let declared = |member: &MemberInfo| { + member.location.is_some_and(|(file, range)| { + let classes = self.index.file(file).map(|entry| entry.index.classes.as_slice()).unwrap_or_default(); + classes.iter().any(|class| class.fields.iter().any(|field| field.name == name && field.range == range)) + }) + }; + self.members_named(owner, name).into_iter().find(declared).map(|member| member.ty) + } + /// The signature that `call` uses, with its arguments and whether it calls a method. fn call_parts<'e>(&self, call: &'e Expr) -> Option<(Arc, CallArgs<'e>, bool)> { let (base, method, args) = match &call.kind { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 3b61fbb..c108593 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6015,6 +6015,51 @@ local counter = function(n) end } } +#[test] +fn functions_defined_for_a_declared_field_take_its_parameter_types() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Timer +---@field start fun(self: Test.Timer, async?: boolean) +---@field getTimeLeft fun(self: Test.Timer, format?: 'ms'|'s'): number +---@field onEnd fun(reason: string) +---@field render fun(self: Test.Timer, alpha: number) +---@field stale fun(self: Test.Timer, first: integer) +local Timer = {} + +function Timer:start(async) end +function Timer:getTimeLeft(format) return 0 end +Timer.onEnd = function(reason) end +function Timer.render(self, alpha) end +---@param second string +function Timer:stale(first, second) end +function Timer:undeclared(value) end + +---@type Test.Timer +local timer = Timer +timer.onEnd = function(why) end +"; + client.open_with(CLIENT, text); + let cases = [ + // A method declared with `:` takes the `self` of the field itself. + ("async)", "async: boolean?"), + ("format)", "format: \"ms\"|\"s\"|nil"), + ("reason)", "reason: string"), + ("alpha)", "alpha: number"), + ("why)", "why: string"), + // A function with more parameters than the field lists does not implement it. + ("first, second", "first: unknown"), + ("second)", "second: string"), + // Only a `@field` declares a field, not another function set on the class. + ("value)", "value: unknown"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn functions_in_table_fields_return_what_the_return_above_their_field_says() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index e8501ae..4557282 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -706,7 +706,7 @@ Only clear cases count, and the rest is left alone: `no-unknown` is off by default. Turned on, qbx-lua-ls reports each parameter, local and loop variable whose type is unknown: none is declared, and none can be inferred from its value, from the function a callback is passed to, from the declared type of the table field a function is -written in, or from what a loop goes through. +written in or defined for, or from what a loop goes through. ```toml [rules] @@ -730,7 +730,10 @@ call, whether that is the call alone or `handlers[name] = RegisterNetEvent(name, A function written in a table takes the `---@param` lines above its field, and the parameter types of the field's `fun(...)` when the table has a declared type: the `---@type` above its `local`, the type of the parameter it is passed for, or `metatable` for the second argument of -`setmetatable`. A local declared without a value needs a `---@type`, since +`setmetatable`. A function defined for a `---@field name fun(...)` of a class, with +`function Class:name()`, `function Class.name()` or `Class.name = function()`, takes the parameter +types of that `fun(...)` when it has as many parameters, not counting the `self` of `:`. A local +declared without a value needs a `---@type`, since later assignments are not followed. Names that start with `ignore_unused_prefix`, and `self`, are not reported. To check only your own resources, set the level in an `[[overrides]]` entry instead of `[rules]`. From bffda2febe64a07c3732d51e19f8dbfe9ec062bd Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:38 +0200 Subject: [PATCH 054/193] fix(index): pass the first value of an export registered from a method to self --- crates/qbx_lua_ls/src/indexer.rs | 27 ++++++++++++++++++++++++++- crates/qbx_lua_ls/tests/lsp.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index c0c4b45..799e792 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -17,7 +17,7 @@ use crate::index::{ }; use crate::infer::{metatable_args, table_elements, table_fields, FileContext, Infer}; use crate::luacats::{own_type, parse_doc_lines, DocGroup}; -use crate::types::{CallbackRole, DescribedValue, FunType, Type}; +use crate::types::{CallbackRole, DescribedValue, FunType, Param, Type}; const MAX_TABLE_DEPTH: u32 = 4; const MAX_TABLE_FIELDS: usize = 400; @@ -128,6 +128,19 @@ fn global_reads(resolution: &Resolution) -> Vec { reads.filter(|name| qbx_fivem_data::native(name).is_none()).collect() } +/// `fun`, declared with `:`, as the function value it is outside a `:` call: one that takes the +/// receiver, of type `receiver`, as its first parameter, `self`. +fn with_receiver(fun: &FunType, receiver: &Type) -> Arc { + let this = Param { name: SmolStr::new("self"), ty: receiver.clone(), ..Param::default() }; + let params = std::iter::once(this).chain(fun.params.iter().cloned()).collect(); + let overloads = fun + .overloads + .iter() + .map(|overload| if overload.is_method { with_receiver(overload, receiver) } else { overload.clone() }) + .collect(); + Arc::new(FunType { params, is_method: false, lists_receiver: true, overloads, ..fun.clone() }) +} + struct Indexer<'a> { file: FileId, source: &'a str, @@ -816,6 +829,18 @@ impl<'a> Indexer<'a> { } _ => self.infer.expr(value), }; + // A method declared with `:` receives the first value of a call as `self`, so the export + // of `Obj.Fn` passes `self` what its callers pass first. What `exports.other.Fn` holds is + // called through the exports proxy already. + let ty = match (&value.kind, ty) { + (ExprKind::Field { base, .. } | ExprKind::Index { base, .. }, Type::Fun(fun)) if fun.is_method => { + match self.infer.expr(base) { + Type::Exports(_) => Type::Fun(fun), + receiver => Type::Fun(with_receiver(&fun, &receiver)), + } + } + (_, ty) => ty, + }; let mut symbol_doc = render_doc(&doc); if symbol_doc.is_none() { symbol_doc = self.referenced_doc(value); diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index c108593..70911a3 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -8271,6 +8271,38 @@ print(cid, rang) ); } +#[test] +fn exports_of_methods_pass_the_first_value_to_self() { + const MYLIB: &str = "[core]/mylib/server.lua"; + let mut client = Client::start(fixture_root()); + let mylib = client.open(MYLIB); + let registered = " +---@class Test.Garage +local Garage = {} + +---@param plate string +---@return boolean +function Garage:Park(plate) return plate ~= '' end +exports('Park', Garage.Park) +"; + client.change(MYLIB, 2, &format!("{mylib}{registered}")); + // `Garage.Park` takes the garage as `self` before the plate, and the proxy passes it the first + // value of the call. + let text = "local parked = exports.mylib:Park({}, 'ABC')\nprint(parked)\n"; + client.open_with(SERVER, text); + let (l, c) = pos(text, "'ABC'", 0); + let result = client.request("textDocument/signatureHelp", client.position_params(SERVER, l, c)); + assert_eq!(result["signatures"][0]["label"], "Park(self: Test.Garage, plate: string): boolean"); + assert_eq!(result["activeParameter"], 1); + let hints = client.request( + "textDocument/inlayHint", + json!({ "textDocument": { "uri": client.uri(SERVER) }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 2, "character": 0 } } }), + ); + let labels: Vec<&str> = hints.as_array().unwrap().iter().map(|h| h["label"].as_str().unwrap()).collect(); + assert_eq!(labels, ["self:", "plate:"]); + assert!(findings(&mut client, SERVER, &["fivem/export-argument-count"]).is_empty()); +} + #[test] fn publishes_lint_diagnostics_with_resource_context() { let mut client = Client::start(fixture_root()); From 1c07286e93c98ba7dee3059141f755290b54c742 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:55:38 +0200 Subject: [PATCH 055/193] fix(natives): keep the signatures of server natives named like client ones --- crates/qbx_fivem_data/data/natives.tsv | 54 ++++++++++---------- crates/qbx_fivem_data/src/lib.rs | 35 ++++++++++++- crates/qbx_lua_ls/src/features/completion.rs | 7 ++- crates/qbx_lua_ls/src/features/hover.rs | 6 +-- crates/qbx_lua_ls/src/infer.rs | 2 +- crates/qbx_lua_ls/tests/lsp.rs | 19 +++++++ xtask/src/main.rs | 49 ++++++++++++++++-- 7 files changed, 133 insertions(+), 39 deletions(-) diff --git a/crates/qbx_fivem_data/data/natives.tsv b/crates/qbx_fivem_data/data/natives.tsv index 5abc221..5ed0d88 100644 --- a/crates/qbx_fivem_data/data/natives.tsv +++ b/crates/qbx_fivem_data/data/natives.tsv @@ -527,13 +527,13 @@ CreateNewScriptedConversation c AUDIO 0xD2C91A0B572AAE56 CreateNmMessage c PED 0x418EF2A1BCE56685 startImmediately:boolean,messageId:integer CreateNonNetworkedAmbientPickup c OBJECT 0x9C93764223E29C50 any pickupHash:any,posX:number,posY:number,posZ:number,flags:integer,value:integer,modelHash:any,p7:boolean,p8:boolean CreateNonNetworkedPortablePickup c OBJECT 0x125494B98A21AAF7 Object pickupHash:Hash,x:number,y:number,z:number,placeOnGround:boolean,modelHash:Hash -CreateObject b OBJECT 0x509D5878EB39E842 Object modelHash:Hash,x:number,y:number,z:number,isNetwork:boolean,netMissionEntity:boolean,doorFlag:boolean -CreateObjectNoOffset b OBJECT 0x9A294B2138ABB884 Object modelHash:Hash,x:number,y:number,z:number,isNetwork:boolean,netMissionEntity:boolean,doorFlag:boolean +CreateObject b OBJECT 0x509D5878EB39E842 Object modelHash:Hash,x:number,y:number,z:number,isNetwork:boolean,netMissionEntity:boolean,doorFlag:boolean 0x2F7AA05C Entity modelHash:Hash,x:number,y:number,z:number,isNetwork:boolean,netMissionEntity:boolean,doorFlag:boolean +CreateObjectNoOffset b OBJECT 0x9A294B2138ABB884 Object modelHash:Hash,x:number,y:number,z:number,isNetwork:boolean,netMissionEntity:boolean,doorFlag:boolean 0x58040420 Entity modelHash:Hash,x:number,y:number,z:number,isNetwork:boolean,netMissionEntity:boolean,doorFlag:boolean CreateParachuteBagObject c PED 0x8C4F3BF23B6237DB Object ped:Ped,p1:boolean,p2:boolean CreateParachuteObject c PED 0x8C4F3BF23B6237DB Object ped:Ped,p1:boolean,p2:boolean CreateParachuteBagObject CreatePatrolRoute c TASK 0xAF8A443CCC8018DC -CreatePed b PED 0xD49F9B0955C367DE Ped pedType:integer,modelHash:Hash,x:number,y:number,z:number,heading:number,isNetwork:boolean,bScriptHostPed:boolean -CreatePedInsideVehicle b PED 0x7DD959874C1FD534 Ped vehicle:Vehicle,pedType:integer,modelHash:Hash,seat:integer,isNetwork:boolean,bScriptHostPed:boolean +CreatePed b PED 0xD49F9B0955C367DE Ped pedType:integer,modelHash:Hash,x:number,y:number,z:number,heading:number,isNetwork:boolean,bScriptHostPed:boolean 0x389EF71 Entity pedType:integer,modelHash:Hash,x:number,y:number,z:number,heading:number,isNetwork:boolean,bScriptHostPed:boolean +CreatePedInsideVehicle b PED 0x7DD959874C1FD534 Ped vehicle:Vehicle,pedType:integer,modelHash:Hash,seat:integer,isNetwork:boolean,bScriptHostPed:boolean 0x3000F092 Entity vehicle:Vehicle,pedType:integer,modelHash:Hash,seat:integer,isNetwork:boolean,bScriptHostPed:boolean CreatePickUpRopeForCargobob c VEHICLE 0x7BEB0C7A235F6F3B cargobob:Vehicle,state:integer CreatePickup c OBJECT 0xFBA08C503DD5FA58 Pickup pickupHash:Hash,posX:number,posY:number,posZ:number,p4:integer,value:integer,p6:boolean,modelHash:Hash CreatePickupRotate c OBJECT 0x891804727E0A98B7 Pickup pickupHash:Hash,posX:number,posY:number,posZ:number,rotX:number,rotY:number,rotZ:number,flag:integer,amount:integer,p9:any,p10:boolean,modelHash:Hash @@ -550,7 +550,7 @@ CreateSynchronizedScene c PED 0x8C18E0F9080ADD73 integer x:number,y:number,z:num CreateSynchronizedScene_2 c PED 0x62EC273D00187DCA integer x:number,y:number,z:number,radius:number,object:Hash CreateTimecycleModifier c CFX 0x70FA2AFA integer modifierName:string CreateTrackedPoint c GRAPHICS 0xE2C9439ED45DEA60 integer -CreateVehicle b VEHICLE 0xAF35D0D2583051B0 Vehicle modelHash:Hash,x:number,y:number,z:number,heading:number,isNetwork:boolean,netMissionEntity:boolean +CreateVehicle b VEHICLE 0xAF35D0D2583051B0 Vehicle modelHash:Hash,x:number,y:number,z:number,heading:number,isNetwork:boolean,netMissionEntity:boolean 0xDD75460A Entity modelHash:Hash,x:number,y:number,z:number,heading:number,isNetwork:boolean,netMissionEntity:boolean CreateVehicleServerSetter s CFX 0x6AE51D4B Vehicle modelHash:Hash,type:string,x:number,y:number,z:number,heading:number CreateWeaponObject c WEAPON 0x9541D3CF0D398F36 Object weaponHash:Hash,ammoCount:integer,x:number,y:number,z:number,bCreateDefaultComponents:boolean,scale:number,customModelHash:integer CustomMenuCoordinates c CAM 0x487A82C650EB7799 p0:number @@ -739,7 +739,7 @@ DoesCutsceneEntityExist c CUTSCENE 0x499EF20C5DB25C59 boolean cutsceneEntName:st DoesDesObjectExist c OBJECT 0x52AF537A0C5B8AAD boolean object:Object DoesRayfireMapObjectExist DoesDoorExist c OBJECT 0xC153C43EA202C8C1 boolean doorHash:Hash IsDoorRegisteredWithSystem DoesEntityBelongToThisScript c ENTITY 0xDDE6DF5AE89981D2 boolean entity:Entity,p2:boolean -DoesEntityExist b ENTITY 0x7239B21A38F536BA boolean entity:Entity +DoesEntityExist b ENTITY 0x7239B21A38F536BA boolean entity:Entity 0x3AC90869 boolean entity:Object DoesEntityHaveAnimDirector c ENTITY 0x2158E81A6AF65EA9 boolean entity:Entity DoesEntityHaveDrawable c ENTITY 0x060D6E96F8B8E48D boolean entity:Entity DoesEntityHaveFragInst c PHYSICS 0x0C112765300C7E1E boolean object:Object GetHasObjectFragInst @@ -1063,7 +1063,7 @@ GetAllPeds s CFX 0xB8584FEF table GetAllRopes c CFX 0x760A2D67 table GetAllTrackJunctions c CFX 0x81A08523 table GetAllVehicleModels c CFX 0xD7531645 table -GetAllVehicles b VEHICLE 0x9B8E1BF04B51F2E8 integer,integer +GetAllVehicles b VEHICLE 0x9B8E1BF04B51F2E8 integer,integer 0x332169F5 table GetAllocatedStackSize c MISC 0x8B3CA62B1EF19B62 integer GetAllowMovementWhileZoomed c PAD 0xFC859E2374407556 boolean GetAmbientPedRangeMultiplier c CFX 0xB550232D number @@ -1185,7 +1185,7 @@ GetCurrentNotification c HUD 0x82352748437638CA integer ThefeedGetFirstVisibleD GetCurrentNumberOfCloudPhotos c GRAPHICS 0x473151EBC762C6DA integer GetCurrentNumberOfPhotos c GRAPHICS 0x473151EBC762C6DA integer GetCurrentNumberOfCloudPhotos GetCurrentPedVehicleWeapon c WEAPON 0x1017582BCD3832DC boolean,Hash ped:Ped -GetCurrentPedWeapon b WEAPON 0x3A87E44BB9A01D54 boolean,Hash ped:Ped,p2:boolean +GetCurrentPedWeapon b WEAPON 0x3A87E44BB9A01D54 boolean,Hash ped:Ped,p2:boolean 0xB0237302 Hash ped:Ped GetCurrentPedWeaponEntityIndex c WEAPON 0x3B390A939AF0B5FC Entity ped:Ped GetCurrentPlaybackForVehicle c VEHICLE 0x42BC05C27A946054 integer vehicle:Vehicle GetCurrentRadioStationHash c AUDIO 0x3E65CDE5215832C1 integer radioStationName:string GetCurrentRadioTrackPlaybackTime @@ -1244,7 +1244,7 @@ GetEntityBoneRotationLocal c ENTITY 0xBD8D32550E5CEBFE vector3 entity:Entity,bon GetEntityCanBeDamaged c ENTITY 0xD95CC5D2AB15A09F boolean entity:Entity GetEntityCollisionDisabled b ENTITY 0xCCF1E97BEFDAE480 boolean entity:Entity GetEntityCollisonDisabled c ENTITY 0xCCF1E97BEFDAE480 boolean entity:Entity GetEntityCollisionDisabled -GetEntityCoords b ENTITY 0x3FEF770D40960D5A vector3 entity:Entity,alive:boolean +GetEntityCoords b ENTITY 0x3FEF770D40960D5A vector3 entity:Entity,alive:boolean 0x1647F1CB vector3 entity:Entity GetEntityForwardVector c ENTITY 0x0A794A5A57F8DF91 vector3 entity:Entity GetEntityForwardX c ENTITY 0x8BB4EF4214E0E6D5 number entity:Entity GetEntityForwardY c ENTITY 0x866A4A5FAE349510 number entity:Entity @@ -1274,10 +1274,10 @@ GetEntityProofs c ENTITY 0xBE8CD9BE829BBEBF boolean,boolean,boolean,boolean,bool GetEntityQuaternion c ENTITY 0x7B3703D2D32DFA18 number,number,number,number entity:Entity GetEntityRemoteSyncedScenesAllowed s CFX 0x91B38FB6 boolean entity:Entity GetEntityRoll c ENTITY 0x831E0242595560DF number entity:Entity -GetEntityRotation b ENTITY 0xAFBD61CC738D9EB9 vector3 entity:Entity,rotationOrder:integer +GetEntityRotation b ENTITY 0xAFBD61CC738D9EB9 vector3 entity:Entity,rotationOrder:integer 0x8FF45B04 vector3 entity:Entity GetEntityRotationVelocity b ENTITY 0x213B91045D09B983 vector3 entity:Entity GetEntityRoutingBucket s CFX 0xED4B0486 integer entity:Entity -GetEntityScript b ENTITY 0xA6E9C38DB51D7748 string,ScrHandle entity:Entity +GetEntityScript b ENTITY 0xA6E9C38DB51D7748 string,ScrHandle entity:Entity 0xB7F70784 string entity:Entity GetEntitySpeed b ENTITY 0xD5037BA82E12416F number entity:Entity GetEntitySpeedVector c ENTITY 0x9A8D700A51CB7B0D vector3 entity:Entity,relative:boolean GetEntitySubmergedLevel c ENTITY 0xE81AFC1BC4CC41CE number entity:Entity @@ -1466,7 +1466,7 @@ GetLandingGearState b VEHICLE 0x9B0F3DCA3DB0F4CD integer vehicle:Vehicle GetLastDrivenVehicle c VEHICLE 0xB2D06FAEDE65B577 Vehicle GetLastInputMethod c PAD 0xA571D46727E2B718 boolean padIndex:integer IsUsingKeyboard GetLastMaterialHitByEntity c ENTITY 0x5C3D0A935F535C4C Hash entity:Entity -GetLastPedInVehicleSeat b VEHICLE 0x83F969AA1EE2A664 Ped vehicle:Vehicle,seatIndex:integer +GetLastPedInVehicleSeat b VEHICLE 0x83F969AA1EE2A664 Ped vehicle:Vehicle,seatIndex:integer 0xF7C6792D Entity vehicle:Vehicle,seatIndex:integer GetLastRammedVehicle c VEHICLE 0x04F2FA6E234162F7 Vehicle vehicle:Vehicle GetLengthOfLiteralString c HUD 0xF030907CCBB8A9FD integer string:string GetLengthOfLiteralStringInBytes c HUD 0x43E4111189E54F0E integer string:string @@ -1682,7 +1682,7 @@ GetPedHeadOverlayValue c PED 0xA60EF3B6461A4D43 integer ped:Ped,overlayID:intege GetPedHelmetStoredHatPropIndex c PED 0x451294E859ECC018 integer ped:Ped GetPedHelmetStoredHatTexIndex c PED 0x9D728C1E12BF5518 integer ped:Ped GetPedIlluminatedClothingGlowIntensity c PED 0x1461B28A06717D68 number ped:Ped GetPedEmissiveIntensity -GetPedInVehicleSeat b VEHICLE 0xBB40DD2270B65366 Ped vehicle:Vehicle,seatIndex:integer +GetPedInVehicleSeat b VEHICLE 0xBB40DD2270B65366 Ped vehicle:Vehicle,seatIndex:integer 0x388FDE9A Entity vehicle:Vehicle,seatIndex:integer GetPedIndexFromEntityIndex c ENTITY 0x04A2A40C73395041 Ped entity:Entity GetPedInsideExplosionArea c FIRE 0x14BA4BA137AF6CEC Entity explosionType:integer,x1:number,y1:number,z1:number,x2:number,y2:number,z2:number,radius:number GetEntityInsideExplosionArea GetPedKiller c PED 0x93C8B64DEB84728C Entity ped:Ped GetPedSourceOfDeath @@ -1742,7 +1742,7 @@ GetPlayerAdvancedModifierPrivileges c NETWORK 0xCD67AD041A394C9C string p0:integ GetPlayerCameraRotation s CFX 0x433C765D vector3 playerSrc:string GetPlayerCurrentStealthNoise c PLAYER 0x2F395D61F3A1F877 number player:Player GetPlayerEndpoint s CFX 0xFEE404F9 string playerSrc:string -GetPlayerFakeWantedLevel b PLAYER 0x56105E599CAB0EFA integer player:Player +GetPlayerFakeWantedLevel b PLAYER 0x56105E599CAB0EFA integer player:Player 0x98D244 integer playerSrc:string GetPlayerFocusPos s CFX 0x586F80FF vector3 playerSrc:string GetPlayerFromIndex s CFX 0xC8A9CE08 string index:integer GetPlayerFromServerId c CFX 0x344EA166 Player serverId:integer @@ -1755,21 +1755,21 @@ GetPlayerHealthRechargeLimit c PLAYER 0x8BC515BAE4AAF8FF number player:Player GetPlayerIdentifier s CFX 0x7302DBCF string playerSrc:string,identiferIndex:integer GetPlayerIdentifierByType s CFX 0xA61C8FC6 string playerSrc:string,identifierType:string GetPlayerIndex c PLAYER 0xA5EDC40EF369B48D Player -GetPlayerInvincible b PLAYER 0xB721981B2B939E07 boolean player:Player +GetPlayerInvincible b PLAYER 0xB721981B2B939E07 boolean player:Player 0x680C90EE boolean playerSrc:string GetPlayerInvincible_2 c CFX 0xF2E3912B boolean player:Player GetPlayerKillFallHeight c CFX 0xBFB2990C number GetPlayerLastMsg s CFX 0x427E8E6A integer playerSrc:string -GetPlayerMaxArmour b PLAYER 0x92659B4CE1863CB3 integer player:Player +GetPlayerMaxArmour b PLAYER 0x92659B4CE1863CB3 integer player:Player 0x2A50657 integer playerSrc:string GetPlayerMaxHealth s CFX 0x8154E470 integer playerSrc:string GetPlayerMaxStamina c CFX 0xD014AB79 number playerId:Player GetPlayerMeleeWeaponDamageModifier b CFX 0x8689A825 number playerId:Player GetPlayerMeleeWeaponDefenseModifier c CFX 0x27E94EF8 number playerId:Player -GetPlayerName b PLAYER 0x6D0DE6A7B5DA71F8 string player:Player +GetPlayerName b PLAYER 0x6D0DE6A7B5DA71F8 string player:Player 0x406B4B20 string playerSrc:string GetPlayerParachuteModelOverride c PLAYER 0xC219887CA3E65C41 Hash player:Player GetPlayerParachutePackTintIndex c PLAYER 0x6E9C742F340CE5A2 integer player:Player GetPlayerParachuteSmokeTrailColor c PLAYER 0xEF56DBABD3CD4887 integer,integer,integer player:Player GetPlayerParachuteTintIndex c PLAYER 0x75D3F7A1B0D9B145 integer player:Player -GetPlayerPed b PLAYER 0x43A66C31C68491C0 Ped playerId:Player +GetPlayerPed b PLAYER 0x43A66C31C68491C0 Ped playerId:Player 0x6E31E993 Entity playerSrc:string GetPlayerPedIsFollowing c PED 0x6A3975DEA89F9A17 Player ped:Ped GetPlayerPedScriptIndex c PLAYER 0x50FAC3A3E030A6E1 Ped player:Player GetPlayerPeerStatistics s CFX 0x9A928294 integer playerSrc:string,peerStatistic:integer @@ -1792,15 +1792,15 @@ GetPlayerSwitchJumpCutIndex c STREAMING 0x78C0D93253149435 integer GetPlayerSwitchState c STREAMING 0x470555300D10B2A5 integer GetPlayerSwitchType c STREAMING 0xB3C94A90D9FC9E62 integer GetPlayerTargetEntity c PLAYER 0x13EDE1A5DBF797C9 boolean,Entity player:Player -GetPlayerTeam b PLAYER 0x37039302F4E0A008 integer player:Player +GetPlayerTeam b PLAYER 0x37039302F4E0A008 integer player:Player 0x9873E404 integer playerSrc:string GetPlayerTimeInPursuit s CFX 0x7ADE63E1 integer playerSrc:string,lastPursuit:boolean GetPlayerTimeOnline s CFX 0x67D2E605 integer playerSrc:string GetPlayerToken s CFX 0x54C06897 string playerSrc:string,index:integer GetPlayerUnderwaterTimeRemaining c PLAYER 0xA1FCF8E6AF40B731 number player:Player GetPlayerVehicleDamageModifier c CFX 0x78F27B1F number playerId:Player GetPlayerVehicleDefenseModifier c CFX 0x8326E7CD number playerId:Player -GetPlayerWantedCentrePosition b PLAYER 0x0C92BA89F1AF26F8 vector3 player:Player -GetPlayerWantedLevel b PLAYER 0xE28E54788CE8F12D integer player:Player +GetPlayerWantedCentrePosition b PLAYER 0x0C92BA89F1AF26F8 vector3 player:Player 0x821F2D2C vector3 playerSrc:string +GetPlayerWantedLevel b PLAYER 0xE28E54788CE8F12D integer player:Player 0xBDCDD163 integer playerSrc:string GetPlayerWeaponDamageModifier b CFX 0x2A3D7CDA number playerId:Player GetPlayerWeaponDefenseModifier b CFX 0xF1543251 number playerId:Player GetPlayerWeaponDefenseModifier_2 b CFX 0x986B65FF number playerId:Player @@ -2076,7 +2076,7 @@ GetVehicleDoorAngleRatio c VEHICLE 0xFE3F9C29F7B32BD5 number vehicle:Vehicle,doo GetVehicleDoorDestroyType c VEHICLE 0xCA4AC3EAAE46EC7B integer vehicle:Vehicle,doorIndex:integer GetVehicleIndividualDoorLockStatus GetVehicleDoorLockStatus b VEHICLE 0x25BC98A59C2EA962 integer vehicle:Vehicle GetVehicleDoorStatus s CFX 0x6E35C49C integer vehicle:Vehicle,doorIndex:integer -GetVehicleDoorsLockedForPlayer b VEHICLE 0xF6AF6CB341349015 boolean vehicle:Vehicle,player:Player +GetVehicleDoorsLockedForPlayer b VEHICLE 0xF6AF6CB341349015 boolean vehicle:Vehicle,player:Player 0x1DC50247 integer vehicle:Vehicle GetVehicleDrawnWheelAngleMult c CFX 0x21C1DA8E number vehicle:Vehicle GetVehicleDrivetrainType c VEHICLE 0x1423725069EE1D14 integer vehicleModel:Hash GetVehicleEngineHealth b VEHICLE 0xC45D23BAF168AAB8 number vehicle:Vehicle @@ -2116,7 +2116,7 @@ GetVehicleLightMultiplier c CFX 0x7E6E219C number vehicle:Vehicle GetVehicleLightsState b VEHICLE 0xB91B4C20085BD12F boolean,boolean,boolean vehicle:Vehicle GetVehicleLivery b VEHICLE 0x2BB9230590DA5E8A integer vehicle:Vehicle GetVehicleLiveryCount c VEHICLE 0x87B63E25A529D526 integer vehicle:Vehicle -GetVehicleLockOnTarget b VEHICLE 0x8F5EBAB1F260CFCE boolean,Entity vehicle:Vehicle +GetVehicleLockOnTarget b VEHICLE 0x8F5EBAB1F260CFCE boolean,Entity vehicle:Vehicle 0x4A557117 Vehicle vehicle:Vehicle GetVehicleMaxBraking c VEHICLE 0xAD7E85FC227197C4 number vehicle:Vehicle GetVehicleMaxNumberOfPassengers c VEHICLE 0xA7C4F2C6E744A550 integer vehicle:Vehicle GetVehicleMaxSpeed c VEHICLE 0x53AF99BAA671CA47 number vehicle:Vehicle GetVehicleEstimatedMaxSpeed @@ -2743,7 +2743,7 @@ IsPedInAnyPoliceVehicle c PED 0x0BD04E29640C9C12 boolean ped:Ped IsPedInAnySub c PED 0xFBFC01CCFB35D99E boolean ped:Ped IsPedInAnyTaxi c PED 0x6E575D6A898AB852 boolean ped:Ped IsPedInAnyTrain c PED 0x6F972C1AB75A1ED0 boolean ped:Ped -IsPedInAnyVehicle b PED 0x997ABD671D25CA0B boolean ped:Ped,atGetIn:boolean +IsPedInAnyVehicle b PED 0x997ABD671D25CA0B boolean ped:Ped,atGetIn:boolean 0x3B0171EE boolean ped:Ped IsPedInCombat c PED 0x4859F1FC66A6278E boolean ped:Ped,target:Ped IsPedInCover c PED 0x60DFD0691A170B88 boolean ped:Ped,exceptUseWeapon:boolean IsPedInCoverFacingLeft c PED 0x845333B3150583AB boolean ped:Ped @@ -2754,7 +2754,7 @@ IsPedInHighCover c PED 0x6A03BF943D767C93 boolean ped:Ped IsPedInMeleeCombat c PED 0x4E209B2C1EAD5159 boolean ped:Ped IsPedInModel c PED 0x796D90EFB19AA332 boolean ped:Ped,modelHash:Hash IsPedInParachuteFreeFall c PED 0x7DCE8BDA0F1C1200 boolean ped:Ped -IsPedInVehicle b PED 0xA3EE4A07279BB9DB boolean ped:Ped,vehicle:Vehicle,atGetIn:boolean +IsPedInVehicle b PED 0xA3EE4A07279BB9DB boolean ped:Ped,vehicle:Vehicle,atGetIn:boolean 0x7DA6BC83 boolean ped:Ped,vehicle:Vehicle IsPedInWrithe c TASK 0xDEB6D52126E7D640 boolean ped:Ped IsPedInjured c PED 0x84A2DD9AC37C35C1 boolean ped:Ped IsPedJacking c PED 0x4AE4FF911DFB61DA boolean ped:Ped @@ -8640,8 +8640,8 @@ SetPedTargetLossResponse c PED 0x0703B9079823DA4A ped:Ped,responseType:integer SetPedTargettableVehicleDestroy c VEHICLE 0xBE70724027F85BCD vehicle:Vehicle,doorIndex:integer,doorLockStatus:integer SetVehicleIndividualDoorsLocked SetPedToInformRespectedFriends c PED 0x112942C6E708F70B ped:Ped,radius:number,maxFriends:integer SetPedToLoadCover c PED 0x332B562EEDA62399 ped:Ped,toggle:boolean -SetPedToRagdoll b PED 0xAE99FB955581844A boolean ped:Ped,time1:integer,time2:integer,ragdollType:integer,p4:boolean,p5:boolean,p6:boolean -SetPedToRagdollWithFall b PED 0xD76632D99E4966C8 boolean ped:Ped,time:integer,p2:integer,ragdollType:integer,x:number,y:number,z:number,p7:number,p8:number,p9:number,p10:number,p11:number,p12:number,p13:number +SetPedToRagdoll b PED 0xAE99FB955581844A boolean ped:Ped,time1:integer,time2:integer,ragdollType:integer,p4:boolean,p5:boolean,p6:boolean 0x83CB5052 ped:Ped,minTime:integer,maxTime:integer,ragdollType:integer,bAbortIfInjured:boolean,bAbortIfDead:boolean,bForceScriptControl:boolean +SetPedToRagdollWithFall b PED 0xD76632D99E4966C8 boolean ped:Ped,time:integer,p2:integer,ragdollType:integer,x:number,y:number,z:number,p7:number,p8:number,p9:number,p10:number,p11:number,p12:number,p13:number 0xFA12E286 ped:Ped,minTime:integer,maxTime:integer,nFallType:integer,dirX:number,dirY:number,dirZ:number,fGroundHeight:number,grab1X:number,grab1Y:number,grab1Z:number,grab2X:number,grab2Y:number,grab2Z:number SetPedTurningThresholds c CFX 0xB300F03 min:number,max:number SetPedUsingActionMode c PED 0xD75ACCF5E0FB5367 ped:Ped,p1:boolean,p2:integer,action:string SetPedVehicleForcedSeatUsage c PED 0x952F06BEECD775CC ped:Ped,vehicle:Vehicle,seatIndex:integer,flags:integer diff --git a/crates/qbx_fivem_data/src/lib.rs b/crates/qbx_fivem_data/src/lib.rs index 3310473..8087682 100644 --- a/crates/qbx_fivem_data/src/lib.rs +++ b/crates/qbx_fivem_data/src/lib.rs @@ -42,6 +42,9 @@ pub struct Native { returns: &'static str, params: &'static str, pub alias_of: Option<&'static str>, + /// The hash, returned values and parameters of the server native of the same name, when they + /// differ from those of the client one: `GetAllVehicles` returns a table on the server. + server: Option<(&'static str, &'static str, &'static str)>, } impl Native { @@ -58,7 +61,22 @@ impl Native { let returns = cols.next()?; let params = cols.next()?; let alias_of = cols.next().filter(|a| !a.is_empty()); - Some(Self { name, side, namespace, hash, returns, params, alias_of }) + let server = match (cols.next(), cols.next(), cols.next()) { + (Some(hash), Some(returns), Some(params)) => Some((hash, returns, params)), + _ => None, + }; + Some(Self { name, side, namespace, hash, returns, params, alias_of, server }) + } + + /// The native that code on `side` calls: on the server, the server native of the same name + /// when its signature differs from the client one. + pub fn on(self, side: Option) -> Self { + match (side, self.server) { + (Some(Side::Server), Some((hash, returns, params))) => { + Self { side: Side::Server, namespace: "CFX", hash, returns, params, server: None, ..self } + } + _ => self, + } } pub fn returns(&self) -> impl Iterator { @@ -232,6 +250,21 @@ mod tests { assert!(native_count() > 6000); } + #[test] + fn server_natives_keep_their_own_signature() { + let vehicles = native("GetAllVehicles").unwrap(); + assert_eq!(vehicles.on(Some(Side::Client)).returns().collect::>(), ["integer", "integer"]); + assert_eq!(vehicles.on(None).returns().collect::>(), ["integer", "integer"]); + let server = vehicles.on(Some(Side::Server)); + assert_eq!(server.returns().collect::>(), ["table"]); + assert_eq!((server.side, server.namespace, server.hash), (Side::Server, "CFX", "0x332169F5")); + let weapon = native("GetCurrentPedWeapon").unwrap().on(Some(Side::Server)); + assert_eq!(weapon.signature(), "function GetCurrentPedWeapon(ped: Ped): Hash"); + // A native without a server signature of its own is the same everywhere. + let health = native("GetEntityHealth").unwrap(); + assert_eq!(health.on(Some(Side::Server)).hash, health.hash); + } + #[test] fn natives_are_sorted_for_binary_search() { let names: Vec<&str> = natives().map(|n| n.name).collect(); diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index 6da734a..082a2b4 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -1522,9 +1522,10 @@ fn scope_items( break; } count += 1; + let native = native.on(Some(side)); let mut out = item(native.name, CompletionItemKind::FUNCTION, 5); out.detail = Some(native.signature()); - out.data = Some(json!({ "native": native.name })); + out.data = Some(json!({ "native": native.name, "server": side == Side::Server })); if native.alias_of.is_some() { out.tags = Some(vec![CompletionItemTag::DEPRECATED]); } @@ -1539,7 +1540,9 @@ fn scope_items( pub fn resolve(mut item: CompletionItem) -> CompletionItem { let name = item.data.as_ref().and_then(|d| d.get("native")).and_then(|n| n.as_str()).map(str::to_string); - if let Some(native) = name.as_deref().and_then(native) { + let server = item.data.as_ref().and_then(|d| d.get("server")).and_then(|s| s.as_bool()).unwrap_or(false); + let side = server.then_some(Side::Server); + if let Some(native) = name.as_deref().and_then(native).map(|native| native.on(side)) { let mut text = lua_block(&native.signature()); text.push_str(&format!("\n\n*{} native* · `{}`", native.side.label(), native.namespace)); if let Some(docs) = native_docs(native.name) { diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 09c91ad..27539c3 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -253,8 +253,8 @@ fn local_hover(infer: &Infer, id: LocalId, offset: u32, called: Option) -> out } -fn native_hover(name: &str) -> Option { - let native = native(name)?; +fn native_hover(name: &str, side: Option) -> Option { + let native = native(name)?.on(side); let mut out = lua_block(&native.signature()); let canonical = native.alias_of.map(|target| format!(" · alias of `{target}`")).unwrap_or_default(); out.push_str(&format!( @@ -276,7 +276,7 @@ fn global_hover(ws: &Workspace, infer: &Infer, name: &str, called: Option) .iter() .max_by_key(|(_, s)| (matches!(s.ty, Type::GlobalTable(_) | Type::Named(..)), s.ty.specificity())); let Some((file, symbol)) = preferred else { - if let Some(hover) = native_hover(name) { + if let Some(hover) = native_hover(name, infer.side()) { return Some(hover); } let ty = infer.global_type(name); diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 1a255de..6f8afbf 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -895,7 +895,7 @@ impl<'a> Infer<'a> { return Type::GlobalTable(SmolStr::new(name)); } match native(name) { - Some(native) => Type::Fun(Arc::new(native_fun_type(&native))), + Some(native) => Type::Fun(Arc::new(native_fun_type(&native.on(self.side)))), None => Type::Unknown, } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 70911a3..eea2e28 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -487,6 +487,25 @@ fn hover_shows_types_docs_and_natives() { assert!(client.hover_text(CLIENT, l, c).contains("label: string")); } +#[test] +fn server_code_calls_the_server_native_of_a_shared_name() { + let mut client = Client::start(fixture_root()); + let text = "local vehicles = GetAllVehicles()\nlocal weapon = GetCurrentPedWeapon(1)\nprint(vehicles, weapon)\n"; + for (file, vehicles, weapon) in + [(SERVER, "vehicles: table", "weapon: Hash"), (CLIENT, "vehicles: integer", "weapon: boolean")] + { + client.open_with(file, text); + for expected in [vehicles, weapon] { + let (l, c) = pos(text, &format!("local {}", &expected[..expected.find(':').unwrap()]), 6); + let hover = client.hover_text(file, l, c); + assert!(hover.contains(expected), "{file}: expected {expected:?} in {hover}"); + } + } + let (l, c) = pos(text, "GetAllVehicles", 0); + let hover = client.hover_text(SERVER, l, c); + assert!(hover.contains("function GetAllVehicles(): table") && hover.contains("*server native* · `CFX`"), "{hover}"); +} + #[test] fn native_argument_hovers_show_defaults_flags_and_exact_ranges() { // Numeric hovers also work for clients that advertise no optional hover capabilities. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 988cdbc..8bef36a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -17,6 +17,15 @@ struct Native { params: Vec<(String, String)>, alias_of: Option, docs: String, + /// The signature of a server native that shares the name of a client one but differs from it, + /// as `GetAllVehicles` returns a table on the server and a count on the client. + server: Option, +} + +struct ServerSignature { + hash: String, + returns: Vec, + params: Vec<(String, String)>, } fn main() { @@ -51,19 +60,26 @@ fn generate_natives() { let mut signatures = String::new(); let mut docs = String::new(); + let param_list = |params: &[(String, String)]| -> String { + params.iter().map(|(n, t)| format!("{n}:{t}")).collect::>().join(",") + }; for (name, native) in &natives { - let params: Vec = native.params.iter().map(|(n, t)| format!("{n}:{t}")).collect(); - writeln!( + write!( signatures, "{name}\t{}\t{}\t{}\t{}\t{}\t{}", native.side, native.ns, native.hash, native.returns.join(","), - params.join(","), + param_list(&native.params), native.alias_of.as_deref().unwrap_or("") ) .unwrap(); + if let Some(server) = &native.server { + write!(signatures, "\t{}\t{}\t{}", server.hash, server.returns.join(","), param_list(&server.params)) + .unwrap(); + } + signatures.push('\n'); if native.alias_of.is_none() && !native.docs.is_empty() { writeln!(docs, "{name}\t{}", escape(&native.docs)).unwrap(); } @@ -173,14 +189,30 @@ fn add_native(natives: &mut BTreeMap, ns: &str, hash: &str, nati params: params.clone(), alias_of: Some(name.clone()), docs: String::new(), + server: None, }); } } - let entry = Native { side, ns: ns.to_string(), hash: hash.to_string(), returns, params, alias_of: None, docs }; + let entry = Native { + side, + ns: ns.to_string(), + hash: hash.to_string(), + returns, + params, + alias_of: None, + docs, + server: None, + }; match natives.get(&name) { Some(existing) if existing.alias_of.is_none() && existing.side != side => { - natives.get_mut(&name).unwrap().side = 'b'; + let existing = natives.get_mut(&name).unwrap(); + // A server native named like a client one keeps its own signature for server code. + if existing.side == 'c' && !same_signature(existing, &entry) { + let Native { hash, returns, params, .. } = entry; + existing.server = Some(ServerSignature { hash, returns, params }); + } + existing.side = 'b'; } Some(existing) if existing.alias_of.is_none() => {} Some(existing) => { @@ -193,6 +225,13 @@ fn add_native(natives: &mut BTreeMap, ns: &str, hash: &str, nati } } +/// Whether two declarations of a native take and return values of the same types, whatever their +/// parameters are named. +fn same_signature(a: &Native, b: &Native) -> bool { + let types = |native: &Native| native.params.iter().map(|(_, ty)| ty.clone()).collect::>(); + a.returns == b.returns && types(a) == types(b) +} + /// Mirrors the name mangling of the official FiveM Lua native codegen. fn lua_name(raw: &str) -> String { let lower = raw.to_ascii_lowercase().replace("0x", "n_0x"); From 949418e329146b1def307c32abd504980f6a58c4 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:07:59 +0200 Subject: [PATCH 056/193] fix(infer): narrow elseif conditions by the conditions before them --- crates/qbx_lua_ls/README.md | 11 ++++--- crates/qbx_lua_ls/src/narrow.rs | 7 +++-- crates/qbx_lua_ls/tests/lsp.rs | 54 ++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 56be885..95835c3 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -144,11 +144,12 @@ Guards are `name`, comparisons with `nil`, `true`, `false`, a string or an integ and `math.type(name)` compared with a name they give, reads from the local, and those joined by `and`, `or` and `not`. A read such as `name.job`, `name?.job`, `name[key]` or `name:get()` is only true when `name` holds a value, and so are `name?.job == "police"` and `name?.job ~= nil`. -They apply to the branches of an `if` or `elseif`, to the code after an `if` whose other branches -all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the right side of -`and` and `or`, and to the code after `assert(name)`. A local that is assigned again after its -declaration is only narrowed by the guards after a [`---@cast`](#casts) of it, since a guard says -nothing about the new value, and globals and fields are not narrowed. +They apply to the branches of an `if` or `elseif`, failed ones to the conditions of the `elseif`s +after them, to the code after an `if` whose other branches all end in `return`, `error(...)`, +`break` or `goto`, to the body of a `while`, to the right side of `and` and `or`, and to the code +after `assert(name)`. A local that is assigned again after its declaration is only narrowed by the +guards after a [`---@cast`](#casts) of it, since a guard says nothing about the new value, and +globals and fields are not narrowed. A comparison with a literal narrows the local to that literal: inside `if state == "busy" then`, a `"active"|"busy"|nil` and a `string` are both `"busy"`, and in the `else` branch the first is diff --git a/crates/qbx_lua_ls/src/narrow.rs b/crates/qbx_lua_ls/src/narrow.rs index b4e29d0..cd007ec 100644 --- a/crates/qbx_lua_ls/src/narrow.rs +++ b/crates/qbx_lua_ls/src/narrow.rs @@ -464,11 +464,14 @@ impl<'ast> Visitor<'ast> for Finder<'_> { let else_start = else_block.as_ref().map(|block| block.span.start.min(stmt.span.end)); let mut failed = Facts::new(); for (index, branch) in branches.iter().enumerate() { - let mut facts = failed.clone(); - self.facts(&branch.cond, true, &mut facts); // Measured between the keywords so half-typed code inside the branch still counts. let next = branches.get(index + 1).map(|b| b.keyword_span.start); let end = next.or(else_start).unwrap_or(stmt.span.end); + // The conditions before it failed for the condition of an `elseif` too. + let start = branch.keyword_span.end; + self.record(Span { start, end: end.max(start) }, failed.clone()); + let mut facts = Facts::new(); + self.facts(&branch.cond, true, &mut facts); self.record(Span { start: branch.cond.span.end, end: end.max(branch.cond.span.end) }, facts); self.facts(&branch.cond, false, &mut failed); } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index eea2e28..305ac53 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2157,6 +2157,57 @@ read() } } +#[test] +fn elseif_conditions_see_what_the_conditions_before_them_ruled_out() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param items? table +---@param weight number +local function create(items, weight) + if not items then + print('none') + elseif weight == 0 and next(items) then -- second + print(items) -- body + elseif items.x then -- third + print(1) + end +end + +---@param items? table +---@param weight number +local function other(items, weight) + if weight == 0 then + print('none') + elseif items.x then -- nothing ruled out + print(1) + end +end +create() +other() +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("items) then -- second", "items: table\n"), + ("items) -- body", "items: table\n"), + ("items.x then -- third", "items: table\n"), + ("items.x then -- nothing ruled out", "items: table?\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), + ); + let (line, _) = pos(text, "items.x then -- nothing ruled out", 0); + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [("need-check-nil".to_string(), line as u64, "`items` may be nil: its type here is `table?`".to_string())], + "an `elseif` after `if not items` reads a value" + ); +} + #[test] fn guards_pick_the_set_of_values_a_call_returned() { let mut client = Client::start(fixture_root()); @@ -9653,9 +9704,10 @@ function SetMode(mode) end assert_eq!(values(&format!("{handle}if state == | then\n\tend\nend"), Some(" ")), quoted(&states, "")); assert_eq!(values(&format!("{handle}if state ~=| then\n\tend\nend"), None), quoted(&states, " ")); assert_eq!(values(&format!("{handle}return state == |\nend"), None), quoted(&states, "")); + // An `elseif` leaves out what the conditions before it ruled out. assert_eq!( values(&format!("{handle}if state == 'busy' then\n\telseif state == | then\n\tend\nend"), None), - quoted(&states, "") + quoted(&["working", "ready"], "") ); assert_eq!(values(&format!("{machine}if machine.state == |"), Some(" ")), quoted(&states, "")); assert_eq!(values(&format!("{state}if ready and state == |"), None), quoted(&states, "")); From b7516197209162b1c3cea828136ded08d9a3230b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:39:10 +0200 Subject: [PATCH 057/193] fix(completion): slice the identifier before the cursor at a char boundary --- crates/qbx_lua_ls/src/features/completion.rs | 8 +++---- crates/qbx_lua_ls/tests/lsp.rs | 24 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs index 082a2b4..b4f68aa 100644 --- a/crates/qbx_lua_ls/src/features/completion.rs +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -418,8 +418,8 @@ fn is_name(text: &str) -> bool { } fn identifier_prefix(before: &str) -> &str { - let start = before.rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).map_or(0, |i| i + 1); - &before[start..] + let head = before.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_'); + &before[head.len()..] } /// `reopen_suggestions`: the client runs `editor.action.triggerSuggest` when a completion asks it to. @@ -1372,8 +1372,8 @@ fn member_items( /// Fallback for member completion when the parser could not attach the trailing `.` to an expression. fn type_of_path(infer: &Infer, text: &str, offset: u32) -> Type { - let start = text.rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':'))).map_or(0, |i| i + 1); - let mut segments = text[start..].split(['.', ':']).filter(|s| !s.is_empty()); + let head = text.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':')); + let mut segments = text[head.len()..].split(['.', ':']).filter(|s| !s.is_empty()); let Some(root) = segments.next() else { return Type::Unknown }; let mut ty = match infer.ctx.resolution.lookup_local_at(root, offset) { Some(id) => infer.local_type_at(id, offset), diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 305ac53..1c52c47 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -7779,6 +7779,30 @@ fn completes_members_globals_natives_and_events() { assert!(labels.contains(&"Garage".to_string()) && labels.contains(&"GarageKind".to_string()), "{labels:?}"); } +#[test] +fn completes_right_after_multibyte_characters() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let line = text.lines().count() as u32; + for (version, (extra, expected)) in [ + ("é", None), + ("local a = →", None), + ("local a = …pri", Some("print")), + ("local a = 🚗GetEntityCo", Some("GetEntityCoords")), + ("local a = “MyLib.", Some("round")), + ("local a = ’.", None), + ] + .into_iter() + .enumerate() + { + client.change(CLIENT, version as i32 + 2, &format!("{text}{extra}")); + let labels = client.completion_labels(CLIENT, line, extra.encode_utf16().count() as u32); + if let Some(expected) = expected { + assert!(labels.contains(&expected.to_string()), "{extra}: {labels:?}"); + } + } +} + #[test] fn server_side_completion_hides_client_natives() { let mut client = Client::start(fixture_root()); From 524d1a60fec163e03e481285d3846df01c49d1e0 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:06:54 +0200 Subject: [PATCH 058/193] fix(directives): split directive parts on multibyte whitespace without panicking --- crates/qbx_lua_analysis/src/directives.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/qbx_lua_analysis/src/directives.rs b/crates/qbx_lua_analysis/src/directives.rs index f221692..8bad1a9 100644 --- a/crates/qbx_lua_analysis/src/directives.rs +++ b/crates/qbx_lua_analysis/src/directives.rs @@ -40,10 +40,8 @@ fn parse_directive(text: &str, own_line: bool) -> Option<(Action, Vec)> .or_else(|| text.strip_prefix("qbxlint:")) .or_else(|| text.strip_prefix("@diagnostic"))? .trim(); - let (action, codes) = match rest.find(|c: char| c == ':' || c.is_whitespace()) { - Some(i) => (&rest[..i], rest[i + 1..].trim_start_matches(':').trim()), - None => (rest, ""), - }; + // Any whitespace separates, so a non-breaking space pasted in where a space was meant still works. + let (action, codes) = rest.split_once(|c: char| c == ':' || c.is_whitespace()).unwrap_or((rest, "")); let action = match action { "disable" => Action::Disable, "enable" => Action::Enable, @@ -52,8 +50,8 @@ fn parse_directive(text: &str, own_line: bool) -> Option<(Action, Vec)> _ => return None, }; let codes = codes - .split([',', ' ']) - .map(str::trim) + .trim_start_matches(':') + .split(|c: char| c == ',' || c.is_whitespace()) .filter(|c| !c.is_empty()) .take_while(|c| !c.starts_with("--")) .map(str::to_string) @@ -139,4 +137,16 @@ function B() end", assert!(!s.is_suppressed("undefined-global", 3)); assert!(s.is_suppressed("unused-local", 5)); } + + #[test] + fn unicode_whitespace_separates() { + let s = suppressions("-- qbx-lint: disable-next-line\u{a0}undefined-global\u{3000}unused-local\nprint(foo)\nlocal a ---@diagnostic disable-line:\u{a0}empty-block,\u{a0}unused-local\n-- qbx-lint: disable\u{3000}lowercase-global\nx = 1"); + assert!(s.is_suppressed("undefined-global", 1)); + assert!(s.is_suppressed("unused-local", 1)); + assert!(!s.is_suppressed("empty-block", 1)); + assert!(s.is_suppressed("empty-block", 2)); + assert!(s.is_suppressed("unused-local", 2)); + assert!(s.is_suppressed("lowercase-global", 4)); + assert!(!s.is_suppressed("undefined-global", 4)); + } } From 5a3ed685efbd4795c4a1db7817ccf3728d8f7f16 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:03:06 +0200 Subject: [PATCH 059/193] perf(rules): read each class once when walking class parents --- .../qbx_lua_ls/src/features/class_tables.rs | 100 ++++++++++++------ crates/qbx_lua_ls/tests/lsp.rs | 26 +++++ 2 files changed, 92 insertions(+), 34 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 8659f53..95a6e28 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -339,10 +339,11 @@ impl<'a, 'b> Classes<'a, 'b> { /// The fields of `class`, given the type arguments `args`, and of its parents that are keyed by /// an integer or boolean literal, like `---@field [1] number`, with their values and the files - /// that declare them. The first declaration of a key wins, as with named fields. + /// that declare them. The first declaration of a key wins, as with named fields, and a class + /// that several parents share is read once. fn literal_fields(&self, class: &str, args: &[Type], from: FileId) -> Vec<(Type, Type, FileId)> { let mut out = Vec::new(); - self.collect_literal_fields(class, args, from, &mut out, 0); + self.collect_literal_fields(class, args, from, &mut out, &mut FxHashSet::default(), 0); out } @@ -352,9 +353,10 @@ impl<'a, 'b> Classes<'a, 'b> { args: &[Type], from: FileId, out: &mut Vec<(Type, Type, FileId)>, + visited: &mut FxHashSet, depth: u32, ) { - if depth > MAX_DEPTH { + if depth > MAX_DEPTH || !visited.insert(SmolStr::new(class)) { return; } let defs = self.class_defs(class, from); @@ -369,7 +371,7 @@ impl<'a, 'b> Classes<'a, 'b> { for (file, def) in &defs { for parent in &def.parent_types { if let Type::Named(parent, args) = substitute(parent, &bindings) { - self.collect_literal_fields(&parent, &args, *file, out, depth + 1); + self.collect_literal_fields(&parent, &args, *file, out, visited, depth + 1); } } } @@ -387,7 +389,7 @@ impl<'a, 'b> Classes<'a, 'b> { let named = match key { Key::Name(name) => { self.fields(class, &[], from).iter().any(|field| field.name == *name) - || self.members_declare(class, from, name, 0) + || self.members_declare(class, from, name, &mut FxHashSet::default(), 0) } Key::Typed(ty) => self.kinds(ty, self.file(), 0).is_none_or(|kinds| kinds & kind::STRING != 0), }; @@ -399,35 +401,55 @@ impl<'a, 'b> Classes<'a, 'b> { }; named || literal - || self.index_for(class, &[], from, &key.ty(), 0).is_some() - || self.has_open_parent(class, from, 0) + || self.index_for(class, &[], from, &key.ty(), &mut FxHashSet::default(), 0).is_some() + || self.has_open_parent(class, from, &mut FxHashSet::default(), 0) } /// Whether the table that the `---@class` annotation of `class` or a parent declares sets `name`. - fn members_declare(&self, class: &str, from: FileId, name: &str, depth: u32) -> bool { - if depth > MAX_DEPTH { + /// A class that several parents share is read once. + fn members_declare( + &self, + class: &str, + from: FileId, + name: &str, + visited: &mut FxHashSet, + depth: u32, + ) -> bool { + if depth > MAX_DEPTH || !visited.insert(SmolStr::new(class)) { return false; } - let parents_declare = |(file, def): &(FileId, &ClassDef)| { - def.parents.iter().any(|parent| self.members_declare(parent, *file, name, depth + 1)) - }; - self.infer.index.declared_members_of(class, self.file()).iter().any(|member| member.name == name) - || self.class_defs(class, from).iter().any(parents_declare) + if self.infer.index.declared_members_of(class, self.file()).iter().any(|member| member.name == name) { + return true; + } + let defs = self.class_defs(class, from); + let mut parents = defs.iter().flat_map(|(file, def)| def.parents.iter().map(move |parent| (parent, *file))); + parents.any(|(parent, file)| self.members_declare(parent, file, name, visited, depth + 1)) } /// Whether `class` or one of its ancestors names a parent that is neither a class nor a table - /// type with fields or indices, such as a plain `table`. - fn has_open_parent(&self, class: &str, from: FileId, depth: u32) -> bool { + /// type with fields or indices, such as a plain `table`. A class that several parents share is + /// read once, so parents that name each other are no open parent. + fn has_open_parent(&self, class: &str, from: FileId, visited: &mut FxHashSet, depth: u32) -> bool { + if !visited.insert(SmolStr::new(class)) { + return false; + } let defs = self.class_defs(class, from); - let is_open = |parent: &Type, file: FileId| match parent { - Type::Named(parent, _) => self.has_open_parent(parent, file, depth + 1), - Type::Shape(shape) => shape.fields.is_empty() && shape.array.is_none() && shape.indices.is_empty(), - Type::Map(..) | Type::Array(_) => false, - _ => true, - }; - defs.is_empty() - || depth > MAX_DEPTH - || defs.iter().any(|(file, def)| def.parent_types.iter().any(|parent| is_open(parent, *file))) + if defs.is_empty() || depth > MAX_DEPTH { + return true; + } + let parents = defs.iter().flat_map(|(file, def)| def.parent_types.iter().map(move |parent| (parent, *file))); + for (parent, file) in parents { + let open = match parent { + Type::Named(parent, _) => self.has_open_parent(parent, file, visited, depth + 1), + Type::Shape(shape) => shape.fields.is_empty() && shape.array.is_none() && shape.indices.is_empty(), + Type::Map(..) | Type::Array(_) => false, + _ => true, + }; + if open { + return true; + } + } + false } /// The type that `key` of a `class` table holds, given the type arguments `args` of the class, @@ -445,14 +467,23 @@ impl<'a, 'b> Classes<'a, 'b> { } Key::Typed(_) => None, }; - field.or_else(|| self.index_for(class, args, from, &key.ty(), 0)) + field.or_else(|| self.index_for(class, args, from, &key.ty(), &mut FxHashSet::default(), 0)) } /// The value type of the first index of `class`, given the type arguments `args`, or of a parent /// that takes keys of type `key`, inferred in this file, with the file that declares it. A table - /// type named as a parent, like `{ [number]: T }` or `table`, counts as one. - fn index_for(&self, class: &str, args: &[Type], from: FileId, key: &Type, depth: u32) -> Option<(Type, FileId)> { - if depth > MAX_DEPTH { + /// type named as a parent, like `{ [number]: T }` or `table`, counts as one. A + /// class that several parents share is read once. + fn index_for( + &self, + class: &str, + args: &[Type], + from: FileId, + key: &Type, + visited: &mut FxHashSet, + depth: u32, + ) -> Option<(Type, FileId)> { + if depth > MAX_DEPTH || !visited.insert(SmolStr::new(class)) { return None; } let defs = self.class_defs(class, from); @@ -463,11 +494,12 @@ impl<'a, 'b> Classes<'a, 'b> { indices.find(|(index_key, _)| self.takes(&substitute(index_key, &bindings), *file, key))?; Some((substitute(value, &bindings), *file)) }); - own.or_else(|| { - bound_parents(&defs, args).into_iter().find_map(|(file, parent)| match parent { - Type::Named(parent, args) => self.index_for(&parent, &args, file, key, depth + 1), - table => self.table_index_for(&table, file, key), - }) + if own.is_some() { + return own; + } + bound_parents(&defs, args).into_iter().find_map(|(file, parent)| match parent { + Type::Named(parent, args) => self.index_for(&parent, &args, file, key, visited, depth + 1), + table => self.table_index_for(&table, file, key), }) } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 1c52c47..9a70cdc 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3268,6 +3268,32 @@ print(secret, another) ); } +#[test] +fn strict_classes_whose_parents_name_each_other_check_their_keys() { + let mut client = Client::start(fixture_root()); + // Seven classes that each name the other six as parents: every walk over the parents reads each + // class once, and a parent that leads back to a class leaves no key open. + let mut text = String::new(); + for i in 1..=7 { + let parents: Vec = (1..=7).filter(|j| *j != i).map(|j| format!("Test.Ring{j}")).collect(); + text.push_str(&format!("---@class (strict) Test.Ring{i} : {}\n---@field f{i} integer\n\n", parents.join(", "))); + } + text.push_str("---@type Test.Ring1\nlocal ring = { f1 = 1, f7 = 7, other = true }\nring.f2 = 2\nring.extra = 1\n"); + text.push_str("print(ring.f5, ring.missing, ring[1])\n"); + client.open_with(CLIENT, &text); + let line = |needle: &str| pos(&text, needle, 0).0 as u64; + let message = |field: &str| format!("Field `{field}` is not declared in strict class `Test.Ring1`"); + assert_eq!( + undeclared_fields(&mut client, CLIENT), + [ + (line("other = true"), message("other")), + (line("ring.extra"), message("extra")), + (line("print(ring"), message("[1]")), + (line("print(ring"), message("missing")), + ] + ); +} + #[test] fn strict_classes_setting_makes_the_workspace_classes_strict() { struct Fixture(PathBuf); From a17b5a9ff356088bfdc88b42d0aea0a376fa035a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:05:25 +0200 Subject: [PATCH 060/193] fix(workspace): index a new file again when it reads what it declares --- crates/qbx_lua_ls/src/index.rs | 5 +++ crates/qbx_lua_ls/src/workspace.rs | 16 ++++--- crates/qbx_lua_ls/tests/workspace_refresh.rs | 45 ++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 6fbab5b..d527324 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -771,6 +771,11 @@ impl Index { changed_groups(out, module(before), module(after), |a, b| a == b && a.same_origins(b)); } + /// Whether indexing the file `id` read any of `reads`. + pub fn read_any(&self, id: FileId, reads: &FxHashSet) -> bool { + self.file(id).is_some_and(|file| file.index.reads.iter().any(|read| reads.contains(read))) + } + /// The files that read what a file in `changed` declares differently and see that file, the file /// itself included, unless they are in `indexed`: indexed again, in order, since that file was. pub fn readers_of(&self, changed: &Changes, indexed: &FxHashSet) -> FxHashSet { diff --git a/crates/qbx_lua_ls/src/workspace.rs b/crates/qbx_lua_ls/src/workspace.rs index 318fc52..20f5d2f 100644 --- a/crates/qbx_lua_ls/src/workspace.rs +++ b/crates/qbx_lua_ls/src/workspace.rs @@ -375,10 +375,11 @@ impl Workspace { } /// Indexes a document open in the editor. Its symbols are inferred with what the index held for - /// the file, so when it declares other classes or aliases now, as one indexed for the first time - /// does, a second pass reads them, as the scan's second pass does for every file, and the - /// methods of `---@class Name` `Name = {}` see the class. What it now declares differently is - /// added to `changes`. + /// the file, so when it is indexed for the first time, or declares other classes or aliases now, + /// and it read what it declares differently, a second pass reads that, as the scan's second + /// pass does for every file: the methods of `---@class Name` `Name = {}` see the class, and + /// `function Shop.count() return Shop.items.apple end` the items. What it now declares + /// differently is added to `changes`. pub fn index_document( &mut self, path: &Path, @@ -388,11 +389,14 @@ impl Workspace { changes: &mut Changes, ) -> FileId { let id = self.index.allocate(path); + let first = self.index.file(id).is_none(); let declared = |ws: &Self| ws.index.file(id).map(|file| declared_types(&file.index)).unwrap_or_default(); let before = declared(self); let (_, replaced) = self.index_entry(path, FileOrigin::Workspace, source, chunk, resolution); - record(changes, id, self.changed(id, replaced.as_ref())); - if declared(self) != before { + let changed = self.changed(id, replaced.as_ref()); + let again = (first || declared(self) != before) && self.index.read_any(id, &changed); + record(changes, id, changed); + if again { let (_, replaced) = self.index_entry(path, FileOrigin::Workspace, source, chunk, resolution); record(changes, id, self.changed(id, replaced.as_ref())); } diff --git a/crates/qbx_lua_ls/tests/workspace_refresh.rs b/crates/qbx_lua_ls/tests/workspace_refresh.rs index 03eb5dd..94b494d 100644 --- a/crates/qbx_lua_ls/tests/workspace_refresh.rs +++ b/crates/qbx_lua_ls/tests/workspace_refresh.rs @@ -610,3 +610,48 @@ fn closed_files_follow_the_classes_members_exports_and_modules_they_read() { client.notify("textDocument/didSave", json!({"textDocument": {"uri": players}})); assert_eq!(hover(&mut client, &query, text, "Boss"), "(global) Boss: string"); } + +#[test] +fn new_files_read_what_they_declare_themselves_from_the_start() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script '*.lua'\n"); + fixture.write("demo/query.lua", ""); + let mut client = Client::start(&fixture.0); + // A document that is not on disk yet, typed from the globals it declares without annotations. + let fresh = path_to_uri(&fixture.0.join("demo/fresh.lua")); + let text = "\ +Shop = {} +Shop.items = { apple = 1 } + +function Shop.count() + return Shop.items.apple +end + +Animal = {} +Animal.__index = Animal + +function Animal.new() + return setmetatable({}, Animal) +end + +function Animal:speak() + return 'hi' +end + +local count = Shop.count() +local said = Animal.new():speak() +print(count, said) +"; + client.open(&fresh, text); + assert_eq!(hover(&mut client, &fresh, text, "count ="), "local count: integer"); + assert_eq!(hover(&mut client, &fresh, text, "said ="), "local said: string"); + + // A file created on disk, which the files that read it follow. + fixture.write("demo/created.lua", "Stock = { pears = 2 }\n\nfunction GetPears()\n return Stock.pears\nend\n"); + let created = path_to_uri(&fixture.0.join("demo/created.lua")); + client.notify("workspace/didChangeWatchedFiles", json!({"changes": [{"uri": created, "type": 1}]})); + let query = path_to_uri(&fixture.0.join("demo/query.lua")); + let text = "local pears = GetPears()\nprint(pears)\n"; + client.open(&query, text); + assert_eq!(hover(&mut client, &query, text, "pears ="), "local pears: integer"); +} From fbfa693023cf5a7a95362cc079b4b7834eaed431 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:44:58 +0200 Subject: [PATCH 061/193] fix(index): stop types that functions infer from themselves from growing A function that returns what it returns itself, as `function T.wrap() return { T.wrap() } end`, read what the index held for it from before, so its type nested one level deeper every time the file was indexed again, until the server overflowed its stack. A call of a function within itself now reads as unknown while its type is inferred, as lua-language-server reads it, and the types the index stores are cut off 8 levels deep, which stops functions that return what each other return. --- crates/qbx_lua_ls/src/index.rs | 22 +++++++ crates/qbx_lua_ls/src/indexer.rs | 18 ++++-- crates/qbx_lua_ls/src/infer.rs | 46 +++++++++++++ crates/qbx_lua_ls/tests/workspace_refresh.rs | 29 +++++++++ crates/qbx_luacats/src/types.rs | 68 ++++++++++++++++++++ 5 files changed, 179 insertions(+), 4 deletions(-) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index d527324..20b51f5 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -343,6 +343,28 @@ pub struct FileIndex { } impl FileIndex { + /// Cuts the types the entry inferred off `depth` levels deep, as `Type::limit_depth` does. + pub fn limit_depth(&mut self, depth: usize) { + let symbols = + self.globals.iter_mut().chain(&mut self.exports).chain(self.members.iter_mut().map(|m| &mut m.symbol)); + symbols.for_each(|symbol| symbol.ty.limit_depth(depth)); + for element in &mut self.elements { + element.key.iter_mut().chain([&mut element.value]).for_each(|ty| ty.limit_depth(depth)); + } + self.metatables.iter_mut().for_each(|metatable| metatable.metatable.limit_depth(depth)); + // The values of an `---@enum` are inferred too. + for alias in self.aliases.iter_mut().filter(|alias| alias.table.is_some()) { + alias.ty.limit_depth(depth); + if let Some(table) = &mut alias.table { + let members = &mut Arc::make_mut(table).members; + members.iter_mut().flat_map(|(key, value)| [key, value]).for_each(|ty| ty.limit_depth(depth)); + } + } + let handlers = self.events.iter_mut().filter_map(|event| event.handler.as_mut()); + handlers.for_each(|handler| Arc::make_mut(handler).limit_depth(depth)); + self.module_return.iter_mut().for_each(|ty| ty.limit_depth(depth)); + } + /// Gives the class and alias names in the types the entry holds that have no origin the file /// `file`, which wrote them. Those it took from the entries of other files keep theirs. pub fn set_origin(&mut self, file: FileId) { diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 799e792..151c8c9 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -20,6 +20,9 @@ use crate::luacats::{own_type, parse_doc_lines, DocGroup}; use crate::types::{CallbackRole, DescribedValue, FunType, Param, Type}; const MAX_TABLE_DEPTH: u32 = 4; +/// How deep the types that the index holds may nest, as `Type::depth` counts. Real code was seen to +/// infer types 7 levels deep, as functions that return nested table constructors do. +const MAX_TYPE_DEPTH: usize = 8; const MAX_TABLE_FIELDS: usize = 400; const MAX_MEMBERS_PER_FILE: usize = 6000; @@ -116,6 +119,9 @@ pub fn index_file( out.reads.extend(global_reads(resolution).into_iter().map(Read::Global)); out.reads.sort_unstable(); out.reads.dedup(); + // Types that functions infer from each other, as `A.f` returning `{ B.g() }` and `B.g` returning + // `{ A.f() }` do, would otherwise grow every time the files are indexed again. + out.limit_depth(MAX_TYPE_DEPTH); // The class names this file wrote stand for the declarations it sees, also where other resources // read its types. out.set_origin(file); @@ -401,7 +407,10 @@ impl<'a> Indexer<'a> { } Some((ExprKind::Function(func), _)) => { kind = SymbolKind::Function; - Type::Fun(Arc::new(self.infer.fun_type(func, Some(doc_anchor), false))) + let range = self.range(name.span); + let fun = + self.infer.typing(&name.text, range, || self.infer.fun_type(func, Some(doc_anchor), false)); + Type::Fun(Arc::new(fun)) } Some((_, expr)) => self.infer.expr(expr).widen(), None => Type::Unknown, @@ -650,16 +659,17 @@ impl<'a> Indexer<'a> { fn function_decl(&mut self, stmt: &Stmt, name: &FuncName, func: &FuncBody) { let doc = self.ctx.doc_at(stmt.span.start); let is_method = name.method.is_some(); - let fun = Type::Fun(Arc::new(self.infer.fun_type(func, Some(stmt.span.start), is_method))); let last = name.method.as_ref().or(name.path.last()).unwrap_or(&name.base); + let range = self.range(last.span); + let fun = self.infer.typing(&last.text, range, || self.infer.fun_type(func, Some(stmt.span.start), is_method)); let symbol = Symbol { name: last.text.clone(), kind: if is_method { SymbolKind::Method } else { SymbolKind::Function }, - ty: fun, + ty: Type::Fun(Arc::new(fun)), doc: render_doc(&doc), deprecated: doc.deprecated.is_some(), literal: None, - range: self.range(last.span), + range, }; if name.path.is_empty() && name.method.is_none() { if self.is_global(&name.base) { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 6f8afbf..ca058d8 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -530,6 +530,9 @@ pub struct Infer<'a> { depth: Cell, /// Set when `guarded` cuts a lookup short at `MAX_DEPTH`, so that what it found is not cached. truncated: Cell, + /// The functions of this file whose types are inferred for the index, by the name that declares + /// each and its range, as `typing` sets them. + typing: RefCell>, } /// Natives call their handles `Vehicle`, `Ped` and so on. They are integers, and resources (ox_lib, @@ -574,6 +577,46 @@ impl<'a> Infer<'a> { passed_indexes: RefCell::new(FxHashMap::default()), depth: Cell::new(0), truncated: Cell::new(false), + typing: RefCell::new(Vec::new()), + } + } + + /// Runs `infer` while the function that `name` at `range` declares in this file is the one + /// whose type is inferred. The index still holds what it returned before, so a call of it within + /// itself reads as unknown, as lua-language-server reads it, rather than as a type that grows + /// each time the file is indexed again. + pub fn typing(&self, name: &SmolStr, range: Range, infer: impl FnOnce() -> T) -> T { + self.typing.borrow_mut().push((name.clone(), range)); + let out = infer(); + self.typing.borrow_mut().pop(); + out + } + + /// Whether calling `base`, or its `method`, calls a function whose type `typing` infers. + fn calls_itself(&self, base: &Expr, method: Option<&Name>) -> bool { + let (owner, name) = match (method, &base.kind) { + (Some(method), _) => (Some(base), &method.text), + (None, ExprKind::Field { base: owner, name, .. }) => (Some(&**owner), &name.text), + (None, ExprKind::Name(name)) => (None, &name.text), + _ => return false, + }; + let typing = self.typing.borrow(); + let typed: Vec = typing.iter().filter(|(typed, _)| typed == name).map(|(_, range)| *range).collect(); + drop(typing); + if typed.is_empty() { + return false; + } + let declared = |file: FileId, range: Range| file == self.ctx.file && typed.contains(&range); + match owner { + Some(owner) => { + let members = self.members_named(&self.expr(owner), name); + members.iter().any(|member| member.location.is_some_and(|(file, range)| declared(file, range))) + } + None => { + let global = !matches!(self.ctx.resolution.resolve_at(base.span.start), Some(Resolved::Local(_))); + let symbols = self.index.globals_named(name, self.ctx.file); + global && symbols.iter().any(|(file, symbol)| declared(*file, symbol.range)) + } } } @@ -2123,6 +2166,9 @@ impl<'a> Infer<'a> { _ => {} } } + if !self.typing.borrow().is_empty() && self.calls_itself(base, method) { + return only(Type::Unknown, false); + } let args = CallArgs::new(args); let fun = match self.sided_definition(base, method, &args) { Some(Some(fun)) => fun, diff --git a/crates/qbx_lua_ls/tests/workspace_refresh.rs b/crates/qbx_lua_ls/tests/workspace_refresh.rs index 94b494d..13103e9 100644 --- a/crates/qbx_lua_ls/tests/workspace_refresh.rs +++ b/crates/qbx_lua_ls/tests/workspace_refresh.rs @@ -373,6 +373,35 @@ fn open_documents_alone_are_indexed_again_for_the_classes_they_declare() { assert_ne!(chained("Scanned", false), "Demo.Scanned"); } +/// What the member `name` of a table returns, as the index holds it. +fn returned(ws: &Workspace, path: &Path, name: &str) -> qbx_lua_ls::types::Type { + let index = &ws.index.file(ws.index.file_id(path).unwrap()).unwrap().index; + let member = index.members.iter().find(|member| member.symbol.name == name).unwrap(); + member.symbol.ty.as_fun().unwrap().returns[0].clone() +} + +#[test] +fn types_that_functions_infer_from_themselves_stop_growing() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script '*.lua'\n"); + // A function that returns what it returns itself reads its own call as unknown. + fixture.write("demo/wrap.lua", "Wrap = {}\n\nfunction Wrap.wrap()\n return { Wrap.wrap() }\nend\n"); + // Functions that return what each other return grow to the depth that the index keeps. + fixture.write("demo/a.lua", "A = {}\n\nfunction A.f()\n return { B.g() }\nend\n"); + fixture.write("demo/b.lua", "B = {}\n\nfunction B.g()\n return { A.f() }\nend\n"); + let mut ws = fixture.workspace("demo"); + let path = |name: &str| fixture.0.join("demo").join(name); + for _ in 0..20 { + for name in ["wrap.lua", "a.lua", "b.lua"] { + ws.index_path(&path(name), FileOrigin::Workspace, None); + } + } + assert_eq!(returned(&ws, &path("wrap.lua"), "wrap").to_string(), "unknown[]"); + // The function type nests 8 levels deep, of which its own takes one. + let f = returned(&ws, &path("a.lua"), "f"); + assert_eq!(f.depth(), 7, "{f}"); +} + #[test] fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { let fixture = Fixture::new(); diff --git a/crates/qbx_luacats/src/types.rs b/crates/qbx_luacats/src/types.rs index 5d0d596..2b6fc48 100644 --- a/crates/qbx_luacats/src/types.rs +++ b/crates/qbx_luacats/src/types.rs @@ -299,6 +299,16 @@ impl Shape { value.origins(out); } } + + fn types(&self) -> impl Iterator { + let indices = self.indices.iter().flat_map(|(key, value)| [key, value]); + self.fields.iter().map(|field| &field.ty).chain(&self.array).chain(indices) + } + + fn types_mut(&mut self) -> impl Iterator { + let indices = self.indices.iter_mut().flat_map(|(key, value)| [key, value]); + self.fields.iter_mut().map(|field| &mut field.ty).chain(&mut self.array).chain(indices) + } } impl FunType { @@ -337,6 +347,25 @@ impl FunType { self.return_sets.iter().flatten().for_each(|ty| ty.origins(out)); self.overloads.iter().for_each(|overload| overload.origins(out)); } + + /// How deep the types of its parameters and returned values nest, as `Type::depth` counts. + fn depth_inside(&self) -> usize { + let params = self.params.iter().map(|param| param.ty.depth()); + let returns = self.returns.iter().chain(self.return_sets.iter().flatten()).map(Type::depth); + let overloads = self.overloads.iter().map(|overload| overload.depth_inside()); + params.chain(returns).chain(overloads).max().unwrap_or(0) + } + + /// Cuts the signature off so that a function type of it nests at most `depth` levels deep, as + /// `Type::limit_depth` does. + pub fn limit_depth(&mut self, depth: usize) { + let inside = depth.saturating_sub(1); + self.params.iter_mut().for_each(|param| param.ty.limit_depth(inside)); + self.returns.iter_mut().chain(self.return_sets.iter_mut().flatten()).for_each(|ty| ty.limit_depth(inside)); + for overload in self.overloads.iter_mut().filter(|overload| overload.depth_inside() > inside) { + Arc::make_mut(overload).limit_depth(depth); + } + } } impl Type { @@ -402,6 +431,45 @@ impl Type { ours == theirs } + /// How many levels of tables, functions and type arguments nest in the type: 2 for + /// `fun(): integer[]`, 0 for `integer|string`. + pub fn depth(&self) -> usize { + let deepest = |types: &mut dyn Iterator| types.map(Type::depth).max().unwrap_or(0); + match self { + Type::Array(inner) | Type::Variadic(inner) => 1 + inner.depth(), + Type::Map(key, value) => 1 + key.depth().max(value.depth()), + Type::Tuple(types) => 1 + deepest(&mut types.iter()), + Type::Union(types) => deepest(&mut types.iter()), + Type::Named(_, args) if !args.is_empty() => 1 + deepest(&mut args.iter()), + Type::Fun(fun) => 1 + fun.depth_inside(), + Type::Shape(shape) => 1 + deepest(&mut shape.types()), + _ => 0, + } + } + + /// Cuts the type off `depth` levels deep, as `depth` counts them: what nests deeper reads as + /// unknown. A type that a function infers from what it returned before, as + /// `function T.wrap() return { T.wrap() } end` does, stops growing so. + pub fn limit_depth(&mut self, depth: usize) { + if self.depth() <= depth { + return; + } + let inside = depth.saturating_sub(1); + match self { + Type::Union(types) => types.iter_mut().for_each(|ty| ty.limit_depth(depth)), + _ if depth == 0 => *self = Type::Unknown, + Type::Array(inner) | Type::Variadic(inner) => inner.limit_depth(inside), + Type::Map(key, value) => { + key.limit_depth(inside); + value.limit_depth(inside); + } + Type::Tuple(types) | Type::Named(_, types) => types.iter_mut().for_each(|ty| ty.limit_depth(inside)), + Type::Fun(fun) => Arc::make_mut(fun).limit_depth(depth), + Type::Shape(shape) => Arc::make_mut(shape).types_mut().for_each(|ty| ty.limit_depth(inside)), + _ => {} + } + } + /// The files that the class and alias names in the type were written in, in order. fn origins(&self, out: &mut Vec>) { match self { From 24da0f0464b0c6057e2683ec93a003e8cd34b71b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:28:02 +0200 Subject: [PATCH 062/193] fix(index): leave the same index whatever order files were indexed in Indexing a file again moved its entries to the end of each name's list, and the scan stopped after two passes, before types that come through several files settled. The index now keeps entries in file order, and the scan goes on, as edits do, indexing again the files that read what the pass before changed, until nothing changes, so opening and closing files leaves what a fresh scan does. A lookup of members by name also reads which files set members on the table, since that decides whose members it takes. --- crates/qbx_lua_ls/src/index.rs | 40 +++++++++++++++----- crates/qbx_lua_ls/src/workspace.rs | 22 +++++------ crates/qbx_lua_ls/tests/workspace_refresh.rs | 39 +++++++++++++++++++ 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 20b51f5..3b3a5bc 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -27,6 +27,9 @@ pub enum Read { /// Whether the tables an owner names have members, and their array parts, `[key]` entries, /// metatables and the `---@enum` whose table they are. Table(SmolStr), + /// The files that set members on the tables an owner names, which decide whose members of a + /// name a lookup takes: those of its own resource, when one of its files sets any. + MemberFiles(SmolStr), /// The classes, aliases and enums of a name. Type(SmolStr), /// The operators that classes declare with `@operator`, whichever classes they are. @@ -510,6 +513,14 @@ pub fn normalize_path(path: &Path) -> PathBuf { } } +/// Adds `slot` to the slots of `key` in file order, where a scan puts them, so that what lookups find +/// first does not depend on which files were indexed again since. +fn insert_slot(map: &mut FxHashMap>, key: &SmolStr, slot: Slot) { + let slots = map.entry(key.clone()).or_default(); + let at = slots.partition_point(|existing| *existing < slot); + slots.insert(at, slot); +} + fn remove_file_slots<'a>( map: &mut FxHashMap>, keys: impl Iterator, @@ -585,26 +596,26 @@ impl Index { pub fn set_file(&mut self, id: FileId, entry: FileEntry) -> Option { let replaced = self.clear_slots(id); for (i, symbol) in entry.index.globals.iter().enumerate() { - self.globals.entry(symbol.name.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.globals, &symbol.name, (id, i as u32)); } for (i, member) in entry.index.members.iter().enumerate() { - self.members.entry(member.owner.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.members, &member.owner, (id, i as u32)); } for (i, element) in entry.index.elements.iter().enumerate() { - self.elements.entry(element.owner.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.elements, &element.owner, (id, i as u32)); } for (i, metatable) in entry.index.metatables.iter().enumerate() { - self.metatables.entry(metatable.owner.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.metatables, &metatable.owner, (id, i as u32)); } for (i, class) in entry.index.classes.iter().enumerate() { - self.classes.entry(class.name.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.classes, &class.name, (id, i as u32)); } for (i, alias) in entry.index.aliases.iter().enumerate() { - self.aliases.entry(alias.name.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.aliases, &alias.name, (id, i as u32)); } for (i, member) in entry.index.members.iter().enumerate() { if member.owner == "exports" && entry.declares_export_type(i as u32) { - self.export_types.entry(member.symbol.name.clone()).or_default().push((id, i as u32)); + insert_slot(&mut self.export_types, &member.symbol.name, (id, i as u32)); } } if let Some(resource) = entry.resource.and_then(|r| self.resources.get_mut(r as usize)) { @@ -723,6 +734,7 @@ impl Index { entry.into_iter().flat_map(|entry| &entry.index.members).map(|member| &member.owner).collect() }; let (old, new) = (owners(before), owners(after)); + out.extend(old.symmetric_difference(&new).map(|owner| Read::MemberFiles((*owner).clone()))); let file = after.or(before).and_then(|entry| self.file_id(&entry.path)); let elsewhere = |owner: &SmolStr| { self.members.get(owner).is_some_and(|slots| slots.iter().any(|(slot, _)| Some(*slot) != file)) @@ -826,7 +838,7 @@ impl Index { fn reads_from(&self, reader: FileId, declarer: FileId, read: &Read) -> bool { match read { Read::Global(_) => self.is_visible(reader, declarer), - Read::Members(owner, _) | Read::Table(owner) => { + Read::Members(owner, _) | Read::Table(owner) | Read::MemberFiles(owner) => { let owner = instance_class(owner).unwrap_or(owner); owner.starts_with('%') || self.classes.contains_key(owner) || self.reaches(reader, declarer) } @@ -933,6 +945,9 @@ impl Index { /// statements that set them tell about them. With a `name`, those called so. pub fn member_entries(&self, owner: &str, name: Option<&str>, from: FileId) -> Vec<(FileId, &Member)> { self.note(|| Read::Members(SmolStr::new(owner), name.map(SmolStr::new))); + if name.is_some() { + self.note(|| Read::MemberFiles(SmolStr::new(owner))); + } self.owner_slots(self.members.get(owner), owner, from) .into_iter() .filter_map(|(file, i)| Some((file, self.file(file)?.index.members.get(i as usize)?))) @@ -1032,6 +1047,7 @@ impl Index { let visible: Vec = slots.iter().copied().filter(|(f, _)| self.reaches(from, *f)).collect(); // Classes travel between resources through exports and events, so their members are looked // up everywhere; plain tables of unrelated resources are not. + self.note(|| Read::Type(SmolStr::new(owner))); if !visible.is_empty() || !self.classes.contains_key(owner) { return visible; } @@ -1106,8 +1122,10 @@ impl Index { /// scoped to. pub fn enums_of_table(&self, owner: &str) -> Vec<(FileId, &AliasDef)> { self.note(|| Read::Table(SmolStr::new(owner))); - let slots = self.aliases.values().flatten(); + let mut slots: Vec<&Slot> = self.aliases.values().flatten().collect(); + slots.sort_unstable(); slots + .into_iter() .filter_map(|(f, i)| Some((*f, self.file(*f)?.index.aliases.get(*i as usize)?))) .filter(|(_, alias)| alias.table.as_ref().is_some_and(|table| table.owner == owner)) .collect() @@ -1129,7 +1147,9 @@ impl Index { /// looked up in every file whose side fits, like registered exports are. pub fn declared_exports(&self, from: FileId) -> Vec<(FileId, &Symbol)> { self.note(|| Read::Members(SmolStr::new("exports"), None)); - self.export_type_slots(self.export_types.values().flatten(), from) + let mut slots: Vec<&Slot> = self.export_types.values().flatten().collect(); + slots.sort_unstable(); + self.export_type_slots(slots.into_iter(), from) } /// The types declared for the exports of `resource`, as `declared_exports` finds them. diff --git a/crates/qbx_lua_ls/src/workspace.rs b/crates/qbx_lua_ls/src/workspace.rs index 20f5d2f..36a9c9b 100644 --- a/crates/qbx_lua_ls/src/workspace.rs +++ b/crates/qbx_lua_ls/src/workspace.rs @@ -23,6 +23,10 @@ const MAX_INDEXED_FILE_BYTES: u64 = 2 * 1024 * 1024; /// How often one change may index the same files again, so that globals typed from each other /// cannot keep it going. pub const MAX_INDEX_PASSES: usize = 4; +/// How often a scan may index files again once every file is known. Only the files that read what +/// the pass before changed are indexed again, and types inferred from each other in a cycle cannot +/// keep it going. +const MAX_SCAN_PASSES: usize = 8; /// A file just indexed, with the entry the index held for it before. type Indexed = (FileId, Option); @@ -140,18 +144,14 @@ impl Workspace { stats } - /// Symbol types are inferred while indexing and may refer to files that were not indexed yet - /// (exports, imported globals), so a second pass settles them once every file is known. + /// Symbol types are inferred while indexing and may come from files that were not indexed yet + /// (exports, imported globals), or from what those infer in turn. So once every file is known, + /// each is indexed again, and then each that read what that changed, until nothing changes. + /// Indexing a file again then changes nothing, so what the index holds does not depend on the + /// order files were indexed in, nor on which were indexed again since. fn reindex_all(&mut self) { - let files: Vec<(PathBuf, FileOrigin)> = self - .index - .files() - .filter(|(_, f)| f.origin != FileOrigin::Stub) - .map(|(_, f)| (f.path.clone(), f.origin)) - .collect(); - for (path, origin) in files { - self.index_path(&path, origin, None); - } + let files = self.index.files().filter(|(_, f)| f.origin != FileOrigin::Stub).map(|(id, _)| id).collect(); + self.refresh_readers(files, &FxHashSet::default(), MAX_SCAN_PASSES); } /// Indexes resources that workspace manifests refer to but that live outside the workspace, diff --git a/crates/qbx_lua_ls/tests/workspace_refresh.rs b/crates/qbx_lua_ls/tests/workspace_refresh.rs index 13103e9..9e7eb35 100644 --- a/crates/qbx_lua_ls/tests/workspace_refresh.rs +++ b/crates/qbx_lua_ls/tests/workspace_refresh.rs @@ -402,6 +402,45 @@ fn types_that_functions_infer_from_themselves_stop_growing() { assert_eq!(f.depth(), 7, "{f}"); } +/// What the index holds for each file, and the members of `owner` in the order lookups from +/// `reader` find them. +fn index_state(ws: &Workspace, owner: &str, reader: &Path) -> Vec { + let files = ws.index.files().filter(|(_, file)| file.origin != FileOrigin::Stub); + let mut state: Vec = files.map(|(_, file)| format!("{:?}", file.index)).collect(); + let reader = ws.index.file_id(reader).unwrap(); + state.extend(ws.index.members_of(owner, reader).into_iter().map(|(_, member)| member.name.to_string())); + state +} + +#[test] +fn a_scan_leaves_what_indexing_files_again_in_any_order_would() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script '*.lua'\n"); + // What `AddBlip` returns comes from `utils.lua`, indexed after it, which takes it from a + // function beside it in turn. Indexing every file twice is not enough to know it. + fixture.write("demo/blips.lua", "function lib.AddBlip()\n return lib.Key()\nend\n"); + fixture.write("demo/init.lua", "lib = {}\n"); + fixture.write( + "demo/utils.lua", + "function lib.Key()\n return lib.String()\nend\n\nfunction lib.String()\n return ''\nend\n", + ); + // Functions that return what each other return settle at the depth that the index keeps. + fixture.write("demo/x.lua", "X = {}\n\nfunction X.f()\n return { Y.g() }\nend\n"); + fixture.write("demo/y.lua", "Y = {}\n\nfunction Y.g()\n return { X.f() }\nend\n"); + let mut ws = fixture.workspace("demo"); + let blips = fixture.0.join("demo/blips.lua"); + assert_eq!(returned(&ws, &blips, "AddBlip"), qbx_lua_ls::types::Type::String); + + // Indexing a file again, as opening and closing it does, leaves it and the order of what + // several files set as the scan did. + let scanned = index_state(&ws, "lib", &blips); + let files = ["blips.lua", "init.lua", "utils.lua", "x.lua", "y.lua"].map(|name| fixture.0.join("demo").join(name)); + for path in files.iter().chain(files.iter().rev()) { + ws.index_path(path, FileOrigin::Workspace, None); + assert_eq!(index_state(&ws, "lib", &blips), scanned, "after indexing {} again", path.display()); + } +} + #[test] fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { let fixture = Fixture::new(); From 7fd75652171d9465859ef73ef7bdaa874a2b4def Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:09:25 +0200 Subject: [PATCH 063/193] fix(analysis): follow symlinked folders as FiveM does FiveM loads resources from symlinked folders, such as a server's resources linked to where they are developed, but the linter and the language server skipped them: the language server only indexed such a file once a document opened it, so what it showed depended on which files were opened before. Finding files, resources and the resources a server starts now follows links, except those into the folder it walks or to a folder above it, which would walk it again, and reads a folder that several links lead to once. --- crates/qbx_lua_analysis/src/lint.rs | 9 ++- crates/qbx_lua_analysis/src/project.rs | 89 +++++++++++++++++++-- crates/qbx_lua_analysis/src/startup.rs | 28 ++++--- crates/qbx_lua_ls/src/features/assistant.rs | 6 +- docs/reference.md | 4 +- 5 files changed, 116 insertions(+), 20 deletions(-) diff --git a/crates/qbx_lua_analysis/src/lint.rs b/crates/qbx_lua_analysis/src/lint.rs index e47d1b6..1fb4622 100644 --- a/crates/qbx_lua_analysis/src/lint.rs +++ b/crates/qbx_lua_analysis/src/lint.rs @@ -12,8 +12,8 @@ use crate::crossref::CrossRefs; use crate::diagnostic::{Diagnostic, Severity}; use crate::locale::{locale_usage, LocaleFile}; use crate::project::{ - find_manifest_dir, is_manifest_file, lua_files_under, read_source, relative_slash_path, ParsedFile, Resource, - ResourceLocator, + find_manifest_dir, is_manifest_file, lua_files_under, read_source, relative_slash_path, walks_into, ParsedFile, + Resource, ResourceLocator, }; use crate::rules; use qbx_lua_syntax::parse; @@ -241,11 +241,14 @@ fn lint_resource( } pub fn all_files(root: &Path) -> Vec { + let walked = root.canonicalize().ok(); WalkDir::new(root) + .follow_links(true) .into_iter() .filter_entry(|e| { let name = e.file_name().to_string_lossy(); - e.depth() == 0 || !(name == "node_modules" || name == ".git") + let skipped = e.depth() > 0 && (name == "node_modules" || name == ".git"); + !skipped && walks_into(walked.as_deref(), e) }) .flatten() .filter(|e| e.file_type().is_file()) diff --git a/crates/qbx_lua_analysis/src/project.rs b/crates/qbx_lua_analysis/src/project.rs index 5c542e5..f0795f7 100644 --- a/crates/qbx_lua_analysis/src/project.rs +++ b/crates/qbx_lua_analysis/src/project.rs @@ -6,7 +6,7 @@ use qbx_lua_syntax::ast::Chunk; use qbx_lua_syntax::{parse, SmolStr}; use qbx_luacats::types::{FunType, Type}; use rustc_hash::{FxHashMap, FxHashSet}; -use walkdir::WalkDir; +use walkdir::{DirEntry, WalkDir}; use crate::config::Config; use crate::glob::{is_glob, manifest_glob_match}; @@ -99,12 +99,41 @@ pub fn relative_slash_path(root: &Path, path: &Path) -> String { path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/") } +/// The real path of `entry` when it is a folder that a walk reached through a symbolic link. +fn linked_folder(entry: &DirEntry) -> Option { + let linked = entry.depth() > 0 && entry.path_is_symlink() && entry.file_type().is_dir(); + linked.then(|| entry.path().canonicalize().ok()).flatten() +} + +/// Whether a folder at `target` that a link leads to is somewhere else than the folders a walk from +/// `root`, by its real path, covers anyway, or one above them, which it would walk again. +fn leads_elsewhere(root: Option<&Path>, target: &Path) -> bool { + root.is_some_and(|root| !target.starts_with(root) && !root.starts_with(target)) +} + +/// Whether a walk from `root`, by its real path, goes into `entry`. FiveM loads resources from +/// symlinked folders too, as a server's `[local]` linked to where they are developed, so walks +/// follow links, but only those that lead somewhere else. +pub fn walks_into(root: Option<&Path>, entry: &DirEntry) -> bool { + linked_folder(entry).is_none_or(|target| leads_elsewhere(root, &target)) +} + +/// The Lua files under `root`, sorted. Symbolic links are followed as `walks_into` decides, and a +/// folder that several of them lead to is read once, through the first by name. pub fn lua_files_under(root: &Path, config: &Config) -> Vec { let mut files = Vec::new(); - let walker = WalkDir::new(root).follow_links(false).into_iter().filter_entry(|entry| { + let canonical_root = root.canonicalize().ok(); + let mut linked: FxHashSet = FxHashSet::default(); + let walker = WalkDir::new(root).follow_links(true).sort_by_file_name().into_iter().filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); let hidden_dir = entry.file_type().is_dir() && name.starts_with('.') && entry.depth() > 0; - !(hidden_dir || name == "node_modules" || config.excludes_entry(entry.path())) + if hidden_dir || name == "node_modules" || config.excludes_entry(entry.path()) { + return false; + } + match linked_folder(entry) { + Some(target) => leads_elsewhere(canonical_root.as_deref(), &target) && linked.insert(target), + None => true, + } }); for entry in walker.flatten() { if entry.file_type().is_file() && entry.path().extension().is_some_and(|e| e == "lua") { @@ -359,9 +388,11 @@ impl ResourceLocator { let dir = Self::resources_dir(from_resource)?; let index = self.roots.entry(dir.clone()).or_insert_with(|| { let mut index = FxHashMap::default(); - let walker = WalkDir::new(&dir).max_depth(6).into_iter().filter_entry(|entry| { + let root = dir.canonicalize().ok(); + let walker = WalkDir::new(&dir).max_depth(6).follow_links(true).into_iter().filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); - entry.depth() == 0 || !(name.starts_with('.') || name == "node_modules") + let hidden = entry.depth() > 0 && (name.starts_with('.') || name == "node_modules"); + !hidden && walks_into(root.as_deref(), entry) }); for entry in walker.flatten() { if entry.file_type().is_file() && is_manifest_file(entry.path()) { @@ -465,6 +496,54 @@ mod tests { assert!(!is_not_source(format!("local t={{{}}}\n", "functions=1,_function=2,".repeat(200)).as_bytes())); } + /// Links `link` to the folder `target`, unless the platform refuses to, as Windows refuses + /// symbolic links without the right to create them; a junction needs no such right. + fn link_folder(target: &Path, link: &Path) -> bool { + #[cfg(unix)] + return std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + return std::os::windows::fs::symlink_dir(target, link).is_ok() + || std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .is_ok_and(|output| output.status.success()); + #[cfg(not(any(unix, windows)))] + false + } + + #[test] + fn lua_files_under_follows_linked_folders_once() { + let root = std::env::temp_dir().join(format!("qbx-linked-folders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + // `mklink` takes no forward slashes. + let at = |relative: &str| relative.split('/').fold(root.clone(), |path, part| path.join(part)); + for file in ["elsewhere/racing/client.lua", "server/resources/[core]/core.lua", "server/cache/stray.lua"] { + std::fs::create_dir_all(at(file).parent().unwrap()).unwrap(); + std::fs::write(at(file), "").unwrap(); + } + let resources = at("server/resources"); + let links = [ + ("elsewhere", "server/resources/[symlink]"), + ("elsewhere", "server/resources/[again]"), + ("server/resources/[core]", "server/resources/[core-link]"), + ("server/resources", "server/resources/[core]/loop"), + ("server", "server/resources/[up]"), + ]; + if !links.iter().all(|(target, link)| link_folder(&at(target), &at(link))) { + eprintln!("skipped: folder links cannot be created here"); + let _ = std::fs::remove_dir_all(&root); + return; + } + let files = lua_files_under(&resources, &Config::default()); + let relative: Vec = files.iter().map(|file| relative_slash_path(&resources, file)).collect(); + let _ = std::fs::remove_dir_all(&root); + // The folder two links lead to is read through the first, and links into the tree, or to a + // folder above it, are left out. + assert_eq!(relative, ["[again]/racing/client.lua", "[core]/core.lua"]); + } + #[test] fn declarations_need_their_side_and_the_import_of_their_provider() { let declare = |side, provider: Option<&str>| Declaration { side, provider: provider.map(SmolStr::new) }; diff --git a/crates/qbx_lua_analysis/src/startup.rs b/crates/qbx_lua_analysis/src/startup.rs index a160acf..2dc20d1 100644 --- a/crates/qbx_lua_analysis/src/startup.rs +++ b/crates/qbx_lua_analysis/src/startup.rs @@ -5,7 +5,7 @@ use qbx_lua_syntax::SmolStr; use rustc_hash::{FxHashMap, FxHashSet}; use walkdir::WalkDir; -use crate::project::manifest_in; +use crate::project::{manifest_in, walks_into}; /// Resources the server artifact ships in `citizen/system_resources`. They are installed on /// every server without appearing under `resources`, so a recipe may even delete its own copy. @@ -36,9 +36,12 @@ fn provided_names(manifest_text: &str) -> impl Iterator + '_ { fn installed_resources(resources_dir: &Path) -> (FxHashSet, FxHashMap>) { let mut names: FxHashSet = SYSTEM_RESOURCES.iter().map(SmolStr::new).collect(); let mut provided: FxHashMap> = FxHashMap::default(); - let mut walker = WalkDir::new(resources_dir).max_depth(7).into_iter().filter_entry(|entry| { + // FiveM starts resources from symlinked folders too. + let root = resources_dir.canonicalize().ok(); + let mut walker = WalkDir::new(resources_dir).max_depth(7).follow_links(true).into_iter().filter_entry(|entry| { let name = entry.file_name().to_string_lossy(); - entry.depth() == 0 || !(name == "node_modules" || name.starts_with('.')) + let hidden = entry.depth() > 0 && (name == "node_modules" || name.starts_with('.')); + !hidden && walks_into(root.as_deref(), entry) }); while let Some(entry) = walker.next() { let Ok(entry) = entry else { continue }; @@ -84,9 +87,13 @@ fn find_server_cfg(resource_root: &Path) -> Option { fn resources_in_category(resources_dir: &Path, category: &str) -> Vec { let mut names = Vec::new(); - let folders = WalkDir::new(resources_dir).max_depth(4).into_iter().flatten(); + let root = resources_dir.canonicalize().ok(); + let folders = WalkDir::new(resources_dir).max_depth(4).follow_links(true).into_iter(); + let folders = folders.filter_entry(|entry| walks_into(root.as_deref(), entry)).flatten(); for folder in folders.filter(|e| e.file_type().is_dir() && e.file_name().to_string_lossy() == category) { - let mut entries = WalkDir::new(folder.path()).max_depth(5).into_iter(); + let walked = folder.path().canonicalize().ok(); + let entries = WalkDir::new(folder.path()).max_depth(5).follow_links(true).into_iter(); + let mut entries = entries.filter_entry(|entry| walks_into(walked.as_deref(), entry)); while let Some(entry) = entries.next() { let Ok(entry) = entry else { continue }; if entry.file_type().is_dir() && manifest_in(entry.path()).is_some() { @@ -117,10 +124,13 @@ impl StartOrder { order.installed.extend(SYSTEM_RESOURCES.iter().map(SmolStr::new)); let mut resources = Vec::new(); let mut partial = false; - let mut walker = WalkDir::new(&resources_dir).max_depth(7).into_iter().filter_entry(|entry| { - let name = entry.file_name().to_string_lossy(); - entry.depth() == 0 || !(name == "node_modules" || name.starts_with('.')) - }); + let root = resources_dir.canonicalize().ok(); + let mut walker = + WalkDir::new(&resources_dir).max_depth(7).follow_links(true).into_iter().filter_entry(|entry| { + let name = entry.file_name().to_string_lossy(); + let hidden = entry.depth() > 0 && (name == "node_modules" || name.starts_with('.')); + !hidden && walks_into(root.as_deref(), entry) + }); while *remaining_entries > 0 { let Some(entry) = walker.next() else { break }; *remaining_entries -= 1; diff --git a/crates/qbx_lua_ls/src/features/assistant.rs b/crates/qbx_lua_ls/src/features/assistant.rs index d3147f9..dbf216f 100644 --- a/crates/qbx_lua_ls/src/features/assistant.rs +++ b/crates/qbx_lua_ls/src/features/assistant.rs @@ -382,8 +382,10 @@ fn bounded_inventory(root: &Path, budget: &mut InspectionBudget, entries: &mut u } let mut files = Vec::new(); let mut complete = true; - let mut walker = walkdir::WalkDir::new(root).into_iter().filter_entry(|entry| { - entry.depth() == 0 || (entry.file_name() != "node_modules" && entry.file_name() != ".git") + let walked = root.canonicalize().ok(); + let mut walker = walkdir::WalkDir::new(root).follow_links(true).into_iter().filter_entry(|entry| { + let skipped = entry.depth() > 0 && (entry.file_name() == "node_modules" || entry.file_name() == ".git"); + !skipped && qbx_lua_analysis::project::walks_into(walked.as_deref(), entry) }); while *entries > 0 { let Some(entry) = walker.next() else { break }; diff --git a/docs/reference.md b/docs/reference.md index 4557282..37bee33 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -60,7 +60,9 @@ quote_style = "preserve" | `format` | Formatting options, shown with their defaults above. | The default exclusions include `node_modules`, `.git`, and `[builders]` directory contents. -Directory traversal also skips hidden directories and does not follow directory symlinks. +Directory traversal also skips hidden directories. It follows directory symlinks, as FiveM does, +except links into the folder it traverses or to a folder above it, and reads a folder that several +links lead to once. ### Ignoring diagnostics From 2494b7b1392959c48f9da12f6e8b216f20ac2c2a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:26:16 +0200 Subject: [PATCH 064/193] feat(infer): narrow locals that are assigned again --- crates/qbx_lua_ls/README.md | 42 +- crates/qbx_lua_ls/src/features/casts.rs | 17 +- .../qbx_lua_ls/src/features/class_tables.rs | 12 +- crates/qbx_lua_ls/src/features/comparisons.rs | 132 +- crates/qbx_lua_ls/src/features/expected.rs | 2 +- crates/qbx_lua_ls/src/features/member_refs.rs | 2 +- crates/qbx_lua_ls/src/features/nil_checks.rs | 27 +- crates/qbx_lua_ls/src/infer.rs | 444 ++++++- crates/qbx_lua_ls/src/narrow.rs | 1105 ++++++++++++++--- crates/qbx_lua_ls/tests/lsp.rs | 280 ++++- docs/reference.md | 37 +- 11 files changed, 1781 insertions(+), 319 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 95835c3..61c669d 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -61,7 +61,9 @@ Available features depend on the editor's LSP client. or `state == "busy"` narrow it to that kind or value. A guard on one value of `local ok, err = f()` also narrows the others, for functions that return [sets of values](#sets-of-returned-values) such as `false | (string, string)`, and a - [`---@cast`](#casts) changes the type of a local from its line on. + [`---@cast`](#casts) changes the type of a local from its line on. A local that is + [assigned again](#locals-that-are-assigned-again) holds the values that reach each point, so + `name = name or 'none'` leaves a `string`. - Diagnostics and quick fixes with resource and client/server context, plus LuaCATS type checks: `missing-fields` and `assign-type-mismatch` for tables and assignments that leave out required fields of their class or store a value of the wrong type, in a field or in a variable typed with @@ -147,9 +149,8 @@ true when `name` holds a value, and so are `name?.job == "police"` and `name?.jo They apply to the branches of an `if` or `elseif`, failed ones to the conditions of the `elseif`s after them, to the code after an `if` whose other branches all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the right side of `and` and `or`, and to the code -after `assert(name)`. A local that is assigned again after its declaration is only narrowed by the -guards after a [`---@cast`](#casts) of it, since a guard says nothing about the new value, and -globals and fields are not narrowed. +after `assert(name)`. A guard narrows what a local holds there, also one that is +[assigned again](#locals-that-are-assigned-again), while globals and fields are not narrowed. A comparison with a literal narrows the local to that literal: inside `if state == "busy" then`, a `"active"|"busy"|nil` and a `string` are both `"busy"`, and in the `else` branch the first is @@ -175,6 +176,39 @@ after `local type = type`, and not `table.type` of ox_lib. Where one of several checks held, as inside `if type(value) == "string" or type(value) == "number" then`, the local is of the kinds they let through. +### Locals that are assigned again + +A local that is assigned again holds, at each point, the values that may reach it there: that of its +declaration or of an assignment, each narrowed by the guards since, and those of the ways that meet +after an `if`, a loop or a label. An assignment gives its value from the end of its statement: + +```lua +local name = GetName() -- string? +name = name or "none" +print(name) -- string + +local label = GetName() +if not label then + label = "none" +end +print(label) -- string +``` + +A literal is widened to its kind, as `mode = 'dev'` stores a `string`. A local declared without a +value holds `nil` until it is given one, and then makes way for the values it may be given, as in +lua-language-server: after `local found` and `if ok then found = item end`, `found` is the item. A +local with a `---@type` or `@param` keeps the parts of that type the value may be, as the `string` of +a `string?` for `"x"`, and all of it for a value of another kind, which `assign-type-mismatch` +reports, or of no known type. Other locals keep the type of their declaration for such a value. A +loop starts with what the code before it and its runs before leave, and a label with what the +`goto`s to it leave. + +Calls are taken to change no local, but a function runs any time after it is created. Inside it, a +local declared outside it may hold what it held where the function was created, and also what the +code after that and the other functions assign, with nothing known about those values. Where a +function that assigns a local is created, the code around it may find that value from then on, as +after `each(list, function(item) found = item end)`. + ### Casts A `---@cast` line changes the type of a local from its line on, as in lua-language-server: diff --git a/crates/qbx_lua_ls/src/features/casts.rs b/crates/qbx_lua_ls/src/features/casts.rs index e4ed2e4..3196ef8 100644 --- a/crates/qbx_lua_ls/src/features/casts.rs +++ b/crates/qbx_lua_ls/src/features/casts.rs @@ -1,8 +1,10 @@ //! `cast-type-mismatch`: a `---@cast name T` that gives a local a type its declared type does not -//! take, such as `string` for an `integer`. As for `assign-type-mismatch`, each type that `T` lists -//! has to be of a kind the declared type allows, and a literal has to be one it lists. A class also -//! has to be one that the declared type names or extends, as lua-language-server checks it. Only -//! plain casts are checked, since `+T` and `-T` add to the type and take from it. +//! take, such as `string` for an `integer`. A local that nothing annotates and that is assigned +//! again has the declared types of the values that reach the cast. As for `assign-type-mismatch`, +//! each type that `T` lists has to be of a kind the declared type allows, and a literal has to be +//! one it lists. A class also has to be one that the declared type names or extends, as +//! lua-language-server checks it. Only plain casts are checked, since `+T` and `-T` add to the type +//! and take from it. use qbx_lua_analysis::scope::LocalId; use qbx_lua_syntax::ast::{ExprKind, StmtKind}; @@ -26,7 +28,12 @@ pub fn mismatched_casts(infer: &Infer) -> Vec<(Span, String)> { let mut out = Vec::new(); for cast in casts.iter() { let Some(local) = cast.local else { continue }; - let expected = declared.declaration(local); + // A local that nothing annotates and that is assigned again holds the declared types of the + // values that reach the cast. + let expected = match declared.declaration(local) { + Type::Unknown => declared.at(local, cast.span.start), + declared => declared, + }; // `local data = nil` makes room for a value that the cast tells the type of. if expected.is_unknown() || expected == Type::Nil { continue; diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 95a6e28..4b44e07 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -544,13 +544,15 @@ impl<'a, 'b> Classes<'a, 'b> { } /// The type of `expr` for checking the value it stores or returns. A local that is assigned - /// again after its declaration is `unknown`, since its declared type may not be what it holds. + /// again after its declaration has the types of the values that may reach it there, and is + /// `unknown` when one of them is. pub fn value_type(&self, expr: &Expr) -> Type { if let ExprKind::Name(name) = &expr.unparen().kind { let resolution = self.infer.ctx.resolution; if let Some(Resolved::Local(id)) = resolution.resolve_at(name.span.start) { - if resolution.local(id).refs.iter().any(|r| r.write) { - return Type::Unknown; + let cast = self.infer.cast_after(expr.span.end); + if cast.is_none() && resolution.local(id).refs.iter().any(|r| r.write) { + return self.infer.known_local_type_at(id, name.span.start); } } } @@ -994,12 +996,12 @@ impl<'c> Finder<'_, '_, 'c> { /// class the target holds, or else the table type it is declared with, as its own `---@type` /// or `@param` declares it. What the target was assigned before only tells what it held then. fn assigned_type(&self, target: &Expr) -> Type { - let ty = self.classes.infer.expr(target); + let ty = self.classes.infer.target_type(target); if self.classes.class_of(&ty, self.classes.file()).is_some() { return ty; } match &self.table_types { - Some((declared, _)) => declared.of(target), + Some((declared, _)) => declared.target(target), None => ty, } } diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index ab18847..4027e7a 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -17,12 +17,12 @@ use qbx_lua_analysis::scope::{Local, LocalId, Resolved}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use super::class_tables::{Classes, Key}; use super::unknown_types::has_param_line; -use crate::infer::{Decl, Expected, Infer}; -use crate::luacats::CastEntry; +use crate::infer::{assigned_value, Decl, Expected, Infer}; +use crate::narrow::Origin; use crate::types::Type; const MAX_DEPTH: u32 = 16; @@ -50,6 +50,10 @@ pub struct Declared<'a, 'b> { cast_starts: OnceCell>, /// The types the locals read so far are declared with. declarations: RefCell>, + /// The declared types of the values that the declarations and assignments of locals that are + /// assigned again give them, by the local and the origin of the value. + origins: RefCell>, + in_progress: RefCell>, } impl<'a, 'b> Declared<'a, 'b> { @@ -60,6 +64,8 @@ impl<'a, 'b> Declared<'a, 'b> { keeps_annotations: false, cast_starts: OnceCell::new(), declarations: RefCell::default(), + origins: RefCell::default(), + in_progress: RefCell::default(), } } @@ -138,26 +144,97 @@ impl<'a, 'b> Declared<'a, 'b> { (starts.get(&expr.span.end) == Some(&expr.span.start)).then_some(cast) } - /// The type of a local where it is read at `offset`. One that is assigned again after its - /// declaration is `unknown`, since its declared type may not be what it holds, unless a - /// `---@cast` line types it. A `+T` entry adds to a type, so one that is not known stays so. + /// The type of a local where it is read at `offset`: the declared types of the values that may + /// reach the read, as the guards and casts around the read narrow them. A value whose type is not + /// declared leaves the local `unknown`, unless a `---@cast` line types it. A `+T` entry adds to a + /// type, so one that is not known stays so. fn local(&self, id: LocalId, offset: u32, depth: u32) -> Type { - let ty = match depth { - 0 => self.declaration(id), - _ => self.local_declaration(id, depth), + self.infer.narrowed_with(id, offset, &|origin| self.origin(id, origin, depth), true) + } + + /// The declared type of the local `id` where it is read at `offset`, as `of` gives it for a read + /// there. + pub fn at(&self, id: LocalId, offset: u32) -> Type { + self.local(id, offset, 0) + } + + /// Whether the guards where the local `id` is read at `offset` rule out `value` for each of the + /// values that may reach the read and be it. + pub fn rules_out(&self, id: LocalId, offset: u32, value: &Type) -> bool { + self.infer.rules_out_with(id, offset, value, &|origin| self.origin(id, origin, 0)) + } + + /// The declared type of the value that `origin` gives the local `id`, or `None` while it is + /// being found. An assignment gives the declared type of the value, bounded by the annotation + /// of the local, or the `---@type` above it. + fn origin(&self, id: LocalId, origin: u32, depth: u32) -> Option { + let local = self.infer.ctx.resolution.local(id); + if !local.refs.iter().any(|r| r.write) { + return Some(match depth { + 0 => self.declaration(id), + _ => self.local_declaration(id, depth), + }); + } + let key = (id, origin); + if let Some(ty) = self.origins.borrow().get(&key) { + return Some(ty.clone()); + } + // The values given before it first, and none given after it that are not known yet, as for + // `Infer::origin_type`. + if self.in_progress.borrow().iter().any(|(local, other)| *local == id && *other <= origin) { + return None; + } + self.in_progress.borrow_mut().insert(key); + for earlier in self.infer.ctx.flow().chained(id).iter().take_while(|earlier| **earlier < origin) { + self.origin(id, *earlier, depth); + } + let ty = match self.infer.ctx.flow().origin(origin) { + Origin::Declaration => self.declared_value(id, depth), + Origin::Assignment { stmt, index } => self.assigned(id, origin, stmt, index, depth), + Origin::Compound { stmt } => match &stmt.kind { + StmtKind::CompoundAssign { op: BinOp::Concat, .. } => Type::String, + _ => Type::Unknown, + }, + Origin::Function { .. } => self.infer.origin_type(id, origin).unwrap_or_default(), }; - let typed = || { - let mut casts = self.infer.ctx.casts().at(id, offset); - casts.any(|cast| cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_)))) + self.in_progress.borrow_mut().remove(&key); + self.origins.borrow_mut().insert(key, ty.clone()); + Some(ty) + } + + /// The declared type of value `index` of the assignment `stmt` to the local `id`, which gives it + /// the value of `origin`. + fn assigned(&self, id: LocalId, origin: u32, stmt: &Stmt, index: usize, depth: u32) -> Type { + let StmtKind::Assign { exprs, .. } = &stmt.kind else { return Type::Unknown }; + let doc = self.infer.ctx.doc_at(stmt.span.start); + if let Some(ty) = doc.type_at(index).filter(|_| doc.declared_class().is_none()) { + return ty.clone(); + } + let single = |expr: &Expr| self.declared(expr, depth + 1); + // `name = tonumber(name) --[[@as number]]` casts the first value of the call. + let multi = |call: &Expr| { + let mut values = self.returned(call).unwrap_or_default(); + if let Some(cast) = self.cast(call) { + match values.first_mut() { + Some(first) => *first = cast, + None => values.push(cast), + } + } + values }; - if ty.is_unknown() && !typed() { - return ty; + let value = assigned_value(exprs, index, single, multi); + match self.infer.annotation(id) { + // A value with no declared type, as `name or 'none'`, still tells which parts of the + // annotation it may be. + Some(_) if value.is_unknown() => self.infer.origin_type(id, origin).unwrap_or_default(), + Some(declared) => self.infer.bounded(&declared, &value), + None => value, } - self.infer.narrowed(id, offset, ty) } /// The type a local is declared with, without what the guards and casts after its declaration - /// tell. + /// tell. One that is assigned again after its declaration is `unknown`, since its declared type + /// may not be what it holds. pub fn declaration(&self, id: LocalId) -> Type { if let Some(ty) = self.declarations.borrow().get(&id) { return ty.clone(); @@ -172,6 +249,12 @@ impl<'a, 'b> Declared<'a, 'b> { if local.refs.iter().any(|r| r.write) && !(self.keeps_annotations && self.is_annotated(local)) { return Type::Unknown; } + self.declared_value(id, depth) + } + + /// The type that the declaration of the local `id` gives it, whatever is assigned to it later. + fn declared_value(&self, id: LocalId, depth: u32) -> Type { + let local = self.infer.ctx.resolution.local(id); match self.infer.ctx.decl(local.decl.start) { Some(Decl::Local { stmt, index }) => self.local_value(id, stmt, *index, depth), Some(Decl::Param { .. }) if has_param_line(self.infer, local) => self.infer.local_type(id), @@ -243,6 +326,23 @@ impl<'a, 'b> Declared<'a, 'b> { } } + /// The type that an assignment to `target` has to store: what its local is declared with, + /// whatever it holds before, or the declared type of the field it sets, which no guard narrows. + pub fn target(&self, target: &Expr) -> Type { + match &target.kind { + ExprKind::Name(name) => match self.infer.ctx.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => self.declaration(id), + _ => self.of(target), + }, + ExprKind::Field { base, name, .. } => self.field(base, &name.text, 0), + ExprKind::Index { base, index, .. } => match index.as_string() { + Some(name) => self.field(base, name, 0), + None => Type::Unknown, + }, + _ => self.of(target), + } + } + /// The type that the `@field`s of a class give `name` of `base`. Fields of other tables are /// inferred from what is assigned to them. fn field(&self, base: &Expr, name: &str, depth: u32) -> Type { diff --git a/crates/qbx_lua_ls/src/features/expected.rs b/crates/qbx_lua_ls/src/features/expected.rs index 733e93d..778a173 100644 --- a/crates/qbx_lua_ls/src/features/expected.rs +++ b/crates/qbx_lua_ls/src/features/expected.rs @@ -209,7 +209,7 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { Some(ty) => Some(ExpectedValue::stored(ty.clone(), doc.type_values_at(index).to_vec())), None => targets .get(index) - .map(|target| ExpectedValue::stored(self.declared.of(target), self.described(target))), + .map(|target| ExpectedValue::stored(self.declared.target(target), self.described(target))), }; } } diff --git a/crates/qbx_lua_ls/src/features/member_refs.rs b/crates/qbx_lua_ls/src/features/member_refs.rs index 9bcb2fe..4ce31ae 100644 --- a/crates/qbx_lua_ls/src/features/member_refs.rs +++ b/crates/qbx_lua_ls/src/features/member_refs.rs @@ -218,7 +218,7 @@ impl<'ast> Visitor<'ast> for Finder<'_, '_> { } StmtKind::Assign { targets, exprs } => { for (target, expr) in targets.iter().zip(exprs) { - self.table(&self.infer.expr(target), expr); + self.table(&self.infer.target_type(target), expr); } } _ => {} diff --git a/crates/qbx_lua_ls/src/features/nil_checks.rs b/crates/qbx_lua_ls/src/features/nil_checks.rs index 99a7c3c..ace2f00 100644 --- a/crates/qbx_lua_ls/src/features/nil_checks.rs +++ b/crates/qbx_lua_ls/src/features/nil_checks.rs @@ -33,6 +33,8 @@ use super::arguments::{candidates, expected, mismatch, param_for, signatures, ta use super::class_tables::Classes; use super::comparisons::Declared; use crate::infer::{Decl, Infer}; +use crate::luacats::CastEntry; +use crate::narrow::Version; use crate::types::{FunType, Param, Type}; /// What `need-check-nil` finds in a file, each with the message naming the type. @@ -99,18 +101,33 @@ impl Finder<'_, '_> { if self.checked.iter().any(|(checked, span)| *checked == id && span.contains(at)) { return; } + let assigned = local.refs.iter().any(|r| r.write); + let typed = || { + self.infer + .ctx + .casts() + .at(id, at) + .any(|cast| cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_)))) + }; + if assigned && !typed() { + return; + } let ty = self.declared.of(operand.unparen()); - let reaches = |value: &Type| !self.infer.rules_out(id, at, value); + let reaches = |value: &Type| !self.declared.rules_out(id, at, value); let missing = missing_value(&self.infer.expand_aliases(&ty, 0), reaches); let Some(missing) = missing.filter(|missing| param.is_none() || *missing == "nil") else { return }; // `local vehicle, coords = lib.getClosestVehicle(...)` declares both as optional, and the // guard on `vehicle` is the check for `coords` as well. - let guards = ctx.guards(); - let linked = guards.linked(id).unwrap_or_default(); - if linked.iter().any(|(other, _)| *other != id && guards.at(*other, at).next().is_some()) { + let flow = ctx.flow(); + let covered = |value: &Version| { + let linked = flow.linked(id, value.origin).unwrap_or_default(); + linked + .iter() + .any(|(other, origin, _)| *other != id && flow.facts(*other, *origin, Span::empty(at)).next().is_some()) + }; + if flow.at(id, at).iter().all(covered) { return; } - let assigned = local.refs.iter().any(|r| r.write); if !assigned && self.is_lookup_of_source(id) { return; } diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index ca058d8..09e9d0d 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -15,7 +15,7 @@ use crate::callback_wrappers::{self, Wrapper}; use crate::index::{instance_class, instance_owner, ClassDef, FileId, Index, SymbolKind}; use crate::locate::statement_at; use crate::luacats::{applies_on, own_type, parse_doc_lines, CastEntry, DocGroup, DocOperator}; -use crate::narrow::{Casts, Guards}; +use crate::narrow::{Cast, Casts, Fact, Flow, Origin, Version, DECLARATION}; use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeName, TypeParser}; const MAX_DEPTH: u32 = 24; @@ -156,7 +156,7 @@ pub struct FileContext<'a> { /// call ends. set_metatables: FxHashMap>, docs: RefCell>>, - guards: OnceCell, + flow: OnceCell>, casts: OnceCell, } @@ -190,14 +190,15 @@ impl<'a> FileContext<'a> { index_writes: collector.index_writes, set_metatables, docs: RefCell::new(FxHashMap::default()), - guards: OnceCell::new(), + flow: OnceCell::new(), casts: OnceCell::new(), } } - /// What the conditions of the file tell about the locals they test. - pub fn guards(&self) -> &Guards { - self.guards.get_or_init(|| Guards::of(self.chunk, self.resolution)) + /// Which values the locals of the file may hold at each point, and what the conditions tell about + /// them. + pub fn flow(&self) -> &Flow<'a> { + self.flow.get_or_init(|| Flow::of(self.chunk, self.resolution)) } /// The `---@cast` lines of the file. @@ -517,6 +518,10 @@ pub struct Infer<'a> { regions: OnceCell, locals: RefCell>, in_progress: RefCell>, + /// The types of the values that the declarations and assignments of locals give them, by the + /// local and the origin of the value. + origins: RefCell>, + origins_in_progress: RefCell>, /// The sets of values returned by the call of each `local a, b = f()` statement read so far, /// by the start of the statement. linked_sets: RefCell>>, @@ -571,6 +576,8 @@ impl<'a> Infer<'a> { regions: OnceCell::new(), locals: RefCell::new(FxHashMap::default()), in_progress: RefCell::new(FxHashSet::default()), + origins: RefCell::new(FxHashMap::default()), + origins_in_progress: RefCell::new(FxHashSet::default()), linked_sets: RefCell::new(FxHashMap::default()), fallbacks: RefCell::new(FxHashMap::default()), following: RefCell::new(FxHashSet::default()), @@ -702,17 +709,7 @@ impl<'a> Infer<'a> { ExprKind::Function(func) => Type::Fun(Arc::new(self.fun_type(func, None, false))), ExprKind::Name(name) => self.name(name), ExprKind::Paren(inner) => self.expr(inner), - ExprKind::Field { base, name, .. } => { - if let ExprKind::Name(root) = &base.kind { - let is_env = matches!(root.text.as_str(), "_ENV" | "_G") - && matches!(self.ctx.resolution.resolve_at(root.span.start), Some(Resolved::Global(_))); - if is_env { - return self.global_type(&name.text); - } - } - let base_ty = self.expr(base); - self.member(&base_ty, &name.text).map(|m| m.ty).unwrap_or_default() - } + ExprKind::Field { base, name, .. } => self.member_type(base, name), ExprKind::Index { base, index, .. } => self.index_expr(base, index), ExprKind::Table(fields) => self.table(fields), ExprKind::Binary { op, lhs, rhs, .. } => self.binary(*op, lhs, rhs), @@ -721,6 +718,33 @@ impl<'a> Infer<'a> { } } + /// The type of the field `name` of `base`, as it is declared or assigned. + fn member_type(&self, base: &Expr, name: &Name) -> Type { + if let ExprKind::Name(root) = &base.kind { + let is_env = matches!(root.text.as_str(), "_ENV" | "_G") + && matches!(self.ctx.resolution.resolve_at(root.span.start), Some(Resolved::Global(_))); + if is_env { + return self.global_type(&name.text); + } + } + let base_ty = self.expr(base); + self.member(&base_ty, &name.text).map(|m| m.ty).unwrap_or_default() + } + + /// The type of what an assignment to `target` stores: what its local is declared with, whatever + /// it holds before, or the type of the field it sets, which no guard narrows. + pub fn target_type(&self, target: &Expr) -> Type { + match &target.kind { + ExprKind::Name(name) => match self.ctx.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => self.local_type(id), + _ => self.global_type(&name.text), + }, + ExprKind::Field { base, name, .. } => self.member_type(base, name), + ExprKind::Index { base, index, .. } => self.index_expr(base, index), + _ => self.expr(target), + } + } + fn name(&self, name: &Name) -> Type { match self.ctx.resolution.resolve_at(name.span.start) { Some(Resolved::Local(id)) => self.local_type_at(id, name.span.start), @@ -728,11 +752,29 @@ impl<'a> Infer<'a> { } } - /// The type of a local where it is read at `offset`: its own type as the `---@cast` lines before - /// the read change it, without what the guards around the read rule out, such as the `nil` of a - /// `string?` after `if not name then return end`. + /// The type of a local where it is read at `offset`: the types of the values that may reach the + /// read, as the `---@cast` lines before the read change them, without what the guards on the way + /// rule out, such as the `nil` of a `string?` after `if not name then return end`. Where an + /// assignment names the local, the type of the value it gives. pub fn local_type_at(&self, id: LocalId, offset: u32) -> Type { - self.narrowed(id, offset, self.with_metatables(id, offset, self.local_type(id))) + let flow = self.ctx.flow(); + if let Some(origin) = flow.written_at(offset) { + // `name += 1` reads the value it replaces there, and a value of no known type tells less + // than the type of the local. + let given = self.origin_type(id, origin).unwrap_or_default(); + if !matches!(flow.origin(origin), Origin::Compound { .. }) && !given.is_unknown() { + return self.with_metatables(id, offset, given); + } + } + let origin = |origin| self.origin_type(id, origin).map(|ty| self.with_metatables(id, offset, ty)); + self.narrowed_with(id, offset, &origin, false) + } + + /// The type of a local where it is read at `offset`, as `local_type_at` gives it, or `unknown` + /// when a value of unknown type may reach the read. + pub fn known_local_type_at(&self, id: LocalId, offset: u32) -> Type { + let origin = |origin| self.origin_type(id, origin).map(|ty| self.with_metatables(id, offset, ty)); + self.narrowed_with(id, offset, &origin, true) } /// `ty`, the type of the local `id`, with what the `setmetatable` calls on the local make of it @@ -776,55 +818,103 @@ impl<'a> Infer<'a> { matches!(ty.without_nil(), Type::GlobalTable(owner) if path.is_some_and(|path| owner == path)) } - /// `ty`, the type of the local `id`, as the casts before a read at `offset` change it and - /// without what the guards around the read rule out. A guard that takes effect before a cast - /// tells nothing about the type the cast gives, but `+T` and `-T` change the type that the - /// guards around their line leave, as `---@cast items +number[]` inside `if items then` does, - /// for as long as those guards hold. - pub fn narrowed(&self, id: LocalId, offset: u32, ty: Type) -> Type { - let mut since = None; - let mut ty = ty; - for cast in self.ctx.casts().at(id, offset) { - // A guard that ends before the read, as one does at a label that a `goto` past the cast - // reaches, tells nothing about the read. - if !cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_))) { - ty = self.guards_applied(id, since, Span::new(cast.span.start, offset), ty); + /// The type of the local `id` where it is read at `offset`: the union of the values that may + /// reach the read, each of the type `origin` gives for where it comes from (`None` while that is + /// being found, as for a loop that assigns the local from what it held before), as the casts + /// before the read change it and without what the guards since then rule out. A value that the + /// guards rule out entirely is left out, unless they rule out every value: like `if not name` + /// for a `string`, or `action ~= "open" and action ~= "close"` for an `"open"|"close"`, such code + /// handles values the annotations leave out, or never runs. With `strict`, a value of unknown + /// type leaves the local unknown, unless a cast replaces its type. + pub fn narrowed_with(&self, id: LocalId, offset: u32, origin: &dyn Fn(u32) -> Option, strict: bool) -> Type { + let casts: Vec<&Cast> = self.ctx.casts().at(id, offset).collect(); + let replaced = casts.iter().any(|cast| replaces(cast)); + let values = self.ctx.flow().at(id, offset); + // `local name` declares no value that the values assigned later have to make room for. + let unset = values.len() > 1 && self.is_unset(id); + let (mut whole, mut kept) = (Vec::new(), Vec::new()); + for value in values.iter().filter(|value| !(unset && value.origin == DECLARATION)) { + let Some(base) = origin(value.origin) else { continue }; + if strict && !replaced && base.is_unknown() { + return Type::Unknown; + } + let (ty, narrowed) = self.version_type(id, offset, value, base, &casts); + kept.extend(narrowed); + whole.push(ty); + } + merge_versions(if kept.is_empty() { whole } else { kept }) + } + + /// What `value` of the local `id` is where it is read at `offset`: `base`, the type of where it + /// comes from, as `casts` change it, and that without what the guards after the last cast rule + /// out, or `None` when they rule out all of it. A guard that takes effect before a cast tells + /// nothing about the type the cast gives, but `+T` and `-T` change the type that the guards + /// around their line leave, as `---@cast items +number[]` inside `if items then` does, for as + /// long as those guards hold. + fn version_type( + &self, + id: LocalId, + offset: u32, + value: &Version, + base: Type, + casts: &[&Cast], + ) -> (Type, Option) { + let facts = |from: u32, to: u32| { + value.facts.iter().filter(move |(at, _)| from <= *at && *at <= to).map(|(_, fact)| fact) + }; + let mut ty = base; + if value.origin == DECLARATION && !casts.first().is_some_and(|cast| replaces(cast)) { + let code = casts.first().map_or(Span::empty(offset), |cast| Span::new(cast.span.start, offset)); + ty = self.linked_type(id, code).unwrap_or(ty); + } + let mut since = 0; + for cast in casts { + if !replaces(cast) { + ty = self.facts_applied(&ty, facts(since, cast.span.start)).unwrap_or(ty); } ty = cast.entries.iter().fold(ty, |ty, (entry, _)| self.cast(ty, entry)); - since = Some(cast.span.start); + since = cast.span.start; } - self.guards_applied(id, since, Span::empty(offset), ty) + let narrowed = self.facts_applied(&ty, facts(since, u32::MAX)); + (ty, narrowed) } - /// `ty` without what the guards that hold from the start to the end of `code` rule out, of - /// those that take effect at `since` or later. - fn guards_applied(&self, id: LocalId, since: Option, code: Span, ty: Type) -> Type { - let ty = match since { - Some(_) => ty, - None => self.linked_type(id, code).unwrap_or(ty), - }; - let mut facts = self.ctx.guards().since(id, since, code).peekable(); + /// `ty` without what `facts` rule out, or `None` when they rule out every value of it. An alias + /// such as `Name = string|nil` is narrowed through what it stands for, and a type the facts leave + /// whole stays as it is written. + fn facts_applied<'f>(&self, ty: &Type, facts: impl Iterator) -> Option { + let mut facts = facts.peekable(); if facts.peek().is_none() { - return ty; + return Some(ty.clone()); } - // An alias such as `Name = string|nil` is narrowed through what it stands for. - let declared = self.expand_aliases(&ty, 0); - // Guards that leave no value of the type are ignored, like `if not name` for a `string`, or - // `action ~= "open" and action ~= "close"` for an `"open"|"close"`: such code handles values - // the annotations leave out, or never runs. - match facts.try_fold(declared.clone(), |narrowed, fact| fact.assume(&narrowed)) { - Some(narrowed) if narrowed != declared => narrowed, - _ => ty, + let declared = self.expand_aliases(ty, 0); + match facts.try_fold(declared.clone(), |narrowed, fact| fact.assume(&narrowed))? { + narrowed if narrowed != declared => Some(narrowed), + _ => Some(ty.clone()), } } /// Whether the guards after the last cast that holds where `id` is read at `offset` rule out - /// `value`, also where they rule out every value of its type, which `narrowed` keeps whole then: - /// `round` is no `nil` in `round and (round == true or i < round)`, whatever else it holds. - pub fn rules_out(&self, id: LocalId, offset: u32, value: &Type) -> bool { - let since = self.ctx.casts().at(id, offset).last().map(|cast| cast.span.start); - let mut facts = self.ctx.guards().since(id, since, Span::empty(offset)); - facts.try_fold(value.clone(), |left, fact| fact.assume(&left)).is_none_or(|left| left != *value) + /// `value` for each value of the local that may be it, `origin` giving the types as for + /// `narrowed_with`, also where they rule out every value of its type, which `narrowed_with` + /// keeps whole then: `round` is no `nil` in `round and (round == true or i < round)`, whatever + /// else it holds. + pub fn rules_out_with(&self, id: LocalId, offset: u32, value: &Type, origin: &dyn Fn(u32) -> Option) -> bool { + let casts: Vec<&Cast> = self.ctx.casts().at(id, offset).collect(); + let since = casts.last().map_or(0, |cast| cast.span.start); + let holds = |ty: &Type| match self.expand_aliases(ty, 0) { + Type::Union(parts) => parts.contains(value), + one => one == *value, + }; + self.ctx.flow().at(id, offset).iter().all(|version| { + let Some(base) = origin(version.origin) else { return true }; + let (ty, narrowed) = self.version_type(id, offset, version, base, &casts); + if !holds(&ty) && !narrowed.as_ref().is_some_and(holds) { + return true; + } + let mut facts = version.facts.iter().filter(|(at, _)| *at >= since).map(|(_, fact)| fact); + facts.try_fold(value.clone(), |left, fact| fact.assume(&left)).is_none_or(|left| left != *value) + }) } /// `ty` as one entry of a `---@cast` line changes it. @@ -868,9 +958,9 @@ impl<'a> Infer<'a> { /// `local ok, err = f()`, when the guards on those locals that hold there rule out some of the /// sets of values the call returns: what its position holds in the sets that are left. fn linked_type(&self, id: LocalId, code: Span) -> Option { - let guards = self.ctx.guards(); - let members = guards.linked(id)?; - if !members.iter().any(|(member, _)| guards.since(*member, None, code).next().is_some()) { + let flow = self.ctx.flow(); + let members = flow.linked(id, DECLARATION)?; + if !members.iter().any(|(member, origin, _)| flow.facts(*member, *origin, code).next().is_some()) { return None; } let Some(Decl::Local { stmt, .. }) = self.ctx.decl(self.ctx.resolution.local(id).decl.start) else { @@ -887,16 +977,16 @@ impl<'a> Infer<'a> { None => Type::Nil, }; let is_possible = |set: &&Vec| { - members.iter().all(|(member, position)| { + members.iter().all(|(member, origin, position)| { let value = value(set, *position); - guards.since(*member, None, code).all(|fact| fact.apply(&value).is_some()) + flow.facts(*member, *origin, code).all(|fact| fact.apply(&value).is_some()) }) }; let possible: Vec<&Vec> = sets.iter().filter(is_possible).collect(); if possible.is_empty() || possible.len() == sets.len() { return None; } - let position = members.iter().find(|(member, _)| *member == id)?.1; + let position = members.iter().find(|(member, ..)| *member == id)?.2; Some(merge_values(possible.iter().map(|set| set.get(position).cloned().unwrap_or(Type::Nil)).collect())) } @@ -996,6 +1086,145 @@ impl<'a> Infer<'a> { } } + /// The type of the value that `origin` gives the local `id`, or `None` while it is being found, + /// as for an assignment in a loop that reads what the local held before. The values that the + /// assignments before it which read the local give are found first, and while one is found, those + /// given after it that are not known yet are left out: the assignments of a loop that each read + /// what the others give, as `j = j + 3` repeated, would be found again along every order of them + /// otherwise. Like the types of locals, it is kept once found. + pub fn origin_type(&self, id: LocalId, origin: u32) -> Option { + // A local that is never assigned again holds the value of its declaration, as found once. + if origin == DECLARATION && !self.ctx.resolution.local(id).refs.iter().any(|r| r.write) { + return Some(self.local_type(id)); + } + let key = (id, origin); + if let Some(ty) = self.origins.borrow().get(&key) { + return Some(ty.clone()); + } + // Origins are numbered in the order they are written. + let later = |(local, other): &(LocalId, u32)| *local == id && *other <= origin; + if self.origins_in_progress.borrow().iter().any(later) { + return None; + } + self.origins_in_progress.borrow_mut().insert(key); + for earlier in self.ctx.flow().chained(id).iter().take_while(|earlier| **earlier < origin) { + self.origin_type(id, *earlier); + } + let ty = self.guarded(|| self.compute_origin(id, origin)); + self.origins_in_progress.borrow_mut().remove(&key); + self.origins.borrow_mut().insert(key, ty.clone()); + Some(ty) + } + + fn compute_origin(&self, id: LocalId, origin: u32) -> Type { + match self.ctx.flow().origin(origin) { + Origin::Declaration => self.declaration_type(id), + Origin::Assignment { stmt, index } => { + let StmtKind::Assign { exprs, .. } = &stmt.kind else { return Type::Unknown }; + let doc = self.ctx.doc_at(stmt.span.start); + if let Some(ty) = doc.type_at(index).filter(|_| doc.declared_class().is_none()) { + return ty.clone(); + } + // A table that a local declared outside any function with one is given again is that + // local's own table, which the fields set on it belong to. + if exprs.get(index).and_then(table_fields).is_some() { + let declared = self.declaration_type(id); + if self.is_own_table(id, &declared) { + return declared; + } + } + let value = assigned_value(exprs, index, |expr| self.expr(expr), |expr| self.expr_multi(expr)); + match self.annotation(id) { + Some(declared) => self.bounded(&declared, &value), + // A value of no known type keeps the type the local is declared with, as one of an + // annotated local does, unless that only tells it held `nil`. + None if value.is_unknown() => match self.local_type(id) { + Type::Nil => value, + declared => declared, + }, + None => value, + } + } + Origin::Compound { stmt } => match &stmt.kind { + StmtKind::CompoundAssign { target, op, expr, .. } => self.binary(*op, target, expr), + _ => Type::Unknown, + }, + Origin::Function { stmt } => match &stmt.kind { + StmtKind::Function { func, .. } => { + Type::Fun(Arc::new(self.fun_type(func, Some(stmt.span.start), false))) + } + _ => Type::Unknown, + }, + } + } + + /// The type that the declaration of the local `id` gives it. One that is assigned again keeps the + /// literals that a call or another variable gives it, as the values given later have types of + /// their own, and without a value it holds `nil` until it is given one. + fn declaration_type(&self, id: LocalId) -> Type { + let local = self.ctx.resolution.local(id); + let Some(Decl::Local { stmt, index }) = self.ctx.decl(local.decl.start) else { return self.local_type(id) }; + if !local.refs.iter().any(|r| r.write) { + return self.local_type(id); + } + if self.is_unset(id) { + return Type::Nil; + } + self.local_stmt_type(stmt, *index, local.func == 0, false) + } + + /// Whether the local `id` is declared without a value or a type, as by `local name`, and assigned + /// later. + fn is_unset(&self, id: LocalId) -> bool { + let local = self.ctx.resolution.local(id); + let Some(Decl::Local { stmt, index }) = self.ctx.decl(local.decl.start) else { return false }; + let StmtKind::Local { exprs, in_unpack, .. } = &stmt.kind else { return false }; + let valued = *in_unpack || *index < exprs.len() || exprs.last().is_some_and(Expr::is_multi_value); + let doc = self.ctx.doc_at(stmt.span.start); + !valued + && doc.declared_class().is_none() + && local_annotation(&doc, *index).is_none() + && local.refs.iter().any(|r| r.write) + } + + /// The type that the `---@type` above the declaration of the local `id`, or its `@param` line, + /// gives it, which also bounds the values assigned to it later. + pub fn annotation(&self, id: LocalId) -> Option { + let local = self.ctx.resolution.local(id); + match self.ctx.decl(local.decl.start)? { + Decl::Local { stmt, index } => { + let doc = self.ctx.doc_at(stmt.span.start); + match doc.declared_class() { + Some(class) => Some(own_type(&class.name, &class.generics)), + None => local_annotation(&doc, *index), + } + } + Decl::Param { func, index, doc_anchor: Some(anchor), .. } => { + let doc = self.ctx.doc_at(*anchor); + let name = &func.params[*index].text; + doc.params.iter().any(|param| param.name == *name).then(|| self.local_type(id)) + } + _ => None, + } + } + + /// What a local declared as `declared` holds once it is given a value of type `value`: the parts + /// of `declared` of the kinds of value it may be, as the `string` of a `string?` for `'x'`. A + /// value of no known kind, or of none of those kinds, which `assign-type-mismatch` reports, + /// leaves all of it. + pub fn bounded(&self, declared: &Type, value: &Type) -> Type { + let fun = FunType::default(); + let Some(kinds) = self.value_kinds(&fun, value, false, 0) else { return declared.clone() }; + let expanded = self.expand_aliases(declared, 0); + let Type::Union(parts) = &expanded else { return declared.clone() }; + let fits = |part: &&Type| self.value_kinds(&fun, part, false, 0).is_none_or(|part| part & kinds != 0); + let kept: Vec = parts.iter().filter(fits).cloned().collect(); + if kept.is_empty() || kept.len() == parts.len() { + return declared.clone(); + } + expanded.rebuilt(kept) + } + /// The type of the name at `index` of a `local` statement. A name that takes what a call returns, /// or the value of another variable or a field, has its type, unless it is `reassigned` after /// its declaration: it may then hold other values of that kind, so `"active"|"busy"` becomes @@ -1656,7 +1885,8 @@ impl<'a> Infer<'a> { fn index_expr(&self, base: &Expr, index: &Expr) -> Type { let base_ty = self.expr(base); - let key_ty = self.expr(index); + // A `nil` key reads no value of the table. + let key_ty = self.expr(index).without_nil(); let mut keys = Vec::new(); if self.literal_keys(&key_ty, 0, &mut keys) && !keys.is_empty() { let fields: Option> = keys.iter().map(|key| self.member(&base_ty, key).map(|m| m.ty)).collect(); @@ -1696,9 +1926,7 @@ impl<'a> Infer<'a> { Type::union(fields.map(|(_, value)| substitute(value, &bindings)).chain([index])) } // `list[i]` on a table whose array part the index or a top-level local's constructor holds. - Type::GlobalTable(owner) - if matches!(key_ty.without_nil().widen(), Type::Integer | Type::Number | Type::Unknown) => - { + Type::GlobalTable(owner) if is_number_key(&key_ty.without_nil().widen()) => { self.global_table_key_values(&owner, true).1 } Type::String | Type::StringLit(_) => Type::Unknown, @@ -3422,6 +3650,17 @@ pub fn table_elements(fields: &[TableField]) -> TableElements<'_> { elements } +/// Whether a key of type `key` is a number, or may be one as far as is known: `integer`, `number`, a +/// union of them, `any` or `unknown`. +fn is_number_key(key: &Type) -> bool { + let number = + |part: &Type| matches!(part, Type::Integer | Type::Number | Type::IntLit(_) | Type::Any | Type::Unknown); + match key { + Type::Union(parts) => parts.iter().all(number), + one => number(one), + } +} + fn is_integer_key(key: &Type) -> bool { matches!(key.widen(), Type::Integer | Type::Number) } @@ -3534,6 +3773,73 @@ fn field_start(source: &str, field: &TableField) -> u32 { } } +/// The value at `index` of those that `exprs` assign, as `single` and `multi` type the expressions: +/// the expression at that place, with a literal widened as `mode = 'dev'` stores a setting rather +/// than the only value it may hold, a value of the call that ends them, or `nil`. +pub fn assigned_value( + exprs: &[Expr], + index: usize, + single: impl Fn(&Expr) -> Type, + multi: impl Fn(&Expr) -> Vec, +) -> Type { + match exprs.get(index) { + Some(expr) if index + 1 == exprs.len() && expr.is_multi_value() => { + multi(expr).into_iter().next().unwrap_or_default() + } + Some(expr) + if matches!( + expr.unparen().kind, + ExprKind::True | ExprKind::False | ExprKind::Number(_) | ExprKind::String(_) + ) => + { + single(expr).widen() + } + Some(expr) => single(expr), + None => match exprs.last() { + Some(last) if last.is_multi_value() => { + multi(last).into_iter().nth(index + 1 - exprs.len()).unwrap_or_default() + } + _ => Type::Nil, + }, + } +} + +/// The union of the types of the values a local may hold. A literal gives way to its kind when +/// another value is of that kind, so `"active"|"busy"` and a `string` assigned later make a `string`, +/// and an `integer` gives way to a `number`. +fn merge_versions(types: Vec) -> Type { + if types.len() < 2 { + return types.into_iter().next().unwrap_or_default(); + } + let parts = |ty: &Type| match ty { + Type::Union(parts) => parts.clone(), + one => vec![one.clone()], + }; + let absorbs = |kind: &Type, part: &Type| { + matches!( + (kind, part), + (Type::String, Type::StringLit(_)) + | (Type::Integer | Type::Number, Type::IntLit(_)) + | (Type::Number, Type::Integer) + | (Type::Boolean, Type::BooleanLit(_)) + ) + }; + let all: Vec> = types.iter().map(parts).collect(); + let merged = types.iter().enumerate().map(|(index, ty)| { + let absorbed = |part: &&Type| { + let others = all.iter().enumerate().filter(|(other, _)| *other != index); + others.flat_map(|(_, kinds)| kinds).any(|kind| absorbs(kind, part)) + }; + ty.rebuilt(all[index].iter().filter(|part| !absorbed(part)).cloned()) + }); + Type::union(merged) +} + +/// Whether a cast replaces the type of its local, rather than adding or taking out types. +fn replaces(cast: &Cast) -> bool { + cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_))) +} + /// The `---@type` that `doc` gives the name at `index` of the `local` statement below it. A local /// is no member of a class, so a `self` in the type stands for nothing and is unknown, as in /// lua-language-server. diff --git a/crates/qbx_lua_ls/src/narrow.rs b/crates/qbx_lua_ls/src/narrow.rs index cd007ec..cfcb780 100644 --- a/crates/qbx_lua_ls/src/narrow.rs +++ b/crates/qbx_lua_ls/src/narrow.rs @@ -1,15 +1,17 @@ -//! Type guards: what a condition tells about the locals it tests, in the code that only runs when -//! it held or failed. `if not name then return end` leaves `name` holding a value for the rest of -//! its block, and `if name then ... end` for the branch. `---@cast` lines change the type of a local -//! from their line on. +//! Type guards and the values locals hold: which of the values given to a local reach each point of +//! the code, and what a condition tells about the locals it tests in the code that only runs when it +//! held or failed. `if not name then return end` leaves `name` holding a value for the rest of its +//! block, `if name then ... end` for the branch, and `name = name or 'none'` gives it a new one. +//! `---@cast` lines change the type of a local from their line on. -use qbx_lua_analysis::scope::{LocalId, Resolution, Resolved}; +use std::rc::Rc; + +use qbx_lua_analysis::scope::{FuncId, LocalId, Resolution, Resolved, MAIN_CHUNK}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::{CommentKind, NumberValue, Span, Token, TokenKind}; -use rustc_hash::{FxHashMap, FxHashSet}; +use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr, Span, Token, TokenKind}; +use rustc_hash::FxHashMap; -use crate::infer::always_exits; use crate::luacats::{parse_cast, CastEntry}; use crate::types::Type; @@ -167,64 +169,222 @@ fn type_name(ty: &Type) -> Option<&str> { type Facts = Vec<(LocalId, Fact)>; -/// The facts the guards of a file establish, each for a local and the code it holds in. -#[derive(Debug, Default)] -pub struct Guards { - facts: FxHashMap>, - /// The locals with guards that are assigned again after their declaration. - reassigned: FxHashSet, - /// The locals that one call declares together, as in `local ok, err = f()`, each with the - /// position of its value among those the call returns. - links: Vec>, - link_of: FxHashMap, +/// The index of the declaration of a local among the origins of the values it holds. +pub const DECLARATION: u32 = 0; +/// How many times a loop is walked at most to find what its locals hold at its start. +const MAX_ROUNDS: usize = 4; + +/// Where a value of a local comes from. +#[derive(Clone, Copy, Debug)] +pub enum Origin<'a> { + /// The declaration of the local: the value of its `local` statement, or what a parameter or loop + /// variable is given. + Declaration, + /// Value `index` of an assignment, as `name` is value 0 of `name, other = a, b`. + Assignment { stmt: &'a Stmt, index: usize }, + /// A compound assignment of CfxLua, as `name += 1`. + Compound { stmt: &'a Stmt }, + /// `function name() end` for a local `name`. + Function { stmt: &'a Stmt }, } -impl Guards { - pub fn of(chunk: &Chunk, resolution: &Resolution) -> Self { - let mut finder = Finder { - resolution, - guards: Guards::default(), - kinds: FxHashMap::default(), - type_functions: FxHashMap::default(), - }; - finder.visit_block(&chunk.block); - let mut guards = finder.guards; - let reassigned = guards.facts.keys().filter(|local| resolution.local(**local).refs.iter().any(|r| r.write)); - guards.reassigned = reassigned.copied().collect(); - guards - } - - /// What the guards around `offset` tell about `local`. - pub fn at(&self, local: LocalId, offset: u32) -> impl Iterator { - self.since(local, None, Span::empty(offset)) - } - - /// What the guards that hold from the start to the end of `code` tell about `local`, with the - /// start of a `cast` that holds there only those that take effect at its start or later. A - /// guard on a local that is assigned again only tells about the type a cast gives it: the value - /// it tested may have been replaced since, unless a cast that holds until the local is next - /// assigned started before the guard. - pub fn since(&self, local: LocalId, cast: Option, code: Span) -> impl Iterator { - let facts = match cast { - None if self.reassigned.contains(&local) => &[], - _ => self.facts.get(&local).map(Vec::as_slice).unwrap_or_default(), +/// One of the values a local may hold at a point of the code. +#[derive(Clone, Debug, PartialEq)] +pub struct Version { + /// Where it comes from, as an index into the origins of the file. + pub origin: u32, + /// What the guards since then tell about it, each with where it starts to hold. + pub facts: Rc<[(u32, Fact)]>, +} + +/// The values a local may hold. +type Values = Rc<[Version]>; + +/// What the locals may hold at a point, for those that may hold more than the value of their +/// declaration with nothing known about it. +type State = FxHashMap; + +/// Which of the values given to the locals of a file may reach each point of its code, and what the +/// guards on the way tell about them. A value reaches the code after the statement that assigns it, +/// branches join where they meet, and a loop starts with what its runs before leave. Calls are +/// taken to change no local, but a function runs any time after it is created: inside it, a local +/// may also hold what the code after its creation and the other functions assign, and the code +/// that creates it may find what it assigns from there on. +#[derive(Debug)] +pub struct Flow<'a> { + origins: Vec>, + /// The origin of the value each assignment gives a local, by the start of the name it assigns. + written: FxHashMap, + /// The origins of the values that assignments which read a local give it, in the order they are + /// written, as `count = count + 1`. + chained: FxHashMap>, + /// Where what each local may hold changes, in order, with what it may hold from there on. Before + /// the first, it holds the value of its declaration, with nothing known about it. + points: FxHashMap>, + declared: Values, + /// The locals that one call gives values together, as in `local ok, err = f()` or + /// `ok, err = f()`, each with the origin of its value and the position of that value among those + /// the call returns. + links: Vec>, + link_of: FxHashMap<(LocalId, u32), usize>, +} + +impl<'a> Flow<'a> { + pub fn of(chunk: &'a Chunk, resolution: &Resolution) -> Self { + let mut origins = Origins { resolution, origins: vec![Origin::Declaration], written: FxHashMap::default() }; + origins.visit_block(&chunk.block); + let no_facts: Rc<[(u32, Fact)]> = Rc::from([]); + let flow = Flow { + origins: origins.origins, + written: origins.written, + chained: FxHashMap::default(), + points: FxHashMap::default(), + declared: Rc::from([Version { origin: DECLARATION, facts: no_facts.clone() }]), + links: Vec::new(), + link_of: FxHashMap::default(), }; - let start = cast.unwrap_or(0); - let holds = move |span: &Span| start <= span.start && span.contains(code.start) && span.contains(code.end); - facts.iter().filter(move |(span, _)| holds(span)).map(|(_, fact)| fact) + let mut walker = Walker::new(resolution, flow, no_facts); + walker.block(&chunk.block); + walker.flow } - /// The locals declared by the same call as `local`, itself included, each with the position of - /// its value among those the call returns. A guard on one of them also tells which of the sets - /// of values the call can return the others come from. - pub fn linked(&self, local: LocalId) -> Option<&[(LocalId, usize)]> { - self.link_of.get(&local).map(|link| self.links[*link].as_slice()) + /// What `local` may hold at `offset`. + pub fn at(&self, local: LocalId, offset: u32) -> &[Version] { + let Some(points) = self.points.get(&local) else { return &self.declared }; + match points.partition_point(|(at, _)| *at <= offset) { + 0 => &self.declared, + index => &points[index - 1].1, + } + } + + pub fn origin(&self, origin: u32) -> Origin<'a> { + self.origins.get(origin as usize).copied().unwrap_or(Origin::Declaration) + } + + /// The origins of the values that assignments which read `local` give it, in the order they are + /// written: those whose types may each depend on the one before. + pub fn chained(&self, local: LocalId) -> &[u32] { + self.chained.get(&local).map_or(&[], Vec::as_slice) + } + + /// The origin of the value that an assignment gives the local whose name starts at `offset`. + pub fn written_at(&self, offset: u32) -> Option { + self.written.get(&offset).copied() + } + + /// What the guards that hold from the start to the end of `code` tell about the value of `origin` + /// when `local` holds it there, as one that is never assigned again holds that of its + /// declaration. + pub fn facts(&self, local: LocalId, origin: u32, code: Span) -> impl Iterator { + let value = self.at(local, code.end).iter().find(|value| value.origin == origin); + let facts = value.map_or(&[][..], |value| &value.facts[..]); + facts.iter().filter(move |(at, _)| *at <= code.start).map(|(_, fact)| fact) + } + + /// The locals that the call which gives `local` the value of `origin` gives values too, itself + /// included, each with the origin of its value and the position of that value among those the + /// call returns. A guard on one of them also tells which of the sets of values the call can + /// return the others come from. + pub fn linked(&self, local: LocalId, origin: u32) -> Option<&[(LocalId, u32, usize)]> { + self.link_of.get(&(local, origin)).map(|link| self.links[*link].as_slice()) } } -struct Finder<'r> { +/// Finds the assignments to locals, which are the origins of the values they hold after their +/// declaration. +struct Origins<'a, 'r> { resolution: &'r Resolution, - guards: Guards, + origins: Vec>, + written: FxHashMap, +} + +impl<'a> Origins<'a, '_> { + fn add(&mut self, name: &Name, origin: Origin<'a>) { + if let Some(Resolved::Local(_)) = self.resolution.resolve_at(name.span.start) { + self.written.insert(name.span.start, self.origins.len() as u32); + self.origins.push(origin); + } + } +} + +impl<'a> Visitor<'a> for Origins<'a, '_> { + fn visit_stmt(&mut self, stmt: &'a Stmt) { + match &stmt.kind { + StmtKind::Assign { targets, .. } => { + for (index, target) in targets.iter().enumerate() { + if let ExprKind::Name(name) = &target.kind { + self.add(name, Origin::Assignment { stmt, index }); + } + } + } + StmtKind::CompoundAssign { target: Expr { kind: ExprKind::Name(name), .. }, .. } => { + self.add(name, Origin::Compound { stmt }); + } + StmtKind::Function { name, .. } if name.path.is_empty() && name.method.is_none() => { + self.add(&name.base, Origin::Function { stmt }); + } + _ => {} + } + visit::walk_stmt(self, stmt); + } +} + +/// An assignment to a local. +#[derive(Clone, Copy)] +struct Write { + start: u32, + /// The function it is in. + func: FuncId, + origin: u32, +} + +/// A function being walked. +struct Frame { + func: FuncId, + /// Where the loops around the code being walked start, outermost first. + loops: Vec, + /// What the locals hold at the `break`s of each of those loops. + breaks: Vec>, + /// The labels of the blocks around the code being walked, innermost last, each with where its + /// statement starts. + labels: Vec>, + /// Where the function being walked inside this one is created, or the outermost loop around + /// that starts: the assignments of this function from there on may run before it does. + created: u32, +} + +impl Frame { + fn new(func: FuncId) -> Self { + Self { func, loops: Vec::new(), breaks: Vec::new(), labels: Vec::new(), created: 0 } + } +} + +/// Walks the code of a file in the order it runs and records what its locals may hold. +struct Walker<'a, 'r> { + resolution: &'r Resolution, + flow: Flow<'a>, + state: State, + /// Whether the code being walked can run: not after a `return`, `break`, `goto` or `error()`. + live: bool, + /// Whether what the locals hold is recorded, which it is not while a loop is walked to find what + /// its locals hold at its start. + record: bool, + no_facts: Rc<[(u32, Fact)]>, + frames: Vec, + /// Each function by where it starts. + functions: FxHashMap, + /// The assignments to each local that is assigned again. + writes: FxHashMap>, + /// Where each assignment to a local starts, in order, with the local and the origin of the value. + ordered: Vec<(u32, LocalId, u32)>, + /// For each function, the locals declared outside it that are assigned again and that it, or a + /// function in it, reads or assigns. + captured: FxHashMap>, + /// For each function, the values that it, or a function in it, gives the locals declared + /// outside it. + inner_writes: FxHashMap>, + /// What the locals hold at the `goto`s that jump ahead to a label, by where its statement starts. + gotos: FxHashMap>, /// The locals that hold what `type`, or `math.type` with `true`, gives for another local, as /// `local kind = type(value)` does. kinds: FxHashMap, @@ -232,9 +392,648 @@ struct Finder<'r> { type_functions: FxHashMap, } -impl Finder<'_> { - /// The local `expr` names. A guard on one that is assigned again after its declaration only - /// counts after a `---@cast`, see `Guards::since`. +impl<'a, 'r> Walker<'a, 'r> { + fn new(resolution: &'r Resolution, flow: Flow<'a>, no_facts: Rc<[(u32, Fact)]>) -> Self { + let mut writes: FxHashMap> = FxHashMap::default(); + let mut ordered = Vec::new(); + for (id, local) in resolution.locals.iter().enumerate() { + for write in local.refs.iter().filter(|r| r.write) { + let Some(origin) = flow.written_at(write.span.start) else { continue }; + writes.entry(id as LocalId).or_default().push(Write { + start: write.span.start, + func: write.func, + origin, + }); + ordered.push((write.span.start, id as LocalId, origin)); + } + } + ordered.sort_unstable(); + // The functions between where a local is used and the function that declares it. + let outside = |from: FuncId, decl: FuncId| { + std::iter::successors(Some(from), |func| resolution.functions.get(*func as usize).and_then(|f| f.parent)) + .take_while(move |func| *func != decl) + }; + let mut captured: FxHashMap> = FxHashMap::default(); + let mut inner_writes: FxHashMap> = FxHashMap::default(); + let mut locals: Vec<&LocalId> = writes.keys().collect(); + locals.sort_unstable(); + for id in locals { + let local = resolution.local(*id); + for reference in &local.refs { + for func in outside(reference.func, local.func) { + let used = captured.entry(func).or_default(); + if used.last() != Some(id) { + used.push(*id); + } + } + } + for write in &writes[id] { + for func in outside(write.func, local.func) { + inner_writes.entry(func).or_default().push((*id, write.origin)); + } + } + } + let functions = resolution.functions.iter().enumerate().skip(1); + let mut flow = flow; + for (local, writes) in &writes { + let reads = |stmt: &Stmt| { + let refs = &resolution.local(*local).refs; + refs.iter().any(|r| !r.write && stmt.span.contains(r.span.start)) + }; + let chained = writes.iter().filter(|write| match flow.origin(write.origin) { + Origin::Assignment { stmt, .. } | Origin::Compound { stmt } => reads(stmt), + _ => false, + }); + let mut origins: Vec<(u32, u32)> = chained.map(|write| (write.start, write.origin)).collect(); + origins.sort_unstable(); + flow.chained.insert(*local, origins.into_iter().map(|(_, origin)| origin).collect()); + } + Self { + resolution, + flow, + state: State::default(), + live: true, + record: true, + no_facts, + frames: vec![Frame::new(MAIN_CHUNK)], + functions: functions.map(|(id, func)| (func.span.start, id as FuncId)).collect(), + writes, + ordered, + captured, + inner_writes, + gotos: FxHashMap::default(), + kinds: FxHashMap::default(), + type_functions: FxHashMap::default(), + } + } + + fn frame(&mut self) -> &mut Frame { + self.frames.last_mut().expect("the main chunk is always walked") + } + + fn is_declared(values: &[Version]) -> bool { + matches!(values, [value] if value.origin == DECLARATION && value.facts.is_empty()) + } + + fn values(&self, state: &State, local: LocalId) -> Values { + state.get(&local).cloned().unwrap_or_else(|| self.flow.declared.clone()) + } + + /// Records that `local` may hold `values` from `at` on. + fn point(&mut self, local: LocalId, at: u32, values: &Values) { + if !self.record { + return; + } + let Some(points) = self.flow.points.get_mut(&local) else { + if !Self::is_declared(values) { + self.flow.points.insert(local, vec![(at, values.clone())]); + } + return; + }; + match points.last_mut() { + Some((last, held)) if *last == at => *held = values.clone(), + Some((_, held)) if held == values => {} + _ => points.push((at, values.clone())), + } + } + + /// Makes `local` hold one of `values` from `at` on. + fn set(&mut self, local: LocalId, at: u32, values: Values) { + self.point(local, at, &values); + if Self::is_declared(&values) { + self.state.remove(&local); + } else { + self.state.insert(local, values); + } + } + + /// Makes the locals hold what `state` says from `at` on. + fn switch(&mut self, at: u32, state: State) { + if self.record { + let old = &self.state; + let changed: Vec = old + .iter() + .filter(|(local, values)| state.get(local) != Some(values)) + .map(|(local, _)| *local) + .chain(state.iter().filter(|(local, values)| old.get(local) != Some(values)).map(|(local, _)| *local)) + .collect(); + for local in changed { + let values = self.values(&state, local); + self.point(local, at, &values); + } + } + self.state = state; + } + + /// `state` with what `facts` tell about its locals from `at` on. + fn with_facts(&self, mut state: State, facts: Facts, at: u32) -> State { + for (local, fact) in facts { + let values = self.values(&state, local); + let values = values.iter().map(|value| Version { + origin: value.origin, + facts: value.facts.iter().cloned().chain([(at, fact.clone())]).collect(), + }); + state.insert(local, values.collect()); + } + state + } + + /// Adds what `cond` being true, or false without `holds`, tells about the locals in it from `at` + /// on. + fn assume(&mut self, cond: &Expr, holds: bool, at: u32) { + let mut facts = Facts::new(); + self.facts(cond, holds, &mut facts); + if !facts.is_empty() { + let state = self.with_facts(self.state.clone(), facts, at); + self.switch(at, state); + } + } + + /// `state` with what `cond` being true, or false without `holds`, tells from `at` on. + fn assuming(&self, state: State, cond: &Expr, holds: bool, at: u32) -> State { + let mut facts = Facts::new(); + self.facts(cond, holds, &mut facts); + self.with_facts(state, facts, at) + } + + /// What the locals may hold where two ways meet that leave them `a` and `b`. + fn join(&self, a: &State, b: &State) -> State { + let mut out = State::default(); + let declared = &self.flow.declared; + for (local, values) in a { + let joined = joined(values, b.get(local).unwrap_or(declared)); + if !Self::is_declared(&joined) { + out.insert(*local, joined); + } + } + for (local, values) in b.iter().filter(|(local, _)| !a.contains_key(local)) { + let joined = joined(declared, values); + if !Self::is_declared(&joined) { + out.insert(*local, joined); + } + } + out + } + + /// Goes on after a statement, from what the ways out of it leave, joined at `at`. With none, the + /// code after it does not run. + fn leave(&mut self, at: u32, ways: Vec) { + let mut ways = ways.into_iter(); + let Some(first) = ways.next() else { + self.live = false; + return; + }; + let joined = ways.fold(first, |joined, way| self.join(&joined, &way)); + self.switch(at, joined); + self.live = true; + } + + fn block(&mut self, block: &'a Block) { + self.statements(block); + self.forget(block); + } + + /// Walks the statements of `block` without forgetting the locals it declares, which the + /// condition of a `repeat` still reads. + fn statements(&mut self, block: &'a Block) { + let labels = block.stmts.iter().filter_map(|stmt| match &stmt.kind { + StmtKind::Label(name) => Some((name.text.clone(), stmt.span.start)), + _ => None, + }); + let labels = labels.collect(); + self.frame().labels.push(labels); + for (index, stmt) in block.stmts.iter().enumerate() { + self.stmt(stmt, block, index); + } + self.frame().labels.pop(); + } + + /// Forgets what the locals that `block` declares hold, as they are gone after it. + fn forget(&mut self, block: &Block) { + for stmt in &block.stmts { + let names: Vec<&Name> = match &stmt.kind { + StmtKind::Local { names, .. } => names.iter().map(|name| &name.name).collect(), + StmtKind::LocalFunction { name, .. } => vec![name], + _ => continue, + }; + for name in names { + if let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) { + self.state.remove(&id); + } + } + } + } + + fn stmt(&mut self, stmt: &'a Stmt, block: &'a Block, index: usize) { + match &stmt.kind { + StmtKind::Local { names, exprs, in_unpack } => { + exprs.iter().for_each(|expr| self.visit_expr(expr)); + if !in_unpack { + let declared: Vec> = + names.iter().map(|name| Some((&name.name, DECLARATION))).collect(); + self.link(&declared, exprs); + self.note_kinds(names, exprs); + } + // A local declared again, as in each run of a loop, holds the value of its declaration. + for name in names { + if let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) { + if self.state.contains_key(&id) { + self.set(id, stmt.span.end, self.flow.declared.clone()); + } + } + } + } + StmtKind::LocalFunction { name, func } => { + if let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) { + if self.state.contains_key(&id) { + self.set(id, name.span.start, self.flow.declared.clone()); + } + } + self.function(func); + } + StmtKind::Function { name, func } => { + self.function(func); + if name.path.is_empty() && name.method.is_none() { + self.assigned(&name.base, stmt.span.end); + } + } + StmtKind::Assign { targets, exprs } => { + exprs.iter().for_each(|expr| self.visit_expr(expr)); + for target in targets.iter().filter(|target| !matches!(target.kind, ExprKind::Name(_))) { + self.visit_expr(target); + } + let names = targets.iter().map(|target| match &target.kind { + ExprKind::Name(name) => self.flow.written_at(name.span.start).map(|origin| (name, origin)), + _ => None, + }); + let names: Vec> = names.collect(); + self.link(&names, exprs); + for target in targets { + if let ExprKind::Name(name) = &target.kind { + self.assigned(name, stmt.span.end); + } + } + } + StmtKind::CompoundAssign { target, expr, .. } => { + self.visit_expr(expr); + match &target.kind { + ExprKind::Name(name) => self.assigned(name, stmt.span.end), + _ => self.visit_expr(target), + } + } + StmtKind::Expr(expr) => { + self.visit_expr(expr); + if let ExprKind::Call { callee, args, .. } = &expr.kind { + if callee.dotted_path().as_deref() == Some("error") { + self.live = false; + } else if let (ExprKind::Name(name), Some(cond)) = (&callee.kind, args.first()) { + // What holds once `assert(cond)` has run. + if name.text == "assert" && self.is_global(name) { + self.assume(cond, true, stmt.span.end); + } + } + } + } + StmtKind::Do(body) => self.block(body), + // It runs when its block ends, which changes nothing for the code after it. + StmtKind::Defer(body) => { + let (state, live) = (self.state.clone(), self.live); + self.block(body); + self.switch(stmt.span.end, state); + self.live = live; + } + StmtKind::If { branches, else_block } => self.if_stmt(stmt, branches, else_block.as_ref()), + StmtKind::While { cond, body } => { + let reached = self.live; + let start = self.loop_start(stmt, |walker| walker.while_run(stmt, cond, body).1); + self.switch(cond.span.start, start); + let (ways, _) = self.while_run(stmt, cond, body); + self.leave(stmt.span.end, ways); + self.live &= reached; + } + StmtKind::Repeat { body, cond } => { + let reached = self.live; + let start = self.loop_start(stmt, |walker| walker.repeat_run(stmt, body, cond).1); + self.switch(body.span.start, start); + let (ways, _) = self.repeat_run(stmt, body, cond); + self.leave(stmt.span.end, ways); + self.live &= reached; + } + StmtKind::NumericFor { start, limit, step, body, .. } => { + [Some(start), Some(limit), step.as_ref()].into_iter().flatten().for_each(|expr| self.visit_expr(expr)); + self.for_loop(stmt, body); + } + StmtKind::GenericFor { exprs, body, .. } => { + exprs.iter().for_each(|expr| self.visit_expr(expr)); + self.for_loop(stmt, body); + } + StmtKind::Return(exprs) => { + exprs.iter().for_each(|expr| self.visit_expr(expr)); + self.live = false; + } + StmtKind::Break => { + if self.live { + let state = self.state.clone(); + if let Some(breaks) = self.frame().breaks.last_mut() { + breaks.push(state); + } + } + self.live = false; + } + StmtKind::Goto(name) => { + self.goto(stmt, name); + self.live = false; + } + StmtKind::Label(name) => self.label(stmt, name, block, index), + StmtKind::Error => {} + } + } + + /// Notes the locals that hold what `type` gives for another local, or `type` itself. + fn note_kinds(&mut self, names: &[AttribName], exprs: &[Expr]) { + for (name, expr) in names.iter().zip(exprs) { + let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) else { continue }; + if self.is_reassigned(id) { + continue; + } + // `local kind = type(value)` tells nothing about what is assigned to `value` later. + if let Some(checked) = self.type_call(expr).filter(|(checked, _)| !self.is_reassigned(*checked)) { + self.kinds.insert(id, checked); + } else if let Some(numbers) = self.type_function(expr) { + self.type_functions.insert(id, numbers); + } + } + } + + /// Makes the local whose name `name` assigns hold the value it is given from `at` on. + fn assigned(&mut self, name: &Name, at: u32) { + let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) else { return }; + let Some(origin) = self.flow.written_at(name.span.start) else { return }; + let values: Values = Rc::from([Version { origin, facts: self.no_facts.clone() }]); + self.set(id, at, values); + } + + fn if_stmt(&mut self, stmt: &'a Stmt, branches: &'a [IfBranch], else_block: Option<&'a Block>) { + let reached = self.live; + let else_start = else_block.map(|block| block.span.start.min(stmt.span.end)); + let mut failed = self.state.clone(); + let mut ways = Vec::new(); + for (index, branch) in branches.iter().enumerate() { + // The conditions before it failed for the condition of an `elseif` too. + self.switch(branch.keyword_span.end, failed); + self.live = reached; + self.visit_expr(&branch.cond); + let tested = self.state.clone(); + // Measured between the keywords so half-typed code inside the branch still counts. + self.assume(&branch.cond, true, branch.cond.span.end); + self.block(&branch.block); + if self.live { + ways.push(self.state.clone()); + } + let next = branches.get(index + 1).map(|branch| branch.keyword_span.end); + let at = next.or(else_start).unwrap_or(stmt.span.end); + failed = self.assuming(tested, &branch.cond, false, at); + } + match else_block { + Some(block) => { + self.switch(else_start.unwrap_or(block.span.start), failed); + self.live = reached; + self.block(block); + if self.live { + ways.push(self.state.clone()); + } + } + None if reached => ways.push(failed), + None => {} + } + self.leave(stmt.span.end, ways); + } + + /// What the locals hold at the start of each run of the loop `stmt`, of which `run` walks one + /// from what they hold at its start, giving what they hold to start it again. + fn loop_start(&mut self, stmt: &Stmt, run: impl Fn(&mut Self) -> Option) -> State { + let entry = self.within(stmt, self.state.clone()); + let first = self.ordered.partition_point(|(start, ..)| *start < stmt.span.start); + if !self.ordered.get(first).is_some_and(|(start, ..)| *start < stmt.span.end) { + return entry; + } + // Inside a loop being walked to find what its locals hold at its start, the loops in it start + // with any value they give, which keeps loops inside loops from multiplying the walks. + if !self.record { + return self.widened(stmt.span, entry); + } + let (record, state, live) = (self.record, self.state.clone(), self.live); + self.record = false; + let mut start = entry; + for round in 1..=MAX_ROUNDS { + self.state = start.clone(); + self.live = true; + let next = match run(self) { + Some(again) => self.join(&start, &self.within(stmt, again)), + None => start.clone(), + }; + if next == start { + break; + } + start = next; + if round == MAX_ROUNDS { + start = self.widened(stmt.span, start); + } + } + (self.record, self.state, self.live) = (record, state, live); + start + } + + /// `state` without the locals that the loop `stmt` declares, which each of its runs declares + /// again. + fn within(&self, stmt: &Stmt, mut state: State) -> State { + state.retain(|local, _| !stmt.span.contains(self.resolution.local(*local).decl.start)); + state + } + + /// `state` where the locals that `code` assigns may hold any value it gives them, with nothing + /// known about them, as at the start of a loop when walking it again has not settled what they + /// hold. Those that `code` declares are left out. + fn widened(&self, code: Span, mut state: State) -> State { + let first = self.ordered.partition_point(|(start, ..)| *start < code.start); + let inside = self.ordered[first..].iter().take_while(|(start, ..)| *start < code.end); + let mut given: FxHashMap> = FxHashMap::default(); + for (_, local, origin) in + inside.filter(|(_, local, _)| !code.contains(self.resolution.local(*local).decl.start)) + { + given.entry(*local).or_default().push(*origin); + } + for (local, origins) in given { + let values = self.values(&state, local); + state.insert(local, self.unknown(&values, origins)); + } + state + } + + /// `values` and those that `origins` may give again, with nothing known about these. + fn given(&self, values: &[Version], origins: impl IntoIterator) -> Values { + let mut out = values.to_vec(); + for origin in origins { + let fresh = Version { origin, facts: self.no_facts.clone() }; + match out.iter_mut().find(|value| value.origin == origin) { + Some(value) => *value = fresh, + None => out.push(fresh), + } + } + out.into() + } + + /// `values` and those of `origins`, with nothing known about any of them. + fn unknown(&self, values: &[Version], origins: impl IntoIterator) -> Values { + let mut out: Vec = + values.iter().map(|value| Version { origin: value.origin, facts: self.no_facts.clone() }).collect(); + for origin in origins { + if !out.iter().any(|value| value.origin == origin) { + out.push(Version { origin, facts: self.no_facts.clone() }); + } + } + out.into() + } + + /// Walks one run of a `while` loop from what the locals hold at its start: what they hold where + /// the loop ends, and where it starts again. + fn while_run(&mut self, stmt: &'a Stmt, cond: &'a Expr, body: &'a Block) -> (Vec, Option) { + let reached = self.live; + self.visit_expr(cond); + let tested = self.state.clone(); + self.assume(cond, true, cond.span.end); + let mut ways = self.loop_body(stmt, body); + let again = self.live.then(|| self.state.clone()); + if reached && !matches!(cond.unparen().kind, ExprKind::True) { + ways.push(self.assuming(tested, cond, false, stmt.span.end)); + } + (ways, again) + } + + /// Walks one run of a `repeat` loop from what the locals hold at its start: what they hold where + /// the loop ends, and where it starts again. + fn repeat_run(&mut self, stmt: &'a Stmt, body: &'a Block, cond: &'a Expr) -> (Vec, Option) { + self.frame().loops.push(stmt.span.start); + self.frame().breaks.push(Vec::new()); + self.statements(body); + let mut ways = self.frame().breaks.pop().unwrap_or_default(); + self.frame().loops.pop(); + // The locals of the body are still there in the condition. + self.visit_expr(cond); + self.forget(body); + let tested = self.state.clone(); + let mut again = None; + if self.live { + if !matches!(cond.unparen().kind, ExprKind::True) { + again = Some(self.assuming(tested.clone(), cond, false, body.span.start)); + } + if !matches!(cond.unparen().kind, ExprKind::False | ExprKind::Nil) { + ways.push(self.assuming(tested, cond, true, stmt.span.end)); + } + } + (ways, again) + } + + fn for_loop(&mut self, stmt: &'a Stmt, body: &'a Block) { + let reached = self.live; + let start = self.loop_start(stmt, |walker| walker.for_run(stmt, body).1); + self.switch(body.span.start, start); + let (ways, _) = self.for_run(stmt, body); + self.leave(stmt.span.end, ways); + self.live &= reached; + } + + /// Walks one run of a `for` loop from what the locals hold at its start: what they hold where the + /// loop ends, and where it starts again. + fn for_run(&mut self, stmt: &'a Stmt, body: &'a Block) -> (Vec, Option) { + let start = self.within(stmt, self.state.clone()); + let mut ways = self.loop_body(stmt, body); + // It ends where a run would start, when what it goes through runs out. + ways.push(start); + (ways, self.live.then(|| self.state.clone())) + } + + /// Walks the body of the loop `stmt`, giving what the locals hold at its `break`s. + fn loop_body(&mut self, stmt: &Stmt, body: &'a Block) -> Vec { + self.frame().loops.push(stmt.span.start); + self.frame().breaks.push(Vec::new()); + self.block(body); + let ways = self.frame().breaks.pop().unwrap_or_default(); + self.frame().loops.pop(); + ways + } + + fn goto(&mut self, stmt: &Stmt, name: &Name) { + if !self.live { + return; + } + let labels = &self.frames.last().expect("the main chunk is always walked").labels; + let target = labels.iter().rev().find_map(|labels| labels.iter().find(|(label, _)| *label == name.text)); + // A `goto` back to a label is handled at the label. + if let Some((_, at)) = target.filter(|(_, at)| *at > stmt.span.start) { + let state = self.state.clone(); + self.gotos.entry(*at).or_default().push(state); + } + } + + /// Goes on at a label from the code before it and the `goto`s that jump ahead to it. A `goto` + /// after it may jump back to it, where the locals assigned since may hold any value given there. + fn label(&mut self, stmt: &Stmt, name: &Name, block: &Block, index: usize) { + let jumps = self.gotos.remove(&stmt.span.start).unwrap_or_default(); + let mut ways = self.live.then(|| self.state.clone()).into_iter().chain(jumps); + let Some(first) = ways.next() else { return }; + let mut joined = ways.fold(first, |joined, way| self.join(&joined, &way)); + let mut jumps = Jumps { name: &name.text, found: false }; + block.stmts[index + 1..].iter().for_each(|stmt| jumps.visit_stmt(stmt)); + if jumps.found { + joined = self.widened(Span::new(stmt.span.start, block.span.end), joined); + } + self.switch(stmt.span.start, joined); + self.live = true; + } + + /// Walks a function from what the locals around it hold where it is created. A local declared + /// outside it that is assigned again may also hold, as the function runs any time later, what + /// the code around it assigns from there on and what other functions assign. Back in the code + /// that creates it, the locals it assigns may hold what it gives them from then on. + fn function(&mut self, func: &'a FuncBody) { + let Some(id) = self.functions.get(&func.span.start).copied() else { + return self.block(&func.body); + }; + let (outer, live) = (self.state.clone(), self.live); + // While a loop is walked to find what its locals hold at its start, nothing a function holds + // changes that. + if self.record { + self.walk_function(id, func); + } + let mut after = outer; + for (local, origin) in self.inner_writes.get(&id).into_iter().flatten() { + let values = self.values(&after, *local); + after.insert(*local, self.given(&values, [*origin])); + } + self.switch(func.span.end, after); + self.live = live; + } + + fn walk_function(&mut self, id: FuncId, func: &'a FuncBody) { + let frame = self.frame(); + frame.created = frame.loops.first().map_or(func.span.start, |start| (*start).min(func.span.start)); + let mut entry = self.state.clone(); + for local in self.captured.get(&id).into_iter().flatten() { + let decl = self.resolution.local(*local).func; + let created = self.frames.iter().rev().find(|frame| frame.func == decl).map_or(0, |frame| frame.created); + let later = self.writes[local].iter().filter(|write| write.func != decl || write.start >= created); + let values = self.values(&entry, *local); + entry.insert(*local, self.given(&values, later.map(|write| write.origin))); + } + self.frames.push(Frame::new(id)); + self.switch(func.params_span.end, entry); + self.live = true; + self.block(&func.body); + self.frames.pop(); + } + + /// The local `expr` names. fn local(&self, expr: &Expr) -> Option { let ExprKind::Name(name) = &expr.unparen().kind else { return None }; let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) else { return None }; @@ -257,7 +1056,7 @@ impl Finder<'_> { } fn is_reassigned(&self, local: LocalId) -> bool { - self.resolution.local(local).refs.iter().any(|r| r.write) + self.writes.contains_key(&local) } fn is_global(&self, name: &Name) -> bool { @@ -377,142 +1176,93 @@ impl Finder<'_> { } } - /// Links the locals that take the values of the call ending a `local` statement. Those assigned - /// again later are left out, as they are not narrowed. - fn link(&mut self, names: &[AttribName], exprs: &[Expr]) { + /// Links the locals that take the values of the call ending a `local` statement or an + /// assignment, each named with the origin of the value it takes. A guard on one only tells about + /// the others while they hold those values, see `Flow::facts`. + fn link(&mut self, names: &[Option<(&Name, u32)>], exprs: &[Expr]) { let Some(first) = exprs.len().checked_sub(1).filter(|_| exprs.last().is_some_and(Expr::is_call)) else { return; }; - let members: Vec<(LocalId, usize)> = names + let members: Vec<(LocalId, u32, usize)> = names .iter() .enumerate() .skip(first) .filter_map(|(index, name)| { - let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) else { return None }; - (!self.resolution.local(id).refs.iter().any(|r| r.write)).then_some((id, index - first)) + let (name, origin) = (*name)?; + let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) else { return None }; + Some((id, origin, index - first)) }) .collect(); - if members.len() > 1 { - for (local, _) in &members { - self.guards.link_of.insert(*local, self.guards.links.len()); + let linked = |(local, origin, _): &(LocalId, u32, usize)| self.flow.link_of.contains_key(&(*local, *origin)); + if members.len() > 1 && !members.iter().any(linked) { + for (local, origin, _) in &members { + self.flow.link_of.insert((*local, *origin), self.flow.links.len()); } - self.guards.links.push(members); + self.flow.links.push(members); } } +} - fn record(&mut self, span: Span, facts: Facts) { - for (local, fact) in facts { - self.guards.facts.entry(local).or_default().push((span, fact)); +impl<'a> Visitor<'a> for Walker<'a, '_> { + fn visit_expr(&mut self, expr: &'a Expr) { + match &expr.kind { + ExprKind::Function(func) => self.function(func), + // `b` of `a and b` only runs when `a` is true, and `b` of `a or b` when it is false. + ExprKind::Binary { op: op @ (BinOp::And | BinOp::Or), lhs, rhs, .. } => { + self.visit_expr(lhs); + let before = self.state.clone(); + self.assume(lhs, *op == BinOp::And, rhs.span.start); + self.visit_expr(rhs); + let after = self.join(&before, &self.state); + self.switch(rhs.span.end, after); + } + _ => visit::walk_expr(self, expr), } } +} - /// What holds once `stmt` has run: the conditions of an `if` whose other paths all leave, as - /// after `if not name then return end`, and the condition of an `assert`. - fn after(&self, stmt: &Stmt) -> Facts { - match &stmt.kind { - StmtKind::If { branches, else_block } => { - // The ways through the statement that reach the code after it. - let mut open: Vec = Vec::new(); - let mut failed = Facts::new(); - for branch in branches { - if !leaves(&branch.block) { - let mut facts = failed.clone(); - self.facts(&branch.cond, true, &mut facts); - open.push(facts); - } - self.facts(&branch.cond, false, &mut failed); - } - if !else_block.as_ref().is_some_and(leaves) { - open.push(failed); - } - match open.len() { - 1 => open.pop().unwrap_or_default(), - _ => Facts::new(), - } - } - StmtKind::Expr(Expr { kind: ExprKind::Call { callee, args, .. }, .. }) => { - let is_assert = matches!(&callee.kind, ExprKind::Name(name) if name.text == "assert" - && !matches!(self.resolution.resolve_at(name.span.start), Some(Resolved::Local(_)))); - let mut facts = Facts::new(); - if let Some(cond) = args.first().filter(|_| is_assert) { - self.facts(cond, true, &mut facts); - } - facts - } - _ => Facts::new(), - } +/// What a local may hold where two ways meet that leave it `a` and `b`: the values of both, each +/// with the facts that hold on both ways. +fn joined(a: &Values, b: &Values) -> Values { + if a == b { + return a.clone(); } + let mut out: Vec = a + .iter() + .map(|value| match b.iter().find(|other| other.origin == value.origin) { + Some(other) => Version { origin: value.origin, facts: shared(&value.facts, &other.facts) }, + None => value.clone(), + }) + .collect(); + out.extend(b.iter().filter(|other| !a.iter().any(|value| value.origin == other.origin)).cloned()); + out.into() } -impl<'ast> Visitor<'ast> for Finder<'_> { - fn visit_block(&mut self, block: &'ast Block) { - for (index, stmt) in block.stmts.iter().enumerate() { - self.visit_stmt(stmt); - let facts = self.after(stmt); - if !facts.is_empty() { - // A label can be reached by a `goto` that skipped the guard. - let label = block.stmts[index + 1..].iter().find(|next| matches!(next.kind, StmtKind::Label(_))); - let end = label.map_or(block.span.end, |label| label.span.start); - self.record(Span { start: stmt.span.end, end: end.max(stmt.span.end) }, facts); - } - } +/// The facts of `a` that `b` has too, each from where it starts to hold in `a`, so that the start of +/// a loop settles once the facts its runs keep stop changing. +fn shared(a: &Rc<[(u32, Fact)]>, b: &Rc<[(u32, Fact)]>) -> Rc<[(u32, Fact)]> { + if a == b { + return a.clone(); } + a.iter().filter(|(_, fact)| b.iter().any(|(_, other)| other == fact)).cloned().collect() +} + +/// Finds a `goto` to a label in the code after it, which jumps back to it. +struct Jumps<'n> { + name: &'n str, + found: bool, +} +impl<'ast> Visitor<'ast> for Jumps<'_> { fn visit_stmt(&mut self, stmt: &'ast Stmt) { match &stmt.kind { - StmtKind::If { branches, else_block } => { - let else_start = else_block.as_ref().map(|block| block.span.start.min(stmt.span.end)); - let mut failed = Facts::new(); - for (index, branch) in branches.iter().enumerate() { - // Measured between the keywords so half-typed code inside the branch still counts. - let next = branches.get(index + 1).map(|b| b.keyword_span.start); - let end = next.or(else_start).unwrap_or(stmt.span.end); - // The conditions before it failed for the condition of an `elseif` too. - let start = branch.keyword_span.end; - self.record(Span { start, end: end.max(start) }, failed.clone()); - let mut facts = Facts::new(); - self.facts(&branch.cond, true, &mut facts); - self.record(Span { start: branch.cond.span.end, end: end.max(branch.cond.span.end) }, facts); - self.facts(&branch.cond, false, &mut failed); - } - if let Some(start) = else_start { - self.record(Span { start, end: stmt.span.end }, failed); - } - } - StmtKind::Local { names, exprs, in_unpack: false } => { - self.link(names, exprs); - for (name, expr) in names.iter().zip(exprs) { - let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) else { continue }; - if self.is_reassigned(id) { - continue; - } - // `local kind = type(value)` tells nothing about what is assigned to `value` later. - if let Some(checked) = self.type_call(expr).filter(|(checked, _)| !self.is_reassigned(*checked)) { - self.kinds.insert(id, checked); - } else if let Some(numbers) = self.type_function(expr) { - self.type_functions.insert(id, numbers); - } - } - } - StmtKind::While { cond, .. } => { - let mut facts = Facts::new(); - self.facts(cond, true, &mut facts); - self.record(Span { start: cond.span.end, end: stmt.span.end.max(cond.span.end) }, facts); - } - _ => {} + StmtKind::Goto(name) if name.text == self.name => self.found = true, + _ => visit::walk_stmt(self, stmt), } - visit::walk_stmt(self, stmt); } - fn visit_expr(&mut self, expr: &'ast Expr) { - // `b` of `a and b` only runs when `a` is true, and `b` of `a or b` when it is false. - if let ExprKind::Binary { op: op @ (BinOp::And | BinOp::Or), lhs, rhs, .. } = &expr.kind { - let mut facts = Facts::new(); - self.facts(lhs, *op == BinOp::And, &mut facts); - self.record(rhs.span, facts); - } - visit::walk_expr(self, expr); - } + // A `goto` cannot leave its function. + fn visit_func_body(&mut self, _: &'ast FuncBody) {} } fn literal(expr: &Expr) -> Option { @@ -530,19 +1280,6 @@ fn literal(expr: &Expr) -> Option { } } -/// Whether running `block` always leaves the block around it: by returning, raising an error, -/// `break` or `goto`. -fn leaves(block: &Block) -> bool { - match block.stmts.last().map(|stmt| &stmt.kind) { - Some(StmtKind::Break) => true, - Some(StmtKind::Do(body)) => leaves(body), - Some(StmtKind::If { branches, else_block: Some(else_block) }) => { - branches.iter().all(|branch| leaves(&branch.block)) && leaves(else_block) - } - _ => always_exits(block), - } -} - /// A `---@cast` line. #[derive(Debug)] pub struct Cast { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 9a70cdc..abd5ce3 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2004,8 +2004,9 @@ print(state, copy, level, label, changed, mode) ("copy = state", "local copy: \"active\"|\"busy\"|\"ready\""), ("level, label", "local level: 1|2"), ("label = GetLevel", "local label: \"low\"|\"high\""), - // One that is assigned again may hold other values of that kind. - ("changed = GetState", "local changed: string"), + // One that is assigned again holds them until then, and what is assigned later after that. + ("changed = GetState", "local changed: \"active\"|\"busy\"|\"ready\""), + ("changed, mode)", "local changed: string"), ("mode = 'dev'", "local mode: string"), ] { let (l, c) = pos(text, needle, 0); @@ -2095,8 +2096,8 @@ local copy = name // A `goto` that skipped the guard reaches the code after its label. ("item) -- item skipped", "item: string?\n"), ("again) -- asserted", "again: string\n"), - // Something assigns to it after its declaration, so a guard says nothing about it. - ("changed) -- reassigned", "changed: string?\n"), + // A guard narrows what the assignments before it give a local that is assigned again. + ("changed) -- reassigned", "changed: string\n"), ] { let (l, c) = pos(text, needle, 0); let hover = client.hover_text(CLIENT, l, c); @@ -2104,6 +2105,209 @@ local copy = name } } +#[test] +fn locals_that_are_assigned_again_hold_what_reaches_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return string? +local function maybe() end +---@param cb fun(value: string) +local function each(cb) end +local cond = math.random() > 0.5 + +local filled = maybe() +filled = filled or 'default' +print(filled) -- filled +local branch = maybe() +if not branch then + branch = 'x' +end +print(branch) -- joined +local cleared = 'x' +if cond then + cleared = nil +end +print(cleared) -- one branch +local later +print(later) -- not yet +later = 5 +print(later) -- assigned +---@type string? +local typed = nil +typed = 'x' +print(typed) -- bounded +---@type integer +local wrong = 1 +wrong = 'x' +print(wrong) -- mismatch +local looped = 'start' +while cond do + print(looped) -- loop start + looped = nil +end +print(looped) -- after loop +local counted = maybe() +for _ = 1, 3 do + counted = counted or 'y' +end +print(counted) -- after for +local repeated = maybe() +repeat + repeated = 'z' +until cond +print(repeated) -- after repeat +local endless = maybe() +while true do + if endless then + break + end + endless = maybe() +end +print(endless) -- after break +local captured = maybe() +captured = captured or 'x' +local function read() + print(captured) -- closure +end +captured = nil +local function clear() + print(captured) -- closure after +end +local found +each(function(value) + found = value +end) +print(found) -- callback +local jumped = maybe() +for _ = 1, 2 do + if not jumped then + goto continue + end + print(jumped) -- before label + ::continue:: + print(jumped) -- after label + jumped = 'w' +end +local flag = true +for _, a in ipairs({ 1 }) do + for _, b in ipairs({ a }) do + if b then + flag = false + break + end + print(flag) -- inner loop + end + if flag == false then + break + end +end +local ready = false +local function waitReady() + if not ready then + each(function() + print(ready) -- given later + end) + end +end +local function setReady() + ready = true +end +print(read, clear, waitReady, setReady) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("filled) -- filled", "filled: string\n"), + ("branch) -- joined", "branch: string\n"), + ("cleared) -- one branch", "cleared: string?\n"), + ("later) -- not yet", "later: nil\n"), + ("later) -- assigned", "later: integer\n"), + // An annotation keeps the parts the value may be... + ("typed) -- bounded", "typed: string\n"), + // ...and all of it when the value fits none of them, which `assign-type-mismatch` reports. + ("wrong) -- mismatch", "wrong: integer\n"), + // A loop starts with what the runs before it leave. + ("looped) -- loop start", "looped: string?\n"), + ("looped) -- after loop", "looped: string?\n"), + ("counted) -- after for", "counted: string?\n"), + ("repeated) -- after repeat", "repeated: string\n"), + ("endless) -- after break", "endless: string\n"), + // A function runs any time after it is created, also after the assignments that follow it. + ("captured) -- closure\n", "captured: string?\n"), + ("captured) -- closure after", "captured: nil\n"), + // The function a call is given may have run once the call returns, and `local found` makes + // way for the values assigned later. + ("found) -- callback", "found: string\n"), + ("jumped) -- before label", "jumped: string\n"), + ("jumped) -- after label", "jumped: string?\n"), + ("flag) -- inner loop", "flag: boolean\n"), + // What a guard told about a value holds no more once something may give it again. + ("ready) -- given later", "ready: boolean\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + // An assignment shows the value it gives. + let (l, c) = pos(text, "later = 5", 0); + assert!(client.hover_text(CLIENT, l, c).contains("later: integer\n")); +} + +#[test] +fn locals_that_are_assigned_again_are_checked_with_what_reaches_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return 'active'|'busy' +local function getState() end +local function untyped(value) return value end +---@param count integer +local function wants(count) end + +local state = getState() +if state == 'other' then end +state = getState() +if state == 'idle' then end +state = tostring(state) +if state == 'other' then end +local loose = getState() +loose = untyped(loose) +if loose == 'other' then end +local name = 'x' +name = name .. 'y' +wants(name) +local mixed = 1 +if untyped(true) then + mixed = untyped(mixed) +end +wants(mixed) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, code: &str, message: &str| { + (code.to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["impossible-comparison", "param-type-mismatch"]), + [ + finding( + "state == 'other' then end\nstate = getState", + "impossible-comparison", + "Comparing `\"active\"|\"busy\"` with `\"other\"` is always false", + ), + finding( + "state == 'idle'", + "impossible-comparison", + "Comparing `\"active\"|\"busy\"` with `\"idle\"` is always false", + ), + finding( + "wants(name)", + "param-type-mismatch", + "Cannot assign `string` to parameter `count` of type `integer`", + ), + ], + "a local that is assigned again has the declared types of the values that reach it, and none when \ + one of them has none" + ); +} + #[test] fn guards_that_read_from_a_local_narrow_it() { let mut client = Client::start(fixture_root()); @@ -2275,9 +2479,9 @@ print(firstname, lastname) -- after ("label -- no label", "label: nil\n"), ("id, label) -- typed", "id: integer\n"), ("label) -- typed", "label: string\n"), - // A local that is assigned again keeps its own type and tells nothing about the others. + // A local that is assigned again holds what it is given, and tells nothing about the others. ("first, last) -- reassigned", "first: string\n"), - ("last) -- reassigned", "last: string?\n"), + ("last) -- reassigned", "last: string\n"), ] { let (l, c) = pos(text, needle, 0); let hover = client.hover_text(CLIENT, l, c); @@ -2533,9 +2737,9 @@ print(loose) -- removed from unknown ("scoped) -- in block", "scoped: integer\n"), // A cast ends with its block... ("scoped) -- after block", "scoped: string|integer|nil\n"), - // ...and once the local is assigned again. + // ...and once the local is assigned again, which gives it the type of its new value. ("changed:upper()", "changed: string\n"), - ("changed) -- reassigned", "changed: string|integer|nil\n"), + ("changed) -- reassigned", "changed: string\n"), ("guarded) -- cast over guard", "guarded: integer\n"), ("guarded) -- guard over cast", "guarded: string\n"), // On the last line of an `if` branch, a cast types what the branch leaves for the code @@ -2549,9 +2753,9 @@ print(loose) -- removed from unknown // A line with no type keeps the guards before it. ("typing) -- no type yet", "typing: string|integer\n"), ("typing) -- no name", "typing: string|integer\n"), - // A guard on a local that is assigned again narrows the type a cast before it gives, up to - // the next assignment. - ("plain) -- reassigned guard", "plain: string|integer|nil\n"), + // A guard on a local that is assigned again narrows what it is given, and the type a cast + // before the guard gives, up to the next assignment. + ("plain) -- reassigned guard", "plain: string|integer\n"), ("data) -- guard after cast", "data: string\n"), ("data) -- assigned after guard", "data: string|integer|nil\n"), ("value) -- closure", "value: string\n"), @@ -2659,6 +2863,29 @@ fn many_casts_and_writes_of_one_local_stay_fast() { assert!(elapsed < Duration::from_secs(5), "hover took {elapsed:?}"); } +#[test] +fn locals_assigned_many_times_in_loops_stay_fast() { + let mut client = Client::start(fixture_root()); + // Each assignment reads what the others give, and each loop is walked again until what its + // locals hold settles, which loops inside it must not multiply. + let mut text = "local j, k = 1, 1\nwhile math.random() > 0.5 do\n".to_string(); + text.push_str(&" if math.random() > 0.5 then j = j + 3 end\n".repeat(100)); + text.push_str("end\n"); + text.push_str(&"for _ = 1, 2 do\n k = k + 1\n".repeat(12)); + text.push_str(&"end\n".repeat(12)); + text.push_str("print(j, k) -- last\n"); + client.open_with(CLIENT, &text); + let started = Instant::now(); + findings(&mut client, CLIENT, &["param-type-mismatch"]); + for (needle, expected) in [("j, k) -- last", "j: integer\n"), ("k) -- last", "k: integer\n")] { + let (line, character) = pos(&text, needle, 0); + let hover = client.hover_text(CLIENT, line, character); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let elapsed = started.elapsed(); + assert!(elapsed < Duration::from_secs(5), "took {elapsed:?}"); +} + #[test] fn returns_have_to_match_one_of_the_sets_of_values() { let mut client = Client::start(fixture_root()); @@ -4736,6 +4963,9 @@ plain = 'text' // The statement whose `---@type` declares a name may give it `nil`, as in LuaLS. finding("then handle = nil", "Cannot assign `nil` to `handle` of type `number`"), finding("local letter", "Cannot assign `\"c\"` to `letter` of type `\"a\"|\"b\"`"), + // A local that is assigned again has the type of what may reach it: what `state = nil` + // gives is still a `Test.State`, as that is reported. + finding("letter = state", "Cannot assign `Test.State` to `letter` of type `\"a\"|\"b\"`"), // The `@field` decides what a class field takes, and reports it once. finding("holder.name = true", "Cannot assign `boolean` to field `name` of type `string`"), finding("holder.label = 5", "Cannot assign `integer` to `holder.label` of type `string`"), @@ -4746,8 +4976,8 @@ plain = 'text' finding("name, count, extra = 1", "Cannot assign `string` to `count` of type `integer?`"), finding("id = tostring(id)", "Cannot assign `string` to `id` of type `number`"), ], - "values the type takes, a guarded local, a local without a value, a reassigned local as the value, \ - the table a `---@class` declares, optional and undocumented parameters and untyped locals pass" + "values the type takes, a guarded local, a local without a value, the table a `---@class` declares, \ + optional and undocumented parameters and untyped locals pass" ); let (line, column) = pos(text, "second = pair()", 0); let hover = client.hover_text(CLIENT, line, column); @@ -4907,6 +5137,30 @@ use(back, returned, built) ); } +#[test] +fn casts_of_locals_assigned_again_compare_the_values_that_reach_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +local count = 5 +count = 6 +---@cast count string +local label = 5 +label = 'five' +---@cast label string +local unknown = 5 +unknown = UnknownValue +---@cast unknown string +print(count, label, unknown) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + assert_eq!( + findings(&mut client, CLIENT, &["cast-type-mismatch"]), + [("cast-type-mismatch".to_string(), line("cast count"), "Cannot convert `integer` to `string`".to_string())], + "a local that nothing annotates holds the values that reach the cast, and one of unknown type takes any" + ); +} + #[test] fn arguments_their_parameters_do_not_take_are_reported() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 37bee33..0d8e5b8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -349,8 +349,8 @@ it. The payload of a `---@callback` wrapper call is compared with the handlers r name. An argument has a type when something declares it, as for `impossible-comparison`: a literal, an -operator, an annotation, a stub, or the value a local that is never assigned again is declared -with. A literal stored in such a local counts by its kind only: `local mode = 'dev'` is a setting to +operator, an annotation, a stub, or the values a local is declared with or assigned that reach the +call. A literal stored in a local counts by its kind only: `local mode = 'dev'` is a setting to change, so it passes for `"fast" | "slow"`, while `local count = 5` is still no `string`. A `--[[@as T]]` right after an argument casts it. Only clear cases count, and the rest is left alone: @@ -559,9 +559,10 @@ Each `return` then has to be one of the sets: `return false` and `return "Joe", comes closest to. This notation is a qbx extension of LuaCATS, which qbx-lua-ls also uses to narrow the locals a call declares; see its [type guards](../crates/qbx_lua_ls/README.md#type-guards). -Only clear cases count. Values whose type is not known are skipped, and so is a local that is -assigned again after its declaration, since its declared type may not be what it holds. The same -applies to the values `assign-type-mismatch` checks, where `nil` is a value like any other: +Only clear cases count. Values whose type is not known are skipped, including a local that one of +the values that may reach the `return` leaves without a known type; see +[locals that are assigned again](../crates/qbx_lua_ls/README.md#locals-that-are-assigned-again). The +same applies to the values `assign-type-mismatch` checks, where `nil` is a value like any other: `abc.field = nil` needs a field type that allows it, such as `string?` or `string|nil`. `discard-returns` reports a call on a line of its own whose function is marked `@nodiscard`, as @@ -653,9 +654,11 @@ local count = 1 The declared type is the `---@type` or `@param` of the local, or else that of the value it is declared with, as for `impossible-comparison`: a literal, or what a function declares it returns. -A local whose type is only inferred, or that is assigned again and has no annotation, is not -checked, and neither is one declared as `nil`, which the cast gives its type as in -lua-language-server, nor the `+T` and `-T` entries, which change the type rather than replace it. +A local that is assigned again and has no annotation has the declared types of the values that +reach the cast, so `local count = 5`, `count = 6` and then `---@cast count string` is reported. A +local whose type is only inferred is not checked, and neither is one declared as `nil`, which the +cast gives its type as in lua-language-server, nor the `+T` and `-T` entries, which change the type +rather than replace it. As in lua-language-server, a class has to be one that the declared type names or extends: a `Test.Animal` can be cast to its subclass `Test.Dog`, but a `Test.Dog` not to `Test.Animal`, and neither to an unrelated class. Type arguments are not compared, a declared type that is no class, @@ -682,13 +685,15 @@ A side has a type when something declares it: a literal, an operator such as `no parameter of a function passed to a call has the type the callee declares for it, with the generics that the other arguments declare: `value` is a `Player` in `onValue('Player', function(value) end)` for `---@param cb fun(value: T)` and a `` `T` ``. A -local that is never assigned again has the type of the value it is declared with, so -`local state = GetState()` is checked like the call, and inside a +local has the types of the values it is declared with or assigned that may reach the comparison, +so `local state = GetState()` is checked like the call, and inside a [type guard](../crates/qbx_lua_ls/README.md#type-guards) the type the guard narrows it to: the right side of `class ~= 13 or class ~= 14` only runs when `class` is `13`, so it is always true. A -literal stored in such a local counts by its kind only: `local mode = 'dev'` is a setting to -change, so `mode == 'prod'` passes, while `mode == false` is still reported as a `string` compared -with `false`. +literal stored in a local counts by its kind only: `local mode = 'dev'` is a setting to change, so +`mode == 'prod'` passes, while `mode == false` is still reported as a `string` compared with +`false`. A value assigned to a local with a `---@type` or `@param` keeps the parts of that type it +may be, as described for +[locals that are assigned again](../crates/qbx_lua_ls/README.md#locals-that-are-assigned-again). Only clear cases count, and the rest is left alone: @@ -697,9 +702,9 @@ Only clear cases count, and the rest is left alone: for what an undocumented function returns, and for the value of `a or b` and `a and b`. - Comparisons with `nil`. Annotations often leave out the `?` of a value that may be missing, and the check for it is deliberate. -- A local that is assigned again after its declaration, since its declared type may not be what - it holds, unless a `---@cast name T` types it. `---@cast name +T` does not: it adds `T` to a - type that is still not known, as it does for a local with no declared type. +- A local that one of the values that may reach the comparison leaves without a declared type, as + `name = untyped(name)` does, unless a `---@cast name T` types it. `---@cast name +T` does not: it + adds `T` to a type that is still not known, as it does for a local with no declared type. - Natives documented as `boolean` compared with a number. Scripts can get `1` instead of `true` from them, so `IsPedInAnyVehicle(ped) == 1` is valid. From 50e2eeb762a8060091768d80842915679788e46e Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:26:17 +0200 Subject: [PATCH 065/193] feat(rules): check locals that are assigned again in need-check-nil --- crates/qbx_lua_ls/src/features/comparisons.rs | 29 ++++- crates/qbx_lua_ls/src/features/nil_checks.rs | 39 +++--- crates/qbx_lua_ls/tests/lsp.rs | 121 +++++++++++++++++- docs/reference.md | 7 +- 4 files changed, 164 insertions(+), 32 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index 4027e7a..a862da6 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -158,10 +158,21 @@ impl<'a, 'b> Declared<'a, 'b> { self.local(id, offset, 0) } + /// The type of `expr`, which reads the local `id`, as `of` gives it from only the values that may + /// reach the read whose origins `keep` picks. + pub fn of_values(&self, expr: &Expr, id: LocalId, keep: &dyn Fn(u32) -> bool) -> Type { + if let Some(cast) = self.cast(expr) { + return cast; + } + let origin = |origin| keep(origin).then(|| self.origin(id, origin, 0)).flatten(); + self.infer.narrowed_with(id, expr.span.start, &origin, true) + } + /// Whether the guards where the local `id` is read at `offset` rule out `value` for each of the - /// values that may reach the read and be it. - pub fn rules_out(&self, id: LocalId, offset: u32, value: &Type) -> bool { - self.infer.rules_out_with(id, offset, value, &|origin| self.origin(id, origin, 0)) + /// values that may reach the read and be it, of those whose origins `keep` picks. + pub fn rules_out_of(&self, id: LocalId, offset: u32, value: &Type, keep: &dyn Fn(u32) -> bool) -> bool { + let origin = |origin| keep(origin).then(|| self.origin(id, origin, 0)).flatten(); + self.infer.rules_out_with(id, offset, value, &origin) } /// The declared type of the value that `origin` gives the local `id`, or `None` while it is @@ -225,8 +236,16 @@ impl<'a, 'b> Declared<'a, 'b> { let value = assigned_value(exprs, index, single, multi); match self.infer.annotation(id) { // A value with no declared type, as `name or 'none'`, still tells which parts of the - // annotation it may be. - Some(_) if value.is_unknown() => self.infer.origin_type(id, origin).unwrap_or_default(), + // annotation it may be, but one of no known type at all, as `untyped()` gives, is + // not declared to be any of them. + Some(_) if value.is_unknown() => { + let inferred = + assigned_value(exprs, index, |expr| self.infer.expr(expr), |expr| self.infer.expr_multi(expr)); + match inferred.is_unknown() { + true => Type::Unknown, + false => self.infer.origin_type(id, origin).unwrap_or_default(), + } + } Some(declared) => self.infer.bounded(&declared, &value), None => value, } diff --git a/crates/qbx_lua_ls/src/features/nil_checks.rs b/crates/qbx_lua_ls/src/features/nil_checks.rs index ace2f00..2e4a32b 100644 --- a/crates/qbx_lua_ls/src/features/nil_checks.rs +++ b/crates/qbx_lua_ls/src/features/nil_checks.rs @@ -9,13 +9,14 @@ //! //! As for `impossible-comparison`, only declared types count, whatever declares them: an //! annotation of the local, or the `@return` or `@field` of a function, class or stub. The type is -//! the one the guards and casts around the read leave. Guards that leave no value of it keep it whole, -//! but a `nil` or `false` that they rule out stays out: `round` is no `nil` in +//! that of the values that may reach the read, as the guards and casts around it leave them, also +//! for a local that is assigned again. Guards that leave no value of it keep it whole, but a `nil` or +//! `false` that they rule out stays out: `round` is no `nil` in //! `round and (round == true or i < round)`, whatever else it holds. The rest is left alone: //! //! - Fields and the values of calls, which guards do not narrow. -//! - Locals that are assigned again, unless a `---@cast` types them: a guard tells nothing about -//! the value an assignment replaces, and `name = name or 'none'` is how code fills in a `nil`. +//! - A value whose type is not declared, as `name = name or 'none'` gives, and the missing value of +//! `local name` before something gives it one. //! - A value that a guard on another value of the same call covers, as `err` of //! `local fn, err = load(code)` after `if not fn then`: annotations declare such values one by one. //! - What a call gives for `source` alone, as `ESX.GetPlayerFromId(source)`: lookups of the player @@ -33,7 +34,6 @@ use super::arguments::{candidates, expected, mismatch, param_for, signatures, ta use super::class_tables::Classes; use super::comparisons::Declared; use crate::infer::{Decl, Infer}; -use crate::luacats::CastEntry; use crate::narrow::Version; use crate::types::{FunType, Param, Type}; @@ -101,33 +101,26 @@ impl Finder<'_, '_> { if self.checked.iter().any(|(checked, span)| *checked == id && span.contains(at)) { return; } - let assigned = local.refs.iter().any(|r| r.write); - let typed = || { - self.infer - .ctx - .casts() - .at(id, at) - .any(|cast| cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_)))) - }; - if assigned && !typed() { - return; - } - let ty = self.declared.of(operand.unparen()); - let reaches = |value: &Type| !self.declared.rules_out(id, at, value); - let missing = missing_value(&self.infer.expand_aliases(&ty, 0), reaches); - let Some(missing) = missing.filter(|missing| param.is_none() || *missing == "nil") else { return }; // `local vehicle, coords = lib.getClosestVehicle(...)` declares both as optional, and the - // guard on `vehicle` is the check for `coords` as well. + // guard on `vehicle` is the check for `coords` as well, while it holds that value. let flow = ctx.flow(); - let covered = |value: &Version| { + let covered = |value: &&Version| { let linked = flow.linked(id, value.origin).unwrap_or_default(); linked .iter() .any(|(other, origin, _)| *other != id && flow.facts(*other, *origin, Span::empty(at)).next().is_some()) }; - if flow.at(id, at).iter().all(covered) { + let values = flow.at(id, at); + let covered: Vec = values.iter().filter(covered).map(|value| value.origin).collect(); + if covered.len() == values.len() { return; } + let keep = |origin: u32| !covered.contains(&origin); + let ty = self.declared.of_values(operand.unparen(), id, &keep); + let reaches = |value: &Type| !self.declared.rules_out_of(id, at, value, &keep); + let missing = missing_value(&self.infer.expand_aliases(&ty, 0), reaches); + let Some(missing) = missing.filter(|missing| param.is_none() || *missing == "nil") else { return }; + let assigned = local.refs.iter().any(|r| r.write); if !assigned && self.is_lookup_of_source(id) { return; } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index abd5ce3..07f1069 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4369,8 +4369,7 @@ handle(1) finding("until ended", "`ended` may be nil: its type here is `string?`"), ], "reads that guards, casts, an earlier read or a guarded value of the same call cover are left alone, \ - and so are reads after one reported in the same function, locals assigned again, fields and the \ - values of calls" + and so are reads after one reported in the same function, fields and the values of calls" ); client.notify( "workspace/didChangeConfiguration", @@ -4379,6 +4378,124 @@ handle(1) assert!(findings(&mut client, CLIENT, &["need-check-nil"]).is_empty(), "the rule can be turned off"); } +#[test] +fn reads_of_locals_that_are_assigned_again_are_checked_for_nil() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Box +---@field name string + +---@return Probe.Box? +local function find() end +---@return Probe.Box +local function make() end +---@param code string +---@return function?, string? +local function compile(code) end +---@return Probe.Box?, Probe.Box? +local function locate() end +local function untyped() return GetSomething() end + +---@param label string? +local function show(label) + if not label then + label = untyped() + end + print(label:upper()) +end + +---@param id integer +---@param input string +local function run(id, input) + local before = find() + print(before.name) + before = make() + local swapped = make() + if id > 1 then + swapped = find() + end + print(swapped.name) + local fixed = find() + if not fixed then + fixed = make() + end + print(fixed.name) + local again = find() + if not again then + return + end + again = find() + print(again.name) + local looped = make() + while id > 2 do + print(looped.name) + looped = nil + end + local fallback = find() + fallback = fallback or make() + print(fallback.name) + local cast = make() + cast = find() --[[@as Probe.Box]] + print(cast.name) + local later + if id > 3 then + later = make() + end + print(later.name) + local fn, err = compile('return ' .. input) + if err then + fn, err = compile(input) + end + if err then + return + end + fn() + local vehicle, spot = locate() + if not vehicle then + return + end + if id > 4 then + spot = make() + end + print(spot.name) + local other, place = locate() + if not other then + return + end + if id > 5 then + place = find() + end + print(place.name) +end +run(1, '') +show() +"; + client.open_with(CLIENT, text); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), + ); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |needle: &str, message: &str| ("need-check-nil".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [ + finding("before.name", "`before` may be nil: its type here is `Probe.Box?`"), + // One way gives it a value that may be missing. + finding("swapped.name", "`swapped` may be nil: its type here is `Probe.Box?`"), + // The guard told about the value it held before. + finding("again.name", "`again` may be nil: its type here is `Probe.Box?`"), + // The loop gives it `nil` before it starts again. + finding("looped.name", "`looped` may be nil: its type here is `Probe.Box?`"), + // The guard on `other` covers the value of the call, not the one assigned after it. + finding("place.name", "`place` may be nil: its type here is `Probe.Box?`"), + ], + "the values that reach a read of a local that is assigned again are checked; one that a guard, \ + `or`, a cast or a guarded value of the same call covers, one of no known type, and the missing \ + value of `local later`, are left alone" + ); +} + #[test] fn nil_checks_leave_alone_values_the_code_relies_on() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 0d8e5b8..872703f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -800,8 +800,11 @@ Only clear cases count, and the rest is left alone: - Fields and the values of calls, which guards do not narrow: `if self.target then self.target:kill() end` would be reported otherwise. -- A local that is assigned again after its declaration, unless a `---@cast name T` types it. - `name = name or 'none'` is how code fills in a missing value. +- A local that one of the values that may reach the read leaves without a declared type, as + `name = name or 'none'` does, unless a `---@cast name T` types it. A local that is assigned again + is checked with the values that reach the read, as described for + [locals that are assigned again](../crates/qbx_lua_ls/README.md#locals-that-are-assigned-again), + and `local name` declares no missing value: only the values given later count. - A value of a call that a guard on another of its values covers. After `local vehicle, coords = lib.getClosestVehicle(pos)` and `if not vehicle then return end`, `coords` is not reported: annotations declare such values one by one, rather than as From 39375023bffac0e0939794c486e66f29fa0ba8bf Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:26:18 +0200 Subject: [PATCH 066/193] feat(infer): narrow fields of locals --- crates/qbx_lua_ls/README.md | 124 +- crates/qbx_lua_ls/src/features/comparisons.rs | 74 +- crates/qbx_lua_ls/src/features/hover.rs | 8 +- crates/qbx_lua_ls/src/features/nil_checks.rs | 181 +- crates/qbx_lua_ls/src/infer.rs | 302 ++- crates/qbx_lua_ls/src/narrow.rs | 1738 +++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 617 +++++- docs/reference.md | 45 +- docs/rules.md | 2 +- 9 files changed, 2401 insertions(+), 690 deletions(-) diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 61c669d..24417fa 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -150,7 +150,8 @@ They apply to the branches of an `if` or `elseif`, failed ones to the conditions after them, to the code after an `if` whose other branches all end in `return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the right side of `and` and `or`, and to the code after `assert(name)`. A guard narrows what a local holds there, also one that is -[assigned again](#locals-that-are-assigned-again), while globals and fields are not narrowed. +[assigned again](#locals-that-are-assigned-again), and the [fields](#fields) of locals, while +globals are not narrowed. A comparison with a literal narrows the local to that literal: inside `if state == "busy" then`, a `"active"|"busy"|nil` and a `string` are both `"busy"`, and in the `else` branch the first is @@ -171,11 +172,30 @@ A value of no known type takes the kind it is checked for, and so does one whose that kind, since such a check handles values the annotations leave out: inside `if type(count) == "string" then`, an `integer` is a `string`. `math.type(name)` tells an `integer` from a `float`, and `kind == "table"` after `local kind = type(name)` narrows like -`type(name) == "table"`. Only the global `type` counts, also through a local that holds it, as +`type(name) == "table"`, while `name` holds the value it held there and no other function assigns +it. Only the global `type` counts, also through a local that holds it, as after `local type = type`, and not `table.type` of ox_lib. Where one of several checks held, as inside `if type(value) == "string" or type(value) == "number" then`, the local is of the kinds they let through. +Guards that rule out every value a local is declared to hold guard code for values the annotations +leave out, or code that never runs. There the local is what lua-language-server reads it as: + +```lua +---@param entities number[] +local function target(entities) + if type(entities) ~= "table" then + entities = { entities } -- entities is unknown, not a number[], so nothing is reported + end +end +``` + +`type(name) ~= "table"` for a table, and `if name` for a value that is always `nil`, leave it +`unknown`; `not count`, `count == nil` and the `else` of `count ~= nil` make a `number` `nil`; and a +comparison with a literal of a kind the local never has makes it that kind, as `count == false` +makes a `number` a `boolean`. After other comparisons with literals, as +`action ~= "open" and action ~= "close"` for an `"open"|"close"`, it keeps its declared type. + ### Locals that are assigned again A local that is assigned again holds, at each point, the values that may reach it there: that of its @@ -200,14 +220,67 @@ lua-language-server: after `local found` and `if ok then found = item end`, `fou local with a `---@type` or `@param` keeps the parts of that type the value may be, as the `string` of a `string?` for `"x"`, and all of it for a value of another kind, which `assign-type-mismatch` reports, or of no known type. Other locals keep the type of their declaration for such a value. A -loop starts with what the code before it and its runs before leave, and a label with what the +`---@type` above an assignment types the value it gives, as in lua-language-server, so a table +given again to a local declared with a table outside any function is still that local's own table +for the functions that run at other times. A loop starts with what the code before it and its runs before leave, and a label with what the `goto`s to it leave. Calls are taken to change no local, but a function runs any time after it is created. Inside it, a local declared outside it may hold what it held where the function was created, and also what the -code after that and the other functions assign, with nothing known about those values. Where a -function that assigns a local is created, the code around it may find that value from then on, as -after `each(list, function(item) found = item end)`. +code after that and the other functions assign, with nothing known about those values. Once a +function that assigns a local is created, the code around it may find any value that a function +gives the local, as after `each(list, function(item) found = item end)`. A function written as an +argument of a runtime function that runs it later, as `SetTimeout`, `CreateThread`, +`AddEventHandler`, `RegisterNetEvent`, `RegisterCommand`, `RegisterNUICallback`, +`AddStateBagChangeHandler` and `exports` do, gives the locals around it nothing until a call +yields. + +A call that yields lets that code run, as the event handlers of a script do while one of its threads +waits. After one, a local that other code assigns may hold any value that code gives it again, so a +guard before the call no longer tells about it: + +```lua +local status = "pending" +RegisterNetEvent("job:done", function() status = "done" end) + +CreateThread(function() + while status ~= "done" do -- not always true: the handler may run while the thread waits + Wait(0) + end +end) +``` + +The calls that yield are those of `Wait`, `Citizen.Wait`, `Citizen.Await` and `coroutine.yield`, +those of a function read as `await`, as `lib.callback.await` and `MySQL.query.await`, and those of +the functions of the file that make such a call. Other functions that wait, such as `lib.progressBar`, +are taken to change no local. + +### Fields + +A guard on a field of a local, as `self.target`, `data.job.name` or `data["job"]`, narrows it like a +local, which lua-language-server does not, and an assignment to one gives it the part of its +declared type that the value is, as `entry.length = entry.length or 1` leaves a `number` of a +`number?`: + +```lua +---@param data { job: Job? } +local function show(data) + if data.job then + print(data.job.name) -- data.job is a Job + end +end +``` + +Reading through a field tells that it holds a value too, so `data.job.grade` inside +`if data.job.name then` reads from a `Job`. What a guard tells about a field holds until something +may change it: an assignment to the local, or to a field of the same name of any table, as +`other.job = nil` may set the same table through another name; a call that is given the local, a +table the field is read through, a local copied from one of them or a table built with them, or an +assignment to a key of one that is not known, as `reset(data)`, `data:reset()`, `reset(job)` after +`local job = data.job`, `clearAll({ data })` or `data[key] = nil`; a call that yields; and a loop +whose code may do any of these before it starts again. A function does not see what the guards +around it tell about fields, as it runs later. Other calls are taken to change no field, and an +assignment to a field still has to store a value of the type the field is declared with. ### Casts @@ -221,25 +294,36 @@ local data = json.decode(payload) `---@cast name T` makes it a `T`, `+T` adds `T` to it, `-T` takes `T` out, `+?` and `-?` add and take out `nil`, and one line can list several, as in `---@cast value +?, -string`. Adding to a local whose type is unknown leaves a value that may still be anything else, as `string|unknown` for -`+string`, as in lua-language-server. A cast holds to -the end of the block of the code after it, including the functions defined there, and until the -statement that assigns the local again. Unlike lua-language-server, a cast inside an `if` ends with -its branch, except on the last line of the branch: there it holds after the `if`, as it does in -lua-language-server, which is how code types what the branch leaves: +`+string`, as in lua-language-server. + +A cast gives the local a value, as an assignment does, and the value goes where the code goes: it +holds until the local is assigned again, a function created after it starts with it, and where the +ways through an `if`, a loop or a label meet, it joins what the other ways leave: ```lua -if type(translation) == "table" then - translation = translation[1] - ---@cast translation string +---@return string|number +local function get() end + +local value = get() +if math.random() > 0.5 then + ---@cast value string + print(value) -- string +end +print(value) -- string|number: the way that skips the `if` leaves a number too + +do + ---@cast value number end -print(translation) -- string +print(value) -- number: a `do` block always runs ``` -On the last line of another block, such as the body of a function or a loop, a cast holds nowhere. -Casts also apply to locals that are assigned again, and only to locals. A guard that -took effect before a cast tells nothing about the type it gives, while one after it narrows that -type. `+T` and `-T` change the type the guards around their line leave instead, so -`---@cast items +number[]` inside `if items then` keeps out the `nil` of a `string[]?`. +So a cast on the last line of a branch types what that branch leaves, and a branch that ends in +`return` or `error(...)` leaves nothing. lua-language-server instead keeps a cast on the last line +of an `if` branch after the `if`, whatever the other ways leave. Casts apply only to locals, also to +those that are assigned again. A guard that took effect before a cast tells nothing about the type +it gives, while one after it narrows that type. `+T` and `-T` change the type the guards around +their line leave instead, so `---@cast items +number[]` inside `if items then` keeps out the `nil` +of a `string[]?` there. `cast-type-mismatch` reports a cast to a type the declared type of the local does not take, such as `---@cast count string` for an `integer`; see the [reference](../../docs/reference.md#casts). diff --git a/crates/qbx_lua_ls/src/features/comparisons.rs b/crates/qbx_lua_ls/src/features/comparisons.rs index a862da6..bf80958 100644 --- a/crates/qbx_lua_ls/src/features/comparisons.rs +++ b/crates/qbx_lua_ls/src/features/comparisons.rs @@ -21,8 +21,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; use super::class_tables::{Classes, Key}; use super::unknown_types::has_param_line; -use crate::infer::{assigned_value, Decl, Expected, Infer}; -use crate::narrow::Origin; +use crate::infer::{assigned_value, distinct_bases, Bases, Decl, Expected, Infer, SetTypes}; +use crate::narrow::{Origin, DECLARATION}; use crate::types::Type; const MAX_DEPTH: u32 = 16; @@ -54,6 +54,9 @@ pub struct Declared<'a, 'b> { /// assigned again give them, by the local and the origin of the value. origins: RefCell>, in_progress: RefCell>, + /// The declared types of the values that each set of `Origin::Others` stands for, by the local + /// and the origin of the set. + others: RefCell, } impl<'a, 'b> Declared<'a, 'b> { @@ -66,6 +69,7 @@ impl<'a, 'b> Declared<'a, 'b> { declarations: RefCell::default(), origins: RefCell::default(), in_progress: RefCell::default(), + others: RefCell::default(), } } @@ -95,9 +99,9 @@ impl<'a, 'b> Declared<'a, 'b> { Some(Resolved::Local(id)) => self.local(id, name.span.start, depth), _ => self.global(&name.text), }, - ExprKind::Field { base, name, .. } => self.field(base, &name.text, depth), + ExprKind::Field { base, name, .. } => self.narrowed_field(expr, self.field(base, &name.text, depth)), ExprKind::Index { base, index, .. } => match index.as_string() { - Some(name) => self.field(base, name, depth), + Some(name) => self.narrowed_field(expr, self.field(base, name, depth)), None => Type::Unknown, }, ExprKind::Call { .. } | ExprKind::MethodCall { .. } => { @@ -149,7 +153,7 @@ impl<'a, 'b> Declared<'a, 'b> { /// declared leaves the local `unknown`, unless a `---@cast` line types it. A `+T` entry adds to a /// type, so one that is not known stays so. fn local(&self, id: LocalId, offset: u32, depth: u32) -> Type { - self.infer.narrowed_with(id, offset, &|origin| self.origin(id, origin, depth), true) + self.infer.narrowed_with(id, offset, &|origin| self.bases(id, origin, depth), true) } /// The declared type of the local `id` where it is read at `offset`, as `of` gives it for a read @@ -158,21 +162,26 @@ impl<'a, 'b> Declared<'a, 'b> { self.local(id, offset, 0) } - /// The type of `expr`, which reads the local `id`, as `of` gives it from only the values that may - /// reach the read whose origins `keep` picks. - pub fn of_values(&self, expr: &Expr, id: LocalId, keep: &dyn Fn(u32) -> bool) -> Type { - if let Some(cast) = self.cast(expr) { - return cast; - } - let origin = |origin| keep(origin).then(|| self.origin(id, origin, 0)).flatten(); - self.infer.narrowed_with(id, expr.span.start, &origin, true) + /// Whether the guards where the local `id` is read at `offset` rule out `value` for each of the + /// values that may reach the read and be it. + pub fn rules_out(&self, id: LocalId, offset: u32, value: &Type) -> bool { + self.infer.rules_out_with(id, offset, value, &|origin| self.bases(id, origin, 0)) } - /// Whether the guards where the local `id` is read at `offset` rule out `value` for each of the - /// values that may reach the read and be it, of those whose origins `keep` picks. - pub fn rules_out_of(&self, id: LocalId, offset: u32, value: &Type, keep: &dyn Fn(u32) -> bool) -> bool { - let origin = |origin| keep(origin).then(|| self.origin(id, origin, 0)).flatten(); - self.infer.rules_out_with(id, offset, value, &origin) + /// The declared types of the values that `origin` gives the local `id`: that of its value, or for + /// a set of `Origin::Others` those of the values in it, each once. `None` while they are being + /// found. + fn bases(&self, id: LocalId, origin: u32, depth: u32) -> Option { + let flow = self.infer.ctx.flow(); + let Origin::Others(set) = flow.origin(origin) else { return self.origin(id, origin, depth).map(Bases::One) }; + if let Some(types) = self.others.borrow().get(&(id, origin)) { + return Some(Bases::Set(types.clone())); + } + let (types, complete) = distinct_bases(flow.others(set).iter().map(|other| self.origin(id, *other, depth))); + if complete { + self.others.borrow_mut().insert((id, origin), types.clone()); + } + (!types.is_empty()).then_some(Bases::Set(types)) } /// The declared type of the value that `origin` gives the local `id`, or `None` while it is @@ -180,7 +189,7 @@ impl<'a, 'b> Declared<'a, 'b> { /// of the local, or the `---@type` above it. fn origin(&self, id: LocalId, origin: u32, depth: u32) -> Option { let local = self.infer.ctx.resolution.local(id); - if !local.refs.iter().any(|r| r.write) { + if origin == DECLARATION && !local.refs.iter().any(|r| r.write) { return Some(match depth { 0 => self.declaration(id), _ => self.local_declaration(id, depth), @@ -207,12 +216,30 @@ impl<'a, 'b> Declared<'a, 'b> { _ => Type::Unknown, }, Origin::Function { .. } => self.infer.origin_type(id, origin).unwrap_or_default(), + Origin::Cast(cast) => self.cast_value(id, cast, depth), + // Read through `bases`, value by value. + Origin::Others(_) => Type::Unknown, }; self.in_progress.borrow_mut().remove(&key); self.origins.borrow_mut().insert(key, ty.clone()); Some(ty) } + /// The type of the value that the `---@cast` line of number `cast` gives the local `id`: the + /// type it names, or the declared type the local has there with the types it adds or takes out. + /// Adding to a type that is not declared leaves one that is still not known. + fn cast_value(&self, id: LocalId, cast: u32, depth: u32) -> Type { + let Some(cast) = self.infer.ctx.casts().get(cast) else { return Type::Unknown }; + let held = match cast.replaces() { + true => Type::Unknown, + false => match self.local(id, cast.span.start, depth + 1) { + Type::Unknown => return Type::Unknown, + held => held, + }, + }; + cast.entries.iter().fold(held, |ty, (entry, _)| self.infer.cast(ty, entry)) + } + /// The declared type of value `index` of the assignment `stmt` to the local `id`, which gives it /// the value of `origin`. fn assigned(&self, id: LocalId, origin: u32, stmt: &Stmt, index: usize, depth: u32) -> Type { @@ -362,6 +389,15 @@ impl<'a, 'b> Declared<'a, 'b> { } } + /// `ty`, the declared type of the field that `expr` reads, without what the guards around the + /// read rule out. A field with no declared type stays unknown. + fn narrowed_field(&self, expr: &Expr, ty: Type) -> Type { + match ty.is_unknown() { + true => ty, + false => self.infer.field_narrowed(expr, ty), + } + } + /// The type that the `@field`s of a class give `name` of `base`. Fields of other tables are /// inferred from what is assigned to them. fn field(&self, base: &Expr, name: &str, depth: u32) -> Type { diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 27539c3..1b71590 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -14,7 +14,7 @@ use crate::index::{ }; use crate::indexer::{described_values, render_doc}; use crate::infer::{Decl, Infer, MemberInfo}; -use crate::locate::locate; +use crate::locate::{locate, MemberAccess}; use crate::luacats::{applies_on, own_type, type_name_at}; use crate::types::{CallbackRole, DescribedValue, Type}; use crate::workspace::Workspace; @@ -62,7 +62,11 @@ pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { if let Some(access) = located.member { let owner = infer.expr(access.base()); let name = access.name(&doc.text)?; - let info = infer.member(&owner, &name.text)?; + let mut info = infer.member(&owner, &name.text)?; + // What the guards around a field of a local leave of it, as `self.target` inside `if self.target then`. + if !matches!(access, MemberAccess::Method { .. }) { + info.ty = infer.member_narrowed(access.base(), &name.text, info.ty); + } return Some(Target::Member { info, owner, span: name.span }); } if let Some((func_name, segment)) = located.func_name.filter(|(_, segment)| *segment > 0) { diff --git a/crates/qbx_lua_ls/src/features/nil_checks.rs b/crates/qbx_lua_ls/src/features/nil_checks.rs index 2e4a32b..4cffebc 100644 --- a/crates/qbx_lua_ls/src/features/nil_checks.rs +++ b/crates/qbx_lua_ls/src/features/nil_checks.rs @@ -10,31 +10,31 @@ //! As for `impossible-comparison`, only declared types count, whatever declares them: an //! annotation of the local, or the `@return` or `@field` of a function, class or stub. The type is //! that of the values that may reach the read, as the guards and casts around it leave them, also -//! for a local that is assigned again. Guards that leave no value of it keep it whole, but a `nil` or -//! `false` that they rule out stays out: `round` is no `nil` in -//! `round and (round == true or i < round)`, whatever else it holds. The rest is left alone: +//! for a local that is assigned again. Where guards leave no value of it, the read has the type the +//! code they guard takes it to be, as lua-language-server reads it: `nil` inside `if not count then` +//! for a `number`, which holds no value that may be missing, or the whole type after a comparison +//! with a literal, but a `nil` or `false` that they rule out stays out: `round` is no `nil` in +//! `round and (round == true or i < round)`, whatever else it holds. //! -//! - Fields and the values of calls, which guards do not narrow. +//! Like TypeScript for a value that may be `undefined`, every read is reported, also after one that +//! would raise the error first, and only a guard on the local itself checks it: one on another value +//! of the same call tells nothing about it, unless the function declares the sets of values it +//! returns, as `@return nil | (integer, vector3)` does. The rest is left alone: +//! +//! - Fields, as in lua-language-server, and the values of calls. A local that takes the value of a +//! field has the type that the guards on the field leave. //! - A value whose type is not declared, as `name = name or 'none'` gives, and the missing value of //! `local name` before something gives it one. -//! - A value that a guard on another value of the same call covers, as `err` of -//! `local fn, err = load(code)` after `if not fn then`: annotations declare such values one by one. -//! - What a call gives for `source` alone, as `ESX.GetPlayerFromId(source)`: lookups of the player -//! whose event or callback runs only miss one that has just left. -//! - Reads after one that always runs before them, which raises the error first, and reads after -//! one reported in the same function, which points at the same missing check. -use qbx_lua_analysis::scope::{FuncId, LocalId, LocalKind, Resolved}; +use qbx_lua_analysis::scope::{LocalKind, Resolved}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; -use rustc_hash::FxHashSet; use super::arguments::{candidates, expected, mismatch, param_for, signatures, takes_nil}; use super::class_tables::Classes; use super::comparisons::Declared; -use crate::infer::{Decl, Infer}; -use crate::narrow::Version; +use crate::infer::Infer; use crate::types::{FunType, Param, Type}; /// What `need-check-nil` finds in a file, each with the message naming the type. @@ -47,16 +47,7 @@ pub struct UncheckedNils { /// The reads and arguments of locals that may hold a missing value there. pub fn unchecked_nils(infer: &Infer, chunk: &Chunk) -> UncheckedNils { - let mut finder = Finder { - infer, - declared: Declared::new(infer), - checked: Vec::new(), - sometimes: 0, - until: chunk.block.span.end, - reported: FxHashSet::default(), - out: Vec::new(), - passed: Vec::new(), - }; + let mut finder = Finder { infer, declared: Declared::new(infer), out: Vec::new(), passed: Vec::new() }; finder.visit_block(&chunk.block); UncheckedNils { reads: finder.out, arguments: finder.passed } } @@ -64,17 +55,6 @@ pub fn unchecked_nils(infer: &Infer, chunk: &Chunk) -> UncheckedNils { struct Finder<'a, 'b> { infer: &'a Infer<'b>, declared: Declared<'a, 'b>, - /// The locals that a reported read showed to hold a value, each with the code that runs after it. - checked: Vec<(LocalId, Span)>, - /// How many of the parts around the expression being visited only run sometimes: the right - /// sides of `and` and `or`, the conditions of `elseif`, and that of a `repeat` whose body can - /// `break` out of it. - sometimes: u32, - /// Where the code that a read in the statement being visited runs before ends: at the end of - /// its block, or at a label a `goto` can reach without the read. - until: u32, - /// The locals reported so far, each with the function the read is in. - reported: FxHashSet<(LocalId, FuncId)>, out: Vec<(Span, String)>, /// The arguments reported, apart from the reads. passed: Vec<(Span, String)>, @@ -92,46 +72,15 @@ impl Finder<'_, '_> { let ExprKind::Name(name) = &operand.unparen().kind else { return }; let ctx = self.infer.ctx; let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(name.span.start) else { return }; - let local = ctx.resolution.local(id); - let at = name.span.start; // `self` is the value a method is called on. - if local.kind == LocalKind::ImplicitSelf { - return; - } - if self.checked.iter().any(|(checked, span)| *checked == id && span.contains(at)) { + if ctx.resolution.local(id).kind == LocalKind::ImplicitSelf { return; } - // `local vehicle, coords = lib.getClosestVehicle(...)` declares both as optional, and the - // guard on `vehicle` is the check for `coords` as well, while it holds that value. - let flow = ctx.flow(); - let covered = |value: &&Version| { - let linked = flow.linked(id, value.origin).unwrap_or_default(); - linked - .iter() - .any(|(other, origin, _)| *other != id && flow.facts(*other, *origin, Span::empty(at)).next().is_some()) - }; - let values = flow.at(id, at); - let covered: Vec = values.iter().filter(covered).map(|value| value.origin).collect(); - if covered.len() == values.len() { - return; - } - let keep = |origin: u32| !covered.contains(&origin); - let ty = self.declared.of_values(operand.unparen(), id, &keep); - let reaches = |value: &Type| !self.declared.rules_out_of(id, at, value, &keep); + let at = name.span.start; + let ty = self.declared.of(operand.unparen()); + let reaches = |value: &Type| !self.declared.rules_out(id, at, value); let missing = missing_value(&self.infer.expand_aliases(&ty, 0), reaches); let Some(missing) = missing.filter(|missing| param.is_none() || *missing == "nil") else { return }; - let assigned = local.refs.iter().any(|r| r.write); - if !assigned && self.is_lookup_of_source(id) { - return; - } - let func = local.refs.iter().find(|r| r.span.start == at).map_or(local.func, |r| r.func); - if !self.reported.insert((id, func)) { - return; - } - // A call passes the missing value on, which raises no error there. - if self.sometimes == 0 && !assigned && param.is_none() { - self.checked.push((id, Span::new(name.span.end, self.until.max(name.span.end)))); - } match param { Some(param) => self.passed.push(( name.span, @@ -181,44 +130,6 @@ impl Finder<'_, '_> { matches!(self.infer.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Local(_))) && missing_value(&self.infer.expand_aliases(&self.declared.of(arg), 0), |_| true) == Some("nil") } - - fn sometimes(&mut self, visit: impl FnOnce(&mut Self)) { - self.sometimes += 1; - visit(self); - self.sometimes -= 1; - } - - /// Whether `id` holds what a call gives for `source` alone, as after - /// `local player = exports.qbx_core:GetPlayer(src)` with `local src = source`. - fn is_lookup_of_source(&self, id: LocalId) -> bool { - let Some(value) = self.value_of(id) else { return false }; - match &value.unparen().kind { - ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. } => { - matches!(args.as_slice(), [arg] if self.is_source(arg)) - } - _ => false, - } - } - - /// Whether `expr` is `source`, or a local that is never assigned again and holds it. - fn is_source(&self, expr: &Expr) -> bool { - let ExprKind::Name(name) = &expr.unparen().kind else { return false }; - if name.text == "source" { - return true; - } - let ctx = self.infer.ctx; - let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(name.span.start) else { return false }; - !ctx.resolution.local(id).refs.iter().any(|r| r.write) && self.value_of(id).is_some_and(|v| self.is_source(v)) - } - - /// The value that the `local` statement declaring `id` gives it: the expression in its - /// position, or the call that ends the list. - fn value_of(&self, id: LocalId) -> Option<&Expr> { - let ctx = self.infer.ctx; - let Some(Decl::Local { stmt, index }) = ctx.decl(ctx.resolution.local(id).decl.start) else { return None }; - let StmtKind::Local { exprs, in_unpack: false, .. } = &stmt.kind else { return None }; - exprs.get(*index).or_else(|| exprs.last().filter(|last| last.is_call())) - } } /// What `ty` allows that raises the error and `reaches` the read: `nil`, or else `false`. `None` when @@ -265,30 +176,7 @@ fn needs_value(op: BinOp) -> bool { ) } -/// Whether `block`, the body of a loop, has a `break` that leaves that loop. -fn breaks(block: &Block) -> bool { - block.stmts.iter().any(|stmt| match &stmt.kind { - StmtKind::Break => true, - StmtKind::Do(body) => breaks(body), - StmtKind::If { branches, else_block } => { - branches.iter().any(|branch| breaks(&branch.block)) || else_block.as_ref().is_some_and(breaks) - } - _ => false, - }) -} - impl<'c> Visitor<'c> for Finder<'_, '_> { - fn visit_block(&mut self, block: &'c Block) { - // A block, a function body included, runs apart from the statement around it. - let outer = (std::mem::take(&mut self.sometimes), self.until); - for (index, stmt) in block.stmts.iter().enumerate() { - let label = block.stmts[index + 1..].iter().find(|next| matches!(next.kind, StmtKind::Label(_))); - self.until = label.map_or(block.span.end, |label| label.span.start); - self.visit_stmt(stmt); - } - (self.sometimes, self.until) = outer; - } - fn visit_stmt(&mut self, stmt: &'c Stmt) { match &stmt.kind { // Storing a value under a `nil` key raises an error, while reading one gives `nil`. @@ -305,31 +193,6 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { self.check(bound); } } - // Only the first condition of an `if` always runs. - StmtKind::If { branches, else_block } => { - for (index, branch) in branches.iter().enumerate() { - if index == 0 { - self.visit_expr(&branch.cond); - } else { - self.sometimes(|finder| finder.visit_expr(&branch.cond)); - } - self.visit_block(&branch.block); - } - if let Some(block) = else_block { - self.visit_block(block); - } - return; - } - // A `break` leaves the loop without running the condition. - StmtKind::Repeat { body, cond } => { - self.visit_block(body); - if breaks(body) { - self.sometimes(|finder| finder.visit_expr(cond)); - } else { - self.visit_expr(cond); - } - return; - } _ => {} } visit::walk_stmt(self, stmt); @@ -347,12 +210,6 @@ impl<'c> Visitor<'c> for Finder<'_, '_> { self.check(lhs); self.check(rhs); } - // The right side of `and` and `or` only runs for some values of the left one. - ExprKind::Binary { op: BinOp::And | BinOp::Or, lhs, rhs, .. } => { - self.visit_expr(lhs); - self.sometimes(|finder| finder.visit_expr(rhs)); - return; - } _ => {} } visit::walk_expr(self, expr); diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 09e9d0d..c1153c6 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -198,7 +198,7 @@ impl<'a> FileContext<'a> { /// Which values the locals of the file may hold at each point, and what the conditions tell about /// them. pub fn flow(&self) -> &Flow<'a> { - self.flow.get_or_init(|| Flow::of(self.chunk, self.resolution)) + self.flow.get_or_init(|| Flow::of(self.chunk, self.resolution, self.casts())) } /// The `---@cast` lines of the file. @@ -522,6 +522,12 @@ pub struct Infer<'a> { /// local and the origin of the value. origins: RefCell>, origins_in_progress: RefCell>, + /// The types of the values that each set of `Origin::Others` of a local stands for, each once, + /// by the local and the origin of the set. + others: RefCell, + /// The types of the values that assignments store in the fields of locals, by their origin. + stored: RefCell>, + storing: RefCell>, /// The sets of values returned by the call of each `local a, b = f()` statement read so far, /// by the start of the statement. linked_sets: RefCell>>, @@ -578,6 +584,9 @@ impl<'a> Infer<'a> { in_progress: RefCell::new(FxHashSet::default()), origins: RefCell::new(FxHashMap::default()), origins_in_progress: RefCell::new(FxHashSet::default()), + others: RefCell::new(FxHashMap::default()), + stored: RefCell::new(FxHashMap::default()), + storing: RefCell::new(FxHashSet::default()), linked_sets: RefCell::new(FxHashMap::default()), fallbacks: RefCell::new(FxHashMap::default()), following: RefCell::new(FxHashSet::default()), @@ -709,8 +718,8 @@ impl<'a> Infer<'a> { ExprKind::Function(func) => Type::Fun(Arc::new(self.fun_type(func, None, false))), ExprKind::Name(name) => self.name(name), ExprKind::Paren(inner) => self.expr(inner), - ExprKind::Field { base, name, .. } => self.member_type(base, name), - ExprKind::Index { base, index, .. } => self.index_expr(base, index), + ExprKind::Field { base, name, .. } => self.field_narrowed(expr, self.member_type(base, name)), + ExprKind::Index { base, index, .. } => self.field_narrowed(expr, self.index_expr(base, index)), ExprKind::Table(fields) => self.table(fields), ExprKind::Binary { op, lhs, rhs, .. } => self.binary(*op, lhs, rhs), ExprKind::Unary { op, expr } => self.unary(*op, expr), @@ -766,17 +775,31 @@ impl<'a> Infer<'a> { return self.with_metatables(id, offset, given); } } - let origin = |origin| self.origin_type(id, origin).map(|ty| self.with_metatables(id, offset, ty)); + let origin = |origin| self.origin_bases(id, origin).map(|bases| self.with_metatables_all(id, offset, bases)); self.narrowed_with(id, offset, &origin, false) } /// The type of a local where it is read at `offset`, as `local_type_at` gives it, or `unknown` /// when a value of unknown type may reach the read. pub fn known_local_type_at(&self, id: LocalId, offset: u32) -> Type { - let origin = |origin| self.origin_type(id, origin).map(|ty| self.with_metatables(id, offset, ty)); + let origin = |origin| self.origin_bases(id, origin).map(|bases| self.with_metatables_all(id, offset, bases)); self.narrowed_with(id, offset, &origin, true) } + /// `bases`, the types of values of the local `id`, with what the `setmetatable` calls on the local + /// make of them where it is read at `offset`. + fn with_metatables_all(&self, id: LocalId, offset: u32, bases: Bases) -> Bases { + if !self.ctx.set_metatables.contains_key(&id) { + return bases; + } + match bases { + Bases::One(ty) => Bases::One(self.with_metatables(id, offset, ty)), + Bases::Set(types) => { + Bases::Set(types.iter().map(|ty| self.with_metatables(id, offset, ty.clone())).collect()) + } + } + } + /// `ty`, the type of the local `id`, with what the `setmetatable` calls on the local make of it /// where it is read at `offset`: `setmetatable(obj, Class)` makes `obj` an instance of `Class`. /// For a table class that holds before the call too, so the fields a constructor sets on `obj` @@ -820,105 +843,167 @@ impl<'a> Infer<'a> { /// The type of the local `id` where it is read at `offset`: the union of the values that may /// reach the read, each of the type `origin` gives for where it comes from (`None` while that is - /// being found, as for a loop that assigns the local from what it held before), as the casts - /// before the read change it and without what the guards since then rule out. A value that the - /// guards rule out entirely is left out, unless they rule out every value: like `if not name` - /// for a `string`, or `action ~= "open" and action ~= "close"` for an `"open"|"close"`, such code - /// handles values the annotations leave out, or never runs. With `strict`, a value of unknown - /// type leaves the local unknown, unless a cast replaces its type. - pub fn narrowed_with(&self, id: LocalId, offset: u32, origin: &dyn Fn(u32) -> Option, strict: bool) -> Type { - let casts: Vec<&Cast> = self.ctx.casts().at(id, offset).collect(); - let replaced = casts.iter().any(|cast| replaces(cast)); + /// being found, as for a loop that assigns the local from what it held before), its declaration, + /// an assignment or a `---@cast` line, without what the guards since then rule out. A value that the + /// guards rule out entirely is left out. When they rule out every value, the code they guard + /// handles values the annotations leave out, or never runs, and it takes the local to be what + /// `Fact::ruled_out` reads from the guard that rules out the last of them, as lua-language-server + /// does: `nil` inside `if not name` for a `string`, `unknown` inside `if type(list) ~= 'table'` + /// for a `number[]`. After a comparison with a literal, as `action ~= "open" and action ~= "close"` + /// for an `"open"|"close"`, it stays whole. With `strict`, a value of unknown type leaves the + /// local unknown. + pub fn narrowed_with(&self, id: LocalId, offset: u32, origin: &dyn Fn(u32) -> Option, strict: bool) -> Type { let values = self.ctx.flow().at(id, offset); // `local name` declares no value that the values assigned later have to make room for. let unset = values.len() > 1 && self.is_unset(id); - let (mut whole, mut kept) = (Vec::new(), Vec::new()); + let (mut whole, mut kept, mut taken) = (Vec::new(), Vec::new(), Some(Vec::new())); for value in values.iter().filter(|value| !(unset && value.origin == DECLARATION)) { - let Some(base) = origin(value.origin) else { continue }; - if strict && !replaced && base.is_unknown() { - return Type::Unknown; + let Some(bases) = origin(value.origin) else { continue }; + for base in bases.iter() { + if strict && base.is_unknown() { + return Type::Unknown; + } + let (ty, left) = self.version_type(id, offset, value, base.clone()); + match left { + Ok(left) => kept.push(left), + Err(Some(as_is)) => taken.iter_mut().for_each(|taken| taken.push(as_is.clone())), + Err(None) => taken = None, + } + whole.push(ty); } - let (ty, narrowed) = self.version_type(id, offset, value, base, &casts); - kept.extend(narrowed); - whole.push(ty); } - merge_versions(if kept.is_empty() { whole } else { kept }) + let types = match (kept.is_empty(), taken) { + (false, _) => kept, + (true, Some(taken)) if !taken.is_empty() => taken, + _ => whole, + }; + merge_versions(types) } /// What `value` of the local `id` is where it is read at `offset`: `base`, the type of where it - /// comes from, as `casts` change it, and that without what the guards after the last cast rule - /// out, or `None` when they rule out all of it. A guard that takes effect before a cast tells - /// nothing about the type the cast gives, but `+T` and `-T` change the type that the guards - /// around their line leave, as `---@cast items +number[]` inside `if items then` does, for as - /// long as those guards hold. + /// comes from, and what `facts_left` leaves of that after the guards since. fn version_type( &self, id: LocalId, offset: u32, value: &Version, base: Type, - casts: &[&Cast], - ) -> (Type, Option) { - let facts = |from: u32, to: u32| { - value.facts.iter().filter(move |(at, _)| from <= *at && *at <= to).map(|(_, fact)| fact) - }; + ) -> (Type, Result>) { let mut ty = base; - if value.origin == DECLARATION && !casts.first().is_some_and(|cast| replaces(cast)) { - let code = casts.first().map_or(Span::empty(offset), |cast| Span::new(cast.span.start, offset)); - ty = self.linked_type(id, code).unwrap_or(ty); - } - let mut since = 0; - for cast in casts { - if !replaces(cast) { - ty = self.facts_applied(&ty, facts(since, cast.span.start)).unwrap_or(ty); - } - ty = cast.entries.iter().fold(ty, |ty, (entry, _)| self.cast(ty, entry)); - since = cast.span.start; + if value.origin == DECLARATION { + ty = self.linked_type(id, Span::empty(offset)).unwrap_or(ty); } - let narrowed = self.facts_applied(&ty, facts(since, u32::MAX)); - (ty, narrowed) + let left = self.facts_left(&ty, value.facts.iter().map(|(_, fact)| fact)); + (ty, left) } - /// `ty` without what `facts` rule out, or `None` when they rule out every value of it. An alias - /// such as `Name = string|nil` is narrowed through what it stands for, and a type the facts leave - /// whole stays as it is written. - fn facts_applied<'f>(&self, ty: &Type, facts: impl Iterator) -> Option { + /// `ty` without what `facts` rule out, or when they rule out every value of it, the type that the + /// code they guard takes the value to be then, as `Fact::ruled_out` reads it from the guard that + /// rules out the last of them, if it gives one. An alias such as `Name = string|nil` is narrowed + /// through what it stands for, and a type the facts leave whole stays as it is written. + fn facts_left<'f>(&self, ty: &Type, facts: impl Iterator) -> Result> { let mut facts = facts.peekable(); if facts.peek().is_none() { - return Some(ty.clone()); + return Ok(ty.clone()); } let declared = self.expand_aliases(ty, 0); - match facts.try_fold(declared.clone(), |narrowed, fact| fact.assume(&narrowed))? { - narrowed if narrowed != declared => Some(narrowed), - _ => Some(ty.clone()), + let mut narrowed = declared.clone(); + while let Some(fact) = facts.next() { + match fact.assume(&narrowed) { + Some(left) => narrowed = left, + // The guards after it tell about what the code takes the value to be. + None => { + let taken = fact.ruled_out(&narrowed); + return Err(taken.and_then(|taken| facts.try_fold(taken, |ty, fact| fact.assume(&ty)))); + } + } } + Ok(if narrowed != declared { narrowed } else { ty.clone() }) + } + + /// `ty`, the type of the field of a local that `expr` reads, as `self.target` or `data.job`, + /// with what the assignments to it and the guards around the read tell. + pub fn field_narrowed(&self, expr: &Expr, ty: Type) -> Type { + let flow = self.ctx.flow(); + let Some(field) = flow.path(self.ctx.resolution, expr) else { return ty }; + self.path_narrowed(field, expr.span.start, ty) + } + + /// `ty`, the type of the field `key` read from `base`, as `field_narrowed` gives it. + pub fn member_narrowed(&self, base: &Expr, key: &str, ty: Type) -> Type { + let flow = self.ctx.flow(); + let Some(field) = flow.member_path(self.ctx.resolution, base, key) else { return ty }; + self.path_narrowed(field, base.span.start, ty) + } + + /// `ty`, the type of the field tracked as `field`, where it is read at `offset`: for each value + /// that may reach the read, `ty`, or the part of it that the assignment which gives the value + /// stores, without what the guards since rule out. + fn path_narrowed(&self, field: LocalId, offset: u32, ty: Type) -> Type { + let values = self.ctx.flow().at(field, offset); + let types = values.iter().map(|value| { + let base = match value.origin { + DECLARATION => ty.clone(), + origin => self.bounded(&ty, &self.stored_value(origin)), + }; + let facts = value.facts.iter().filter(|(at, _)| *at <= offset).map(|(_, fact)| fact); + match self.facts_left(&base, facts) { + Ok(left) | Err(Some(left)) => left, + Err(None) => base, + } + }); + merge_versions(types.collect()) + } + + /// The type of the value that the assignment of `origin` stores in a field, or `unknown` while + /// it is being found, as in `data.count = data.count + 1` in a loop. + fn stored_value(&self, origin: u32) -> Type { + if let Some(ty) = self.stored.borrow().get(&origin) { + return ty.clone(); + } + let Origin::Assignment { stmt, index } = self.ctx.flow().origin(origin) else { return Type::Unknown }; + let StmtKind::Assign { exprs, .. } = &stmt.kind else { return Type::Unknown }; + if !self.storing.borrow_mut().insert(origin) { + return Type::Unknown; + } + let ty = assigned_value(exprs, index, |expr| self.expr(expr), |expr| self.expr_multi(expr)); + self.storing.borrow_mut().remove(&origin); + self.stored.borrow_mut().insert(origin, ty.clone()); + ty } - /// Whether the guards after the last cast that holds where `id` is read at `offset` rule out - /// `value` for each value of the local that may be it, `origin` giving the types as for - /// `narrowed_with`, also where they rule out every value of its type, which `narrowed_with` - /// keeps whole then: `round` is no `nil` in `round and (round == true or i < round)`, whatever - /// else it holds. - pub fn rules_out_with(&self, id: LocalId, offset: u32, value: &Type, origin: &dyn Fn(u32) -> Option) -> bool { - let casts: Vec<&Cast> = self.ctx.casts().at(id, offset).collect(); - let since = casts.last().map_or(0, |cast| cast.span.start); + /// Whether the guards where `id` is read at `offset` rule out `value` for each value of the local + /// that may be it, `origin` giving the types as for + /// `narrowed_with`, also where they rule out every value of its type and `narrowed_with` keeps + /// it whole: `round` is no `nil` in `round and (round == true or i < round)`, whatever else it + /// holds. + pub fn rules_out_with( + &self, + id: LocalId, + offset: u32, + value: &Type, + origin: &dyn Fn(u32) -> Option, + ) -> bool { let holds = |ty: &Type| match self.expand_aliases(ty, 0) { Type::Union(parts) => parts.contains(value), one => one == *value, }; self.ctx.flow().at(id, offset).iter().all(|version| { - let Some(base) = origin(version.origin) else { return true }; - let (ty, narrowed) = self.version_type(id, offset, version, base, &casts); - if !holds(&ty) && !narrowed.as_ref().is_some_and(holds) { + let Some(bases) = origin(version.origin) else { return true }; + let may_be = |base: &Type| { + let (ty, left) = self.version_type(id, offset, version, base.clone()); + holds(&ty) || matches!(&left, Ok(left) | Err(Some(left)) if holds(left)) + }; + if !bases.iter().any(may_be) { return true; } - let mut facts = version.facts.iter().filter(|(at, _)| *at >= since).map(|(_, fact)| fact); + let mut facts = version.facts.iter().map(|(_, fact)| fact); facts.try_fold(value.clone(), |left, fact| fact.assume(&left)).is_none_or(|left| left != *value) }) } /// `ty` as one entry of a `---@cast` line changes it. - fn cast(&self, ty: Type, entry: &CastEntry) -> Type { + pub fn cast(&self, ty: Type, entry: &CastEntry) -> Type { match entry { CastEntry::Replace(cast) => cast.clone(), // Adding to a type nothing tells leaves a value that may still be anything else, as @@ -1155,9 +1240,60 @@ impl<'a> Infer<'a> { } _ => Type::Unknown, }, + // A `---@cast` line gives the type it names, or adds to the type the local has there or + // takes from it. + Origin::Cast(cast) => { + let Some(cast) = self.ctx.casts().get(cast) else { return Type::Unknown }; + let held = match replaces(cast) { + true => Type::Unknown, + false => self.local_type_at(id, cast.span.start), + }; + cast.entries.iter().fold(held, |ty, (entry, _)| self.cast(ty, entry)) + } + Origin::Others(_) => match self.origin_bases(id, origin) { + Some(bases) => merge_versions(bases.iter().cloned().collect()), + None => Type::Unknown, + }, } } + /// The types of the values that `origin` gives the local `id`: that of its value, or for a set + /// of `Origin::Others` those of the values in it, each once. `None` while they are being found. + pub fn origin_bases(&self, id: LocalId, origin: u32) -> Option { + let flow = self.ctx.flow(); + let Origin::Others(set) = flow.origin(origin) else { return self.origin_type(id, origin).map(Bases::One) }; + if let Some(types) = self.others.borrow().get(&(id, origin)) { + return Some(Bases::Set(types.clone())); + } + let given = flow.others(set).iter().map(|origin| self.other_origin_type(id, *origin)); + let (types, complete) = distinct_bases(given); + if complete { + self.others.borrow_mut().insert((id, origin), types.clone()); + } + (!types.is_empty()).then_some(Bases::Set(types)) + } + + /// The type of the value that `origin` gives the local `id` as code that runs at other times sees + /// it, as `origin_type` finds it. A table constructor that a `---@type` line gives again to a + /// local declared outside any function with one is that local's own table there: the line types + /// the table where it is given, as lua-language-server reads it, while code elsewhere may fill + /// the local's table with other values. + fn other_origin_type(&self, id: LocalId, origin: u32) -> Option { + if let Origin::Assignment { stmt, index } = self.ctx.flow().origin(origin) { + let doc = self.ctx.doc_at(stmt.span.start); + let typed = doc.type_at(index).is_some() && doc.declared_class().is_none(); + if let StmtKind::Assign { exprs, .. } = &stmt.kind { + if typed && exprs.get(index).and_then(table_fields).is_some() { + let declared = self.declaration_type(id); + if self.is_own_table(id, &declared) { + return Some(declared); + } + } + } + } + self.origin_type(id, origin) + } + /// The type that the declaration of the local `id` gives it. One that is assigned again keeps the /// literals that a call or another variable gives it, as the values given later have types of /// their own, and without a value it holds `nil` until it is given one. @@ -3804,6 +3940,40 @@ pub fn assigned_value( } } +/// The types of the values that each set of `Origin::Others` of a local stands for, each once, by +/// the local and the origin of the set. +pub type SetTypes = FxHashMap<(LocalId, u32), Rc<[Type]>>; + +/// The types of the values that one of the values a local may hold stands for: that of where it +/// comes from, or for a set of `Origin::Others` those of the values in it, each once. +pub enum Bases { + One(Type), + Set(Rc<[Type]>), +} + +impl Bases { + pub fn iter(&self) -> std::slice::Iter<'_, Type> { + match self { + Bases::One(ty) => std::slice::from_ref(ty).iter(), + Bases::Set(types) => types.iter(), + } + } +} + +/// The types that `given` finds, each once, and whether it found each, rather than leaving out one +/// that is being found. +pub fn distinct_bases(given: impl Iterator>) -> (Rc<[Type]>, bool) { + let (mut types, mut complete) = (Vec::new(), true); + for ty in given { + match ty { + Some(ty) if !types.contains(&ty) => types.push(ty), + Some(_) => {} + None => complete = false, + } + } + (types.into(), complete) +} + /// The union of the types of the values a local may hold. A literal gives way to its kind when /// another value is of that kind, so `"active"|"busy"` and a `string` assigned later make a `string`, /// and an `integer` gives way to a `number`. diff --git a/crates/qbx_lua_ls/src/narrow.rs b/crates/qbx_lua_ls/src/narrow.rs index cfcb780..65bc789 100644 --- a/crates/qbx_lua_ls/src/narrow.rs +++ b/crates/qbx_lua_ls/src/narrow.rs @@ -4,13 +4,14 @@ //! block, `if name then ... end` for the branch, and `name = name or 'none'` gives it a new one. //! `---@cast` lines change the type of a local from their line on. +use std::cell::RefCell; use std::rc::Rc; use qbx_lua_analysis::scope::{FuncId, LocalId, Resolution, Resolved, MAIN_CHUNK}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr, Span, Token, TokenKind}; -use rustc_hash::FxHashMap; +use qbx_lua_syntax::{CommentKind, NumberValue, SmolStr, Span, Token}; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::luacats::{parse_cast, CastEntry}; use crate::types::Type; @@ -67,6 +68,28 @@ impl Fact { self.apply(ty).or_else(|| self.kind_type()) } + /// What the code the fact guards takes a value of `ty` to be when the fact holds for none of + /// them, as lua-language-server reads such code: `unknown` after `type(x) ~= 'table'` for a + /// table, or after `if x` for a value that is always `nil`; `nil` after `not x` or `x == nil` + /// for one that never is; and the kind of the literal after `x == 5` for a value that is never a + /// number. `None` keeps the type whole, as after `x ~= 'a'` for an `'a'`, or `x == 'c'` for an + /// `'a'|'b'`. + pub fn ruled_out(&self, ty: &Type) -> Option { + match self { + Fact::NotKind(_) | Fact::Truthy => Some(Type::Unknown), + Fact::Falsy | Fact::Is(Type::Nil) => Some(Type::Nil), + Fact::Is(value) => { + let kind = type_name(value)?; + let parts = match ty { + Type::Union(parts) => &parts[..], + one => std::slice::from_ref(one), + }; + (!parts.iter().any(|part| type_name(part) == Some(kind))).then(|| value.widen()) + } + _ => None, + } + } + /// The type of the values a `type` check, or each of several, lets through. fn kind_type(&self) -> Option { match self { @@ -173,6 +196,8 @@ type Facts = Vec<(LocalId, Fact)>; pub const DECLARATION: u32 = 0; /// How many times a loop is walked at most to find what its locals hold at its start. const MAX_ROUNDS: usize = 4; +/// How many keys a field that guards narrow is read through at most, as the two of `data.job.name`. +const MAX_KEYS: usize = 4; /// Where a value of a local comes from. #[derive(Clone, Copy, Debug)] @@ -186,6 +211,12 @@ pub enum Origin<'a> { Compound { stmt: &'a Stmt }, /// `function name() end` for a local `name`. Function { stmt: &'a Stmt }, + /// Any of the values that the assignments of a set give, as `Flow::others` lists their origins: + /// what code that runs at other times may have put in the local. + Others(u32), + /// The `---@cast` line of this number among `Casts`, which gives the local a value of the type + /// it names, or of the type the local has there with the types it adds or takes out. + Cast(u32), } /// One of the values a local may hold at a point of the code. @@ -193,28 +224,105 @@ pub enum Origin<'a> { pub struct Version { /// Where it comes from, as an index into the origins of the file. pub origin: u32, - /// What the guards since then tell about it, each with where it starts to hold. - pub facts: Rc<[(u32, Fact)]>, + /// What the guards since then tell about it. + pub facts: FactList, +} + +/// What the guards tell about a value, each with where it starts to hold. The list shares what it +/// held before each guard added to it, so a guard adds in constant time and the ways that meet share +/// what they hold in common. +#[derive(Clone, Debug, Default)] +pub struct FactList(Option>); + +#[derive(Debug)] +struct FactNode { + at: u32, + fact: Fact, + /// How many facts the list holds with this one. + len: usize, + before: FactList, +} + +impl FactList { + /// The list with `fact` added, holding from `at` on. + fn with(&self, at: u32, fact: Fact) -> Self { + FactList(Some(Rc::new(FactNode { at, fact, len: self.len() + 1, before: self.clone() }))) + } + + pub fn len(&self) -> usize { + self.0.as_ref().map_or(0, |node| node.len) + } + + pub fn is_empty(&self) -> bool { + self.0.is_none() + } + + /// Whether `other` is this very list. + fn is(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Some(a), Some(b)) => Rc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + + /// The list without the fact added last. + fn before(&self) -> &Self { + self.0.as_ref().map_or(self, |node| &node.before) + } + + /// The facts, the one added last first. + fn latest(&self) -> impl Iterator { + std::iter::successors(self.0.as_deref(), |node| node.before.0.as_deref()) + } + + /// The facts in the order they were added, each with where it starts to hold. + pub fn iter(&self) -> impl Iterator { + let mut nodes: Vec<&FactNode> = self.latest().collect(); + nodes.reverse(); + nodes.into_iter().map(|node| (node.at, &node.fact)) + } +} + +impl PartialEq for FactList { + fn eq(&self, other: &Self) -> bool { + self.is(other) + || (self.len() == other.len() + && self.latest().zip(other.latest()).all(|(a, b)| a.at == b.at && a.fact == b.fact)) + } } /// The values a local may hold. type Values = Rc<[Version]>; -/// What the locals may hold at a point, for those that may hold more than the value of their -/// declaration with nothing known about it. -type State = FxHashMap; +/// The keys that a field of a local is read through, as `job` and `name` for `data.job.name`. +type Keys = Box<[SmolStr]>; /// Which of the values given to the locals of a file may reach each point of its code, and what the /// guards on the way tell about them. A value reaches the code after the statement that assigns it, /// branches join where they meet, and a loop starts with what its runs before leave. Calls are /// taken to change no local, but a function runs any time after it is created: inside it, a local -/// may also hold what the code after its creation and the other functions assign, and the code -/// that creates it may find what it assigns from there on. +/// may also hold what the code after its creation and the other functions assign, and once a +/// function that assigns it is created, the code around may find what any function gives it, +/// unless a runtime function that runs it later, as `SetTimeout` does, is given it. A call +/// that yields, as `Wait(0)` does, lets that code run too, so after one the locals it may assign +/// hold any value it gives them again. +/// +/// The fields of locals that guards test or assignments set, as `self.target` or `data.job.name`, +/// are narrowed like locals, an assignment giving one the value it stores, until something may +/// change them: an assignment to the local or to a field of the same name of any table; a call that +/// is given, or an assignment for a key that is not known to, the local, a table the field is read +/// through, a local copied from one of them or a table built with them; a call that yields; or the +/// start of a function, which runs later. #[derive(Debug)] pub struct Flow<'a> { origins: Vec>, + /// The origins of the values that each set of `Origin::Others` stands for, in order. + others: Vec>, /// The origin of the value each assignment gives a local, by the start of the name it assigns. written: FxHashMap, + /// The origin of the value each assignment gives a field of a local, as `data.job.name = name` + /// does, by where its target starts. + stored: FxHashMap, /// The origins of the values that assignments which read a local give it, in the order they are /// written, as `count = count + 1`. chained: FxHashMap>, @@ -227,25 +335,98 @@ pub struct Flow<'a> { /// the call returns. links: Vec>, link_of: FxHashMap<(LocalId, u32), usize>, + /// The fields that guards test, by the local they are read from, each with the keys after it and + /// the number it is tracked under, after those of the locals. + paths: FxHashMap>, } impl<'a> Flow<'a> { - pub fn of(chunk: &'a Chunk, resolution: &Resolution) -> Self { - let mut origins = Origins { resolution, origins: vec![Origin::Declaration], written: FxHashMap::default() }; - origins.visit_block(&chunk.block); - let no_facts: Rc<[(u32, Fact)]> = Rc::from([]); + pub fn of(chunk: &'a Chunk, resolution: &Resolution, casts: &Casts) -> Self { + let mut found = Origins { resolution, found: Vec::new() }; + found.visit_block(&chunk.block); + let mut found = found.found; + // A `---@cast` line gives the local a value, as an assignment does. + let mut cast_values = Vec::new(); + for (index, cast) in casts.iter().enumerate() { + let Some(local) = cast.local else { continue }; + found.push((cast.span.start, Origin::Cast(index as u32), Target::None)); + let reads = !cast.replaces(); + let value = + CastValue { before: cast.before, local, origin: 0, at: cast.span.end, start: cast.span.start, reads }; + cast_values.push(value); + } + // Origins are numbered in the order they are written. + found.sort_by_key(|(at, ..)| *at); + let mut origins = vec![Origin::Declaration]; + let (mut written, mut stored) = (FxHashMap::default(), FxHashMap::default()); + for (at, origin, target) in found { + let number = origins.len() as u32; + match target { + Target::Local(name) => { + written.insert(name, number); + } + Target::Field(start) => { + stored.insert(start, number); + } + Target::None => {} + } + if let Some(value) = cast_values.iter_mut().find(|value| value.start == at) { + value.origin = number; + } + origins.push(origin); + } + cast_values.sort_unstable_by_key(|value| value.before); let flow = Flow { - origins: origins.origins, - written: origins.written, + origins, + others: Vec::new(), + written, + stored, chained: FxHashMap::default(), points: FxHashMap::default(), - declared: Rc::from([Version { origin: DECLARATION, facts: no_facts.clone() }]), + declared: Rc::from([Version { origin: DECLARATION, facts: FactList::default() }]), links: Vec::new(), link_of: FxHashMap::default(), + paths: FxHashMap::default(), }; - let mut walker = Walker::new(resolution, flow, no_facts); + let mut walker = Walker::new(chunk, resolution, flow, cast_values); + walker.frames[0].others = walker.others_of(MAIN_CHUNK); walker.block(&chunk.block); - walker.flow + let mut flow = walker.flow; + for (index, (root, keys)) in walker.paths.into_inner().into_iter().enumerate() { + flow.paths.entry(root).or_default().push((keys, walker.first_path + index as LocalId)); + } + flow + } + + /// The number that the field `expr` reads is tracked under, when guards test it. + pub fn path(&self, resolution: &Resolution, expr: &Expr) -> Option { + if self.paths.is_empty() { + return None; + } + let (root, keys) = field_path(resolution, expr)?; + self.path_of(root, &keys) + } + + /// The number that the field `key` of the table `base` names or reads is tracked under, when + /// guards test it. + pub fn member_path(&self, resolution: &Resolution, base: &Expr, key: &str) -> Option { + if self.paths.is_empty() { + return None; + } + let (root, mut keys) = match &base.unparen().kind { + ExprKind::Name(name) => match resolution.resolve_at(name.span.start) { + Some(Resolved::Local(root)) => (root, Vec::new()), + _ => return None, + }, + _ => field_path(resolution, base)?, + }; + keys.push(SmolStr::new(key)); + self.path_of(root, &keys) + } + + fn path_of(&self, root: LocalId, keys: &[SmolStr]) -> Option { + let paths = self.paths.get(&root)?; + paths.iter().find(|(path, _)| **path == *keys).map(|(_, id)| *id) } /// What `local` may hold at `offset`. @@ -261,6 +442,11 @@ impl<'a> Flow<'a> { self.origins.get(origin as usize).copied().unwrap_or(Origin::Declaration) } + /// The origins of the values that the set `set` of `Origin::Others` stands for, in order. + pub fn others(&self, set: u32) -> &[u32] { + self.others.get(set as usize).map_or(&[], |origins| &origins[..]) + } + /// The origins of the values that assignments which read `local` give it, in the order they are /// written: those whose types may each depend on the one before. pub fn chained(&self, local: LocalId) -> &[u32] { @@ -277,8 +463,8 @@ impl<'a> Flow<'a> { /// declaration. pub fn facts(&self, local: LocalId, origin: u32, code: Span) -> impl Iterator { let value = self.at(local, code.end).iter().find(|value| value.origin == origin); - let facts = value.map_or(&[][..], |value| &value.facts[..]); - facts.iter().filter(move |(at, _)| *at <= code.start).map(|(_, fact)| fact) + let facts = value.map(|value| value.facts.iter()).into_iter().flatten(); + facts.filter(move |(at, _)| *at <= code.start).map(|(_, fact)| fact) } /// The locals that the call which gives `local` the value of `origin` gives values too, itself @@ -290,19 +476,27 @@ impl<'a> Flow<'a> { } } +/// What an origin gives a value to. +enum Target { + /// The local whose name starts here. + Local(u32), + /// The field of a local whose target starts here, as `data.job` of `data.job = job`. + Field(u32), + None, +} + /// Finds the assignments to locals, which are the origins of the values they hold after their -/// declaration. +/// declaration, and those to the fields of locals. struct Origins<'a, 'r> { resolution: &'r Resolution, - origins: Vec>, - written: FxHashMap, + /// Each origin, with where it is written and what it gives the value to. + found: Vec<(u32, Origin<'a>, Target)>, } impl<'a> Origins<'a, '_> { fn add(&mut self, name: &Name, origin: Origin<'a>) { if let Some(Resolved::Local(_)) = self.resolution.resolve_at(name.span.start) { - self.written.insert(name.span.start, self.origins.len() as u32); - self.origins.push(origin); + self.found.push((name.span.start, origin, Target::Local(name.span.start))); } } } @@ -312,8 +506,13 @@ impl<'a> Visitor<'a> for Origins<'a, '_> { match &stmt.kind { StmtKind::Assign { targets, .. } => { for (index, target) in targets.iter().enumerate() { - if let ExprKind::Name(name) = &target.kind { - self.add(name, Origin::Assignment { stmt, index }); + match &target.kind { + ExprKind::Name(name) => self.add(name, Origin::Assignment { stmt, index }), + _ if field_path(self.resolution, target).is_some() => { + let at = target.span.start; + self.found.push((at, Origin::Assignment { stmt, index }, Target::Field(at))); + } + _ => {} } } } @@ -338,38 +537,76 @@ struct Write { origin: u32, } +/// What the locals that changed since a point of the walk hold, with `Flow::declared` for one that +/// holds no more than the value of its declaration with nothing known about it. +type Delta = FxHashMap; + /// A function being walked. struct Frame { func: FuncId, - /// Where the loops around the code being walked start, outermost first. - loops: Vec, + /// Where the loops around the code being walked start, outermost first, each with the point of + /// the trail where it starts. + loops: Vec<(u32, usize)>, /// What the locals hold at the `break`s of each of those loops. - breaks: Vec>, + breaks: Vec>, /// The labels of the blocks around the code being walked, innermost last, each with where its - /// statement starts. - labels: Vec>, + /// statement starts, and each block with the point of the trail where it starts. + labels: Vec<(Vec<(SmolStr, u32)>, usize)>, /// Where the function being walked inside this one is created, or the outermost loop around /// that starts: the assignments of this function from there on may run before it does. created: u32, + /// The locals that code running while the function yields may assign, each with the origin of + /// the values that code gives it. + others: Vec<(LocalId, u32)>, } impl Frame { fn new(func: FuncId) -> Self { - Self { func, loops: Vec::new(), breaks: Vec::new(), labels: Vec::new(), created: 0 } + Self { func, loops: Vec::new(), breaks: Vec::new(), labels: Vec::new(), created: 0, others: Vec::new() } } } -/// Walks the code of a file in the order it runs and records what its locals may hold. +/// The value a `---@cast` line gives its local. +#[derive(Clone, Copy)] +struct CastValue { + /// Where the token after the line starts, before which the local takes the value. + before: u32, + local: LocalId, + origin: u32, + /// Where the line ends, from which the local holds the value. + at: u32, + /// Where the line starts. + start: u32, + /// Whether the type of the value depends on the one the local has there, as for `+T` and `-T`. + reads: bool, +} + +/// The locals that hold a table read from another local, as `local child = self.child` does, each +/// with the tables it was read from, as a local and the keys after it. +type Aliases = FxHashMap)>>; + +/// Walks the code of a file in the order it runs and records what its locals may hold. It keeps what +/// they hold where it is, with a trail of what each change replaced: the ways through an `if` or a +/// loop go back along the trail and are joined by what they changed, so the locals they leave alone +/// cost nothing. struct Walker<'a, 'r> { resolution: &'r Resolution, flow: Flow<'a>, - state: State, + tokens: &'a [Token], + /// The values the `---@cast` lines of the file give, by where the token after each line starts. + casts: Vec, + /// What the locals hold where the walk is, for those that may hold more than the value of their + /// declaration with nothing known about it. + state: FxHashMap, + /// Each change to `state`, as the local and what it held before. + trail: Vec<(LocalId, Option)>, + /// The fields in `state`. + fields: FxHashSet, /// Whether the code being walked can run: not after a `return`, `break`, `goto` or `error()`. live: bool, /// Whether what the locals hold is recorded, which it is not while a loop is walked to find what /// its locals hold at its start. record: bool, - no_facts: Rc<[(u32, Fact)]>, frames: Vec, /// Each function by where it starts. functions: FxHashMap, @@ -377,23 +614,45 @@ struct Walker<'a, 'r> { writes: FxHashMap>, /// Where each assignment to a local starts, in order, with the local and the origin of the value. ordered: Vec<(u32, LocalId, u32)>, + /// The locals by where they are declared, in order. + declarations: Vec<(u32, LocalId)>, /// For each function, the locals declared outside it that are assigned again and that it, or a /// function in it, reads or assigns. captured: FxHashMap>, - /// For each function, the values that it, or a function in it, gives the locals declared - /// outside it. - inner_writes: FxHashMap>, - /// What the locals hold at the `goto`s that jump ahead to a label, by where its statement starts. - gotos: FxHashMap>, + /// For each function, the locals declared outside it that it, or a function in it, assigns. + inner_writes: FxHashMap>, + /// For each function, the locals it declares that the functions in it assign. + nested_writes: FxHashMap>, + /// Where the assignments to each local in the function that declares it start, in order. + own_writes: FxHashMap>, + /// The origins that stand for what code that runs at other times may give a local, by the local + /// and how many of the assignments in the function that declares it come before that code. + others: FxHashMap<(LocalId, usize), Option>, + /// The local and that number of each of those origins. + others_of: FxHashMap, + /// The calls that may yield, by where they start, in order, each with the function it is in. + yields: Vec<(u32, FuncId)>, + /// The functions that runtime functions are given to run later, by where they start. + deferred: FxHashSet, + aliases: Aliases, + /// What the locals hold at the `goto`s that jump ahead to a label, by where its statement starts, + /// as what changed since the block of the label started. + gotos: FxHashMap>, /// The locals that hold what `type`, or `math.type` with `true`, gives for another local, as - /// `local kind = type(value)` does. - kinds: FxHashMap, + /// `local kind = type(value)` does, with, for one that is assigned again, the origins of the + /// values it held there, which comparisons of the kind tell about only while it holds them. + kinds: FxHashMap>)>, /// The locals that hold `type`, or with `true` `math.type`, as after `local type = type`. type_functions: FxHashMap, + /// The fields that guards test, each as the local it is read from and the keys after it, tracked + /// under `first_path` and the numbers after it. + paths: RefCell>, + path_ids: RefCell>, + first_path: LocalId, } impl<'a, 'r> Walker<'a, 'r> { - fn new(resolution: &'r Resolution, flow: Flow<'a>, no_facts: Rc<[(u32, Fact)]>) -> Self { + fn new(chunk: &'a Chunk, resolution: &'r Resolution, flow: Flow<'a>, casts: Vec) -> Self { let mut writes: FxHashMap> = FxHashMap::default(); let mut ordered = Vec::new(); for (id, local) in resolution.locals.iter().enumerate() { @@ -407,14 +666,20 @@ impl<'a, 'r> Walker<'a, 'r> { ordered.push((write.span.start, id as LocalId, origin)); } } + ordered.extend(casts.iter().map(|cast| (cast.start, cast.local, cast.origin))); ordered.sort_unstable(); + let mut declarations: Vec<(u32, LocalId)> = + resolution.locals.iter().enumerate().map(|(id, local)| (local.decl.start, id as LocalId)).collect(); + declarations.sort_unstable(); // The functions between where a local is used and the function that declares it. let outside = |from: FuncId, decl: FuncId| { std::iter::successors(Some(from), |func| resolution.functions.get(*func as usize).and_then(|f| f.parent)) .take_while(move |func| *func != decl) }; let mut captured: FxHashMap> = FxHashMap::default(); - let mut inner_writes: FxHashMap> = FxHashMap::default(); + let mut inner_writes: FxHashMap> = FxHashMap::default(); + let mut nested_writes: FxHashMap> = FxHashMap::default(); + let mut own_writes: FxHashMap> = FxHashMap::default(); let mut locals: Vec<&LocalId> = writes.keys().collect(); locals.sort_unstable(); for id in locals { @@ -429,12 +694,27 @@ impl<'a, 'r> Walker<'a, 'r> { } for write in &writes[id] { for func in outside(write.func, local.func) { - inner_writes.entry(func).or_default().push((*id, write.origin)); + let assigned = inner_writes.entry(func).or_default(); + if assigned.last() != Some(id) { + assigned.push(*id); + } } } + if writes[id].iter().any(|write| write.func != local.func) { + nested_writes.entry(local.func).or_default().push(*id); + } + let mut own: Vec = + writes[id].iter().filter(|write| write.func == local.func).map(|write| write.start).collect(); + own.sort_unstable(); + own_writes.insert(*id, own); } - let functions = resolution.functions.iter().enumerate().skip(1); + let functions: FxHashMap = + resolution.functions.iter().enumerate().skip(1).map(|(id, func)| (func.span.start, id as FuncId)).collect(); + let yields = Yields::of(chunk, resolution, &functions); + let mut deferred = Deferred { resolution, out: FxHashSet::default() }; + deferred.visit_block(&chunk.block); let mut flow = flow; + let mut chains: FxHashMap> = FxHashMap::default(); for (local, writes) in &writes { let reads = |stmt: &Stmt| { let refs = &resolution.local(*local).refs; @@ -444,26 +724,226 @@ impl<'a, 'r> Walker<'a, 'r> { Origin::Assignment { stmt, .. } | Origin::Compound { stmt } => reads(stmt), _ => false, }); - let mut origins: Vec<(u32, u32)> = chained.map(|write| (write.start, write.origin)).collect(); + chains.entry(*local).or_default().extend(chained.map(|write| (write.start, write.origin))); + } + // `+T` and `-T` read the type the local has there. + for cast in casts.iter().filter(|cast| cast.reads) { + chains.entry(cast.local).or_default().push((cast.start, cast.origin)); + } + for (local, mut origins) in chains { origins.sort_unstable(); - flow.chained.insert(*local, origins.into_iter().map(|(_, origin)| origin).collect()); + flow.chained.insert(local, origins.into_iter().map(|(_, origin)| origin).collect()); } Self { resolution, flow, - state: State::default(), + tokens: &chunk.tokens, + casts, + state: FxHashMap::default(), + trail: Vec::new(), + fields: FxHashSet::default(), live: true, record: true, - no_facts, frames: vec![Frame::new(MAIN_CHUNK)], - functions: functions.map(|(id, func)| (func.span.start, id as FuncId)).collect(), + functions, writes, ordered, + declarations, captured, inner_writes, + nested_writes, + own_writes, + others: FxHashMap::default(), + others_of: FxHashMap::default(), + yields, + deferred: deferred.out, + aliases: Aliases::default(), gotos: FxHashMap::default(), kinds: FxHashMap::default(), type_functions: FxHashMap::default(), + paths: RefCell::default(), + path_ids: RefCell::default(), + first_path: resolution.locals.len() as LocalId, + } + } + + /// The origin that stands for the values that code other than the function declaring `local` + /// gives it, with those that function gives it from `created` on: what it may hold in a function + /// created there, which may run any time later. `None` when there are none. + fn others(&mut self, local: LocalId, created: u32) -> Option { + let from = self.own_writes.get(&local)?.partition_point(|start| *start < created); + if let Some(origin) = self.others.get(&(local, from)) { + return *origin; + } + let decl = self.resolution.local(local).func; + let given = self.writes[&local].iter().filter(|write| write.func != decl || write.start >= created); + let mut origins: Vec = given.map(|write| write.origin).collect(); + origins.sort_unstable(); + let origin = (!origins.is_empty()).then(|| { + let origin = self.flow.origins.len() as u32; + self.flow.origins.push(Origin::Others(self.flow.others.len() as u32)); + self.flow.others.push(origins.into()); + self.others_of.insert(origin, (local, from)); + origin + }); + self.others.insert((local, from), origin); + origin + } + + /// The locals that code running while the function `func` yields may assign, those it declares + /// that the functions in it assign, each with the origin of the values that code gives it. + fn others_of(&mut self, func: FuncId) -> Vec<(LocalId, u32)> { + let locals = self.nested_writes.get(&func).cloned().unwrap_or_default(); + locals.into_iter().filter_map(|local| Some((local, self.others(local, u32::MAX)?))).collect() + } + + /// `values` where the local may also hold any of the values that `others`, an origin of + /// `Walker::others`, stands for, with nothing known about them. The values among them are left + /// out, as are those of the same local that stand for no more. + fn with_others(&self, values: &Values, others: u32) -> Values { + let (local, from) = self.others_of[&others]; + let Origin::Others(set) = self.flow.origin(others) else { return values.clone() }; + let origins = self.flow.others(set); + let covered = |value: &Version| { + origins.binary_search(&value.origin).is_ok() + || self.others_of.get(&value.origin).is_some_and(|(of, after)| *of == local && *after >= from) + }; + let fresh = |value: &Version| value.origin == others && value.facts.is_empty(); + if values.iter().any(fresh) && !values.iter().any(|value| value.origin != others && covered(value)) { + return values.clone(); + } + let kept = values.iter().filter(|value| !covered(value)).cloned(); + kept.chain([Version { origin: others, facts: FactList::default() }]).collect() + } + + /// Goes on after the call `call`, which lets the code that runs while it yields change what the + /// locals hold, when it may. + fn call_ended(&mut self, call: &Expr) { + let first = self.yields.partition_point(|(at, _)| *at < call.span.start); + if self.yields.get(first).is_some_and(|(at, _)| *at == call.span.start) { + let yielded = self.yielded(Delta::default()); + self.apply(call.span.end, &yielded); + } + } + + /// Whether a call in `code` that the function being walked makes may yield. + fn yields_in(&self, code: Span) -> bool { + let func = self.frames.last().map_or(MAIN_CHUNK, |frame| frame.func); + let first = self.yields.partition_point(|(at, _)| *at < code.start); + self.yields[first..].iter().take_while(|(at, _)| *at < code.end).any(|(_, of)| *of == func) + } + + /// `delta`, once the function being walked yields: the locals that the code that runs meanwhile + /// may assign may hold what it gives them, and the fields of any table may hold anything. + fn yielded(&self, mut delta: Delta) -> Delta { + let others = self.frames.last().map_or(&[][..], |frame| &frame.others[..]); + for (local, origin) in others { + let values = self.with_others(&self.values_in(&delta, *local), *origin); + delta.insert(*local, values); + } + let fields: Vec = + self.fields.iter().copied().chain(delta.keys().copied().filter(|id| self.is_path(*id))).collect(); + for field in fields { + delta.insert(field, self.flow.declared.clone()); + } + delta + } + + /// Notes that `local` holds what `value` gives it, which is a table read from another local when + /// it names one or reads a field of one, or holds those tables when it builds a table with them. + fn note_alias(&mut self, local: LocalId, value: &Expr) { + let mut tables = Vec::new(); + tables_of(self.resolution, &self.aliases, value, &mut tables); + tables.retain(|(root, _)| *root != local); + if tables.is_empty() { + return; + } + let aliases = self.aliases.entry(local).or_default(); + for table in tables { + if !aliases.contains(&table) { + aliases.push(table); + } + } + } + + /// Whether `id` is the number of a field rather than of a local. + fn is_path(&self, id: LocalId) -> bool { + id >= self.first_path + } + + /// The local that `id` is, or that the field it is the number of is read from. + fn root(&self, id: LocalId) -> LocalId { + match self.is_path(id) { + true => self.paths.borrow()[(id - self.first_path) as usize].0, + false => id, + } + } + + /// The local `expr` names, or the number of the field of a local it reads. + fn place(&self, expr: &Expr) -> Option { + if let Some(local) = self.local(expr) { + return Some(local); + } + let (root, keys) = field_path(self.resolution, expr)?; + let key = (root, keys.into_boxed_slice()); + if let Some(id) = self.path_ids.borrow().get(&key) { + return Some(*id); + } + let id = self.first_path + self.paths.borrow().len() as LocalId; + self.paths.borrow_mut().push(key.clone()); + self.path_ids.borrow_mut().insert(key, id); + Some(id) + } + + /// Forgets what the guards told about the fields that `changed` picks by the local they are + /// read from and the keys after it, from `at` on. + fn forget_fields(&mut self, at: u32, changed: impl Fn(LocalId, &[SmolStr]) -> bool) { + if self.fields.is_empty() { + return; + } + let paths = self.paths.borrow(); + let fields = self.fields.iter().filter(|id| { + let (root, keys) = &paths[(**id - self.first_path) as usize]; + changed(*root, keys) + }); + let fields: Vec = fields.copied().collect(); + drop(paths); + for id in fields { + self.set(id, at, self.flow.declared.clone()); + } + } + + /// Forgets what the guards told about the fields of the tables that `expr` names or reads, which + /// something it is given to may change, also through the locals they were copied to and the + /// tables built with them. + fn forget_fields_of(&mut self, expr: &Expr, at: u32) { + let mut tables = Vec::new(); + tables_of(self.resolution, &self.aliases, expr, &mut tables); + if tables.is_empty() { + return; + } + self.forget_fields(at, |root, keys| { + let within = |(table, prefix): &(LocalId, Vec)| { + root == *table && keys.len() > prefix.len() && keys.starts_with(prefix) + }; + tables.iter().any(within) + }); + } + + /// Forgets what the guards told about the fields that an assignment to `target` may change: those + /// of the same name, read from any table, or for a key that is not known, those of the table and + /// of the tables it was copied from. + fn forget_assigned(&mut self, target: &Expr, at: u32) { + let key = match &target.unparen().kind { + ExprKind::Field { name, .. } => Some(name.text.clone()), + ExprKind::Index { base, index, .. } => match index.as_string() { + Some(key) => Some(SmolStr::new(key)), + None => return self.forget_fields_of(base, at), + }, + _ => None, + }; + if let Some(key) = key { + self.forget_fields(at, |_, keys| keys.contains(&key)); } } @@ -475,8 +955,14 @@ impl<'a, 'r> Walker<'a, 'r> { matches!(values, [value] if value.origin == DECLARATION && value.facts.is_empty()) } - fn values(&self, state: &State, local: LocalId) -> Values { - state.get(&local).cloned().unwrap_or_else(|| self.flow.declared.clone()) + /// What `local` holds where the walk is. + fn values(&self, local: LocalId) -> Values { + self.state.get(&local).cloned().unwrap_or_else(|| self.flow.declared.clone()) + } + + /// What `local` holds where the walk is, once the locals that `delta` tells about hold that. + fn values_in(&self, delta: &Delta, local: LocalId) -> Values { + delta.get(&local).cloned().unwrap_or_else(|| self.values(local)) } /// Records that `local` may hold `values` from `at` on. @@ -492,7 +978,7 @@ impl<'a, 'r> Walker<'a, 'r> { }; match points.last_mut() { Some((last, held)) if *last == at => *held = values.clone(), - Some((_, held)) if held == values => {} + Some((_, held)) if same(held, values) => {} _ => points.push((at, values.clone())), } } @@ -500,42 +986,87 @@ impl<'a, 'r> Walker<'a, 'r> { /// Makes `local` hold one of `values` from `at` on. fn set(&mut self, local: LocalId, at: u32, values: Values) { self.point(local, at, &values); - if Self::is_declared(&values) { - self.state.remove(&local); - } else { - self.state.insert(local, values); + self.replace(local, values); + } + + /// Makes `local` hold one of `values`, with what it held before on the trail. + fn replace(&mut self, local: LocalId, values: Values) { + let old = match Self::is_declared(&values) { + true => self.state.remove(&local), + false => self.state.insert(local, values), + }; + if old.is_none() && !self.state.contains_key(&local) { + return; } + self.note_field(local); + self.trail.push((local, old)); } - /// Makes the locals hold what `state` says from `at` on. - fn switch(&mut self, at: u32, state: State) { - if self.record { - let old = &self.state; - let changed: Vec = old - .iter() - .filter(|(local, values)| state.get(local) != Some(values)) - .map(|(local, _)| *local) - .chain(state.iter().filter(|(local, values)| old.get(local) != Some(values)).map(|(local, _)| *local)) - .collect(); - for local in changed { - let values = self.values(&state, local); - self.point(local, at, &values); - } + /// Keeps `fields` telling whether the field `id` is in `state`. + fn note_field(&mut self, id: LocalId) { + if !self.is_path(id) { + return; } - self.state = state; + match self.state.contains_key(&id) { + true => self.fields.insert(id), + false => self.fields.remove(&id), + }; } - /// `state` with what `facts` tell about its locals from `at` on. - fn with_facts(&self, mut state: State, facts: Facts, at: u32) -> State { - for (local, fact) in facts { - let values = self.values(&state, local); - let values = values.iter().map(|value| Version { - origin: value.origin, - facts: value.facts.iter().cloned().chain([(at, fact.clone())]).collect(), - }); - state.insert(local, values.collect()); + /// The point of the trail the walk is at. + fn mark(&self) -> usize { + self.trail.len() + } + + /// Goes back along the trail to what the locals held at `mark`, from `at` on. Without `at` the + /// code after it is not walked from there. + fn rollback(&mut self, mark: usize, at: Option) { + let mut restored = Vec::new(); + while self.trail.len() > mark { + let Some((local, old)) = self.trail.pop() else { break }; + match old { + Some(values) => self.state.insert(local, values), + None => self.state.remove(&local), + }; + self.note_field(local); + restored.push(local); + } + let Some(at) = at else { return }; + restored.sort_unstable(); + restored.dedup(); + for local in restored { + let values = self.values(local); + self.point(local, at, &values); + } + } + + /// What the locals that changed since `mark` hold. + fn delta(&self, mark: usize) -> Delta { + let mut delta = Delta::default(); + for (local, _) in &self.trail[mark..] { + delta.entry(*local).or_insert_with(|| self.values(*local)); + } + delta + } + + /// Makes the locals hold what `delta` tells from `at` on. + fn apply(&mut self, at: u32, delta: &Delta) { + for (local, values) in delta { + self.set(*local, at, values.clone()); } - state + } + + /// `delta` without what tells that a local holds what it holds where the walk is. + fn settled(&self, mut delta: Delta) -> Delta { + delta.retain(|local, values| !same(values, &self.values(*local))); + delta + } + + /// `values` with `fact` holding for each of them from `at` on. + fn with_fact(values: &[Version], at: u32, fact: &Fact) -> Values { + let values = + values.iter().map(|value| Version { origin: value.origin, facts: value.facts.with(at, fact.clone()) }); + values.collect() } /// Adds what `cond` being true, or false without `holds`, tells about the locals in it from `at` @@ -543,48 +1074,51 @@ impl<'a, 'r> Walker<'a, 'r> { fn assume(&mut self, cond: &Expr, holds: bool, at: u32) { let mut facts = Facts::new(); self.facts(cond, holds, &mut facts); - if !facts.is_empty() { - let state = self.with_facts(self.state.clone(), facts, at); - self.switch(at, state); + for (local, fact) in facts { + let values = Self::with_fact(&self.values(local), at, &fact); + self.set(local, at, values); } } - /// `state` with what `cond` being true, or false without `holds`, tells from `at` on. - fn assuming(&self, state: State, cond: &Expr, holds: bool, at: u32) -> State { + /// What changed since `mark`, with what `cond` being true, or false without `holds`, tells from + /// `at` on. + fn assuming(&self, mark: usize, cond: &Expr, holds: bool, at: u32) -> Delta { + let mut delta = self.delta(mark); let mut facts = Facts::new(); self.facts(cond, holds, &mut facts); - self.with_facts(state, facts, at) + for (local, fact) in facts { + let values = Self::with_fact(&self.values_in(&delta, local), at, &fact); + delta.insert(local, values); + } + delta } - /// What the locals may hold where two ways meet that leave them `a` and `b`. - fn join(&self, a: &State, b: &State) -> State { - let mut out = State::default(); - let declared = &self.flow.declared; - for (local, values) in a { - let joined = joined(values, b.get(local).unwrap_or(declared)); - if !Self::is_declared(&joined) { - out.insert(*local, joined); - } - } - for (local, values) in b.iter().filter(|(local, _)| !a.contains_key(local)) { - let joined = joined(declared, values); - if !Self::is_declared(&joined) { - out.insert(*local, joined); - } + /// What the locals may hold where the ways in `ways` meet, each as what changed since the + /// locals held what they hold where the walk is. + fn joined_ways(&self, ways: &[Delta]) -> Delta { + let mut locals: Vec = ways.iter().flat_map(|way| way.keys().copied()).collect(); + locals.sort_unstable(); + locals.dedup(); + let mut out = Delta::default(); + for local in locals { + let held = self.values(local); + let mut values = ways.iter().map(|way| way.get(&local).unwrap_or(&held)); + let Some(first) = values.next() else { continue }; + out.insert(local, values.fold(first.clone(), |all, values| joined(&all, values))); } out } - /// Goes on after a statement, from what the ways out of it leave, joined at `at`. With none, the - /// code after it does not run. - fn leave(&mut self, at: u32, ways: Vec) { - let mut ways = ways.into_iter(); - let Some(first) = ways.next() else { + /// Goes on after a statement, from what the ways out of it leave, joined at `at`, each as what + /// changed since the locals held what they hold where the walk is. With none, the code after it + /// does not run. + fn leave(&mut self, at: u32, ways: Vec) { + if ways.is_empty() { self.live = false; return; - }; - let joined = ways.fold(first, |joined, way| self.join(&joined, &way)); - self.switch(at, joined); + } + let joined = self.joined_ways(&ways); + self.apply(at, &joined); self.live = true; } @@ -600,14 +1134,36 @@ impl<'a, 'r> Walker<'a, 'r> { StmtKind::Label(name) => Some((name.text.clone(), stmt.span.start)), _ => None, }); - let labels = labels.collect(); + let labels = (labels.collect(), self.mark()); self.frame().labels.push(labels); for (index, stmt) in block.stmts.iter().enumerate() { + self.cast(stmt.span.start); self.stmt(stmt, block, index); } + // A `---@cast` line after the last statement holds for what runs after the block. + let next = match block.stmts.is_empty() { + true => block.span.start, + false => { + let next = self.tokens.partition_point(|token| token.span.start < block.span.end); + self.tokens.get(next).map_or(u32::MAX, |token| token.span.start) + } + }; + self.cast(next); self.frame().labels.pop(); } + /// Gives the locals that the `---@cast` lines right before the token starting at `before` name + /// the values those lines give them. + fn cast(&mut self, before: u32) { + let first = self.casts.partition_point(|cast| cast.before < before); + let count = self.casts[first..].iter().take_while(|cast| cast.before == before).count(); + for index in first..first + count { + let CastValue { local, origin, at, .. } = self.casts[index]; + self.set(local, at, Rc::from([Version { origin, facts: FactList::default() }])); + self.forget_fields(at, |root, _| root == local); + } + } + /// Forgets what the locals that `block` declares hold, as they are gone after it. fn forget(&mut self, block: &Block) { for stmt in &block.stmts { @@ -618,7 +1174,7 @@ impl<'a, 'r> Walker<'a, 'r> { }; for name in names { if let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) { - self.state.remove(&id); + self.replace(id, self.flow.declared.clone()); } } } @@ -635,11 +1191,15 @@ impl<'a, 'r> Walker<'a, 'r> { self.note_kinds(names, exprs); } // A local declared again, as in each run of a loop, holds the value of its declaration. - for name in names { + for (index, name) in names.iter().enumerate() { if let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.name.span.start) { if self.state.contains_key(&id) { self.set(id, stmt.span.end, self.flow.declared.clone()); } + self.forget_fields(stmt.span.end, |root, _| root == id); + if let Some(value) = exprs.get(index) { + self.note_alias(id, value); + } } } } @@ -653,8 +1213,12 @@ impl<'a, 'r> Walker<'a, 'r> { } StmtKind::Function { name, func } => { self.function(func); - if name.path.is_empty() && name.method.is_none() { - self.assigned(&name.base, stmt.span.end); + match name.method.as_ref().or(name.path.last()) { + Some(key) => { + let key = key.text.clone(); + self.forget_fields(stmt.span.end, |_, keys| keys.contains(&key)); + } + None => self.assigned(&name.base, stmt.span.end), } } StmtKind::Assign { targets, exprs } => { @@ -668,9 +1232,18 @@ impl<'a, 'r> Walker<'a, 'r> { }); let names: Vec> = names.collect(); self.link(&names, exprs); - for target in targets { - if let ExprKind::Name(name) = &target.kind { - self.assigned(name, stmt.span.end); + for (index, target) in targets.iter().enumerate() { + match &target.kind { + ExprKind::Name(name) => { + self.assigned(name, stmt.span.end); + if let (Some(local), Some(value)) = (self.local(target), exprs.get(index)) { + self.note_alias(local, value); + } + } + _ => { + self.forget_assigned(target, stmt.span.end); + self.store(target, stmt.span.end); + } } } } @@ -678,7 +1251,10 @@ impl<'a, 'r> Walker<'a, 'r> { self.visit_expr(expr); match &target.kind { ExprKind::Name(name) => self.assigned(name, stmt.span.end), - _ => self.visit_expr(target), + _ => { + self.visit_expr(target); + self.forget_assigned(target, stmt.span.end); + } } } StmtKind::Expr(expr) => { @@ -697,25 +1273,25 @@ impl<'a, 'r> Walker<'a, 'r> { StmtKind::Do(body) => self.block(body), // It runs when its block ends, which changes nothing for the code after it. StmtKind::Defer(body) => { - let (state, live) = (self.state.clone(), self.live); + let (mark, live) = (self.mark(), self.live); self.block(body); - self.switch(stmt.span.end, state); + self.rollback(mark, Some(stmt.span.end)); self.live = live; } StmtKind::If { branches, else_block } => self.if_stmt(stmt, branches, else_block.as_ref()), StmtKind::While { cond, body } => { - let reached = self.live; - let start = self.loop_start(stmt, |walker| walker.while_run(stmt, cond, body).1); - self.switch(cond.span.start, start); - let (ways, _) = self.while_run(stmt, cond, body); + let (reached, mark) = (self.live, self.mark()); + self.loop_start(stmt, cond.span.start, mark, |walker| walker.while_run(stmt, cond, body, mark).1); + let (ways, _) = self.while_run(stmt, cond, body, mark); + self.rollback(mark, Some(stmt.span.end)); self.leave(stmt.span.end, ways); self.live &= reached; } StmtKind::Repeat { body, cond } => { - let reached = self.live; - let start = self.loop_start(stmt, |walker| walker.repeat_run(stmt, body, cond).1); - self.switch(body.span.start, start); - let (ways, _) = self.repeat_run(stmt, body, cond); + let (reached, mark) = (self.live, self.mark()); + self.loop_start(stmt, body.span.start, mark, |walker| walker.repeat_run(stmt, body, cond, mark).1); + let (ways, _) = self.repeat_run(stmt, body, cond, mark); + self.rollback(mark, Some(stmt.span.end)); self.leave(stmt.span.end, ways); self.live &= reached; } @@ -732,10 +1308,11 @@ impl<'a, 'r> Walker<'a, 'r> { self.live = false; } StmtKind::Break => { - if self.live { - let state = self.state.clone(); + let mark = self.frames.last().and_then(|frame| frame.loops.last()).map(|(_, mark)| *mark); + if let Some(mark) = mark.filter(|_| self.live) { + let delta = self.delta(mark); if let Some(breaks) = self.frame().breaks.last_mut() { - breaks.push(state); + breaks.push(delta); } } self.live = false; @@ -756,105 +1333,166 @@ impl<'a, 'r> Walker<'a, 'r> { if self.is_reassigned(id) { continue; } - // `local kind = type(value)` tells nothing about what is assigned to `value` later. - if let Some(checked) = self.type_call(expr).filter(|(checked, _)| !self.is_reassigned(*checked)) { - self.kinds.insert(id, checked); + // `local kind = type(value)` tells nothing about what is assigned to `value` later, or + // by another function. + let unchanged = + |(checked, _): &(LocalId, bool)| !self.is_path(*checked) && !self.assigned_elsewhere(*checked); + if let Some((checked, numbers)) = self.type_call(expr).filter(unchanged) { + let held = self.is_reassigned(checked).then(|| origins(&self.values(checked))); + self.kinds.insert(id, (checked, numbers, held)); } else if let Some(numbers) = self.type_function(expr) { self.type_functions.insert(id, numbers); } } } + /// Makes the field of a local that `target` names hold the value an assignment gives it from `at` + /// on. + fn store(&mut self, target: &Expr, at: u32) { + let Some(origin) = self.flow.stored.get(&target.span.start).copied() else { return }; + let Some(field) = self.place(target) else { return }; + self.set(field, at, Rc::from([Version { origin, facts: FactList::default() }])); + } + /// Makes the local whose name `name` assigns hold the value it is given from `at` on. fn assigned(&mut self, name: &Name, at: u32) { let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) else { return }; let Some(origin) = self.flow.written_at(name.span.start) else { return }; - let values: Values = Rc::from([Version { origin, facts: self.no_facts.clone() }]); + let values: Values = Rc::from([Version { origin, facts: FactList::default() }]); self.set(id, at, values); + self.forget_fields(at, |root, _| root == id); } fn if_stmt(&mut self, stmt: &'a Stmt, branches: &'a [IfBranch], else_block: Option<&'a Block>) { let reached = self.live; let else_start = else_block.map(|block| block.span.start.min(stmt.span.end)); - let mut failed = self.state.clone(); + let mark = self.mark(); let mut ways = Vec::new(); for (index, branch) in branches.iter().enumerate() { // The conditions before it failed for the condition of an `elseif` too. - self.switch(branch.keyword_span.end, failed); self.live = reached; self.visit_expr(&branch.cond); - let tested = self.state.clone(); + let tested = self.mark(); // Measured between the keywords so half-typed code inside the branch still counts. self.assume(&branch.cond, true, branch.cond.span.end); self.block(&branch.block); if self.live { - ways.push(self.state.clone()); + ways.push(self.delta(mark)); } let next = branches.get(index + 1).map(|branch| branch.keyword_span.end); let at = next.or(else_start).unwrap_or(stmt.span.end); - failed = self.assuming(tested, &branch.cond, false, at); + self.rollback(tested, Some(at)); + self.assume(&branch.cond, false, at); } match else_block { Some(block) => { - self.switch(else_start.unwrap_or(block.span.start), failed); self.live = reached; self.block(block); if self.live { - ways.push(self.state.clone()); + ways.push(self.delta(mark)); } } - None if reached => ways.push(failed), + None if reached => ways.push(self.delta(mark)), None => {} } + self.rollback(mark, Some(stmt.span.end)); self.leave(stmt.span.end, ways); } - /// What the locals hold at the start of each run of the loop `stmt`, of which `run` walks one - /// from what they hold at its start, giving what they hold to start it again. - fn loop_start(&mut self, stmt: &Stmt, run: impl Fn(&mut Self) -> Option) -> State { - let entry = self.within(stmt, self.state.clone()); + /// Makes the locals hold, from `at` on, what they hold at the start of each run of the loop + /// `stmt`, of which `run` walks one from what they hold at its start, giving what changed since + /// `mark` where it starts again. + fn loop_start(&mut self, stmt: &Stmt, at: u32, mark: usize, run: impl Fn(&mut Self) -> Option) { + let entry = self.entry(stmt); let first = self.ordered.partition_point(|(start, ..)| *start < stmt.span.start); - if !self.ordered.get(first).is_some_and(|(start, ..)| *start < stmt.span.end) { - return entry; + let assigns = self.ordered.get(first).is_some_and(|(start, ..)| *start < stmt.span.end); + let yields = self.yields_in(stmt.span); + if !assigns && !yields { + return self.apply(at, &entry); } // Inside a loop being walked to find what its locals hold at its start, the loops in it start // with any value they give, which keeps loops inside loops from multiplying the walks. if !self.record { - return self.widened(stmt.span, entry); + let mut widened = self.widened(stmt.span, entry); + if yields { + widened = self.yielded(widened); + } + return self.apply(at, &widened); } - let (record, state, live) = (self.record, self.state.clone(), self.live); + let live = self.live; self.record = false; - let mut start = entry; + let mut start = self.settled(entry); for round in 1..=MAX_ROUNDS { - self.state = start.clone(); + self.apply(at, &start); self.live = true; - let next = match run(self) { - Some(again) => self.join(&start, &self.within(stmt, again)), + let again = run(self); + self.rollback(mark, None); + let next = match again { + Some(again) => { + let ways = [start.clone(), self.within(stmt, again)]; + self.settled(self.joined_ways(&ways)) + } None => start.clone(), }; - if next == start { + if same_deltas(&next, &start) { break; } start = next; if round == MAX_ROUNDS { - start = self.widened(stmt.span, start); + start = self.settled(self.widened(stmt.span, start)); + } + } + (self.record, self.live) = (true, live); + self.apply(at, &start); + } + + /// What the loop `stmt` starts with: the locals it declares, which each of its runs declares + /// again, hold the value of their declaration, and the fields it may change before it starts + /// again are forgotten. + fn entry(&self, stmt: &Stmt) -> Delta { + let span = stmt.span; + let mut entry = Delta::default(); + let first = self.declarations.partition_point(|(start, _)| *start < span.start); + let inside = self.declarations[first..].iter().take_while(|(start, _)| *start <= span.end); + for (_, local) in inside.filter(|(start, local)| span.contains(*start) && self.state.contains_key(local)) { + entry.insert(*local, self.flow.declared.clone()); + } + if self.fields.is_empty() { + return entry; + } + let mut changes = Changes { + resolution: self.resolution, + aliases: &self.aliases, + locals: Vec::new(), + keys: Vec::new(), + tables: Vec::new(), + }; + visit::walk_stmt(&mut changes, stmt); + let paths = self.paths.borrow(); + for id in &self.fields { + let (root, keys) = &paths[(*id - self.first_path) as usize]; + if span.contains(self.resolution.local(*root).decl.start) || changes.changes(*root, keys) { + entry.insert(*id, self.flow.declared.clone()); } } - (self.record, self.state, self.live) = (record, state, live); - start + entry } - /// `state` without the locals that the loop `stmt` declares, which each of its runs declares + /// `delta` without the locals that the loop `stmt` declares, which each of its runs declares /// again. - fn within(&self, stmt: &Stmt, mut state: State) -> State { - state.retain(|local, _| !stmt.span.contains(self.resolution.local(*local).decl.start)); - state + fn within(&self, stmt: &Stmt, mut delta: Delta) -> Delta { + for (local, values) in delta.iter_mut() { + if stmt.span.contains(self.resolution.local(self.root(*local)).decl.start) { + *values = self.flow.declared.clone(); + } + } + delta } - /// `state` where the locals that `code` assigns may hold any value it gives them, with nothing + /// `delta` where the locals that `code` assigns may hold any value it gives them, with nothing /// known about them, as at the start of a loop when walking it again has not settled what they /// hold. Those that `code` declares are left out. - fn widened(&self, code: Span, mut state: State) -> State { + fn widened(&self, code: Span, mut delta: Delta) -> Delta { let first = self.ordered.partition_point(|(start, ..)| *start < code.start); let inside = self.ordered[first..].iter().take_while(|(start, ..)| *start < code.end); let mut given: FxHashMap> = FxHashMap::default(); @@ -864,56 +1502,54 @@ impl<'a, 'r> Walker<'a, 'r> { given.entry(*local).or_default().push(*origin); } for (local, origins) in given { - let values = self.values(&state, local); - state.insert(local, self.unknown(&values, origins)); - } - state - } - - /// `values` and those that `origins` may give again, with nothing known about these. - fn given(&self, values: &[Version], origins: impl IntoIterator) -> Values { - let mut out = values.to_vec(); - for origin in origins { - let fresh = Version { origin, facts: self.no_facts.clone() }; - match out.iter_mut().find(|value| value.origin == origin) { - Some(value) => *value = fresh, - None => out.push(fresh), - } + let values = self.values_in(&delta, local); + delta.insert(local, self.unknown(&values, origins)); } - out.into() + delta } /// `values` and those of `origins`, with nothing known about any of them. fn unknown(&self, values: &[Version], origins: impl IntoIterator) -> Values { let mut out: Vec = - values.iter().map(|value| Version { origin: value.origin, facts: self.no_facts.clone() }).collect(); + values.iter().map(|value| Version { origin: value.origin, facts: FactList::default() }).collect(); for origin in origins { if !out.iter().any(|value| value.origin == origin) { - out.push(Version { origin, facts: self.no_facts.clone() }); + out.push(Version { origin, facts: FactList::default() }); } } out.into() } - /// Walks one run of a `while` loop from what the locals hold at its start: what they hold where - /// the loop ends, and where it starts again. - fn while_run(&mut self, stmt: &'a Stmt, cond: &'a Expr, body: &'a Block) -> (Vec, Option) { + /// Walks one run of a `while` loop from what the locals hold at its start: what changed since + /// `mark` where the loop ends, and where it starts again. + fn while_run( + &mut self, + stmt: &'a Stmt, + cond: &'a Expr, + body: &'a Block, + mark: usize, + ) -> (Vec, Option) { let reached = self.live; self.visit_expr(cond); - let tested = self.state.clone(); + let ended = reached && !matches!(cond.unparen().kind, ExprKind::True); + let ended = ended.then(|| self.assuming(mark, cond, false, stmt.span.end)); self.assume(cond, true, cond.span.end); - let mut ways = self.loop_body(stmt, body); - let again = self.live.then(|| self.state.clone()); - if reached && !matches!(cond.unparen().kind, ExprKind::True) { - ways.push(self.assuming(tested, cond, false, stmt.span.end)); - } + let mut ways = self.loop_body(stmt, body, mark); + let again = self.live.then(|| self.delta(mark)); + ways.extend(ended); (ways, again) } - /// Walks one run of a `repeat` loop from what the locals hold at its start: what they hold where - /// the loop ends, and where it starts again. - fn repeat_run(&mut self, stmt: &'a Stmt, body: &'a Block, cond: &'a Expr) -> (Vec, Option) { - self.frame().loops.push(stmt.span.start); + /// Walks one run of a `repeat` loop from what the locals hold at its start: what changed since + /// `mark` where the loop ends, and where it starts again. + fn repeat_run( + &mut self, + stmt: &'a Stmt, + body: &'a Block, + cond: &'a Expr, + mark: usize, + ) -> (Vec, Option) { + self.frame().loops.push((stmt.span.start, mark)); self.frame().breaks.push(Vec::new()); self.statements(body); let mut ways = self.frame().breaks.pop().unwrap_or_default(); @@ -921,41 +1557,40 @@ impl<'a, 'r> Walker<'a, 'r> { // The locals of the body are still there in the condition. self.visit_expr(cond); self.forget(body); - let tested = self.state.clone(); let mut again = None; if self.live { if !matches!(cond.unparen().kind, ExprKind::True) { - again = Some(self.assuming(tested.clone(), cond, false, body.span.start)); + again = Some(self.assuming(mark, cond, false, body.span.start)); } if !matches!(cond.unparen().kind, ExprKind::False | ExprKind::Nil) { - ways.push(self.assuming(tested, cond, true, stmt.span.end)); + ways.push(self.assuming(mark, cond, true, stmt.span.end)); } } (ways, again) } fn for_loop(&mut self, stmt: &'a Stmt, body: &'a Block) { - let reached = self.live; - let start = self.loop_start(stmt, |walker| walker.for_run(stmt, body).1); - self.switch(body.span.start, start); - let (ways, _) = self.for_run(stmt, body); + let (reached, mark) = (self.live, self.mark()); + self.loop_start(stmt, body.span.start, mark, |walker| walker.for_run(stmt, body, mark).1); + let (ways, _) = self.for_run(stmt, body, mark); + self.rollback(mark, Some(stmt.span.end)); self.leave(stmt.span.end, ways); self.live &= reached; } - /// Walks one run of a `for` loop from what the locals hold at its start: what they hold where the - /// loop ends, and where it starts again. - fn for_run(&mut self, stmt: &'a Stmt, body: &'a Block) -> (Vec, Option) { - let start = self.within(stmt, self.state.clone()); - let mut ways = self.loop_body(stmt, body); + /// Walks one run of a `for` loop from what the locals hold at its start: what changed since + /// `mark` where the loop ends, and where it starts again. + fn for_run(&mut self, stmt: &'a Stmt, body: &'a Block, mark: usize) -> (Vec, Option) { + let start = self.within(stmt, self.delta(mark)); + let mut ways = self.loop_body(stmt, body, mark); // It ends where a run would start, when what it goes through runs out. ways.push(start); - (ways, self.live.then(|| self.state.clone())) + (ways, self.live.then(|| self.delta(mark))) } - /// Walks the body of the loop `stmt`, giving what the locals hold at its `break`s. - fn loop_body(&mut self, stmt: &Stmt, body: &'a Block) -> Vec { - self.frame().loops.push(stmt.span.start); + /// Walks the body of the loop `stmt`, giving what changed since `mark` at its `break`s. + fn loop_body(&mut self, stmt: &Stmt, body: &'a Block, mark: usize) -> Vec { + self.frame().loops.push((stmt.span.start, mark)); self.frame().breaks.push(Vec::new()); self.block(body); let ways = self.frame().breaks.pop().unwrap_or_default(); @@ -968,27 +1603,38 @@ impl<'a, 'r> Walker<'a, 'r> { return; } let labels = &self.frames.last().expect("the main chunk is always walked").labels; - let target = labels.iter().rev().find_map(|labels| labels.iter().find(|(label, _)| *label == name.text)); + let target = labels.iter().rev().find_map(|(labels, mark)| { + labels.iter().find(|(label, _)| *label == name.text).map(|(_, at)| (*at, *mark)) + }); // A `goto` back to a label is handled at the label. - if let Some((_, at)) = target.filter(|(_, at)| *at > stmt.span.start) { - let state = self.state.clone(); - self.gotos.entry(*at).or_default().push(state); + if let Some((at, mark)) = target.filter(|(at, _)| *at > stmt.span.start) { + let delta = self.delta(mark); + self.gotos.entry(at).or_default().push(delta); } } /// Goes on at a label from the code before it and the `goto`s that jump ahead to it. A `goto` /// after it may jump back to it, where the locals assigned since may hold any value given there. fn label(&mut self, stmt: &Stmt, name: &Name, block: &Block, index: usize) { + let mark = self.frames.last().and_then(|frame| frame.labels.last()).map_or(0, |(_, mark)| *mark); let jumps = self.gotos.remove(&stmt.span.start).unwrap_or_default(); - let mut ways = self.live.then(|| self.state.clone()).into_iter().chain(jumps); - let Some(first) = ways.next() else { return }; - let mut joined = ways.fold(first, |joined, way| self.join(&joined, &way)); + let ways: Vec = self.live.then(|| self.delta(mark)).into_iter().chain(jumps).collect(); + if ways.is_empty() { + return; + } + self.rollback(mark, Some(stmt.span.start)); + let mut joined = self.joined_ways(&ways); let mut jumps = Jumps { name: &name.text, found: false }; block.stmts[index + 1..].iter().for_each(|stmt| jumps.visit_stmt(stmt)); if jumps.found { joined = self.widened(Span::new(stmt.span.start, block.span.end), joined); + let fields: Vec = + self.fields.iter().copied().chain(joined.keys().copied().filter(|id| self.is_path(*id))).collect(); + for field in fields { + joined.insert(field, self.flow.declared.clone()); + } } - self.switch(stmt.span.start, joined); + self.apply(stmt.span.start, &joined); self.live = true; } @@ -1000,34 +1646,46 @@ impl<'a, 'r> Walker<'a, 'r> { let Some(id) = self.functions.get(&func.span.start).copied() else { return self.block(&func.body); }; - let (outer, live) = (self.state.clone(), self.live); + let (mark, live) = (self.mark(), self.live); // While a loop is walked to find what its locals hold at its start, nothing a function holds // changes that. if self.record { self.walk_function(id, func); } - let mut after = outer; - for (local, origin) in self.inner_writes.get(&id).into_iter().flatten() { - let values = self.values(&after, *local); - after.insert(*local, self.given(&values, [*origin])); + self.rollback(mark, Some(func.span.end)); + // A function that a runtime function is given to run later, as `SetTimeout` is, gives the + // locals nothing before the code that creates it yields. + if !self.deferred.contains(&func.span.start) { + for local in self.inner_writes.get(&id).cloned().unwrap_or_default() { + if let Some(others) = self.others(local, u32::MAX) { + let values = self.with_others(&self.values(local), others); + self.set(local, func.span.end, values); + } + } } - self.switch(func.span.end, after); self.live = live; } fn walk_function(&mut self, id: FuncId, func: &'a FuncBody) { let frame = self.frame(); - frame.created = frame.loops.first().map_or(func.span.start, |start| (*start).min(func.span.start)); - let mut entry = self.state.clone(); - for local in self.captured.get(&id).into_iter().flatten() { - let decl = self.resolution.local(*local).func; + frame.created = frame.loops.first().map_or(func.span.start, |(start, _)| (*start).min(func.span.start)); + let at = func.params_span.end; + let mut others = self.others_of(id); + for local in self.captured.get(&id).cloned().unwrap_or_default() { + let decl = self.resolution.local(local).func; let created = self.frames.iter().rev().find(|frame| frame.func == decl).map_or(0, |frame| frame.created); - let later = self.writes[local].iter().filter(|write| write.func != decl || write.start >= created); - let values = self.values(&entry, *local); - entry.insert(*local, self.given(&values, later.map(|write| write.origin))); + if let Some(origin) = self.others(local, created) { + let values = self.with_others(&self.values(local), origin); + self.set(local, at, values); + others.push((local, origin)); + } } - self.frames.push(Frame::new(id)); - self.switch(func.params_span.end, entry); + // It runs later, when its fields may hold anything. + let fields: Vec = self.fields.iter().copied().collect(); + for field in fields { + self.set(field, at, self.flow.declared.clone()); + } + self.frames.push(Frame { others, ..Frame::new(id) }); self.live = true; self.block(&func.body); self.frames.pop(); @@ -1055,10 +1713,38 @@ impl<'a, 'r> Walker<'a, 'r> { } } + /// Adds that the local and fields that `expr` reads a field, index or call from hold a value, as + /// `data` and `data.job` do in `data.job.name` and `data.job:get()`. + fn reads(&self, expr: &Expr, out: &mut Facts) { + if let Some(local) = self.read_from(expr) { + out.push((local, Fact::Truthy)); + } + let mut expr = expr.unparen(); + loop { + let base = match &expr.kind { + ExprKind::Field { base, .. } | ExprKind::Index { base, .. } | ExprKind::MethodCall { base, .. } => base, + ExprKind::Call { callee, .. } if !matches!(callee.kind, ExprKind::Name(_)) => callee, + _ => return, + }; + expr = base.unparen(); + if !matches!(expr.kind, ExprKind::Name(_)) && field_path(self.resolution, expr).is_some() { + out.extend(self.place(expr).map(|field| (field, Fact::Truthy))); + } + } + } + fn is_reassigned(&self, local: LocalId) -> bool { self.writes.contains_key(&local) } + /// Whether a function other than the one being walked assigns `local`, which it may do at any + /// time. + fn assigned_elsewhere(&self, local: LocalId) -> bool { + let func = self.frames.last().map_or(MAIN_CHUNK, |frame| frame.func); + let writes = self.writes.get(&local).map_or(&[][..], Vec::as_slice); + writes.iter().any(|write| write.func != func) + } + fn is_global(&self, name: &Name) -> bool { !matches!(self.resolution.resolve_at(name.span.start), Some(Resolved::Local(_))) } @@ -1068,7 +1754,7 @@ impl<'a, 'r> Walker<'a, 'r> { let ExprKind::Call { callee, args, .. } = &expr.unparen().kind else { return None }; let [arg] = args.as_slice() else { return None }; let numbers = self.type_function(callee)?; - Some((self.local(arg)?, numbers)) + Some((self.place(arg)?, numbers)) } /// Whether `expr` is the global `type` (`false`) or `math.type` (`true`), or a local that holds @@ -1093,7 +1779,14 @@ impl<'a, 'r> Walker<'a, 'r> { fn kind_check(&self, side: &Expr, other: &Expr) -> Option<(LocalId, &'static str)> { let name = other.unparen().as_string()?; let (local, numbers) = match &side.unparen().kind { - ExprKind::Name(_) => *self.kinds.get(&self.local(side)?)?, + ExprKind::Name(_) => { + let (local, numbers, held) = self.kinds.get(&self.local(side)?)?; + // Assigned since, the local holds another value than the one whose kind it tells. + if held.as_ref().is_some_and(|held| *held != origins(&self.values(*local))) { + return None; + } + (*local, *numbers) + } _ => self.type_call(side)?, }; let names: &[&'static str] = if numbers { &NUMBER_NAMES } else { &TYPE_NAMES }; @@ -1109,6 +1802,12 @@ impl<'a, 'r> Walker<'a, 'r> { out.push((local, if holds { Fact::Truthy } else { Fact::Falsy })); } } + ExprKind::Field { .. } | ExprKind::Index { .. } if field_path(self.resolution, cond).is_some() => { + out.extend(self.place(cond).map(|field| (field, if holds { Fact::Truthy } else { Fact::Falsy }))); + if holds { + self.reads(cond, out); + } + } ExprKind::Unary { op: UnOp::Not, expr } => self.facts(expr, !holds, out), // Both sides of a true `and` are true, and both sides of a false `or` are false. ExprKind::Binary { op: BinOp::And, lhs, rhs, .. } if holds => { @@ -1123,9 +1822,10 @@ impl<'a, 'r> Walker<'a, 'r> { ExprKind::Binary { op: BinOp::Or | BinOp::And, lhs, rhs, .. } => self.either(lhs, rhs, holds, out), ExprKind::Binary { op: op @ (BinOp::Eq | BinOp::Ne), lhs, rhs, .. } => { let equal = (*op == BinOp::Eq) == holds; - let compared = match (self.local(lhs), literal(rhs)) { - (Some(local), Some(value)) => Some((local, value)), - _ => self.local(rhs).zip(literal(lhs)), + let compared = match (literal(rhs), literal(lhs)) { + (Some(value), _) => self.place(lhs).zip(Some(value)), + (None, Some(value)) => self.place(rhs).zip(Some(value)), + (None, None) => None, }; if let Some((local, value)) = compared { out.push((local, if equal { Fact::Is(value) } else { Fact::IsNot(value) })); @@ -1135,20 +1835,14 @@ impl<'a, 'r> Walker<'a, 'r> { } // `data?.job == 'police'` and `data?.job ~= nil` read a value from `data`. for (side, other) in [(lhs, rhs), (rhs, lhs)] { - if let (Some(local), Some(value)) = (self.read_from(side), literal(other)) { - if equal != (value == Type::Nil) { - out.push((local, Fact::Truthy)); - } + if literal(other).is_some_and(|value| equal != (value == Type::Nil)) { + self.reads(side, out); } } } // `data?.job`, `data.job`, `data[key]` and `data:get()` are only true when `data` holds a // value: `?.` gives `nil` for a `nil` one, and the others raise an error. - _ if holds => { - if let Some(local) = self.read_from(cond) { - out.push((local, Fact::Truthy)); - } - } + _ if holds => self.reads(cond, out), _ => {} } } @@ -1210,21 +1904,390 @@ impl<'a> Visitor<'a> for Walker<'a, '_> { // `b` of `a and b` only runs when `a` is true, and `b` of `a or b` when it is false. ExprKind::Binary { op: op @ (BinOp::And | BinOp::Or), lhs, rhs, .. } => { self.visit_expr(lhs); - let before = self.state.clone(); + let mark = self.mark(); self.assume(lhs, *op == BinOp::And, rhs.span.start); self.visit_expr(rhs); - let after = self.join(&before, &self.state); - self.switch(rhs.span.end, after); + let after = self.delta(mark); + self.rollback(mark, Some(rhs.span.end)); + let joined = self.joined_ways(&[Delta::default(), after]); + self.apply(rhs.span.end, &joined); + } + // What a call is given, it may change the fields of, and one that yields lets other code + // run. + ExprKind::Call { args, .. } => { + visit::walk_expr(self, expr); + args.iter().for_each(|arg| self.forget_fields_of(arg, expr.span.end)); + self.call_ended(expr); + } + ExprKind::MethodCall { base, args, .. } => { + visit::walk_expr(self, expr); + for given in std::iter::once(&**base).chain(args) { + self.forget_fields_of(given, expr.span.end); + } + self.call_ended(expr); } _ => visit::walk_expr(self, expr), } } } +/// Whether a call of the function at `path` yields, and so lets other code run, as the `fivem` rules +/// read it: `Wait`, `Citizen.Wait`, `Citizen.Await` and `coroutine.yield`, and those read as +/// `await`, such as `lib.callback.await` and `MySQL.query.await`. +fn is_yield_path(path: &str) -> bool { + let last = path.rsplit('.').next().unwrap_or(path); + matches!(path, "Wait" | "Citizen.Wait" | "Citizen.Await" | "coroutine.yield") || last.eq_ignore_ascii_case("await") +} + +/// Whether the runtime function at `path` runs the functions it is given later, once the code that +/// calls it yields, as a timeout, a thread or a handler does. +fn defers_path(path: &str) -> bool { + matches!( + path, + "SetTimeout" + | "Citizen.SetTimeout" + | "CreateThread" + | "Citizen.CreateThread" + | "AddEventHandler" + | "RegisterNetEvent" + | "RegisterServerEvent" + | "RegisterCommand" + | "RegisterNUICallback" + | "AddStateBagChangeHandler" + | "exports" + ) +} + +/// Finds the functions written as arguments of the calls of runtime functions that run them later, +/// as `defers_path` picks them. +struct Deferred<'r> { + resolution: &'r Resolution, + out: FxHashSet, +} + +impl<'ast> Visitor<'ast> for Deferred<'_> { + fn visit_expr(&mut self, expr: &'ast Expr) { + if let ExprKind::Call { callee, args, .. } = &expr.kind { + let mut root = callee.unparen(); + while let ExprKind::Field { base, .. } = &root.kind { + root = base.unparen(); + } + let global = matches!(&root.kind, ExprKind::Name(name) + if matches!(self.resolution.resolve_at(name.span.start), Some(Resolved::Global(_)))); + if global && callee.dotted_path().as_deref().is_some_and(defers_path) { + for arg in args { + if let ExprKind::Function(func) = &arg.unparen().kind { + self.out.insert(func.span.start); + } + } + } + } + visit::walk_expr(self, expr); + } +} + +/// What a call calls, as far as telling whether it may yield goes. +enum Callee { + Yields, + Local(LocalId), + Path(String), + Other, +} + +/// Finds the calls of a file that may yield: those `is_yield_path` picks, and those of the functions +/// of the file that make such a call. +struct Yields<'r> { + resolution: &'r Resolution, + functions: &'r FxHashMap, + /// The function being visited. + func: FuncId, + /// The functions of the file, by the local they are given to. + locals: FxHashMap>, + /// The functions of the file, by the path of the global or field they are given to. + paths: FxHashMap>, + /// Each call, with where it starts, the function it is in, and what it calls. + calls: Vec<(u32, FuncId, Callee)>, +} + +impl<'r> Yields<'r> { + /// The calls of `chunk` that may yield, by where they start, in order, each with the function it + /// is in. + fn of(chunk: &Chunk, resolution: &'r Resolution, functions: &'r FxHashMap) -> Vec<(u32, FuncId)> { + let mut finder = Yields { + resolution, + functions, + func: MAIN_CHUNK, + locals: FxHashMap::default(), + paths: FxHashMap::default(), + calls: Vec::new(), + }; + finder.visit_block(&chunk.block); + let mut yielding: FxHashSet = FxHashSet::default(); + loop { + let found = yielding.len(); + for (_, func, callee) in &finder.calls { + if finder.yields(callee, &yielding) { + yielding.insert(*func); + } + } + if yielding.len() == found { + break; + } + } + let calls = finder.calls.iter().filter(|(_, _, callee)| finder.yields(callee, &yielding)); + let mut calls: Vec<(u32, FuncId)> = calls.map(|(at, func, _)| (*at, *func)).collect(); + calls.sort_unstable(); + calls + } + + /// Whether a call of `callee` may yield, when the functions of the file in `yielding` do. + fn yields(&self, callee: &Callee, yielding: &FxHashSet) -> bool { + let funcs = match callee { + Callee::Yields => return true, + Callee::Local(local) => self.locals.get(local), + Callee::Path(path) => self.paths.get(path), + Callee::Other => None, + }; + funcs.is_some_and(|funcs| funcs.iter().any(|func| yielding.contains(func))) + } + + fn local(&self, name: &Name) -> Option { + match self.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(local)) => Some(local), + _ => None, + } + } + + /// The local that `expr` names. + fn local_of(&self, expr: &Expr) -> Option { + match &expr.unparen().kind { + ExprKind::Name(name) => self.local(name), + _ => None, + } + } + + /// Notes that the local `local`, or else the global or field at `path`, is given `value`, when + /// that is a function. + fn given(&mut self, local: Option, path: Option, value: &Expr) { + let ExprKind::Function(func) = &value.unparen().kind else { return }; + let Some(func) = self.functions.get(&func.span.start).copied() else { return }; + match (local, path) { + (Some(local), _) => self.locals.entry(local).or_default().push(func), + (None, Some(path)) => self.paths.entry(path).or_default().push(func), + (None, None) => {} + } + } + + fn callee(&self, callee: &Expr) -> Callee { + let callee = callee.unparen(); + let path = callee.dotted_path(); + let awaits = matches!(&callee.kind, ExprKind::Field { name, .. } if name.text.eq_ignore_ascii_case("await")); + if awaits || path.as_deref().is_some_and(is_yield_path) { + return Callee::Yields; + } + match self.local_of(callee) { + Some(local) => Callee::Local(local), + None => path.map_or(Callee::Other, Callee::Path), + } + } +} + +impl<'ast> Visitor<'ast> for Yields<'_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::LocalFunction { name, func } => { + if let (Some(local), Some(func)) = (self.local(name), self.functions.get(&func.span.start)) { + self.locals.entry(local).or_default().push(*func); + } + } + StmtKind::Local { names, exprs, .. } => { + for (name, value) in names.iter().zip(exprs) { + self.given(self.local(&name.name), None, value); + } + } + StmtKind::Function { name, func } => { + if let Some(func) = self.functions.get(&func.span.start).copied() { + match self.local(&name.base).filter(|_| name.path.is_empty() && name.method.is_none()) { + Some(local) => self.locals.entry(local).or_default().push(func), + None => { + let names = std::iter::once(&name.base).chain(&name.path).chain(&name.method); + let path: Vec<&str> = names.map(|name| name.text.as_str()).collect(); + self.paths.entry(path.join(".")).or_default().push(func); + } + } + } + } + StmtKind::Assign { targets, exprs } => { + for (target, value) in targets.iter().zip(exprs) { + self.given(self.local_of(target), target.dotted_path(), value); + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + let callee = match &expr.kind { + ExprKind::Call { callee, .. } => Some(self.callee(callee)), + ExprKind::MethodCall { base, method, .. } => Some(match method.text.eq_ignore_ascii_case("await") { + true => Callee::Yields, + false => { + base.dotted_path().map_or(Callee::Other, |path| Callee::Path(format!("{path}.{}", method.text))) + } + }), + _ => None, + }; + if let Some(callee) = callee { + self.calls.push((expr.span.start, self.func, callee)); + } + visit::walk_expr(self, expr); + } + + fn visit_func_body(&mut self, func: &'ast FuncBody) { + let outer = self.func; + self.func = self.functions.get(&func.span.start).copied().unwrap_or(outer); + visit::walk_func_body(self, func); + self.func = outer; + } +} + +/// Adds the tables that `expr` names or reads to `out`, each as a local and the keys after it, with +/// those that the locals it reads from were read from, and for a table it builds, those of the +/// values in it. +fn tables_of(resolution: &Resolution, aliases: &Aliases, expr: &Expr, out: &mut Vec<(LocalId, Vec)>) { + let expr = expr.unparen(); + if let ExprKind::Table(fields) = &expr.kind { + for field in fields { + match field { + TableField::Positional(value) | TableField::Named { value, .. } | TableField::Keyed { value, .. } => { + tables_of(resolution, aliases, value, out); + } + TableField::SetMember(_) => {} + } + } + return; + } + let table = match &expr.kind { + ExprKind::Name(name) => match resolution.resolve_at(name.span.start) { + Some(Resolved::Local(local)) => Some((local, Vec::new())), + _ => None, + }, + _ => field_path(resolution, expr), + }; + let Some((root, keys)) = table else { return }; + for (alias, prefix) in aliases.get(&root).into_iter().flatten() { + out.push((*alias, prefix.iter().chain(&keys).cloned().collect())); + } + out.push((root, keys)); +} + +/// What the code of a loop may change before it starts again: the locals and the fields of each name +/// it assigns, and the fields of the tables it gives calls or assigns keys of that are not known. +struct Changes<'r> { + resolution: &'r Resolution, + aliases: &'r Aliases, + locals: Vec, + keys: Vec, + tables: Vec<(LocalId, Vec)>, +} + +impl Changes<'_> { + fn changes(&self, root: LocalId, keys: &[SmolStr]) -> bool { + self.locals.contains(&root) + || keys.iter().any(|key| self.keys.contains(key)) + || self + .tables + .iter() + .any(|(table, prefix)| *table == root && keys.len() > prefix.len() && keys.starts_with(prefix)) + } + + /// Notes that the fields of the tables `expr` names or reads may change. + fn table(&mut self, expr: &Expr) { + tables_of(self.resolution, self.aliases, expr, &mut self.tables); + } + + fn assigned(&mut self, target: &Expr) { + match &target.unparen().kind { + ExprKind::Name(name) => { + if let Some(Resolved::Local(id)) = self.resolution.resolve_at(name.span.start) { + self.locals.push(id); + } + } + ExprKind::Field { name, .. } => self.keys.push(name.text.clone()), + ExprKind::Index { base, index, .. } => match index.as_string() { + Some(key) => self.keys.push(SmolStr::new(key)), + None => self.table(base), + }, + _ => {} + } + } +} + +impl<'ast> Visitor<'ast> for Changes<'_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::Assign { targets, .. } => targets.iter().for_each(|target| self.assigned(target)), + StmtKind::CompoundAssign { target, .. } => self.assigned(target), + StmtKind::Function { name, .. } => match name.method.as_ref().or(name.path.last()) { + Some(key) => self.keys.push(key.text.clone()), + None => self.assigned(&Expr { kind: ExprKind::Name(name.base.clone()), span: name.base.span }), + }, + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + match &expr.kind { + ExprKind::Call { args, .. } => args.iter().for_each(|arg| self.table(arg)), + ExprKind::MethodCall { base, args, .. } => { + self.table(base); + args.iter().for_each(|arg| self.table(arg)); + } + _ => {} + } + visit::walk_expr(self, expr); + } + + // A function in the loop changes nothing until it is called. + fn visit_func_body(&mut self, _: &'ast FuncBody) {} +} + +/// The local that `expr` reads a field from and the keys it reads through, as `data` and `job`, +/// `name` for `data.job.name` or `data["job"].name`, for at most `MAX_KEYS` keys written out. +pub fn field_path(resolution: &Resolution, expr: &Expr) -> Option<(LocalId, Vec)> { + let mut keys = Vec::new(); + let mut expr = expr.unparen(); + loop { + match &expr.kind { + ExprKind::Field { base, name, .. } => { + keys.push(name.text.clone()); + expr = base.unparen(); + } + ExprKind::Index { base, index, .. } => { + keys.push(SmolStr::new(index.as_string()?)); + expr = base.unparen(); + } + ExprKind::Name(name) if !keys.is_empty() && keys.len() <= MAX_KEYS => { + let Some(Resolved::Local(root)) = resolution.resolve_at(name.span.start) else { return None }; + keys.reverse(); + return Some((root, keys)); + } + _ => return None, + } + } +} + +/// Where each of `values` comes from. +fn origins(values: &Values) -> Vec { + values.iter().map(|value| value.origin).collect() +} + /// What a local may hold where two ways meet that leave it `a` and `b`: the values of both, each /// with the facts that hold on both ways. fn joined(a: &Values, b: &Values) -> Values { - if a == b { + if same(a, b) { return a.clone(); } let mut out: Vec = a @@ -1240,11 +2303,38 @@ fn joined(a: &Values, b: &Values) -> Values { /// The facts of `a` that `b` has too, each from where it starts to hold in `a`, so that the start of /// a loop settles once the facts its runs keep stop changing. -fn shared(a: &Rc<[(u32, Fact)]>, b: &Rc<[(u32, Fact)]>) -> Rc<[(u32, Fact)]> { - if a == b { +fn shared(a: &FactList, b: &FactList) -> FactList { + if a.is(b) { + return a.clone(); + } + // What both were given before they parted. + let (mut common, mut other) = (a, b); + while common.len() > other.len() { + common = common.before(); + } + while other.len() > common.len() { + other = other.before(); + } + while !common.is(other) { + (common, other) = (common.before(), other.before()); + } + let added: Vec<&FactNode> = a.latest().take(a.len() - common.len()).collect(); + let kept: Vec<&FactNode> = + added.iter().copied().filter(|node| b.latest().any(|other| other.fact == node.fact)).collect(); + if kept.len() == added.len() { return a.clone(); } - a.iter().filter(|(_, fact)| b.iter().any(|(_, other)| other == fact)).cloned().collect() + kept.iter().rev().fold(common.clone(), |list, node| list.with(node.at, node.fact.clone())) +} + +/// Whether `a` and `b` hold the same values. +fn same(a: &Values, b: &Values) -> bool { + Rc::ptr_eq(a, b) || a == b +} + +/// Whether `a` and `b` tell the same changes. +fn same_deltas(a: &Delta, b: &Delta) -> bool { + a.len() == b.len() && a.iter().all(|(local, values)| b.get(local).is_some_and(|other| same(values, other))) } /// Finds a `goto` to a label in the code after it, which jumps back to it. @@ -1287,10 +2377,20 @@ pub struct Cast { pub local: Option, /// What each entry does, with where its type is written. pub entries: Vec<(CastEntry, Span)>, - /// The code it holds in: from its line to the end of the block the statement after it is in, - /// or to the end of the statement that next assigns the local. One on the last line of an `if` - /// branch holds from the end of the `if`, and one on the last line of another block nowhere. + /// The comment it is written in. pub span: Span, + /// Where the token after it starts: that of the statement it gives the local its value before, + /// or the `end`, `else`, `elseif` or `until` of the block it ends. + pub before: u32, +} + +impl Cast { + /// Whether it gives its local a type of its own, rather than adding types to or taking them out + /// of the one the local has there: an entry that names a type replaces what the ones before it + /// leave. + pub fn replaces(&self) -> bool { + self.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_))) + } } /// The `---@cast` lines of a file. @@ -1301,6 +2401,7 @@ pub struct Casts { impl Casts { pub fn of(source: &str, chunk: &Chunk, resolution: &Resolution) -> Self { + let tokens = &chunk.tokens; let mut casts = Vec::new(); for comment in chunk.comments.iter().filter(|comment| comment.kind == CommentKind::Line) { let text = comment.content.text(source); @@ -1312,51 +2413,15 @@ impl Casts { } let base = comment.content.end - line.len() as u32; let entries = cast.entries.into_iter(); + let next = tokens.partition_point(|token| token.span.start < comment.span.end); casts.push(Cast { local: resolution.lookup_local_at(cast.name, comment.span.start), entries: entries .map(|(entry, at)| (entry, Span::new(base + at.start as u32, base + at.end as u32))) .collect(), - span: Span::empty(comment.span.end), - }); - } - if casts.is_empty() { - return Self::default(); - } - let mut locals: Vec = casts.iter().filter_map(|cast| cast.local).collect(); - locals.sort_unstable(); - locals.dedup(); - let writes = |local: LocalId| resolution.local(local).refs.iter().filter(|r| r.write).map(|r| r.span.start); - let mut reach = Reach { - tokens: &chunk.tokens, - statements: Vec::new(), - branch_ends: FxHashMap::default(), - writes: locals.iter().flat_map(|local| writes(*local)).map(|at| (at, u32::MAX)).collect(), - }; - reach.writes.sort_unstable(); - reach.visit_block(&chunk.block); - reach.statements.sort_unstable(); - // For each local, where each write to it starts, with the earliest end of a statement that - // assigns it from that write on. - let mut assigned = FxHashMap::default(); - for local in locals { - let mut ends: Vec<(u32, u32)> = writes(local).map(|at| (at, reach.end_of_write(at))).collect(); - ends.sort_unstable(); - let mut earliest = u32::MAX; - for (_, end) in ends.iter_mut().rev() { - earliest = earliest.min(*end); - *end = earliest; - } - assigned.insert(local, ends); - } - for cast in &mut casts { - let line_end = cast.span.start; - let Some((start, end)) = reach.from(line_end) else { continue }; - let reassigned = cast.local.and_then(|local| { - let ends: &Vec<(u32, u32)> = &assigned[&local]; - ends.get(ends.partition_point(|(at, _)| *at <= line_end)).map(|(_, end)| *end) + span: comment.span, + before: tokens.get(next).map_or(u32::MAX, |token| token.span.start), }); - cast.span = Span::new(start, end.min(reassigned.unwrap_or(u32::MAX)).max(start)); } Self { casts } } @@ -1365,72 +2430,7 @@ impl Casts { self.casts.iter() } - /// The casts of `local` that hold at `offset`, in the order of their lines. - pub fn at(&self, local: LocalId, offset: u32) -> impl Iterator { - self.casts.iter().filter(move |cast| cast.local == Some(local) && cast.span.contains(offset)) - } -} - -/// Finds where the casts of a file stop holding. -struct Reach<'a> { - tokens: &'a [Token], - /// Where each statement starts, with the end of the block it is in. - statements: Vec<(u32, u32)>, - /// Where each `elseif`, `else` and `end` of an `if` starts, with the end of that `if`. - branch_ends: FxHashMap, - /// Where each write to the local of a cast starts, in order, with the end of the innermost - /// statement it is in. - writes: Vec<(u32, u32)>, -} - -impl Reach<'_> { - /// Where a cast whose line ends at `at` starts holding, and the end of the block it holds to: - /// that of the statement after it. After the last statement of an `if` branch, a cast holds - /// from the end of the `if`, as code types what the branch leaves that way, and after that of - /// another block, such as a function or loop body, nowhere. - fn from(&self, at: u32) -> Option<(u32, u32)> { - let next = self.tokens.get(self.tokens.partition_point(|token| token.span.start < at))?; - if let Some(end) = self.branch_ends.get(&next.span.start) { - return self.from(*end); - } - if matches!(next.kind, TokenKind::End | TokenKind::Until | TokenKind::Eof) { - return None; - } - let next = self.statements.partition_point(|(start, _)| *start < at); - self.statements.get(next).map(|(_, end)| (at, *end)) - } - - /// The end of the innermost statement that the write starting at `at` is in. - fn end_of_write(&self, at: u32) -> u32 { - let index = self.writes.partition_point(|(start, _)| *start < at); - self.writes.get(index).map_or(u32::MAX, |(_, end)| *end) - } -} - -impl<'ast> Visitor<'ast> for Reach<'_> { - fn visit_block(&mut self, block: &'ast Block) { - self.statements.extend(block.stmts.iter().map(|stmt| (stmt.span.start, block.span.end))); - visit::walk_block(self, block); - } - - fn visit_stmt(&mut self, stmt: &'ast Stmt) { - // Statements are visited before those inside them, so the innermost one is seen last. - let first = self.writes.partition_point(|(at, _)| *at < stmt.span.start); - let after = self.writes.partition_point(|(at, _)| *at < stmt.span.end); - for (_, end) in &mut self.writes[first..after] { - *end = stmt.span.end; - } - if let StmtKind::If { branches, else_block } = &stmt.kind { - let tokens = self.tokens; - let before = |offset: u32| tokens[..tokens.partition_point(|token| token.span.start < offset)].last(); - let elseifs = branches.iter().skip(1).map(|branch| branch.keyword_span.start); - let keyword = else_block.as_ref().and_then(|block| before(block.span.start)); - let keyword = keyword.filter(|token| token.kind == TokenKind::Else).map(|token| token.span.start); - let end = before(stmt.span.end).filter(|token| token.kind == TokenKind::End); - for start in elseifs.chain(keyword).chain(end.map(|token| token.span.start)) { - self.branch_ends.insert(start, stmt.span.end); - } - } - visit::walk_stmt(self, stmt); + pub fn get(&self, index: u32) -> Option<&Cast> { + self.casts.get(index as usize) } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 07f1069..33fb34b 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2252,6 +2252,58 @@ print(read, clear, waitReady, setReady) assert!(client.hover_text(CLIENT, l, c).contains("later: integer\n")); } +#[test] +fn a_type_line_on_a_reassignment_types_the_value_it_assigns() { + let mut client = Client::start(fixture_root()); + let text = "---@alias Test.Data { job: string } + +local points = {} +local function reset() + ---@type vector2[] + points = { vec(1.0, 2.0) } + print(points) -- typed +end +local function draw() + for i = 1, #points do + points[i] = vec(points[i].x, points[i].y, 3.0) + local corner = points[i] + print(points, corner.z) -- drawn + end +end + +local cached = nil +RegisterNetEvent('test:cached', function(data) + ---@type Test.Data + cached = data +end) +local holder = {} +RegisterNetEvent('test:holder', function(data) + ---@type Test.Data + holder = data +end) +local function read() + return cached, holder -- read +end +print(reset, draw, read) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("points) -- typed", "local points: vector2[]"), + // Code that runs at other times may fill the local's own table with other values. + ("points, corner.z) -- drawn", "local points: table"), + ("corner = points", "local corner: unknown"), + // A local declared without a type, or with a table that is given a value of no known type, + // has the type the line gives. + ("cached, holder -- read", "local cached: Test.Data"), + ("holder -- read", "job: string"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + assert_eq!(findings(&mut client, CLIENT, &["undefined-field"]), []); +} + #[test] fn locals_that_are_assigned_again_are_checked_with_what_reaches_them() { let mut client = Client::start(fixture_root()); @@ -2308,6 +2360,359 @@ wants(mixed) ); } +#[test] +fn calls_that_yield_let_other_code_change_locals() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type 'pending'|'done' +local status = 'pending' +RegisterNetEvent('probe:done', function() status = 'done' end) +---@type 'idle'|'busy' +local mode = 'idle' +AddEventHandler('probe:busy', function() mode = 'busy' end) +local function sleep(ms) Wait(ms) end + +CreateThread(function() + if status == 'pending' then + while status ~= 'done' do Wait(0) end + end + if mode ~= 'idle' then return end + Wait(5000) + if mode == 'busy' then return end + sleep(10) + if mode == 'busy' then return end + local result = lib.callback.await('probe:get') + if mode == 'busy' then return end + print(result) + if mode == 'busy' then return end +end) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("impossible-comparison".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["impossible-comparison"]), + [finding("mode == 'busy' then return end\nend)", "Comparing `\"idle\"` with `\"busy\"` is always false")], + "`Wait`, a function of the file that waits and `await` let the handlers run, while `print` does not" + ); +} + +#[test] +fn functions_that_runtime_functions_run_later_leave_the_narrowing_alone() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type table? +local buffer +---@type table? +local other + +function Log(arg) + if not buffer then + buffer = {} + SetTimeout(500, function() buffer = nil end) + end + buffer[1] = arg + if not other then other = {} end + pcall(function() other = nil end) + other[1] = arg +end + +function Later(arg) + if buffer then + Wait(0) + buffer[2] = arg + end +end +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("need-check-nil".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil"]), + [ + finding("other[1]", "`other` may be nil: its type here is `table?`"), + finding("buffer[2]", "`buffer` may be nil: its type here is `table?`"), + ], + "`SetTimeout` runs its function once the code yields, while `pcall` runs it right away" + ); +} + +#[test] +fn assignments_narrow_the_fields_of_locals() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Entry +---@field length? number +---@field radius number +---@field vehicle? integer + +---@param n number +local function needsNumber(n) end + +---@param entry Probe.Entry +local function add(entry) + entry.length = entry.length or entry.radius * 2 + needsNumber(entry.length) + entry.vehicle = GetGameTimer() + needsNumber(entry.vehicle) +end + +---@param entry Probe.Entry +local function later(entry) + entry.length = 5 + CreateThread(function() needsNumber(entry.length) end) + Wait(0) + needsNumber(entry.length) +end +print(add, later) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str| { + let message = "Cannot assign `number?` to parameter `n` of type `number`"; + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [finding("function() needsNumber(entry.length)"), finding("needsNumber(entry.length)\nend")], + "a field holds the value assigned to it until a function that runs later, or a yield, may find another" + ); +} + +#[test] +fn locals_that_hold_what_type_gives_narrow_values_assigned_before_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param s string +local function needsString(s) end +---@return string|number +local function either() return 1 end + +---@param value string|number +local function show(value) + value = value or 'none' + local kind = type(value) + if kind == 'string' then needsString(value) end + local before = type(value) + value = either() + if before == 'string' then needsString(value) end +end +print(show) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str| { + let message = "Cannot assign `string|number` to parameter `s` of type `string`"; + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [finding("before == 'string'")], + "`type(value)` tells about the value the local holds until it is assigned again" + ); +} + +#[test] +fn many_functions_that_assign_the_same_locals_stay_fast() { + let mut client = Client::start(fixture_root()); + // Each handler may run while any other has assigned the locals, and the guards in each tell about + // all of what the others give. + let mut text = + "---@return string?\nlocal function maybe() end\nlocal busy = false\nlocal current = maybe()\n".to_string(); + text.push_str( + &"AddEventHandler('probe', function()\n if busy then return end\n busy = true\n current = maybe()\n \ + busy = false\nend)\n" + .repeat(600), + ); + text.push_str("print(busy, current) -- last\n"); + client.open_with(CLIENT, &text); + let started = Instant::now(); + for (needle, expected) in + [("busy, current) -- last", "busy: boolean\n"), ("current) -- last", "current: string?\n")] + { + let (line, character) = pos(&text, needle, 0); + let hover = client.hover_text(CLIENT, line, character); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + let elapsed = started.elapsed(); + assert!(elapsed < Duration::from_secs(5), "took {elapsed:?}"); +} + +#[test] +fn guards_narrow_the_fields_of_locals_until_something_may_change_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Job +---@field name string +---@field grade integer? + +---@class Probe.Data +---@field job Probe.Job? +---@field label string? +---@field mode 'a'|'b'|nil + +---@param data Probe.Data +---@param other Probe.Data +---@param key string +local function check(data, other, key) + if data.job then + print(data.job) -- held + print(data.job.grade) -- deeper + local job = data.job + print(job) -- copied + end + print(data.job) -- after + if data.job and data.job.grade then + print(data.job.grade) -- both + end + if data.label then + other.label = nil + print(data.label) -- same name + end + if data.label then + reset(data) + print(data.label) -- passed + end + if data.label then + data.label:upper() + print(data.label) -- method of the field + end + if data.mode == 'a' then + print(data.mode) -- compared + data = other + print(data.mode) -- local assigned + end + if data['label'] then + print(data['label']) -- index + end + if data.label then + CreateThread(function() + print(data.label) -- later + end) + end + if data.label then + for _ = 1, 2 do + print(data.label) -- loop + data.label = nil + end + end + if data.job and data.job.grade then + local job = data.job + reset(job) + print(data.job.grade) -- copy passed + end + if data.label then + local copy = data + copy[key] = nil + print(data.label) -- key not known + end + if data.label then + local list = {} + list[#list + 1] = data.label + print(data.label) -- other table + end + if data.label then + clear({ data }) + print(data.label) -- in a table + end + if data.label then + local list = { data } + clear(list) + print(data.label) -- in a local table + end + if data.label then + Wait(0) + print(data.label) -- waited + end +end +check() +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("job) -- held", "Probe.Job {"), + ("grade) -- deeper", "grade: integer?\n"), + ("job) -- copied", "local job: Probe.Job {"), + ("job) -- after", "Probe.Job? {"), + ("grade) -- both", "grade: integer\n"), + // An assignment to a field of that name may be one to this field, through another name. + ("label) -- same name", "label: string?\n"), + // A call that is given the table may change its fields. + ("label) -- passed", "label: string?\n"), + ("label) -- method of the field", "label: string\n"), + ("mode) -- compared", "mode: \"a\"\n"), + ("mode) -- local assigned", "mode: \"a\"|\"b\"|nil\n"), + ("label']) -- index", "label: string\n"), + // A function runs later, when the field may hold anything. + ("label) -- later", "label: string?\n"), + // The loop starts again after the field is assigned. + ("label) -- loop", "label: string?\n"), + // A call that is given a copy of the table, or a table built with it, may change its fields, + // as may an assignment to a key of the copy that is not known. + ("grade) -- copy passed", "grade: integer?\n"), + ("label) -- key not known", "label: string?\n"), + ("label) -- other table", "label: string\n"), + ("label) -- in a table", "label: string?\n"), + ("label) -- in a local table", "label: string?\n"), + // Other code runs while the call yields. + ("label) -- waited", "label: string?\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn checks_read_the_fields_that_guards_narrow() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Slot +---@field label string? +---@field mode 'a'|'b'|nil +---@field spots vector3|vector3[] + +---@param slot Probe.Slot +local function show(slot) + if type(slot.spots) ~= 'table' then + slot.spots = { slot.spots } + end + if slot.label then + local label = slot.label + print(label:upper()) + end + local plain = slot.label + print(plain:upper()) + if slot.mode == 'a' then + if slot.mode == 'b' then return end + end + print(slot.label:upper()) +end +show() +"; + client.open_with(CLIENT, text); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), + ); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + assert_eq!( + findings(&mut client, CLIENT, &["need-check-nil", "impossible-comparison", "missing-fields"]), + [ + ( + "need-check-nil".to_string(), + line("plain:upper"), + "`plain` may be nil: its type here is `string?`".to_string() + ), + ( + "impossible-comparison".to_string(), + line("slot.mode == 'b'"), + "Comparing `\"a\"` with `\"b\"` is always false".to_string() + ), + ], + "a local takes the type a guard narrows a field to, fields themselves are not checked for nil, and a field is given values of its declared type" + ); +} + #[test] fn guards_that_read_from_a_local_narrow_it() { let mut client = Client::start(fixture_root()); @@ -2626,6 +3031,90 @@ check() ); } +#[test] +fn guards_that_rule_out_every_declared_value_read_as_lua_language_server_does() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param entities number[] +local function plain(entities) + if type(entities) ~= 'table' then + print(entities) -- plain branch + entities = { entities } + end + print(entities) -- plain after +end + +---@param entities number[] | number +local function either(entities) + if type(entities) ~= 'table' then + print(entities) -- either branch + entities = { entities } + print(entities) -- either assigned + end + print(entities) -- either after +end + +---@param entities number[] | number +local function checked(entities) + if type(entities) == 'number' then + print(entities) -- checked branch + entities = { entities } + end + print(entities) -- checked after +end + +---@param count number +---@param name string +---@param both number|string +---@param missing nil +---@param action 'open'|'close' +local function guards(count, name, both, missing, action) + if not count then print(count) end -- not + if count == nil then print(count) end -- equal nil + if count ~= nil then print(count) else print(count) end -- else of not nil + if count == false then print(count) end -- false + if name == 5 then print(name) end -- integer + if count == 'x' then print(count) end -- string + if type(both) ~= 'number' and type(both) ~= 'string' then print(both) end -- neither kind + if missing then print(missing) end -- truthy nil + if action ~= 'open' and action ~= 'close' then print(action) end -- other literal +end +print(plain, either, checked, guards) +"; + client.open_with(CLIENT, text); + assert_eq!( + findings(&mut client, CLIENT, &["assign-type-mismatch"]), + [], + "a `number[]` that is not a table is unknown, which `{{ entities }}` stores without a mismatch" + ); + for (needle, expected) in [ + ("entities) -- plain branch", "entities: unknown\n"), + ("entities) -- plain after", "entities: number[]\n"), + ("entities) -- either branch", "entities: number\n"), + ("entities) -- either assigned", "entities: number[]\n"), + ("entities) -- either after", "entities: number[]\n"), + ("entities) -- checked branch", "entities: number\n"), + ("entities) -- checked after", "entities: number[]\n"), + // A check for a missing value leaves `nil`... + ("count) end -- not", "count: nil\n"), + ("count) end -- equal nil", "count: nil\n"), + ("count) end -- else of not nil", "count: nil\n"), + // ...a comparison with a literal of another kind leaves that kind... + ("count) end -- false", "count: boolean\n"), + ("name) end -- integer", "name: integer\n"), + ("count) end -- string", "count: string\n"), + // ...and a check that the value is not of its kind, or holds a value, leaves nothing known. + ("both) end -- neither kind", "both: unknown\n"), + ("missing) end -- truthy nil", "missing: unknown\n"), + // Other comparisons with literals keep the declared type. + ("action) end -- other literal", "action: \"open\"|\"close\"\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn casts_change_the_type_of_a_local_from_their_line_on() { let mut client = Client::start(fixture_root()); @@ -2735,19 +3224,20 @@ print(loose) -- removed from unknown ("other) -- without nil", "other: string|integer\n"), ("other) -- added and removed", "other: string|boolean\n"), ("scoped) -- in block", "scoped: integer\n"), - // A cast ends with its block... - ("scoped) -- after block", "scoped: string|integer|nil\n"), - // ...and once the local is assigned again, which gives it the type of its new value. + // A cast gives the local a value, as an assignment does: where the ways meet, it joins what + // the others leave, here the `nil` that skips the `if`... + ("scoped) -- after block", "scoped: integer?\n"), + // ...and the local holds it until it is assigned again. ("changed:upper()", "changed: string\n"), ("changed) -- reassigned", "changed: string\n"), ("guarded) -- cast over guard", "guarded: integer\n"), ("guarded) -- guard over cast", "guarded: string\n"), - // On the last line of an `if` branch, a cast types what the branch leaves for the code - // after the `if`, but not for its other branches... - ("converted) -- after branch", "converted: string\n"), - ("nested) -- after nested branch", "nested: integer\n"), + // On the last line of an `if` branch, a cast types what the branch leaves for the code after + // the `if`, beside what the other ways leave, but not for its other branches. + ("converted) -- after branch", "converted: string?\n"), + ("nested) -- after nested branch", "nested: integer|string|nil\n"), ("branched) -- next branch", "branched: \"a\"\n"), - // ...and on that of another block, it holds nowhere. + // A loop may run no time, and a function runs on its own. ("looped) -- after loop", "looped: string|integer|nil\n"), ("called) -- after function", "called: string|integer|nil\n"), // A line with no type keeps the guards before it. @@ -2770,6 +3260,51 @@ print(loose) -- removed from unknown } } +#[test] +fn casts_follow_the_ways_the_code_runs() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return string|number +local function get() end + +local value = get() +if math.random() > 0.5 then + ---@cast value string + print(value) -- branch +end +print(value) -- joined +local only = get() +if math.random() > 0.5 then + ---@cast only string +else + return +end +print(only) -- only way +local block = get() +do + ---@cast block number +end +print(block) -- after do +local added = get() +if math.random() > 0.5 then + ---@cast added +boolean +end +print(added) -- added on one way +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("value) -- branch", "value: string\n"), + ("value) -- joined", "value: string|number\n"), + ("only) -- only way", "only: string\n"), + ("block) -- after do", "block: number\n"), + ("added) -- added on one way", "added: string|number|boolean\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn casts_that_add_or_remove_types_keep_the_guards_before_them() { let mut client = Client::start(fixture_root()); @@ -2799,11 +3334,12 @@ print(skipped) -- after the label client.open_with(CLIENT, text); for (needle, expected) in [ ("items) -- added", "items: string[]|true|number[]\n"), - ("items) -- removed", "items: string[]\n"), - ("items) -- outside guards", "items: string[]|boolean|integer|nil\n"), + // The `number[]` that the branch before adds is one of the values after it. + ("items) -- removed", "items: string[]|number[]\n"), + ("items) -- outside guards", "items: string[]|number[]|true|false|integer\n"), ("skipped) -- before the label", "skipped: string[]|true|number\n"), - // The `goto` reaches the label without the guard. - ("skipped) -- after the label", "skipped: string[]|boolean|number|nil\n"), + // The `goto` reaches the label without the guard or the cast. + ("skipped) -- after the label", "skipped: string[]|true|number|false|nil\n"), ] { let (l, c) = pos(text, needle, 0); let hover = client.hover_text(CLIENT, l, c); @@ -4261,6 +4797,9 @@ local function GetHolder() end ---@return vector3? coords local function GetClosest() end +---@return nil | (number, vector3) +local function GetNearest() end + ---@return string? local function GetName() end @@ -4333,6 +4872,9 @@ local function handle(id, label, done, list, state) local vehicle, coords = GetClosest() if not vehicle then return end print(coords.x) + local nearest, spot = GetNearest() + if not nearest then return end + print(spot.x) print(guarded.job:upper()) print(GetHolder().name) local quiet = GetHolder() @@ -4348,6 +4890,8 @@ handle(1) findings(&mut client, CLIENT, &["need-check-nil"]), [ finding("holder.name", "`holder` may be nil: its type here is `Probe.Holder?`"), + // Every read is reported, also after one that would raise the error first. + finding("holder.job", "`holder` may be nil: its type here is `Probe.Holder?`"), finding("label:upper", "`label` may be nil: its type here is `string?`"), finding("done()", "`done` may be nil: its type here is `(fun())?`"), finding("#list", "`list` may be nil: its type here is `string[]?`"), @@ -4355,11 +4899,13 @@ handle(1) finding("amount + 1", "`amount` may be nil: its type here is `number?`"), finding("seen[key]", "`key` may be nil: its type here is `Probe.Holder?`"), finding("print(branch.name)", "`branch` may be nil: its type here is `Probe.Holder?`"), - // The read inside the `if` does not always run. finding("return branch.name", "`branch` may be nil: its type here is `Probe.Holder?`"), finding("print(always.name)", "`always` may be nil: its type here is `Probe.Holder?`"), + finding("return always.job", "`always` may be nil: its type here is `Probe.Holder?`"), + finding("print(twice.name)", "`twice` may be nil: its type here is `Probe.Holder?`"), finding("print(twice.name)", "`twice` may be nil: its type here is `Probe.Holder?`"), finding("count > 0", "`count` may be nil: its type here is `number?`"), + finding("count > 0", "`count` may be nil: its type here is `number?`"), finding("1, limit", "`limit` may be nil: its type here is `number?`"), // The cast is of the whole concatenation. finding("'id:' .. suffix", "`suffix` may be nil: its type here is `string?`"), @@ -4367,9 +4913,12 @@ handle(1) // The `break` leaves the loop without running the condition. finding("return looped", "`looped` may be nil: its type here is `string?`"), finding("until ended", "`ended` may be nil: its type here is `string?`"), + // A guard on `vehicle` tells nothing about `coords`, unless the function declares the sets + // of values it returns, as `GetNearest` does. + finding("coords.x", "`coords` may be nil: its type here is `vector3?`"), ], - "reads that guards, casts, an earlier read or a guarded value of the same call cover are left alone, \ - and so are reads after one reported in the same function, fields and the values of calls" + "every read that no guard or cast covers is reported, while fields and the values of calls are left \ + alone" ); client.notify( "workspace/didChangeConfiguration", @@ -4487,17 +5036,18 @@ show() finding("again.name", "`again` may be nil: its type here is `Probe.Box?`"), // The loop gives it `nil` before it starts again. finding("looped.name", "`looped` may be nil: its type here is `Probe.Box?`"), - // The guard on `other` covers the value of the call, not the one assigned after it. + // A guard on another value of the same call tells nothing about it. + finding("fn()", "`fn` may be nil: its type here is `function?`"), + finding("spot.name", "`spot` may be nil: its type here is `Probe.Box?`"), finding("place.name", "`place` may be nil: its type here is `Probe.Box?`"), ], "the values that reach a read of a local that is assigned again are checked; one that a guard, \ - `or`, a cast or a guarded value of the same call covers, one of no known type, and the missing \ - value of `local later`, are left alone" + `or` or a cast covers, one of no known type, and the missing value of `local later`, are left alone" ); } #[test] -fn nil_checks_leave_alone_values_the_code_relies_on() { +fn nil_checks_count_lookups_of_source_like_other_calls() { let mut client = Client::start(fixture_root()); let text = "\ ---@class Probe.Member @@ -4543,10 +5093,12 @@ first() [ // A local declared outside any function is checked like any other. finding("print(shared.name)", "`shared` may be nil: its type here is `Probe.Member?`"), + finding("print(member.name)", "`member` may be nil: its type here is `Probe.Member?`"), + finding("print(same.name)", "`same` may be nil: its type here is `Probe.Member?`"), finding("print(other.name)", "`other` may be nil: its type here is `Probe.Member?`"), finding("print(slot.name)", "`slot` may be nil: its type here is `Probe.Member?`"), ], - "what a call gives for `source` alone is left alone" + "what a call gives for `source` counts as its function declares it" ); } @@ -4557,11 +5109,13 @@ fn nil_checks_leave_alone_missing_values_the_guards_rule_out() { ---@param round? boolean ---@param mode? 'open'|'close' ---@param label? string -local function scale(round, mode, label) +---@param count number +local function scale(round, mode, label, count) local i = 0 print(round and (round == true or i < round)) if mode and mode ~= 'open' and mode ~= 'close' then print(mode:upper()) end if label ~= 'x' and label ~= 'y' then print(label:upper()) end + if not count then print(count.value) end end scale() "; @@ -4570,14 +5124,15 @@ scale() "workspace/didChangeConfiguration", json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "need-check-nil": "warning" } } } } }), ); + let finding = |needle: &str, message: &str| { + ("need-check-nil".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; assert_eq!( findings(&mut client, CLIENT, &["need-check-nil"]), - [( - "need-check-nil".to_string(), - pos(text, "label:upper", 0).0 as u64, - "`label` may be nil: its type here is `string?`".to_string() - )], - "guards that rule out every value of the type keep it whole, but not the `nil` they rule out" + [finding("label:upper", "`label` may be nil: its type here is `string?`")], + "comparisons with literals that rule out every value of the type keep it whole, but not the `nil` \ + they rule out, and a check for a missing value that rules out every value leaves only `nil`, as \ + lua-language-server reads it, which is no value that may be missing" ); } @@ -4661,9 +5216,10 @@ run() finding("greet(missing)", &passed("missing")), finding("Box:put(stored)", &passed("stored")), finding("print(read.name)", "`read` may be nil: its type here is `Probe.Target?`"), + finding("greet(read)", &passed("read")), ], "parameters that are optional or take nil, any value or a generic, overloads that take nil, guards, \ - earlier reads, natives, arguments of another type and suppressed `param-type-mismatch` are left alone" + natives, arguments of another type and suppressed `param-type-mismatch` are left alone" ); } @@ -6659,7 +7215,10 @@ end function Holder:set(item) self.item = item end -print(Machine.state, Machine.ratio, Machine.cleared, Machine.named, Machine.handler, Machine.reason, Holder.new().item, Machine.size) +-- A function runs later, when the fields may hold any of their values. +function Show() + print(Machine.state, Machine.ratio, Machine.cleared, Machine.named, Machine.handler, Machine.reason, Holder.new().item, Machine.size) +end "; client.open_with(CLIENT, text); let print = pos(text, "print(", 0).0; diff --git a/docs/reference.md b/docs/reference.md index 872703f..9f8635b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -688,7 +688,8 @@ that the other arguments declare: `value` is a `Player` in local has the types of the values it is declared with or assigned that may reach the comparison, so `local state = GetState()` is checked like the call, and inside a [type guard](../crates/qbx_lua_ls/README.md#type-guards) the type the guard narrows it to: the right -side of `class ~= 13 or class ~= 14` only runs when `class` is `13`, so it is always true. A +side of `class ~= 13 or class ~= 14` only runs when `class` is `13`, so it is always true. Guards +narrow the [fields](../crates/qbx_lua_ls/README.md#fields) of locals the same way. A literal stored in a local counts by its kind only: `local mode = 'dev'` is a setting to change, so `mode == 'prod'` passes, while `mode == false` is still reported as a `string` compared with `false`. A value assigned to a local with a `---@type` or `@param` keeps the parts of that type it @@ -764,9 +765,12 @@ local function GetName() end local name = GetName() print(name:upper()) -- `name` may be nil: its type here is `string?` -print(name:lower()) -- the read above raises the error first +print(name:lower()) -- `name` may be nil: its type here is `string?` ``` +As TypeScript does for a value that may be `undefined`, every such read is reported, also after one +that would raise the error first, until a guard or cast rules the missing value out. + A local may hold `nil` when its declared type allows it: its `---@type` or `@param`, the `@return` or `@field` of the function or class its value comes from, or for a parameter of a function passed to a call, what the callee declares for it, whoever declares them, stubs and other @@ -775,9 +779,11 @@ resources included. `false`, as in `false|string`, counts as well. The type first, so `if not name then return end`, `if name then`, `name and name:upper()`, `assert(name)` and `---@cast name -?` all check it, as does a condition that reads from it, such as `if player?.job then` for `player`. `?.`, `?[` and `?:` give `nil` for a missing value and are not -reported. Guards that together rule out every value of the type, as -`round and (round == true or i < round)` does for a `boolean?`, keep the type whole, but the `nil` -that `round and` rules out stays out. +reported. Where guards together rule out every value of the type, the read has the type that the +[type guards](../crates/qbx_lua_ls/README.md#type-guards) give such code. Inside +`if not count then`, a `number` is `nil`, which holds no value that may be missing and is not +reported, as in lua-language-server. After `round and (round == true or i < round)`, a `boolean?` +keeps its type, but the `nil` that `round and` rules out stays out. A local that may hold `nil` is reported as well where a call passes it for a parameter that does not take `nil`, in every signature the call may use as `param-type-mismatch` reads them. A parameter @@ -796,36 +802,31 @@ greet(player) -- `player` may be nil, which parameter `target` of type `Player` lua-language-server reports such an argument as a `param-type-mismatch`, so a `---@diagnostic disable` comment for that rule silences it too. +Only a guard on the local itself checks it. After +`local vehicle, coords = lib.getClosestVehicle(pos)` and `if not vehicle then return end`, `coords` +is still reported where it is declared `vector3?`, unless the function declares the +[sets of values](../crates/qbx_lua_ls/README.md#sets-of-returned-values) it returns, as +`@return nil | (integer, vector3)` does. A lookup such as `ESX.GetPlayerFromId(source)` that is +declared to return `xPlayer?` counts like any other call. + Only clear cases count, and the rest is left alone: -- Fields and the values of calls, which guards do not narrow: - `if self.target then self.target:kill() end` would be reported otherwise. +- Fields, which lua-language-server leaves alone too, and the values of calls. A local that takes + the value of a field has the type the guards around it leave of the field, so + `local job = data.job` inside `if data.job then` holds a value; see + [fields](../crates/qbx_lua_ls/README.md#fields). - A local that one of the values that may reach the read leaves without a declared type, as `name = name or 'none'` does, unless a `---@cast name T` types it. A local that is assigned again is checked with the values that reach the read, as described for [locals that are assigned again](../crates/qbx_lua_ls/README.md#locals-that-are-assigned-again), and `local name` declares no missing value: only the values given later count. -- A value of a call that a guard on another of its values covers. After - `local vehicle, coords = lib.getClosestVehicle(pos)` and `if not vehicle then return end`, - `coords` is not reported: annotations declare such values one by one, rather than as - [sets of values](../crates/qbx_lua_ls/README.md#sets-of-returned-values). -- What a call gives for `source` alone, or for a local that holds it, as - `ESX.GetPlayerFromId(source)` or `exports.qbx_core:GetPlayer(src)` after `local src = source`: - lookups of the player whose event or callback runs only miss one that has just left. - `ESX.GetPlayerFromId(target)` is reported. -- Reads after one reported in the same function, which points at the same missing check. -- Reads after one that always runs before them. Once `name:upper()` has run without an error, - `name` held a value, so the reads after it in its block are not reported, including those in - functions defined there. A read inside an `if` branch, on the right of `and` and `or`, or in the - condition of a `repeat` loop that a `break` can leave, does not count for the code after it. Guards only narrow the code after them, so a read inside a function defined before the guard is reported, as `return name:upper()` is in a `local function` above `if not name then return end`: the function may run before the guard does. lua-language-server reports it as well. Unlike lua-language-server, qbx-lua-ls also checks arithmetic, concatenation, `#`, `<`, `<=`, `>`, -`>=` and `for` bounds, leaves alone the lookups of `source` above, and reports a local once per -function rather than at every read. +`>=` and `for` bounds. ## Events, exports, and locales diff --git a/docs/rules.md b/docs/rules.md index fbfc44e..beeaa8b 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -51,7 +51,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `self-comparison` | warning | A comparison has the same expression on both sides. | | `count-down-loop` | warning | A numeric `for` loop starts above its end without a negative step: a literal start above a literal end, with no step or a positive one, which never runs, or `#list, 1` or arithmetic on `#list` such as `#list - 1, 1`, with no step, which never runs once the start is above 1. Fix available. | | `impossible-comparison` | info | An `==` or `~=` compares values whose declared types share no value, such as a string with a number, or a literal with a type that does not list it, so it always gives the same answer. Comparisons with `nil` are not reported. Reported by qbx-lua-ls only. | -| `need-check-nil` | warning | A local whose declared type allows `nil` or `false`, such as the `string?` a function's `@return` declares, is indexed, called, used as a key to store a value under, used in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, given as a numeric `for` bound, or passed for a parameter that does not take `nil`, with no guard or cast ruling the missing value out. A local is reported once per function. What a call gives for `source` alone is not reported. Reported by qbx-lua-ls only. | +| `need-check-nil` | warning | A local whose declared type allows `nil` or `false`, such as the `string?` a function's `@return` declares, is indexed, called, used as a key to store a value under, used in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, given as a numeric `for` bound, or passed for a parameter that does not take `nil`, with no guard or cast ruling the missing value out. Every such read is reported. Reported by qbx-lua-ls only. | | `lowercase-global` | warning | A global definition starts with a lowercase letter. | | `implicit-global` | warning | A function creates a global without a file-scope declaration. | | `builtin-overwrite` | warning | Code overwrites a known runtime global or native. | From 4de39bafbf8336531e6ba585d1a98f2eab7a3fce Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:59:01 +0200 Subject: [PATCH 067/193] fix(infer): type `and` and `or` by what each side can be --- crates/qbx_lua_ls/src/infer.rs | 94 ++++++++++++++++++++++++++++------ crates/qbx_lua_ls/tests/lsp.rs | 54 ++++++++++++++++++- 2 files changed, 130 insertions(+), 18 deletions(-) diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index c1153c6..3b5a0fa 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -15,7 +15,7 @@ use crate::callback_wrappers::{self, Wrapper}; use crate::index::{instance_class, instance_owner, ClassDef, FileId, Index, SymbolKind}; use crate::locate::statement_at; use crate::luacats::{applies_on, own_type, parse_doc_lines, CastEntry, DocGroup, DocOperator}; -use crate::narrow::{Cast, Casts, Fact, Flow, Origin, Version, DECLARATION}; +use crate::narrow::{Casts, Fact, Flow, Origin, Version, DECLARATION}; use crate::types::{CallbackRole, FunType, Param, Shape, ShapeField, Type, TypeName, TypeParser}; const MAX_DEPTH: u32 = 24; @@ -1244,7 +1244,7 @@ impl<'a> Infer<'a> { // takes from it. Origin::Cast(cast) => { let Some(cast) = self.ctx.casts().get(cast) else { return Type::Unknown }; - let held = match replaces(cast) { + let held = match cast.replaces() { true => Type::Unknown, false => self.local_type_at(id, cast.span.start), }; @@ -2381,18 +2381,18 @@ impl<'a> Infer<'a> { match op { BinOp::Concat => operator("concat").unwrap_or(Type::String), BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => Type::Boolean, - // `a and b` is `b` whenever `a` holds a value, so an unknown `b` leaves it unknown. - BinOp::And => match self.expr(rhs).widen() { + // `a and b` is `b` whenever `a` holds a value, and the `nil` or `false` of `a` otherwise, + // so `cond and 'a'` is a `string|false`. An unknown `b` leaves it unknown. + BinOp::And => match self.expr(rhs) { rhs if rhs.is_unknown() => Type::Unknown, - rhs => Type::union([rhs, Type::BooleanLit(false)]).widen(), + rhs => booleans_joined(Type::union( + [widened_values(&rhs)].into_iter().chain(self.falsy_parts(&self.expr(lhs))), + )), }, + // `a or b` is what `a` holds when it holds a value, and `b` otherwise, so + // `cond and 'a' or 'b'` is a `string`. BinOp::Or => { - let left = self.expr(lhs).without_nil().widen(); - let left = match left { - Type::Boolean => Type::Unknown, - other => other, - }; - Type::union([left, self.expr(rhs).widen()]) + booleans_joined(Type::union([self.truthy_part(&self.expr(lhs)), widened_values(&self.expr(rhs))])) } BinOp::BAnd | BinOp::BOr | BinOp::BXor | BinOp::Shl | BinOp::Shr | BinOp::IDiv => { let name = match op { @@ -2435,6 +2435,48 @@ impl<'a> Infer<'a> { } } + /// The values of `ty` that are false, which `a and b` gives for an `a` of that type: its `nil` + /// and its `false`, also that of a `boolean`. A type that tells nothing gives none. + fn falsy_parts(&self, ty: &Type) -> Vec { + let expanded = self.expand_aliases(ty, 0); + let parts = match &expanded { + Type::Union(parts) => parts.as_slice(), + one => std::slice::from_ref(one), + }; + let mut out = Vec::new(); + for part in parts { + let falsy = match part { + Type::Nil => Type::Nil, + Type::Boolean | Type::BooleanLit(false) => Type::BooleanLit(false), + _ => continue, + }; + if !out.contains(&falsy) { + out.push(falsy); + } + } + out + } + + /// What `ty` holds when it holds a true value, which `a or b` gives for an `a` of that type: all + /// of it but `nil` and `false`, and `true` of a `boolean`. + fn truthy_part(&self, ty: &Type) -> Type { + let expanded = self.expand_aliases(ty, 0); + let parts = match &expanded { + Type::Union(parts) => parts.as_slice(), + one => std::slice::from_ref(one), + }; + // A type with no false value, as an alias of one, is shown as it is written. + if !parts.iter().any(|part| matches!(part, Type::Nil | Type::Boolean | Type::BooleanLit(false))) { + return widened_values(ty); + } + let truthy = parts.iter().filter_map(|part| match part { + Type::Nil | Type::BooleanLit(false) => None, + Type::Boolean => Some(Type::BooleanLit(true)), + other => Some(widened_values(other)), + }); + expanded.rebuilt(truthy) + } + /// The callee's function type, taking `base:method` lookups into account. pub fn callee_fun(&self, base: &Expr, method: Option<&Name>) -> Option<(Arc, Option)> { match method { @@ -3976,7 +4018,7 @@ pub fn distinct_bases(given: impl Iterator>) -> (Rc<[Type]>, /// The union of the types of the values a local may hold. A literal gives way to its kind when /// another value is of that kind, so `"active"|"busy"` and a `string` assigned later make a `string`, -/// and an `integer` gives way to a `number`. +/// an `integer` gives way to a `number`, and a `true` and a `false` make a `boolean`. fn merge_versions(types: Vec) -> Type { if types.len() < 2 { return types.into_iter().next().unwrap_or_default(); @@ -4002,12 +4044,32 @@ fn merge_versions(types: Vec) -> Type { }; ty.rebuilt(all[index].iter().filter(|part| !absorbed(part)).cloned()) }); - Type::union(merged) + booleans_joined(Type::union(merged)) +} + +/// `ty` with the strings and numbers it lists widened to their kinds, as a value written out is +/// shown, while `true` and `false` stay: they tell which way an `and` or `or` goes. +fn widened_values(ty: &Type) -> Type { + match ty { + Type::Union(parts) => ty.rebuilt(parts.iter().map(widened_values)), + Type::BooleanLit(_) => ty.clone(), + other => other.widen(), + } } -/// Whether a cast replaces the type of its local, rather than adding or taking out types. -fn replaces(cast: &Cast) -> bool { - cast.entries.iter().any(|(entry, _)| matches!(entry, CastEntry::Replace(_))) +/// `ty` with a `true` and a `false` it lists, or either beside a `boolean`, joined into a `boolean`. +fn booleans_joined(ty: Type) -> Type { + let Type::Union(parts) = &ty else { return ty }; + let both = parts.contains(&Type::BooleanLit(true)) && parts.contains(&Type::BooleanLit(false)); + let literal = parts.iter().any(|part| matches!(part, Type::BooleanLit(_))); + if !(both || (literal && parts.contains(&Type::Boolean))) { + return ty; + } + let joined = parts.iter().map(|part| match part { + Type::BooleanLit(_) => Type::Boolean, + other => other.clone(), + }); + ty.rebuilt(joined) } /// The `---@type` that `doc` gives the name at `index` of the `local` statement below it. A local diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 33fb34b..8097060 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -2015,6 +2015,56 @@ print(state, copy, level, label, changed, mode) } } +#[test] +fn and_and_or_give_what_each_side_can_be() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Thing +---@field name string + +---@return Probe.Thing +local function make() end +---@type boolean +local cond +---@type Probe.Thing? +local optional +local thing = make() +local untyped = {} +local picked = cond and 'a' or 'b' +local mixed = cond and 1 or 'x' +local guarded = optional and optional.name +local always = thing and thing.name +local partial = cond and 'a' +local skipped = cond and nil or 5 +local kept = cond and optional or nil +local flag = cond and true or false +local compared = cond and #untyped == 1 +local fallback = cond or 'x' +local unknown = untyped and untyped.missing +print(picked, mixed, guarded, always, partial, skipped, kept, flag, compared, fallback, unknown) +"; + client.open_with(CLIENT, text); + for (needle, expected) in [ + ("picked =", "local picked: string\n"), + ("mixed =", "local mixed: integer|string\n"), + // `a and b` is the `nil` or `false` of `a`, or `b`... + ("guarded =", "local guarded: string?\n"), + ("always =", "local always: string\n"), + ("partial =", "local partial: string|false\n"), + // ...and `a or b` what `a` holds when it holds a value, or `b`. + ("skipped =", "local skipped: integer\n"), + ("kept =", "local kept: Probe.Thing? {"), + ("flag =", "local flag: boolean\n"), + ("compared =", "local compared: boolean\n"), + ("fallback =", "local fallback: true|string\n"), + ("unknown =", "local unknown: unknown\n"), + ] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn guards_narrow_the_locals_they_test() { let mut client = Client::start(fixture_root()); @@ -3336,10 +3386,10 @@ print(skipped) -- after the label ("items) -- added", "items: string[]|true|number[]\n"), // The `number[]` that the branch before adds is one of the values after it. ("items) -- removed", "items: string[]|number[]\n"), - ("items) -- outside guards", "items: string[]|number[]|true|false|integer\n"), + ("items) -- outside guards", "items: string[]|number[]|boolean|integer\n"), ("skipped) -- before the label", "skipped: string[]|true|number\n"), // The `goto` reaches the label without the guard or the cast. - ("skipped) -- after the label", "skipped: string[]|true|number|false|nil\n"), + ("skipped) -- after the label", "skipped: string[]|boolean|number|nil\n"), ] { let (l, c) = pos(text, needle, 0); let hover = client.hover_text(CLIENT, l, c); From 4219c4b0b1b5d00e5ba1fe884d8492103558aa94 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:21:54 +0200 Subject: [PATCH 068/193] feat(rules): report values of unknown type that declared types take in no-unknown --- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_ls/src/features/arguments.rs | 78 ++++++++++++- crates/qbx_lua_ls/src/features/assignments.rs | 35 +++++- .../qbx_lua_ls/src/features/class_tables.rs | 25 +++- crates/qbx_lua_ls/src/features/diagnostics.rs | 9 +- crates/qbx_lua_ls/src/features/returns.rs | 22 ++++ .../qbx_lua_ls/src/features/unknown_types.rs | 110 ++++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 76 ++++++++++++ docs/reference.md | 19 +++ docs/rules.md | 2 +- 10 files changed, 338 insertions(+), 40 deletions(-) diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index b8a1423..a438549 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -171,7 +171,7 @@ pub static RULES: &[Rule] = &[ rule(REDUNDANT_RETURN_VALUE, Correctness, WARN, false, "A function returns more values than its @return annotations declare (language server only)."), rule(DISCARD_RETURNS, Correctness, WARN, false, "A call drops the values of a function whose @nodiscard annotation requires using them (language server only)."), rule(CAST_TYPE_MISMATCH, Correctness, WARN, false, "A ---@cast gives a variable a type that its declared type does not take (language server only)."), - rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type: none is declared and none can be inferred (language server only)."), + rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type, or a value of unknown type goes where a type is declared (language server only)."), rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), rule(SELF_ASSIGNMENT, Suspicious, WARN, false, "A variable is assigned to itself."), rule(SELF_COMPARISON, Suspicious, WARN, false, "Both sides of a comparison are the same expression."), diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs index bf90b22..b293c34 100644 --- a/crates/qbx_lua_ls/src/features/arguments.rs +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -30,6 +30,7 @@ use qbx_lua_syntax::{SmolStr, Span}; use super::callback_payloads::Payload; use super::class_tables::Classes; use super::comparisons::Declared; +use super::unknown_types::is_typed; use crate::index::{FileId, FileOrigin}; use crate::infer::{Infer, NATIVE_HANDLE_TYPES}; use crate::types::{FunType, Param, Type}; @@ -37,12 +38,32 @@ use crate::types::{FunType, Param, Type}; /// Each argument whose parameter does not take it, with the message naming both types, also among /// the `payloads` of the wrapper calls of `chunk`. pub fn mismatched_arguments(infer: &Infer, chunk: &Chunk, payloads: &[Payload]) -> Vec<(Span, String)> { - let mut calls = Calls { infer, declared: Declared::new(infer), classes: Classes::new(infer), out: Vec::new() }; + checked_arguments(infer, chunk, payloads, None) +} + +/// Each argument of no known type, of those `pick` picks, passed for a parameter whose type is +/// declared in each signature that the call may use, with the message naming the parameter. +pub fn unknown_arguments( + infer: &Infer, + chunk: &Chunk, + payloads: &[Payload], + pick: &dyn Fn(&Expr) -> bool, +) -> Vec<(Span, String)> { + checked_arguments(infer, chunk, payloads, Some(pick)) +} + +fn checked_arguments( + infer: &Infer, + chunk: &Chunk, + payloads: &[Payload], + unknowns: Option<&dyn Fn(&Expr) -> bool>, +) -> Vec<(Span, String)> { + let mut calls = + Calls { infer, declared: Declared::new(infer), classes: Classes::new(infer), unknowns, out: Vec::new() }; calls.visit_block(&chunk.block); for payload in payloads { - let values: Vec = payload.args.iter().map(|arg| calls.value(arg)).collect(); let handlers: Vec<&FunType> = payload.handlers.iter().collect(); - calls.compare(&handlers, false, payload.args, &values); + calls.passed(&handlers, false, payload.args); } calls.out } @@ -51,6 +72,9 @@ struct Calls<'a, 'b> { infer: &'a Infer<'b>, declared: Declared<'a, 'b>, classes: Classes<'a, 'b>, + /// When the arguments of no known type are looked for instead of mismatches, which of them + /// count. + unknowns: Option<&'a dyn Fn(&Expr) -> bool>, out: Vec<(Span, String)>, } @@ -71,9 +95,53 @@ impl Calls<'_, '_> { if signatures.is_empty() { return; } - let values: Vec = args.iter().map(|arg| self.value(arg)).collect(); let signatures: Vec<&FunType> = signatures.iter().map(Arc::as_ref).collect(); - self.compare(&signatures, method.is_some(), args, &values); + self.passed(&signatures, method.is_some(), args); + } + + /// Checks `args`, passed to a function that has one of `signatures`. + fn passed(&mut self, signatures: &[&FunType], via_colon: bool, args: &[Expr]) { + if let Some(pick) = self.unknowns { + return self.unknown(signatures, via_colon, args, pick); + } + let values: Vec = args.iter().map(|arg| self.value(arg)).collect(); + self.compare(signatures, via_colon, args, &values); + } + + /// Reports each of `args` of no known type that `pick` picks, when the parameter it goes to has a + /// declared type in each signature the call may use. + fn unknown(&mut self, signatures: &[&FunType], via_colon: bool, args: &[Expr], pick: &dyn Fn(&Expr) -> bool) { + let candidates = candidates(self.infer, signatures, via_colon, args); + for (index, arg) in args.iter().enumerate() { + let given = self.infer.cast_after(arg.span.end).unwrap_or_else(|| self.infer.expr(arg)); + if !given.is_unknown() || !pick(arg) { + continue; + } + // The parameter it goes to in the first signature, when each has a declared type. + let mut first: Option<&Param> = None; + for fun in candidates.iter().copied() { + let Some(param) = param_for(fun, via_colon, index) else { + first = None; + break; + }; + let is_generic = |name: &str| fun.generics.iter().any(|generic| generic == name); + let is_handle = |name: &str| NATIVE_HANDLE_TYPES.contains(&name); + let expected = expected(param); + if !is_typed(expected) || mentions(expected, &is_generic) || names(expected, &is_handle) { + first = None; + break; + } + first.get_or_insert(param); + } + if let Some(param) = first { + let message = format!( + "The type of the value passed to parameter `{}` of type `{}` is unknown", + param.name, + expected(param) + ); + self.out.push((arg.span, message)); + } + } } /// The declared type of an argument, or the type a `--[[@as T]]` right after it gives it. diff --git a/crates/qbx_lua_ls/src/features/assignments.rs b/crates/qbx_lua_ls/src/features/assignments.rs index 525f37d..82be24b 100644 --- a/crates/qbx_lua_ls/src/features/assignments.rs +++ b/crates/qbx_lua_ls/src/features/assignments.rs @@ -9,8 +9,8 @@ use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::Span; -use super::class_tables::{mismatched_fields, Classes}; -use super::unknown_types::has_param_line; +use super::class_tables::{mismatched_fields, unknown_fields, Classes}; +use super::unknown_types::{given_values, has_param_line, is_typed, value_at}; use crate::infer::{Decl, Infer}; use crate::types::Type; @@ -18,7 +18,18 @@ use crate::types::Type; /// naming both types. A value that its field rejects is reported for the field alone. pub fn mismatched_assignments(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let mut out = mismatched_fields(infer, chunk); - let mut finder = Finder { infer, classes: Classes::new(infer), out: Vec::new() }; + let mut finder = Finder { infer, classes: Classes::new(infer), unknowns: None, out: Vec::new() }; + finder.visit_block(&chunk.block); + finder.out.retain(|(span, _)| !out.iter().any(|(reported, _)| reported == span)); + out.append(&mut finder.out); + out +} + +/// Each value of no known type, of those `pick` picks, stored in a class field or a variable whose +/// type is declared, with the message naming the target. +pub fn unknown_assignments(infer: &Infer, chunk: &Chunk, pick: &dyn Fn(&Expr) -> bool) -> Vec<(Span, String)> { + let mut out = unknown_fields(infer, chunk, pick); + let mut finder = Finder { infer, classes: Classes::new(infer), unknowns: Some(pick), out: Vec::new() }; finder.visit_block(&chunk.block); finder.out.retain(|(span, _)| !out.iter().any(|(reported, _)| reported == span)); out.append(&mut finder.out); @@ -28,6 +39,8 @@ pub fn mismatched_assignments(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String struct Finder<'a, 'b> { infer: &'a Infer<'b>, classes: Classes<'a, 'b>, + /// When the values of no known type are looked for instead of mismatches, which of them count. + unknowns: Option<&'a dyn Fn(&Expr) -> bool>, out: Vec<(Span, String)>, } @@ -58,12 +71,26 @@ impl Finder<'_, '_> { return; } let from = self.classes.file(); - for (index, (target, (given, span))) in targets.iter().zip(self.classes.values(exprs)).enumerate() { + let mut values = self.classes.values(exprs); + if self.unknowns.is_some() { + values = given_values(self.infer, exprs, values); + } + for (index, (target, (given, span))) in targets.iter().zip(values).enumerate() { let Some(expected) = &target.ty else { continue }; // LuaLS lets the statement whose `---@type` declares a name give it `nil`. if target.annotated && exprs.get(index).is_some_and(|expr| matches!(expr.kind, ExprKind::Nil)) { continue; } + if let Some(pick) = self.unknowns { + if given.is_unknown() && is_typed(expected) && value_at(exprs, index).is_some_and(pick) { + let name = target.name.text(self.infer.ctx.source); + self.out.push(( + span, + format!("The type of the value assigned to `{name}` of type `{expected}` is unknown"), + )); + } + continue; + } if self.classes.rejects(expected, from, &given) { let shown = if self.classes.literal_mismatch(expected, from, &given) { given } else { given.widen() }; let name = target.name.text(self.infer.ctx.source); diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 4b44e07..bd1a790 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -20,6 +20,7 @@ use qbx_lua_syntax::{NumberValue, SmolStr, Span}; use rustc_hash::{FxHashMap, FxHashSet}; use super::comparisons::Declared; +use super::unknown_types::is_typed; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; use crate::infer::{bound_parents, class_bindings, expanded_bindings, substitute, Infer}; use crate::luacats::applies_on; @@ -896,17 +897,33 @@ pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { /// `table` are checked the same way, but only classes are checked in assignments. Only clear /// cases count: a different kind of value, or a literal the field does not list. pub fn mismatched_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + checked_fields(infer, chunk, None) +} + +/// Each value of no known type, of those `pick` picks, that a typed table constructor or an +/// assignment stores in a field whose type is declared, as `mismatched_fields` finds them. +pub fn unknown_fields(infer: &Infer, chunk: &Chunk, pick: &dyn Fn(&Expr) -> bool) -> Vec<(Span, String)> { + checked_fields(infer, chunk, Some(pick)) +} + +fn checked_fields(infer: &Infer, chunk: &Chunk, unknowns: Option<&dyn Fn(&Expr) -> bool>) -> Vec<(Span, String)> { let classes = Classes::new(infer); let mut out = Vec::new(); let mut check = |field: Option<(Type, FileId)>, key: &Key, value: &Expr| { let Some((ty, file)) = field else { return }; let given = classes.value_type(value); + let target = match key.field() { + Some(field) => format!("field `{field}`"), + None => format!("`[{}]`", key.ty()), + }; + if let Some(pick) = unknowns { + if given.is_unknown() && is_typed(&ty) && pick(value) { + out.push((value.span, format!("The type of the value assigned to {target} of type `{ty}` is unknown"))); + } + return; + } if classes.rejects(&ty, file, &given) { let shown = if classes.literal_mismatch(&ty, file, &given) { given } else { given.widen() }; - let target = match key.field() { - Some(field) => format!("field `{field}`"), - None => format!("`[{}]`", key.ty()), - }; out.push((value.span, format!("Cannot assign `{shown}` to {target} of type `{ty}`"))); } }; diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index f7c1484..a64a591 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -27,7 +27,7 @@ use super::doc_names::undefined_doc_names; use super::nil_checks::{unchecked_nils, UncheckedNils}; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; -use super::unknown_types::unknown_types; +use super::unknown_types::{unknown_types, unknown_values}; use super::visibility::invisible_members; use crate::document::Document; use crate::index::{FileId, FileOrigin}; @@ -106,7 +106,12 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), (MISSING_PARAMETER, |input| missing_payloads(input.infer, input.payloads())), (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), - (NO_UNKNOWN, |input| unknown_types(input.infer, &input.ws.lint_config.ignore_unused_prefix)), + (NO_UNKNOWN, |input| { + let prefix = &input.ws.lint_config.ignore_unused_prefix; + let mut out = unknown_types(input.infer, prefix); + out.extend(unknown_values(input.infer, &input.doc.chunk, input.payloads(), prefix)); + out + }), (IMPOSSIBLE_COMPARISON, |input| impossible_comparisons(input.infer, &input.doc.chunk)), (NEED_CHECK_NIL, |input| { let UncheckedNils { reads, arguments } = unchecked_nils(input.infer, &input.doc.chunk); diff --git a/crates/qbx_lua_ls/src/features/returns.rs b/crates/qbx_lua_ls/src/features/returns.rs index 0fe87ad..840806d 100644 --- a/crates/qbx_lua_ls/src/features/returns.rs +++ b/crates/qbx_lua_ls/src/features/returns.rs @@ -23,6 +23,7 @@ use qbx_lua_syntax::Span; use rustc_hash::FxHashSet; use super::class_tables::Classes; +use super::unknown_types::{given_values, is_typed, value_at}; use crate::infer::{always_exits, return_stmts, Infer}; use crate::types::Type; @@ -40,6 +41,27 @@ pub fn mismatched_returns(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { out } +/// Each returned value of no known type, of those `pick` picks, whose function's `@return` declares +/// its type, with the message naming the position. +pub fn unknown_returns(infer: &Infer, chunk: &Chunk, pick: &dyn Fn(&Expr) -> bool) -> Vec<(Span, String)> { + let classes = Classes::new(infer); + let mut out = Vec::new(); + for function in documented(infer, chunk) { + for (_, exprs) in return_stmts(&function.func.body) { + let values = given_values(infer, exprs, classes.values(exprs)); + let returns = function.closest(&classes, exprs, &values); + for (index, (given, span)) in values.iter().enumerate() { + let Some(expected) = expected_at(returns, index) else { break }; + if given.is_unknown() && is_typed(expected) && value_at(exprs, index).is_some_and(pick) { + let message = format!("The type of return value #{} of type `{expected}` is unknown", index + 1); + out.push((*span, message)); + } + } + } + } + out +} + /// Each of `values` that the `@return` type at its position does not take. fn mismatches(classes: &Classes, returns: &[Type], values: &[(Type, Span)]) -> Vec<(Span, String)> { let mut out = Vec::new(); diff --git a/crates/qbx_lua_ls/src/features/unknown_types.rs b/crates/qbx_lua_ls/src/features/unknown_types.rs index 4d2faf1..a650b51 100644 --- a/crates/qbx_lua_ls/src/features/unknown_types.rs +++ b/crates/qbx_lua_ls/src/features/unknown_types.rs @@ -1,39 +1,78 @@ //! `no-unknown`: a parameter, local or loop variable that has no type, because none is declared -//! and none can be inferred. The rule is off until a project turns it on, since most code is not -//! annotated this far. +//! and none can be inferred, and a value of no known type that code stores in a variable or field, +//! passes for a parameter or returns where a type is declared for it, other than `any`. A value read +//! from a local the rule reports itself is left out, as that report names what gives both a type. +//! The rule is off until a project turns it on, since most code is not annotated this far. -use qbx_lua_analysis::scope::{Local, LocalId, LocalKind}; +use qbx_lua_analysis::scope::{Local, LocalId, LocalKind, Resolved}; +use qbx_lua_syntax::ast::*; use qbx_lua_syntax::Span; +use rustc_hash::FxHashSet; +use super::arguments::unknown_arguments; +use super::assignments::unknown_assignments; +use super::callback_payloads::Payload; +use super::returns::unknown_returns; use crate::infer::{Decl, Infer}; use crate::types::Type; +/// Each value of no known type that a variable, field, parameter or `@return` with a declared type +/// is given. One read from a local that `unknown_types` reports itself is left out, as that report +/// names what would give it a type. +pub fn unknown_values(infer: &Infer, chunk: &Chunk, payloads: &[Payload], ignored_prefix: &str) -> Vec<(Span, String)> { + let reported: FxHashSet = untyped_locals(infer, ignored_prefix).into_iter().map(|(id, _)| id).collect(); + let pick = |expr: &Expr| !root_local(infer, expr).is_some_and(|id| reported.contains(&id)); + let mut out = unknown_assignments(infer, chunk, &pick); + out.extend(unknown_arguments(infer, chunk, payloads, &pick)); + out.extend(unknown_returns(infer, chunk, &pick)); + out +} + +/// The local that `expr` names, or that the fields, indexes and calls it makes are read from. +fn root_local(infer: &Infer, expr: &Expr) -> Option { + match &expr.unparen().kind { + ExprKind::Name(name) => match infer.ctx.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => Some(id), + _ => None, + }, + ExprKind::Field { base, .. } | ExprKind::Index { base, .. } | ExprKind::MethodCall { base, .. } => { + root_local(infer, base) + } + ExprKind::Call { callee, .. } => root_local(infer, callee), + _ => None, + } +} + +/// Whether `ty`, the declared type of where a value goes, says which values it takes: neither +/// `any` nor `unknown`, which take anything. +pub(super) fn is_typed(ty: &Type) -> bool { + !matches!(ty, Type::Unknown | Type::Any) +} + +/// `values`, the values that `exprs` give as `Classes::values` types them, with one of unknown type +/// for a call of no known type at their end, which gives none there. +pub(super) fn given_values(infer: &Infer, exprs: &[Expr], mut values: Vec<(Type, Span)>) -> Vec<(Type, Span)> { + if let Some(last) = exprs.last().filter(|last| last.is_call()) { + if values.len() < exprs.len() && infer.expr(last).is_unknown() { + values.push((Type::Unknown, last.span)); + } + } + values +} + +/// The expression that gives value `index` of those `exprs` give: the one at that place, or the call +/// that ends them. +pub(super) fn value_at(exprs: &[Expr], index: usize) -> Option<&Expr> { + exprs.get(index).or_else(|| exprs.last().filter(|last| last.is_multi_value())) +} + /// The declaration of each local name whose type is unknown, with a message naming what gives it /// one. `self`, local functions and the names that start with `ignored_prefix`, as unused locals /// do, are left out. pub fn unknown_types(infer: &Infer, ignored_prefix: &str) -> Vec<(Span, String)> { let mut out = Vec::new(); - for (id, local) in infer.ctx.resolution.locals.iter().enumerate() { + for (_, local) in untyped_locals(infer, ignored_prefix) { let name = local.name.as_str(); - let ignored = name == "_" || (!ignored_prefix.is_empty() && name.starts_with(ignored_prefix)); - if name.is_empty() || name == "self" || ignored { - continue; - } - if matches!(local.kind, LocalKind::ImplicitSelf | LocalKind::LocalFunction) { - continue; - } - let known = match infer.local_type(id as LocalId) { - Type::Unknown => false, - // The handler of `RegisterNetEvent(name, function(payload) end)` takes `any` from the - // `...` of the `fun(...)` it is passed as, which says no more about `payload` than - // nothing does. A parameter the function type names, like `value` of - // `fun(reason: string, value: any)`, is declared `any`. - Type::Any if local.kind == LocalKind::Param => has_param_line(infer, local) || !from_vararg(infer, local), - _ => true, - }; - if known { - continue; - } let message = match local.kind { LocalKind::Param => format!("Parameter `{name}` has no type; add `---@param {name} `"), LocalKind::LoopVar => { @@ -46,6 +85,31 @@ pub fn unknown_types(infer: &Infer, ignored_prefix: &str) -> Vec<(Span, String)> out } +/// The locals whose type is unknown, as `unknown_types` reports them. +fn untyped_locals<'i>(infer: &'i Infer, ignored_prefix: &str) -> Vec<(LocalId, &'i Local)> { + let locals = infer.ctx.resolution.locals.iter().enumerate().map(|(id, local)| (id as LocalId, local)); + let untyped = locals.filter(|(id, local)| { + let name = local.name.as_str(); + let ignored = name == "_" || (!ignored_prefix.is_empty() && name.starts_with(ignored_prefix)); + if name.is_empty() || name == "self" || ignored { + return false; + } + if matches!(local.kind, LocalKind::ImplicitSelf | LocalKind::LocalFunction) { + return false; + } + match infer.local_type(*id) { + Type::Unknown => true, + // The handler of `RegisterNetEvent(name, function(payload) end)` takes `any` from the + // `...` of the `fun(...)` it is passed as, which says no more about `payload` than + // nothing does. A parameter the function type names, like `value` of + // `fun(reason: string, value: any)`, is declared `any`. + Type::Any if local.kind == LocalKind::Param => !has_param_line(infer, local) && from_vararg(infer, local), + _ => false, + } + }); + untyped.collect() +} + /// Whether a `@param` line documents the parameter: above its function, the call its function is /// passed to, or the table field that holds it. pub(super) fn has_param_line(infer: &Infer, param: &Local) -> bool { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 8097060..74404cb 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3165,6 +3165,82 @@ print(plain, either, checked, guards) } } +#[test] +fn values_of_unknown_type_that_typed_targets_take_are_reported_by_no_unknown() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Probe.Box +---@field label string + +---@param n number +local function takesNumber(n) end +---@param value any +local function takesAny(value) end + +---@param entities number[] +local function target(entities, options) + if type(entities) ~= 'table' then + entities = { entities } + end + takesNumber(options.count) + return entities +end + +---@type number +local typed = Undefined() +takesNumber(Undefined) +takesAny(Undefined) +---@type Probe.Box +local box = { label = Undefined } +box.label = Undefined + +---@return string +local function name() + return Undefined +end + +---@return string +local function call() + return Undefined() +end +print(target, typed, name, box, call) +"; + client.open_with(CLIENT, text); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "no-unknown": "warning" } } } } }), + ); + let finding = + |needle: &str, message: &str| ("no-unknown".to_string(), pos(text, needle, 0).0 as u64, message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["no-unknown"]), + [ + finding("target(entities, options)", "Parameter `options` has no type; add `---@param options `"), + finding( + "entities = { entities }", + "The type of the value assigned to field `[1]` of type `number` is unknown" + ), + finding("local typed", "The type of the value assigned to `typed` of type `number` is unknown"), + finding( + "takesNumber(Undefined)", + "The type of the value passed to parameter `n` of type `number` is unknown" + ), + finding( + "label = Undefined }", + "The type of the value assigned to field `label` of type `string` is unknown" + ), + finding( + "box.label = Undefined", + "The type of the value assigned to field `label` of type `string` is unknown" + ), + finding("return Undefined\n", "The type of return value #1 of type `string` is unknown"), + finding("return Undefined()", "The type of return value #1 of type `string` is unknown"), + ], + "a value of unknown type is reported where a declared type takes it, but not for `any`, nor when it is read \ + from a local that is reported itself" + ); +} + #[test] fn casts_change_the_type_of_a_local_from_their_line_on() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 9f8635b..a109792 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -746,6 +746,25 @@ later assignments are not followed. Names that start with `ignore_unused_prefix` not reported. To check only your own resources, set the level in an `[[overrides]]` entry instead of `[rules]`. +It also reports a value of unknown type where a type is declared for what takes it: a local or +parameter with a `---@type` or `@param`, a field of a class or of a table built for a declared +table type, an element of such a table, a parameter that each signature the call may use declares, +and a value that `@return` declares: + +```lua +---@param entities number[] +local function target(entities) + if type(entities) ~= "table" then + entities = { entities } -- The type of the value assigned to field `[1]` of type `number` is unknown + end +end +``` + +A target declared `any` takes any value, and the parameters that `param-type-mismatch` leaves out, +those of natives and those typed with a generic of the function called, are left out here too. A +value read from a local that is itself reported, as `data.id` for an untyped parameter `data`, is +not reported again: typing the local types the value. + ## Nil checks `need-check-nil` reports a local that may hold `nil` where code indexes it, calls it, stores a value diff --git a/docs/rules.md b/docs/rules.md index beeaa8b..c2ca1b8 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -45,7 +45,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `redundant-return-value` | warning | A `return` passes more values than the function's `@return` annotations, or the function type it is written as, declare. A trailing `...T` takes any number. Reported by qbx-lua-ls only. | | `discard-returns` | warning | A call on a line of its own drops the values of a function marked `@nodiscard`, such as `tostring(value)`. Reported by qbx-lua-ls only. | | `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | -| `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. Reported by qbx-lua-ls only. | +| `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. A value of unknown type is stored in a typed variable or field, passed for a typed parameter, or returned where `@return` declares a type. Reported by qbx-lua-ls only. | | `const-reassign` | error | An assignment to a `` or `` local. | | `self-assignment` | warning | A variable is assigned to itself. | | `self-comparison` | warning | A comparison has the same expression on both sides. | From 16937e2872f83dd8572245ef6773fb8b4ac82a7a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:24:30 +0200 Subject: [PATCH 069/193] feat(hover): write out the aliases that function signatures and fields use --- crates/qbx_lua_ls/src/features/hover.rs | 134 +++++++++++++++++--- crates/qbx_lua_ls/src/features/signature.rs | 25 +++- crates/qbx_lua_ls/tests/lsp.rs | 53 ++++++++ 3 files changed, 192 insertions(+), 20 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 1b71590..54100a5 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -95,22 +95,39 @@ const MAX_OVERVIEW_FIELDS: usize = 14; fn describe_value(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> String { if let (Some(fun), Type::Fun(_)) = (ty.as_fun(), ty) { let marker = if fun.is_async { "(async) " } else { "" }; - return format!("{marker}{prefix}{}", fun.signature(name)); - } - let mut out = value_overview(infer, prefix, name, ty, literal); - for (name, alias) in alias_expansions(infer, ty) { + let mut out = format!("{marker}{prefix}{}", fun.signature(name)); + push_aliases(&mut out, &nested_aliases(infer, [ty], &[], false)); + return out; + } + let (mut out, shown) = value_overview(infer, prefix, name, ty, literal); + let aliases = alias_expansions(infer, ty); + push_aliases(&mut out, &aliases); + // Then those that its type arguments and the fields it lists use, as `Mode` of `Box` or of + // `Car { mode: Mode }`. + let names = |aliases: &[(SmolStr, &AliasDef)]| aliases.iter().map(|(name, _)| name.clone()).collect::>(); + let mut nested = nested_aliases(infer, [ty], &names(&aliases), true); + let listed = [names(&aliases), names(&nested)].concat(); + nested.extend(nested_aliases(infer, &shown, &listed, false)); + nested.truncate(MAX_NESTED_ALIASES); + push_aliases(&mut out, &nested); + out +} + +/// Writes each alias out on a line of its own after `out`. +fn push_aliases(out: &mut String, aliases: &[(SmolStr, &AliasDef)]) { + for (name, alias) in aliases { out.push('\n'); - out.push_str(&alias_definition(&name, alias)); + out.push_str(&alias_definition(name, alias)); } - out } -fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> String { +/// `name: type`, or for tables an overview of their fields, with the types of the fields it lists. +fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> (String, Vec) { let bare = ty.without_nil(); let members = infer.members(&table_part(infer, &bare, 0)); if members.is_empty() { let value = literal.map(|l| format!(" = {l}")).unwrap_or_default(); - return format!("{prefix}{name}: {}{value}", shown_type(infer, ty)); + return (format!("{prefix}{name}: {}{value}", shown_type(infer, ty)), Vec::new()); } // The fields are those of the value when it is not nil; the `?` still says it may be. let optional = if *ty != bare && matches!(ty, Type::Union(types) if types.contains(&Type::Nil)) { "?" } else { "" }; @@ -122,11 +139,15 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O _ => String::new(), }; let mut out = format!("{prefix}{name}: {label}{{"); + let mut shown = Vec::new(); for member in members.iter().take(MAX_OVERVIEW_FIELDS) { let ty = match &member.ty { Type::Fun(_) => "function".to_string(), Type::GlobalTable(_) | Type::Shape(_) => "table".to_string(), - other => other.to_string(), + other => { + shown.push(other.clone()); + other.to_string() + } }; let value = member.literal.as_ref().map(|l| format!(" = {l}")).unwrap_or_default(); out.push_str(&format!("\n {}: {ty}{value},", member.name)); @@ -138,7 +159,7 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O if label.is_empty() { out.push_str(optional); } - out + (out, shown) } /// Whether `ty` is a table that a hover shows by its fields alone, such as `{ name: string }` or @@ -200,9 +221,78 @@ fn alias_expansions<'a>(infer: &Infer<'a>, ty: &Type) -> Vec<(SmolStr, &'a Alias out } +/// How many aliases a hover, or a parameter in signature help, writes out after the types that use +/// them, so that a class with many fields stays readable. +const MAX_NESTED_ALIASES: usize = 8; + +/// The aliases that `types` use in the parameters and returns of functions, the fields of tables and +/// type arguments, as `Mode` in `fun(mode: Mode)` or `{ mode: Mode }`, each once and leaving out those +/// in `skip`. With `top`, the aliases the types name themselves are left out too, as a hover that +/// expands them already shows them. Classes are left to their own hovers. +pub(super) fn nested_aliases<'a, 't>( + infer: &Infer<'a>, + types: impl IntoIterator, + skip: &[SmolStr], + top: bool, +) -> Vec<(SmolStr, &'a AliasDef)> { + let mut out = Vec::new(); + for ty in types { + collect_aliases(infer, ty, skip, top, 0, &mut out); + } + out +} + +fn collect_aliases<'a>( + infer: &Infer<'a>, + ty: &Type, + skip: &[SmolStr], + top: bool, + depth: u32, + out: &mut Vec<(SmolStr, &'a AliasDef)>, +) { + if depth > 8 || out.len() >= MAX_NESTED_ALIASES { + return; + } + let mut inner = |ty: &Type, top: bool| collect_aliases(infer, ty, skip, top, depth + 1, out); + match ty { + Type::Named(name, args) => { + for arg in args { + inner(arg, false); + } + let listed = skip.iter().chain(out.iter().map(|(seen, _)| seen)).any(|seen| *seen == name.text); + if top || listed || out.len() >= MAX_NESTED_ALIASES || infer.index.class(name, infer.side()).is_some() { + return; + } + if let Some((_, alias)) = infer.index.alias(name, infer.side()) { + out.push((name.text.clone(), alias)); + } + } + Type::Union(types) => types.iter().for_each(|ty| inner(ty, top)), + Type::Array(item) | Type::Variadic(item) => inner(item, top), + Type::Tuple(types) => types.iter().for_each(|ty| inner(ty, false)), + Type::Map(key, value) => { + inner(key, false); + inner(value, false); + } + Type::Fun(fun) => { + fun.params.iter().for_each(|param| inner(¶m.ty, false)); + fun.returns.iter().for_each(|ty| inner(ty, false)); + } + Type::Shape(shape) => { + shape.fields.iter().for_each(|field| inner(&field.ty, false)); + shape.array.iter().for_each(|ty| inner(ty, false)); + shape.indices.iter().for_each(|(key, value)| { + inner(key, false); + inner(value, false); + }); + } + _ => {} + } +} + /// An alias as written out in a hover: `type Mode = "fast"|"slow"`, or with each value on a line of /// its own when the `---|` lines that list them describe any. -fn alias_definition(name: &str, alias: &AliasDef) -> String { +pub(super) fn alias_definition(name: &str, alias: &AliasDef) -> String { let name = with_params(name, &alias.generics); if alias.values.iter().all(|listed| listed.description.is_empty()) { return format!("type {name} = {}", alias.ty); @@ -369,22 +459,32 @@ fn class_hover(infer: &Infer, class: &ClassDef) -> String { declaration.push_str(&format!(" : {}", parents.join(", "))); } let members = infer.members(&own_type(&class.name, &class.generics)); - let mut fields: Vec = members.iter().map(|member| format!("{}: {}", member.name, member.ty)).collect(); - fields.extend(class.literal_fields(infer.side()).map(|(key, value)| format!("[{key}]: {value}"))); + let mut fields: Vec<(String, Type)> = + members.iter().map(|member| (format!("{}: {}", member.name, member.ty), member.ty.clone())).collect(); + fields.extend(class.literal_fields(infer.side()).map(|(key, value)| (format!("[{key}]: {value}"), value.clone()))); let indices = class.indices(infer.side()); + let mut shown: Vec<&Type> = Vec::new(); if !fields.is_empty() || !indices.is_empty() { declaration.push_str(" {"); - for field in fields.iter().take(MAX_OVERVIEW_FIELDS) { + for (field, ty) in fields.iter().take(MAX_OVERVIEW_FIELDS) { declaration.push_str(&format!("\n {field},")); + shown.push(ty); } - for (key, value) in indices { + for (key, value) in &indices { declaration.push_str(&format!("\n [{key}]: {value},")); + shown.extend([key, value]); } if fields.len() > MAX_OVERVIEW_FIELDS { declaration.push_str(&format!("\n ...(+{})", fields.len() - MAX_OVERVIEW_FIELDS)); } declaration.push_str("\n}"); } + let parents = class.parent_types.iter().filter_map(|parent| match parent { + Type::Named(_, args) => Some(args.iter()), + _ => None, + }); + let used = parents.flatten().chain(shown); + push_aliases(&mut declaration, &nested_aliases(infer, used, std::slice::from_ref(&class.name), false)); let mut out = lua_block(&declaration); if let Some(doc) = &class.doc { out.push_str("\n\n"); @@ -409,7 +509,9 @@ fn type_hover(infer: &Infer, name: &str) -> Option { } let aliases = infer.index.alias_defs(name).into_iter(); let (_, alias) = aliases.max_by_key(|(file, alias)| preference(*file, alias.side))?; - let mut out = lua_block(&alias_definition(name, alias)); + let mut definition = alias_definition(name, alias); + push_aliases(&mut definition, &nested_aliases(infer, [&alias.ty], &[SmolStr::new(name)], false)); + let mut out = lua_block(&definition); if let Some(doc) = &alias.doc { out.push_str("\n\n"); out.push_str(doc); diff --git a/crates/qbx_lua_ls/src/features/signature.rs b/crates/qbx_lua_ls/src/features/signature.rs index ca1c36f..f883993 100644 --- a/crates/qbx_lua_ls/src/features/signature.rs +++ b/crates/qbx_lua_ls/src/features/signature.rs @@ -4,12 +4,13 @@ use qbx_lua_analysis::scope::Resolved; use qbx_lua_syntax::ast::{ExprKind, Name, StmtKind}; use super::event_call::{event_call, wrapper_call}; -use super::{markdown, with_infer}; +use super::hover::{alias_definition, nested_aliases}; +use super::{lua_block, markdown, with_infer}; use crate::document::Document; use crate::indexer::render_doc; use crate::infer::{Decl, Infer}; use crate::locate::locate; -use crate::types::FunType; +use crate::types::{FunType, Type}; use crate::workspace::Workspace; pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Option { @@ -60,7 +61,7 @@ pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Opt let argument = site.active_argument(&doc.text, offset); let signatures: Vec = signatures .iter() - .map(|fun| information(fun, &name, argument, site.method.is_some(), documentation.as_deref())) + .map(|fun| information(infer, fun, &name, argument, site.method.is_some(), documentation.as_deref())) .collect(); let active_parameter = signatures[active_signature].active_parameter; Some(SignatureHelp { signatures, active_signature: Some(active_signature as u32), active_parameter }) @@ -103,7 +104,9 @@ fn name_doc(ws: &Workspace, doc: &Document, infer: &Infer, name: &Name) -> Optio } /// One signature of a call, with the parameter that the argument at index `argument` is passed to. +/// Each parameter writes out the aliases its type uses, as `type Mode = "a"|"b"` for `mode: Mode`. fn information( + infer: &Infer, fun: &FunType, name: &str, argument: usize, @@ -129,9 +132,23 @@ fn information( parameters: Some( labels .into_iter() - .map(|l| ParameterInformation { label: ParameterLabel::Simple(l), documentation: None }) + .zip(¶ms) + .map(|(label, param)| ParameterInformation { + label: ParameterLabel::Simple(label), + documentation: aliases_of(infer, ¶m.ty), + }) .collect(), ), active_parameter: Some(active as u32), } } + +/// The aliases that a parameter of type `ty` uses, written out, if it uses any. +fn aliases_of(infer: &Infer, ty: &Type) -> Option { + let aliases = nested_aliases(infer, [ty], &[], false); + if aliases.is_empty() { + return None; + } + let lines: Vec = aliases.iter().map(|(name, alias)| alias_definition(name, alias)).collect(); + Some(Documentation::MarkupContent(markdown(lua_block(&lines.join("\n"))))) +} diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 74404cb..b6352b8 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -8541,6 +8541,59 @@ end } } +#[test] +fn hovers_and_signature_help_write_out_the_aliases_their_types_use() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Mode \"a\"|\"b\" +---@alias Test.Pair { left: number, right: number } +---@alias Test.Wrapped { value: T } +---@alias Test.Opts { mode: Test.Mode, size: integer } + +---@class Test.Car +---@field mode Test.Mode +---@field speed number + +---@param m Test.Mode +---@param p Test.Pair +---@return Test.Mode?, Test.Car +local function useMode(m, p) end + +---@type Test.Car +local car = { mode = 'a', speed = 1 } +---@type Test.Wrapped +local wrapped = { value = 'a' } +---@type Test.Opts +local opts = { mode = 'a', size = 1 } +useMode('a', { left = 1, right = 2 }) +print(car, wrapped, opts) +"; + client.open_with(CLIENT, text); + let mode = "type Test.Mode = \"a\"|\"b\""; + let pair = "type Test.Pair = { left: number, right: number }"; + let cases: &[(&str, &[&str])] = &[ + ("useMode(m", &["useMode(m: Test.Mode, p: Test.Pair): Test.Mode?, Test.Car", mode, pair]), + ("car = {", &["mode: Test.Mode,", mode]), + ("wrapped = {", &["Test.Wrapped", mode]), + ("Test.Opts\nlocal", &["type Test.Opts = { mode: Test.Mode, size: integer }", mode]), + ("Test.Car\nlocal car", &["(class) Test.Car", mode]), + ]; + for &(needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + for part in expected { + assert!(hover.contains(part), "{needle}: expected {part:?} in {hover}"); + } + } + + let (l, c) = pos(text, "useMode('a'", 9); + let result = client.request("textDocument/signatureHelp", client.position_params(CLIENT, l, c)); + let parameters = &result["signatures"][0]["parameters"]; + let doc = |i: usize| parameters[i]["documentation"]["value"].as_str().unwrap_or_default().to_string(); + assert!(doc(0).contains(mode), "{}", doc(0)); + assert!(doc(1).contains(pair) && !doc(1).contains(mode), "{}", doc(1)); +} + #[test] fn calls_through_function_aliases_use_their_signature() { let mut client = Client::start(fixture_root()); From a02c9a26fdca94f261db5a3425dd96465c34294a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:46:55 +0200 Subject: [PATCH 070/193] feat(rules): report local statements that leave names without a value --- crates/qbx_lua_analysis/src/checks/flow.rs | 8 ++++---- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_analysis/tests/rules.rs | 16 ++++++++++++++++ docs/rules.md | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index 5a07959..a877bcc 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -99,7 +99,7 @@ impl Flow<'_, '_> { ); } - fn balance(&mut self, targets: usize, exprs: &[Expr], stmt_span: Span, is_local: bool) { + fn balance(&mut self, targets: usize, exprs: &[Expr], stmt_span: Span) { let Some(last) = exprs.last() else { return }; if exprs.len() > targets { let extra = exprs[targets].span.to(last.span); @@ -108,7 +108,7 @@ impl Flow<'_, '_> { extra, format!("{} value(s) assigned to {targets} target(s); the extra values are discarded", exprs.len()), ); - } else if exprs.len() < targets && !last.is_multi_value() && !is_local { + } else if exprs.len() < targets && !last.is_multi_value() { self.sink.report( rules::UNBALANCED_ASSIGNMENTS, stmt_span, @@ -334,9 +334,9 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { fn visit_stmt(&mut self, stmt: &'ast Stmt) { match &stmt.kind { - StmtKind::Local { names, exprs, in_unpack: false } => self.balance(names.len(), exprs, stmt.span, true), + StmtKind::Local { names, exprs, in_unpack: false } => self.balance(names.len(), exprs, stmt.span), StmtKind::Assign { targets, exprs } => { - self.balance(targets.len(), exprs, stmt.span, false); + self.balance(targets.len(), exprs, stmt.span); if let ([target], [value]) = (targets.as_slice(), exprs.as_slice()) { if same_place(target, value) { self.sink.report(rules::SELF_ASSIGNMENT, stmt.span, "value is assigned to itself"); diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index a438549..844b1fe 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -151,7 +151,7 @@ pub static RULES: &[Rule] = &[ rule(SHADOWED_LOCAL, Style, OFF, false, "A local hides a local from an enclosing scope."), rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a return, break or goto and can never run."), rule(EMPTY_BLOCK, Style, INFO, false, "A block has no statements."), - rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment has more values than targets, or leaves targets without a value."), + rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment or `local` statement has more values than targets, or leaves targets without a value."), rule(DUPLICATE_INDEX, Suspicious, WARN, false, "A table constructor sets the same key twice."), rule(DUPLICATE_SET_FIELD, Suspicious, WARN, false, "A function is assigned to the same table field twice in one block of a file, so the second replaces the first."), rule(DUPLICATE_ARGUMENT, Correctness, ERROR, false, "Two parameters of one function share a name."), diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 4ea22a6..8ae5a50 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -389,6 +389,22 @@ fn clean_snippets_stay_clean() { } } +#[test] +fn local_statements_and_assignments_give_each_name_a_value() { + let unbalanced = |source: &str| codes(source).into_iter().filter(|code| *code == "unbalanced-assignments").count(); + assert_eq!(unbalanced("local a, b = 1\nprint(a, b)"), 1, "as in LuaLS"); + assert_eq!(unbalanced("gx, gy = 1\nprint(gx, gy)"), 1); + assert_eq!(unbalanced("local a = 1, 2\nprint(a)"), 1, "an extra value"); + for source in [ + "local a, b\nprint(a, b)", + "local a, b = pcall(error)\nprint(a, b)", + "local function f(...) local a, b = ... return a, b end\nprint(f)", + "local a, b = 1, 2\nprint(a, b)", + ] { + assert_eq!(unbalanced(source), 0, "{source}"); + } +} + #[test] fn syntax_errors_are_not_suppressible() { assert_eq!(codes("-- qbx-lint: disable\nlocal = 1"), ["syntax-error"]); diff --git a/docs/rules.md b/docs/rules.md index c2ca1b8..e4478db 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -25,7 +25,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `shadowed-local` | off | A local hides one in an enclosing scope. | | `unreachable-code` | warning | Code follows a control-flow statement that exits the path. | | `empty-block` | info | A block has no statements. | -| `unbalanced-assignments` | warning | An assignment has mismatched target and value counts. | +| `unbalanced-assignments` | warning | An assignment or `local` statement has mismatched target and value counts, as in `local a, b = 1`. A last value that is a call or `...` gives any number of values. | | `duplicate-index` | warning | A table constructor assigns the same key twice. | | `duplicate-set-field` | warning | A function is assigned to the same field of the same table twice in one block of a file, so the second replaces the first. A block is the main chunk, a function body, one branch of an `if`, or the code after an `if` with a branch that returns, such as an `IsDuplicityVersion()` or `lib.context` guard. A read of the field between the two, as a wrapper has, or an assignment to the table's variable or to a field it is in, also separates them. Definitions in other files or in `---@meta` files are not compared. | | `duplicate-argument` | error | Function parameters share a name. | From 63503c602f186672ac853bbf03d1352c66bda1ff Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:01:51 +0200 Subject: [PATCH 071/193] feat(rules): report nil, optional and false arguments and check natives in param-type-mismatch --- crates/qbx_lua_ls/src/features/arguments.rs | 200 ++++++++++++++---- .../qbx_lua_ls/src/features/class_tables.rs | 19 ++ crates/qbx_lua_ls/tests/lsp.rs | 127 +++++++++-- docs/reference.md | 60 ++++-- docs/rules.md | 4 +- 5 files changed, 341 insertions(+), 69 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/arguments.rs b/crates/qbx_lua_ls/src/features/arguments.rs index b293c34..f35d5d0 100644 --- a/crates/qbx_lua_ls/src/features/arguments.rs +++ b/crates/qbx_lua_ls/src/features/arguments.rs @@ -1,7 +1,8 @@ //! `param-type-mismatch`: a call passes an argument that its parameter does not take, such as a //! string for `number`. As for `assign-type-mismatch`, the argument has to be a different kind of -//! value, or a literal the parameter does not list, and as for `impossible-comparison` only -//! declared types count: what inference reads from assigned values is left out. +//! value, or a literal the parameter does not list, for each type a union lists, and as for +//! `impossible-comparison` only declared types count: what inference reads from assigned values is +//! left out. //! //! A call is compared with every function it may run: each member of that name on its side, such //! as the client and the server `lib.notify`, or each global of that name its file sees, with their @@ -9,15 +10,22 @@ //! takes each of them; only when none takes that many do the others count. The payload of a //! `---@callback` wrapper call is compared with the handlers registered under its name. //! -//! Natives are left out: the runtime converts their arguments, so `0` passes for a `boolean`, a -//! string for a hash and a vector for three floats. `nil` is never a mismatch, as annotations often -//! leave out the `?` of a parameter that code skips, and neither is `false`, which FiveM code passes -//! to skip a parameter of exports and events, whose arguments are serialized. Parameters typed with a -//! generic of the function called are left out, as the arguments of the call bind them. The function -//! a callee passes to a callback, such as `resolve` of `fun(resolve: fun(value: T))`, takes and -//! returns what the other arguments of that call declare for its generics, as the `boolean` of -//! `Promise:New('boolean', function(resolve) end)` for a `` `T` ``; a generic they leave unbound -//! takes any value. +//! As in LuaLS, `nil`, and a value whose type allows it like a `string?`, needs a parameter that takes +//! `nil`, and `false` is a boolean like any other. +//! +//! Natives are checked as the runtime passes their arguments on: numbers and booleans pass for each +//! other, as a `BOOL` is an integer to them, a hash parameter hashes a string, and a vector fills +//! one number parameter for each of its parts. The native wrappers turn `nil`, `0` and `false` into +//! NULL for string parameters, and only for those, and other values into their text, so a string +//! parameter takes a number or a boolean written out, and the server id of a player, but not +//! another number, such as a hash. A value of unknown type that may be a vector leaves where the +//! values after it go unknown, when the call passes fewer values than the native takes. +//! +//! Parameters typed with a generic of the function called are left out, as the arguments of the +//! call bind them. The function a callee passes to a callback, such as `resolve` of +//! `fun(resolve: fun(value: T))`, takes and returns what the other arguments of that call declare +//! for its generics, as the `boolean` of `Promise:New('boolean', function(resolve) end)` for a +//! `` `T` ``; a generic they leave unbound takes any value. use std::sync::Arc; @@ -63,7 +71,7 @@ fn checked_arguments( calls.visit_block(&chunk.block); for payload in payloads { let handlers: Vec<&FunType> = payload.handlers.iter().collect(); - calls.passed(&handlers, false, payload.args); + calls.passed(&handlers, false, false, payload.args); } calls.out } @@ -91,21 +99,26 @@ impl<'c> Visitor<'c> for Calls<'_, '_> { impl Calls<'_, '_> { fn check(&mut self, call: &Expr, base: &Expr, method: Option<&Name>, args: &[Expr]) { - let signatures = signatures(self.infer, &self.declared, call, base, method, args); + // A native's arguments are checked as the runtime converts them, which `no-unknown` leaves out. + let native = method.is_none() && !args.is_empty() && self.declared.is_native(base, 0); + let signatures = match native && self.unknowns.is_none() { + true => definitions(self.infer, base, method), + false => signatures(self.infer, &self.declared, call, base, method, args), + }; if signatures.is_empty() { return; } let signatures: Vec<&FunType> = signatures.iter().map(Arc::as_ref).collect(); - self.passed(&signatures, method.is_some(), args); + self.passed(&signatures, method.is_some(), native, args); } - /// Checks `args`, passed to a function that has one of `signatures`. - fn passed(&mut self, signatures: &[&FunType], via_colon: bool, args: &[Expr]) { + /// Checks `args`, passed to a function that has one of `signatures`, or to a `native`. + fn passed(&mut self, signatures: &[&FunType], via_colon: bool, native: bool, args: &[Expr]) { if let Some(pick) = self.unknowns { return self.unknown(signatures, via_colon, args, pick); } let values: Vec = args.iter().map(|arg| self.value(arg)).collect(); - self.compare(signatures, via_colon, args, &values); + self.compare(signatures, via_colon, native, args, &values); } /// Reports each of `args` of no known type that `pick` picks, when the parameter it goes to has a @@ -152,10 +165,10 @@ impl Calls<'_, '_> { /// Reports the arguments that the signature closest to taking them does not take, unless one of /// them takes them all. Only the signatures that take as many arguments as the call passes count, /// unless none does. - fn compare(&mut self, signatures: &[&FunType], via_colon: bool, args: &[Expr], values: &[Type]) { + fn compare(&mut self, signatures: &[&FunType], via_colon: bool, native: bool, args: &[Expr], values: &[Type]) { let mut closest: Option> = None; for fun in candidates(self.infer, signatures, via_colon, args) { - let found = self.mismatches(fun, via_colon, args, values); + let found = self.mismatches(fun, via_colon, native, args, values); if found.is_empty() { return; } @@ -166,21 +179,75 @@ impl Calls<'_, '_> { self.out.extend(closest.unwrap_or_default()); } - /// Each of `args` that the parameter of `fun` it goes to does not take. - fn mismatches(&self, fun: &FunType, via_colon: bool, args: &[Expr], values: &[Type]) -> Vec<(Span, String)> { - let passed = args.iter().zip(values).enumerate(); - passed - .filter_map(|(i, (arg, given))| { - let message = mismatch(&self.classes, fun, param_for(fun, via_colon, i)?, given)?; - Some((arg.span, message)) - }) - .collect() + /// Each of `args` that the parameter of `fun`, or of a `native`, it goes to does not take. + fn mismatches( + &self, + fun: &FunType, + via_colon: bool, + native: bool, + args: &[Expr], + values: &[Type], + ) -> Vec<(Span, String)> { + let mut out = Vec::new(); + // The parameters that the vectors passed before take beyond their first. + let mut spread = 0; + for (i, (arg, given)) in args.iter().zip(values).enumerate() { + // The `self` a `.` call passes to a method has no parameter of its own. + let Some(param) = param_for(fun, via_colon, i + spread) else { continue }; + if native && matches!(expected(param), Type::Number | Type::Integer) { + match self.vector_parts(arg, given) { + Some(1) => {} + Some(parts) => { + spread += parts - 1; + continue; + } + None if args.len() - i < fun.params.len().saturating_sub(i + spread) => break, + None => {} + } + } + let message = match native { + true => native_mismatch(&self.classes, param, arg, given), + false => mismatch(&self.classes, fun, param, given), + }; + out.extend(message.map(|message| (arg.span, message))); + } + out + } + + /// How many parameters of a native `arg` fills, declared as `declared`: a vector fills one for + /// each of its parts, as the runtime spreads it. Where nothing declares the type of `arg`, what + /// it holds tells, as only its place is in question. `None` when that is not known, or the type + /// allows vectors of different sizes, as the `vector` of the runtime stubs does. + fn vector_parts(&self, arg: &Expr, declared: &Type) -> Option { + let ty = match declared.is_unknown() { + true => self.infer.cast_after(arg.span.end).unwrap_or_else(|| self.infer.expr(arg)), + false => declared.clone(), + }; + let parts = |ty: &Type| match ty { + Type::Named(name, _) => match name.as_str() { + "vector2" => Some(2), + "vector3" => Some(3), + "vector4" | "quat" => Some(4), + _ => Some(1), + }, + Type::Unknown | Type::Any => None, + _ => Some(1), + }; + match self.infer.expand_aliases(&ty.without_nil(), 0) { + Type::Union(types) => { + let mut sizes = types.iter().filter(|ty| **ty != Type::Nil).map(parts); + let first = sizes.next()??; + sizes.all(|size| size == Some(first)).then_some(first) + } + ty => parts(&ty), + } } } /// The signatures that a call of `base`, or of its `method`, with `args` may use: those of each /// function `definitions` finds, with their `@overload`s for the side of the call. None for a call -/// without arguments, for a native, whose arguments the runtime converts, and for +/// without arguments, for a native, whose arguments the runtime converts as `param-type-mismatch` +/// checks them on its own, and for /// `require 'name' --[[@as T]]`, where the cast after the argument is that of the call. pub fn signatures( infer: &Infer, @@ -246,22 +313,79 @@ pub fn expected(param: &Param) -> &Type { /// The message for a value of type `given` that `param` of `fun` does not take. pub fn mismatch(classes: &Classes, fun: &FunType, param: &Param, given: &Type) -> Option { let expected = expected(param); - let given = given.without_nil(); let is_generic = |name: &str| fun.generics.iter().any(|generic| generic == name); // Resources declare classes such as `Vehicle` that share the name of a native handle, which a // parameter of that type may well mean. let is_handle = |name: &str| NATIVE_HANDLE_TYPES.contains(&name); - if matches!(given, Type::Unknown | Type::Nil | Type::BooleanLit(false)) - || mentions(expected, &is_generic) - || names(expected, &is_handle) - { + if matches!(given, Type::Unknown | Type::Any) || mentions(expected, &is_generic) || names(expected, &is_handle) { return None; } let from = classes.file(); - if !classes.rejects(expected, from, &given) { + if classes.admits_nil(given, from) && !takes_nil(classes, param) { + return Some(format!("Cannot assign `{given}` to parameter `{}` of type `{expected}`", param.name)); + } + rejected(classes, param, &given.without_nil()) +} + +/// The message for the value `arg` of type `given` that `param` of a native does not take, as the +/// runtime passes it on: numbers and booleans pass for each other, and a hash parameter takes the +/// hash of a string. The native wrappers turn `nil`, `0` and `false` into NULL for string +/// parameters and other values into their text, so a string parameter takes `nil`, a number or a +/// boolean written out, and the number of a player's server id; other numbers and booleans, such as +/// the hash that `GetHashKey` gives, are no string it is meant to get. Each type a union lists has +/// to pass. +fn native_mismatch(classes: &Classes, param: &Param, arg: &Expr, given: &Type) -> Option { + let expected = expected(param); + if matches!(given, Type::Unknown | Type::Any) { return None; } - let shown = if classes.literal_mismatch(expected, from, &given) { given } else { given.widen() }; + let from = classes.file(); + if *expected != Type::String && classes.admits_nil(given, from) && !takes_nil(classes, param) { + return Some(format!("Cannot assign `{given}` to parameter `{}` of type `{expected}`", param.name)); + } + let given = given.without_nil(); + let written = is_literal(arg) || PLAYER_ID_PARAMS.contains(¶m.name.as_str()); + let converts = |part: &Type| { + let converted = |kind: Type| !classes.rejects(&kind, from, part); + match expected { + Type::String => written && (converted(Type::Number) || converted(Type::Boolean)), + Type::Handle(name) if name == "Hash" => converted(Type::String) || converted(Type::Boolean), + Type::Number | Type::Integer | Type::Handle(_) => converted(Type::Boolean), + Type::Boolean => converted(Type::Number), + _ => false, + } + }; + let mut parts = Vec::new(); + classes.flatten(&given, from, &mut parts, 0); + let rejected = |part: &&Type| **part != Type::Nil && !converts(part) && classes.rejects(expected, from, part); + let part = parts.iter().find(rejected)?; + let shown = classes.shown(expected, from, &given, part); + Some(format!("Cannot assign `{shown}` to parameter `{}` of type `{expected}`", param.name)) +} + +/// The string parameters of natives that take the server id of a player, which scripts hold as a +/// number. +const PLAYER_ID_PARAMS: &[&str] = &["playerSrc", "eventTarget"]; + +/// Whether `expr` is a number or a boolean written out, as `0`, `-1` or `false`. +fn is_literal(expr: &Expr) -> bool { + match &expr.unparen().kind { + ExprKind::Number(_) | ExprKind::True | ExprKind::False => true, + ExprKind::Unary { op: UnOp::Neg, expr } => matches!(expr.unparen().kind, ExprKind::Number(_)), + _ => false, + } +} + +/// The message for a value of type `given`, with no `nil` in it, that `param` clearly does not +/// take: one that may be of another kind, or a literal it does not list. +fn rejected(classes: &Classes, param: &Param, given: &Type) -> Option { + let expected = expected(param); + let from = classes.file(); + if matches!(given, Type::Unknown | Type::Nil) { + return None; + } + let part = classes.rejected_part(expected, from, given)?; + let shown = classes.shown(expected, from, given, &part); Some(format!("Cannot assign `{shown}` to parameter `{}` of type `{expected}`", param.name)) } @@ -311,6 +435,10 @@ pub fn definitions(infer: &Infer, base: &Expr, method: Option<&Name>) -> Vec match infer.ctx.resolution.resolve_at(name.span.start) { Some(Resolved::Global(_)) => { let globals = infer.index.globals_named(&name.text, infer.ctx.file); + // A native is no global of the index. + if globals.is_empty() { + return held(); + } let globals: Vec<_> = globals.into_iter().filter(|(file, _)| reaches(*file)).collect(); if !globals.iter().all(|(file, _)| known(Some(*file))) { return Vec::new(); diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index bd1a790..c88d362 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -599,6 +599,25 @@ impl<'a, 'b> Classes<'a, 'b> { } } + /// The member of `given`, inferred in this file, that can clearly not be stored where + /// `expected` is declared in `from`, as the `number` of a `string|number` for a `string`: each + /// member of a union has to fit, and one of no known type does. + pub fn rejected_part(&self, expected: &Type, from: FileId, given: &Type) -> Option { + let mut parts = Vec::new(); + self.flatten(given, self.file(), &mut parts, 0); + parts.into_iter().find(|part| self.rejects(expected, from, part)) + } + + /// How a message shows `given`, of which `expected` rejects `part`: as it is written when `part` + /// is a literal that `expected` does not list, like the `'c'` of `'a'|'c'`, and widened to its + /// kinds otherwise. + pub fn shown(&self, expected: &Type, from: FileId, given: &Type, part: &Type) -> Type { + match self.literal_mismatch(expected, from, part) { + true => given.clone(), + false => given.widen(), + } + } + /// The kinds of Lua value `ty` allows, as `kind` bits, or `None` when it may be anything. fn kinds(&self, ty: &Type, from: FileId, depth: u32) -> Option { if depth > MAX_DEPTH { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index b6352b8..3c8b4d9 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -5330,22 +5330,26 @@ run() ); let line = |needle: &str| pos(text, needle, 0).0 as u64; let finding = |needle: &str, message: &str| ("need-check-nil".to_string(), line(needle), message.to_string()); - let passed = |local: &str| { - format!( - "`{local}` may be nil, which parameter `target` of type `Probe.Target` does not take: its type here is \ - `Probe.Target?`" - ) + let mismatch = |needle: &str, given: &str, param: &str, ty: &str| { + let message = format!("Cannot assign `{given}` to parameter `{param}` of type `{ty}`"); + ("param-type-mismatch".to_string(), line(needle), message) }; + let target = |needle: &str| mismatch(needle, "Probe.Target?", "target", "Probe.Target"); assert_eq!( - findings(&mut client, CLIENT, &["need-check-nil"]), + findings(&mut client, CLIENT, &["need-check-nil", "param-type-mismatch"]), [ - finding("greet(missing)", &passed("missing")), - finding("Box:put(stored)", &passed("stored")), + target("greet(missing)"), + mismatch("needsNumber(wrong)", "Probe.Target?", "n", "number"), + target("either(overloaded)"), + target("Box:put(stored)"), finding("print(read.name)", "`read` may be nil: its type here is `Probe.Target?`"), - finding("greet(read)", &passed("read")), + target("greet(read)"), + mismatch("SetEntityHeading(handle", "number?", "entity", "Entity"), ], - "parameters that are optional or take nil, any value or a generic, overloads that take nil, guards, \ - natives, arguments of another type and suppressed `param-type-mismatch` are left alone" + "`param-type-mismatch` reports a value that may be nil passed for a parameter that does not take \ + it, as LuaLS does, also to a native, and also where the overload that takes `nil` takes no \ + `Probe.Target`; parameters that are optional or take nil, any value or a generic, guards and \ + suppressed lines are left alone" ); } @@ -6028,11 +6032,106 @@ print(either, rest, head, position) finding("Box.resize(Box, 'large')", "Cannot assign `string` to parameter `size` of type `integer`"), finding("find('alone')", "Cannot assign `string` to parameter `id` of type `number`"), finding("find(1, 'label')", "Cannot assign `integer` to parameter `name` of type `string`"), - finding("needsNumber(maybe)", "Cannot assign `string` to parameter `n` of type `number`"), + finding("needsNumber(nil)", "Cannot assign `nil` to parameter `n` of type `number`"), + finding("needsNumber(false)", "Cannot assign `boolean` to parameter `n` of type `number`"), + finding("needsNumber(maybe)", "Cannot assign `string?` to parameter `n` of type `number`"), finding("needsNumber(name())", "Cannot assign `string` to parameter `n` of type `number`"), + finding("SetEntityHeading", "Cannot assign `string` to parameter `heading` of type `number`"), ], - "a call passes when a signature that takes as many arguments takes them all; nil, false, values \ - inferred from assignments, casts, type guards, generics, natives, the string a string method is called on and suppressed lines pass" + "a call passes when a signature that takes as many arguments takes them all; `nil` and `false` are \ + values like any other, as in LuaLS; values inferred from assignments, casts, type guards, generics, \ + the string a string method is called on and suppressed lines pass" + ); +} + +#[test] +fn natives_take_their_arguments_as_the_runtime_converts_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type Ped? +local maybePed +---@type string? +local maybeName +local coords = vector3(1, 2, 3) +local ped = PlayerPedId() +SetEntityVisible(ped, 1, 0) +SetEntityHeading(ped, true) +print(GetHashKey(123), GetHashKey(maybeName)) +RequestModel('adder') +SetEntityCoords(ped, coords, false, false, false, false) +SetEntityCoords(ped, coords.x, coords.y, coords.z, false, false, false, false) +DrawMarker(1, coords, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 255, 0, 0, 100, false, true, 2, false, nil, nil, false) +SetEntityCoords(ped, UnknownCoords, 'x') +AddExplosion(1.0, 2.0, 3.0, 'EXPLOSION_TANKER', 2.0, true, false, 2.0) +NetworkSetInSpectatorMode(false, nil) +print(DoesEntityExist(maybePed)) +SetEntityCoords(ped, coords, 'flag', false, false, false) +---@param point vector +local function place(point) + SetEntityCoords(ped, point, 'size unknown') +end +print(place) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [ + finding("AddExplosion", "Cannot assign `string` to parameter `explosionType` of type `integer`"), + finding("NetworkSetInSpectatorMode", "Cannot assign `nil` to parameter `playerPed` of type `Ped`"), + finding("DoesEntityExist", "Cannot assign `Ped?` to parameter `entity` of type `Entity`"), + finding("'flag'", "Cannot assign `string` to parameter `alive` of type `boolean`"), + ], + "numbers and booleans pass for each other, a string parameter takes numbers and `nil`, a hash \ + parameter strings, a vector fills a number parameter for each of its parts, and after a value of \ + unknown type where a vector may go, the values that follow are not checked" + ); +} + +#[test] +fn native_string_parameters_take_only_numbers_and_booleans_written_out() { + let mut client = Client::start(fixture_root()); + let text = "\ +local name = 'target' +---@type number +local volume = 30 +---@type string|number +local value = 1 +ReleaseNamedRendertarget(GetHashKey(name)) +ReleaseNamedRendertarget(0) +ReleaseNamedRendertarget(false) +ReleaseNamedRendertarget(nil) +SetConvarReplicated('volume', 30) +SetConvarReplicated('volume', true) +SetConvarReplicated('volume', volume) +SetConvarReplicated('volume', value) +TaskStartScenarioInPlace(PlayerPedId(), 'WORLD_HUMAN_SMOKING', true) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("param-type-mismatch".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["param-type-mismatch"]), + [ + finding("GetHashKey(name)", "Cannot assign `Hash` to parameter `name` of type `string`"), + finding("volume', volume)", "Cannot assign `number` to parameter `value` of type `string`"), + finding("volume', value)", "Cannot assign `string|number` to parameter `value` of type `string`"), + ], + "`nil`, `0` and `false` are NULL to a native and a literal is the text it is written as, while \ + other numbers and booleans are no string; a boolean still passes for a number" + ); + let server = "\ +---@type number +local src = 1 +DropPlayer(src, 'kicked') +"; + client.open_with(SERVER, server); + assert!( + findings(&mut client, SERVER, &["param-type-mismatch"]).is_empty(), + "a player's server id is a number to scripts" ); } diff --git a/docs/reference.md b/docs/reference.md index a109792..d75fcf0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -326,7 +326,8 @@ and for `redundant-parameter` the one that takes the most. `param-type-mismatch` compares each argument with the type of its parameter, as `assign-type-mismatch` compares a value with the type of its variable: a different kind of value, -or a literal the type does not list, is reported. +or a literal the type does not list, is reported. Each type a union lists has to fit, so a +`string|number` is no `string`, as in lua-language-server. ```lua ---@param mode "fast" | "slow" @@ -352,14 +353,41 @@ An argument has a type when something declares it, as for `impossible-comparison operator, an annotation, a stub, or the values a local is declared with or assigned that reach the call. A literal stored in a local counts by its kind only: `local mode = 'dev'` is a setting to change, so it passes for `"fast" | "slow"`, while `local count = 5` is still no `string`. A -`--[[@as T]]` right after an argument casts it. Only clear cases count, and the rest is left alone: - -- Natives. The runtime converts their arguments, so `0` and `1` pass for a `boolean`, a string for - a hash, a number for a string, and a vector for three floats. -- `nil`, written out or as the `?` of a `string?`, which is checked as a `string`. Annotations often - leave out the `?` of a parameter that code skips. -- `false`, which FiveM code passes to skip a parameter, as in `AddItem(source, item, 1, false, info)`, - since exports and events serialize their arguments. +`--[[@as T]]` right after an argument casts it. + +As in LuaLS, `nil`, written out or as the `?` of a `string?`, needs a parameter that takes it: one +marked optional, or typed with `nil`, `any` or `unknown`. A guard such as `if name then` rules the +`nil` out first. `false` is a `boolean` like any other, also where code passes it to skip a +parameter, as in `AddItem(source, item, 1, false, info)`. + +```lua +---@param name string +local function greet(name) end + +---@type string? +local maybe +greet(maybe) -- Cannot assign `string?` to parameter `name` of type `string` +greet(nil) -- Cannot assign `nil` to parameter `name` of type `string` +``` + +Natives are checked as the runtime passes their arguments on: + +- Numbers and booleans pass for each other, as a `BOOL` is an integer to natives: `0` and `1` pass + for a `boolean`, and `false` for an integer. +- A string parameter takes `nil` or a `string?`, and `0` and `false`, which the native wrappers + turn into NULL, as in `DrawMarker(..., nil, nil, false)`, and a number or boolean written out, + which they turn into the text it is written as, as in `SetConvarReplicated('volume', 30)`. A + parameter for the server id of a player, such as the `playerSrc` of `DropPlayer`, takes any + number. Other numbers and booleans are reported: `ReleaseNamedRendertarget(GetHashKey(name))` + passes a hash, not the name. +- A hash parameter takes a string, which the wrappers hash. +- A vector fills a number parameter for each of its parts, as in `SetEntityCoords(ped, coords, ...)`. + The values after a value of unknown type that may be a vector are not checked when the call + passes fewer values than the native takes, since they may go to later parameters. + +Other parameters of natives do not take `nil`: `DoesEntityExist(entity)` with an `Entity?` is +reported. Only clear cases count, and the rest is left alone: + - Types inferred from assigned values, such as that of `Config.Value = ''`. - Parameters typed with a generic of the function called: the arguments of the call bind it, which declares nothing. A function that a callee passes to a callback, such as `resolve` of @@ -804,22 +832,20 @@ reported. Where guards together rule out every value of the type, the read has t reported, as in lua-language-server. After `round and (round == true or i < round)`, a `boolean?` keeps its type, but the `nil` that `round and` rules out stays out. -A local that may hold `nil` is reported as well where a call passes it for a parameter that does not -take `nil`, in every signature the call may use as `param-type-mismatch` reads them. A parameter -takes `nil` when it is optional, as `label?`, or its type allows it or any value, as `string?`, -`any` or a generic do. Natives are left out, and so is an argument of another type than the -parameter takes, which `param-type-mismatch` reports: +A local that may hold `nil`, passed for a parameter that does not take `nil`, is a +`param-type-mismatch`, as in lua-language-server: ```lua ---@param target Player local function greet(target) end local player = GetPlayer() -- Player? -greet(player) -- `player` may be nil, which parameter `target` of type `Player` does not take: ... +greet(player) -- Cannot assign `Player?` to parameter `target` of type `Player` ``` -lua-language-server reports such an argument as a `param-type-mismatch`, so a -`---@diagnostic disable` comment for that rule silences it too. +`need-check-nil` still reports one passed for a parameter typed with the name of a native handle, +such as `Vehicle`, which `param-type-mismatch` leaves out, in every signature the call may use. A +`---@diagnostic disable` comment for `param-type-mismatch` silences it too. Only a guard on the local itself checks it. After `local vehicle, coords = lib.getClosestVehicle(pos)` and `if not vehicle then return end`, `coords` diff --git a/docs/rules.md b/docs/rules.md index e4478db..1995892 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -37,7 +37,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Tables typed as a shape, such as `{ value: string }`, an array or a `table` are checked by the types of their entries. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | -| `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, or a literal the type does not list. Natives, `nil` and `false` are not checked. Reported by qbx-lua-ls only. | +| `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, a `string\|number` for `string`, a literal the type does not list, or `nil` or a `string?` for a parameter that does not take `nil`. Natives are checked as the runtime converts their arguments. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | | `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. A function without `@return` is checked against the function type it is written as, such as the `fun(): string` of the parameter it is passed for. Reported by qbx-lua-ls only. | @@ -51,7 +51,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `self-comparison` | warning | A comparison has the same expression on both sides. | | `count-down-loop` | warning | A numeric `for` loop starts above its end without a negative step: a literal start above a literal end, with no step or a positive one, which never runs, or `#list, 1` or arithmetic on `#list` such as `#list - 1, 1`, with no step, which never runs once the start is above 1. Fix available. | | `impossible-comparison` | info | An `==` or `~=` compares values whose declared types share no value, such as a string with a number, or a literal with a type that does not list it, so it always gives the same answer. Comparisons with `nil` are not reported. Reported by qbx-lua-ls only. | -| `need-check-nil` | warning | A local whose declared type allows `nil` or `false`, such as the `string?` a function's `@return` declares, is indexed, called, used as a key to store a value under, used in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, given as a numeric `for` bound, or passed for a parameter that does not take `nil`, with no guard or cast ruling the missing value out. Every such read is reported. Reported by qbx-lua-ls only. | +| `need-check-nil` | warning | A local whose declared type allows `nil` or `false`, such as the `string?` a function's `@return` declares, is indexed, called, used as a key to store a value under, used in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, given as a numeric `for` bound, or passed for a parameter typed with a native handle name such as `Vehicle` that does not take `nil`, with no guard or cast ruling the missing value out. Every such read is reported. Reported by qbx-lua-ls only. | | `lowercase-global` | warning | A global definition starts with a lowercase letter. | | `implicit-global` | warning | A function creates a global without a file-scope declaration. | | `builtin-overwrite` | warning | Code overwrites a known runtime global or native. | From 39fba5137c1ec8ecf33df18e5329b7265a06b711 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:14:31 +0200 Subject: [PATCH 072/193] fix(index): add fields only through the names that own their tables --- crates/qbx_lua_ls/src/index.rs | 3 +- crates/qbx_lua_ls/src/indexer.rs | 51 +++++++++++++++++++++++++-- crates/qbx_lua_ls/src/infer.rs | 5 +++ crates/qbx_lua_ls/tests/lsp.rs | 60 ++++++++++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index 3b3a5bc..ae2ec1f 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -156,7 +156,8 @@ pub struct Member { pub owner: SmolStr, pub symbol: Symbol, /// Set through a value typed as the class rather than on the table its `---@class` declares, - /// like `abc.x = 1` below `---@type Test` `local abc`. Strict classes do not count it as declared. + /// like `self.x = 1` in a method of `Test`, or `abc.x = 1` below `---@type Test` `abc = {}`. + /// Strict classes do not count it as declared. pub injected: bool, /// The `---@private`, `---@protected` or `---@package` above the statement that sets it. pub visibility: Visibility, diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 151c8c9..191c097 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -15,7 +15,7 @@ use crate::index::{ AliasDef, ClassDef, Element, EnumTable, EventDef, EventFamily, EventKind, FileId, FileIndex, Index, Member, Metatable, NuiCallbackDef, Read, Symbol, SymbolKind, }; -use crate::infer::{metatable_args, table_elements, table_fields, FileContext, Infer}; +use crate::infer::{metatable_args, table_elements, table_fields, Decl, FileContext, Infer}; use crate::luacats::{own_type, parse_doc_lines, DocGroup}; use crate::types::{CallbackRole, DescribedValue, FunType, Param, Type}; @@ -179,6 +179,16 @@ pub fn is_state_bag(expr: &Expr) -> bool { } } +/// The name that a path of fields like `a.b["c"]` starts from. +fn path_root(expr: &Expr) -> Option<&Name> { + match &expr.kind { + ExprKind::Name(name) => Some(name), + ExprKind::Field { base, .. } => path_root(base), + ExprKind::Index { base, index, .. } if index.as_string().is_some() => path_root(base), + _ => None, + } +} + const NET_EVENT_CALLS: &[&str] = &["RegisterNetEvent", "RegisterServerEvent"]; const HANDLER_CALLS: &[&str] = &["AddEventHandler"]; const CALLBACK_CALLS: &[&str] = &["lib.callback.register"]; @@ -703,6 +713,9 @@ impl<'a> Indexer<'a> { None => SmolStr::new(format!("{owner}.{}", segment.text)), }; } + if !self.is_global(&name.base) && !self.owns(&name.base, &owner, injected) { + return; + } self.push_member(owner, symbol, injected, Some(stmt.span.start)); } @@ -777,6 +790,10 @@ impl<'a> Indexer<'a> { } }; let class_table = matches!(&base.kind, ExprKind::Name(root) if self.is_class_table(root)); + let injected = typed_as_class && !class_table; + if !self.root_is_global(base) && !path_root(base).is_some_and(|root| self.owns(root, &owner, injected)) { + return; + } let nested = format!("{owner}.{}", name.text); let mut symbol = self.value_symbol(name, value, stmt.span.start, position, &nested, 1); if symbol.kind == SymbolKind::Variable { @@ -788,7 +805,37 @@ impl<'a> Indexer<'a> { self.out.typed_exports.push(self.out.members.len() as u32); } } - self.push_member(owner, symbol, typed_as_class && !class_table, Some(stmt.span.start)); + self.push_member(owner, symbol, injected, Some(stmt.span.start)); + } + + /// Whether a field set through a path from the local `root` is one of `owner`, the table the + /// path names: through `self` in a method, the table a `---@class` annotation declares, a table + /// that a local declares at the top of the file, or the instance a constructor makes in a local, + /// as in `local self = setmetatable({}, Child)` or before `setmetatable(made, Child)`. + /// `injected` tells whether the path holds a value typed as a class rather than its table. + /// Other locals hold tables made elsewhere, such as a parameter typed as a class or a loop + /// variable over another file's tables, and like TypeScript, what is set through them adds no + /// field to those tables. + fn owns(&self, root: &Name, owner: &str, injected: bool) -> bool { + let Some(Resolved::Local(id)) = self.ctx.resolution.resolve_at(root.span.start) else { return false }; + let decl = self.ctx.resolution.local(id).decl.start; + let made = match self.ctx.decl(decl) { + Some(Decl::SelfParam { .. }) => return true, + Some(Decl::Local { stmt, index }) => { + self.ctx.sets_metatable(id) + || matches!(&stmt.kind, StmtKind::Local { exprs, .. } + if exprs.get(*index).is_some_and(|value| table_fields(value).is_some())) + } + _ => false, + }; + if injected { + return false; + } + let holds = + |table: &str| owner.strip_prefix(table).is_some_and(|rest| rest.is_empty() || rest.starts_with('.')); + self.is_class_table(root) + || holds(&self.ctx.local_owner_key(decl)) + || made && self.owner_of(&self.infer.local_type_at(id, root.span.start)).is_some_and(|table| holds(&table)) } fn root_is_global(&self, expr: &Expr) -> bool { diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index 3b5a0fa..d73c843 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -210,6 +210,11 @@ impl<'a> FileContext<'a> { self.decls.get(&decl_start) } + /// Whether a `setmetatable(name, metatable)` call gives the local `id` a metatable. + pub fn sets_metatable(&self, id: LocalId) -> bool { + self.set_metatables.contains_key(&id) + } + pub fn doc_at(&self, stmt_start: u32) -> Rc { if let Some(doc) = self.docs.borrow().get(&stmt_start) { return doc.clone(); diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 3c8b4d9..8a04d2a 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -7464,6 +7464,61 @@ end } } +#[test] +fn fields_are_set_only_through_the_names_that_own_their_tables() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Thing +---@field name string +local Thing = {} +Thing.version = 2 + +function Thing:init() + self.ready = true +end + +---@param thing Test.Thing +local function tag(thing) + thing.extra = 5 +end + +local Config = { debug = false } +Config.verbose = true +local alias = Config +alias.copied = 1 + +local rows = { { label = 'a' }, { label = 'b' } } +for _, row in pairs(rows) do + row.count = 0 +end + +---@type Test.Thing +local other +print(other.version, other.ready, other.extra, Config.verbose, Config.copied, rows[1].count, tag) +"; + client.open_with(CLIENT, text); + let print = pos(text, "print(", 0).0; + let line = text.lines().nth(print as usize).unwrap(); + // The table of the `---@class`, `self` in its methods and a top-level local's own table take + // the fields set through them. + for (needle, expected) in [ + ("other.version", "version: integer"), + ("other.ready", "ready: boolean"), + ("Config.verbose", "verbose: boolean"), + ] { + let column = (line.find(needle).unwrap() + needle.len() - 1) as u32; + let hover = client.hover_text(CLIENT, print, column); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } + // A parameter typed as the class, another local holding the table and a loop variable over + // the rows hold tables they do not own, so what is set through them is no field of those. + for needle in ["other.extra", "Config.copied", "rows[1].count"] { + let column = (line.find(needle).unwrap() + needle.len() - 1) as u32; + let hover = client.hover_text(CLIENT, print, column); + assert!(hover.is_empty(), "{needle}: expected no hover, got {hover}"); + } +} + #[test] fn class_operators_type_the_operations_on_their_values() { let mut client = Client::start(fixture_root()); @@ -8388,8 +8443,9 @@ print(held, typed, late, looped, loopedMissing, fromRight, fromLeft, neither) // `setmetatable(Other, Base)` gives `Other` the methods of `Base`. ("local other =", &["greet: function"], &[]), // `setmetatable(o, self)` inside `Animal:new`, and on a local before it is returned. A method - // defined on an instance is one of the instances, not of the class. - ("local dog =", &["speak: function", "bark: function"], &[]), + // defined through a local that holds an instance made elsewhere adds nothing, to the + // instances or the class, as TypeScript reads `dog.bark = ...`. + ("local dog =", &["speak: function"], &["bark"]), ("local built =", &["speak: function"], &[]), // An instance of a `---@class` is that class, and the class keeps its own fields. ("local point =", &["local point: Point"], &[]), From d02043a2c716706be4c2f8e4f385347805fc6c22 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:22:41 +0200 Subject: [PATCH 073/193] feat(rules): report fields set through values whose type lacks them in inject-field --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 4 +- .../qbx_lua_ls/src/features/class_tables.rs | 13 ++- crates/qbx_lua_ls/src/features/diagnostics.rs | 12 ++- .../src/features/injected_fields.rs | 94 +++++++++++++++++++ crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/tests/lsp.rs | 90 ++++++++++++++++++ docs/reference.md | 49 +++++++++- docs/rules.md | 1 + 11 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/injected_fields.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index af57434..d42d97e 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -47,6 +47,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "cast-type-mismatch", "no-unknown", "need-check-nil", + "inject-field", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 844b1fe..b27910b 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -84,6 +84,8 @@ pub const PARAM_TYPE_MISMATCH: &str = "param-type-mismatch"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const UNDECLARED_FIELD: &str = "undeclared-field"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. +pub const INJECT_FIELD: &str = "inject-field"; +/// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const INVISIBLE: &str = "invisible"; /// Reported by qbx-lua-ls, which infers the returned values; the linter itself has no type information. pub const RETURN_TYPE_MISMATCH: &str = "return-type-mismatch"; @@ -165,6 +167,7 @@ pub static RULES: &[Rule] = &[ rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(PARAM_TYPE_MISMATCH, Correctness, WARN, false, "A call passes an argument of a different type than its parameter's LuaCATS annotation declares (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), + rule(INJECT_FIELD, Correctness, WARN, false, "A field is set through a value whose LuaCATS class or table type does not have it, rather than through the name that owns the table (language server only)."), rule(INVISIBLE, Correctness, WARN, false, "A private, protected or package field or method of a LuaCATS class is used outside its class, subclasses or file (language server only)."), rule(RETURN_TYPE_MISMATCH, Correctness, WARN, false, "A function returns a value of a different type than its @return annotation declares (language server only)."), rule(MISSING_RETURN, Correctness, WARN, false, "A function with a required @return value can end, or return, without it (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 60990b2..841e7da 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -85,6 +85,7 @@ fn bad_resource_reports_every_rule() { | "assign-type-mismatch" | "param-type-mismatch" | "undeclared-field" + | "inject-field" | "invisible" | "return-type-mismatch" | "missing-return" diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 24417fa..454f510 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -77,7 +77,9 @@ Available features depend on the editor's LSP client. completion leaves them out there. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and - `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. The opt-in + `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. `inject-field` + reports fields set through a value whose class or table type does not have them, and + `need-check-nil` a value that may be `nil` used where a missing value raises an error. The opt-in `no-unknown` reports parameters, locals and loop variables that have no type. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. - QB-Core and ESX server callback completion, navigation and payload hints from local handlers. diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index c88d362..2c1281e 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -1,8 +1,8 @@ //! Table constructors typed as a LuaCATS class, found where their type is known: `---@type` locals //! and assignments, arguments to class-typed parameters, `return` in a function documented with //! `@return`, and the tables such fields hold. With the fields that code sets on and reads from -//! values of a class, they drive `missing-fields`, `assign-type-mismatch`, `undeclared-field` and -//! the completion of field names. Table constructors typed as a table type, like a shape +//! values of a class, they drive `missing-fields`, `assign-type-mismatch`, `undeclared-field`, +//! `inject-field` and the completion of field names. Table constructors typed as a table type, like a shape //! `{ value: string }`, `string[]` or `table`, are found in the same places for //! `assign-type-mismatch`. Only the language server knows the classes, so qbx-lint registers the //! rules and this module reports them. @@ -761,6 +761,8 @@ fn typed_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> (Vec>, Ve pub struct Access<'c> { /// The type of the value whose field it is. pub owner: Type, + /// Where that value is written, like `a.b` in `a.b.c = 1`. + pub holder: Span, pub key: Key<'c>, /// Where the name or key is written. pub span: Span, @@ -815,7 +817,7 @@ impl<'c> Visitor<'c> for Accesses<'_, '_, 'c> { let on_class_table = self.is_class_table(base); let owner = self.infer.expr(base); let value = exprs.get(i); - self.out.push(Access { owner, key, span, value, on_class_table }); + self.out.push(Access { owner, holder: base.span, key, span, value, on_class_table }); // The target's value and key are read. visit::walk_expr(self, target); } @@ -836,9 +838,10 @@ impl<'c> Visitor<'c> for Accesses<'_, '_, 'c> { let on_class_table = path.is_empty() && self.infer.is_class_table(&name.base); let owner = FuncName { base: name.base.clone(), path: path.to_vec(), method: None, span: name.base.span }; + let holder = Span::new(name.base.span.start, path.last().unwrap_or(&name.base).span.end); let owner = self.infer.func_name_owner_type(&owner); let key = Key::Name(&field.text); - self.out.push(Access { owner, key, span: field.span, value: None, on_class_table }); + self.out.push(Access { owner, holder, key, span: field.span, value: None, on_class_table }); } } _ => {} @@ -854,7 +857,7 @@ impl<'c> Visitor<'c> for Accesses<'_, '_, 'c> { }; if let Some((base, key, span)) = read { let owner = self.infer.expr(base); - self.out.push(Access { owner, key, span, value: None, on_class_table: false }); + self.out.push(Access { owner, holder: base.span, key, span, value: None, on_class_table: false }); } } visit::walk_expr(self, expr); diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index a64a591..2bb8d5b 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,9 +5,9 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INVISIBLE, MISSING_FIELDS, - MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, - REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INJECT_FIELD, INVISIBLE, + MISSING_FIELDS, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, + REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -24,6 +24,7 @@ use super::class_tables::missing_fields; use super::comparisons::impossible_comparisons; use super::discards::discarded_returns; use super::doc_names::undefined_doc_names; +use super::injected_fields::injected_fields; use super::nil_checks::{unchecked_nils, UncheckedNils}; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; @@ -83,7 +84,7 @@ impl<'a> CheckInput<'a> { /// do to the linter's own. fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 16] = [ + let checks: [(&'static str, Check); 17] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -98,6 +99,9 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< } undeclared_fields(input.infer, &input.doc.chunk, |file| strict_by_default(input.ws, file)) }), + (INJECT_FIELD, |input| { + injected_fields(input.infer, &input.doc.chunk, |file| strict_by_default(input.ws, file)) + }), (INVISIBLE, |input| invisible_members(input.infer, &input.doc.chunk)), (RETURN_TYPE_MISMATCH, |input| mismatched_returns(input.infer, &input.doc.chunk)), (MISSING_RETURN, |input| missing_returns(input.infer, &input.doc.chunk)), diff --git a/crates/qbx_lua_ls/src/features/injected_fields.rs b/crates/qbx_lua_ls/src/features/injected_fields.rs new file mode 100644 index 0000000..544f912 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/injected_fields.rs @@ -0,0 +1,94 @@ +//! `inject-field`: fields that an assignment or a `function value:name()` statement sets through a +//! value whose type does not have them, as TypeScript reports a property its type does not +//! declare. The type is a class, a table type such as the `{ label: string }` a table constructor +//! gives a local, or a table that another name declares, like the rows of another file's table +//! that a loop goes through. What is set through the names that own their tables declares the +//! field instead, so it is never reported: a global and the paths from it, the table a `---@class` +//! annotation declares, `self` in a method, a local declared with a table at the top of the file, +//! and the instance a constructor makes in a local. An empty table such as `local result = {}` +//! takes any field, and so do values of unknown type, `table` and `any`. Only names are checked, +//! not keys held in variables, and strict classes are left to `undeclared-field`. + +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::Span; + +use super::class_tables::{accesses, Classes, Key}; +use crate::index::FileId; +use crate::infer::{Decl, Infer}; +use crate::types::Type; + +/// The longest expression a message names for a table whose type has no name of its own. +const MAX_HOLDER_LEN: usize = 40; + +/// Each field that an assignment or a `function value:name()` statement sets through a value whose +/// type does not have it, with the message naming the type. `by_default` tells whether +/// `strict_classes` applies to a class declared in a file. +pub fn injected_fields(infer: &Infer, chunk: &Chunk, by_default: impl Fn(FileId) -> bool) -> Vec<(Span, String)> { + let classes = Classes::new(infer); + let mut out = Vec::new(); + for access in accesses(infer, chunk, false).into_iter().filter(|access| !access.on_class_table) { + let Key::Name(name) = access.key else { continue }; + if through_self(infer, access.holder) { + continue; + } + let parts = match access.owner.without_nil() { + Type::Union(types) => types.iter().filter(|ty| !matches!(ty, Type::Nil)).cloned().collect(), + ty => vec![ty], + }; + let lacks = |ty: &Type| has(infer, &classes, ty, &access.key, name, &by_default) == Some(false); + if !parts.is_empty() && parts.iter().all(lacks) { + out.push((access.span, message(infer, name, &access.owner, access.holder))); + } + } + out +} + +/// Whether the value written at `holder` is `self` in a method, or a path from it, whose fields the +/// method declares. +fn through_self(infer: &Infer, holder: Span) -> bool { + let Some(Resolved::Local(id)) = infer.ctx.resolution.resolve_at(holder.start) else { return false }; + matches!(infer.ctx.decl(infer.ctx.resolution.local(id).decl.start), Some(Decl::SelfParam { .. })) +} + +/// Whether a value of type `ty` has the field `name`, which `key` is: a field of its class or +/// table type, or one that code sets through the names that own its table. `None` when the type +/// does not tell which fields a value has, or is a strict class, whose fields `undeclared-field` +/// checks. +fn has( + infer: &Infer, + classes: &Classes, + ty: &Type, + key: &Key, + name: &str, + by_default: impl Fn(FileId) -> bool, +) -> Option { + let file = classes.file(); + if let Some((class, _, view)) = classes.class_of(ty, file) { + if classes.is_strict(&class, view, by_default) { + return None; + } + return Some(classes.declares(&class, view, key) || infer.member(ty, name).is_some()); + } + match classes.table_type_of(ty, file) { + Some(Type::Shape(shape)) if shape.fields.is_empty() && shape.array.is_none() && shape.indices.is_empty() => { + None + } + Some(table) => Some(classes.table_field_type(&table, file, key).is_some()), + None => matches!(ty, Type::GlobalTable(_)).then(|| infer.member(ty, name).is_some()), + } +} + +/// The message for the field `name` set through the value of type `owner` written at `holder`. +/// A table that a local declares has no name of its own, so the expression that holds it names it. +fn message(infer: &Infer, name: &str, owner: &Type, holder: Span) -> String { + let shown = owner.without_nil().to_string(); + if !shown.is_empty() && shown != "table" { + return format!("Field `{name}` is not declared in `{shown}`"); + } + let text = &infer.ctx.source[holder.start as usize..holder.end as usize]; + if text.len() > MAX_HOLDER_LEN || text.contains('\n') { + return format!("Field `{name}` is not declared in its table"); + } + format!("Field `{name}` is not declared in the table `{text}` holds") +} diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index 20d89dc..077043c 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -15,6 +15,7 @@ pub mod event_call; pub mod expected; pub mod folding; pub mod hover; +pub mod injected_fields; pub mod inlay; pub mod member_refs; mod native_argument; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 8a04d2a..d0da301 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4251,6 +4251,96 @@ print(own, ownLoose, vendor, vendorStrict, api) assert_eq!(lines(&mut client), [7, 13, 15, 16], "the same once the file is open"); } +#[test] +fn inject_field_reports_fields_set_through_values_whose_type_lacks_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Gadget +---@field name string +---@field [integer] string +local Gadget = {} +Gadget.__index = Gadget +Gadget.version = 2 + +function Gadget.new() + local self = setmetatable({}, Gadget) + self.made = 1 + return self +end + +function Gadget:init() + self.ready = true +end + +Tracker = {} + +function Tracker:reset() + self.count = 0 +end + +---@param gadget Test.Gadget +local function tag(gadget, key) + gadget.name = 'tagged' + gadget.ready = false + gadget.extra = 5 + gadget[key] = 'x' + gadget[1] = 'first' +end + +---@class (strict) Test.Sealed + +---@type Test.Sealed +local sealed = {} +sealed.other = 1 + +local Config = { debug = false } +Config.verbose = true +local alias = Config +alias.copied = 1 + +local rows = { { label = 'a' }, { label = 'b' } } +for _, row in pairs(rows) do + row.label = 'c' + row.count = 0 +end + +local function build() + local result = {} + result.any = 1 + local options = { size = 1 } + options.color = 'red' + function options.reset() end + return result, options +end + +---@diagnostic disable-next-line: inject-field +alias.allowed = 1 +print(tag, build) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let found = |needle: &str, message: &str| ("inject-field".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["inject-field"]), + [ + found("self.made", "Field `made` is not declared in `Test.Gadget`"), + found("gadget.extra", "Field `extra` is not declared in `Test.Gadget`"), + found("alias.copied", "Field `copied` is not declared in the table `alias` holds"), + found("row.count", "Field `count` is not declared in `{ label: string }`"), + found("options.color", "Field `color` is not declared in `{ size: integer }`"), + found("options.reset", "Field `reset` is not declared in `{ size: integer }`"), + ], + "fields set through `self` in a method, also of a global declared empty, the class table, a \ + global or a table's own local are declared, and an empty table, keys in variables and strict \ + classes are left alone" + ); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "inject-field": "off" } } } } }), + ); + assert_eq!(findings(&mut client, CLIENT, &["inject-field"]), [], "the rule can be turned off"); +} + #[test] fn private_protected_and_package_members_stay_in_their_class_or_file() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index d75fcf0..2150bdd 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown` and `need-check-nil`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown`, `need-check-nil` and `inject-field`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -482,6 +482,53 @@ classes of third-party resources stay as they are unless they say `(strict)` the class is declared in several places, one `(strict)` makes it strict, and otherwise one `(loose)` keeps it loose. +## Injected fields + +A field belongs to a table when the code that owns the table sets it: through a global or a path +from one, like `Config.debug = true`, on the table a `---@class` annotation declares, through +`self` in a method, through a local declared with a table at the top of the file, and through the +instance a constructor makes in a local, as `local self = setmetatable({}, Base)` does for a table +without a `---@class`. As in TypeScript, what is set through other values adds no field: a +parameter or local typed as a class, a loop variable over another file's tables, or another local +that holds the same table. Hover and completion leave such fields out, and lua-language-server +leaves them out of a class too. + +`inject-field` reports the fields that assignments and `function value:name()` statements set +through a value whose type does not have them: a class, a table type such as the `{ label: string }` +a table constructor gives, or a table declared under another name, as TypeScript and +lua-language-server do. To turn it off: + +```toml +[rules] +"inject-field" = "off" +``` + +```lua +---@class Thing +---@field name string +local Thing = {} + +function Thing:init() + self.ready = true -- declares `ready` +end + +---@param thing Thing +local function tag(thing) + thing.ready = false + thing.extra = 5 -- inject-field: Field `extra` is not declared in `Thing` +end + +local rows = { { label = 'a' }, { label = 'b' } } +for _, row in pairs(rows) do + row.count = 0 -- inject-field: Field `count` is not declared in `{ label: string }` +end +``` + +An empty table, such as `local result = {}`, takes any field, and so do values of unknown type, +`table` and `any`. Keys held in variables are not checked, and strict classes are left to +`undeclared-field`. lua-language-server's `inject-field` checks classes only, and its +`---@diagnostic disable: inject-field` comments and `diagnostics.disable` entries apply here too. + ## Member visibility A class can keep fields and methods to itself, as in LuaLS: diff --git a/docs/rules.md b/docs/rules.md index 1995892..039135e 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -39,6 +39,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Tables typed as a shape, such as `{ value: string }`, an array or a `table` are checked by the types of their entries. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, a `string\|number` for `string`, a literal the type does not list, or `nil` or a `string?` for a parameter that does not take `nil`. Natives are checked as the runtime converts their arguments. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | +| `inject-field` | warning | A field is set, by an assignment or a `function value:name()` statement, through a value whose `---@class` or table type does not have it, like a parameter typed as a class or a loop variable over tables built as `{ label = 'a' }`, as TypeScript reports a property its type does not declare. Fields set through the name that owns a table declare them instead. See [Injected fields](reference.md#injected-fields). Reported by qbx-lua-ls only. | | `invisible` | warning | A field or method that a `---@class` keeps `private`, `protected` or `package`, with `---@field private name type` or `---@private` above the statement that sets it, is read, set or called outside its class, subclasses or file. See [Member visibility](reference.md#member-visibility). Reported by qbx-lua-ls only. | | `return-type-mismatch` | warning | A function returns a value that its `@return` type does not take, such as a number for `string`. A function without `@return` is checked against the function type it is written as, such as the `fun(): string` of the parameter it is passed for. Reported by qbx-lua-ls only. | | `missing-return` | warning | A function with a required `@return` value, or written as a function type that returns one, can reach its end without returning, or has a `return` with fewer values than required. Reported by qbx-lua-ls only. | From ba9b7215fdc87029eb27c7d81bb1ce5faba6f429 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:18:03 +0200 Subject: [PATCH 074/193] feat(rules): check tables typed as a union of classes and shapes, or as a shape, in missing-fields --- crates/qbx_lua_analysis/src/rules.rs | 2 +- .../qbx_lua_ls/src/features/class_tables.rs | 211 ++++++++++++++---- crates/qbx_lua_ls/tests/lsp.rs | 131 +++++++++++ docs/reference.md | 13 +- docs/rules.md | 2 +- 5 files changed, 315 insertions(+), 44 deletions(-) diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index b27910b..d542218 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -163,7 +163,7 @@ pub static RULES: &[Rule] = &[ rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), - rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class leaves out required fields (language server only)."), + rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class or shape, or a union of them, leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(PARAM_TYPE_MISMATCH, Correctness, WARN, false, "A call passes an argument of a different type than its parameter's LuaCATS annotation declares (language server only)."), rule(UNDECLARED_FIELD, Correctness, WARN, false, "A field or key that a strict LuaCATS class does not declare is set or read (language server only)."), diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 2c1281e..84466c7 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -4,8 +4,9 @@ //! values of a class, they drive `missing-fields`, `assign-type-mismatch`, `undeclared-field`, //! `inject-field` and the completion of field names. Table constructors typed as a table type, like a shape //! `{ value: string }`, `string[]` or `table`, are found in the same places for -//! `assign-type-mismatch`. Only the language server knows the classes, so qbx-lint registers the -//! rules and this module reports them. +//! `assign-type-mismatch`, and those typed as a shape or a union of classes and shapes for +//! `missing-fields`. Only the language server knows the classes, so qbx-lint registers the rules +//! and this module reports them. //! //! Type names are global, and resources may declare the same one differently: ox_fuel's //! `@class State` is not qbx_vehicles' `@enum State`. A name is looked up the way the file that @@ -24,7 +25,7 @@ use super::unknown_types::is_typed; use crate::index::{AliasDef, ClassDef, FileId, ResourceId}; use crate::infer::{bound_parents, class_bindings, expanded_bindings, substitute, Infer}; use crate::luacats::applies_on; -use crate::types::{DescribedValue, Type}; +use crate::types::{DescribedValue, Shape, Type}; const MAX_DEPTH: u32 = 8; @@ -38,6 +39,32 @@ pub struct ClassTable<'c> { pub from: FileId, } +/// A table constructor and the classes and shapes of which it has to be one, for `missing-fields`. +struct RequiredTable<'c> { + members: Vec, + table: &'c Expr, +} + +/// A type with required fields that a table constructor may have to be. +#[derive(PartialEq)] +enum Member { + /// A class, with the type arguments it is given and the file whose view of the type names + /// decides it. + Class(SmolStr, Vec, FileId), + /// A shape like `{ name: string }`, with the file whose view of the type names it is read with. + Shape(Arc, FileId), +} + +impl Member { + /// How a message names the type: a class by its name, a shape written out. + fn name(&self) -> String { + match self { + Member::Class(class, ..) => class.to_string(), + Member::Shape(shape, _) => Type::Shape(shape.clone()).to_string(), + } + } +} + /// A `@field` of a class, or of one of its parents. pub struct ClassField { pub name: SmolStr, @@ -249,6 +276,61 @@ impl<'a, 'b> Classes<'a, 'b> { self.table_index_for(ty, from, &key.ty()) } + /// The classes and shapes of which a table constructor typed as one of `types`, each named as + /// its file sees it, has to be one, looking through `?`, unions and aliases and leaving out the + /// types that hold no table, like `string`. There are none when no class or shape is listed, or + /// when another type that holds tables is, like `table`, `any`, `string[]` or a generic, since a + /// table may be one of those without setting any field. + fn table_members(&self, types: &[(Type, FileId)]) -> Option> { + let mut members = Vec::new(); + for (ty, from) in types { + let mut parts = Vec::new(); + self.flatten(ty, *from, &mut parts, 0); + for part in parts { + let member = match (self.class_of(&part, *from), part) { + (Some((class, args, view)), _) => Member::Class(class, args, view), + (None, Type::Shape(shape)) => Member::Shape(shape, *from), + (None, part) if self.kinds(&part, *from, 0).is_some_and(|kinds| kinds & kind::TABLE == 0) => { + continue + } + (None, _) => return None, + }; + if !members.contains(&member) { + members.push(member); + } + } + } + (!members.is_empty()).then_some(members) + } + + /// The type that `key` of a table of `member` holds, with the file whose view of the type names + /// it is read with, as `field_type` and `table_field_type` find it. + fn member_field_type(&self, member: &Member, key: &Key) -> Option<(Type, FileId)> { + match member { + Member::Class(class, args, from) => self.field_type(class, args, *from, key), + Member::Shape(shape, from) => self.table_field_type(&Type::Shape(shape.clone()), *from, key), + } + } + + /// The fields of `member` that a table setting the names `given` leaves out although their + /// type does not allow `nil`. + fn missing(&self, member: &Member, given: &[&str]) -> Vec { + let fields: Vec<(SmolStr, Type, FileId)> = match member { + Member::Class(class, args, from) => { + self.fields(class, args, *from).into_iter().map(|field| (field.name, field.ty, field.file)).collect() + } + Member::Shape(shape, from) => { + let required = shape.fields.iter().filter(|field| !field.optional); + required.map(|field| (field.name.clone(), field.ty.clone(), *from)).collect() + } + }; + fields + .into_iter() + .filter(|(name, ty, file)| !self.admits_nil(ty, *file) && !given.contains(&name.as_str())) + .map(|(name, ..)| name) + .collect() + } + /// The `@field`s of `class`, given the type arguments `args`, and of its parents, with the fields /// of a table type it names as a parent, leaving out those scoped to the other side. The first /// declaration of a name wins, so a class can narrow a field of its parent. A class that several @@ -722,13 +804,7 @@ impl<'a, 'b> Classes<'a, 'b> { /// Every class-typed table constructor of `chunk`, outer tables before the ones they hold. pub fn class_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { - let mut finder = Finder { - classes: Classes::new(infer), - function_returns: Vec::new(), - documented: FxHashMap::default(), - out: Vec::new(), - table_types: None, - }; + let mut finder = Finder::new(infer); finder.visit_block(&chunk.block); finder.out } @@ -746,15 +822,20 @@ pub struct TypedTable<'c> { /// The table constructors of `chunk` typed as a class, as `class_tables` finds them, together with /// those typed as a table type, and the class-typed ones that tables of such types hold. fn typed_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> (Vec>, Vec>) { - let mut finder = Finder { - classes: Classes::new(infer), - function_returns: Vec::new(), - documented: FxHashMap::default(), - out: Vec::new(), - table_types: Some((Declared::keeping_annotations(infer), Vec::new())), - }; + let declared = Some(Declared::keeping_annotations(infer)); + let mut finder = Finder { declared, table_types: Some(Vec::new()), ..Finder::new(infer) }; + finder.visit_block(&chunk.block); + (finder.out, finder.table_types.unwrap_or_default()) +} + +/// The table constructors of `chunk` that `missing-fields` checks: those typed as a class, a shape +/// or a union of them, found where `class_tables` finds class tables, and those that such tables +/// hold for fields of these types. +fn required_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { + let declared = Some(Declared::keeping_annotations(infer)); + let mut finder = Finder { declared, required: Some(Vec::new()), ..Finder::new(infer) }; finder.visit_block(&chunk.block); - (finder.out, finder.table_types.map(|(_, typed)| typed).unwrap_or_default()) + finder.required.unwrap_or_default() } /// A field set or read outside a table constructor. @@ -891,24 +972,27 @@ impl<'c> Visitor<'c> for Innermost { } } -/// Each class-typed table constructor that leaves out required fields, with the message naming them. -/// Fields that its class keeps from the code there, like `---@field private`, are required too, as -/// in LuaLS. +/// Each table constructor typed as a class or a shape that leaves out required fields, with the +/// message naming them. Fields that a class keeps from the code there, like `---@field private`, +/// are required too, as in LuaLS. A table typed as a union of classes and shapes has to set the +/// required fields of one of them, and its message has a line for each, as LuaLS's does. pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let classes = Classes::new(infer); - class_tables(infer, chunk) + required_tables(infer, chunk) .into_iter() .filter_map(|found| { let ExprKind::Table(fields) = &found.table.kind else { return None }; let given: Vec<&str> = fields.iter().filter_map(named_field).map(|(name, _)| name).collect(); - let missing: Vec = classes - .fields(&found.class, &found.args, found.from) - .iter() - .filter(|field| !classes.admits_nil(&field.ty, field.file) && !given.contains(&field.name.as_str())) - .map(|field| format!("`{}`", field.name)) - .collect(); - let message = format!("Missing required fields in type `{}`: {}", found.class, missing.join(", ")); - (!missing.is_empty()).then_some((found.table.span, message)) + let mut lines = Vec::new(); + for member in &found.members { + let missing: Vec = + classes.missing(member, &given).iter().map(|name| format!("`{name}`")).collect(); + if missing.is_empty() { + return None; + } + lines.push(format!("Missing required fields in type `{}`: {}", member.name(), missing.join(", "))); + } + Some((found.table.span, lines.join("\n"))) }) .collect() } @@ -985,16 +1069,38 @@ struct Finder<'a, 'b, 'c> { /// parameter list. documented: FxHashMap>, out: Vec>, - /// When tables typed as table types are looked for too, the reader of declared types that - /// assignments without a `---@type` are typed by, and the tables found. - table_types: Option<(Declared<'a, 'b>, Vec>)>, + /// When tables of other types than classes are looked for, the reader of declared types that + /// assignments without a `---@type` are typed by. + declared: Option>, + /// When tables typed as table types are looked for too, the tables found. + table_types: Option>>, + /// When the tables that `missing-fields` checks are looked for instead of class tables, the + /// tables found. + required: Option>>, } -impl<'c> Finder<'_, '_, 'c> { +impl<'a, 'b, 'c> Finder<'a, 'b, 'c> { + /// A finder of class tables. + fn new(infer: &'a Infer<'b>) -> Self { + Self { + classes: Classes::new(infer), + function_returns: Vec::new(), + documented: FxHashMap::default(), + out: Vec::new(), + declared: None, + table_types: None, + required: None, + } + } + /// Records `expr` when it is a table constructor and `expected`, named as `from` sees it, is a /// class, or a table type when those are looked for, then the constructors it holds for fields - /// of such types. + /// of such types. The tables that `missing-fields` checks are recorded by `required_table`. fn table(&mut self, expected: &Type, expr: &'c Expr, from: FileId, depth: u32) { + if self.required.is_some() { + self.required_table(&[(expected.clone(), from)], expr, depth); + return; + } let table = expr.unparen(); let ExprKind::Table(fields) = &table.kind else { return }; if let Some((class, args, from)) = self.classes.class_of(expected, from) { @@ -1021,26 +1127,51 @@ impl<'c> Finder<'_, '_, 'c> { } } } - if let Some((_, typed)) = &mut self.table_types { + if let Some(typed) = &mut self.table_types { typed.push(TypedTable { ty, table, from }); } } + /// Records `expr` when it is a table constructor that has to be one of the classes and shapes + /// that `expected` lists, each named as its file sees it, then the constructors it holds for + /// fields that some of them declare, which have to be one of the types those declare. + fn required_table(&mut self, expected: &[(Type, FileId)], expr: &'c Expr, depth: u32) { + let table = expr.unparen(); + let ExprKind::Table(fields) = &table.kind else { return }; + let Some(members) = self.classes.table_members(expected) else { return }; + let mut held = Vec::new(); + if depth < MAX_DEPTH { + for (key, _, value) in entries(self.classes.infer, fields) { + let Some(value) = value.filter(|value| is_table(value)) else { continue }; + let types: Vec<_> = + members.iter().filter_map(|member| self.classes.member_field_type(member, &key)).collect(); + held.push((types, value)); + } + } + if let Some(required) = &mut self.required { + required.push(RequiredTable { members, table }); + } + for (types, value) in held { + self.required_table(&types, value, depth + 1); + } + } + /// A table this file's code builds, typed by a name this file sees. fn top_table(&mut self, expected: &Type, expr: &'c Expr) { self.table(expected, expr, self.classes.file(), 0); } /// The type that an assignment to `target` without a `---@type` gives a table: that of a - /// class the target holds, or else the table type it is declared with, as its own `---@type` - /// or `@param` declares it. What the target was assigned before only tells what it held then. + /// class the target holds, or else, when tables of other types are looked for, the type it is + /// declared with, as its own `---@type` or `@param` declares it. What the target was assigned + /// before only tells what it held then. fn assigned_type(&self, target: &Expr) -> Type { let ty = self.classes.infer.target_type(target); if self.classes.class_of(&ty, self.classes.file()).is_some() { return ty; } - match &self.table_types { - Some((declared, _)) => declared.target(target), + match &self.declared { + Some(declared) => declared.target(target), None => ty, } } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index d0da301..4168212 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3792,6 +3792,137 @@ local declared = {} ); } +/// The `missing-fields` diagnostics of `text`, with the line of each. +fn missing_field_messages(client: &mut Client, text: &str) -> Vec<(u64, String)> { + client.open_with(CLIENT, text); + client.diagnostics_for(CLIENT); + let uri = client.uri(CLIENT).to_string(); + client.diagnostics[&uri] + .as_array() + .unwrap() + .iter() + .filter(|d| d["code"] == "missing-fields") + .map(|d| (d["range"]["start"]["line"].as_u64().unwrap(), d["message"].as_str().unwrap().into())) + .collect() +} + +#[test] +fn tables_typed_as_a_union_need_the_fields_of_one_of_its_types() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Animal +---@field legs integer + +---@class Test.Dog : Test.Animal +---@field bark boolean + +---@class Test.Cat : Test.Animal +---@field meow boolean + +---@class Test.Owner +---@field pet Test.Dog|Test.Cat + +---@alias Test.Pet Test.Dog|Test.Cat + +---@type Test.Dog|Test.Animal +local empty = {} + +---@type Test.Dog|Test.Animal +local animal = { legs = 4 } + +---@type Test.Pet +local neither = { legs = 4 } + +---@type Test.Dog|string +local dog = { legs = 4 } + +---@type Test.Dog|table +local any = {} + +---@type Test.Dog|Test.Cat[] +local list = {} + +---@type Test.Owner +local owner = { pet = {} } + +---@return Test.Dog|Test.Cat +local function adopt() + return { legs = 4, meow = true } +end +print(empty, animal, neither, dog, any, list, owner, adopt) +"; + let line = |needle: &str| pos(text, needle, 0).0 as u64; + assert_eq!( + missing_field_messages(&mut client, text), + [ + ( + line("local empty"), + "Missing required fields in type `Test.Dog`: `bark`, `legs`\n\ + Missing required fields in type `Test.Animal`: `legs`" + .into() + ), + ( + line("local neither"), + "Missing required fields in type `Test.Dog`: `bark`\n\ + Missing required fields in type `Test.Cat`: `meow`" + .into() + ), + // Of `Test.Dog|string`, only the class can be a table. + (line("local dog"), "Missing required fields in type `Test.Dog`: `bark`".into()), + // The table for `pet` has to be one of the types the field declares. + ( + line("local owner"), + "Missing required fields in type `Test.Dog`: `bark`, `legs`\n\ + Missing required fields in type `Test.Cat`: `meow`, `legs`" + .into() + ), + ], + "a table with the fields of one type passes, and `table` or an array takes any table" + ); +} + +#[test] +fn tables_typed_as_a_shape_need_its_fields() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Dog +---@field bark boolean + +---@class Test.Kennel +---@field size { width: integer, depth: integer, label?: string } + +---@type { name: string, tag: string? } +local untagged = { name = 'Rex' } + +---@type { name: string } +local unnamed = {} + +---@type Test.Kennel +local kennel = { size = { width = 2 } } + +---@type Test.Dog|{ name: string } +local named = { name = 'Rex' } + +---@param options { pet: Test.Dog } +local function walk(options) end +walk({ pet = {} }) +print(untagged, unnamed, kennel, named) +"; + let line = |needle: &str| pos(text, needle, 0).0 as u64; + assert_eq!( + missing_field_messages(&mut client, text), + [ + (line("local unnamed"), "Missing required fields in type `{ name: string }`: `name`".into()), + ( + line("local kennel"), + "Missing required fields in type `{ width: integer, depth: integer, label?: string }`: `depth`".into() + ), + // A class-typed field of a shape is checked as a field of a class is. + (line("walk({"), "Missing required fields in type `Test.Dog`: `bark`".into()), + ] + ); +} + #[test] fn class_typed_tables_complete_the_fields_they_lack() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 2150bdd..b805f75 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -705,8 +705,17 @@ generic ones such as `Box` for `---@alias Box { value: T }` included, as an argument, as a returned value, and in the fields of another typed table, also for the class tables a shape holds. An assignment without a `---@type` checks a table against the type its target is declared with, not one inferred from what the target held before. As in -lua-language-server, such a table may leave fields out and set fields its type does not name, and -a union of table types is not checked. +lua-language-server, such a table may set fields its type does not name, and the entries of a table +typed as a union are not checked. + +`missing-fields` reports a table typed as a shape that leaves out a field the shape requires, one +without `?` whose type does not allow `nil`, as TypeScript does and lua-language-server does not. A +table typed as a union of classes and shapes needs the required fields of one of them, and the +report has a line for each, as lua-language-server's does. Types that hold no table are left out, +so a table for `Dog|string` needs the fields of `Dog`, and as in TypeScript, a union that also +lists a type that takes any table, such as `table`, `any` or `string[]`, is not reported. A table +in a field of such a table, like the `{}` of `{ pet = {} }`, needs the fields of one of the types +that the classes and shapes declare for the field. ## Casts diff --git a/docs/rules.md b/docs/rules.md index 039135e..6e7b77f 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -35,7 +35,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | -| `missing-fields` | warning | A table constructor typed as a `---@class`, including one returned for an `@return` type, leaves out required fields. Reported by qbx-lua-ls only. | +| `missing-fields` | warning | A table constructor typed as a `---@class` or a shape such as `{ name: string }`, including one returned for an `@return` type, leaves out required fields. A table typed as a union of them needs the required fields of one. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Tables typed as a shape, such as `{ value: string }`, an array or a `table` are checked by the types of their entries. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, a `string\|number` for `string`, a literal the type does not list, or `nil` or a `string?` for a parameter that does not take `nil`. Natives are checked as the runtime converts their arguments. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | From c0f4f24c6656238cfc678f07f85c2fd76b896f05 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:33:26 +0200 Subject: [PATCH 075/193] fix(rules): require only the fields a (partial) class declares itself in missing-fields --- .../qbx_lua_ls/src/features/class_tables.rs | 51 ++++++++--- crates/qbx_lua_ls/src/index.rs | 5 +- crates/qbx_lua_ls/src/indexer.rs | 1 + crates/qbx_lua_ls/tests/lsp.rs | 91 +++++++++++++++++++ crates/qbx_luacats/src/luacats.rs | 6 ++ 5 files changed, 138 insertions(+), 16 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 84466c7..42e3b10 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -313,11 +313,12 @@ impl<'a, 'b> Classes<'a, 'b> { } /// The fields of `member` that a table setting the names `given` leaves out although their - /// type does not allow `nil`. + /// type does not allow `nil`; of a class, those that `required_fields` lists. fn missing(&self, member: &Member, given: &[&str]) -> Vec { let fields: Vec<(SmolStr, Type, FileId)> = match member { Member::Class(class, args, from) => { - self.fields(class, args, *from).into_iter().map(|field| (field.name, field.ty, field.file)).collect() + let fields = self.required_fields(class, args, *from).into_iter(); + fields.map(|field| (field.name, field.ty, field.file)).collect() } Member::Shape(shape, from) => { let required = shape.fields.iter().filter(|field| !field.optional); @@ -355,17 +356,7 @@ impl<'a, 'b> Classes<'a, 'b> { return; } let defs = self.class_defs(class, from); - let bindings = class_bindings(&defs, args); - for (file, def) in &defs { - let sides = def.field_sides.iter().copied().chain(std::iter::repeat(None)); - for (i, (field, side)) in def.fields.iter().zip(sides).enumerate() { - if applies_on(side, self.infer.side()) && !out.iter().any(|seen| seen.name == field.name) { - let (name, ty, doc) = (field.name.clone(), substitute(&field.ty, &bindings), field.doc.clone()); - let values = def.field_values.get(i).cloned().unwrap_or_default(); - out.push(ClassField { name, ty, doc, values, file: *file }); - } - } - } + self.add_own_fields(&defs, args, out); for (file, parent) in bound_parents(&defs, args) { match parent { Type::Named(parent, args) => self.collect_fields(&parent, &args, file, out, visited, depth + 1), @@ -382,6 +373,35 @@ impl<'a, 'b> Classes<'a, 'b> { } } + /// Adds the `@field`s that `defs`, the declarations of a class given the type arguments `args`, + /// declare for this side to `out`, leaving out the names it already has. + fn add_own_fields(&self, defs: &[(FileId, &ClassDef)], args: &[Type], out: &mut Vec) { + let bindings = class_bindings(defs, args); + for (file, def) in defs { + let sides = def.field_sides.iter().copied().chain(std::iter::repeat(None)); + for (i, (field, side)) in def.fields.iter().zip(sides).enumerate() { + if applies_on(side, self.infer.side()) && !out.iter().any(|seen| seen.name == field.name) { + let (name, ty, doc) = (field.name.clone(), substitute(&field.ty, &bindings), field.doc.clone()); + let values = def.field_values.get(i).cloned().unwrap_or_default(); + out.push(ClassField { name, ty, doc, values, file: *file }); + } + } + } + } + + /// The fields that a table of `class`, given the type arguments `args`, has to set unless their + /// type allows `nil`: its `fields`, or when one of its declarations says `(partial)`, only those + /// that its declarations declare themselves, without those of its parents, as in LuaLS. + pub fn required_fields(&self, class: &str, args: &[Type], from: FileId) -> Vec { + let defs = self.class_defs(class, from); + if !defs.iter().any(|(_, def)| def.partial) { + return self.fields(class, args, from); + } + let mut out = Vec::new(); + self.add_own_fields(&defs, args, &mut out); + out + } + /// Whether the class `class`, as `from` sees it, is `ancestor` or extends it through its parents. pub fn extends(&self, class: &str, from: FileId, ancestor: &str) -> bool { self.extends_at(class, from, ancestor, &mut FxHashSet::default(), 0) @@ -974,8 +994,9 @@ impl<'c> Visitor<'c> for Innermost { /// Each table constructor typed as a class or a shape that leaves out required fields, with the /// message naming them. Fields that a class keeps from the code there, like `---@field private`, -/// are required too, as in LuaLS. A table typed as a union of classes and shapes has to set the -/// required fields of one of them, and its message has a line for each, as LuaLS's does. +/// are required too, as in LuaLS, and a class marked `(partial)` requires only the fields it +/// declares itself. A table typed as a union of classes and shapes has to set the required fields +/// of one of them, and its message has a line for each, as LuaLS's does. pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let classes = Classes::new(infer); required_tables(infer, chunk) diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index ae2ec1f..d0c243f 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -92,7 +92,8 @@ fn same_class(a: &ClassDef, b: &ClassDef) -> bool { let operators = class.operators.iter().flat_map(|operator| operator.operand.iter().chain([&operator.result])); class.fields.iter().map(|field| &field.ty).chain(&class.parent_types).chain(indices).chain(operators) } - (a.generics == b.generics && a.parent_types == b.parent_types && a.side == b.side && a.strict == b.strict) + (a.generics == b.generics && a.parent_types == b.parent_types && a.side == b.side) + && (a.strict == b.strict && a.partial == b.partial) && (fields(a) == fields(b) && a.field_sides == b.field_sides && a.field_visibility == b.field_visibility) && (indices(&a.indices) == indices(&b.indices) && indices(&a.literal_fields) == indices(&b.literal_fields)) && (a.call == b.call && a.operators == b.operators) @@ -229,6 +230,8 @@ pub struct ClassDef { /// `Some(true)` for `@class (strict) Name` or `(exact)`, `Some(false)` for `(loose)`, and `None` /// when `strict_classes` in qbxlint.toml decides. pub strict: Option, + /// `@class (partial) Name`, whose tables need only the fields the class itself declares. + pub partial: bool, } impl ClassDef { diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 191c097..86c776d 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -275,6 +275,7 @@ impl<'a> Indexer<'a> { range, side: class.side, strict: class.strict, + partial: class.partial, }); } for alias in doc.aliases { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 4168212..8a657cb 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4088,6 +4088,97 @@ local account = { name = 'Ann' } assert_eq!(missing(&mut client, SERVER), [5]); } +#[test] +fn missing_fields_of_partial_classes_leave_out_inherited_fields() { + let mut client = Client::start(fixture_root()); + const SHARED: &str = "myresource/shared/config.lua"; + let shared = "\ +---@class Test.Anim +---@field Dict string +---@field Player string + +---@class (partial) Test.CutAnim : Test.Anim +---@field StartPhase number + +---@class Test.Anims +---@field Cut Test.CutAnim + +---@class (partial) Test.Grandchild : Test.CutAnim +---@field Extra string + +---@class Test.Child : Test.CutAnim + +---@class Test.Merged : Test.Anim +---@field First string + +---@class (partial) Test.Merged +---@field Second string + +---@class (partial) Test.Narrowed : Test.Anim +---@field Dict string + +---@class (partial) Test.Empty : Test.Anim + +---@class (partial) Test.Holder : Test.Anim +---@field value T +"; + let text = "\ +---@type Test.Anims +local nested = { Cut = { Dict = 'x', StartPhase = 1 } } + +---@type Test.CutAnim +local direct = { Dict = 'x' } + +---@type Test.Grandchild +local grandchild = {} + +---@type Test.Child +local child = {} + +---@type Test.Merged +local merged = {} + +---@type Test.Narrowed +local narrowed = {} + +---@type Test.Empty +local empty = {} + +---@type Test.Holder +local optional = {} + +---@type Test.Holder +local holder = {} +"; + client.open_with(SHARED, shared); + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |at: &str, message: &str| ("missing-fields".to_string(), line(at), message.to_string()); + // Only a class that is itself partial leaves out the fields of its parents. + assert_eq!( + findings(&mut client, CLIENT, &["missing-fields"]), + [ + finding("local direct", "Missing required fields in type `Test.CutAnim`: `StartPhase`"), + finding("local grandchild", "Missing required fields in type `Test.Grandchild`: `Extra`"), + finding("local child", "Missing required fields in type `Test.Child`: `StartPhase`, `Dict`, `Player`"), + finding("local merged", "Missing required fields in type `Test.Merged`: `First`, `Second`"), + finding("local narrowed", "Missing required fields in type `Test.Narrowed`: `Dict`"), + finding("local holder", "Missing required fields in type `Test.Holder`: `value`"), + ] + ); + + client.change(SHARED, 2, &shared.replace("(partial) Test.CutAnim", "Test.CutAnim")); + let found = findings(&mut client, CLIENT, &["missing-fields"]); + assert_eq!( + found[..2], + [ + finding("local nested", "Missing required fields in type `Test.CutAnim`: `Player`"), + finding("local direct", "Missing required fields in type `Test.CutAnim`: `StartPhase`, `Player`"), + ], + "a class that is no longer partial asks for the fields of its parents again" + ); +} + #[test] fn a_type_below_the_class_types_the_table_as_a_value_of_it() { let mut client = Client::start(fixture_root()); diff --git a/crates/qbx_luacats/src/luacats.rs b/crates/qbx_luacats/src/luacats.rs index 14f64a3..ab955e3 100644 --- a/crates/qbx_luacats/src/luacats.rs +++ b/crates/qbx_luacats/src/luacats.rs @@ -87,6 +87,8 @@ pub struct DocClass { /// `Some(true)` for `@class (strict) Name` or LuaLS's `(exact)`, `Some(false)` for `(loose)`, and /// `None` when the configured default decides. pub strict: Option, + /// `@class (partial) Name`, whose tables need only the fields the class itself declares. + pub partial: bool, } /// `---@operator add(Vec): Vec`: what an operation on a value of the class gives, for an operand of @@ -424,6 +426,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { line: index, side: side_attribute(attributes), strict: strict_attribute(attributes), + partial: attributes.split(',').any(|attribute| attribute.trim() == "partial"), ..DocClass::default() }); } @@ -1254,6 +1257,9 @@ mod tests { assert_eq!(doc.classes[0].name, "Player"); assert_eq!(doc.classes[0].parents, ["Entity"]); assert_eq!(doc.classes[0].strict, None); + assert!(doc.classes[0].partial); + assert!(parse("---@class (server, partial) Test").classes[0].partial); + assert!(!parse("---@class Partial : Test").classes[0].partial); let strict = |text: &str| parse(text).classes[0].strict; assert_eq!(strict("---@class (strict) Test"), Some(true)); assert_eq!(strict("---@class (server, exact) Test"), Some(true)); From 9100f831dec0ff317f208921802e0ed9d612119e Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:34:46 +0200 Subject: [PATCH 076/193] feat(rules): check tables held in arrays, maps and tuples in missing-fields --- .../qbx_lua_ls/src/features/class_tables.rs | 70 ++++++---- crates/qbx_lua_ls/tests/lsp.rs | 124 ++++++++++++++++++ docs/reference.md | 17 ++- docs/rules.md | 2 +- 4 files changed, 183 insertions(+), 30 deletions(-) diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index 42e3b10..bf08837 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -4,9 +4,9 @@ //! values of a class, they drive `missing-fields`, `assign-type-mismatch`, `undeclared-field`, //! `inject-field` and the completion of field names. Table constructors typed as a table type, like a shape //! `{ value: string }`, `string[]` or `table`, are found in the same places for -//! `assign-type-mismatch`, and those typed as a shape or a union of classes and shapes for -//! `missing-fields`. Only the language server knows the classes, so qbx-lint registers the rules -//! and this module reports them. +//! `assign-type-mismatch`, and those typed as a shape or a union of classes and shapes, also as the +//! entries of an array, a map or a tuple, for `missing-fields`. Only the language server knows the +//! classes, so qbx-lint registers the rules and this module reports them. //! //! Type names are global, and resources may declare the same one differently: ox_fuel's //! `@class State` is not qbx_vehicles' `@enum State`. A name is looked up the way the file that @@ -39,13 +39,14 @@ pub struct ClassTable<'c> { pub from: FileId, } -/// A table constructor and the classes and shapes of which it has to be one, for `missing-fields`. +/// A table constructor and the types of which it has to be one, for `missing-fields`. struct RequiredTable<'c> { members: Vec, table: &'c Expr, } -/// A type with required fields that a table constructor may have to be. +/// A type that a table constructor may have to be, which requires fields or types the tables the +/// constructor holds. #[derive(PartialEq)] enum Member { /// A class, with the type arguments it is given and the file whose view of the type names @@ -53,14 +54,18 @@ enum Member { Class(SmolStr, Vec, FileId), /// A shape like `{ name: string }`, with the file whose view of the type names it is read with. Shape(Arc, FileId), + /// An array like `Dog[]`, a `table` or a tuple like `[Dog, Cat]`, with the file whose + /// view of the type names it is read with. It requires no field, but types its entries. + Table(Type, FileId), } impl Member { - /// How a message names the type: a class by its name, a shape written out. + /// How a message names the type: a class by its name, a table type written out. fn name(&self) -> String { match self { Member::Class(class, ..) => class.to_string(), Member::Shape(shape, _) => Type::Shape(shape.clone()).to_string(), + Member::Table(ty, _) => ty.to_string(), } } } @@ -264,23 +269,34 @@ impl<'a, 'b> Classes<'a, 'b> { } /// The type that `key` of a table of the table type `ty`, as `from` sees it, holds: the field - /// of a shape that a name names, or else the value of its array part or of an index that takes - /// the key. + /// of a shape that a name names, the item of a tuple at the position a literal names, or else + /// the value of its array part or of an index that takes the key. Any other integer key of a + /// tuple may hold each of its items. pub fn table_field_type(&self, ty: &Type, from: FileId, key: &Key) -> Option<(Type, FileId)> { - if let (Type::Shape(shape), Key::Name(name)) = (ty, key) { - if let Some(field) = shape.fields.iter().find(|field| field.name == *name) { - let ty = if field.optional { field.ty.clone().optional() } else { field.ty.clone() }; - return Some((ty, from)); + match (ty, key) { + (Type::Shape(shape), Key::Name(name)) => { + if let Some(field) = shape.fields.iter().find(|field| field.name == *name) { + let ty = if field.optional { field.ty.clone().optional() } else { field.ty.clone() }; + return Some((ty, from)); + } + } + (Type::Tuple(items), Key::Typed(Type::IntLit(i))) => { + return items.get((*i as usize).wrapping_sub(1)).map(|item| (item.clone(), from)); } + (Type::Tuple(items), key) => { + return self.takes(&Type::Integer, from, &key.ty()).then(|| (Type::union(items.clone()), from)); + } + _ => {} } self.table_index_for(ty, from, &key.ty()) } - /// The classes and shapes of which a table constructor typed as one of `types`, each named as - /// its file sees it, has to be one, looking through `?`, unions and aliases and leaving out the - /// types that hold no table, like `string`. There are none when no class or shape is listed, or - /// when another type that holds tables is, like `table`, `any`, `string[]` or a generic, since a - /// table may be one of those without setting any field. + /// The types of which a table constructor typed as one of `types`, each named as its file sees + /// it, has to be one, looking through `?`, unions and aliases and leaving out the types that + /// hold no table, like `string`: classes and shapes, and arrays, maps and tuples, which a table + /// is without setting any field but which type the tables it holds. There are none when no such + /// type is listed, or when another type that holds tables is, like `table`, `any` or a generic, + /// since a table may be one of those without setting any field or what it holds being known. fn table_members(&self, types: &[(Type, FileId)]) -> Option> { let mut members = Vec::new(); for (ty, from) in types { @@ -290,6 +306,7 @@ impl<'a, 'b> Classes<'a, 'b> { let member = match (self.class_of(&part, *from), part) { (Some((class, args, view)), _) => Member::Class(class, args, view), (None, Type::Shape(shape)) => Member::Shape(shape, *from), + (None, part @ (Type::Array(_) | Type::Map(..) | Type::Tuple(_))) => Member::Table(part, *from), (None, part) if self.kinds(&part, *from, 0).is_some_and(|kinds| kinds & kind::TABLE == 0) => { continue } @@ -309,11 +326,13 @@ impl<'a, 'b> Classes<'a, 'b> { match member { Member::Class(class, args, from) => self.field_type(class, args, *from, key), Member::Shape(shape, from) => self.table_field_type(&Type::Shape(shape.clone()), *from, key), + Member::Table(ty, from) => self.table_field_type(ty, *from, key), } } /// The fields of `member` that a table setting the names `given` leaves out although their - /// type does not allow `nil`; of a class, those that `required_fields` lists. + /// type does not allow `nil`; of a class, those that `required_fields` lists. Arrays, maps and + /// tuples require none. fn missing(&self, member: &Member, given: &[&str]) -> Vec { let fields: Vec<(SmolStr, Type, FileId)> = match member { Member::Class(class, args, from) => { @@ -324,6 +343,7 @@ impl<'a, 'b> Classes<'a, 'b> { let required = shape.fields.iter().filter(|field| !field.optional); required.map(|field| (field.name.clone(), field.ty.clone(), *from)).collect() } + Member::Table(..) => Vec::new(), }; fields .into_iter() @@ -849,8 +869,8 @@ fn typed_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> (Vec>, Ve } /// The table constructors of `chunk` that `missing-fields` checks: those typed as a class, a shape -/// or a union of them, found where `class_tables` finds class tables, and those that such tables -/// hold for fields of these types. +/// or a union of them, found where `class_tables` finds class tables, and those that such tables, +/// or arrays, maps and tuples of them, hold for fields and entries of these types. fn required_tables<'c>(infer: &Infer, chunk: &'c Chunk) -> Vec> { let declared = Some(Declared::keeping_annotations(infer)); let mut finder = Finder { declared, required: Some(Vec::new()), ..Finder::new(infer) }; @@ -996,7 +1016,8 @@ impl<'c> Visitor<'c> for Innermost { /// message naming them. Fields that a class keeps from the code there, like `---@field private`, /// are required too, as in LuaLS, and a class marked `(partial)` requires only the fields it /// declares itself. A table typed as a union of classes and shapes has to set the required fields -/// of one of them, and its message has a line for each, as LuaLS's does. +/// of one of them, and its message has a line for each, as LuaLS's does. A table that may be an +/// array, a map or a tuple is not reported, but the tables it holds are. pub fn missing_fields(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { let classes = Classes::new(infer); required_tables(infer, chunk) @@ -1153,9 +1174,10 @@ impl<'a, 'b, 'c> Finder<'a, 'b, 'c> { } } - /// Records `expr` when it is a table constructor that has to be one of the classes and shapes - /// that `expected` lists, each named as its file sees it, then the constructors it holds for - /// fields that some of them declare, which have to be one of the types those declare. + /// Records `expr` when it is a table constructor that has to be one of the classes, shapes, + /// arrays, maps and tuples that `expected` lists, each named as its file sees it, then the + /// constructors it holds for fields and entries that some of them declare, which have to be one + /// of the types those declare. fn required_table(&mut self, expected: &[(Type, FileId)], expr: &'c Expr, depth: u32) { let table = expr.unparen(); let ExprKind::Table(fields) = &table.kind else { return }; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 8a657cb..6ee5b01 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3923,6 +3923,130 @@ print(untagged, unnamed, kennel, named) ); } +#[test] +fn tables_held_in_arrays_maps_and_tuples_need_the_fields_of_their_entries() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Dog +---@field bark boolean + +---@class Test.Cat +---@field meow boolean + +---@class Test.Owner +---@field pets Test.Dog[] + +---@alias Test.Dogs Test.Dog[] + +---@type Test.Dog[] +local dogs = { {}, { bark = true } } + +---@type table +local named = { rex = {} } + +---@type table +local positional = { {} } + +---@type [Test.Dog, Test.Cat] +local pair = { {}, {}, {} } + +---@type Test.Dogs? +local aliased = { {} } + +---@type Test.Dog[][] +local nested = { { {} } } + +---@type Test.Owner +local owner = { pets = { {} } } + +---@param list Test.Dog[] +local function walk(list) end +walk({ {} }) + +---@return Test.Dog[] +local function adopt() + return { {} } +end +print(dogs, named, positional, pair, aliased, nested, owner, adopt) +"; + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let dog = || "Missing required fields in type `Test.Dog`: `bark`".to_string(); + assert_eq!( + missing_field_messages(&mut client, text), + [ + (line("local dogs"), dog()), + (line("local named"), dog()), + // A `[string]` index takes no array entry, and a tuple has no item past its end. + (line("local pair"), dog()), + (line("local pair"), "Missing required fields in type `Test.Cat`: `meow`".into()), + (line("local aliased"), dog()), + (line("local nested"), dog()), + (line("local owner"), dog()), + (line("walk({"), dog()), + (line("return {"), dog()), + ], + "the tables an array, a map or a tuple holds have the type of its entries" + ); +} + +#[test] +fn tables_held_in_a_union_with_arrays_need_the_fields_of_a_type_its_members_give_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Dog +---@field bark boolean + +---@class Test.Cat +---@field meow boolean + +---@type Test.Dog|Test.Dog[] +local either = { + {}, +} + +---@type Test.Dog[]|Test.Cat[] +local lists = { {} } + +---@type [Test.Dog, Test.Cat]|Test.Dog[] +local mixed = { {}, {} } + +---@type { pet: Test.Dog }|Test.Dog[] +local shaped = { pet = {} } + +---@type Test.Dog[]|{ name: string } +local unnamed = { {} } + +---@type Test.Dog[]|table +local open = { {} } +print(either, lists, mixed, shaped, unnamed, open) +"; + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let dog = || "Missing required fields in type `Test.Dog`: `bark`".to_string(); + assert_eq!( + missing_field_messages(&mut client, text), + [ + // The array makes the outer table pass, and types the one it holds. + (line(" {},"), dog()), + ( + line("local lists"), + "Missing required fields in type `Test.Dog`: `bark`\n\ + Missing required fields in type `Test.Cat`: `meow`" + .into() + ), + (line("local mixed"), dog()), + ( + line("local mixed"), + "Missing required fields in type `Test.Cat`: `meow`\n\ + Missing required fields in type `Test.Dog`: `bark`" + .into() + ), + (line("local shaped"), dog()), + (line("local unnamed"), dog()), + ], + "a held table has to be one of the types the members give its key, and `table` takes any table" + ); +} + #[test] fn class_typed_tables_complete_the_fields_they_lack() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index b805f75..01f4e5f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -709,13 +709,20 @@ lua-language-server, such a table may set fields its type does not name, and the typed as a union are not checked. `missing-fields` reports a table typed as a shape that leaves out a field the shape requires, one -without `?` whose type does not allow `nil`, as TypeScript does and lua-language-server does not. A -table typed as a union of classes and shapes needs the required fields of one of them, and the +without `?` whose type does not allow `nil`, as TypeScript does and lua-language-server does not. +The tables that an array, a `table` or a tuple holds need the fields of the type of their +entry, so the `{}` of `{ {} }` for `Dog[]` and of `{ rex = {} }` for `table` are +reported, as in both. An entry whose key the type does not take, like the `{}` of `{ {} }` for +`table`, is not checked. + +A table typed as a union of classes and shapes needs the required fields of one of them, and the report has a line for each, as lua-language-server's does. Types that hold no table are left out, so a table for `Dog|string` needs the fields of `Dog`, and as in TypeScript, a union that also -lists a type that takes any table, such as `table`, `any` or `string[]`, is not reported. A table -in a field of such a table, like the `{}` of `{ pet = {} }`, needs the fields of one of the types -that the classes and shapes declare for the field. +lists an array, a `table`, a tuple or a type that takes any table, such as `table` or `any`, +is not reported. A table that a table typed as a union holds, like the `{}` of `{ pet = {} }`, +needs the fields of one of the types that its members declare for its key, as `Dog|Cat` for an +entry of `Dog[]|Cat[]`. As in TypeScript and unlike in lua-language-server, the tables held by one +that may be a `table` or `any`, as for `Dog[]|table`, are not checked. ## Casts diff --git a/docs/rules.md b/docs/rules.md index 6e7b77f..97ae8d8 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -35,7 +35,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | -| `missing-fields` | warning | A table constructor typed as a `---@class` or a shape such as `{ name: string }`, including one returned for an `@return` type, leaves out required fields. A table typed as a union of them needs the required fields of one. Reported by qbx-lua-ls only. | +| `missing-fields` | warning | A table constructor typed as a `---@class` or a shape such as `{ name: string }`, including one returned for an `@return` type, leaves out required fields. A table typed as a union of them needs the required fields of one. The tables that an array, a `table` or a tuple holds are checked by the type of their entry, like the `{}` of `{ {} }` for `Dog[]`. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Tables typed as a shape, such as `{ value: string }`, an array or a `table` are checked by the types of their entries. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, a `string\|number` for `string`, a literal the type does not list, or `nil` or a `string?` for a parameter that does not take `nil`. Natives are checked as the runtime converts their arguments. Reported by qbx-lua-ls only. | | `undeclared-field` | warning | A field or key that a strict `---@class`, marked `(strict)` or `(exact)` or made strict by `strict_classes`, does not declare is set in a table constructor or assignment, or read. Reported by qbx-lua-ls only. | From c52be857f7a907a707a6a357d0b7033c4c9d948a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:31:32 +0200 Subject: [PATCH 077/193] feat(rules): add opt-in checks for undocumented functions --- crates/qbx_lua_analysis/src/checks/arity.rs | 171 +++--- crates/qbx_lua_analysis/src/checks/docs.rs | 489 +++++++++++++++++- crates/qbx_lua_analysis/src/lua_ls_config.rs | 43 +- crates/qbx_lua_analysis/src/rules.rs | 6 + .../fixtures/good_resource/client/main.lua | 1 + .../fixtures/good_resource/shared/config.lua | 1 + crates/qbx_lua_analysis/tests/rules.rs | 186 ++++++- crates/qbx_lua_ls/src/features/diagnostics.rs | 38 +- .../qbx_lua_ls/src/features/unknown_types.rs | 16 + crates/qbx_lua_ls/tests/lsp.rs | 51 ++ docs/reference.md | 57 +- docs/rules.md | 3 + 12 files changed, 959 insertions(+), 103 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/arity.rs b/crates/qbx_lua_analysis/src/checks/arity.rs index 734467c..e1c4e11 100644 --- a/crates/qbx_lua_analysis/src/checks/arity.rs +++ b/crates/qbx_lua_analysis/src/checks/arity.rs @@ -29,27 +29,102 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { if sink.enabled(rules::MISSING_PARAMETER).is_none() && sink.enabled(rules::REDUNDANT_PARAMETER).is_none() { return; } - let own_env; - let env = match input.resource { - Some(resource) => resource.env, - None => { - let mut env = ResourceEnv::default(); - env.add_summary(input.summary, None); - own_env = env; - &own_env + let callees = Callees::new(input); + Calls { callees: &callees, sink }.visit_block(&input.chunk.block); +} + +/// The functions that the calls of a file may run, as far as the file and the summaries of its +/// resource tell: the documented global functions, and the local functions and fields of local +/// tables of the file. +pub(super) struct Callees<'i, 'a> { + input: &'i FileInput<'a>, + /// The file's own summary, for a file outside any resource. + own_env: ResourceEnv, + locals: LocalDefs, + declared: DeclaredDefs, + regions: SideRegions, +} + +impl<'i, 'a> Callees<'i, 'a> { + pub(super) fn new(input: &'i FileInput<'a>) -> Self { + let mut own_env = ResourceEnv::default(); + if input.resource.is_none() { + own_env.add_summary(input.summary, None); } - }; - let mut collector = LocalCollector { input, env, defs: LocalDefs::default(), declared: DeclaredDefs::default() }; - collector.visit_block(&input.chunk.block); - let mut calls = Calls { - input, - env, - locals: collector.defs, - declared: collector.declared, - regions: SideRegions::of(input.source, input.chunk), - sink, - }; - calls.visit_block(&input.chunk.block); + let env = input.resource.map_or(&own_env, |resource| resource.env); + let mut collector = + LocalCollector { input, env, defs: LocalDefs::default(), declared: DeclaredDefs::default() }; + collector.visit_block(&input.chunk.block); + let (locals, declared) = (collector.defs, collector.declared); + Self { input, own_env, locals, declared, regions: SideRegions::of(input.source, input.chunk) } + } + + fn env(&self) -> &ResourceEnv { + self.input.resource.map_or(&self.own_env, |resource| resource.env) + } + + /// The signatures a call of `root.fields` may run: every definition, and its overloads unless + /// they are scoped to the other side. `None` when the call may run a function the file does not + /// know, such as one held by a parameter. + fn signatures(&self, call: &Expr, root: &Name, fields: &[&str]) -> Option>> { + let side = self.regions.effective(call.span.start, self.input.side); + let defs: Vec>> = match self.input.resolution.resolve_at(root.span.start)? { + Resolved::Local(id) => { + // Parameters and loop variables start out with a value this file does not know. + let kind = self.input.resolution.local(id).kind; + if !matches!(kind, LocalKind::Local | LocalKind::LocalFunction) { + return None; + } + let key = (id, SmolStr::new(fields.join("."))); + match (self.declared.get(&key), self.locals.get(&key)) { + (Some(declared), _) => vec![Some(declared.clone())], + (None, Some(defs)) => defs.clone(), + (None, None) => return None, + } + } + // An encrypted script of the resource may define the global differently. + Resolved::Global(_) if self.env().opaque => return None, + Resolved::Global(_) => { + let path = global_key(&root.text, fields)?; + self.env().function_defs(&path, side).map(|def| def.cloned()).collect() + } + }; + // A value other than a function literal may be any function. + let defs = defs.into_iter().collect::>>()?; + let signatures: Vec> = defs + .iter() + .flat_map(|fun| { + let overloads = fun.overloads.iter().filter(|overload| applies_on(overload.side, side)); + std::iter::once(fun).chain(overloads).cloned() + }) + .collect(); + (!signatures.is_empty()).then_some(signatures) + } + + /// The function types that the functions `call` may run declare for its argument at + /// `position`, such as the `fun(source: number, ...)` of a callback wrapper. + pub(super) fn argument_functions(&self, call: &Expr, position: usize) -> Vec> { + let callee = match &call.kind { + ExprKind::Call { callee, .. } => member_path(callee).map(|(root, fields)| (root, fields, false)), + ExprKind::MethodCall { base, method, .. } => member_path(base).map(|(root, mut fields)| { + fields.push(&method.text); + (root, fields, true) + }), + _ => None, + }; + let Some((root, fields, via_colon)) = callee else { return Vec::new() }; + let Some(signatures) = self.signatures(call, root, &fields) else { return Vec::new() }; + let alias = |name: &str| self.env().alias(name); + signatures + .iter() + .filter_map(|fun| { + // Positions count as `Requirement::of` counts them. + let index = (position + usize::from(via_colon)).checked_sub(usize::from(fun.is_method))?; + let param = fun.params.get(index).or_else(|| fun.params.last().filter(|p| p.name == "..."))?; + function_type(¶m.ty, &alias, 0) + }) + .collect() + } } struct LocalCollector<'i, 'a> { @@ -156,16 +231,12 @@ impl<'ast> Visitor<'ast> for LocalCollector<'_, '_> { } } -struct Calls<'i, 'a, 's> { - input: &'i FileInput<'a>, - env: &'i ResourceEnv, - locals: LocalDefs, - declared: DeclaredDefs, - regions: SideRegions, - sink: &'i mut Sink<'s>, +struct Calls<'c, 'i, 'a, 's> { + callees: &'c Callees<'i, 'a>, + sink: &'c mut Sink<'s>, } -impl<'ast> Visitor<'ast> for Calls<'_, '_, '_> { +impl<'ast> Visitor<'ast> for Calls<'_, '_, '_, '_> { fn visit_expr(&mut self, expr: &'ast Expr) { match &expr.kind { ExprKind::Call { callee, args, .. } => { @@ -190,7 +261,7 @@ impl<'ast> Visitor<'ast> for Calls<'_, '_, '_> { } } -impl Calls<'_, '_, '_> { +impl Calls<'_, '_, '_, '_> { #[allow(clippy::too_many_arguments)] fn check_call( &mut self, @@ -202,43 +273,9 @@ impl Calls<'_, '_, '_> { span: Span, display: &str, ) { - let side = self.regions.effective(call.span.start, self.input.side); - let defs: Vec>> = match self.input.resolution.resolve_at(root.span.start) { - Some(Resolved::Local(id)) => { - // Parameters and loop variables start out with a value this file does not know. - let kind = self.input.resolution.local(id).kind; - if !matches!(kind, LocalKind::Local | LocalKind::LocalFunction) { - return; - } - let key = (id, SmolStr::new(fields.join("."))); - match (self.declared.get(&key), self.locals.get(&key)) { - (Some(declared), _) => vec![Some(declared.clone())], - (None, Some(defs)) => defs.clone(), - (None, None) => return, - } - } - // An encrypted script of the resource may define the global differently. - Some(Resolved::Global(_)) if self.env.opaque => return, - Some(Resolved::Global(_)) => { - let Some(path) = global_key(&root.text, fields) else { return }; - self.env.function_defs(&path, side).map(|def| def.cloned()).collect() - } - None => return, - }; - // A value other than a function literal may be any function. - let Some(defs) = defs.into_iter().collect::>>() else { return }; - // Any definition or overload may be the one that runs, unless the overload is scoped to the - // other side. - let signatures: Vec<&FunType> = defs - .iter() - .flat_map(|fun| { - let overloads = fun.overloads.iter().filter(|overload| applies_on(overload.side, side)); - std::iter::once(fun).chain(overloads).map(|fun| fun.as_ref()) - }) - .collect(); - if signatures.is_empty() { - return; - } + // Any definition or overload may be the one that runs. + let Some(signatures) = self.callees.signatures(call, root, fields) else { return }; + let signatures: Vec<&FunType> = signatures.iter().map(|fun| fun.as_ref()).collect(); self.missing(&signatures, args, via_colon, span, display); self.redundant(&signatures, args, via_colon, display); } @@ -246,7 +283,7 @@ impl Calls<'_, '_, '_> { /// `missing-parameter`, for the signature that needs the fewest arguments. fn missing(&mut self, signatures: &[&FunType], args: &[Expr], via_colon: bool, span: Span, display: &str) { let Some(passed) = passed_count(args, 0) else { return }; - let env = self.env; + let env = self.callees.env(); let alias = |name: &str| env.alias(name); let least = signatures .iter() diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index 9276213..b504a8a 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,5 +1,10 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented -//! function has, and aliases, enums and class fields that one file declares twice for one side. +//! function has, aliases, enums and class fields that one file declares twice for one side, and +//! functions whose parameters and returned values the doc comments leave out. + +use std::cell::OnceCell; +use std::fmt; +use std::sync::Arc; use qbx_fivem_data::Side; use qbx_lua_syntax::ast::*; @@ -9,6 +14,7 @@ use qbx_luacats::luacats::{applies_on, declared_name, has_attribute, parse_doc_l use qbx_luacats::types::{FunType, Type}; use rustc_hash::{FxHashMap, FxHashSet}; +use super::arity::Callees; use super::{FileInput, Sink}; use crate::env::{doc_blocks, is_meta_file}; use crate::rules; @@ -21,7 +27,10 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { let params = wanted(rules::UNDEFINED_DOC_PARAM, &["@param"]) && !is_meta_file(source, input.chunk); let aliases = wanted(rules::DUPLICATE_DOC_ALIAS, &["@alias", "@enum"]); let fields = wanted(rules::DUPLICATE_DOC_FIELD, &["@field"]); - if !(params || aliases || fields) { + let globals = sink.enabled(rules::MISSING_GLOBAL_DOC).is_some(); + let exported = sink.enabled(rules::MISSING_LOCAL_EXPORT_DOC).is_some(); + let incomplete = wanted(rules::INCOMPLETE_SIGNATURE_DOC, &["@param", "@return"]); + if !(params || aliases || fields || globals || exported || incomplete) { return; } let blocks: Vec = doc_blocks(source, &input.chunk.comments) @@ -30,8 +39,13 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { .map(|comments| DocBlock::new(source, comments)) .collect(); let lines = LineIndex::new(source); + let code = OnceCell::new(); + let code = || code.get_or_init(|| Code::of(input.chunk, input.resolution, &lines)); if params { - undefined_params(input, &blocks, &lines, sink); + undefined_params(input, &blocks, code, &lines, sink); + } + if globals || exported || incomplete { + missing_docs(input, &blocks, code(), &lines, sink); } if aliases { duplicate_aliases(&blocks, &lines, sink); @@ -76,8 +90,15 @@ fn line_number(lines: &LineIndex, span: Span) -> u32 { /// start on the line below it, as LuaLS does, which covers table fields and nested calls. Comment /// lines in between do not separate them; a blank line does. A `@type fun(...)` in the comment /// documents its parameter names too. -fn undefined_params(input: &FileInput, blocks: &[DocBlock], lines: &LineIndex, sink: &mut Sink) { - let mut code: Option = None; +fn undefined_params<'a, 'c>( + input: &FileInput<'a>, + blocks: &[DocBlock], + code: impl Fn() -> &'c Code<'a>, + lines: &LineIndex, + sink: &mut Sink, +) where + 'a: 'c, +{ for block in blocks { // A doc comment after code on its line describes that line; only the lines below it document // the code that follows. @@ -92,10 +113,9 @@ fn undefined_params(input: &FileInput, blocks: &[DocBlock], lines: &LineIndex, s let typed = (first..block.lines.len()) .find(|&i| block.tag(i) == Some("type")) .and_then(|i| parse_doc_lines(&block.lines[i..=i]).ty); - let code = code.get_or_insert_with(|| Code::of(input.chunk, input.resolution, lines)); let last = block.comments[block.comments.len() - 1]; let mut documented = documented_offset(input.source, &input.chunk.comments, last) - .map(|offset| code.documented(offset, lines)) + .map(|offset| code().documented(offset, lines)) .unwrap_or_default(); if let Some(fun) = typed.as_ref().and_then(Type::as_fun) { documented.add_type(fun); @@ -189,13 +209,37 @@ impl<'a> Code<'a> { StmtKind::GenericFor { names, .. } => documented.names.extend(names.iter().map(|n| n.text.as_str())), _ => {} } - StatementFunctions { documented: &mut documented, code: self }.visit_stmt(stmt); } - for func in self.functions.get(&lines.line_of(offset)).into_iter().flatten() { + for func in self.bound(offset, lines, true) { documented.add(func); } documented } + + /// The functions a comment above the code at `offset` describes: those the statement there + /// defines or passes, with `by_name` also those it passes by the name of a variable it gave + /// them, and every function whose parameters start on its line. + fn bound(&self, offset: u32, lines: &LineIndex, by_name: bool) -> Vec<&'a FuncBody> { + let mut statement = StatementFunctions { functions: Vec::new(), code: self, by_name }; + if let Some(stmt) = self.statements.get(&offset) { + statement.visit_stmt(stmt); + } + let mut functions = statement.functions; + functions.extend(self.functions.get(&lines.line_of(offset)).into_iter().flatten()); + functions + } + + /// The one function a doc comment above the code at `offset` annotates, as LuaLS binds `@param` + /// and `@return`: the first one whose parameters start on its line, or else, for a statement + /// that spans lines, the first one it defines or passes. + fn annotated(&self, offset: u32, lines: &LineIndex) -> Option<&'a FuncBody> { + if let Some(func) = self.functions.get(&lines.line_of(offset)).and_then(|functions| functions.first()) { + return Some(func); + } + let mut statement = StatementFunctions { functions: Vec::new(), code: self, by_name: false }; + statement.visit_stmt(self.statements.get(&offset)?); + statement.functions.first().copied() + } } struct CodeCollector<'a, 'l> { @@ -244,28 +288,27 @@ impl<'ast> Visitor<'ast> for CodeCollector<'ast, '_> { } } -/// The functions a statement defines or passes, also by the name of a variable it gave them, -/// outside the blocks it runs and the bodies of those functions. +/// The functions a statement defines or passes, with `by_name` also by the name of a variable it +/// gave them, outside the blocks it runs and the bodies of those functions. struct StatementFunctions<'a, 'd> { - documented: &'d mut Documented<'a>, + functions: Vec<&'a FuncBody>, code: &'d Code<'a>, + by_name: bool, } impl<'ast> Visitor<'ast> for StatementFunctions<'ast, '_> { fn visit_block(&mut self, _: &'ast Block) {} fn visit_func_body(&mut self, func: &'ast FuncBody) { - self.documented.add(func); + self.functions.push(func); } fn visit_expr(&mut self, expr: &'ast Expr) { - if let ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. } = &expr.kind { + if let (true, ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. }) = (self.by_name, &expr.kind) { for arg in args { let ExprKind::Name(name) = &arg.kind else { continue }; let named = self.code.variable(name).and_then(|variable| self.code.named.get(&variable)); - for func in named.into_iter().flatten() { - self.documented.add(func); - } + self.functions.extend(named.into_iter().flatten()); } } visit::walk_expr(self, expr); @@ -307,6 +350,418 @@ impl<'a> Documented<'a> { } } +/// `missing-global-doc`, `missing-local-export-doc` and `incomplete-signature-doc`, as LuaLS reports +/// them: each parameter of a function that no `@param` names, and each value of each `return` at a +/// position no `@return` covers. A global or exported function without parameters or returned +/// values needs a comment instead. A `@type fun(...)` in the doc comment documents the parameters +/// and values it lists, and so does the function type a call declares for the function it is +/// passed, when the file or its resource documents the function called. +fn missing_docs<'a>(input: &FileInput<'a>, blocks: &[DocBlock], code: &Code<'a>, lines: &LineIndex, sink: &mut Sink) { + let source = input.source; + let mut signatures: FxHashMap = FxHashMap::default(); + for block in blocks { + let first = usize::from(!starts_line(source, block.comments[0].span.start)); + let last = block.comments[block.comments.len() - 1]; + let Some(offset) = documented_offset(source, &input.chunk.comments, last) else { continue }; + let Some(func) = code.annotated(offset, lines) else { continue }; + let signature = Signature::of(block, first); + signatures.entry(func.params_span.start).or_insert_with(|| (func, Signature::default())).1.merge(&signature); + } + let gaps = Gaps { input, arguments: OnceCell::new(), callees: OnceCell::new() }; + if sink.enabled(rules::INCOMPLETE_SIGNATURE_DOC).is_some() { + for (func, signature) in signatures.values().filter(|(_, signature)| signature.annotated) { + for (span, gap) in gaps.of(func, &returned_values(func), signature) { + let message = match gap { + Gap::Param(name) => format!("incomplete signature: parameter '{name}' has no @param annotation"), + Gap::Return(index) => { + format!("incomplete signature: return value #{index} has no @return annotation") + } + }; + sink.report(rules::INCOMPLETE_SIGNATURE_DOC, span, message); + } + } + } + let globals = sink.enabled(rules::MISSING_GLOBAL_DOC).is_some(); + let exported = sink.enabled(rules::MISSING_LOCAL_EXPORT_DOC).is_some(); + if !(globals || exported) { + return; + } + let mut targets = Targets::new(input.resolution); + targets.visit_block(&input.chunk.block); + let undocumented = Signature::default(); + let commented = OnceCell::new(); + let mut check = |rule: &'static str, subject: Subject, name: Span, func: &FuncBody| { + let returns = returned_values(func); + let signature = signatures.get(&func.params_span.start).map_or(&undocumented, |(_, signature)| signature); + // LuaLS asks for a comment only here, where there is nothing to annotate. + let bare = func.params.is_empty() && func.vararg.is_none() && returns.iter().all(|values| values.is_empty()); + if bare && !commented.get_or_init(|| commented_functions(input, code, lines)).contains(&func.params_span.start) + { + sink.report(rule, name, format!("{subject} has no comment")); + } + for (span, gap) in gaps.of(func, &returns, signature) { + let message = match gap { + Gap::Param(name) => format!("parameter '{name}' of {subject} has no @param annotation"), + Gap::Return(index) => format!("return value #{index} of {subject} has no @return annotation"), + }; + sink.report(rule, span, message); + } + }; + if globals { + for &(name, func) in &targets.globals { + check(rules::MISSING_GLOBAL_DOC, Subject::Global(&name.text), name.span, func); + } + } + if exported { + for (subject, name, func) in targets.exported() { + check(rules::MISSING_LOCAL_EXPORT_DOC, subject, name, func); + } + } +} + +/// What the doc comments of one function say about its parameters and returned values. +#[derive(Default)] +struct Signature { + params: FxHashSet, + vararg: bool, + returns: usize, + /// The last `@return` is `...T`, which covers every later value. + variadic_return: bool, + /// An `@param` or `@return` annotates the function, so `incomplete-signature-doc` checks it. + annotated: bool, +} + +impl Signature { + /// What the lines of `block` from `first` on say. + fn of(block: &DocBlock, first: usize) -> Self { + let parsed; + let doc = if first == 0 { + &block.doc + } else { + parsed = parse_doc_lines(&block.lines[first..]); + &parsed + }; + let mut signature = Signature::default(); + for param in &doc.params { + signature.add_param(¶m.name); + } + signature.add_returns(doc.returns.iter().map(|value| &value.ty)); + if let Some(fun) = doc.ty.as_ref().and_then(Type::as_fun) { + for param in &fun.params { + signature.add_param(¶m.name); + } + signature.add_returns(fun.returns.iter()); + } + signature.annotated = (first..block.lines.len()).any(|i| matches!(block.tag(i), Some("param" | "return"))); + signature + } + + fn add_param(&mut self, name: &SmolStr) { + match name.as_str() { + "..." => self.vararg = true, + _ => { + self.params.insert(name.clone()); + } + } + } + + fn add_returns<'t>(&mut self, types: impl Iterator) { + let types: Vec<&Type> = types.collect(); + self.returns = self.returns.max(types.len()); + self.variadic_return |= matches!(types.last(), Some(Type::Variadic(_))); + } + + fn merge(&mut self, other: &Signature) { + self.params.extend(other.params.iter().cloned()); + self.vararg |= other.vararg; + self.returns = self.returns.max(other.returns); + self.variadic_return |= other.variadic_return; + self.annotated |= other.annotated; + } + + /// Whether an annotation covers the returned value at `index`, counting from 1. + fn covers_return(&self, index: usize) -> bool { + index <= self.returns || self.variadic_return + } +} + +/// A parameter or returned value that no annotation documents. +enum Gap<'a> { + Param(&'a str), + /// The position of the value, counting from 1. + Return(usize), +} + +/// Finds what the annotations of a function leave out. +struct Gaps<'i, 'a> { + input: &'i FileInput<'a>, + arguments: OnceCell>, + callees: OnceCell>, +} + +impl<'a> Gaps<'_, 'a> { + /// The parameters of `func` that `signature` does not name, other than `self`, placeholders and + /// those the function type it is passed as names, and the values of its `returns` at positions + /// `signature` does not cover. + fn of<'f>(&self, func: &'f FuncBody, returns: &[&'f [Expr]], signature: &Signature) -> Vec<(Span, Gap<'f>)> { + let prefix = self.input.config.ignore_unused_prefix.as_str(); + let passed_as = OnceCell::new(); + let typed = |position: usize| { + let functions = passed_as.get_or_init(|| self.passed_as(func)); + functions.iter().any(|fun| fun.params.get(position).is_some_and(|param| param.name != "...")) + }; + let mut gaps = Vec::new(); + for (position, param) in func.params.iter().enumerate() { + let name = param.text.as_str(); + let placeholder = name == "_" || (!prefix.is_empty() && name.starts_with(prefix)); + if !(name.is_empty() || name == "self" || placeholder || signature.params.contains(name) || typed(position)) + { + gaps.push((param.span, Gap::Param(name))); + } + } + if let Some(span) = func.vararg.filter(|_| !signature.vararg) { + gaps.push((span, Gap::Param("..."))); + } + for values in returns { + for (index, value) in values.iter().enumerate() { + if !signature.covers_return(index + 1) { + gaps.push((value.span, Gap::Return(index + 1))); + } + } + } + gaps + } + + /// The function types that the call `func` is passed to declares for it, such as the + /// `fun(source: number, ...)` of a callback wrapper. + fn passed_as(&self, func: &FuncBody) -> Vec> { + let arguments = self.arguments.get_or_init(|| call_arguments(self.input.chunk)); + let Some(&(call, position)) = arguments.get(&func.params_span.start) else { return Vec::new() }; + self.callees.get_or_init(|| Callees::new(self.input)).argument_functions(call, position) + } +} + +/// The functions that calls pass, by where their parameters start, with the call and the position +/// of the argument. +fn call_arguments(chunk: &Chunk) -> FxHashMap { + struct Arguments<'a>(FxHashMap); + + impl<'ast> Visitor<'ast> for Arguments<'ast> { + fn visit_expr(&mut self, expr: &'ast Expr) { + if let ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. } = &expr.kind { + for (position, arg) in args.iter().enumerate() { + if let ExprKind::Function(func) = &arg.kind { + self.0.insert(func.params_span.start, (expr, position)); + } + } + } + visit::walk_expr(self, expr); + } + } + + let mut arguments = Arguments(FxHashMap::default()); + arguments.visit_block(&chunk.block); + arguments.0 +} + +/// The values of each `return` of `func`, not of the functions inside it. +fn returned_values(func: &FuncBody) -> Vec<&[Expr]> { + struct Returns<'a>(Vec<&'a [Expr]>); + + impl<'ast> Visitor<'ast> for Returns<'ast> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + if let StmtKind::Return(values) = &stmt.kind { + self.0.push(values); + } + visit::walk_stmt(self, stmt); + } + + fn visit_func_body(&mut self, _: &'ast FuncBody) {} + } + + let mut returns = Returns(Vec::new()); + returns.visit_block(&func.body); + returns.0 +} + +/// The functions with a comment, by where their parameters start, as LuaLS reads one: a comment of +/// any kind directly above the code that defines or passes them, or after code on the line their +/// parameters start on. A `---@diagnostic` line is no comment. +fn commented_functions(input: &FileInput, code: &Code, lines: &LineIndex) -> FxHashSet { + let source = input.source; + let mut commented = FxHashSet::default(); + for comment in &input.chunk.comments { + let text = comment.span.text(source); + if text.strip_prefix("---").is_some_and(|doc| doc.trim_start().starts_with("@diagnostic")) { + continue; + } + let functions = if starts_line(source, comment.span.start) { + match documented_offset(source, &input.chunk.comments, comment) { + Some(offset) => code.bound(offset, lines, false), + None => continue, + } + } else { + code.functions.get(&lines.line_of(comment.span.start)).cloned().unwrap_or_default() + }; + commented.extend(functions.iter().map(|func| func.params_span.start)); + } + commented +} + +/// What a finding of `missing-global-doc` or `missing-local-export-doc` calls the function. +#[derive(Clone, Copy)] +enum Subject<'a> { + Global(&'a str), + ExportedLocal(&'a str), + /// A function written in an `exports(name, function() end)` call, by the name of the export. + Export(&'a str), +} + +impl fmt::Display for Subject<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Subject::Global(name) => write!(f, "global function '{name}'"), + Subject::ExportedLocal(name) => write!(f, "exported local function '{name}'"), + Subject::Export(name) => write!(f, "exported function '{name}'"), + } + } +} + +/// The global functions of a file, and the local functions it exports: those assigned by name to +/// fields of a local table that a `return` gives, as LuaLS reads a module, and those passed to +/// `exports(name, fn)`, which also exports a function written in the call. +struct Targets<'a> { + resolution: &'a Resolution, + globals: Vec<(&'a Name, &'a FuncBody)>, + /// Locals declared with a function, by the name that declares them. + local_functions: FxHashMap, + /// Locals declared with a table constructor. + tables: FxHashSet, + returned: FxHashSet, + /// `table.field = local` assignments, as the table and the local assigned. + fields: Vec<(LocalId, LocalId)>, + exported_locals: Vec, + exported_functions: Vec<(&'a str, Span, &'a FuncBody)>, +} + +impl<'a> Targets<'a> { + fn new(resolution: &'a Resolution) -> Self { + Self { + resolution, + globals: Vec::new(), + local_functions: FxHashMap::default(), + tables: FxHashSet::default(), + returned: FxHashSet::default(), + fields: Vec::new(), + exported_locals: Vec::new(), + exported_functions: Vec::new(), + } + } + + fn local(&self, name: &Name) -> Option { + match self.resolution.resolve_at(name.span.start)? { + Resolved::Local(id) => Some(id), + Resolved::Global(_) => None, + } + } + + fn is_global(&self, name: &Name) -> bool { + matches!(self.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + } + + /// The exported functions, each once, with the name that defines them. + fn exported(&self) -> Vec<(Subject<'a>, Span, &'a FuncBody)> { + let module = + self.fields.iter().filter(|(table, _)| self.tables.contains(table) && self.returned.contains(table)); + let locals = module.map(|&(_, local)| local).chain(self.exported_locals.iter().copied()); + let mut seen = FxHashSet::default(); + let mut exported: Vec<_> = locals + .filter(|local| seen.insert(*local)) + .filter_map(|local| self.local_functions.get(&local)) + .map(|&(name, func)| (Subject::ExportedLocal(&name.text), name.span, func)) + .collect(); + exported.extend(self.exported_functions.iter().map(|&(name, span, func)| (Subject::Export(name), span, func))); + exported + } +} + +impl<'ast> Visitor<'ast> for Targets<'ast> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::Function { name, func } if name.path.is_empty() && name.method.is_none() => { + if self.is_global(&name.base) { + self.globals.push((&name.base, func)); + } + } + StmtKind::LocalFunction { name, func } => { + if let Some(local) = self.local(name) { + self.local_functions.insert(local, (name, func)); + } + } + StmtKind::Local { names, exprs, .. } => { + for (name, expr) in names.iter().zip(exprs) { + let Some(local) = self.local(&name.name) else { continue }; + match &expr.kind { + ExprKind::Function(func) => { + self.local_functions.insert(local, (&name.name, func)); + } + ExprKind::Table(_) => { + self.tables.insert(local); + } + _ => {} + } + } + } + StmtKind::Assign { targets, exprs } => { + for (target, expr) in targets.iter().zip(exprs) { + match (&target.kind, &expr.kind) { + (ExprKind::Name(name), ExprKind::Function(func)) if self.is_global(name) => { + self.globals.push((name, func)); + } + (ExprKind::Field { base, .. }, ExprKind::Name(value)) => { + let ExprKind::Name(table) = &base.kind else { continue }; + if let (Some(table), Some(value)) = (self.local(table), self.local(value)) { + self.fields.push((table, value)); + } + } + _ => {} + } + } + } + StmtKind::Return(values) => { + for value in values { + if let Some(local) = name_of(value).and_then(|name| self.local(name)) { + self.returned.insert(local); + } + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + if let ExprKind::Call { callee, args, .. } = &expr.kind { + let exports = name_of(callee).is_some_and(|callee| callee.text == "exports" && self.is_global(callee)); + if let (true, [name, value, ..]) = (exports, args.as_slice()) { + match (name.as_string(), &value.kind) { + (Some(export), ExprKind::Function(func)) => self.exported_functions.push((export, name.span, func)), + (Some(_), ExprKind::Name(local)) => self.exported_locals.extend(self.local(local)), + _ => {} + } + } + } + visit::walk_expr(self, expr); + } +} + +fn name_of(expr: &Expr) -> Option<&Name> { + match &expr.kind { + ExprKind::Name(name) => Some(name), + _ => None, + } +} + /// A type name a doc comment declares. struct TypeName<'a> { name: &'a str, diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index d42d97e..f756554 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -36,6 +36,9 @@ const EQUIVALENT_CODES: &[&str] = &[ "undefined-doc-param", "duplicate-doc-alias", "duplicate-doc-field", + "missing-global-doc", + "missing-local-export-doc", + "incomplete-signature-doc", "missing-fields", "assign-type-mismatch", "invisible", @@ -76,6 +79,8 @@ pub(crate) fn parse(text: &str, emmylua: bool, settings: &mut Settings) -> Resul let mut severities = Vec::new(); let mut disabled = Vec::new(); + let mut turned_on = Vec::new(); + let mut turned_off = Vec::new(); for (key, value) in entries { // LuaLS also accepts the settings under the client's `Lua.` prefix. let key = key.strip_prefix("Lua.").unwrap_or(&key); @@ -97,13 +102,25 @@ pub(crate) fn parse(text: &str, emmylua: bool, settings: &mut Settings) -> Resul if let Some(level) = level_from_severity(level) { severities.push((code.to_string(), level)); } + } else if let (Some(code), Some(status)) = + (key.strip_prefix("diagnostics.neededFileStatus."), value.as_str()) + { + match status.trim_end_matches('!').to_ascii_lowercase().as_str() { + "any" | "opened" => turned_on.push(code.to_string()), + "none" => turned_off.push(code.to_string()), + _ => {} + } } } } } - // Both servers let `diagnostics.disable` win over a severity for the same code. - let disabled = disabled.into_iter().map(|code| (code, Level::Off)); - for (code, level) in severities.into_iter().chain(disabled) { + // A LuaLS file status turns a check on at the check's own severity, a warning for each check + // that is off here by default, unless `diagnostics.severity` gives one, and `None` turns it off + // whatever its severity. Both servers let `diagnostics.disable` win over everything. + let off_by_default = |code: &String| crate::rules::find(code).is_some_and(|rule| rule.default.is_none()); + let turned_on = turned_on.into_iter().filter(off_by_default).map(|code| (code, Level::Warning)); + let turned_off = turned_off.into_iter().chain(disabled).map(|code| (code, Level::Off)); + for (code, level) in turned_on.chain(severities).chain(turned_off) { match ALIASES.iter().find(|(alias, _)| *alias == code) { Some((_, codes)) => settings.rules.extend(codes.iter().map(|c| (c.to_string(), level))), None if EQUIVALENT_CODES.contains(&code.as_str()) => settings.rules.push((code, level)), @@ -305,6 +322,26 @@ mod tests { assert!(settings.rules.iter().all(|(code, level)| code.starts_with("unused-") && *level == Level::Off)); } + #[test] + fn file_status_turns_checks_on_and_off() { + let settings = parse_luals( + r#"{ "diagnostics.neededFileStatus": { "missing-global-doc": "Any", "incomplete-signature-doc": "Opened!", + "missing-local-export-doc": "Any", "unused-local": "Opened", "need-check-nil": "None", "no-unknown": "Any" }, + "diagnostics.severity": { "missing-local-export-doc": "Hint", "need-check-nil": "Error" }, + "diagnostics.disable": ["no-unknown"] }"#, + ); + let level = |code: &str| settings.rules.iter().rev().find(|(c, _)| c == code).map(|(_, level)| *level); + // Checks that are off here by default turn on as warnings, unless a severity is given. + assert_eq!(level("missing-global-doc"), Some(Level::Warning)); + assert_eq!(level("incomplete-signature-doc"), Some(Level::Warning)); + assert_eq!(level("missing-local-export-doc"), Some(Level::Hint)); + // Rules on by default keep their level. + assert_eq!(level("unused-local"), None); + // `None` wins over a severity, and `diagnostics.disable` over a status. + assert_eq!(level("need-check-nil"), Some(Level::Off)); + assert_eq!(level("no-unknown"), Some(Level::Off)); + } + #[test] fn accepts_comments_and_trailing_commas() { let settings = parse_luals( diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index d542218..308cfe3 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -75,6 +75,9 @@ pub const UNDEFINED_DOC_NAME: &str = "undefined-doc-name"; pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; +pub const MISSING_GLOBAL_DOC: &str = "missing-global-doc"; +pub const MISSING_LOCAL_EXPORT_DOC: &str = "missing-local-export-doc"; +pub const INCOMPLETE_SIGNATURE_DOC: &str = "incomplete-signature-doc"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. pub const MISSING_FIELDS: &str = "missing-fields"; /// Reported by qbx-lua-ls, which reads the LuaCATS classes; the linter itself has no type information. @@ -163,6 +166,9 @@ pub static RULES: &[Rule] = &[ rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), + rule(MISSING_GLOBAL_DOC, Style, OFF, false, "A global function has a parameter without @param or returns a value without @return; one that has neither needs a comment."), + rule(MISSING_LOCAL_EXPORT_DOC, Style, OFF, false, "A local function that a module's returned table or exports() exports, or a function passed to exports(), has a parameter without @param or returns a value without @return; one that has neither needs a comment."), + rule(INCOMPLETE_SIGNATURE_DOC, Style, OFF, false, "A function whose doc comment has @param or @return annotations leaves out a parameter or a returned value."), rule(MISSING_FIELDS, Correctness, WARN, false, "A table constructor typed as a LuaCATS class or shape, or a union of them, leaves out required fields (language server only)."), rule(ASSIGN_TYPE_MISMATCH, Correctness, WARN, false, "A table constructor or assignment stores a value of the wrong type in a field of a LuaCATS class, or in a variable typed with @type or @param (language server only)."), rule(PARAM_TYPE_MISMATCH, Correctness, WARN, false, "A call passes an argument of a different type than its parameter's LuaCATS annotation declares (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/good_resource/client/main.lua b/crates/qbx_lua_analysis/tests/fixtures/good_resource/client/main.lua index c9991f0..842f256 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/good_resource/client/main.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/good_resource/client/main.lua @@ -24,4 +24,5 @@ RegisterNUICallback('close', function(_, cb) cb(1) end) +---@return integer exports('getCount', function() return count end) diff --git a/crates/qbx_lua_analysis/tests/fixtures/good_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/good_resource/shared/config.lua index 30152a4..70e65e8 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/good_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/good_resource/shared/config.lua @@ -5,6 +5,7 @@ SharedConfig = { ---@param value table ---@param fallback? string +---@return string? function SharedHelper(value, fallback) return value?.nested?.field or fallback end diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 8ae5a50..72142dc 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -808,16 +808,26 @@ fn values_the_linter_cannot_follow_are_not_checked() { /// The findings of `code` in `source`, linted on its own, as `(line, message)`. fn findings(source: &str, code: &str) -> Vec<(u32, String)> { + findings_with(source, code, &FileConfig::default()) +} + +/// The findings of `code`, a rule that is off by default, with it turned on. +fn opt_in_findings(source: &str, code: &str) -> Vec<(u32, String)> { + let mut config = FileConfig::default(); + config.set(code, Level::Warning); + findings_with(source, code, &config) +} + +fn findings_with(source: &str, code: &str, config: &FileConfig) -> Vec<(u32, String)> { let chunk = parse(source); let resolution = resolve(&chunk); let summary = summarize(source, &chunk, &resolution); - let config = FileConfig::default(); let input = FileInput { source, chunk: &chunk, resolution: &resolution, summary: &summary, - config: &config, + config, side: None, resource: None, crossrefs: None, @@ -1043,6 +1053,178 @@ return handler"; ); } +fn owned(findings: &[(u32, &str)]) -> Vec<(u32, String)> { + findings.iter().map(|&(line, message)| (line, message.to_string())).collect() +} + +#[test] +fn global_functions_without_annotations() { + let source = "function Add(a, b) return a + b end +function Nothing() end +-- Opens the menu. +function Commented() end +---@diagnostic disable-next-line: lowercase-global +function diagnosticOnly() end +---@param id integer +function Partial(id) return tostring(id) end +function Branches(flag) + if flag then return 1, 2 end + return 3 +end +function Ignored(self, _, _unused, ...) end +---@type fun(a: integer): integer +Typed = function(a) return a end +---@param ... any +---@return integer ... +function Variadic(...) return 1, 2, 3 end +function Outer() + function Inner() end +end +local function notGlobal(x) return x end +Table = {} +function Table.field(y) return y end +function Table:method(z) return z end +function BareReturn() if Table then return end end +return notGlobal"; + assert_eq!( + opt_in_findings(source, "missing-global-doc"), + owned(&[ + (1, "parameter 'a' of global function 'Add' has no @param annotation"), + (1, "parameter 'b' of global function 'Add' has no @param annotation"), + (1, "return value #1 of global function 'Add' has no @return annotation"), + (2, "global function 'Nothing' has no comment"), + (6, "global function 'diagnosticOnly' has no comment"), + (8, "return value #1 of global function 'Partial' has no @return annotation"), + (9, "parameter 'flag' of global function 'Branches' has no @param annotation"), + (10, "return value #1 of global function 'Branches' has no @return annotation"), + (10, "return value #2 of global function 'Branches' has no @return annotation"), + (11, "return value #1 of global function 'Branches' has no @return annotation"), + (13, "parameter '...' of global function 'Ignored' has no @param annotation"), + (19, "global function 'Outer' has no comment"), + (20, "global function 'Inner' has no comment"), + (26, "global function 'BareReturn' has no comment"), + ]) + ); + // Off unless turned on, like the other two. + for code in ["missing-global-doc", "missing-local-export-doc", "incomplete-signature-doc"] { + assert_eq!(findings(source, code), [], "{code}"); + } +} + +#[test] +fn exported_local_functions_without_annotations() { + let source = "local M = {} +local function helper(a) return a end +M.helper = helper +M.again = helper +function M.direct(b) return b end +M.assigned = function(c) return c end +---@param d integer +local function documented(d) return d end +M.documented = documented +local function nothing() end +M.nothing = nothing +local inline = function(e) end +exports('Inline', function(f) return f end) +exports('Local', inline) +function Global(g) return g end +exports('Global', Global) +-- Shows the menu. +exports('Commented', function() end) +exports('Bare', function() end) +local Other = {} +local function hidden(h) return h end +Other.hidden = hidden +return M"; + assert_eq!( + opt_in_findings(source, "missing-local-export-doc"), + owned(&[ + (2, "parameter 'a' of exported local function 'helper' has no @param annotation"), + (2, "return value #1 of exported local function 'helper' has no @return annotation"), + (8, "return value #1 of exported local function 'documented' has no @return annotation"), + (10, "exported local function 'nothing' has no comment"), + (12, "parameter 'e' of exported local function 'inline' has no @param annotation"), + (13, "parameter 'f' of exported function 'Inline' has no @param annotation"), + (13, "return value #1 of exported function 'Inline' has no @return annotation"), + (19, "exported function 'Bare' has no comment"), + ]) + ); +} + +#[test] +fn partly_annotated_functions() { + let source = "---@param a integer +local function partial(a, b) return a end +local function undocumented(c) return c end +--- Only a description. +local function described(d) return d end +---@param src number +RegisterNetEvent('event', function(src, data) end) +local handlers = { + ---@param e integer + one = function(e, f) end, two = function(g) end, +} +---@param x integer +local nested = function(x) return function(y) return y end end +---@param h integer +---@return integer +---@return string +local function extra(h) return h, 'x', nil end +local function byName(i, j) return i, j end +---@param i integer +RegisterNetEvent('byName', byName) +---@param k integer +RegisterNetEvent('lines', + function(k, l) end) +---@param self table +---@param m integer +function handlers:method(m, _n) end +---@type fun(o: integer) +local typed = function(o, p) end +return partial, undocumented, described, handlers, nested, extra, byName, typed"; + assert_eq!( + opt_in_findings(source, "incomplete-signature-doc"), + owned(&[ + (2, "incomplete signature: parameter 'b' has no @param annotation"), + (2, "incomplete signature: return value #1 has no @return annotation"), + (7, "incomplete signature: parameter 'data' has no @param annotation"), + (10, "incomplete signature: parameter 'f' has no @param annotation"), + (13, "incomplete signature: return value #1 has no @return annotation"), + (17, "incomplete signature: return value #3 has no @return annotation"), + (23, "incomplete signature: parameter 'l' has no @param annotation"), + ]) + ); + + // The parameters that the function type of a documented callback wrapper names. + let source = "---@param event string +---@param callback fun(source: number, phoneNumber: string, ...): any +function BaseCallback(event, callback) end +---@param contact table +BaseCallback('saveContact', function(source, phoneNumber, contact, extra) return contact, extra end) +---@param name string +---@param handler fun(src: number, ...) +local function register(name, handler) end +---@param data table +register('local', function(src, data) end) +local Wrapper = {} +---@param cb fun(player: table) +function Wrapper:on(cb) end +---@param other string +Wrapper:on(function(player, other) end) +local function undocumented(name, cb) end +---@param data table +undocumented('x', function(src, data) end)"; + assert_eq!( + opt_in_findings(source, "incomplete-signature-doc"), + owned(&[ + (5, "incomplete signature: parameter 'extra' has no @param annotation"), + (5, "incomplete signature: return value #1 has no @return annotation"), + (5, "incomplete signature: return value #2 has no @return annotation"), + (18, "incomplete signature: parameter 'src' has no @param annotation"), + ]) + ); +} + #[test] fn aliases_declared_twice_in_one_file() { let source = "---@alias Dup string diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 2bb8d5b..b492df8 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,9 +5,10 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INJECT_FIELD, INVISIBLE, - MISSING_FIELDS, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, - REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, + INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, + MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, + RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -28,7 +29,7 @@ use super::injected_fields::injected_fields; use super::nil_checks::{unchecked_nils, UncheckedNils}; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; -use super::unknown_types::{unknown_types, unknown_values}; +use super::unknown_types::{typed_by_declaration, unknown_types, unknown_values}; use super::visibility::invisible_members; use crate::document::Document; use crate::index::{FileId, FileOrigin}; @@ -78,11 +79,17 @@ impl<'a> CheckInput<'a> { } } -/// Findings for rules qbx-lint registers but cannot check, because they need the LuaCATS types only -/// the server indexes, and the parts of `missing-parameter` and `redundant-parameter` that depend on -/// the handler a `@callback` wrapper call reaches. Inline suppression comments apply to them as they -/// do to the linter's own. -fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec { +/// Adds to `found`, the linter's findings, those of rules qbx-lint registers but cannot check, +/// because they need the LuaCATS types only the server indexes, and the parts of `missing-parameter` +/// and `redundant-parameter` that depend on the handler a `@callback` wrapper call reaches. Inline +/// suppression comments apply to them as they do to the linter's own. Removes the linter's findings +/// of parameters without `@param` that those types document. +fn type_diagnostics( + ws: &Workspace, + doc: &Document, + config: &FileConfig, + found: &mut Vec, +) { type Check = fn(&CheckInput) -> Vec<(Span, String)>; let checks: [(&'static str, Check); 17] = [ (UNDEFINED_DOC_NAME, |input| { @@ -129,14 +136,19 @@ fn type_diagnostics(ws: &Workspace, doc: &Document, config: &FileConfig) -> Vec< let infer = Infer::new(&ctx, &ws.index); let suppressions = Suppressions::parse(&doc.text, &doc.chunk.comments, &doc.lines); let input = CheckInput { ws, doc, infer: &infer, payloads: OnceCell::new(), suppressions }; - let mut out = Vec::new(); + // A parameter typed by what its function is written as, such as the `fun(source: number, ...)` + // of a callback wrapper, needs no `@param` of its own. The linter only knows the function types + // that the functions of its resource document. + let missing_docs = [MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, INCOMPLETE_SIGNATURE_DOC]; + if missing_docs.iter().any(|code| config.severity(code).is_some()) { + found.retain(|d| !missing_docs.contains(&d.code) || !typed_by_declaration(&infer, d.span)); + } for (code, check) in checks { let Some(severity) = config.severity(code) else { continue }; - out.extend(check(&input).into_iter().filter(|(span, _)| !input.is_suppressed(code, *span)).map( + found.extend(check(&input).into_iter().filter(|(span, _)| !input.is_suppressed(code, *span)).map( |(span, message)| qbx_lua_analysis::Diagnostic { code, severity, span, message, tag: None, fix: None }, )); } - out } pub fn diagnostics( @@ -242,7 +254,7 @@ pub(crate) fn diagnostics_with_support( crossrefs: Some(crossrefs), locale: support.and_then(|support| support.locale).or(owned_locale.as_ref()), }); - found.extend(type_diagnostics(ws, doc, &config)); + type_diagnostics(ws, doc, &config, &mut found); found }; diff --git a/crates/qbx_lua_ls/src/features/unknown_types.rs b/crates/qbx_lua_ls/src/features/unknown_types.rs index a650b51..b06bfdf 100644 --- a/crates/qbx_lua_ls/src/features/unknown_types.rs +++ b/crates/qbx_lua_ls/src/features/unknown_types.rs @@ -117,6 +117,22 @@ pub(super) fn has_param_line(infer: &Infer, param: &Local) -> bool { doc_anchor.is_some_and(|anchor| infer.ctx.doc_at(anchor).params.iter().any(|doc| doc.name == param.name)) } +/// Whether `span` declares a parameter that takes a type from what its function is written as: the +/// function type of the parameter it is passed for or of the field it is written for, or a `@type`. +/// Taking `any` from the `...` of that function type says nothing about it. +pub(super) fn typed_by_declaration(infer: &Infer, span: Span) -> bool { + let Some(Resolved::Local(id)) = infer.ctx.resolution.resolve_at(span.start) else { return false }; + let local = infer.ctx.resolution.local(id); + if local.kind != LocalKind::Param || local.decl != span { + return false; + } + match infer.local_type(id) { + Type::Unknown => false, + Type::Any => !from_vararg(infer, local), + _ => true, + } +} + /// Whether the parameter takes its value from the `...` of the function type its function has to be. fn from_vararg(infer: &Infer, param: &Local) -> bool { let Some(Decl::Param { index, expected: Some(expected), .. }) = infer.ctx.decl(param.decl.start) else { diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 6ee5b01..7471536 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3241,6 +3241,57 @@ print(target, typed, name, box, call) ); } +#[test] +fn missing_doc_rules_report_when_turned_on_in_the_editor() { + let codes = ["missing-global-doc", "missing-local-export-doc", "incomplete-signature-doc"]; + let rules: serde_json::Map = codes.iter().map(|code| (code.to_string(), json!("warning"))).collect(); + let mut client = + Client::start_with_options(fixture_root(), json!({}), json!({ "diagnostics": { "rules": rules } })); + let text = "\ +---@param id integer +function GetName(id) return tostring(id) end + +local function format(text) return text end +exports('Format', format) +"; + client.open_with(CLIENT, text); + let finding = |code: &str, line: u64, message: &str| (code.to_string(), line, message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &codes), + [ + finding("incomplete-signature-doc", 1, "incomplete signature: return value #1 has no @return annotation"), + finding("missing-global-doc", 1, "return value #1 of global function 'GetName' has no @return annotation"), + finding( + "missing-local-export-doc", + 3, + "parameter 'text' of exported local function 'format' has no @param annotation" + ), + finding( + "missing-local-export-doc", + 3, + "return value #1 of exported local function 'format' has no @return annotation" + ), + ] + ); + + // A parameter that the function type a function is passed as names needs no `@param`, also + // where only the types the server reads say what that function type is. + let text = "\ +---@class Probe.Bus +---@field on fun(self: Probe.Bus, name: string, callback: fun(player: table, ...)) + +---@type Probe.Bus +local bus = GetBus() +---@param reason string +bus:on('dropped', function(player, reason, extra) end) +"; + client.change(CLIENT, 2, text); + assert_eq!( + findings(&mut client, CLIENT, &codes), + [finding("incomplete-signature-doc", 6, "incomplete signature: parameter 'extra' has no @param annotation")] + ); +} + #[test] fn casts_change_the_type_of_a_local_from_their_line_on() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 01f4e5f..9e58508 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,8 +102,9 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown`, `need-check-nil` and `inject-field`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown`, `need-check-nil` and `inject-field`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | +| `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | @@ -936,6 +937,60 @@ the function may run before the guard does. lua-language-server reports it as we Unlike lua-language-server, qbx-lua-ls also checks arithmetic, concatenation, `#`, `<`, `<=`, `>`, `>=` and `for` bounds. +## Missing documentation + +`missing-global-doc`, `missing-local-export-doc` and `incomplete-signature-doc` are off by default +and keep lua-language-server's names, so a `.luarc.json` that turns them on with +`diagnostics.neededFileStatus` turns them on here too. Turned on, each reports the parameters of a +function that no `@param` names, and each value of each `return` at a position that no `@return` +covers: + +```toml +[rules] +"missing-global-doc" = "warning" +"incomplete-signature-doc" = "warning" +``` + +```lua +function GetName(id) return tostring(id) end -- parameter 'id' of global function 'GetName' has no @param annotation + +---@param id integer +local function getName(id) return tostring(id) end -- incomplete signature: return value #1 has no @return annotation +``` + +- `missing-global-doc` checks the global functions defined with `function Name()` or + `Name = function()`, also inside other functions, but not fields of global tables or methods. One + without parameters or returned values needs a comment instead: any comment directly above it, or + after code on the line its parameters start on, other than `---@diagnostic` lines. +- `missing-local-export-doc` checks the local functions a file exports, and asks for a comment the + same way. A module exports those it assigns by name to a field of a local table that a `return` + gives, as `M.name = name` before `return M`; functions defined with `function M.name()` are not + checked, as in lua-language-server. `exports('Name', fn)` exports `fn`, and a function written in + the call. A global function passed to `exports` is left to `missing-global-doc`. +- `incomplete-signature-doc` checks every function whose doc comment has `@param` or `@return` + lines, handlers passed to calls and functions in table fields included. A global function can be + reported by it and `missing-global-doc` alike. + +A function's doc comment is the one above the line it starts on, or above a call that spans lines +and passes it; comment lines in between do not separate them, a blank line does. Only the first +function that starts on the line takes it. `@param ...` or `@vararg` documents `...`, and `self` +and parameters that start with `ignore_unused_prefix` need no `@param`. A call or `...` at the end +of a `return` counts as one value, and a last `@return` of `...T`, or named `...`, covers every +later value. A `---@type fun(...)` in the doc comment documents the parameters and values it lists. + +A parameter that the function type its function is passed as names needs no `@param` either, such +as `source` and `phoneNumber` of a handler passed to a wrapper documented with +`---@param callback fun(source: number, phoneNumber: string, ...)`; the parameters that fall into +its `...` still do. qbx-lint knows the function types of the global functions its resource +documents and of the local functions and local tables of the file. qbx-lua-ls knows every declared +type: those of stubs and other resources, class fields, and the `---@type` of a variable or field. + +Unlike lua-language-server, these rules count `---@type fun(...)`, the function type a function is +passed as, `@vararg` and a `...T` `@return` as documentation, takes a comment above +`Name = function()` as that function's comment, asks for a comment on a function whose only +`return` gives no value, and marks the name of a function that needs a comment rather than the +whole function. + ## Events, exports, and locales The CLI first collects event registrations and exports from the resources being analyzed, then diff --git a/docs/rules.md b/docs/rules.md index 97ae8d8..2617e95 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -35,6 +35,9 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | +| `missing-global-doc` | off | A global function, defined with `function Name()` or `Name = function()`, has a parameter that no `@param` names, or a `return` whose values no `@return` covers. One without parameters or returned values has no comment. See [Missing documentation](reference.md#missing-documentation). | +| `missing-local-export-doc` | off | The same for a local function that a file exports: assigned by name to a field of a local table that a `return` gives, or passed to `exports('Name', fn)`, and for a function written in an `exports` call. | +| `incomplete-signature-doc` | off | A function whose doc comment has `@param` or `@return` lines leaves out a parameter or a returned value. A parameter that the function type its function is passed as names, such as `source` of a handler passed to a documented callback wrapper, needs none. | | `missing-fields` | warning | A table constructor typed as a `---@class` or a shape such as `{ name: string }`, including one returned for an `@return` type, leaves out required fields. A table typed as a union of them needs the required fields of one. The tables that an array, a `table` or a tuple holds are checked by the type of their entry, like the `{}` of `{ {} }` for `Dog[]`. Reported by qbx-lua-ls only. | | `assign-type-mismatch` | warning | A table constructor or an assignment stores a value that the `@field` type, or the value type of an index such as `[string] number`, does not take, such as a number for a `string` field. Tables typed as a shape, such as `{ value: string }`, an array or a `table` are checked by the types of their entries. Clearing a field with `nil` needs a type that allows it, such as `string?` or `string|nil`. The same goes for a value stored in a variable typed with `---@type` or `@param`, such as `"test"` for `---@type number`, except for the `nil` that the statement under a `---@type` gives the names it declares. Reported by qbx-lua-ls only. | | `param-type-mismatch` | warning | A call passes an argument that the type of its parameter does not take, such as a string for `number`, a `string\|number` for `string`, a literal the type does not list, or `nil` or a `string?` for a parameter that does not take `nil`. Natives are checked as the runtime converts their arguments. Reported by qbx-lua-ls only. | From 0afe22d5c8ca3fddaf8cc140bb888f74ff222c2a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:41 +0200 Subject: [PATCH 078/193] feat(rules): report fields read from values whose type lacks them in undefined-field --- crates/qbx_fivem_data/stubs/cfx.lua | 227 ++++++++++++++++++ crates/qbx_lua_analysis/src/lua_ls_config.rs | 6 +- crates/qbx_lua_analysis/src/rules.rs | 4 +- crates/qbx_lua_ls/README.md | 3 +- .../qbx_lua_ls/src/features/class_tables.rs | 28 ++- crates/qbx_lua_ls/src/features/diagnostics.rs | 8 +- .../src/features/injected_fields.rs | 4 +- crates/qbx_lua_ls/src/features/mod.rs | 1 + .../src/features/undefined_fields.rs | 162 +++++++++++++ crates/qbx_lua_ls/src/index.rs | 6 + crates/qbx_lua_ls/src/infer.rs | 29 +++ crates/qbx_lua_ls/tests/lsp.rs | 141 +++++++++++ docs/reference.md | 60 ++++- docs/rules.md | 2 +- 14 files changed, 657 insertions(+), 24 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/undefined_fields.rs diff --git a/crates/qbx_fivem_data/stubs/cfx.lua b/crates/qbx_fivem_data/stubs/cfx.lua index 6122696..dc15228 100644 --- a/crates/qbx_fivem_data/stubs/cfx.lua +++ b/crates/qbx_fivem_data/stubs/cfx.lua @@ -724,6 +724,233 @@ function string.tostringall(...) end ---@return string function string.blob(length) end +---The difference between 1 and the next larger number. CfxLua extension. +---@type number +math.eps = 2.220446049250313e-16 + +---The difference between 1 and the next larger single-precision float. CfxLua extension. +---@type number +math.feps = 1.1920928955078125e-07 + +---Returns the inverse hyperbolic cosine of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.acosh(x) end + +---Returns the inverse hyperbolic sine of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.asinh(x) end + +---Returns the arc tangent of `y / x` in radians, using both signs to pick the quadrant. `x` defaults to 1. CfxLua extension. +---@param y number +---@param x? number +---@return number result +---@nodiscard +function math.atan2(y, x) end + +---Returns the inverse hyperbolic tangent of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.atanh(x) end + +---Returns the cube root of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.cbrt(x) end + +---Returns `x` limited to the range from `min` to `max`, per component for vectors. CfxLua extension. +---@param x number +---@param min number +---@param max number +---@return number result +---@overload fun(x: vector2, min: vector2, max: vector2): vector2 +---@overload fun(x: vector3, min: vector3, max: vector3): vector3 +---@overload fun(x: vector4, min: vector4, max: vector4): vector4 +---@nodiscard +function math.clamp(x, min, max) end + +---Returns `x` with the sign of `y`. CfxLua extension. +---@param x number +---@param y number +---@return number result +---@nodiscard +function math.copysign(x, y) end + +---Returns the hyperbolic cosine of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.cosh(x) end + +---Returns the error function of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.erf(x) end + +---Returns the complementary error function of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.erfc(x) end + +---Returns 2 raised to the power `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.exp2(x) end + +---Returns e raised to the power `x`, minus 1. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.expm1(x) end + +---Returns `x - y` when it is positive, otherwise 0. CfxLua extension. +---@param x number +---@param y number +---@return number result +---@nodiscard +function math.fdim(x, y) end + +---Splits `x` into a mantissa in [0.5, 1) and an exponent of 2. CfxLua extension. +---@param x number +---@return number mantissa +---@return integer exponent +---@nodiscard +function math.frexp(x) end + +---Returns the gamma function of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.gamma(x) end + +---Returns the length of the hypotenuse of a right triangle with sides `x` and `y`. CfxLua extension. +---@param x number +---@param y number +---@return number result +---@nodiscard +function math.hypot(x, y) end + +---Returns whether `x` is neither infinite nor NaN. CfxLua extension. +---@param x number +---@return boolean result +---@nodiscard +function math.isfinite(x) end + +---Returns whether `x` is infinite. CfxLua extension. +---@param x number +---@return boolean result +---@nodiscard +function math.isinf(x) end + +---Returns whether `x` is NaN. CfxLua extension. +---@param x number +---@return boolean result +---@nodiscard +function math.isnan(x) end + +---Returns whether `x` is a normal number: not zero, subnormal, infinite or NaN. CfxLua extension. +---@param x number +---@return boolean result +---@nodiscard +function math.isnormal(x) end + +---Returns `m` multiplied by 2 raised to the power `e`. CfxLua extension. +---@param m number +---@param e integer +---@return number result +---@nodiscard +function math.ldexp(m, e) end + +---Returns the natural logarithm of the absolute value of the gamma function of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.lgamma(x) end + +---Returns the base-10 logarithm of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.log10(x) end + +---Returns the natural logarithm of 1 plus `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.log1p(x) end + +---Returns the exponent of `x` in base 2, as a float. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.logb(x) end + +---Returns `x` rounded to an integral value, halfway cases to even, as a float. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.nearbyint(x) end + +---Returns the next representable number after `x` in the direction of `y`. CfxLua extension. +---@param x number +---@param y number +---@return number result +---@nodiscard +function math.nextafter(x, y) end + +---Returns `x` raised to the power `y`. CfxLua extension. +---@param x number +---@param y number +---@return number result +---@nodiscard +function math.pow(x, y) end + +---Returns the remainder of `x / y` with the quotient rounded to the nearest integer. CfxLua extension. +---@param x number +---@param y number +---@return number result +---@nodiscard +function math.remainder(x, y) end + +---Returns `x` rounded to an integral value, halfway cases away from zero, as a float. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.round(x) end + +---Returns `x` multiplied by 2 raised to the power `n`. CfxLua extension. +---@param x number +---@param n integer +---@return number result +---@nodiscard +function math.scalbn(x, n) end + +---Returns the hyperbolic sine of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.sinh(x) end + +---Returns the hyperbolic tangent of `x`. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.tanh(x) end + +---Returns `x` with its fractional part removed, as a float. CfxLua extension. +---@param x number +---@return number result +---@nodiscard +function math.trunc(x) end + ---Lists the entries of a directory. CfxLua extension, server only in practice. ---@param path string ---@return string[] diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index f756554..89f2a3c 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -15,8 +15,8 @@ pub const FILE_NAMES: &[&str] = &[".luarc.json", ".luarc.jsonc", ".emmyrc.json"] const ALIASES: &[(&str, &[&str])] = &[("unused", &["unused-local", "unused-function", "unused-argument", "unused-loop-variable"])]; -/// Codes that check the same thing in both tools. `undefined-field` and `deprecated` only share the -/// name: projects often turn off the broad LuaLS checks, which must not turn off the narrow ones here. +/// Codes that check the same thing in both tools. `deprecated` only shares the name: projects often +/// turn off the broad LuaLS check, which must not turn off the narrow one here. const EQUIVALENT_CODES: &[&str] = &[ "undefined-global", "lowercase-global", @@ -51,6 +51,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "no-unknown", "need-check-nil", "inject-field", + "undefined-field", ]; #[derive(Debug, Default)] @@ -297,6 +298,7 @@ mod tests { assert_eq!( settings.rules, [ + ("undefined-field".to_string(), Level::Off), ("undefined-global".to_string(), Level::Off), ("missing-parameter".to_string(), Level::Off), ("duplicate-set-field".to_string(), Level::Off) diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 308cfe3..eb69b6e 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -53,6 +53,8 @@ const OFF: Option = None; pub const SYNTAX_ERROR: &str = "syntax-error"; pub const UNDEFINED_GLOBAL: &str = "undefined-global"; +/// The linter reports it for the standard library tables, and qbx-lua-ls, which reads the LuaCATS +/// types, also for other values. pub const UNDEFINED_FIELD: &str = "undefined-field"; pub const UNUSED_LOCAL: &str = "unused-local"; pub const UNUSED_FUNCTION: &str = "unused-function"; @@ -145,7 +147,7 @@ pub const UNUSED_LOCALE_KEY: &str = "qbox/unused-locale-key"; pub static RULES: &[Rule] = &[ rule(SYNTAX_ERROR, Correctness, ERROR, false, "The file cannot be parsed by the CfxLua 5.4 runtime."), rule(UNDEFINED_GLOBAL, Correctness, WARN, false, "A global is read that no script in the resource, its imports, the runtime or the natives define."), - rule(UNDEFINED_FIELD, Correctness, WARN, false, "A field that does not exist is read from a standard library table."), + rule(UNDEFINED_FIELD, Correctness, WARN, false, "A field that does not exist is read from a standard library table or, in the language server, from a value whose LuaCATS class or table type does not have it."), rule(UNUSED_LOCAL, Suspicious, WARN, false, "A local variable is never read."), rule(UNUSED_FUNCTION, Suspicious, WARN, false, "A local function is never used."), rule(UNUSED_ARGUMENT, Style, HINT, false, "A function parameter is never read."), diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 454f510..e4eb97b 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -78,7 +78,8 @@ Available features depend on the editor's LSP client. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. `inject-field` - reports fields set through a value whose class or table type does not have them, and + reports fields set through a value whose class or table type does not have them, + `undefined-field` fields read from one, and `need-check-nil` a value that may be `nil` used where a missing value raises an error. The opt-in `no-unknown` reports parameters, locals and loop variables that have no type. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. diff --git a/crates/qbx_lua_ls/src/features/class_tables.rs b/crates/qbx_lua_ls/src/features/class_tables.rs index bf08837..2e15668 100644 --- a/crates/qbx_lua_ls/src/features/class_tables.rs +++ b/crates/qbx_lua_ls/src/features/class_tables.rs @@ -2,11 +2,11 @@ //! and assignments, arguments to class-typed parameters, `return` in a function documented with //! `@return`, and the tables such fields hold. With the fields that code sets on and reads from //! values of a class, they drive `missing-fields`, `assign-type-mismatch`, `undeclared-field`, -//! `inject-field` and the completion of field names. Table constructors typed as a table type, like a shape -//! `{ value: string }`, `string[]` or `table`, are found in the same places for -//! `assign-type-mismatch`, and those typed as a shape or a union of classes and shapes, also as the -//! entries of an array, a map or a tuple, for `missing-fields`. Only the language server knows the -//! classes, so qbx-lint registers the rules and this module reports them. +//! `inject-field`, `undefined-field` and the completion of field names. Table constructors typed as a +//! table type, like a shape `{ value: string }`, `string[]` or `table`, are found in +//! the same places for `assign-type-mismatch`, and those typed as a shape or a union of classes and +//! shapes, also as the entries of an array, a map or a tuple, for `missing-fields`. Only the language +//! server knows the classes, so qbx-lint registers the rules and this module reports them. //! //! Type names are global, and resources may declare the same one differently: ox_fuel's //! `@class State` is not qbx_vehicles' `@enum State`. A name is looked up the way the file that @@ -891,6 +891,9 @@ pub struct Access<'c> { pub value: Option<&'c Expr>, /// Set on the table a `---@class` annotation declares, which may take new fields. pub on_class_table: bool, + /// For a read, the expression whose field it reads, like `a.b` in `a.b.c`; `None` for a field + /// that is set. + pub read: Option<&'c Expr>, } /// The fields that assignments and `function value:name()` statements set and, with `reads`, the @@ -937,8 +940,8 @@ impl<'c> Visitor<'c> for Accesses<'_, '_, 'c> { Some((base, key, span)) => { let on_class_table = self.is_class_table(base); let owner = self.infer.expr(base); - let value = exprs.get(i); - self.out.push(Access { owner, holder: base.span, key, span, value, on_class_table }); + let (holder, value) = (base.span, exprs.get(i)); + self.out.push(Access { owner, holder, key, span, value, on_class_table, read: None }); // The target's value and key are read. visit::walk_expr(self, target); } @@ -962,7 +965,8 @@ impl<'c> Visitor<'c> for Accesses<'_, '_, 'c> { let holder = Span::new(name.base.span.start, path.last().unwrap_or(&name.base).span.end); let owner = self.infer.func_name_owner_type(&owner); let key = Key::Name(&field.text); - self.out.push(Access { owner, holder, key, span: field.span, value: None, on_class_table }); + let span = field.span; + self.out.push(Access { owner, holder, key, span, value: None, on_class_table, read: None }); } } _ => {} @@ -973,12 +977,16 @@ impl<'c> Visitor<'c> for Accesses<'_, '_, 'c> { fn visit_expr(&mut self, expr: &'c Expr) { if self.reads { let read = match &expr.kind { - ExprKind::MethodCall { base, method, .. } => Some((&**base, Key::Name(&method.text), method.span)), + ExprKind::MethodCall { base, method, .. } if !method.is_missing() => { + Some((&**base, Key::Name(&method.text), method.span)) + } + ExprKind::MethodCall { .. } => None, _ => self.target(expr), }; if let Some((base, key, span)) = read { let owner = self.infer.expr(base); - self.out.push(Access { owner, holder: base.span, key, span, value: None, on_class_table: false }); + let (holder, read) = (base.span, Some(base)); + self.out.push(Access { owner, holder, key, span, value: None, on_class_table: false, read }); } } visit::walk_expr(self, expr); diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index b492df8..5ce4120 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -8,7 +8,7 @@ use qbx_lua_analysis::rules::{ ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, - RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, UNDEFINED_FIELD, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -29,6 +29,7 @@ use super::injected_fields::injected_fields; use super::nil_checks::{unchecked_nils, UncheckedNils}; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; use super::strict_classes::undeclared_fields; +use super::undefined_fields::undefined_fields; use super::unknown_types::{typed_by_declaration, unknown_types, unknown_values}; use super::visibility::invisible_members; use crate::document::Document; @@ -91,7 +92,7 @@ fn type_diagnostics( found: &mut Vec, ) { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 17] = [ + let checks: [(&'static str, Check); 18] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -109,6 +110,9 @@ fn type_diagnostics( (INJECT_FIELD, |input| { injected_fields(input.infer, &input.doc.chunk, |file| strict_by_default(input.ws, file)) }), + (UNDEFINED_FIELD, |input| { + undefined_fields(input.infer, &input.doc.chunk, |file| strict_by_default(input.ws, file)) + }), (INVISIBLE, |input| invisible_members(input.infer, &input.doc.chunk)), (RETURN_TYPE_MISMATCH, |input| mismatched_returns(input.infer, &input.doc.chunk)), (MISSING_RETURN, |input| missing_returns(input.infer, &input.doc.chunk)), diff --git a/crates/qbx_lua_ls/src/features/injected_fields.rs b/crates/qbx_lua_ls/src/features/injected_fields.rs index 544f912..515e471 100644 --- a/crates/qbx_lua_ls/src/features/injected_fields.rs +++ b/crates/qbx_lua_ls/src/features/injected_fields.rs @@ -46,7 +46,7 @@ pub fn injected_fields(infer: &Infer, chunk: &Chunk, by_default: impl Fn(FileId) /// Whether the value written at `holder` is `self` in a method, or a path from it, whose fields the /// method declares. -fn through_self(infer: &Infer, holder: Span) -> bool { +pub(super) fn through_self(infer: &Infer, holder: Span) -> bool { let Some(Resolved::Local(id)) = infer.ctx.resolution.resolve_at(holder.start) else { return false }; matches!(infer.ctx.decl(infer.ctx.resolution.local(id).decl.start), Some(Decl::SelfParam { .. })) } @@ -81,7 +81,7 @@ fn has( /// The message for the field `name` set through the value of type `owner` written at `holder`. /// A table that a local declares has no name of its own, so the expression that holds it names it. -fn message(infer: &Infer, name: &str, owner: &Type, holder: Span) -> String { +pub(super) fn message(infer: &Infer, name: &str, owner: &Type, holder: Span) -> String { let shown = owner.without_nil().to_string(); if !shown.is_empty() && shown != "table" { return format!("Field `{name}` is not declared in `{shown}`"); diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index 077043c..6daa6d7 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -31,6 +31,7 @@ pub mod semantic_tokens; pub mod signature; pub mod strict_classes; pub mod symbols; +pub mod undefined_fields; pub mod unknown_types; pub mod visibility; pub mod workspace_health; diff --git a/crates/qbx_lua_ls/src/features/undefined_fields.rs b/crates/qbx_lua_ls/src/features/undefined_fields.rs new file mode 100644 index 0000000..06a103d --- /dev/null +++ b/crates/qbx_lua_ls/src/features/undefined_fields.rs @@ -0,0 +1,162 @@ +//! `undefined-field`: fields that code reads from a value whose type does not have them, as +//! TypeScript reports a property its type does not declare. The linter reports the same code for +//! the standard library tables, like `string.nope`; this module checks the values whose type the +//! language server knows. A type has the fields that `inject-field` lets code set: those of a class +//! or table type, also the `{ label: string }` a table constructor gives, and those set through the +//! names that own its table, such as a global path, the table a `---@class` annotation declares, +//! `self` in a method or a local declared with a table at the top of a file. Reads such as +//! `value.name`, `value['name']` and `value:name()` are checked, also in conditions like +//! `if value.name then`, and a union lacks a field when none of its parts has it. A local that is +//! assigned again has the types of all the values that may reach the read. +//! +//! Some values may have any field, so reads from them are left alone: +//! +//! - values of unknown type, `table` and `any`, empty tables such as `local result = {}`, and +//! classes with an index that takes the name or a parent that is no class, like `table`; +//! - global tables and the paths from them, such as `Config.debug` or `ESX.PlayerData`, whose +//! fields other files and resources set, and the instances made from them, unless an annotation +//! types them, and the exports of a resource that no type is declared for; +//! - `self` in a method of a table that is no class, whose instances get their fields elsewhere, +//! and tables whose metatable has an `__index` that is no table, such as a function; +//! - numbers, booleans and functions whose type is not declared, as `point - origin` is inferred to +//! be a `number` for values of unknown type; +//! - strict classes, whose fields `undeclared-field` checks. + +use qbx_lua_analysis::env::builtins; +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::{SmolStr, Span}; + +use super::class_tables::{accesses, Classes, Key}; +use super::comparisons::Declared; +use super::injected_fields::{message, through_self}; +use crate::index::{instance_class, is_local_table, FileId}; +use crate::infer::Infer; +use crate::types::Type; + +/// Each field that code reads from a value whose type does not have it, with the message naming +/// the type. `by_default` tells whether `strict_classes` applies to a class declared in a file. +pub fn undefined_fields(infer: &Infer, chunk: &Chunk, by_default: impl Fn(FileId) -> bool) -> Vec<(Span, String)> { + let classes = Classes::new(infer); + let declared = Declared::new(infer); + let mut out = Vec::new(); + for access in accesses(infer, chunk, true) { + let (Some(base), Key::Name(name)) = (access.read, &access.key) else { continue }; + let owner = match base.unparen().kind { + ExprKind::Name(_) => classes.value_type(base), + _ => access.owner, + }; + let parts: Vec<&Type> = match &owner { + Type::Union(types) => types.iter().filter(|ty| !matches!(ty, Type::Nil)).collect(), + Type::Nil => Vec::new(), + ty => vec![ty], + }; + let is_class = |ty: &&Type| classes.class_of(ty, classes.file()).is_some(); + if through_self(infer, access.holder) && !parts.iter().any(is_class) { + continue; + } + let lacks = |ty: &&Type| lacks(infer, &classes, &declared, base, ty, &access.key, &by_default); + if parts.is_empty() || !parts.iter().all(lacks) { + continue; + } + // The exports of a resource are named by the type declared for them. + let owner = match &owner { + Type::Exports(Some(resource)) => infer.declared_export_type(resource).unwrap_or(owner), + _ => owner, + }; + if let Some(path) = global_path(infer, base) { + // The linter checks the standard library tables, and a global path has the fields set on + // it, like the `function math.clamp()` of a library that extends `math`. + if builtins().closed_table(&path).is_some() + || infer.member(&Type::GlobalTable(SmolStr::new(path)), name).is_some() + { + continue; + } + } + out.push((access.span, message(infer, name, &owner, access.holder))); + } + out +} + +/// Whether a value of type `ty`, read from `base`, lacks the field that `key` names. Values that +/// may have any field lack none. +fn lacks( + infer: &Infer, + classes: &Classes, + declared: &Declared, + base: &Expr, + ty: &Type, + key: &Key, + by_default: impl Fn(FileId) -> bool, +) -> bool { + let Key::Name(name) = key else { return false }; + let file = classes.file(); + if let Some((class, _, view)) = classes.class_of(ty, file) { + return !classes.is_strict(&class, view, by_default) + && !classes.declares(&class, view, key) + && infer.member(ty, name).is_none(); + } + match classes.table_type_of(ty, file) { + Some(Type::Shape(shape)) if shape.fields.is_empty() && shape.array.is_none() && shape.indices.is_empty() => { + return false; + } + Some(table) => return classes.table_field_type(&table, file, key).is_none(), + None => {} + } + match infer.resolve_alias(ty) { + Type::GlobalTable(owner) => { + let instance = instance_class(&owner); + // Other files and resources set the fields of global tables, and so of their instances. + if !is_local_table(instance.unwrap_or(&owner)) || infer.indexes_at_runtime(&owner) { + return false; + } + // A table that nothing gives a field may have any, as `{}` does. + (instance.is_some() || infer.index.has_members(&owner)) && infer.member(ty, name).is_none() + } + Type::String | Type::StringLit(_) => infer.member(ty, name).is_none(), + // A type declared for the exports of a resource tells what they hold, with what the files + // that describe them set and what the resource registers. + Type::Exports(Some(resource)) => match infer.declared_export_type(&resource) { + Some(export_type) => { + lacks(infer, classes, declared, base, &export_type, key, by_default) && infer.member(ty, name).is_none() + } + None => false, + }, + ty if is_scalar(&ty) => { + let declared = declared.of(base); + let mut parts = match &declared { + Type::Union(types) => types.iter().collect(), + ty => vec![ty], + }; + parts.retain(|ty| !matches!(ty, Type::Nil)); + !parts.is_empty() && parts.iter().all(|ty| is_scalar(&infer.resolve_alias(ty))) + } + _ => false, + } +} + +/// Whether a value of type `ty` is a number, a boolean or a function, which has no fields. +fn is_scalar(ty: &Type) -> bool { + matches!( + ty, + Type::Number + | Type::Integer + | Type::IntLit(_) + | Type::Handle(_) + | Type::Boolean + | Type::BooleanLit(_) + | Type::Function + | Type::Fun(_) + ) +} + +/// The dotted path of `expr` when it starts from a global, like `math` or `Config.Sound`. +fn global_path(infer: &Infer, expr: &Expr) -> Option { + let mut root = expr.unparen(); + while let ExprKind::Field { base, .. } | ExprKind::Index { base, .. } = &root.kind { + root = base.unparen(); + } + let ExprKind::Name(name) = &root.kind else { return None }; + let global = matches!(infer.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))); + global.then(|| expr.unparen().dotted_path()).flatten() +} diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs index d0c243f..face6bb 100644 --- a/crates/qbx_lua_ls/src/index.rs +++ b/crates/qbx_lua_ls/src/index.rs @@ -192,6 +192,12 @@ pub fn instance_class(owner: &str) -> Option<&str> { owner.strip_prefix("%inst(")?.strip_suffix(')') } +/// Whether `owner` is the table that a local of one file holds, or a path from it, as +/// `FileContext::local_owner_key` names them, rather than a global table. +pub fn is_local_table(owner: &str) -> bool { + owner.starts_with("%f") +} + /// A metatable that `setmetatable` gives the table `owner`, as `setmetatable(Child, { __index = Base })` /// does: the table falls back on the `__index` of `metatable` for the fields it lacks. #[derive(Clone, Debug)] diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs index d73c843..bfedcc8 100644 --- a/crates/qbx_lua_ls/src/infer.rs +++ b/crates/qbx_lua_ls/src/infer.rs @@ -2246,6 +2246,28 @@ impl<'a> Infer<'a> { index } + /// Whether a metatable that `setmetatable` gives the table `owner` has an `__index` that is no + /// table, such as a function, which decides at runtime which fields the table has. + pub fn indexes_at_runtime(&self, owner: &str) -> bool { + if !self.index.has_metatables(owner) { + return false; + } + self.index.metatables_of(owner, self.ctx.file).into_iter().any(|metatable| { + let mut found = Vec::new(); + self.guarded(|| self.raw_index(metatable, &mut found, 0)); + let parts = found.into_iter().flat_map(|ty| match ty { + Type::Union(types) => types, + other => vec![other], + }); + let mut parts = parts.filter(|ty| !matches!(ty, Type::Nil)); + parts.any(|ty| match ty { + Type::GlobalTable(_) | Type::Shape(_) | Type::Require(_) => false, + Type::Named(name, _) => self.index.class(&name, self.side).is_none(), + _ => true, + }) + }) + } + /// The tables that the `__index` of a metatable of type `metatable` gives, whose fields a table /// with that metatable falls back on. A function there decides them at runtime, so it gives none. fn metatable_index(&self, metatable: &Type) -> Type { @@ -3191,6 +3213,13 @@ impl<'a> Infer<'a> { values } + /// The type declared for the exports of `resource`, as `---@type PhoneExports` above + /// `exports['phone'] = {}` declares it, when one is. + pub fn declared_export_type(&self, resource: &str) -> Option { + let declared = self.index.declared_exports_of(resource, self.ctx.file); + declared.into_iter().map(|(_, symbol)| &symbol.ty).find(|ty| !holds_exports(ty)).cloned() + } + /// Every member of `ty` called `name`, as the definitions a call of it may reach. pub fn members_named(&self, ty: &Type, name: &str) -> Vec { self.guarded(|| self.members_matching(ty, Some(name))) diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 7471536..21ae915 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -4738,6 +4738,128 @@ print(tag, build) assert_eq!(findings(&mut client, CLIENT, &["inject-field"]), [], "the rule can be turned off"); } +#[test] +fn undefined_field_reports_fields_read_from_values_whose_type_lacks_them() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Point +---@field x number +local Point = {} +Point.__index = Point +Point.origin = 0 + +function Point:move() + self.moved = true +end + +---@class Test.Other +---@field y number + +---@class Test.Open : table + +---@class Test.Dict +---@field [string] number + +---@class (strict) Test.Exact +---@field x number + +---@param point Test.Point +---@param either Test.Point|Test.Other +---@param maybe? Test.Point +---@param open Test.Open +---@param dict Test.Dict +---@param exact Test.Exact +---@param count number +---@param name string +local function read(point, either, maybe, open, dict, exact, count, name) + print(point.x, point.moved, point.origin, point:move()) + print(point.y, point['w'], point:jump()) + if point.z then + print(either.y, either.k) + end + print(maybe and maybe.y) + print(open.anything, dict.anything, exact.y) + print(count.x, name:upper(), name:nope()) + ---@diagnostic disable-next-line: undefined-field + print(point.allowed) +end + +local Settings = { debug = false } +Settings.verbose = true +print(Settings.debug, Settings.verbose, Settings.missing) + +Shared = { items = {} } +print(Shared.items, Shared.missing, Shared.items.anything) + +local rows = { { label = 'a' }, { label = 'b' } } +for _, row in pairs(rows) do + print(row.label, row.count) +end + +local function build(first, second) + local result = {} + local options = { size = 1 } + local delta = first - second + return result.any, options.size, options.color, delta.x +end + +local current = { id = 0 } +RegisterNetEvent('test:set', function(value) + current = value +end) + +local function show() + return current.anything +end + +local Lazy = setmetatable({ loaded = true }, { __index = function(_, key) return key end }) +print(Lazy.loaded, Lazy.anything) + +Tracker = {} + +function Tracker:reset() + return self.count, self.anything +end + +function math.lerp(a, b, t) + return a + (b - a) * t +end + +print(json.nope, math.clamp(1, 0, 2), math.lerp(1, 2, 0.5), math.nope) +print(read, build, show) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let found = |needle: &str, message: &str| ("undefined-field".to_string(), line(needle), message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["undefined-field"]), + [ + found("point.y", "Field `y` is not declared in `Test.Point`"), + found("point.y", "Field `w` is not declared in `Test.Point`"), + found("point.y", "Field `jump` is not declared in `Test.Point`"), + found("point.z", "Field `z` is not declared in `Test.Point`"), + found("either.k", "Field `k` is not declared in `Test.Point|Test.Other`"), + found("maybe.y", "Field `y` is not declared in `Test.Point`"), + found("count.x", "Field `x` is not declared in `number`"), + found("count.x", "Field `nope` is not declared in `string`"), + found("Settings.missing", "Field `missing` is not declared in the table `Settings` holds"), + found("row.count", "Field `count` is not declared in `{ label: string }`"), + found("options.color", "Field `color` is not declared in `{ size: integer }`"), + found("json.nope", "'json' has no field 'nope'"), + found("json.nope", "Field `nope` is not declared in `mathlib`"), + ], + "fields that a class declares, that its methods set through `self` or its table holds, or that \ + the table's own local sets are read, while open classes, strict classes, global tables, empty \ + tables, locals given values of unknown type, an `__index` function, `self` of a table that is \ + no class and an inferred `number` take any field; the linter alone reports `json`" + ); + client.notify( + "workspace/didChangeConfiguration", + json!({ "settings": { "qbxLua": { "diagnostics": { "rules": { "undefined-field": "off" } } } } }), + ); + assert_eq!(findings(&mut client, CLIENT, &["undefined-field"]), [], "the rule can be turned off"); +} + #[test] fn private_protected_and_package_members_stay_in_their_class_or_file() { let mut client = Client::start(fixture_root()); @@ -12024,6 +12146,25 @@ fn exports_of_escrowed_resources_are_not_second_guessed() { ); } +#[test] +fn methods_that_the_declared_type_of_an_export_lacks_are_reported() { + let root = declared_exports_root(); + let mut client = Client::start_with_library(root.join("workspace"), &root.join("types")); + let file = "app/client.lua"; + let text = client.open(file); + let added = "exports['tablet']:Flash()\nexports.tablet:Ring(1)\nexports.phone:Anything()\n\ + ---@type TabletExports\nlocal tablet = exports.tablet\ntablet:Flash()\n"; + client.change(file, 2, &format!("{text}{added}")); + let line = text.lines().count() as u64; + let message = "Field `Flash` is not declared in `TabletExports`".to_string(); + assert_eq!( + findings(&mut client, file, &["undefined-field"]), + [("undefined-field".to_string(), line, message.clone()), ("undefined-field".to_string(), line + 5, message)], + "a call through the exports proxy is checked as one through a local of the declared type; an index \ + of the type takes any name" + ); +} + #[test] fn completes_resources_and_exports_in_both_spellings() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 9e58508..ac714c1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,16 +102,16 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown`, `need-check-nil` and `inject-field`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown`, `need-check-nil`, `inject-field` and `undefined-field`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | | `workspace.ignoreGlobs` | Exclusions, as glob patterns | -Codes that only share a name with a rule here, such as `undefined-field` and `deprecated`, are -ignored, as are all other settings. Keys may be dotted (`"diagnostics.globals"`), nested, or -prefixed with `Lua.`. Comments and trailing commas are accepted. Formatting keeps its defaults; in -the language server, the editor's indentation settings still apply. Discovery skips a file or +Codes that only share a name with a rule here, such as `deprecated`, are ignored, as are all other +settings. Keys may be dotted (`"diagnostics.globals"`), nested, or prefixed with `Lua.`. Comments +and trailing commas are accepted. Formatting keeps its defaults; in the language server, the +editor's indentation settings still apply. Discovery skips a file or exclusion pattern it cannot read and says so on stderr; pass the file with `--config` to make that an error. @@ -530,6 +530,56 @@ An empty table, such as `local result = {}`, takes any field, and so do values o `undeclared-field`. lua-language-server's `inject-field` checks classes only, and its `---@diagnostic disable: inject-field` comments and `diagnostics.disable` entries apply here too. +## Undefined fields + +`undefined-field` reports a field read from a value whose type does not have it, as TypeScript +reports a property its type does not declare. Outside the language server it covers the standard +library tables, such as `string.nope`. In qbx-lua-ls it also covers values whose type the language +server knows, with the fields that `inject-field` lets code set: those a class or table type +declares, including the `{ label: string }` a table constructor gives, and those set through the +names that own a table. Reads with `.`, `['name']` and `:` count, also in conditions such as +`if point.z then`. A union lacks a field when none of its parts has it, and a local that is +assigned again has the types of all the values that may reach the read. + +```lua +---@class Point +---@field x number +---@field y number + +---@param point Point +local function show(point) + print(point.x, point.z) -- undefined-field: Field `z` is not declared in `Point` +end + +local rows = { { label = 'a' }, { label = 'b' } } +for _, row in pairs(rows) do + print(row.count) -- undefined-field: Field `count` is not declared in `{ label: string }` +end +``` + +Some values may have any field, so reads from them are not checked: + +- values of unknown type, `table` and `any`, empty tables such as `local result = {}`, and classes + with an index that takes the name or a parent that is no class, such as `table`; +- global tables and the paths from them, such as `Config.debug` or `ESX.PlayerData`, whose fields + other files and resources set, and the instances made from them, unless an annotation types them; + the exports of a resource are checked only against a type declared for them, as `---@type + PhoneExports` above `exports['phone'] = {}` declares one, with what the resource registers; +- `self` in a method of a table without a `---@class`, whose instances get their fields elsewhere, + and tables whose metatable has an `__index` function; +- numbers, booleans and functions whose type is only inferred, not declared; +- strict classes, whose fields `undeclared-field` checks. + +lua-language-server's `undefined-field` does not check tables built from table constructors, and +it counts a field set through any value of a class, which `inject-field` reports instead. Its +`---@diagnostic disable: undefined-field` comments and `diagnostics.disable` entries apply here too, +also to the standard library tables. To turn it off: + +```toml +[rules] +"undefined-field" = "off" +``` + ## Member visibility A class can keep fields and methods to itself, as in LuaLS: diff --git a/docs/rules.md b/docs/rules.md index 2617e95..ad21a66 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -14,7 +14,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | --- | --- | --- | | `syntax-error` | error | Source rejected by the parser, including its nesting limit. | | `undefined-global` | warning | A read of a global absent from the known environment. | -| `undefined-field` | warning | An unknown field on a standard library table. | +| `undefined-field` | warning | An unknown field on a standard library table. qbx-lua-ls also reports a field read from a value whose `---@class` or table type does not have it, such as `point.z` for a class with `x` and `y`, as TypeScript reports a property its type does not declare. A type has the fields that `inject-field` lets code set; global tables, the exports of a resource without a declared type, empty tables and values of unknown type have any. See [Undefined fields](reference.md#undefined-fields). | | `unused-local` | warning | A local variable is never read. | | `unused-function` | warning | A local function is never used. | | `unused-argument` | hint | A function parameter is never read. | From c3f650c58d2334528d6296566e5c3948723f272e Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:20:38 +0200 Subject: [PATCH 079/193] fix(hover): write out the table types fields declare in overviews --- crates/qbx_lua_ls/src/features/hover.rs | 4 +++- crates/qbx_lua_ls/tests/lsp.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 54100a5..65c52e2 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -141,9 +141,11 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O let mut out = format!("{prefix}{name}: {label}{{"); let mut shown = Vec::new(); for member in members.iter().take(MAX_OVERVIEW_FIELDS) { + // A table type that a field declares, as `{ [string]: Variation }`, is written out as + // lua-language-server writes it; a table the index only knows by its path is not. let ty = match &member.ty { Type::Fun(_) => "function".to_string(), - Type::GlobalTable(_) | Type::Shape(_) => "table".to_string(), + Type::GlobalTable(_) => "table".to_string(), other => { shown.push(other.clone()); other.to_string() diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 21ae915..9a50c2a 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -9358,6 +9358,31 @@ print(car, wrapped, opts) assert!(doc(1).contains(pair) && !doc(1).contains(mode), "{}", doc(1)); } +#[test] +fn hover_overviews_write_out_the_table_types_fields_declare() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Variation +---@field drawable number + +---@class Test.Clothes +---@field components { [string]: Test.Variation } +---@field hidden boolean + +---@type Test.Clothes +local clothes = { components = {}, hidden = false } +local nested = { inner = { value = 1 } } +print(clothes, nested) +"; + client.open_with(CLIENT, text); + let (l, c) = pos(text, "print(clothes", 6); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("components: { [string]: Test.Variation },"), "{hover}"); + let (l, c) = pos(text, "nested)", 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("inner: table,"), "a table the index holds stays `table`: {hover}"); +} + #[test] fn calls_through_function_aliases_use_their_signature() { let mut client = Client::start(fixture_root()); From 3854cdc923278c4f1bdfc85df2dece23ae967a8e Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:20:35 +0200 Subject: [PATCH 080/193] feat(rules): add cast-local-type --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 5 +- crates/qbx_lua_ls/src/features/diagnostics.rs | 12 +- crates/qbx_lua_ls/src/features/mod.rs | 1 + .../qbx_lua_ls/src/features/retyped_locals.rs | 205 ++++++++++++++++++ crates/qbx_lua_ls/tests/lsp.rs | 159 ++++++++++++++ docs/reference.md | 49 ++++- docs/rules.md | 1 + 10 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/retyped_locals.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 89f2a3c..915503e 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -48,6 +48,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "redundant-return-value", "discard-returns", "cast-type-mismatch", + "cast-local-type", "no-unknown", "need-check-nil", "inject-field", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index eb69b6e..6680e33 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -103,6 +103,8 @@ pub const DISCARD_RETURNS: &str = "discard-returns"; /// Reported by qbx-lua-ls, which reads the LuaCATS types; the linter itself has no type information. pub const CAST_TYPE_MISMATCH: &str = "cast-type-mismatch"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. +pub const CAST_LOCAL_TYPE: &str = "cast-local-type"; +/// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. pub const NO_UNKNOWN: &str = "no-unknown"; pub const CONST_REASSIGN: &str = "const-reassign"; pub const SELF_ASSIGNMENT: &str = "self-assignment"; @@ -182,6 +184,7 @@ pub static RULES: &[Rule] = &[ rule(REDUNDANT_RETURN_VALUE, Correctness, WARN, false, "A function returns more values than its @return annotations declare (language server only)."), rule(DISCARD_RETURNS, Correctness, WARN, false, "A call drops the values of a function whose @nodiscard annotation requires using them (language server only)."), rule(CAST_TYPE_MISMATCH, Correctness, WARN, false, "A ---@cast gives a variable a type that its declared type does not take (language server only)."), + rule(CAST_LOCAL_TYPE, Correctness, WARN, false, "A local without @type or @param is given a value that the type of the value it is declared with does not take, such as a string after `local speed = 5` (language server only)."), rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type, or a value of unknown type goes where a type is declared (language server only)."), rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), rule(SELF_ASSIGNMENT, Suspicious, WARN, false, "A variable is assigned to itself."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 841e7da..5587751 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -92,6 +92,7 @@ fn bad_resource_reports_every_rule() { | "redundant-return-value" | "discard-returns" | "cast-type-mismatch" + | "cast-local-type" | "no-unknown" | "impossible-comparison" | "need-check-nil" diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index e4eb97b..3105d32 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -77,7 +77,10 @@ Available features depend on the editor's LSP client. completion leaves them out there. `impossible-comparison` reports an `==` or `~=` between values whose declared types share no value, such as `GetState() == "invalid"` for a function that returns `"active"|"busy"`, and - `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. `inject-field` + `cast-type-mismatch` a `---@cast` to a type the local is not declared to take. + `cast-local-type` reports an assignment that gives a local without an annotation a value of + another type than the one it is declared with, such as `speed = "5"` after `local speed = 5`, as + lua-language-server and TypeScript do. `inject-field` reports fields set through a value whose class or table type does not have them, `undefined-field` fields read from one, and `need-check-nil` a value that may be `nil` used where a missing value raises an error. The opt-in diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 5ce4120..62cfa75 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,10 +5,10 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, - INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, - MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, - RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, UNDEFINED_FIELD, + ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, + INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, + MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, + REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, UNDEFINED_FIELD, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -28,6 +28,7 @@ use super::doc_names::undefined_doc_names; use super::injected_fields::injected_fields; use super::nil_checks::{unchecked_nils, UncheckedNils}; use super::returns::{mismatched_returns, missing_returns, redundant_returns}; +use super::retyped_locals::retyped_locals; use super::strict_classes::undeclared_fields; use super::undefined_fields::undefined_fields; use super::unknown_types::{typed_by_declaration, unknown_types, unknown_values}; @@ -92,7 +93,7 @@ fn type_diagnostics( found: &mut Vec, ) { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 18] = [ + let checks: [(&'static str, Check); 19] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -119,6 +120,7 @@ fn type_diagnostics( (REDUNDANT_RETURN_VALUE, |input| redundant_returns(input.infer, &input.doc.chunk)), (DISCARD_RETURNS, |input| discarded_returns(input.infer, &input.doc.chunk)), (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), + (CAST_LOCAL_TYPE, |input| retyped_locals(input.infer, &input.doc.chunk)), (MISSING_PARAMETER, |input| missing_payloads(input.infer, input.payloads())), (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), (NO_UNKNOWN, |input| { diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index 6daa6d7..a4e0285 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -27,6 +27,7 @@ pub mod references; pub mod resource_assets; pub mod resource_details; pub mod returns; +pub mod retyped_locals; pub mod semantic_tokens; pub mod signature; pub mod strict_classes; diff --git a/crates/qbx_lua_ls/src/features/retyped_locals.rs b/crates/qbx_lua_ls/src/features/retyped_locals.rs new file mode 100644 index 0000000..2a979c6 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/retyped_locals.rs @@ -0,0 +1,205 @@ +//! `cast-local-type`: an assignment that gives a local a value its type does not take, where nothing +//! annotates that type, as `speed = "5"` after `local speed = 5`. As in lua-language-server and +//! TypeScript, such a local has the type of the value it is declared with. A literal written out +//! stands for its kind, so `local mode = 'dev'` takes any string and `local count = 0` any number, +//! while the literals that a function declares it returns, as the `'a'|'b'` of `getMode()`, are the +//! only ones it takes. A loop variable has the type its loop gives it, and a parameter that of the +//! function type its function is passed or written as. A local declared without a value, or as +//! `nil`, `unknown` or `any`, takes any value. +//! +//! The check is that of `assign-type-mismatch`, for each type the value may be: a different kind of +//! value, or a literal the type does not list, so `nil`, or a `string?`, needs a local that may be +//! `nil`. A native returns a `BOOL` as a boolean or an integer, so either passes for the other where +//! a native gives it. Locals typed with `---@type` or `@param` are left to `assign-type-mismatch`, +//! `` and `` ones to `const-reassign`. + +use qbx_lua_analysis::scope::{LocalId, Resolved}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; +use rustc_hash::FxHashMap; + +use super::class_tables::Classes; +use super::comparisons::Declared; +use super::unknown_types::has_param_line; +use crate::infer::{Decl, Expected, Infer}; +use crate::narrow::DECLARATION; +use crate::types::Type; + +/// Each value assigned to a local that the type it is declared with does not take, with the message +/// naming both types. +pub fn retyped_locals(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let mut finder = Finder { + infer, + classes: Classes::new(infer), + declared: Declared::new(infer), + types: FxHashMap::default(), + out: Vec::new(), + }; + finder.visit_block(&chunk.block); + finder.out +} + +struct Finder<'a, 'b> { + infer: &'a Infer<'b>, + classes: Classes<'a, 'b>, + declared: Declared<'a, 'b>, + /// The type of each local assigned so far, and whether a native gives it, or `None` for one + /// that takes any value or whose annotation `assign-type-mismatch` checks. + types: FxHashMap>, + out: Vec<(Span, String)>, +} + +impl Finder<'_, '_> { + /// The type of the values the local `id` takes, and whether a native gives it, or `None` when + /// it takes any value or an annotation declares its type. + fn local_type(&mut self, id: LocalId) -> Option<(Type, bool)> { + if let Some(ty) = self.types.get(&id) { + return ty.clone(); + } + let ty = self + .declaration(id) + .map(|(ty, native)| (single(ty), native)) + .filter(|(ty, _)| !matches!(ty, Type::Unknown | Type::Any | Type::Nil)); + self.types.insert(id, ty.clone()); + ty + } + + /// The type that the declaration of the local `id` gives it, unless an annotation declares it, + /// and whether a native gives it. + fn declaration(&self, id: LocalId) -> Option<(Type, bool)> { + let ctx = self.infer.ctx; + let local = ctx.resolution.local(id); + // `_` is a name for values that are thrown away. + if local.name == "_" || local.attrib.is_some() { + return None; + } + let ty = match ctx.decl(local.decl.start)? { + Decl::Local { stmt, index } => return self.local_value(id, stmt, *index), + Decl::LocalFunction { .. } | Decl::NumericFor | Decl::GenericFor { .. } => { + self.infer.origin_type(id, DECLARATION)?.widen() + } + // What the callee declares, rather than what the calls that reach the function pass. + Decl::Param { expected: Some(Expected::Arg { .. }), .. } => { + self.infer.declared_callback_param(id, |arg| self.declared.of(arg))? + } + Decl::Param { expected: Some(_), .. } if !has_param_line(self.infer, local) => self.infer.local_type(id), + Decl::Param { .. } | Decl::SelfParam { .. } => return None, + }; + Some((ty, false)) + } + + /// The type of the value that the `local` statement `stmt` gives the name at `index`, with the + /// literals written out widened to their kind, and whether a native gives it. + fn local_value(&self, id: LocalId, stmt: &Stmt, index: usize) -> Option<(Type, bool)> { + let StmtKind::Local { exprs, .. } = &stmt.kind else { return None }; + if self.infer.ctx.doc_at(stmt.span.start).type_at(index).is_some() { + return None; + } + let native = self.is_native_value(exprs, index); + let ty = self.infer.origin_type(id, DECLARATION)?; + let widened = ty.widen(); + if widened == ty { + return Some((ty, native)); + } + // A literal that a declaration lists, rather than one that is written out, is a value the + // local is meant to hold. + let declared = match exprs.get(index) { + Some(value) if !is_literal(value) => self.declared.of(value), + Some(_) => Type::Unknown, + None => exprs + .last() + .filter(|last| last.is_multi_value()) + .and_then(|last| self.infer.declared_returns(last)) + .and_then(|values| values.into_iter().nth(index + 1 - exprs.len())) + .unwrap_or_default(), + }; + Some((if declared.widen() == declared { widened } else { ty }, native)) + } + + /// Whether a native call gives the value at `index` of those that `exprs` assign. + fn is_native_value(&self, exprs: &[Expr], index: usize) -> bool { + let value = exprs.get(index.min(exprs.len().saturating_sub(1))); + value.is_some_and(|value| match &value.unparen().kind { + ExprKind::Call { callee, .. } => self.declared.is_native(callee, 0), + _ => false, + }) + } + + /// Reports `given`, written at `span`, when the local that `target` names does not take it. + /// `native` tells whether a native gives it. + fn check(&mut self, target: &Expr, given: Type, native: bool, span: Span) { + let ExprKind::Name(name) = &target.kind else { return }; + let Some(Resolved::Local(id)) = self.infer.ctx.resolution.resolve_at(name.span.start) else { return }; + let Some((expected, declared_by_native)) = self.local_type(id) else { return }; + let given = single(given); + let from = self.classes.file(); + let takes = |ty: &Type| !self.classes.rejects(&expected, from, ty); + let takes_part = |part: &Type| match part { + Type::Unknown | Type::Any => true, + // A native returns a `BOOL` as a boolean or an integer. + Type::Boolean | Type::BooleanLit(_) if native => takes(part) || takes(&Type::Integer), + Type::Number | Type::Integer | Type::IntLit(_) | Type::Handle(_) if declared_by_native => { + takes(part) || takes(&Type::Boolean) + } + part => takes(part), + }; + let mut parts = Vec::new(); + self.classes.flatten(&given, from, &mut parts, 0); + if parts.iter().all(takes_part) { + return; + } + let shown = if self.classes.literal_mismatch(&expected, from, &given) { given } else { given.widen() }; + self.out.push((span, format!("Cannot assign `{shown}` to `{}`, defined as `{expected}`", name.text))); + } + + /// What `target ..= value`, or another compound assignment, stores in the local `target` names. + fn compound_value(&self, target: &Expr) -> Option { + let ExprKind::Name(name) = &target.kind else { return None }; + let Some(Resolved::Local(id)) = self.infer.ctx.resolution.resolve_at(name.span.start) else { return None }; + let origin = self.infer.ctx.flow().written_at(name.span.start)?; + self.infer.origin_type(id, origin) + } +} + +/// Whether `expr` is a literal written out. +fn is_literal(expr: &Expr) -> bool { + matches!(expr.unparen().kind, ExprKind::True | ExprKind::False | ExprKind::Number(_) | ExprKind::String(_)) +} + +/// One of the values that `ty` stands for: a `string` for the `...string` a function returns. +fn single(ty: Type) -> Type { + match ty { + Type::Variadic(inner) => *inner, + ty => ty, + } +} + +impl<'c> Visitor<'c> for Finder<'_, '_> { + fn visit_stmt(&mut self, stmt: &'c Stmt) { + match &stmt.kind { + StmtKind::Assign { targets, exprs } => { + let doc = self.infer.ctx.doc_at(stmt.span.start); + let values = self.classes.values(exprs); + for (index, (target, (given, span))) in targets.iter().zip(values).enumerate() { + // A `---@type` above the assignment declares what it stores, as lua-language-server + // reads it, and `assign-type-mismatch` checks the value against it. + match doc.type_at(index).filter(|_| doc.declared_class().is_none()) { + Some(annotated) => self.check(target, annotated.clone(), false, span), + None => { + let native = self.is_native_value(exprs, index); + self.check(target, given, native, span); + } + } + } + } + StmtKind::CompoundAssign { target, .. } => { + if let Some(given) = self.compound_value(target) { + self.check(target, given, false, stmt.span); + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } +} diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 9a50c2a..459583c 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6549,6 +6549,165 @@ use(back, returned, built) ); } +#[test] +fn locals_take_values_of_the_type_they_are_declared_with() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@return string? +local function maybe() end + +local speed = 5 +speed = '5' +local count = 5 +count = count .. 'x' +local done = false +done = {} +local enabled = true +enabled = 'yes' +local list = {} +list = 5 +local name = maybe() +name = 5 +local label = 'a' +label = nil +local total = 0 +total = nil +local ratio = 1 +ratio = 1.5 +local state = 'on' +state = 'off' +local data = {} +data = { a = 1 } +local cleared = nil +cleared = 5 +local unset +unset = 'x' +local fallback = 'a' +fallback = maybe() +print(speed, count, done, enabled, list, name, label, total, ratio, state, data, cleared, unset, fallback) +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("cast-local-type".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + assert_eq!( + findings(&mut client, CLIENT, &["cast-local-type"]), + [ + finding("speed = '5'", "Cannot assign `string` to `speed`, defined as `integer`"), + finding("count = count", "Cannot assign `string` to `count`, defined as `integer`"), + finding("done = {}", "Cannot assign `table` to `done`, defined as `boolean`"), + finding("enabled = 'yes'", "Cannot assign `string` to `enabled`, defined as `boolean`"), + finding("list = 5", "Cannot assign `integer` to `list`, defined as `table`"), + finding("name = 5", "Cannot assign `integer` to `name`, defined as `string?`"), + finding("label = nil", "Cannot assign `nil` to `label`, defined as `string`"), + finding("total = nil", "Cannot assign `nil` to `total`, defined as `integer`"), + finding("fallback = maybe()", "Cannot assign `string?` to `fallback`, defined as `string`"), + ], + "as in lua-language-server and TypeScript, a local has the type of its first value: `integer` widens to \ + `number`, a literal written out to its kind, a table takes any table, `nil` needs a type that allows it, and \ + a local declared without a value or as `nil` takes anything" + ); +} + +#[test] +fn locals_typed_by_annotations_loops_and_callees_take_values_of_their_type() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Mode 'a'|'b' +---@return Test.Mode +local function getMode() return 'a' end +---@return 'x'|'y' +local function getAxis() return 'x' end +---@param handler fun(id: integer, name: string) +local function each(handler) end +---@class Test.Point +---@field x number +local Point = {} +Point.__index = Point + +---@type integer +local typed = 1 +typed = 'x' +---@param documented string +local function document(documented, plain) + documented = 1 + plain = 1 + plain = 'x' +end +local cast = 5 +---@cast cast string +cast = 'x' +local captured = 5 +local function capture() captured = 'x' end +local first, second = 1, 's' +first, second = 'x', 2 +for i = 1, 3 do i = 'x' end +for index, value in ipairs({ 1, 2 }) do index = 'x'; value = 2.5 end +each(function(id, name) id = 'x'; name = 'y' end) +local loose = undefinedGlobal +loose = 5 +local fn = function() end +fn = 5 +local point = setmetatable({}, Point) +point = 5 +point = {} +local mode = getMode() +mode = 'c' +mode = 'a' +local axis = getAxis() +axis = 'z' +local choice = math.random() > 0.5 and 'a' or 'b' +choice = 'c' +local fixed = 5 +fixed = 'x' +local _ = 5 +_ = 'x' +local annotated = 5 +---@type string +annotated = 'x' +local text = 0 +text ..= 'x' +local hit = 0 +_, hit = GetShapeTestResult(1) +local _, struck = GetShapeTestResult(1) +struck = 0 +local part = string.strsplit(',', 'a,b') +part = 1 +print(typed, document, cast, capture, first, second, loose, fn, point, mode, axis, choice, fixed, annotated, text) +print(hit, struck, part) +"; + client.open_with(CLIENT, text); + let line = |needle: &str| pos(text, needle, 0).0 as u64; + let finding = |code: &str, needle: &str, message: &str| (code.to_string(), line(needle), message.to_string()); + let (local, mismatch) = ("cast-local-type", "assign-type-mismatch"); + assert_eq!( + findings(&mut client, CLIENT, &[local, mismatch]), + [ + finding(mismatch, "typed = 'x'", "Cannot assign `string` to `typed` of type `integer`"), + finding(mismatch, "documented = 1", "Cannot assign `integer` to `documented` of type `string`"), + finding(local, "cast = 'x'", "Cannot assign `string` to `cast`, defined as `integer`"), + finding(local, "captured = 'x'", "Cannot assign `string` to `captured`, defined as `integer`"), + finding(local, "first, second = 'x'", "Cannot assign `string` to `first`, defined as `integer`"), + finding(local, "first, second = 'x'", "Cannot assign `integer` to `second`, defined as `string`"), + finding(local, "i = 'x'", "Cannot assign `string` to `i`, defined as `number`"), + finding(local, "index = 'x'", "Cannot assign `string` to `index`, defined as `integer`"), + finding(local, "id = 'x'", "Cannot assign `string` to `id`, defined as `integer`"), + finding(local, "fn = 5", "Cannot assign `integer` to `fn`, defined as `fun()`"), + finding(local, "point = 5", "Cannot assign `integer` to `point`, defined as `Test.Point`"), + finding(local, "mode = 'c'", "Cannot assign `\"c\"` to `mode`, defined as `Test.Mode`"), + finding(local, "axis = 'z'", "Cannot assign `\"z\"` to `axis`, defined as `\"x\"|\"y\"`"), + finding(local, "annotated = 'x'", "Cannot assign `string` to `annotated`, defined as `integer`"), + finding(local, "text ..= 'x'", "Cannot assign `string` to `text`, defined as `integer`"), + finding(local, "part = 1", "Cannot assign `integer` to `part`, defined as `string`"), + ], + "a `---@type` or `@param` local is left to `assign-type-mismatch`, a cast or a `---@type` above an \ + assignment does not change the type a local is declared with, loop variables and the parameters of a \ + function passed for a function type have the type they are given, and the literals a function declares \ + it returns are the only ones its local takes; parameters without a type, `` locals and `_` take \ + anything, and a `BOOL` a native returns may be an integer" + ); +} + #[test] fn casts_of_locals_assigned_again_compare_the_values_that_reach_them() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index ac714c1..a992fd3 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `no-unknown`, `need-check-nil`, `inject-field` and `undefined-field`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field` and `undefined-field`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | @@ -739,8 +739,9 @@ A `---@type` line that lists several types gives each name of the statement its `---@type boolean, string?` does above `local ok, err = pcall(...)`. A single type is the first name's alone, as lua-language-server binds it, so `---@type boolean` above `local onScreen, x, y = GetScreenCoordFromWorldCoord(...)` types only `onScreen`. Names without a -type of their own, and other locals whose type is inferred from what they hold, take any value, -and so do globals in assignments that have no `---@type` above them. +type of their own, and other locals whose type is inferred from what they hold, have the type of +the value they are declared with, which [`cast-local-type`](#local-types) checks. Globals in +assignments that have no `---@type` above them take any value. The value stored is what the code is known to give. A global's `nil` tells nothing: one declared as `CurrentZone = nil` and set to a name by an event handler holds whatever the handler gives it, so @@ -775,6 +776,48 @@ needs the fields of one of the types that its members declare for its key, as `D entry of `Dog[]|Cat[]`. As in TypeScript and unlike in lua-language-server, the tables held by one that may be a `table` or `any`, as for `Dog[]|table`, are not checked. +## Local types + +`cast-local-type` reports an assignment that gives a local without a `---@type` or `@param` a value +its type does not take. As in lua-language-server and TypeScript, such a local has the type of the +value it is declared with: + +```lua +---@return string? +local function find() end + +local speed = 5 +speed = "5" -- Cannot assign `string` to `speed`, defined as `integer` +speed = 7.5 -- passes: an `integer` local takes any number +speed = nil -- Cannot assign `nil` to `speed`, defined as `integer` + +local mode = "dev" +mode = "live" -- passes: a literal written out stands for its kind + +local name = find() +name = 5 -- Cannot assign `integer` to `name`, defined as `string?` +``` + +The check is that of `assign-type-mismatch`, for each type the value may be: a different kind of +value, or a literal the type does not list. So a `string?` needs a local that may be `nil`, and +clearing a local with `nil` needs a type that allows it, also for a table, as in TypeScript and +unlike in lua-language-server. A local that starts as `false` or `true` is a `boolean`, and one +declared with a table constructor takes any table. A literal that a function declares it returns, +as the `"a"|"b"` of `---@return "a"|"b"`, is one of the only values its local takes, while a literal +written out, also through `and` and `or`, widens to its kind. Each name of an assignment is checked +against its own local, also inside the functions that a local is assigned in. + +A local declared without a value, as `nil`, or with a value of unknown type or `any`, takes any +value, and so do `_`, `self` and parameters without `@param`. A loop variable has the type its loop +gives it, such as the `integer` of `for i, v in ipairs(list)`, and a parameter of a function passed +for a function type, such as the handler of `fun(id: integer)`, has the type the function type +declares, as in TypeScript. Locals with a `---@type` or `@param` are left to +[`assign-type-mismatch`](#typed-variables), and `` and `` locals to `const-reassign`. +A `---@cast` changes what a local holds from its line on, but not the type it is declared with, as +in lua-language-server, so assigning a value of the cast type is still reported; a `---@type` above +an assignment is the type of the value it stores. A `BOOL` that a native returns may be a boolean +or an integer, so either passes for the other where a native gives it. + ## Casts A `---@cast` line changes the type of a local from its line on: `---@cast name T` makes it a `T`, diff --git a/docs/rules.md b/docs/rules.md index ad21a66..02b4535 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -49,6 +49,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `redundant-return-value` | warning | A `return` passes more values than the function's `@return` annotations, or the function type it is written as, declare. A trailing `...T` takes any number. Reported by qbx-lua-ls only. | | `discard-returns` | warning | A call on a line of its own drops the values of a function marked `@nodiscard`, such as `tostring(value)`. Reported by qbx-lua-ls only. | | `cast-type-mismatch` | warning | A `---@cast name T` gives a local a type that its declared type does not take, such as `string` for a local declared as `integer`, a literal the type does not list, or a class it neither names nor extends. Reported by qbx-lua-ls only. | +| `cast-local-type` | warning | An assignment gives a local without `---@type` or `@param` a value that the type of the value it is declared with does not take, such as a string after `local speed = 5`, or `nil` for a local that cannot be `nil`. A literal written out widens to its kind, a loop variable has the type its loop gives it, and a parameter of a function passed for a function type the type it declares; a local declared without a value or as `nil` takes any value. See [Local types](reference.md#local-types). Reported by qbx-lua-ls only. | | `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. A value of unknown type is stored in a typed variable or field, passed for a typed parameter, or returned where `@return` declares a type. Reported by qbx-lua-ls only. | | `const-reassign` | error | An assignment to a `` or `` local. | | `self-assignment` | warning | A variable is assigned to itself. | From eae97b14d16e5df0d9852d691501f910f4cc0e39 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:27:11 +0200 Subject: [PATCH 081/193] feat(rules): report server-only Lua libraries used on the client --- crates/qbx_fivem_data/src/lib.rs | 20 ++- crates/qbx_fivem_data/stubs/cfx.lua | 27 ---- crates/qbx_fivem_data/stubs/cfx_server.lua | 49 ++++++ crates/qbx_fivem_data/stubs/lua54.lua | 153 ------------------ crates/qbx_fivem_data/stubs/lua54_server.lua | 130 +++++++++++++++ crates/qbx_lua_analysis/src/checks/globals.rs | 16 +- crates/qbx_lua_analysis/src/env.rs | 31 ++-- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_analysis/src/side_guard.rs | 15 +- crates/qbx_lua_analysis/tests/rules.rs | 67 +++++++- crates/qbx_lua_ls/tests/lsp.rs | 39 ++++- docs/reference.md | 12 +- docs/rules.md | 2 +- 13 files changed, 358 insertions(+), 205 deletions(-) create mode 100644 crates/qbx_fivem_data/stubs/lua54_server.lua diff --git a/crates/qbx_fivem_data/src/lib.rs b/crates/qbx_fivem_data/src/lib.rs index 8087682..7f78ebc 100644 --- a/crates/qbx_fivem_data/src/lib.rs +++ b/crates/qbx_fivem_data/src/lib.rs @@ -189,15 +189,25 @@ pub fn native_docs(name: &str) -> Option { pub struct Stub { pub name: &'static str, pub side: Side, + /// Whether it declares Lua standard libraries rather than CfxLua additions. + pub library: bool, pub source: &'static str, } +const fn stub(name: &'static str, side: Side, library: bool, source: &'static str) -> Stub { + Stub { name, side, library, source } +} + +/// The runtime definitions. FiveM only opens the `io` and `os` libraries on the server +/// (`IS_FXSERVER` in citizen-scripting-lua's `LuaScriptRuntime.cpp`), so `lua54_server.lua` holds +/// them; stubs extending a table come after the one declaring it. pub static STUBS: &[Stub] = &[ - Stub { name: "lua54.lua", side: Side::Shared, source: include_str!("../stubs/lua54.lua") }, - Stub { name: "cfx.lua", side: Side::Shared, source: include_str!("../stubs/cfx.lua") }, - Stub { name: "glm.lua", side: Side::Shared, source: include_str!("../stubs/glm.lua") }, - Stub { name: "cfx_client.lua", side: Side::Client, source: include_str!("../stubs/cfx_client.lua") }, - Stub { name: "cfx_server.lua", side: Side::Server, source: include_str!("../stubs/cfx_server.lua") }, + stub("lua54.lua", Side::Shared, true, include_str!("../stubs/lua54.lua")), + stub("lua54_server.lua", Side::Server, true, include_str!("../stubs/lua54_server.lua")), + stub("cfx.lua", Side::Shared, false, include_str!("../stubs/cfx.lua")), + stub("glm.lua", Side::Shared, false, include_str!("../stubs/glm.lua")), + stub("cfx_client.lua", Side::Client, false, include_str!("../stubs/cfx_client.lua")), + stub("cfx_server.lua", Side::Server, false, include_str!("../stubs/cfx_server.lua")), ]; pub struct KnownImport { diff --git a/crates/qbx_fivem_data/stubs/cfx.lua b/crates/qbx_fivem_data/stubs/cfx.lua index dc15228..d2d3503 100644 --- a/crates/qbx_fivem_data/stubs/cfx.lua +++ b/crates/qbx_fivem_data/stubs/cfx.lua @@ -686,28 +686,6 @@ function table.wipe(t) end ---@return string function table.type(t) end ----Returns a timestamp in microseconds. CfxLua extension. ----@return integer -function os.microtime() end - ----Returns a timestamp in nanoseconds. CfxLua extension. ----@return integer -function os.nanotime() end - ----Returns the difference between two timestamps. CfxLua extension. ----@param a integer ----@param b integer ----@return integer -function os.deltatime(a, b) end - ----Returns the processor time stamp counter. CfxLua extension. ----@return integer -function os.rdtsc() end - ----Returns the processor time stamp counter, serialized. CfxLua extension. ----@return integer -function os.rdtscp() end - ---Returns a shallow copy of the table. CfxLua extension. ---@generic T: table ---@param t T @@ -951,11 +929,6 @@ function math.tanh(x) end ---@nodiscard function math.trunc(x) end ----Lists the entries of a directory. CfxLua extension, server only in practice. ----@param path string ----@return string[] -function io.readdir(path) end - ---Builds a vector from its arguments; the result type depends on how many numbers are passed. ---@param ... number ---@return vector2|vector3|vector4|number diff --git a/crates/qbx_fivem_data/stubs/cfx_server.lua b/crates/qbx_fivem_data/stubs/cfx_server.lua index 5c02024..f18e2fe 100644 --- a/crates/qbx_fivem_data/stubs/cfx_server.lua +++ b/crates/qbx_fivem_data/stubs/cfx_server.lua @@ -67,3 +67,52 @@ function RconLog(data) end ---@param playerSrc integer|string ---@return string endpoint function GetPlayerEP(playerSrc) end + +---Returns a timestamp in microseconds. CfxLua extension. +---@return integer +function os.microtime() end + +---Returns a timestamp in nanoseconds. CfxLua extension. +---@return integer +function os.nanotime() end + +---Returns the difference between two timestamps. CfxLua extension. +---@param a integer +---@param b integer +---@return integer +function os.deltatime(a, b) end + +---Returns the processor time stamp counter. CfxLua extension. +---@return integer +function os.rdtsc() end + +---Returns the processor time stamp counter, serialized. CfxLua extension. +---@return integer +function os.rdtscp() end + +---Creates a directory. Returns true, or nil plus an error message and code when it already exists or cannot be created. CfxLua extension. +---@param path string +---@return boolean? ok +---@return string? err +---@return integer? code +function os.createdir(path) end + +---The entries of a directory, as `io.readdir` lists them. CfxLua extension. +---@class directory +local directory = {} + +---Returns an iterator over the names of the entries, without "." and "..". +---@return fun(): string iterator +function directory:lines() end + +---Frees the list of entries; `lines` fails once it is closed. +---@return boolean ok +function directory:close() end + +---Lists the entries of a directory. Returns them, or nil plus an error message and code when the path is on no known device. CfxLua extension. +---@param path string +---@return directory? entries +---@return string? err +---@return integer? code +---@nodiscard +function io.readdir(path) end diff --git a/crates/qbx_fivem_data/stubs/lua54.lua b/crates/qbx_fivem_data/stubs/lua54.lua index 028c793..f87ecc5 100644 --- a/crates/qbx_fivem_data/stubs/lua54.lua +++ b/crates/qbx_fivem_data/stubs/lua54.lua @@ -461,87 +461,6 @@ function file:setvbuf(mode, size) end ---@return string? err function file:write(...) end ----@class iolib -io = {} - ----Standard input stream. ----@type file -io.stdin = {} - ----Standard output stream. ----@type file -io.stdout = {} - ----Standard error stream. ----@type file -io.stderr = {} - ----Closes `f`, or the default output file when omitted. ----@param f? file ----@return boolean? ok ----@return string? exitcode ----@return integer? code -function io.close(f) end - ----Flushes the default output file. -function io.flush() end - ----Opens the named file (or uses the given handle) as the default input; with no argument returns the current default input. ----@param f? string|file ----@return file input -function io.input(f) end - ----Opens the file in read mode and returns a line iterator that closes the file when it finishes. Without a name it iterates the default input. ----@param filename? string ----@param ... read_format ----@return fun(): any, ...any iterator -function io.lines(filename, ...) end - ----Opens a file in the given mode (default "r"). Returns the handle, or nil plus an error message and code. ----@param filename string ----@param mode? open_mode ----@return file? handle ----@return string? err ----@return integer? code ----@nodiscard -function io.open(filename, mode) end - ----Opens the named file (or uses the given handle) as the default output; with no argument returns the current default output. ----@param f? string|file ----@return file output -function io.output(f) end - ----Starts `prog` in a separate process and returns a handle to read from ("r", default) or write to ("w") it. Not available on every platform. ----@param prog string ----@param mode? popen_mode ----@return file? handle ----@return string? err -function io.popen(prog, mode) end - ----Reads from the default input file; see `file:read`. ----@param ... read_format ----@return any value ----@return any ... ----@nodiscard -function io.read(...) end - ----Returns a handle to a temporary file opened in update mode that is removed when the program ends. ----@return file handle ----@nodiscard -function io.tmpfile() end - ----Returns "file" for an open handle, "closed file" for a closed one and nil for anything else. ----@param obj any ----@return "file"|"closed file"|nil kind ----@nodiscard -function io.type(obj) end - ----Writes to the default output file; see `file:write`. ----@param ... string|number ----@return file? output ----@return string? err -function io.write(...) end - ---@class mathlib math = {} @@ -723,78 +642,6 @@ function math.ult(m, n) end ---@field isdst? boolean local osdate = {} ----@class oslib -os = {} - ----Returns an approximation of the CPU time used by the program, in seconds. ----@return number seconds ----@nodiscard -function os.clock() end - ----Formats a time (default: now) using strftime-style `format`. A leading "!" selects UTC and the format "*t" returns a table of date fields. ----@param format? string ----@param time? integer ----@return string|osdate result ----@nodiscard -function os.date(format, time) end - ----Returns the number of seconds from time `t1` to time `t2`. ----@param t2 integer ----@param t1 integer ----@return number seconds ----@nodiscard -function os.difftime(t2, t1) end - ----Runs `command` through the system shell and returns success, "exit" or "signal", and the status code. Without a command it reports whether a shell is available. ----@param command? string ----@return boolean? ok ----@return string? exitcode ----@return integer? code -function os.execute(command) end - ----Terminates the host program with the given status (default success). When `close` is true the Lua state is closed first. ----@param code? boolean|integer ----@param close? boolean -function os.exit(code, close) end - ----Returns the value of an environment variable, or nil when it is not defined. ----@param varname string ----@return string? value ----@nodiscard -function os.getenv(varname) end - ----Deletes a file or an empty directory. Returns true, or nil plus an error message and code. ----@param filename string ----@return boolean? ok ----@return string? err ----@return integer? code -function os.remove(filename) end - ----Renames or moves a file or directory. Returns true, or nil plus an error message and code. ----@param oldname string ----@param newname string ----@return boolean? ok ----@return string? err ----@return integer? code -function os.rename(oldname, newname) end - ----Sets the program locale for the given category, or queries it when `locale` is nil. Returns the locale name, or nil on failure. ----@param locale? string ----@param category? locale_category ----@return string? name -function os.setlocale(locale, category) end - ----Returns the current time as a timestamp, or the timestamp described by the given date table. ----@param date? osdate ----@return integer timestamp ----@nodiscard -function os.time(date) end - ----Returns a file name that can be used for a temporary file. ----@return string name ----@nodiscard -function os.tmpname() end - ---@class packagelib package = {} diff --git a/crates/qbx_fivem_data/stubs/lua54_server.lua b/crates/qbx_fivem_data/stubs/lua54_server.lua new file mode 100644 index 0000000..83e710e --- /dev/null +++ b/crates/qbx_fivem_data/stubs/lua54_server.lua @@ -0,0 +1,130 @@ +---@meta + +---@class iolib +io = {} + +---Standard input stream. +---@type file +io.stdin = {} + +---Standard output stream. +---@type file +io.stdout = {} + +---Standard error stream. +---@type file +io.stderr = {} + +---Closes `f`; see `file:close`. FiveM has no default output file to close when `f` is omitted. +---@param f file +---@return boolean? ok +---@return string? exitcode +---@return integer? code +function io.close(f) end + +---Does nothing: FiveM has no default output file. +function io.flush() end + +---Returns an iterator that yields nothing: FiveM does not read files here. Open the file with `io.open` and use `file:lines` instead. +---@param filename? string +---@param ... read_format +---@return fun(): any, ...any iterator +function io.lines(filename, ...) end + +---Opens a file in the given mode (default "r"). Returns the handle, or nil plus an error message and code. +---@param filename string +---@param mode? open_mode +---@return file? handle +---@return string? err +---@return integer? code +---@nodiscard +function io.open(filename, mode) end + +---Starts `prog` in a separate process and returns a handle to read from ("r", default) or write to ("w") it. Not available on every platform. +---@param prog string +---@param mode? popen_mode +---@return file? handle +---@return string? err +function io.popen(prog, mode) end + +---Returns a handle to a temporary file opened in update mode that is removed when the program ends. +---@return file handle +---@nodiscard +function io.tmpfile() end + +---Returns "file" for an open handle, "closed file" for a closed one and nil for anything else. +---@param obj any +---@return "file"|"closed file"|nil kind +---@nodiscard +function io.type(obj) end + +---Prints its arguments to the server console like `print`: FiveM has no default output file. +---@param ... string|number +function io.write(...) end + +---@class oslib +os = {} + +---Returns an approximation of the CPU time used by the program, in seconds. +---@return number seconds +---@nodiscard +function os.clock() end + +---Formats a time (default: now) using strftime-style `format`. A leading "!" selects UTC and the format "*t" returns a table of date fields. +---@param format? string +---@param time? integer +---@return string|osdate result +---@nodiscard +function os.date(format, time) end + +---Returns the number of seconds from time `t1` to time `t2`. +---@param t2 integer +---@param t1 integer +---@return number seconds +---@nodiscard +function os.difftime(t2, t1) end + +---Runs `command` through the system shell and returns success, "exit" or "signal", and the status code. Without a command it reports whether a shell is available. +---@param command? string +---@return boolean? ok +---@return string? exitcode +---@return integer? code +function os.execute(command) end + +---Returns the value of an environment variable, or nil when it is not defined. +---@param varname string +---@return string? value +---@nodiscard +function os.getenv(varname) end + +---Deletes a file or an empty directory. Returns true, or nil plus an error message and code. +---@param filename string +---@return boolean? ok +---@return string? err +---@return integer? code +function os.remove(filename) end + +---Renames or moves a file or directory. Returns true, or nil plus an error message and code. +---@param oldname string +---@param newname string +---@return boolean? ok +---@return string? err +---@return integer? code +function os.rename(oldname, newname) end + +---Sets the program locale for the given category, or queries it when `locale` is nil. Returns the locale name, or nil on failure. +---@param locale? string +---@param category? locale_category +---@return string? name +function os.setlocale(locale, category) end + +---Returns the current time as a timestamp, or the timestamp described by the given date table. +---@param date? osdate +---@return integer timestamp +---@nodiscard +function os.time(date) end + +---Returns a file name that can be used for a temporary file. +---@return string name +---@nodiscard +function os.tmpname() end diff --git a/crates/qbx_lua_analysis/src/checks/globals.rs b/crates/qbx_lua_analysis/src/checks/globals.rs index 02e3e3e..bf090a8 100644 --- a/crates/qbx_lua_analysis/src/checks/globals.rs +++ b/crates/qbx_lua_analysis/src/checks/globals.rs @@ -7,7 +7,7 @@ use crate::diagnostic::Tag; use crate::env::builtins; use crate::rules; use crate::scope::{GlobalRef, GlobalRefKind, Resolved, MAIN_CHUNK}; -use crate::side_guard::SideRegions; +use crate::side_guard::{other, SideRegions}; /// Fields ox_lib adds to standard library tables when it is imported. const OX_LIB_STD_EXTENSIONS: &[(&str, &str)] = &[ @@ -56,6 +56,10 @@ fn check_read(input: &FileInput, global: &GlobalRef, regions: &SideRegions, sink let place = if guarded.is_some() { "code that only runs on the" } else { "a" }; let unit = if guarded.is_some() { "" } else { " script" }; if let Some(builtin) = builtins().get(name) { + // A shared script also runs where a standard library is missing, unless a guard keeps the + // code off that side. Natives and CfxLua globals are left to the side calling the code. + let missing_from_shared = + builtin.library && builtin.side != Side::Shared && guarded.is_none() && input.side == Some(Side::Shared); if !builtin.side.is_available_on(side) { sink.report( rules::NATIVE_WRONG_SIDE, @@ -66,6 +70,16 @@ fn check_read(input: &FileInput, global: &GlobalRef, regions: &SideRegions, sink side.label() ), ); + } else if missing_from_shared { + sink.report( + rules::NATIVE_WRONG_SIDE, + global.span, + format!( + "'{name}' only exists on the {}, but this shared script also runs on the {}", + builtin.side.label(), + other(builtin.side).label() + ), + ); } else if builtin.deprecated { sink.report_with( rules::DEPRECATED, diff --git a/crates/qbx_lua_analysis/src/env.rs b/crates/qbx_lua_analysis/src/env.rs index 8511ca1..b5e4a6d 100644 --- a/crates/qbx_lua_analysis/src/env.rs +++ b/crates/qbx_lua_analysis/src/env.rs @@ -1,6 +1,6 @@ use std::sync::OnceLock; -use qbx_fivem_data::{Side, STUBS}; +use qbx_fivem_data::{Side, Stub, STUBS}; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::{parse, Comment, CommentKind, SmolStr}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -8,6 +8,8 @@ use rustc_hash::{FxHashMap, FxHashSet}; #[derive(Debug)] pub struct BuiltinGlobal { pub side: Side, + /// Whether it is a Lua standard library, such as `os`, rather than a CfxLua addition. + pub library: bool, pub fields: FxHashSet, pub deprecated: bool, pub deprecated_fields: FxHashSet, @@ -40,16 +42,18 @@ impl Builtins { self.globals.iter() } - fn entry(&mut self, name: &SmolStr, side: Side) -> &mut BuiltinGlobal { + fn entry(&mut self, name: &SmolStr, side: Side, library: bool) -> &mut BuiltinGlobal { self.globals.entry(name.clone()).or_insert_with(|| BuiltinGlobal { side, + library, fields: FxHashSet::default(), deprecated: false, deprecated_fields: FxHashSet::default(), }) } - fn load_stub(&mut self, source: &str, side: Side) { + fn load_stub(&mut self, stub: &Stub) { + let source = stub.source; let chunk = parse(source); for stmt in &chunk.block.stmts { let deprecated = leading_doc_lines(source, &chunk.comments, stmt.span.start) @@ -58,15 +62,15 @@ impl Builtins { match &stmt.kind { StmtKind::Function { name, .. } => { let member = name.path.first().or(name.method.as_ref()); - self.define(&name.base.text, member.map(|m| &m.text), side, deprecated); + self.define(stub, &name.base.text, member.map(|m| &m.text), deprecated); } StmtKind::Assign { targets, .. } => { for target in targets { match &target.kind { - ExprKind::Name(name) => self.define(&name.text, None, side, deprecated), + ExprKind::Name(name) => self.define(stub, &name.text, None, deprecated), ExprKind::Field { base, name, .. } => { if let ExprKind::Name(base) = &base.kind { - self.define(&base.text, Some(&name.text), side, deprecated); + self.define(stub, &base.text, Some(&name.text), deprecated); } } _ => {} @@ -78,8 +82,8 @@ impl Builtins { } } - fn define(&mut self, name: &SmolStr, field: Option<&SmolStr>, side: Side, deprecated: bool) { - let global = self.entry(name, side); + fn define(&mut self, stub: &Stub, name: &SmolStr, field: Option<&SmolStr>, deprecated: bool) { + let global = self.entry(name, stub.side, stub.library); match field { Some(field) => { global.fields.insert(field.clone()); @@ -97,10 +101,10 @@ pub fn builtins() -> &'static Builtins { BUILTINS.get_or_init(|| { let mut builtins = Builtins::default(); for stub in STUBS { - builtins.load_stub(stub.source, stub.side); + builtins.load_stub(stub); } for name in EXTRA_GLOBALS { - builtins.entry(&SmolStr::new_static(name), Side::Shared); + builtins.entry(&SmolStr::new_static(name), Side::Shared, false); } builtins }) @@ -196,6 +200,13 @@ mod tests { assert!(!builtins.is_defined("TriggerServerEvent", Side::Server)); assert!(builtins.is_defined("TriggerClientEvent", Side::Server)); assert!(!builtins.is_defined("TriggerClientEvent", Side::Client)); + for name in ["os", "io"] { + let library = builtins.closed_table(name).unwrap(); + assert_eq!((library.side, library.library), (Side::Server, true), "{name}"); + } + assert!(builtins.get("string").unwrap().library); + assert!(!builtins.get("Citizen").unwrap().library); + assert!(builtins.closed_table("os").unwrap().fields.contains("nanotime")); assert!(builtins.closed_table("string").unwrap().fields.contains("format")); assert!(builtins.closed_table("Citizen").unwrap().fields.contains("Wait")); assert!(builtins.closed_table("math").is_none()); diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 6680e33..52b108e 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -198,7 +198,7 @@ pub static RULES: &[Rule] = &[ rule(DEPRECATED, Style, WARN, false, "A deprecated runtime function is used."), rule(LOOP_NEVER_YIELDS, FiveM, ERROR, false, "An infinite loop has no Wait and will freeze the game or server thread."), rule(SOURCE_AFTER_YIELD, FiveM, WARN, false, "The global 'source' is read after a yield or inside a deferred callback, where it may belong to another event."), - rule(NATIVE_WRONG_SIDE, FiveM, ERROR, false, "A client-only native is called from a server script, or the reverse."), + rule(NATIVE_WRONG_SIDE, FiveM, ERROR, false, "A native or runtime global is used on a side that lacks it, such as a client-only native in a server script, or the server's `os` and `io` libraries in client or unguarded shared code."), rule(CITIZEN_PREFIX, FiveM, INFO, true, "Citizen.Wait/CreateThread/SetTimeout have shorter global aliases."), rule(HASH_LITERAL, Performance, INFO, true, "GetHashKey/joaat of a string literal can be a compile-time `hash` literal."), rule(DEPRECATED_NATIVE_USAGE, Performance, INFO, true, "A slow legacy native pattern has a faster modern replacement."), diff --git a/crates/qbx_lua_analysis/src/side_guard.rs b/crates/qbx_lua_analysis/src/side_guard.rs index 1f727bd..9d08a60 100644 --- a/crates/qbx_lua_analysis/src/side_guard.rs +++ b/crates/qbx_lua_analysis/src/side_guard.rs @@ -6,7 +6,8 @@ use rustc_hash::FxHashMap; /// Parts of a file that only run on one side, whatever the manifest says about the file: /// the branches of `if IsDuplicityVersion() then ... else ... end`, of `lib.context == 'server'`, -/// and the code after `if not IsDuplicityVersion() then return end`. +/// the code after `if not IsDuplicityVersion() then return end`, and the right side of +/// `IsDuplicityVersion() and ...`. #[derive(Debug, Default)] pub struct SideRegions { regions: Vec<(Span, Side)>, @@ -46,7 +47,7 @@ impl SideRegions { } } -fn other(side: Side) -> Side { +pub(crate) fn other(side: Side) -> Side { match side { Side::Client => Side::Server, Side::Server => Side::Client, @@ -182,4 +183,14 @@ impl<'ast> Visitor<'ast> for Finder { } visit::walk_stmt(self, stmt); } + + fn visit_expr(&mut self, expr: &'ast Expr) { + // The right side of `isServer and os.time()` only runs where the left side is true. + if let ExprKind::Binary { op: BinOp::And, lhs, rhs, .. } = &expr.kind { + if let Some((side, _)) = side_when_true(lhs, &self.flags) { + self.regions.push((rhs.span, side)); + } + } + visit::walk_expr(self, expr); + } } diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 72142dc..f6ac228 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -264,6 +264,8 @@ fn side_checks_follow_is_duplicity_version() { assert_eq!(client("if IsDuplicityVersion() then\n print(PlayerPedId())\nend"), wrong, "server-only branch"); assert_eq!(client("if not IsDuplicityVersion() then return end\nTriggerClientEvent('a', -1)"), none); assert_eq!(client("local isServer = IsDuplicityVersion()\nif isServer then TriggerClientEvent('a', -1) end"), none); + assert_eq!(client("print(IsDuplicityVersion() and GetPlayers() or {})"), none); + assert_eq!(client("print(IsDuplicityVersion() or GetPlayers())"), wrong); assert_eq!( client("local isServer = IsDuplicityVersion()\nif not isServer then TriggerClientEvent('a', -1) end"), wrong @@ -282,6 +284,69 @@ fn side_checks_follow_is_duplicity_version() { assert_eq!(project("TriggerClientEvent('sync:push', -1)", Side::Server, &[handler]), ["fivem/event-wrong-side"]); } +#[test] +fn server_libraries_are_missing_on_the_client() { + let wrong_side = |source: &str, side: Option| call_messages("fivem/native-wrong-side", source, side, &[]); + let (client, server, shared) = (Some(Side::Client), Some(Side::Server), Some(Side::Shared)); + let none = Vec::::new(); + + let os_on_client = "'os' only exists on the server, but this is a client script"; + assert_eq!(wrong_side("print(os.time())", client), [os_on_client]); + assert_eq!(wrong_side("local now = os.microtime", client), [os_on_client], "CfxLua's additions"); + assert_eq!( + wrong_side("local file = io.open('data.json')", client), + ["'io' only exists on the server, but this is a client script"] + ); + assert_eq!(wrong_side("print(os.time(), io.open('data.json'), os.createdir('logs'))", server), none); + assert_eq!(wrong_side("print(os.time())", None), none, "a file no manifest lists"); + assert_eq!(wrong_side("if IsDuplicityVersion() then\n print(os.time())\nend", client), none); + + assert_eq!( + wrong_side("print(os.time())", shared), + ["'os' only exists on the server, but this shared script also runs on the client"] + ); + assert_eq!(wrong_side("print(string.format('%d', math.floor(1.5)))", shared), none); + assert_eq!(wrong_side("TriggerClientEvent('a', -1)", shared), none, "CfxLua globals follow the calling side"); + + for source in [ + "if IsDuplicityVersion() then\n print(os.time())\nend", + "if IsDuplicityVersion() and os.time() > 0 then\n print(1)\nend", + "local isServer = IsDuplicityVersion()\nprint(isServer and os.time() or GetGameTimer())", + "if not IsDuplicityVersion() then return end\nprint(os.time())", + "local function now()\n if not IsDuplicityVersion() then return 0 end\n return os.time()\nend\nprint(now())", + "local isServer = IsDuplicityVersion()\nif isServer then print(os.time()) end", + "local isServer = IsDuplicityVersion()\nif not isServer then return end\nprint(os.time())", + "if not IsDuplicityVersion() then\n print(1)\nelse\n print(os.time())\nend", + "if lib.context == 'server' then\n print(os.time())\nend", + "if lib.context ~= 'client' then\n print(os.time())\nend", + ] { + assert_eq!(wrong_side(source, shared), none, "only the server runs: {source}"); + } + + let os_in_client_code = "'os' only exists on the server, but this is code that only runs on the client"; + for source in [ + "if not IsDuplicityVersion() then\n print(os.time())\nend", + "if IsDuplicityVersion() then return end\nprint(os.time())", + "local isServer = IsDuplicityVersion()\nif not isServer then print(os.time()) end", + "print(not IsDuplicityVersion() and os.time())", + "if IsDuplicityVersion() then\n print(1)\nelse\n print(os.time())\nend", + "if lib.context == 'client' then\n print(os.time())\nend", + ] { + assert_eq!(wrong_side(source, shared), [os_in_client_code], "only the client runs: {source}"); + } +} + +#[test] +fn server_libraries_have_the_fields_fivem_gives_them() { + let undefined = |source: &str| call_messages("undefined-field", source, Some(Side::Server), &[]); + assert_eq!(undefined("os.exit(1)"), ["'os' has no field 'exit'"]); + assert_eq!(undefined("print(io.read('l'))"), ["'io' has no field 'read'"]); + assert_eq!( + undefined("print(os.createdir('logs'), os.nanotime(), io.readdir('logs'), io.write('a'))"), + Vec::::new() + ); +} + #[test] fn exports_are_checked_against_their_definition() { let other = (Some(Side::Server), "local function getPlayer(id) return id end\nexports('GetPlayer', getPlayer)"); @@ -383,7 +448,7 @@ fn clean_snippets_stay_clean() { "print(Citizen.InvokeNative2(0x1), Citizen.ResultAsObject2(msgpack.unpack))", "Global = Global or {}\nfunction Global.helper() end", "local function mayWait() end\nwhile true do\n mayWait()\nend", - "repeat\n local line = io.read()\nuntil line == nil", + "repeat\n local line = coroutine.yield()\nuntil line == nil", ] { assert_eq!(codes(source), Vec::<&str>::new(), "{source}"); } diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 459583c..f395176 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -9609,21 +9609,22 @@ local entries = io.readdir('.') local length = utf8.strlenutf8('a') print(nano, delta, clock, trimmed, kind, entries, length) "; - client.open_with(CLIENT, text); + // FiveM only gives the server `os` and `io`. + client.open_with(SERVER, text); for (needle, expected) in [ ("nano = os", "nano: integer\n"), ("delta = os", "delta: integer\n"), ("clock = os", "clock: number\n"), ("trimmed = string", "trimmed: string\n"), ("kind = table", "kind: string\n"), - ("entries = io", "entries: string[]\n"), + ("entries = io", "entries: directory? {"), ("length = utf8", "length: integer\n"), // The functions CfxLua adds belong to the same table as the ones Lua defines. ("nanotime()", "function oslib.nanotime(): integer"), ("strtrim(", "function stringlib.strtrim(s: string, chars?: string): string"), ] { let (l, c) = pos(text, needle, 0); - let hover = client.hover_text(CLIENT, l, c); + let hover = client.hover_text(SERVER, l, c); assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); } } @@ -10498,6 +10499,38 @@ fn publishes_lint_diagnostics_with_resource_context() { assert!(codes.contains(&"unused-local".to_string()), "{codes:?}"); } +#[test] +fn server_libraries_only_exist_where_the_server_runs_the_code() { + const SHARED: &str = "myresource/shared/config.lua"; + let mut client = Client::start(fixture_root()); + let text = "local now = os.time()\nlocal entries = io.readdir('logs')\nprint(now, entries)\n"; + let mut wrong_side = |file: &str| -> Vec { + client.open_with(file, text); + let found = client.diagnostics_for(file).into_iter().filter(|(code, _)| code == "fivem/native-wrong-side"); + found.map(|(_, line)| line).collect() + }; + assert_eq!(wrong_side(CLIENT), [0, 1]); + assert_eq!(wrong_side(SHARED), [0, 1]); + assert!(wrong_side(SERVER).is_empty()); + client.open_with(SHARED, &format!("if not IsDuplicityVersion() then return end\n{text}")); + assert!(client.diagnostics_for(SHARED).iter().all(|(code, _)| code != "fivem/native-wrong-side")); + + let (l, c) = pos(text, "os.time", 0); + assert!(client.hover_text(SERVER, l, c).contains("oslib")); + assert!(!client.hover_text(CLIENT, l, c).contains("oslib"), "the client has no `os`"); + + let typed = "print(os.)\n"; + let (l, c) = pos(typed, "os.", 3); + for file in [SERVER, SHARED] { + client.open_with(file, typed); + let labels = client.completion_labels(file, l, c); + assert!(["time", "createdir", "nanotime"].iter().all(|f| labels.iter().any(|l| l == f)), "{file}: {labels:?}"); + assert!(!labels.iter().any(|l| l == "exit"), "FiveM's `os` has no `exit`: {labels:?}"); + } + client.open_with(CLIENT, typed); + assert!(client.completion_labels(CLIENT, l, c).is_empty()); +} + #[test] fn missing_arguments_follow_annotations_in_other_files() { const SHARED: &str = "myresource/shared/config.lua"; diff --git a/docs/reference.md b/docs/reference.md index a992fd3..1419697 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -222,7 +222,17 @@ reach scripts of that side. The side is never guessed from folder names. Recognized runtime guards narrow side checks within a file. Examples include `IsDuplicityVersion()`, a local flag initialized from it, and `lib.context == 'server'`. An early return such as `if not IsDuplicityVersion() then return end` narrows the following code -to the server. Event registrations inside these regions use that effective side. +to the server, and `isServer and os.time()` narrows the right side of the `and`. Event +registrations inside these regions use that effective side. + +FiveM only loads the `io` and `os` libraries on the server. `fivem/native-wrong-side` reports them +in client scripts, and in shared scripts too, since those also run on the client, unless one of +these guards keeps the code on the server. Scripts whose side is unknown are not reported. Natives +and the other runtime globals of one side, such as `TriggerClientEvent`, are only reported in +scripts or guarded code of the other side, as shared code usually calls them where they exist. In +client scripts, `os` and `io` are not completed and have no hover. Their functions are FiveM's: +there is no `os.exit`, `io.input`, `io.output` or `io.read`, and CfxLua adds `os.createdir`, +`io.readdir` and timers such as `os.nanotime`. Where client and server scripts define a global function differently, as two `GetJob`s, a call from code that runs on both sides takes neither definition: what it returns is unknown and passes diff --git a/docs/rules.md b/docs/rules.md index 02b4535..372a7ba 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -66,7 +66,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | Rule | Default | Check | | --- | --- | --- | -| `fivem/native-wrong-side` | error | A native is used on the wrong client/server side. | +| `fivem/native-wrong-side` | error | A native or runtime global is used on a side that lacks it, such as `TriggerClientEvent` in a client script. The `io` and `os` libraries only exist on the server, so they are reported in shared scripts too, unless an `IsDuplicityVersion()` or `lib.context` guard keeps the code on the server. | | `fivem/import-not-declared` | warning | A library global is used without its manifest import on that side. | | `fivem/loop-never-yields` | error | An apparently infinite loop has no recognized yield or exit. | | `fivem/source-after-yield` | warning | Global `source` is read after a yield or in a deferred callback. | From b6deb0234be3294cb931ba20b74b46dc07bccd8a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:35:50 +0200 Subject: [PATCH 082/193] fix(analysis): make loop variables visible from the end of the loop header --- crates/qbx_lua_analysis/src/scope.rs | 8 ++++++-- crates/qbx_lua_ls/tests/lsp.rs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/qbx_lua_analysis/src/scope.rs b/crates/qbx_lua_analysis/src/scope.rs index e7d830c..c6dc7c9 100644 --- a/crates/qbx_lua_analysis/src/scope.rs +++ b/crates/qbx_lua_analysis/src/scope.rs @@ -344,7 +344,10 @@ impl Resolver { self.expr(step); } self.scopes.push(Scope { names: Vec::new() }); - self.declare(var, LocalKind::LoopVar, None, body.span.start, stmt.span.end, true); + // Visible from the end of the header, as parameters are from the `)`, so a comment + // above the first statement of the body, like a `---@cast`, sees the variable. + let header_end = step.as_ref().unwrap_or(limit).span.end; + self.declare(var, LocalKind::LoopVar, None, header_end, stmt.span.end, true); self.block(body, stmt.span.end); self.scopes.pop(); } @@ -353,8 +356,9 @@ impl Resolver { self.expr(expr); } self.scopes.push(Scope { names: Vec::new() }); + let header_end = exprs.last().map_or(body.span.start, |expr| expr.span.end); for name in names { - self.declare(name, LocalKind::LoopVar, None, body.span.start, stmt.span.end, true); + self.declare(name, LocalKind::LoopVar, None, header_end, stmt.span.end, true); } self.block(body, stmt.span.end); self.scopes.pop(); diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index f395176..0c91614 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -3482,6 +3482,29 @@ print(added) -- added on one way } } +#[test] +fn casts_above_the_first_statement_of_a_loop_change_its_variables() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type table +local list = {} +for _, item in ipairs(list) do + ---@cast item string + print(item) -- generic +end +for i = 1, 2 do + ---@cast i string + print(i) -- numeric +end +"; + client.open_with(CLIENT, text); + for (needle, expected) in [("item) -- generic", "item: string\n"), ("i) -- numeric", "i: string\n")] { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + #[test] fn casts_that_add_or_remove_types_keep_the_guards_before_them() { let mut client = Client::start(fixture_root()); From 485121ad01cf8699f1bec796ad436556243d6072 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:36:22 +0200 Subject: [PATCH 083/193] feat(rules): add newline-call --- crates/qbx_lua_analysis/src/checks/layout.rs | 58 +++++++++++++++++++ crates/qbx_lua_analysis/src/checks/mod.rs | 2 + crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 5 ++ crates/qbx_lua_analysis/tests/rules.rs | 29 ++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 9 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 crates/qbx_lua_analysis/src/checks/layout.rs diff --git a/crates/qbx_lua_analysis/src/checks/layout.rs b/crates/qbx_lua_analysis/src/checks/layout.rs new file mode 100644 index 0000000..5c1366b --- /dev/null +++ b/crates/qbx_lua_analysis/src/checks/layout.rs @@ -0,0 +1,58 @@ +//! Checks of how code is laid out over lines: a call whose arguments start a line of their own. + +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::{LineIndex, Span}; + +use super::{FileInput, Sink}; +use crate::rules; + +pub(super) fn check(input: &FileInput, sink: &mut Sink) { + if sink.enabled(rules::NEWLINE_CALL).is_none() { + return; + } + let lines = LineIndex::new(input.source); + NewlineCalls { source: input.source, lines: &lines, sink }.visit_block(&input.chunk.block); +} + +/// `newline-call`: Lua reads a line that starts with `(` as the arguments of a call to whatever +/// ends the line above, so `local f = g` followed by `(h):run()` calls `g`. Like lua-language-server, +/// only arguments that could also stand as a statement of their own are reported: one expression in +/// parentheses whose result is then indexed, as by `.x`, `[k]` or `:m()`, or called, as an +/// immediately invoked function is. +struct NewlineCalls<'a, 's> { + source: &'a str, + lines: &'a LineIndex, + sink: &'a mut Sink<'s>, +} + +impl NewlineCalls<'_, '_> { + fn continued(&mut self, call: &Expr) { + let ExprKind::Call { callee, args, args_span, style: CallStyle::Paren } = &call.kind else { return }; + if args.len() != 1 || self.lines.line_of(callee.span.end) == self.lines.line_of(args_span.start) { + return; + } + let text = callee.span.text(self.source); + let callee = match text.contains('\n') || text.chars().count() > 40 { + true => "the expression".to_string(), + false => format!("'{text}'"), + }; + let message = format!( + "{callee} on the line above is called with the parentheses on this line; put a ';' before the '(' if this line starts a new statement" + ); + self.sink.report(rules::NEWLINE_CALL, Span::new(call.span.start, args_span.end), message); + } +} + +impl<'ast> Visitor<'ast> for NewlineCalls<'_, '_> { + fn visit_expr(&mut self, expr: &'ast Expr) { + match &expr.kind { + ExprKind::Field { base, .. } | ExprKind::Index { base, .. } | ExprKind::MethodCall { base, .. } => { + self.continued(base) + } + ExprKind::Call { callee, .. } => self.continued(callee), + _ => {} + } + visit::walk_expr(self, expr); + } +} diff --git a/crates/qbx_lua_analysis/src/checks/mod.rs b/crates/qbx_lua_analysis/src/checks/mod.rs index 155f9e5..be34280 100644 --- a/crates/qbx_lua_analysis/src/checks/mod.rs +++ b/crates/qbx_lua_analysis/src/checks/mod.rs @@ -4,6 +4,7 @@ mod docs; mod fivem; mod flow; mod globals; +mod layout; mod locale; mod locals; pub mod manifest; @@ -107,6 +108,7 @@ pub fn check_file(input: &FileInput) -> Vec { } locals::check(input, &mut sink); flow::check(input, &mut sink); + layout::check(input, &mut sink); docs::check(input, &mut sink); globals::check(input, &mut sink); arity::check(input, &mut sink); diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 915503e..39c5279 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -53,6 +53,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "need-check-nil", "inject-field", "undefined-field", + "newline-call", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 52b108e..3bbb4cf 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -67,6 +67,7 @@ pub const SHADOWED_LOCAL: &str = "shadowed-local"; pub const UNREACHABLE_CODE: &str = "unreachable-code"; pub const EMPTY_BLOCK: &str = "empty-block"; pub const UNBALANCED_ASSIGNMENTS: &str = "unbalanced-assignments"; +pub const NEWLINE_CALL: &str = "newline-call"; pub const DUPLICATE_INDEX: &str = "duplicate-index"; pub const DUPLICATE_SET_FIELD: &str = "duplicate-set-field"; pub const DUPLICATE_ARGUMENT: &str = "duplicate-argument"; @@ -161,6 +162,7 @@ pub static RULES: &[Rule] = &[ rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a return, break or goto and can never run."), rule(EMPTY_BLOCK, Style, INFO, false, "A block has no statements."), rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment or `local` statement has more values than targets, or leaves targets without a value."), + rule(NEWLINE_CALL, Suspicious, WARN, false, "A parenthesized expression that starts a line, and is indexed or called, is read as the argument of a call to the expression that ends the line above."), rule(DUPLICATE_INDEX, Suspicious, WARN, false, "A table constructor sets the same key twice."), rule(DUPLICATE_SET_FIELD, Suspicious, WARN, false, "A function is assigned to the same table field twice in one block of a file, so the second replaces the first."), rule(DUPLICATE_ARGUMENT, Correctness, ERROR, false, "Two parameters of one function share a name."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 686ae91..dbed718 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -44,3 +44,4 @@ shared/config.lua:13:11 warning duplicate-doc-alias shared/config.lua:17:11 warning duplicate-doc-field shared/config.lua:19:11 warning undefined-doc-param shared/config.lua:24:10 warning duplicate-set-field +shared/config.lua:29:18 warning newline-call diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 332ee7e..89c3102 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -24,3 +24,8 @@ end function Config.Notify(message) print('replaced', message) end + +local item = Config.Item +local fallback = print +(item):Use() +print(fallback) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index f6ac228..116b09b 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1358,3 +1358,32 @@ fn fields_declared_twice_for_one_class() { assert_eq!(reported_lines(quiet, "duplicate-doc-field"), Vec::::new(), "{quiet}"); } } + +#[test] +fn parentheses_that_continue_the_line_above() { + let source = "local a = print +(\"x\"):len() +local b = print +(\"x\", \"y\"):len() +local c = print +(\"x\") +local d = print +(function() end)() +local e = print -- note +(a).f = 1 +local f = a:m +(\"x\"):len() +local g = print +():len() +local h = print(\"x\"):len() +local i = Config.Some.Very.Long.Path.To.A.Function.Here +(1)() +print(b, c, d, e, f, g, h, i)"; + assert_eq!(reported_lines(source, "newline-call"), [1, 7, 9, 16]); + let messages: Vec = findings(source, "newline-call").into_iter().map(|(_, m)| m).collect(); + assert_eq!( + messages[0], + "'print' on the line above is called with the parentheses on this line; put a ';' before the '(' if this line starts a new statement" + ); + assert!(messages[3].starts_with("the expression on the line above is called"), "{}", messages[3]); +} diff --git a/docs/reference.md b/docs/reference.md index 1419697..d4161e9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field` and `undefined-field`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field` and `newline-call`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 372a7ba..8830de4 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -26,6 +26,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `unreachable-code` | warning | Code follows a control-flow statement that exits the path. | | `empty-block` | info | A block has no statements. | | `unbalanced-assignments` | warning | An assignment or `local` statement has mismatched target and value counts, as in `local a, b = 1`. A last value that is a call or `...` gives any number of values. | +| `newline-call` | warning | A line starts with an expression in parentheses that is then indexed or called, such as `(value):method()` or `(function() end)()`, so Lua reads it as the argument of a call to the expression that ends the line above. A `;` before the `(` starts a new statement. | | `duplicate-index` | warning | A table constructor assigns the same key twice. | | `duplicate-set-field` | warning | A function is assigned to the same field of the same table twice in one block of a file, so the second replaces the first. A block is the main chunk, a function body, one branch of an `if`, or the code after an `if` with a branch that returns, such as an `IsDuplicityVersion()` or `lib.context` guard. A read of the field between the two, as a wrapper has, or an assignment to the table's variable or to a field it is in, also separates them. Definitions in other files or in `---@meta` files are not compared. | | `duplicate-argument` | error | Function parameters share a name. | From 93200c15bebe3ba2aab950e791de334f8a0a74cf Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:36:51 +0200 Subject: [PATCH 084/193] feat(rules): add trailing-space --- crates/qbx_lua_analysis/src/checks/layout.rs | 51 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 3 ++ crates/qbx_lua_analysis/tests/rules.rs | 34 +++++++++++++ crates/qbx_lua_ls/tests/lsp.rs | 23 +++++++++ docs/reference.md | 11 ++-- docs/rules.md | 1 + 9 files changed, 118 insertions(+), 9 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/layout.rs b/crates/qbx_lua_analysis/src/checks/layout.rs index 5c1366b..69da04d 100644 --- a/crates/qbx_lua_analysis/src/checks/layout.rs +++ b/crates/qbx_lua_analysis/src/checks/layout.rs @@ -1,18 +1,27 @@ -//! Checks of how code is laid out over lines: a call whose arguments start a line of their own. +//! Checks of how code is laid out over lines: a call whose arguments start a line of their own, and +//! whitespace at the end of a line. use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use qbx_lua_syntax::{LineIndex, Span}; +use qbx_lua_syntax::{LineIndex, Span, TokenKind}; use super::{FileInput, Sink}; +use crate::diagnostic::{Fix, TextEdit}; use crate::rules; pub(super) fn check(input: &FileInput, sink: &mut Sink) { - if sink.enabled(rules::NEWLINE_CALL).is_none() { + let calls = sink.enabled(rules::NEWLINE_CALL).is_some(); + let spaces = sink.enabled(rules::TRAILING_SPACE).is_some(); + if !(calls || spaces) { return; } let lines = LineIndex::new(input.source); - NewlineCalls { source: input.source, lines: &lines, sink }.visit_block(&input.chunk.block); + if calls { + NewlineCalls { source: input.source, lines: &lines, sink: &mut *sink }.visit_block(&input.chunk.block); + } + if spaces { + trailing_spaces(input, &lines, sink); + } } /// `newline-call`: Lua reads a line that starts with `(` as the arguments of a call to whatever @@ -56,3 +65,37 @@ impl<'ast> Visitor<'ast> for NewlineCalls<'_, '_> { visit::walk_expr(self, expr); } } + +/// `trailing-space`: spaces or tabs at the end of a line, as lua-language-server reports them. Those +/// inside a comment or a string, such as a long string that spans lines, belong to it and are left +/// alone, as the formatter leaves them. +fn trailing_spaces(input: &FileInput, lines: &LineIndex, sink: &mut Sink) { + let source = input.source; + // The comments and the strings of the file, in the order they are written. + let comments = input.chunk.comments.iter().map(|comment| comment.span); + let strings = + input.chunk.tokens.iter().filter(|token| matches!(token.kind, TokenKind::String | TokenKind::LongString)); + let mut kept: Vec = comments.chain(strings.map(|token| token.span)).collect(); + kept.sort_by_key(|span| span.start); + let is_kept = |offset: u32| { + let next = kept.partition_point(|span| span.start <= offset); + next > 0 && kept[next - 1].contains(offset) + }; + for line in 0..lines.line_count() { + let start = lines.line_start(line) as usize; + let text = source[start..].split('\n').next().unwrap_or_default(); + let text = text.strip_suffix('\r').unwrap_or(text); + let code = text.trim_end_matches([' ', '\t']); + if code.len() == text.len() { + continue; + } + let span = Span::new((start + code.len()) as u32, (start + text.len()) as u32); + if is_kept(span.end - 1) { + continue; + } + let message = if code.is_empty() { "line contains only whitespace" } else { "trailing whitespace" }; + let fix = + Fix { title: "Remove trailing whitespace".into(), edits: vec![TextEdit { span, new_text: String::new() }] }; + sink.report_with(rules::TRAILING_SPACE, span, message, None, Some(fix)); + } +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 39c5279..0ec7666 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -54,6 +54,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "inject-field", "undefined-field", "newline-call", + "trailing-space", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 3bbb4cf..6ad191b 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -66,6 +66,7 @@ pub const REDEFINED_LOCAL: &str = "redefined-local"; pub const SHADOWED_LOCAL: &str = "shadowed-local"; pub const UNREACHABLE_CODE: &str = "unreachable-code"; pub const EMPTY_BLOCK: &str = "empty-block"; +pub const TRAILING_SPACE: &str = "trailing-space"; pub const UNBALANCED_ASSIGNMENTS: &str = "unbalanced-assignments"; pub const NEWLINE_CALL: &str = "newline-call"; pub const DUPLICATE_INDEX: &str = "duplicate-index"; @@ -161,6 +162,7 @@ pub static RULES: &[Rule] = &[ rule(SHADOWED_LOCAL, Style, OFF, false, "A local hides a local from an enclosing scope."), rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a return, break or goto and can never run."), rule(EMPTY_BLOCK, Style, INFO, false, "A block has no statements."), + rule(TRAILING_SPACE, Style, HINT, true, "A line ends in spaces or tabs outside a comment or string."), rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment or `local` statement has more values than targets, or leaves targets without a value."), rule(NEWLINE_CALL, Suspicious, WARN, false, "A parenthesized expression that starts a line, and is indexed or called, is read as the argument of a call to the expression that ends the line above."), rule(DUPLICATE_INDEX, Suspicious, WARN, false, "A table constructor sets the same key twice."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index dbed718..ba8a746 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -45,3 +45,4 @@ shared/config.lua:17:11 warning duplicate-doc-field shared/config.lua:19:11 warning undefined-doc-param shared/config.lua:24:10 warning duplicate-set-field shared/config.lua:29:18 warning newline-call +shared/config.lua:33:17 hint trailing-space diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 89c3102..93b7c9c 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -29,3 +29,6 @@ local item = Config.Item local fallback = print (item):Use() print(fallback) + +local spaced = 1 +print(spaced) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 116b09b..39f3111 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1359,6 +1359,27 @@ fn fields_declared_twice_for_one_class() { } } +/// `source` with every fix that the default rules offer applied. +fn all_fixes(source: &str) -> String { + let chunk = parse(source); + let resolution = resolve(&chunk); + let summary = summarize(source, &chunk, &resolution); + let config = FileConfig::default(); + let input = FileInput { + source, + chunk: &chunk, + resolution: &resolution, + summary: &summary, + config: &config, + side: None, + resource: None, + crossrefs: None, + locale: None, + relative_path: "", + }; + qbx_lua_analysis::apply_fixes(source, &check_file(&input)).0 +} + #[test] fn parentheses_that_continue_the_line_above() { let source = "local a = print @@ -1387,3 +1408,16 @@ print(b, c, d, e, f, g, h, i)"; ); assert!(messages[3].starts_with("the expression on the line above is called"), "{}", messages[3]); } + +#[test] +fn whitespace_at_the_end_of_lines() { + let source = "local a = 1 \nlocal b = 2\t\n \n-- comment \nlocal s = [[x \ny]] \nprint(a, b, s) --[[ c ]] \r\nprint(1) "; + assert_eq!(reported_lines(source, "trailing-space"), [1, 2, 3, 6, 7, 8]); + let messages: Vec = findings(source, "trailing-space").into_iter().map(|(_, m)| m).collect(); + assert_eq!(messages[0], "trailing whitespace"); + assert_eq!(messages[2], "line contains only whitespace"); + assert_eq!( + all_fixes(source), + "local a = 1\nlocal b = 2\n\n-- comment \nlocal s = [[x \ny]]\nprint(a, b, s) --[[ c ]]\r\nprint(1)" + ); +} diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 0c91614..b0fd0af 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -10663,6 +10663,29 @@ fn count_down_loops_get_a_negative_step_as_a_quick_fix() { assert_eq!(edit["newText"], ", -1"); } +#[test] +fn trailing_whitespace_is_removed_by_a_quick_fix() { + let mut client = Client::start(fixture_root()); + let text = "local total = 0 \nprint(total)\n"; + client.open_with(CLIENT, text); + let found = client.diagnostics_for(CLIENT); + assert_eq!(found, [("trailing-space".to_string(), 0)], "{found:?}"); + let uri = client.uri(CLIENT).to_string(); + let diagnostics = client.diagnostics[&uri].clone(); + let actions = client.request( + "textDocument/codeAction", + json!({ "textDocument": { "uri": uri }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "context": { "diagnostics": diagnostics } }), + ); + let fix = + actions.as_array().unwrap().iter().find(|a| a["title"] == "Remove trailing whitespace").expect("quick fix"); + let edit = &fix["edit"]["changes"][&uri][0]; + assert_eq!( + edit["range"], + json!({ "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 17 } }) + ); + assert_eq!(edit["newText"], ""); +} + #[test] fn survives_garbage_input_while_typing() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index d4161e9..3585672 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field` and `newline-call`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call` and `trailing-space`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | @@ -1191,10 +1191,11 @@ Before writing, the formatter compares the input and output tokens and comments. are compared after decoding escapes. A verification failure leaves that file unchanged. Statements with comments the printer cannot place are retained verbatim. Syntax errors also prevent formatting. -`--fix` applies available, non-overlapping fixes and then lints again. The candidate output must -parse before it is written. A source file that changed after analysis is not overwritten. Hash -literal replacements are offered in value positions; a standalone `GetHashKey('adder')` call -cannot become a bare hash statement. +`--fix` applies available, non-overlapping fixes and then lints again, including those of rules +whose findings `--min-severity` leaves out, such as the removal of the whitespace `trailing-space` +reports at the end of lines. The candidate output must parse before it is written. A source file +that changed after analysis is not overwritten. Hash literal replacements are offered in value +positions; a standalone `GetHashKey('adder')` call cannot become a bare hash statement. Editing commands can process several files before encountering an error; they are not a transaction over the entire input. Review the resulting diff. diff --git a/docs/rules.md b/docs/rules.md index 8830de4..58367e1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -25,6 +25,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `shadowed-local` | off | A local hides one in an enclosing scope. | | `unreachable-code` | warning | Code follows a control-flow statement that exits the path. | | `empty-block` | info | A block has no statements. | +| `trailing-space` | hint | A line ends in spaces or tabs, or holds nothing else. Whitespace inside a comment or a string, such as a long string that spans lines, is left alone, as the formatter leaves it. Fix available. | | `unbalanced-assignments` | warning | An assignment or `local` statement has mismatched target and value counts, as in `local a, b = 1`. A last value that is a call or `...` gives any number of values. | | `newline-call` | warning | A line starts with an expression in parentheses that is then indexed or called, such as `(value):method()` or `(function() end)()`, so Lua reads it as the argument of a call to the expression that ends the line above. A `;` before the `(` starts a new statement. | | `duplicate-index` | warning | A table constructor assigns the same key twice. | From 7344c025de227935b1ac7570b8f14186972c8518 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:37:18 +0200 Subject: [PATCH 085/193] feat(rules): add redundant-return --- crates/qbx_lua_analysis/src/checks/flow.rs | 13 +++++++++++++ crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 ++ .../tests/fixtures/bad_resource.expected | 1 + .../tests/fixtures/bad_resource/shared/config.lua | 3 +++ crates/qbx_lua_analysis/tests/rules.rs | 13 +++++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index a877bcc..a75bad4 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -324,6 +324,19 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { self.sink.report(rules::DUPLICATE_ARGUMENT, param.span, format!("duplicate argument '{}'", param.text)); } } + // `redundant-return`: a `return` without values that ends the body, not one inside a block + // of it, which may skip the code after that block. + if let Some(Stmt { kind: StmtKind::Return(values), span }) = func.body.stmts.last() { + if values.is_empty() { + self.sink.report_with( + rules::REDUNDANT_RETURN, + Span::new(span.start, span.start + "return".len() as u32), + "redundant return at the end of the function", + Some(Tag::Unnecessary), + None, + ); + } + } let outer = self.function; self.in_new_block(|flow| { flow.function = flow.block; diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 0ec7666..b83ee36 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -55,6 +55,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "undefined-field", "newline-call", "trailing-space", + "redundant-return", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 6ad191b..1dfbc15 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -65,6 +65,7 @@ pub const UNDEFINED_LABEL: &str = "undefined-label"; pub const REDEFINED_LOCAL: &str = "redefined-local"; pub const SHADOWED_LOCAL: &str = "shadowed-local"; pub const UNREACHABLE_CODE: &str = "unreachable-code"; +pub const REDUNDANT_RETURN: &str = "redundant-return"; pub const EMPTY_BLOCK: &str = "empty-block"; pub const TRAILING_SPACE: &str = "trailing-space"; pub const UNBALANCED_ASSIGNMENTS: &str = "unbalanced-assignments"; @@ -161,6 +162,7 @@ pub static RULES: &[Rule] = &[ rule(REDEFINED_LOCAL, Suspicious, WARN, false, "A local is declared twice in the same scope."), rule(SHADOWED_LOCAL, Style, OFF, false, "A local hides a local from an enclosing scope."), rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a return, break or goto and can never run."), + rule(REDUNDANT_RETURN, Style, HINT, false, "A function ends with a `return` that gives no values, which changes nothing."), rule(EMPTY_BLOCK, Style, INFO, false, "A block has no statements."), rule(TRAILING_SPACE, Style, HINT, true, "A line ends in spaces or tabs outside a comment or string."), rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment or `local` statement has more values than targets, or leaves targets without a value."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index ba8a746..e0fb831 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -46,3 +46,4 @@ shared/config.lua:19:11 warning undefined-doc-param shared/config.lua:24:10 warning duplicate-set-field shared/config.lua:29:18 warning newline-call shared/config.lua:33:17 hint trailing-space +shared/config.lua:36:23 hint redundant-return diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 93b7c9c..cde1e94 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -32,3 +32,6 @@ print(fallback) local spaced = 1 print(spaced) + +local function done() return end +done() diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 39f3111..bc16c8c 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1409,6 +1409,19 @@ print(b, c, d, e, f, g, h, i)"; assert!(messages[3].starts_with("the expression on the line above is called"), "{}", messages[3]); } +#[test] +fn returns_without_values_that_end_a_function() { + let source = "local function a() return end +local function b() if a then return end end +local function c() do return end end +local function d() print(1) return; end +local e = function() return nil end +print(b, c, d, e) +return"; + assert_eq!(reported_lines(source, "redundant-return"), [1, 4]); + assert_eq!(findings(source, "redundant-return")[0].1, "redundant return at the end of the function"); +} + #[test] fn whitespace_at_the_end_of_lines() { let source = "local a = 1 \nlocal b = 2\t\n \n-- comment \nlocal s = [[x \ny]] \nprint(a, b, s) --[[ c ]] \r\nprint(1) "; diff --git a/docs/reference.md b/docs/reference.md index 3585672..cf647bc 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call` and `trailing-space`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space` and `redundant-return`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 58367e1..0b51ec1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -24,6 +24,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `redefined-local` | warning | A local is declared twice in one scope. | | `shadowed-local` | off | A local hides one in an enclosing scope. | | `unreachable-code` | warning | Code follows a control-flow statement that exits the path. | +| `redundant-return` | hint | A `return` without values ends a function body, where the function returns anyway. One inside an `if` or `do` block, which skips the code after it, and one that ends the file are not reported. | | `empty-block` | info | A block has no statements. | | `trailing-space` | hint | A line ends in spaces or tabs, or holds nothing else. Whitespace inside a comment or a string, such as a long string that spans lines, is left alone, as the formatter leaves it. Fix available. | | `unbalanced-assignments` | warning | An assignment or `local` statement has mismatched target and value counts, as in `local a, b = 1`. A last value that is a call or `...` gives any number of values. | From d56a433f7eb77d34a6f6cecea8c9b12bdf1a4446 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:37:56 +0200 Subject: [PATCH 086/193] feat(rules): add duplicate-doc-param --- crates/qbx_lua_analysis/src/checks/docs.rs | 135 +++++++++++++++++- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 2 + .../fixtures/bad_resource/shared/config.lua | 6 + crates/qbx_lua_analysis/tests/rules.rs | 20 +++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 165 insertions(+), 4 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index b504a8a..6b132b5 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,9 +1,11 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented -//! function has, aliases, enums and class fields that one file declares twice for one side, and -//! functions whose parameters and returned values the doc comments leave out. +//! function has, or that one doc comment repeats, aliases, enums and class fields that one file +//! declares twice for one side, and functions whose parameters and returned values the doc +//! comments leave out. use std::cell::OnceCell; use std::fmt; +use std::ops::Range; use std::sync::Arc; use qbx_fivem_data::Side; @@ -30,7 +32,8 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { let globals = sink.enabled(rules::MISSING_GLOBAL_DOC).is_some(); let exported = sink.enabled(rules::MISSING_LOCAL_EXPORT_DOC).is_some(); let incomplete = wanted(rules::INCOMPLETE_SIGNATURE_DOC, &["@param", "@return"]); - if !(params || aliases || fields || globals || exported || incomplete) { + let repeated_params = wanted(rules::DUPLICATE_DOC_PARAM, &["@param"]); + if !(params || aliases || fields || globals || exported || incomplete || repeated_params) { return; } let blocks: Vec = doc_blocks(source, &input.chunk.comments) @@ -53,6 +56,11 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { if fields { duplicate_fields(&blocks, &lines, sink); } + if repeated_params { + for block in &blocks { + duplicate_params(block, &block.bind_groups(source, &lines), &lines, sink); + } + } } /// One doc comment: its `---` lines without the prefix, and what they declare. @@ -79,6 +87,111 @@ impl<'a> DocBlock<'a> { let start = self.comments[index].span.start + 3 + offset as u32; Some((Span::new(start, start + name.len() as u32), name)) } + + /// What line `index` is to lua-language-server. + fn kind(&self, index: usize) -> LineKind<'a> { + if self.lines[index].trim_start().starts_with('|') { + return LineKind::Value; + } + match self.tag(index) { + Some(tag) if LUA_LS_TAGS.contains(&tag) => LineKind::Tag(tag), + _ => LineKind::Comment, + } + } + + /// The lines of the comment in the groups lua-language-server binds together, which decide what + /// a `@field` or `@param` belongs to. A line joins the group of the line above when the two tags + /// allow it: after a `@class`, only fields, operators, overloads and descriptions go on. A + /// comment after code on its line is a group of its own, unless it is a field. + fn bind_groups(&self, source: &str, lines: &LineIndex) -> Vec> { + let mut groups = Vec::new(); + let mut start = 0; + let mut last: Option<(LineKind, u32)> = None; + for index in 0..self.lines.len() { + let kind = self.kind(index); + let line = lines.line_of(self.comments[index].span.start); + // A `---|` line lists another value of the line above, as part of it. + if kind == LineKind::Value { + if let Some((_, last_line)) = last.as_mut() { + *last_line = line; + } + continue; + } + if let Some((last_kind, last_line)) = last { + // LuaLS reads the other comments in between, which a doc comment passes over, as + // descriptions, which anything may follow. + let last_kind = if line == last_line + 1 { last_kind } else { LineKind::Comment }; + if !continues(last_kind, kind) { + groups.push(start..index); + start = index; + } + } + last = Some((kind, line)); + if index == 0 && !starts_line(source, self.comments[0].span.start) && kind != LineKind::Tag("field") { + groups.push(0..1); + (start, last) = (1, None); + } + } + if start < self.lines.len() { + groups.push(start..self.lines.len()); + } + groups + } +} + +/// The tags lua-language-server reads; a line with any other is a description to it. +const LUA_LS_TAGS: &[&str] = &[ + "class", + "type", + "alias", + "param", + "return", + "field", + "generic", + "vararg", + "overload", + "deprecated", + "meta", + "version", + "see", + "diagnostic", + "module", + "async", + "nodiscard", + "as", + "cast", + "operator", + "source", + "enum", + "private", + "protected", + "public", + "package", +]; + +/// What a doc line is to lua-language-server when it groups the lines of a comment. +#[derive(Clone, Copy, PartialEq, Eq)] +enum LineKind<'a> { + /// A line without a tag, or with one lua-language-server does not read, such as `@author`. + Comment, + /// A `---|` line, which lists another value of the line above. + Value, + Tag(&'a str), +} + +/// Whether lua-language-server binds the line `next` to the group of the line `last` above it. +fn continues(last: LineKind, next: LineKind) -> bool { + if next == LineKind::Tag("diagnostic") { + return true; + } + let allowed = match last { + LineKind::Tag("type" | "module" | "enum") => next == LineKind::Comment, + LineKind::Tag("class" | "field" | "operator") => { + matches!(next, LineKind::Comment | LineKind::Tag("field" | "operator" | "overload" | "source")) + } + _ => true, + }; + allowed && next != LineKind::Tag("cast") } fn line_number(lines: &LineIndex, span: Span) -> u32 { @@ -843,3 +956,19 @@ fn duplicate_fields(blocks: &[DocBlock], lines: &LineIndex, sink: &mut Sink) { } } } + +/// `duplicate-doc-param`: two `@param` lines for one name in a group of doc lines, as +/// lua-language-server binds them. Each is reported, with the line of another. +fn duplicate_params(block: &DocBlock, groups: &[Range], lines: &LineIndex, sink: &mut Sink) { + for group in groups { + let params: Vec<(Span, &str)> = + group.clone().filter(|&i| block.tag(i) == Some("param")).filter_map(|i| block.declared(i)).collect(); + for (span, name) in ¶ms { + let Some((other, _)) = params.iter().find(|(other, other_name)| other != span && other_name == name) else { + continue; + }; + let message = format!("duplicate @param '{name}', also on line {}", line_number(lines, *other)); + sink.report(rules::DUPLICATE_DOC_PARAM, *span, message); + } + } +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index b83ee36..4d9ea15 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -56,6 +56,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "newline-call", "trailing-space", "redundant-return", + "duplicate-doc-param", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 1dfbc15..b3d823e 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -80,6 +80,7 @@ pub const UNDEFINED_DOC_NAME: &str = "undefined-doc-name"; pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; +pub const DUPLICATE_DOC_PARAM: &str = "duplicate-doc-param"; pub const MISSING_GLOBAL_DOC: &str = "missing-global-doc"; pub const MISSING_LOCAL_EXPORT_DOC: &str = "missing-local-export-doc"; pub const INCOMPLETE_SIGNATURE_DOC: &str = "incomplete-signature-doc"; @@ -176,6 +177,7 @@ pub static RULES: &[Rule] = &[ rule(UNDEFINED_DOC_PARAM, Correctness, WARN, false, "A LuaCATS @param names no parameter of the function its doc comment documents, or no function follows the comment."), rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), + rule(DUPLICATE_DOC_PARAM, Suspicious, WARN, false, "A doc comment has two @param annotations for the same parameter."), rule(MISSING_GLOBAL_DOC, Style, OFF, false, "A global function has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(MISSING_LOCAL_EXPORT_DOC, Style, OFF, false, "A local function that a module's returned table or exports() exports, or a function passed to exports(), has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(INCOMPLETE_SIGNATURE_DOC, Style, OFF, false, "A function whose doc comment has @param or @return annotations leaves out a parameter or a returned value."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index e0fb831..eb37940 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -47,3 +47,5 @@ shared/config.lua:24:10 warning duplicate-set-field shared/config.lua:29:18 warning newline-call shared/config.lua:33:17 hint trailing-space shared/config.lua:36:23 hint redundant-return +shared/config.lua:39:11 warning duplicate-doc-param +shared/config.lua:40:11 warning duplicate-doc-param diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index cde1e94..f9873b6 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -35,3 +35,9 @@ print(spaced) local function done() return end done() + +---@param amount number +---@param amount integer +function Config.Pay(amount) + print(amount) +end diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index bc16c8c..0bc603d 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1409,6 +1409,26 @@ print(b, c, d, e, f, g, h, i)"; assert!(messages[3].starts_with("the expression on the line above is called"), "{}", messages[3]); } +#[test] +fn params_documented_twice() { + let source = "---@param x number +---@param x string +---@param y number +local function f(x, y) return x, y end +---@param ... any +---@param ... any +local function g(...) return ... end +---@param z number + +---@param z number +local function h(z) return z end +print(f, g, h)"; + assert_eq!(reported_lines(source, "duplicate-doc-param"), [1, 2, 5, 6]); + let messages: Vec = findings(source, "duplicate-doc-param").into_iter().map(|(_, m)| m).collect(); + assert_eq!(messages[0], "duplicate @param 'x', also on line 2"); + assert_eq!(messages[1], "duplicate @param 'x', also on line 1"); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index cf647bc..34dcddc 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space` and `redundant-return`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return` and `duplicate-doc-param`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 0b51ec1..7fe2dbc 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -38,6 +38,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-doc-param` | warning | A `@param` names no parameter of the function its doc comment documents, or no function follows the comment. A doc comment documents the functions of the statement below it, including a local or global function of the file that the statement passes by name, and every function whose parameter list starts on the next line of code; comment lines in between do not separate them, a blank line does. The parameter names of a `@type fun(...)` in the comment also count. `---@meta` files are not checked. | | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | +| `duplicate-doc-param` | warning | A doc comment has two `@param` lines for the same parameter. Each of them is reported. | | `missing-global-doc` | off | A global function, defined with `function Name()` or `Name = function()`, has a parameter that no `@param` names, or a `return` whose values no `@return` covers. One without parameters or returned values has no comment. See [Missing documentation](reference.md#missing-documentation). | | `missing-local-export-doc` | off | The same for a local function that a file exports: assigned by name to a field of a local table that a `return` gives, or passed to `exports('Name', fn)`, and for a function written in an `exports` call. | | `incomplete-signature-doc` | off | A function whose doc comment has `@param` or `@return` lines leaves out a parameter or a returned value. A parameter that the function type its function is passed as names, such as `source` of a handler passed to a documented callback wrapper, needs none. | From 513e4c1006ae8f4d0a41aabf768492997310b702 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:38:23 +0200 Subject: [PATCH 087/193] feat(rules): add doc-field-no-class --- crates/qbx_lua_analysis/src/checks/docs.rs | 53 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 4 ++ crates/qbx_lua_analysis/tests/rules.rs | 50 +++++++++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index 6b132b5..2728e2c 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,7 +1,7 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented //! function has, or that one doc comment repeats, aliases, enums and class fields that one file -//! declares twice for one side, and functions whose parameters and returned values the doc -//! comments leave out. +//! declares twice for one side, fields that follow no class, and functions whose parameters and +//! returned values the doc comments leave out. use std::cell::OnceCell; use std::fmt; @@ -33,7 +33,8 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { let exported = sink.enabled(rules::MISSING_LOCAL_EXPORT_DOC).is_some(); let incomplete = wanted(rules::INCOMPLETE_SIGNATURE_DOC, &["@param", "@return"]); let repeated_params = wanted(rules::DUPLICATE_DOC_PARAM, &["@param"]); - if !(params || aliases || fields || globals || exported || incomplete || repeated_params) { + let classless_fields = wanted(rules::DOC_FIELD_NO_CLASS, &["@field"]); + if !(params || aliases || fields || globals || exported || incomplete || repeated_params || classless_fields) { return; } let blocks: Vec = doc_blocks(source, &input.chunk.comments) @@ -56,9 +57,15 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { if fields { duplicate_fields(&blocks, &lines, sink); } - if repeated_params { + if repeated_params || classless_fields { for block in &blocks { - duplicate_params(block, &block.bind_groups(source, &lines), &lines, sink); + let groups = block.bind_groups(source, &lines); + if repeated_params { + duplicate_params(block, &groups, &lines, sink); + } + if classless_fields { + classless_fields_of(block, &groups, sink); + } } } } @@ -972,3 +979,39 @@ fn duplicate_params(block: &DocBlock, groups: &[Range], lines: &LineIndex } } } + +/// `doc-field-no-class`: a `@field` without a `@class` before it in its group of doc lines, which +/// lua-language-server then gives to no class. qbx-lua-ls still gives it to a `@class` higher up in +/// the comment, so the message names what separates the two. +fn classless_fields_of(block: &DocBlock, groups: &[Range], sink: &mut Sink) { + for group in groups { + let class = group.clone().position(|i| block.tag(i) == Some("class")).map(|position| group.start + position); + for index in group.clone().filter(|&i| block.tag(i) == Some("field")) { + if class.is_some_and(|class| class < index) { + continue; + } + let Some((span, name)) = block.declared(index) else { continue }; + let message = match (0..group.start).any(|i| block.tag(i) == Some("class")) { + false => format!("field '{name}' has no @class above it"), + true => match separator(block, group.start) { + Some(separator) => format!("{separator} separates field '{name}' from its @class"), + None => format!("field '{name}' does not directly follow its @class"), + }, + }; + sink.report(rules::DOC_FIELD_NO_CLASS, span, message); + } + } +} + +/// The line that starts a new group of doc lines at line `start` of `block`, as a message names it: +/// a tag that may not follow a class or its fields, like `@deprecated`, or one that nothing may +/// follow, like `@type`. +fn separator(block: &DocBlock, start: usize) -> Option { + if let LineKind::Tag(tag) = block.kind(start) { + if tag != "field" { + return Some(format!("the @{tag} line")); + } + } + let above = start.checked_sub(1)?; + block.tag(above).filter(|tag| *tag != "class").map(|tag| format!("the @{tag} line")) +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 4d9ea15..463797b 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -57,6 +57,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "trailing-space", "redundant-return", "duplicate-doc-param", + "doc-field-no-class", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index b3d823e..77e35f9 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -81,6 +81,7 @@ pub const UNDEFINED_DOC_PARAM: &str = "undefined-doc-param"; pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; pub const DUPLICATE_DOC_PARAM: &str = "duplicate-doc-param"; +pub const DOC_FIELD_NO_CLASS: &str = "doc-field-no-class"; pub const MISSING_GLOBAL_DOC: &str = "missing-global-doc"; pub const MISSING_LOCAL_EXPORT_DOC: &str = "missing-local-export-doc"; pub const INCOMPLETE_SIGNATURE_DOC: &str = "incomplete-signature-doc"; @@ -178,6 +179,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_DOC_ALIAS, Suspicious, WARN, false, "An @alias or @enum reuses the name of an alias, enum or class that the same file declares for the same side."), rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), rule(DUPLICATE_DOC_PARAM, Suspicious, WARN, false, "A doc comment has two @param annotations for the same parameter."), + rule(DOC_FIELD_NO_CLASS, Correctness, WARN, false, "A @field does not directly follow the @class it belongs to, so lua-language-server attaches it to no class."), rule(MISSING_GLOBAL_DOC, Style, OFF, false, "A global function has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(MISSING_LOCAL_EXPORT_DOC, Style, OFF, false, "A local function that a module's returned table or exports() exports, or a function passed to exports(), has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(INCOMPLETE_SIGNATURE_DOC, Style, OFF, false, "A function whose doc comment has @param or @return annotations leaves out a parameter or a returned value."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index eb37940..2359d5a 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -49,3 +49,4 @@ shared/config.lua:33:17 hint trailing-space shared/config.lua:36:23 hint redundant-return shared/config.lua:39:11 warning duplicate-doc-param shared/config.lua:40:11 warning duplicate-doc-param +shared/config.lua:45:11 warning doc-field-no-class diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index f9873b6..a5b10a9 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -41,3 +41,7 @@ done() function Config.Pay(amount) print(amount) end + +---@field orphan number +local orphaned = {} +print(orphaned) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 0bc603d..1807bc0 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1429,6 +1429,56 @@ print(f, g, h)"; assert_eq!(messages[1], "duplicate @param 'x', also on line 1"); } +#[test] +fn fields_must_directly_follow_their_class() { + let source = "---@field orphan number +local t = {} +---@class A +---@field a number +---Description +---@field b number +---@diagnostic disable-next-line: unused-local +---@field c number +---@operator add: A +---@overload fun(): A +---@field d number +---@deprecated +---@field e number +local A = {} +---@type A +---@field f number +local tA = {} +---@class B +---@param p number +---@field g number +local function fB(p) return p end +---@class C +---@author someone +---@field h number +local C = {} +---@class D +-- plain comment +---@field i number +local D = {} +local tail = {} ---@field j number +---@class E +---@field k number +---| 'x' +---@field l number +local E = {} +print(t, A, tA, fB, C, D, tail, E)"; + assert_eq!( + findings(source, "doc-field-no-class"), + [ + (1, "field 'orphan' has no @class above it".to_string()), + (13, "the @deprecated line separates field 'e' from its @class".to_string()), + (16, "field 'f' has no @class above it".to_string()), + (20, "the @param line separates field 'g' from its @class".to_string()), + (30, "field 'j' has no @class above it".to_string()), + ] + ); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index 34dcddc..c14dc66 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return` and `duplicate-doc-param`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param` and `doc-field-no-class`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 7fe2dbc..e5c7501 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -39,6 +39,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-alias` | warning | An `@alias` or `@enum` repeats the name of an alias, enum or class that the same file declares for the same side. `(server)` and `(client)` declarations of one name, and names marked `(partial)`, are allowed. | | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `duplicate-doc-param` | warning | A doc comment has two `@param` lines for the same parameter. Each of them is reported. | +| `doc-field-no-class` | warning | A `@field` has no `@class` above it in its doc comment, or another tag, such as `@deprecated` or `@type`, comes between them. lua-language-server then gives the field to no class; qbx-lua-ls still gives it to the class above. Only other fields, `@operator`, `@overload`, `@source`, `@diagnostic` and description lines may come between a `@class` and its fields; other comments and tags lua-language-server does not read, such as `@author`, are descriptions. | | `missing-global-doc` | off | A global function, defined with `function Name()` or `Name = function()`, has a parameter that no `@param` names, or a `return` whose values no `@return` covers. One without parameters or returned values has no comment. See [Missing documentation](reference.md#missing-documentation). | | `missing-local-export-doc` | off | The same for a local function that a file exports: assigned by name to a field of a local table that a `return` gives, or passed to `exports('Name', fn)`, and for a function written in an `exports` call. | | `incomplete-signature-doc` | off | A function whose doc comment has `@param` or `@return` lines leaves out a parameter or a returned value. A parameter that the function type its function is passed as names, such as `source` of a handler passed to a documented callback wrapper, needs none. | From 75d86ec5d4d34f1079cec2c177b45cbbb25c44d1 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:38:52 +0200 Subject: [PATCH 088/193] feat(rules): add unknown-operator --- crates/qbx_lua_analysis/src/checks/docs.rs | 44 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 5 +++ crates/qbx_lua_analysis/tests/rules.rs | 19 ++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index 2728e2c..094bb22 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,7 +1,7 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented //! function has, or that one doc comment repeats, aliases, enums and class fields that one file -//! declares twice for one side, fields that follow no class, and functions whose parameters and -//! returned values the doc comments leave out. +//! declares twice for one side, fields that follow no class, operators that do not exist, and +//! functions whose parameters and returned values the doc comments leave out. use std::cell::OnceCell; use std::fmt; @@ -34,7 +34,9 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { let incomplete = wanted(rules::INCOMPLETE_SIGNATURE_DOC, &["@param", "@return"]); let repeated_params = wanted(rules::DUPLICATE_DOC_PARAM, &["@param"]); let classless_fields = wanted(rules::DOC_FIELD_NO_CLASS, &["@field"]); - if !(params || aliases || fields || globals || exported || incomplete || repeated_params || classless_fields) { + let operators = wanted(rules::UNKNOWN_OPERATOR, &["@operator"]); + let tags = repeated_params || classless_fields || operators; + if !(params || aliases || fields || globals || exported || incomplete || tags) { return; } let blocks: Vec = doc_blocks(source, &input.chunk.comments) @@ -68,6 +70,9 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { } } } + if operators { + unknown_operators(&blocks, sink); + } } /// One doc comment: its `---` lines without the prefix, and what they declare. @@ -91,8 +96,13 @@ impl<'a> DocBlock<'a> { /// The name line `index` declares, and where it is. fn declared(&self, index: usize) -> Option<(Span, &'a str)> { let (offset, name) = declared_name(self.lines.get(index)?)?; + Some((self.span(index, offset, name), name)) + } + + /// Where `text`, which starts at byte `offset` of line `index`, is written. + fn span(&self, index: usize, offset: usize, text: &str) -> Span { let start = self.comments[index].span.start + 3 + offset as u32; - Some((Span::new(start, start + name.len() as u32), name)) + Span::new(start, start + text.len() as u32) } /// What line `index` is to lua-language-server. @@ -1015,3 +1025,29 @@ fn separator(block: &DocBlock, start: usize) -> Option { let above = start.checked_sub(1)?; block.tag(above).filter(|tag| *tag != "class").map(|tag| format!("the @{tag} line")) } + +/// The operators an `@operator` line can declare: the metamethods of Lua 5.4, without their `__`, +/// that lua-language-server reads. It also knows LuaJIT's `sar`, which is accepted unlisted. +const OPERATORS: &[&str] = &[ + "add", "sub", "mul", "div", "mod", "pow", "idiv", "band", "bor", "bxor", "shl", "shr", "concat", "unm", "bnot", + "len", "call", +]; + +/// `unknown-operator`: an `@operator` whose name is no operator lua-language-server knows, such as +/// `eq` or `index`, which it cannot apply. +fn unknown_operators(blocks: &[DocBlock], sink: &mut Sink) { + for block in blocks { + for index in (0..block.lines.len()).filter(|&i| block.tag(i) == Some("operator")) { + let line = block.lines[index]; + let Some(rest) = line.trim_start().strip_prefix("@operator") else { continue }; + let rest = rest.trim_start(); + let name = &rest[..rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).unwrap_or(rest.len())]; + if name.is_empty() || name == "sar" || OPERATORS.contains(&name) { + continue; + } + let (listed, last) = OPERATORS.split_at(OPERATORS.len() - 1); + let message = format!("unknown operator '{name}'; @operator takes {} or {}", listed.join(", "), last[0]); + sink.report(rules::UNKNOWN_OPERATOR, block.span(index, line.len() - rest.len(), name), message); + } + } +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 463797b..1b24806 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -58,6 +58,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "redundant-return", "duplicate-doc-param", "doc-field-no-class", + "unknown-operator", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 77e35f9..9c7e9a3 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -82,6 +82,7 @@ pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; pub const DUPLICATE_DOC_PARAM: &str = "duplicate-doc-param"; pub const DOC_FIELD_NO_CLASS: &str = "doc-field-no-class"; +pub const UNKNOWN_OPERATOR: &str = "unknown-operator"; pub const MISSING_GLOBAL_DOC: &str = "missing-global-doc"; pub const MISSING_LOCAL_EXPORT_DOC: &str = "missing-local-export-doc"; pub const INCOMPLETE_SIGNATURE_DOC: &str = "incomplete-signature-doc"; @@ -180,6 +181,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), rule(DUPLICATE_DOC_PARAM, Suspicious, WARN, false, "A doc comment has two @param annotations for the same parameter."), rule(DOC_FIELD_NO_CLASS, Correctness, WARN, false, "A @field does not directly follow the @class it belongs to, so lua-language-server attaches it to no class."), + rule(UNKNOWN_OPERATOR, Correctness, WARN, false, "An @operator names an operator that LuaCATS annotations cannot declare, such as `eq`."), rule(MISSING_GLOBAL_DOC, Style, OFF, false, "A global function has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(MISSING_LOCAL_EXPORT_DOC, Style, OFF, false, "A local function that a module's returned table or exports() exports, or a function passed to exports(), has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(INCOMPLETE_SIGNATURE_DOC, Style, OFF, false, "A function whose doc comment has @param or @return annotations leaves out a parameter or a returned value."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 2359d5a..9514902 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -50,3 +50,4 @@ shared/config.lua:36:23 hint redundant-return shared/config.lua:39:11 warning duplicate-doc-param shared/config.lua:40:11 warning duplicate-doc-param shared/config.lua:45:11 warning doc-field-no-class +shared/config.lua:50:14 warning unknown-operator diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index a5b10a9..75d2ee5 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -45,3 +45,8 @@ end ---@field orphan number local orphaned = {} print(orphaned) + +---@class Config.Vector +---@operator eq: boolean +local vector = {} +print(vector) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 1807bc0..fce8232 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1479,6 +1479,25 @@ print(t, A, tA, fB, C, D, tail, E)"; ); } +#[test] +fn operators_luacats_cannot_declare() { + let source = "---@class Vec +---@operator add(Vec): Vec +---@operator unm: Vec +---@operator call: Vec +---@operator sar: Vec +---@operator eq: boolean +---@operator index(string): Vec +---@operator ad(Vec): Vec +local Vec = {} +return Vec"; + assert_eq!(reported_lines(source, "unknown-operator"), [6, 7, 8]); + assert_eq!( + findings(source, "unknown-operator")[0].1, + "unknown operator 'eq'; @operator takes add, sub, mul, div, mod, pow, idiv, band, bor, bxor, shl, shr, concat, unm, bnot, len or call" + ); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index c14dc66..6d69922 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param` and `doc-field-no-class`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class` and `unknown-operator`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index e5c7501..b7a5313 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -40,6 +40,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `duplicate-doc-param` | warning | A doc comment has two `@param` lines for the same parameter. Each of them is reported. | | `doc-field-no-class` | warning | A `@field` has no `@class` above it in its doc comment, or another tag, such as `@deprecated` or `@type`, comes between them. lua-language-server then gives the field to no class; qbx-lua-ls still gives it to the class above. Only other fields, `@operator`, `@overload`, `@source`, `@diagnostic` and description lines may come between a `@class` and its fields; other comments and tags lua-language-server does not read, such as `@author`, are descriptions. | +| `unknown-operator` | warning | An `@operator` names an operator that annotations cannot declare, such as `eq` or `index`. The operators are `add`, `sub`, `mul`, `div`, `mod`, `pow`, `idiv`, `band`, `bor`, `bxor`, `shl`, `shr`, `concat`, `unm`, `bnot`, `len` and `call`. | | `missing-global-doc` | off | A global function, defined with `function Name()` or `Name = function()`, has a parameter that no `@param` names, or a `return` whose values no `@return` covers. One without parameters or returned values has no comment. See [Missing documentation](reference.md#missing-documentation). | | `missing-local-export-doc` | off | The same for a local function that a file exports: assigned by name to a field of a local table that a `return` gives, or passed to `exports('Name', fn)`, and for a function written in an `exports` call. | | `incomplete-signature-doc` | off | A function whose doc comment has `@param` or `@return` lines leaves out a parameter or a returned value. A parameter that the function type its function is passed as names, such as `source` of a handler passed to a documented callback wrapper, needs none. | From e222c44da33490b97ffa218d9d7277045b685beb Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:39:19 +0200 Subject: [PATCH 089/193] feat(rules): add unknown-cast-variable --- crates/qbx_lua_analysis/src/checks/docs.rs | 30 +++++++++++++--- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 ++ .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 2 ++ crates/qbx_lua_analysis/tests/rules.rs | 34 +++++++++++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/docs.rs b/crates/qbx_lua_analysis/src/checks/docs.rs index 094bb22..c5e91d7 100644 --- a/crates/qbx_lua_analysis/src/checks/docs.rs +++ b/crates/qbx_lua_analysis/src/checks/docs.rs @@ -1,7 +1,7 @@ //! Doc comment checks that need no other file: `@param` names that no parameter of the documented //! function has, or that one doc comment repeats, aliases, enums and class fields that one file -//! declares twice for one side, fields that follow no class, operators that do not exist, and -//! functions whose parameters and returned values the doc comments leave out. +//! declares twice for one side, fields that follow no class, operators and cast variables that do +//! not exist, and functions whose parameters and returned values the doc comments leave out. use std::cell::OnceCell; use std::fmt; @@ -12,7 +12,7 @@ use qbx_fivem_data::Side; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; -use qbx_luacats::luacats::{applies_on, declared_name, has_attribute, parse_doc_lines, DocGroup}; +use qbx_luacats::luacats::{applies_on, declared_name, has_attribute, parse_cast, parse_doc_lines, DocGroup}; use qbx_luacats::types::{FunType, Type}; use rustc_hash::{FxHashMap, FxHashSet}; @@ -35,7 +35,8 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { let repeated_params = wanted(rules::DUPLICATE_DOC_PARAM, &["@param"]); let classless_fields = wanted(rules::DOC_FIELD_NO_CLASS, &["@field"]); let operators = wanted(rules::UNKNOWN_OPERATOR, &["@operator"]); - let tags = repeated_params || classless_fields || operators; + let casts = wanted(rules::UNKNOWN_CAST_VARIABLE, &["@cast"]); + let tags = repeated_params || classless_fields || operators || casts; if !(params || aliases || fields || globals || exported || incomplete || tags) { return; } @@ -73,6 +74,9 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { if operators { unknown_operators(&blocks, sink); } + if casts { + unknown_cast_variables(input.resolution, &blocks, sink); + } } /// One doc comment: its `---` lines without the prefix, and what they declare. @@ -1051,3 +1055,21 @@ fn unknown_operators(blocks: &[DocBlock], sink: &mut Sink) { } } } + +/// `unknown-cast-variable`: a `---@cast` whose name is no local in scope where it is written, such as +/// a global, a field or a local declared below it, whose type it cannot change. +fn unknown_cast_variables(resolution: &Resolution, blocks: &[DocBlock], sink: &mut Sink) { + for block in blocks { + for (index, line) in block.lines.iter().enumerate() { + let Some(cast) = parse_cast(line) else { continue }; + let comment = block.comments[index]; + if resolution.lookup_local_at(cast.name, comment.span.start).is_some() { + continue; + } + let Some(rest) = line.trim_start().strip_prefix("@cast") else { continue }; + let offset = line.len() - rest.trim_start().len(); + let message = format!("no local '{}' is in scope here; @cast changes the type of a local", cast.name); + sink.report(rules::UNKNOWN_CAST_VARIABLE, block.span(index, offset, cast.name), message); + } + } +} diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 1b24806..8de477b 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -59,6 +59,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "duplicate-doc-param", "doc-field-no-class", "unknown-operator", + "unknown-cast-variable", ]; #[derive(Debug, Default)] diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 9c7e9a3..101ba85 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -83,6 +83,7 @@ pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; pub const DUPLICATE_DOC_PARAM: &str = "duplicate-doc-param"; pub const DOC_FIELD_NO_CLASS: &str = "doc-field-no-class"; pub const UNKNOWN_OPERATOR: &str = "unknown-operator"; +pub const UNKNOWN_CAST_VARIABLE: &str = "unknown-cast-variable"; pub const MISSING_GLOBAL_DOC: &str = "missing-global-doc"; pub const MISSING_LOCAL_EXPORT_DOC: &str = "missing-local-export-doc"; pub const INCOMPLETE_SIGNATURE_DOC: &str = "incomplete-signature-doc"; @@ -182,6 +183,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_DOC_PARAM, Suspicious, WARN, false, "A doc comment has two @param annotations for the same parameter."), rule(DOC_FIELD_NO_CLASS, Correctness, WARN, false, "A @field does not directly follow the @class it belongs to, so lua-language-server attaches it to no class."), rule(UNKNOWN_OPERATOR, Correctness, WARN, false, "An @operator names an operator that LuaCATS annotations cannot declare, such as `eq`."), + rule(UNKNOWN_CAST_VARIABLE, Correctness, WARN, false, "A ---@cast names no local that is in scope where it is written."), rule(MISSING_GLOBAL_DOC, Style, OFF, false, "A global function has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(MISSING_LOCAL_EXPORT_DOC, Style, OFF, false, "A local function that a module's returned table or exports() exports, or a function passed to exports(), has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(INCOMPLETE_SIGNATURE_DOC, Style, OFF, false, "A function whose doc comment has @param or @return annotations leaves out a parameter or a returned value."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 9514902..344cf50 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -51,3 +51,4 @@ shared/config.lua:39:11 warning duplicate-doc-param shared/config.lua:40:11 warning duplicate-doc-param shared/config.lua:45:11 warning doc-field-no-class shared/config.lua:50:14 warning unknown-operator +shared/config.lua:54:10 warning unknown-cast-variable diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 75d2ee5..05446d4 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -50,3 +50,5 @@ print(orphaned) ---@operator eq: boolean local vector = {} print(vector) + +---@cast missing string diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index fce8232..1331231 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1498,6 +1498,40 @@ return Vec"; ); } +#[test] +fn casts_name_a_local_in_scope() { + let source = "Global = 1 +---@cast Global string +local function f(p) + ---@cast p string + ---@cast self string + return p +end +local M = {} +function M:m() + ---@cast self table + return self +end +---@cast later string +local later = 1 +---@cast later string +local x = 1 ---@cast x string +---@cast M.field string +for _, item in ipairs({}) do + ---@cast item string + print(item) +end +for i = 1, 2 do + ---@cast i string +end +print(f, later, x)"; + assert_eq!(reported_lines(source, "unknown-cast-variable"), [2, 5, 13, 17]); + assert_eq!( + findings(source, "unknown-cast-variable")[0].1, + "no local 'Global' is in scope here; @cast changes the type of a local" + ); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index 6d69922..b6d8e65 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class` and `unknown-operator`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator` and `unknown-cast-variable`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index b7a5313..0ad49cf 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -41,6 +41,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-param` | warning | A doc comment has two `@param` lines for the same parameter. Each of them is reported. | | `doc-field-no-class` | warning | A `@field` has no `@class` above it in its doc comment, or another tag, such as `@deprecated` or `@type`, comes between them. lua-language-server then gives the field to no class; qbx-lua-ls still gives it to the class above. Only other fields, `@operator`, `@overload`, `@source`, `@diagnostic` and description lines may come between a `@class` and its fields; other comments and tags lua-language-server does not read, such as `@author`, are descriptions. | | `unknown-operator` | warning | An `@operator` names an operator that annotations cannot declare, such as `eq` or `index`. The operators are `add`, `sub`, `mul`, `div`, `mod`, `pow`, `idiv`, `band`, `bor`, `bxor`, `shl`, `shr`, `concat`, `unm`, `bnot`, `len` and `call`. | +| `unknown-cast-variable` | warning | A `---@cast` names no local that is in scope where it is written, such as a global, a field like `M.x`, or a local declared below it. | | `missing-global-doc` | off | A global function, defined with `function Name()` or `Name = function()`, has a parameter that no `@param` names, or a `return` whose values no `@return` covers. One without parameters or returned values has no comment. See [Missing documentation](reference.md#missing-documentation). | | `missing-local-export-doc` | off | The same for a local function that a file exports: assigned by name to a field of a local table that a `return` gives, or passed to `exports('Name', fn)`, and for a function written in an `exports` call. | | `incomplete-signature-doc` | off | A function whose doc comment has `@param` or `@return` lines leaves out a parameter or a returned value. A parameter that the function type its function is passed as names, such as `source` of a handler passed to a documented callback wrapper, needs none. | From 91cf19336927081d27e25ac2ccad95ccea15b804 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:39:50 +0200 Subject: [PATCH 090/193] feat(rules): add unknown-diag-code --- .../qbx_lua_analysis/src/checks/diag_codes.rs | 36 ++++ crates/qbx_lua_analysis/src/checks/mod.rs | 2 + crates/qbx_lua_analysis/src/directives.rs | 68 ++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 157 ++++++++++++++++++ crates/qbx_lua_analysis/src/manifest.rs | 2 +- crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 3 + crates/qbx_lua_analysis/tests/rules.rs | 17 ++ docs/reference.md | 7 +- docs/rules.md | 1 + 11 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 crates/qbx_lua_analysis/src/checks/diag_codes.rs diff --git a/crates/qbx_lua_analysis/src/checks/diag_codes.rs b/crates/qbx_lua_analysis/src/checks/diag_codes.rs new file mode 100644 index 0000000..7d32eb2 --- /dev/null +++ b/crates/qbx_lua_analysis/src/checks/diag_codes.rs @@ -0,0 +1,36 @@ +//! `unknown-diag-code`: a `---@diagnostic` or `-- qbx-lint:` comment that names a code no rule here +//! has and lua-language-server does not know either, so it suppresses nothing. The codes of +//! lua-language-server count even when no rule here checks the same, so comments written for it are +//! left alone. + +use super::{FileInput, Sink}; +use crate::directives::directive_codes; +use crate::lua_ls_config::{is_lua_ls_code, LUA_LS_CODES}; +use crate::manifest::edit_distance; +use crate::rules; + +pub(super) fn check(input: &FileInput, sink: &mut Sink) { + if sink.enabled(rules::UNKNOWN_DIAG_CODE).is_none() { + return; + } + for (span, code) in directive_codes(input.source, &input.chunk.comments) { + if rules::find(code).is_some() || is_lua_ls_code(code) { + continue; + } + let message = match closest_code(code) { + Some(known) => format!("unknown diagnostic code '{code}'; did you mean '{known}'?"), + None => format!("unknown diagnostic code '{code}'"), + }; + sink.report(rules::UNKNOWN_DIAG_CODE, span, message); + } +} + +/// The rule or lua-language-server code that `code` most likely misspells. +fn closest_code(code: &str) -> Option<&'static str> { + let known = rules::RULES.iter().map(|rule| rule.code).chain(LUA_LS_CODES.iter().copied()); + known + .map(|known| (known, edit_distance(code, known))) + .filter(|(_, distance)| *distance <= 2) + .min_by_key(|(_, distance)| *distance) + .map(|(known, _)| known) +} diff --git a/crates/qbx_lua_analysis/src/checks/mod.rs b/crates/qbx_lua_analysis/src/checks/mod.rs index be34280..2c40d88 100644 --- a/crates/qbx_lua_analysis/src/checks/mod.rs +++ b/crates/qbx_lua_analysis/src/checks/mod.rs @@ -1,5 +1,6 @@ mod arity; mod crossfile; +mod diag_codes; mod docs; mod fivem; mod flow; @@ -110,6 +111,7 @@ pub fn check_file(input: &FileInput) -> Vec { flow::check(input, &mut sink); layout::check(input, &mut sink); docs::check(input, &mut sink); + diag_codes::check(input, &mut sink); globals::check(input, &mut sink); arity::check(input, &mut sink); fivem::check(input, &mut sink); diff --git a/crates/qbx_lua_analysis/src/directives.rs b/crates/qbx_lua_analysis/src/directives.rs index 8bad1a9..abfb6b9 100644 --- a/crates/qbx_lua_analysis/src/directives.rs +++ b/crates/qbx_lua_analysis/src/directives.rs @@ -1,4 +1,4 @@ -use qbx_lua_syntax::{Comment, LineIndex}; +use qbx_lua_syntax::{Comment, LineIndex, Span}; #[derive(Debug)] struct LineRule { @@ -28,18 +28,19 @@ enum Action { DisableNextLine, } -fn parse_directive(text: &str, own_line: bool) -> Option<(Action, Vec)> { - let text = text.trim_start_matches('-').trim(); - if let Some(luacheck) = text.strip_prefix("luacheck:") { +/// What a directive comment does, and the codes it names with the byte of `text` each starts at. +fn parse_directive(text: &str, own_line: bool) -> Option<(Action, Vec<(usize, &str)>)> { + let body = text.trim_start_matches('-').trim_start(); + if let Some(luacheck) = body.strip_prefix("luacheck:") { let ignores = luacheck.split_whitespace().next() == Some("ignore"); let action = if own_line { Action::Disable } else { Action::DisableLine }; return ignores.then_some((action, Vec::new())); } - let rest = text + let rest = body .strip_prefix("qbx-lint:") - .or_else(|| text.strip_prefix("qbxlint:")) - .or_else(|| text.strip_prefix("@diagnostic"))? - .trim(); + .or_else(|| body.strip_prefix("qbxlint:")) + .or_else(|| body.strip_prefix("@diagnostic"))? + .trim_start(); // Any whitespace separates, so a non-breaking space pasted in where a space was meant still works. let (action, codes) = rest.split_once(|c: char| c == ':' || c.is_whitespace()).unwrap_or((rest, "")); let action = match action { @@ -49,16 +50,34 @@ fn parse_directive(text: &str, own_line: bool) -> Option<(Action, Vec)> "disable-next-line" => Action::DisableNextLine, _ => return None, }; - let codes = codes - .trim_start_matches(':') - .split(|c: char| c == ',' || c.is_whitespace()) - .filter(|c| !c.is_empty()) - .take_while(|c| !c.starts_with("--")) - .map(str::to_string) - .collect(); + let is_separator = |c: char| c == ',' || c.is_whitespace(); + let mut rest = codes.trim_start_matches(':'); + let mut codes = Vec::new(); + loop { + rest = rest.trim_start_matches(is_separator); + if rest.is_empty() || rest.starts_with("--") { + break; + } + let end = rest.find(is_separator).unwrap_or(rest.len()); + codes.push((text.len() - rest.len(), &rest[..end])); + rest = &rest[end..]; + } Some((action, codes)) } +/// The codes that inline directive comments name, with where each is written. +pub fn directive_codes<'s>(source: &'s str, comments: &[Comment]) -> Vec<(Span, &'s str)> { + let mut out = Vec::new(); + for comment in comments { + let Some((_, codes)) = parse_directive(comment.span.text(source), true) else { continue }; + for (offset, code) in codes { + let start = comment.span.start + offset as u32; + out.push((Span::new(start, start + code.len() as u32), code)); + } + } + out +} + impl Suppressions { pub fn parse(source: &str, comments: &[Comment], line_index: &LineIndex) -> Self { let mut out = Self::default(); @@ -69,6 +88,7 @@ impl Suppressions { let Some((action, codes)) = parse_directive(comment.span.text(source), before.trim().is_empty()) else { continue; }; + let codes: Vec = codes.into_iter().map(|(_, code)| code.to_string()).collect(); match action { Action::DisableLine => out.lines.push(LineRule { line, codes }), Action::DisableNextLine => out.lines.push(LineRule { line: line + 1, codes }), @@ -138,6 +158,24 @@ function B() end", assert!(s.is_suppressed("unused-local", 5)); } + #[test] + fn codes_keep_their_position() { + let source = "local a ---@diagnostic disable-line: unused-local, empty-block -- why\n-- qbx-lint: disable\u{a0}fivem/loop-never-yields\n-- luacheck: ignore 211"; + let chunk = parse(source); + let codes: Vec<(&str, &str)> = directive_codes(source, &chunk.comments) + .into_iter() + .map(|(span, code)| (span.text(source), code)) + .collect(); + assert_eq!( + codes, + [ + ("unused-local", "unused-local"), + ("empty-block", "empty-block"), + ("fivem/loop-never-yields", "fivem/loop-never-yields") + ] + ); + } + #[test] fn unicode_whitespace_separates() { let s = suppressions("-- qbx-lint: disable-next-line\u{a0}undefined-global\u{3000}unused-local\nprint(foo)\nlocal a ---@diagnostic disable-line:\u{a0}empty-block,\u{a0}unused-local\n-- qbx-lint: disable\u{3000}lowercase-global\nx = 1"); diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 8de477b..2280c0c 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -60,8 +60,162 @@ const EQUIVALENT_CODES: &[&str] = &[ "doc-field-no-class", "unknown-operator", "unknown-cast-variable", + "unknown-diag-code", ]; +/// The diagnostic codes of lua-language-server 3.19.1, including those that have no rule here. +pub const LUA_LS_CODES: &[&str] = &[ + "unused-local", + "unused-function", + "unused-label", + "unused-vararg", + "trailing-space", + "redundant-return", + "empty-block", + "code-after-break", + "unreachable-code", + "redundant-value", + "unbalanced-assignments", + "redundant-parameter", + "missing-parameter", + "missing-return-value", + "redundant-return-value", + "missing-return", + "missing-fields", + "need-check-nil", + "undefined-field", + "cast-local-type", + "assign-type-mismatch", + "param-type-mismatch", + "cast-type-mismatch", + "return-type-mismatch", + "inject-field", + "unnecessary-assert", + "duplicate-doc-alias", + "undefined-doc-class", + "undefined-doc-name", + "circle-doc-class", + "undefined-doc-param", + "duplicate-doc-param", + "doc-field-no-class", + "duplicate-doc-field", + "unknown-diag-code", + "unknown-cast-variable", + "unknown-operator", + "incomplete-signature-doc", + "missing-global-doc", + "missing-local-export-doc", + "codestyle-check", + "spell-check", + "name-style-check", + "newline-call", + "newfield-call", + "ambiguity-1", + "count-down-loop", + "different-requires", + "await-in-sync", + "not-yieldable", + "no-unknown", + "redefined-local", + "undefined-global", + "global-in-nil-env", + "lowercase-global", + "undefined-env-child", + "global-element", + "duplicate-index", + "duplicate-set-field", + "close-non-object", + "deprecated", + "discard-returns", + "invisible", +]; + +/// The names of lua-language-server's syntax errors, which `---@diagnostic` comments may name too. +const LUA_LS_SYNTAX_ERRORS: &[&str] = &[ + "action-after-return", + "ambiguous-syntax", + "args-after-dots", + "assign-const-global", + "block-after-else", + "break-outside", + "declare-const", + "env-is-global", + "err-assign-as-eq", + "err-c-long-comment", + "err-comment-prefix", + "err-do-as-then", + "err-eq-as-assign", + "err-esc", + "err-nonstandard-symbol", + "err-then-as-do", + "exp-in-action", + "global-close-attribute", + "index-in-func-name", + "jump-local-scope", + "keyword", + "local-limit", + "lua-doc-miss-sign", + "luadoc-error-diag-mode", + "luadoc-miss-alias-extends", + "luadoc-miss-alias-name", + "luadoc-miss-arg-name", + "luadoc-miss-cate-name", + "luadoc-miss-class-extends-name", + "luadoc-miss-class-name", + "luadoc-miss-diag-mode", + "luadoc-miss-diag-name", + "luadoc-miss-field-extends", + "luadoc-miss-field-name", + "luadoc-miss-fun-after-overload", + "luadoc-miss-generic-name", + "luadoc-miss-local-name", + "luadoc-miss-module-name", + "luadoc-miss-operator-name", + "luadoc-miss-param-extends", + "luadoc-miss-param-name", + "luadoc-miss-see-name", + "luadoc-miss-sign-name", + "luadoc-miss-symbol", + "luadoc-miss-type-name", + "luadoc-miss-vararg-type", + "luadoc-miss-version", + "malformed-number", + "miss-end", + "miss-esc-x", + "miss-exp", + "miss-exponent", + "miss-field", + "miss-loop-max", + "miss-loop-min", + "miss-method", + "miss-name", + "miss-sep-in-table", + "miss-space-between", + "miss-symbol", + "multi-close", + "need-paren", + "nesting-long-mark", + "no-visible-label", + "redefined-label", + "set-const", + "unexpect-dots", + "unexpect-efunc-name", + "unexpect-gfunc-name", + "unexpect-lfunc-name", + "unexpect-symbol", + "unicode-name", + "unknown-attribute", + "unknown-symbol", + "unsupport-named-vararg", + "unsupport-symbol", + "variable-not-declared", +]; + +/// Whether lua-language-server knows `code` as one of its diagnostics or syntax errors. +pub fn is_lua_ls_code(code: &str) -> bool { + LUA_LS_CODES.contains(&code) || LUA_LS_SYNTAX_ERRORS.contains(&code) +} + #[derive(Debug, Default)] pub(crate) struct Settings { pub globals: Vec, @@ -316,6 +470,9 @@ mod tests { for code in targets { assert!(crate::rules::find(code).is_some(), "{code} is not a rule"); } + for code in EQUIVALENT_CODES { + assert!(LUA_LS_CODES.contains(code), "{code} is not a lua-language-server code"); + } } #[test] diff --git a/crates/qbx_lua_analysis/src/manifest.rs b/crates/qbx_lua_analysis/src/manifest.rs index b8a829d..b0142f5 100644 --- a/crates/qbx_lua_analysis/src/manifest.rs +++ b/crates/qbx_lua_analysis/src/manifest.rs @@ -210,7 +210,7 @@ pub fn closest_directive(name: &str) -> Option<&'static str> { .map(|(known, _)| known) } -fn edit_distance(a: &str, b: &str) -> usize { +pub(crate) fn edit_distance(a: &str, b: &str) -> usize { let (a, b) = (a.as_bytes(), b.as_bytes()); let mut prev: Vec = (0..=b.len()).collect(); for (i, ca) in a.iter().enumerate() { diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 101ba85..52c5aba 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -84,6 +84,7 @@ pub const DUPLICATE_DOC_PARAM: &str = "duplicate-doc-param"; pub const DOC_FIELD_NO_CLASS: &str = "doc-field-no-class"; pub const UNKNOWN_OPERATOR: &str = "unknown-operator"; pub const UNKNOWN_CAST_VARIABLE: &str = "unknown-cast-variable"; +pub const UNKNOWN_DIAG_CODE: &str = "unknown-diag-code"; pub const MISSING_GLOBAL_DOC: &str = "missing-global-doc"; pub const MISSING_LOCAL_EXPORT_DOC: &str = "missing-local-export-doc"; pub const INCOMPLETE_SIGNATURE_DOC: &str = "incomplete-signature-doc"; @@ -184,6 +185,7 @@ pub static RULES: &[Rule] = &[ rule(DOC_FIELD_NO_CLASS, Correctness, WARN, false, "A @field does not directly follow the @class it belongs to, so lua-language-server attaches it to no class."), rule(UNKNOWN_OPERATOR, Correctness, WARN, false, "An @operator names an operator that LuaCATS annotations cannot declare, such as `eq`."), rule(UNKNOWN_CAST_VARIABLE, Correctness, WARN, false, "A ---@cast names no local that is in scope where it is written."), + rule(UNKNOWN_DIAG_CODE, Suspicious, WARN, false, "A ---@diagnostic or qbx-lint comment names a code that is neither a rule here nor a lua-language-server diagnostic."), rule(MISSING_GLOBAL_DOC, Style, OFF, false, "A global function has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(MISSING_LOCAL_EXPORT_DOC, Style, OFF, false, "A local function that a module's returned table or exports() exports, or a function passed to exports(), has a parameter without @param or returns a value without @return; one that has neither needs a comment."), rule(INCOMPLETE_SIGNATURE_DOC, Style, OFF, false, "A function whose doc comment has @param or @return annotations leaves out a parameter or a returned value."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 344cf50..e45bbd4 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -52,3 +52,4 @@ shared/config.lua:40:11 warning duplicate-doc-param shared/config.lua:45:11 warning doc-field-no-class shared/config.lua:50:14 warning unknown-operator shared/config.lua:54:10 warning unknown-cast-variable +shared/config.lua:56:35 warning unknown-diag-code diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index 05446d4..c2068c2 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -52,3 +52,6 @@ local vector = {} print(vector) ---@cast missing string + +---@diagnostic disable-next-line: no-such-rule +print(Config.Debug) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 1331231..24ff3cf 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1532,6 +1532,23 @@ print(f, later, x)"; ); } +#[test] +fn directives_name_known_codes() { + let source = "---@diagnostic disable-next-line: unused-local, no-such-code +local a = 1 +-- qbx-lint: disable-next-line fivem/loop-never-yields unused-locals +local b = 2 +---@diagnostic disable: unused-vararg, miss-end, codestyle-check +---@diagnostic enable: undefined-globl +-- luacheck: ignore 211 +print(a, b)"; + assert_eq!(reported_lines(source, "unknown-diag-code"), [1, 3, 6]); + let messages: Vec = findings(source, "unknown-diag-code").into_iter().map(|(_, m)| m).collect(); + assert_eq!(messages[0], "unknown diagnostic code 'no-such-code'"); + assert_eq!(messages[1], "unknown diagnostic code 'unused-locals'; did you mean 'unused-local'?"); + assert_eq!(messages[2], "unknown diagnostic code 'undefined-globl'; did you mean 'undefined-global'?"); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index b6d8e65..7b2df06 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator` and `unknown-cast-variable`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable` and `unknown-diag-code`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | @@ -148,6 +148,11 @@ print(fromSomewhereElse) findings; after code it suppresses that line. Luacheck's numeric codes and name filters are not translated. Prefer explicit `qbx-lint` rule codes when narrowing a suppression. +A code that is neither a rule here nor one of lua-language-server's diagnostics or syntax errors +suppresses nothing, and `unknown-diag-code` reports it, with the rule it most likely misspells. +lua-language-server's codes are accepted even when no rule here checks the same, so comments +written for it stay quiet. + ## Globals and client/server context For a file inside a resource, the nearest `fxmanifest.lua` or `__resource.lua` supplies script diff --git a/docs/rules.md b/docs/rules.md index 0ad49cf..09222e6 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -42,6 +42,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `doc-field-no-class` | warning | A `@field` has no `@class` above it in its doc comment, or another tag, such as `@deprecated` or `@type`, comes between them. lua-language-server then gives the field to no class; qbx-lua-ls still gives it to the class above. Only other fields, `@operator`, `@overload`, `@source`, `@diagnostic` and description lines may come between a `@class` and its fields; other comments and tags lua-language-server does not read, such as `@author`, are descriptions. | | `unknown-operator` | warning | An `@operator` names an operator that annotations cannot declare, such as `eq` or `index`. The operators are `add`, `sub`, `mul`, `div`, `mod`, `pow`, `idiv`, `band`, `bor`, `bxor`, `shl`, `shr`, `concat`, `unm`, `bnot`, `len` and `call`. | | `unknown-cast-variable` | warning | A `---@cast` names no local that is in scope where it is written, such as a global, a field like `M.x`, or a local declared below it. | +| `unknown-diag-code` | warning | A `---@diagnostic` or `-- qbx-lint:` comment names a code that is neither a rule here nor a lua-language-server diagnostic or syntax error, so it suppresses nothing. A close match is suggested. | | `missing-global-doc` | off | A global function, defined with `function Name()` or `Name = function()`, has a parameter that no `@param` names, or a `return` whose values no `@return` covers. One without parameters or returned values has no comment. See [Missing documentation](reference.md#missing-documentation). | | `missing-local-export-doc` | off | The same for a local function that a file exports: assigned by name to a field of a local table that a `return` gives, or passed to `exports('Name', fn)`, and for a function written in an `exports` call. | | `incomplete-signature-doc` | off | A function whose doc comment has `@param` or `@return` lines leaves out a parameter or a returned value. A parameter that the function type its function is passed as names, such as `source` of a handler passed to a documented callback wrapper, needs none. | From a005eab9beb6f2d8f848e767a51d7c1ad8831d2a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:40:21 +0200 Subject: [PATCH 091/193] feat(rules): add close-non-object --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 5 +- crates/qbx_lua_ls/src/features/closes.rs | 80 +++++++++++++++++++ crates/qbx_lua_ls/src/features/diagnostics.rs | 13 +-- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/tests/lsp.rs | 53 ++++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 10 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/closes.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 2280c0c..5690432 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -61,6 +61,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "unknown-operator", "unknown-cast-variable", "unknown-diag-code", + "close-non-object", ]; /// The diagnostic codes of lua-language-server 3.19.1, including those that have no rule here. diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 52c5aba..3bb5455 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -115,6 +115,8 @@ pub const CAST_LOCAL_TYPE: &str = "cast-local-type"; /// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. pub const NO_UNKNOWN: &str = "no-unknown"; pub const CONST_REASSIGN: &str = "const-reassign"; +/// Reported by qbx-lua-ls, which infers the types; the linter itself has no type information. +pub const CLOSE_NON_OBJECT: &str = "close-non-object"; pub const SELF_ASSIGNMENT: &str = "self-assignment"; pub const SELF_COMPARISON: &str = "self-comparison"; pub const COUNT_DOWN_LOOP: &str = "count-down-loop"; @@ -203,6 +205,7 @@ pub static RULES: &[Rule] = &[ rule(CAST_LOCAL_TYPE, Correctness, WARN, false, "A local without @type or @param is given a value that the type of the value it is declared with does not take, such as a string after `local speed = 5` (language server only)."), rule(NO_UNKNOWN, Style, OFF, false, "A parameter, local or loop variable has no type, or a value of unknown type goes where a type is declared (language server only)."), rule(CONST_REASSIGN, Correctness, ERROR, false, "A or local is assigned to."), + rule(CLOSE_NON_OBJECT, Correctness, WARN, false, "A local has no value, or one that cannot be closed, such as a number or a string (language server only)."), rule(SELF_ASSIGNMENT, Suspicious, WARN, false, "A variable is assigned to itself."), rule(SELF_COMPARISON, Suspicious, WARN, false, "Both sides of a comparison are the same expression."), rule(COUNT_DOWN_LOOP, Suspicious, WARN, true, "A numeric for loop counts up from a start above its end, or from #list to 1, without a negative step."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 5587751..83c0fd9 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -96,6 +96,7 @@ fn bad_resource_reports_every_rule() { | "no-unknown" | "impossible-comparison" | "need-check-nil" + | "close-non-object" ); assert!(exercised || covered_elsewhere, "rule {} is not exercised by the bad_resource fixture", rule.code); } diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 3105d32..4d08cf1 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -83,8 +83,9 @@ Available features depend on the editor's LSP client. lua-language-server and TypeScript do. `inject-field` reports fields set through a value whose class or table type does not have them, `undefined-field` fields read from one, and - `need-check-nil` a value that may be `nil` used where a missing value raises an error. The opt-in - `no-unknown` reports parameters, locals and loop variables that have no type. + `need-check-nil` a value that may be `nil` used where a missing value raises an error. + `close-non-object` reports a `` local whose value cannot be closed, such as a number. The + opt-in `no-unknown` reports parameters, locals and loop variables that have no type. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. - QB-Core and ESX server callback completion, navigation and payload hints from local handlers. - Callback systems a resource wraps itself, declared with `---@callback`, with name completion, diff --git a/crates/qbx_lua_ls/src/features/closes.rs b/crates/qbx_lua_ls/src/features/closes.rs new file mode 100644 index 0000000..d14f550 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/closes.rs @@ -0,0 +1,80 @@ +//! `close-non-object`: a `` local without a value, or with one that cannot be closed. When +//! the local goes out of scope, Lua 5.4 calls the `__close` metamethod of its value, skips `nil` and +//! `false`, and raises an error for anything else. As in lua-language-server, a value is reported +//! only when no part of its type can be closed: numbers, strings, `true`, functions, threads and +//! userdata. Tables and classes may have the metamethod, and a value of unknown type may be anything. +//! Unlike lua-language-server, `false` passes, as the runtime takes it, and aliases are looked through. + +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; + +use super::class_tables::Classes; +use crate::infer::Infer; +use crate::types::Type; + +/// Each `` local of `chunk` without a value, and each value of one that cannot be closed, +/// with the message. +pub fn unclosable_values(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let mut finder = Finder { classes: Classes::new(infer), out: Vec::new() }; + finder.visit_block(&chunk.block); + finder.out +} + +struct Finder<'a, 'b> { + classes: Classes<'a, 'b>, + out: Vec<(Span, String)>, +} + +impl Finder<'_, '_> { + /// Whether a value of type `ty` may be one that a `` local takes. + fn may_close(&self, ty: &Type) -> bool { + let mut parts = Vec::new(); + self.classes.flatten(ty, self.classes.file(), &mut parts, 0); + parts.is_empty() + || parts.iter().any(|part| match part { + Type::Variadic(inner) => self.may_close(inner), + Type::Boolean + | Type::BooleanLit(true) + | Type::Number + | Type::Integer + | Type::IntLit(_) + | Type::Handle(_) + | Type::String + | Type::StringLit(_) + | Type::Function + | Type::Fun(_) + | Type::Thread + | Type::Userdata => false, + _ => true, + }) + } +} + +impl<'c> Visitor<'c> for Finder<'_, '_> { + fn visit_stmt(&mut self, stmt: &'c Stmt) { + if let StmtKind::Local { names, exprs, in_unpack: false } = &stmt.kind { + let closed = names.iter().enumerate().filter(|(_, name)| matches!(name.attrib, Some((Attrib::Close, _)))); + for (index, name) in closed { + match self.classes.values(exprs).get(index) { + Some((ty, span)) => { + if !self.may_close(ty) { + let message = format!( + "Cannot close a value of type `{}`; a `` local takes `nil`, `false` or a value with a `__close` metamethod", + ty.widen() + ); + self.out.push((*span, message)); + } + } + // A call or `...` that gives fewer values leaves `nil`. + None if exprs.last().is_some_and(Expr::is_multi_value) => {} + None => { + let message = format!("`{}` is declared `` without a value to close", name.name.text); + self.out.push((name.name.span, message)); + } + } + } + } + visit::walk_stmt(self, stmt); + } +} diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 62cfa75..89aa009 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,10 +5,11 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, - INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, - MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, - REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, UNDEFINED_FIELD, + ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, CLOSE_NON_OBJECT, DISCARD_RETURNS, + IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, + MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, + REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, + UNDEFINED_FIELD, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -22,6 +23,7 @@ use super::assignments::mismatched_assignments; use super::callback_payloads::{missing_payloads, payloads, redundant_payloads, Payload}; use super::casts::mismatched_casts; use super::class_tables::missing_fields; +use super::closes::unclosable_values; use super::comparisons::impossible_comparisons; use super::discards::discarded_returns; use super::doc_names::undefined_doc_names; @@ -93,7 +95,7 @@ fn type_diagnostics( found: &mut Vec, ) { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 19] = [ + let checks: [(&'static str, Check); 20] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -121,6 +123,7 @@ fn type_diagnostics( (DISCARD_RETURNS, |input| discarded_returns(input.infer, &input.doc.chunk)), (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), (CAST_LOCAL_TYPE, |input| retyped_locals(input.infer, &input.doc.chunk)), + (CLOSE_NON_OBJECT, |input| unclosable_values(input.infer, &input.doc.chunk)), (MISSING_PARAMETER, |input| missing_payloads(input.infer, input.payloads())), (REDUNDANT_PARAMETER, |input| redundant_payloads(input.payloads())), (NO_UNKNOWN, |input| { diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index a4e0285..e83a7e0 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -4,6 +4,7 @@ pub mod assistant; pub mod callback_payloads; pub mod casts; pub mod class_tables; +pub mod closes; pub mod code_action; pub mod comparisons; pub mod completion; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index b0fd0af..0c1c724 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6510,6 +6510,59 @@ use(value, decoded) ); } +#[test] +fn closed_locals_need_a_value_that_can_be_closed() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Name string +---@type Test.Name +local name +---@type number? +local maybe +---@class Test.Handle +local handle = {} +local function pair() return 1, nil end +local count = 1 +local label = 'text' +local flag = true +local callback = function() end +local alias = name +local empty +local first, second = 1 +local nothing = nil +local off = false +local optional = maybe +local object = handle +local closable = setmetatable({}, { __close = function() end }) +local a, b = pair() +local varargs = ... +---@diagnostic disable-next-line: close-non-object +local suppressed = 2 +"; + client.open_with(CLIENT, text); + let finding = |needle: &str, message: &str| { + ("close-non-object".to_string(), pos(text, needle, 0).0 as u64, message.to_string()) + }; + let cannot = |ty: &str| { + format!( + "Cannot close a value of type `{ty}`; a `` local takes `nil`, `false` or a value with a `__close` metamethod" + ) + }; + assert_eq!( + findings(&mut client, CLIENT, &["close-non-object"]), + [ + finding("count ", &cannot("integer")), + finding("label ", &cannot("string")), + finding("flag ", &cannot("boolean")), + finding("callback ", &cannot("fun()")), + finding("alias ", &cannot("Test.Name")), + finding("empty ", "`empty` is declared `` without a value to close"), + finding("first, second", "`second` is declared `` without a value to close"), + ], + "`nil`, `false`, values that may be `nil`, tables, classes, values a call leaves out and `...` can be closed" + ); +} + #[test] fn casts_to_classes_the_declared_type_does_not_extend_are_reported() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index 7b2df06..cea4dec 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable` and `unknown-diag-code`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code` and `close-non-object`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 09222e6..e9e4ed9 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -60,6 +60,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `cast-local-type` | warning | An assignment gives a local without `---@type` or `@param` a value that the type of the value it is declared with does not take, such as a string after `local speed = 5`, or `nil` for a local that cannot be `nil`. A literal written out widens to its kind, a loop variable has the type its loop gives it, and a parameter of a function passed for a function type the type it declares; a local declared without a value or as `nil` takes any value. See [Local types](reference.md#local-types). Reported by qbx-lua-ls only. | | `no-unknown` | off | A parameter, local or loop variable has no type: none is declared with `@param` or `@type`, and none can be inferred. A value of unknown type is stored in a typed variable or field, passed for a typed parameter, or returned where `@return` declares a type. Reported by qbx-lua-ls only. | | `const-reassign` | error | An assignment to a `` or `` local. | +| `close-non-object` | warning | A `` local has no value, or one that cannot be closed, such as a number, a string, `true` or a function. A value passes when part of its type is `nil`, `false`, a table or a class, which may have a `__close` metamethod, or unknown. Reported by qbx-lua-ls only. | | `self-assignment` | warning | A variable is assigned to itself. | | `self-comparison` | warning | A comparison has the same expression on both sides. | | `count-down-loop` | warning | A numeric `for` loop starts above its end without a negative step: a literal start above a literal end, with no step or a positive one, which never runs, or `#list, 1` or arithmetic on `#list` such as `#list - 1, 1`, with no step, which never runs once the start is above 1. Fix available. | From c08f02ee36708a2728e3a49870e41111970494ac Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:40:52 +0200 Subject: [PATCH 092/193] feat(rules): add circle-doc-class --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 3 + crates/qbx_lua_analysis/tests/fixtures.rs | 1 + crates/qbx_lua_ls/README.md | 5 +- .../qbx_lua_ls/src/features/class_cycles.rs | 82 +++++++++++++++++++ crates/qbx_lua_ls/src/features/diagnostics.rs | 6 +- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/tests/lsp.rs | 38 +++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 10 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/class_cycles.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 5690432..93e99b1 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -62,6 +62,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "unknown-cast-variable", "unknown-diag-code", "close-non-object", + "circle-doc-class", ]; /// The diagnostic codes of lua-language-server 3.19.1, including those that have no rule here. diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 3bb5455..2d32724 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -82,6 +82,8 @@ pub const DUPLICATE_DOC_ALIAS: &str = "duplicate-doc-alias"; pub const DUPLICATE_DOC_FIELD: &str = "duplicate-doc-field"; pub const DUPLICATE_DOC_PARAM: &str = "duplicate-doc-param"; pub const DOC_FIELD_NO_CLASS: &str = "doc-field-no-class"; +/// Reported by qbx-lua-ls, which indexes the classes of every file; the linter itself sees one file. +pub const CIRCLE_DOC_CLASS: &str = "circle-doc-class"; pub const UNKNOWN_OPERATOR: &str = "unknown-operator"; pub const UNKNOWN_CAST_VARIABLE: &str = "unknown-cast-variable"; pub const UNKNOWN_DIAG_CODE: &str = "unknown-diag-code"; @@ -185,6 +187,7 @@ pub static RULES: &[Rule] = &[ rule(DUPLICATE_DOC_FIELD, Suspicious, WARN, false, "A class declares the same field twice in one file for the same side; repeated function fields are overloads."), rule(DUPLICATE_DOC_PARAM, Suspicious, WARN, false, "A doc comment has two @param annotations for the same parameter."), rule(DOC_FIELD_NO_CLASS, Correctness, WARN, false, "A @field does not directly follow the @class it belongs to, so lua-language-server attaches it to no class."), + rule(CIRCLE_DOC_CLASS, Correctness, WARN, false, "A @class inherits from itself, directly or through its parents (language server only)."), rule(UNKNOWN_OPERATOR, Correctness, WARN, false, "An @operator names an operator that LuaCATS annotations cannot declare, such as `eq`."), rule(UNKNOWN_CAST_VARIABLE, Correctness, WARN, false, "A ---@cast names no local that is in scope where it is written."), rule(UNKNOWN_DIAG_CODE, Suspicious, WARN, false, "A ---@diagnostic or qbx-lint comment names a code that is neither a rule here nor a lua-language-server diagnostic."), diff --git a/crates/qbx_lua_analysis/tests/fixtures.rs b/crates/qbx_lua_analysis/tests/fixtures.rs index 83c0fd9..a5f731d 100644 --- a/crates/qbx_lua_analysis/tests/fixtures.rs +++ b/crates/qbx_lua_analysis/tests/fixtures.rs @@ -96,6 +96,7 @@ fn bad_resource_reports_every_rule() { | "no-unknown" | "impossible-comparison" | "need-check-nil" + | "circle-doc-class" | "close-non-object" ); assert!(exercised || covered_elsewhere, "rule {} is not exercised by the bad_resource fixture", rule.code); diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index 4d08cf1..acfa01f 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -84,8 +84,9 @@ Available features depend on the editor's LSP client. reports fields set through a value whose class or table type does not have them, `undefined-field` fields read from one, and `need-check-nil` a value that may be `nil` used where a missing value raises an error. - `close-non-object` reports a `` local whose value cannot be closed, such as a number. The - opt-in `no-unknown` reports parameters, locals and loop variables that have no type. + `close-non-object` reports a `` local whose value cannot be closed, such as a number, and + `circle-doc-class` a `---@class` that inherits from itself through classes of any file. The opt-in + `no-unknown` reports parameters, locals and loop variables that have no type. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. - QB-Core and ESX server callback completion, navigation and payload hints from local handlers. - Callback systems a resource wraps itself, declared with `---@callback`, with name completion, diff --git a/crates/qbx_lua_ls/src/features/class_cycles.rs b/crates/qbx_lua_ls/src/features/class_cycles.rs new file mode 100644 index 0000000..2200f1a --- /dev/null +++ b/crates/qbx_lua_ls/src/features/class_cycles.rs @@ -0,0 +1,82 @@ +//! `circle-doc-class`: a `---@class` that inherits from itself, directly as `---@class A : A` does, or +//! through its parents, as `---@class A : B` with `---@class B : A` does. Parents declared in other +//! files count, as the index has them, so each declaration on the cycle is reported in its own file. + +use qbx_lua_analysis::env::doc_blocks; +use qbx_lua_syntax::ast::Chunk; +use qbx_lua_syntax::{SmolStr, Span}; +use rustc_hash::FxHashSet; + +use super::class_tables::Classes; +use crate::index::FileId; +use crate::infer::Infer; +use crate::luacats::{declared_name, parse_doc_lines}; +use crate::types::Type; + +/// How many classes deep a chain of parents is followed. +const MAX_DEPTH: u32 = 32; + +/// Each `---@class` line of `chunk` whose class inherits from itself, with the message naming the +/// classes in between. +pub fn circular_classes(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let source = infer.ctx.source; + if !source.contains("@class") { + return Vec::new(); + } + let classes = Classes::new(infer); + let mut out = Vec::new(); + for block in doc_blocks(source, &chunk.comments) { + let lines: Vec<&str> = block.iter().map(|comment| &comment.span.text(source)[3..]).collect(); + let doc = parse_doc_lines(&lines); + for class in &doc.classes { + let mut visited = FxHashSet::default(); + let Some(path) = class + .parent_types + .iter() + .find_map(|parent| path_to(&classes, parent, classes.file(), &class.name, &mut visited, 0)) + else { + continue; + }; + let Some((offset, name)) = declared_name(lines[class.line]) else { continue }; + let start = block[class.line].span.start + 3 + offset as u32; + let message = match path.split_last() { + None => format!("Class `{name}` inherits from itself"), + Some((last, [])) => format!("Class `{name}` inherits from itself through `{last}`"), + Some((last, before)) => { + let before: Vec = before.iter().map(|class| format!("`{class}`")).collect(); + format!("Class `{name}` inherits from itself through {} and `{last}`", before.join(", ")) + } + }; + out.push((Span::new(start, start + name.len() as u32), message)); + } + } + out +} + +/// The classes through which the class `parent`, as `from` sees it, inherits from `target`, starting +/// with `parent` itself and empty when it is `target`, or `None` when it does not inherit from it. +fn path_to( + classes: &Classes, + parent: &Type, + from: FileId, + target: &str, + visited: &mut FxHashSet, + depth: u32, +) -> Option> { + let (name, _, view) = classes.class_of(parent, from)?; + if name == target { + return Some(Vec::new()); + } + if depth > MAX_DEPTH || !visited.insert(name.clone()) { + return None; + } + for (file, def) in classes.class_defs(&name, view) { + for grandparent in &def.parent_types { + if let Some(mut path) = path_to(classes, grandparent, file, target, visited, depth + 1) { + path.insert(0, name); + return Some(path); + } + } + } + None +} diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 89aa009..1517456 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,7 +5,7 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, CLOSE_NON_OBJECT, DISCARD_RETURNS, + ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, CIRCLE_DOC_CLASS, CLOSE_NON_OBJECT, DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, @@ -22,6 +22,7 @@ use super::arguments::mismatched_arguments; use super::assignments::mismatched_assignments; use super::callback_payloads::{missing_payloads, payloads, redundant_payloads, Payload}; use super::casts::mismatched_casts; +use super::class_cycles::circular_classes; use super::class_tables::missing_fields; use super::closes::unclosable_values; use super::comparisons::impossible_comparisons; @@ -95,11 +96,12 @@ fn type_diagnostics( found: &mut Vec, ) { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 20] = [ + let checks: [(&'static str, Check); 21] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) }), + (CIRCLE_DOC_CLASS, |input| circular_classes(input.infer, &input.doc.chunk)), (MISSING_FIELDS, |input| missing_fields(input.infer, &input.doc.chunk)), (ASSIGN_TYPE_MISMATCH, |input| mismatched_assignments(input.infer, &input.doc.chunk)), (PARAM_TYPE_MISMATCH, |input| mismatched_arguments(input.infer, &input.doc.chunk, input.payloads())), diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index e83a7e0..d059190 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -3,6 +3,7 @@ pub mod assignments; pub mod assistant; pub mod callback_payloads; pub mod casts; +pub mod class_cycles; pub mod class_tables; pub mod closes; pub mod code_action; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 0c1c724..27f3790 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -6563,6 +6563,44 @@ local suppressed = 2 ); } +#[test] +fn classes_that_inherit_from_themselves_are_reported_in_each_file() { + const SHARED: &str = "myresource/shared/config.lua"; + let mut client = Client::start(fixture_root()); + let shared = "---@class Test.Loop.B : Test.Loop.C\n---@class Test.Loop.C : Test.Loop.A\nreturn {}\n"; + client.open_with(SHARED, shared); + let text = "\ +---@class Test.Loop.A : Test.Loop.B +---@class Test.Self : Test.Self +---@class Test.Pair.A : Test.Pair.B +---@class Test.Pair.B : Test.Pair.A +---@class Test.Base +---@class Test.Fine : Test.Base +---@class Test.Generic : Test.Base +---@class Test.Child : Test.Generic +---@class Test.Above : Test.Pair.A +"; + client.open_with(CLIENT, text); + let finding = |line: u64, message: &str| ("circle-doc-class".to_string(), line, message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["circle-doc-class"]), + [ + finding(0, "Class `Test.Loop.A` inherits from itself through `Test.Loop.B` and `Test.Loop.C`"), + finding(1, "Class `Test.Self` inherits from itself"), + finding(2, "Class `Test.Pair.A` inherits from itself through `Test.Pair.B`"), + finding(3, "Class `Test.Pair.B` inherits from itself through `Test.Pair.A`"), + ], + "a class that only extends a cycle is not on it" + ); + assert_eq!( + findings(&mut client, SHARED, &["circle-doc-class"]), + [ + finding(0, "Class `Test.Loop.B` inherits from itself through `Test.Loop.C` and `Test.Loop.A`"), + finding(1, "Class `Test.Loop.C` inherits from itself through `Test.Loop.A` and `Test.Loop.B`"), + ] + ); +} + #[test] fn casts_to_classes_the_declared_type_does_not_extend_are_reported() { let mut client = Client::start(fixture_root()); diff --git a/docs/reference.md b/docs/reference.md index cea4dec..c46f7da 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code` and `close-non-object`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index e9e4ed9..ee8041f 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -40,6 +40,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `duplicate-doc-field` | warning | A class declares a field twice in one file for the same side. A repeated function field is another signature of it. | | `duplicate-doc-param` | warning | A doc comment has two `@param` lines for the same parameter. Each of them is reported. | | `doc-field-no-class` | warning | A `@field` has no `@class` above it in its doc comment, or another tag, such as `@deprecated` or `@type`, comes between them. lua-language-server then gives the field to no class; qbx-lua-ls still gives it to the class above. Only other fields, `@operator`, `@overload`, `@source`, `@diagnostic` and description lines may come between a `@class` and its fields; other comments and tags lua-language-server does not read, such as `@author`, are descriptions. | +| `circle-doc-class` | warning | A `@class` inherits from itself, directly as `---@class A : A` does, or through its parents, which other files may declare. Reported by qbx-lua-ls only. | | `unknown-operator` | warning | An `@operator` names an operator that annotations cannot declare, such as `eq` or `index`. The operators are `add`, `sub`, `mul`, `div`, `mod`, `pow`, `idiv`, `band`, `bor`, `bxor`, `shl`, `shr`, `concat`, `unm`, `bnot`, `len` and `call`. | | `unknown-cast-variable` | warning | A `---@cast` names no local that is in scope where it is written, such as a global, a field like `M.x`, or a local declared below it. | | `unknown-diag-code` | warning | A `---@diagnostic` or `-- qbx-lint:` comment names a code that is neither a rule here nor a lua-language-server diagnostic or syntax error, so it suppresses nothing. A close match is suggested. | From ede234715d21ccb17eafb2a09f806d7789640f4a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:57:13 +0200 Subject: [PATCH 093/193] feat(hover): show more or less of hovers at the level lua-language-server's client asks for --- crates/qbx_lua_ls/docs/protocol.md | 15 +- crates/qbx_lua_ls/src/features/hover.rs | 485 +++++++++++++++++++----- crates/qbx_lua_ls/src/server.rs | 16 +- crates/qbx_lua_ls/tests/lsp.rs | 178 +++++++++ 4 files changed, 605 insertions(+), 89 deletions(-) diff --git a/crates/qbx_lua_ls/docs/protocol.md b/crates/qbx_lua_ls/docs/protocol.md index 953be2a..f1d0ef3 100644 --- a/crates/qbx_lua_ls/docs/protocol.md +++ b/crates/qbx_lua_ls/docs/protocol.md @@ -18,7 +18,8 @@ For example: "rules": { "unused-argument": "off" } }, "inlayHints": { "enable": true }, - "semanticTokens": { "enable": true } + "semanticTokens": { "enable": true }, + "hover": { "verbosity": 1 } } ``` @@ -32,6 +33,7 @@ Use absolute paths in `library`; Windows paths such as `C:/server/resources` wor | `diagnostics.rules` | `{}` | Rule levels, as `off`, `hint`, `info`, `warning` or `error`, for the rules the config file does not set. | | `inlayHints.enable` | `true` | Return parameter hints. | | `semanticTokens.enable` | `true` | Return semantic highlighting tokens. | +| `hover.verbosity` | `1` | The [hover level](#client-capabilities-and-file-changes), from `0` to `5`, of hover requests that ask for none. | Send updates using `workspace/didChangeConfiguration`. Its parameters may use either form: @@ -130,6 +132,17 @@ so that the client asks again once a word is typed and gets the names in scope b string in one of those places, or on either side of the comparison, the string values replace the string's contents. +A hover request may carry a `level`, as lua-language-server's VS Code client sends one. Level `0` +writes the type of a value alone, as `local clothes: Clothes`, and keeps the description. Level `1` +also lists the fields of its tables and the aliases they use. Each level up to `5` lists more +fields, `14·n²` at level `n`, and writes out one more step: the tables that fields hold without a +class to name them, the signatures of functions, and below the hover the classes it names, as +`(class) Variation { ... }`, each once. Classes of the built-in runtime, such as `vector3`, are +left out. A request without a `level` uses `hover.verbosity`. The +result adds `maxLevel`: the level above the requested one when that writes more, and otherwise the +lowest level that writes the same as the requested one. A hover that levels do not change, such as +that of a native, has `maxLevel` `0`. + The server requests file watches only when `workspace.didChangeWatchedFiles.dynamicRegistration` is `true`. It watches Lua, lint config, locale JSON and server config files through the client. It has no internal filesystem watcher diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs index 65c52e2..afe6d04 100644 --- a/crates/qbx_lua_ls/src/features/hover.rs +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -1,10 +1,11 @@ use std::sync::Arc; -use lsp_types::{Hover, HoverContents, Position}; +use lsp_types::{Hover, HoverContents, HoverParams, Position}; use qbx_fivem_data::{native, native_docs, Side}; use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolved}; use qbx_lua_syntax::ast::{Expr, ExprKind}; use qbx_lua_syntax::{CommentKind, SmolStr, Span}; +use serde::{Deserialize, Serialize}; use super::{lua_block, markdown, with_infer}; use crate::callback_wrappers::{source_skip, target_of, Wrapper}; @@ -16,7 +17,7 @@ use crate::indexer::{described_values, render_doc}; use crate::infer::{Decl, Infer, MemberInfo}; use crate::locate::{locate, MemberAccess}; use crate::luacats::{applies_on, own_type, type_name_at}; -use crate::types::{CallbackRole, DescribedValue, Type}; +use crate::types::{CallbackRole, DescribedValue, Type, TypeName}; use crate::workspace::Workspace; pub enum Target { @@ -89,30 +90,121 @@ pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { const MAX_OVERVIEW_FIELDS: usize = 14; +/// The deepest level of a hover. Level 0 writes the type of a value alone, 1 the fields of its +/// tables and the aliases they use, and each level above lists more fields and writes out one more +/// step of the tables and classes they hold. +pub const MAX_LEVEL: u32 = 5; + +/// The level a hover is written at, and what writing it finds out about the other levels: the +/// lowest one that writes the same, and whether a higher one writes more. Together they make the +/// `maxLevel` that lua-language-server answers a hover request with. +struct Detail { + level: u32, + same_from: u32, + more: bool, +} + +impl Detail { + fn new(level: u32) -> Self { + Self { level: level.min(MAX_LEVEL), same_from: 0, more: false } + } + + /// Whether what hovers write from `level` on is written at this level. + fn reaches(&mut self, level: u32) -> bool { + if self.level < level { + self.more = true; + return false; + } + self.same_from = self.same_from.max(level); + true + } + + /// How many of `count` items a list that hovers write from level `from` on holds: `per` at that + /// level and `per·n²` at the `n`th, as lua-language-server grows its table overviews. + fn take(&mut self, from: u32, count: usize, per: usize) -> usize { + let cap = |levels: u32| per * (levels as usize).pow(2); + let held = cap(self.level + 1 - from); + if count > held { + self.more = true; + self.same_from = self.level; + return held; + } + let needed = (1..).find(|&levels| cap(levels) >= count).unwrap_or(1); + self.same_from = self.same_from.max(from + needed - 1); + count + } + + /// How many items to collect for a list that hovers write from level `from` on: one more than + /// this level writes, which tells `take` whether there are more. + fn room(&self, from: u32, per: usize) -> usize { + match (self.level + 1).checked_sub(from) { + Some(levels) if levels > 0 => per * (levels as usize).pow(2) + 1, + _ => 1, + } + } + + /// The deepest level that writes more than the one below it. + fn max_level(&self) -> u32 { + if self.more { + (self.level + 1).min(MAX_LEVEL) + } else { + self.same_from + } + } +} + /// `name: type`, a function signature, or for tables an overview of the fields that are in scope -/// for this file, with the literal values the index remembered. Aliases follow on their own lines. -/// The signature of a function that may yield starts with `(async)`, as in lua-language-server. -fn describe_value(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> String { +/// for this file, with the literal values the index remembered. Aliases follow on their own lines, +/// then from level 2 on the classes the hover names. The signature of a function that may yield +/// starts with `(async)`, as in lua-language-server. +fn describe_value( + infer: &Infer, + detail: &mut Detail, + prefix: &str, + name: &str, + ty: &Type, + literal: Option<&str>, +) -> String { if let (Some(fun), Type::Fun(_)) = (ty.as_fun(), ty) { let marker = if fun.is_async { "(async) " } else { "" }; let mut out = format!("{marker}{prefix}{}", fun.signature(name)); - push_aliases(&mut out, &nested_aliases(infer, [ty], &[], false)); + let room = detail.room(1, MAX_NESTED_ALIASES); + let aliases = alias_lines(detail, 1, used_aliases(infer, [ty], &[], false, room)); + push_aliases(&mut out, &aliases); + let mut listed = names(&aliases); + let mut classes = Vec::new(); + used_classes(infer, [ty], false, &listed, &mut classes); + push_classes(infer, detail, &mut out, &mut listed, classes, 2); return out; } - let (mut out, shown) = value_overview(infer, prefix, name, ty, literal); - let aliases = alias_expansions(infer, ty); + let (mut out, shown) = value_overview(infer, detail, prefix, name, ty, literal); + let mut aliases = alias_expansions(infer, ty); + let skip = names(&aliases); + if !aliases.is_empty() && !detail.reaches(1) { + aliases.clear(); + } push_aliases(&mut out, &aliases); // Then those that its type arguments and the fields it lists use, as `Mode` of `Box` or of // `Car { mode: Mode }`. - let names = |aliases: &[(SmolStr, &AliasDef)]| aliases.iter().map(|(name, _)| name.clone()).collect::>(); - let mut nested = nested_aliases(infer, [ty], &names(&aliases), true); - let listed = [names(&aliases), names(&nested)].concat(); - nested.extend(nested_aliases(infer, &shown, &listed, false)); - nested.truncate(MAX_NESTED_ALIASES); + let room = detail.room(1, MAX_NESTED_ALIASES); + let mut nested = used_aliases(infer, [ty], &skip, true, room); + let mut listed = [skip, names(&nested)].concat(); + nested.extend(used_aliases(infer, shown.iter().flatten(), &listed, false, room)); + let nested = alias_lines(detail, 1, nested); push_aliases(&mut out, &nested); + listed.extend(names(&nested)); + // A table that the overview lists the fields of has its own class written out already. + let mut classes = Vec::new(); + used_classes(infer, [ty], shown.is_some(), &listed, &mut classes); + used_classes(infer, shown.iter().flatten(), false, &listed, &mut classes); + push_classes(infer, detail, &mut out, &mut listed, classes, 2); out } +fn names(aliases: &[(SmolStr, &AliasDef)]) -> Vec { + aliases.iter().map(|(name, _)| name.clone()).collect() +} + /// Writes each alias out on a line of its own after `out`. fn push_aliases(out: &mut String, aliases: &[(SmolStr, &AliasDef)]) { for (name, alias) in aliases { @@ -121,13 +213,66 @@ fn push_aliases(out: &mut String, aliases: &[(SmolStr, &AliasDef)]) { } } -/// `name: type`, or for tables an overview of their fields, with the types of the fields it lists. -fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> (String, Vec) { +/// The aliases that hovers write out from level `from` on, as many as this level writes. +fn alias_lines<'a>( + detail: &mut Detail, + from: u32, + mut aliases: Vec<(SmolStr, &'a AliasDef)>, +) -> Vec<(SmolStr, &'a AliasDef)> { + if aliases.is_empty() || !detail.reaches(from) { + return Vec::new(); + } + aliases.truncate(detail.take(from, aliases.len(), MAX_NESTED_ALIASES)); + aliases +} + +/// Writes out the classes a hover names from level `from` on, each once after the lines before +/// it, followed by the aliases their fields use. The classes those fields name follow one level +/// later, and so on. +fn push_classes<'a>( + infer: &Infer<'a>, + detail: &mut Detail, + out: &mut String, + listed: &mut Vec, + mut classes: Vec<(SmolStr, &'a ClassDef)>, + mut from: u32, +) { + while !classes.is_empty() && detail.reaches(from) { + classes.truncate(detail.take(from, classes.len(), MAX_NESTED_ALIASES)); + listed.extend(classes.iter().map(|(name, _)| name.clone())); + let mut shown = Vec::new(); + for (_, class) in &classes { + let (block, types) = class_block(infer, detail, class, from); + out.push('\n'); + out.push_str(&block); + shown.extend(types); + } + let room = detail.room(from, MAX_NESTED_ALIASES); + let aliases = alias_lines(detail, from, used_aliases(infer, &shown, listed, false, room)); + push_aliases(out, &aliases); + listed.extend(names(&aliases)); + classes = Vec::new(); + used_classes(infer, &shown, false, listed, &mut classes); + from += 1; + } +} + +/// `name: type`, or for tables an overview of their fields from level 1 on, with the types of the +/// fields it lists. The types are `None` for a value that is no table. +fn value_overview( + infer: &Infer, + detail: &mut Detail, + prefix: &str, + name: &str, + ty: &Type, + literal: Option<&str>, +) -> (String, Option>) { let bare = ty.without_nil(); - let members = infer.members(&table_part(infer, &bare, 0)); + let owner = table_part(infer, &bare, 0); + let members = infer.members(&owner); if members.is_empty() { let value = literal.map(|l| format!(" = {l}")).unwrap_or_default(); - return (format!("{prefix}{name}: {}{value}", shown_type(infer, ty)), Vec::new()); + return (format!("{prefix}{name}: {}{value}", shown_type(infer, ty)), None); } // The fields are those of the value when it is not nil; the `?` still says it may be. let optional = if *ty != bare && matches!(ty, Type::Union(types) if types.contains(&Type::Nil)) { "?" } else { "" }; @@ -138,30 +283,107 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O Type::Union(types) if !types.iter().any(is_table_value) => format!("{ty} "), _ => String::new(), }; - let mut out = format!("{prefix}{name}: {label}{{"); - let mut shown = Vec::new(); - for member in members.iter().take(MAX_OVERVIEW_FIELDS) { - // A table type that a field declares, as `{ [string]: Variation }`, is written out as - // lua-language-server writes it; a table the index only knows by its path is not. - let ty = match &member.ty { - Type::Fun(_) => "function".to_string(), - Type::GlobalTable(_) => "table".to_string(), - other => { - shown.push(other.clone()); - other.to_string() - } - }; - let value = member.literal.as_ref().map(|l| format!(" = {l}")).unwrap_or_default(); - out.push_str(&format!("\n {}: {ty}{value},", member.name)); - } - if members.len() > MAX_OVERVIEW_FIELDS { - out.push_str(&format!("\n ...(+{})", members.len() - MAX_OVERVIEW_FIELDS)); + if !detail.reaches(1) { + let label = if label.is_empty() { format!("table{optional}") } else { label.trim_end().to_string() }; + return (format!("{prefix}{name}: {label}"), Some(Vec::new())); } - out.push_str("\n}"); + let tables = match &bare { + Type::GlobalTable(path) => vec![path.clone()], + _ => Vec::new(), + }; + let out = format!("{prefix}{name}: {label}{{"); + let mut overview = Overview { infer, detail, out, shown: Vec::new(), tables }; + overview.fields(&owner, &members, 1, 1); + let Overview { mut out, shown, .. } = overview; + out.push_str( + " +}", + ); if label.is_empty() { out.push_str(optional); } - (out, shown) + (out, Some(shown)) +} + +/// Writes out the fields of tables, collecting the types it shows. `tables` are the paths of the +/// tables it is inside of, which a field that holds one of them leaves at `table`. +struct Overview<'i, 'a> { + infer: &'i Infer<'a>, + detail: &'i mut Detail, + out: String, + shown: Vec, + tables: Vec, +} + +impl Overview<'_, '_> { + /// The fields of `owner` that hovers list from level `from` on, `indent` steps in. A table type + /// that a field declares, as `{ [string]: Variation }`, is written out as lua-language-server + /// writes it. A table the index only knows by its path is `table`, and its fields follow in + /// place one level later, as do the signatures of functions. + fn fields(&mut self, owner: &Type, members: &[MemberInfo], from: u32, indent: usize) { + let pad = " ".repeat(indent); + let count = self.detail.take(from, members.len(), MAX_OVERVIEW_FIELDS); + for member in &members[..count] { + self.out.push_str(&format!( + " +{pad}{}: ", + member.name + )); + match &member.ty { + Type::Fun(_) if !self.detail.reaches(from + 1) => self.out.push_str("function"), + Type::GlobalTable(path) => self.table(path, from + 1, indent), + // Tables that several files assign merge into a bare `table`, while reading the + // field finds the one the index holds, as hovering it does. + Type::Table => match self.infer.member(owner, &member.name).map(|info| info.ty) { + Some(Type::GlobalTable(path)) => self.table(&path, from + 1, indent), + _ => self.out.push_str("table"), + }, + other => { + self.shown.push(other.clone()); + self.out.push_str(&other.to_string()); + } + } + let value = member.literal.as_ref().map(|l| format!(" = {l}")).unwrap_or_default(); + self.out.push_str(&format!("{value},")); + } + if members.len() > count { + self.out.push_str(&format!( + " +{pad}...(+{})", + members.len() - count + )); + } + } + + /// A table that a field `indent` steps in holds, which the index knows by its `path`: `table`, + /// then from level `from` on its fields, or the array type of a table with only integer keys. + fn table(&mut self, path: &SmolStr, from: u32, indent: usize) { + let ty = Type::GlobalTable(path.clone()); + let members = if self.tables.contains(path) { Vec::new() } else { self.infer.members(&ty) }; + if members.is_empty() { + match shown_type(self.infer, &ty) { + array @ Type::Array(_) if self.detail.reaches(from) => { + self.out.push_str(&array.to_string()); + self.shown.push(array); + } + _ => self.out.push_str("table"), + } + return; + } + if !self.detail.reaches(from) { + self.out.push_str("table"); + return; + } + self.out.push('{'); + self.tables.push(path.clone()); + self.fields(&ty, &members, from, indent + 1); + self.tables.pop(); + self.out.push_str(&format!( + " +{}}}", + " ".repeat(indent) + )); + } } /// Whether `ty` is a table that a hover shows by its fields alone, such as `{ name: string }` or @@ -224,7 +446,8 @@ fn alias_expansions<'a>(infer: &Infer<'a>, ty: &Type) -> Vec<(SmolStr, &'a Alias } /// How many aliases a hover, or a parameter in signature help, writes out after the types that use -/// them, so that a class with many fields stays readable. +/// them, so that a class with many fields stays readable. Hovers write out as many classes from +/// level 2 on, and more of both at higher levels. const MAX_NESTED_ALIASES: usize = 8; /// The aliases that `types` use in the parameters and returns of functions, the fields of tables and @@ -237,37 +460,70 @@ pub(super) fn nested_aliases<'a, 't>( skip: &[SmolStr], top: bool, ) -> Vec<(SmolStr, &'a AliasDef)> { - let mut out = Vec::new(); + used_aliases(infer, types, skip, top, MAX_NESTED_ALIASES) +} + +/// The first `limit` of the aliases that `nested_aliases` finds. +fn used_aliases<'a, 't>( + infer: &Infer<'a>, + types: impl IntoIterator, + skip: &[SmolStr], + top: bool, + limit: usize, +) -> Vec<(SmolStr, &'a AliasDef)> { + let mut out: Vec<(SmolStr, &AliasDef)> = Vec::new(); for ty in types { - collect_aliases(infer, ty, skip, top, 0, &mut out); + visit_names(ty, top, 0, &mut |name, top| { + let listed = skip.iter().chain(out.iter().map(|(seen, _)| seen)).any(|seen| *seen == name.text); + if top || listed || out.len() >= limit || infer.index.class(name, infer.side()).is_some() { + return; + } + if let Some((_, alias)) = infer.index.alias(name, infer.side()) { + out.push((name.text.clone(), alias)); + } + }); } out } -fn collect_aliases<'a>( +/// Adds the classes that `types` name to `out`, as `Variation` of `{ [string]: Variation }` or of +/// `fun(variation: Variation)`, each once and leaving out those in `listed`, those without fields to +/// write out and those of the built-in library, such as `vector3`. With `top`, the classes the types +/// name themselves are left out too, as a hover that lists their fields already shows them. +fn used_classes<'a, 't>( infer: &Infer<'a>, - ty: &Type, - skip: &[SmolStr], + types: impl IntoIterator, top: bool, - depth: u32, - out: &mut Vec<(SmolStr, &'a AliasDef)>, + listed: &[SmolStr], + out: &mut Vec<(SmolStr, &'a ClassDef)>, ) { - if depth > 8 || out.len() >= MAX_NESTED_ALIASES { + for ty in types { + visit_names(ty, top, 0, &mut |name, top| { + if top || listed.contains(&name.text) || out.iter().any(|(seen, _)| *seen == name.text) { + return; + } + let Some((file, class)) = infer.index.class(name, infer.side()) else { return }; + let library = infer.index.file(file).is_some_and(|entry| entry.origin == FileOrigin::Stub); + if !library && has_fields(infer, class) { + out.push((name.text.clone(), class)); + } + }); + } +} + +/// Calls `visit` with each class or alias name in `ty`, after the type arguments given to it, and +/// with `top` for the names that `ty` is itself, alone or in a union or an array. +fn visit_names(ty: &Type, top: bool, depth: u32, visit: &mut impl FnMut(&TypeName, bool)) { + if depth > 8 { return; } - let mut inner = |ty: &Type, top: bool| collect_aliases(infer, ty, skip, top, depth + 1, out); + let mut inner = |ty: &Type, top: bool| visit_names(ty, top, depth + 1, visit); match ty { Type::Named(name, args) => { for arg in args { inner(arg, false); } - let listed = skip.iter().chain(out.iter().map(|(seen, _)| seen)).any(|seen| *seen == name.text); - if top || listed || out.len() >= MAX_NESTED_ALIASES || infer.index.class(name, infer.side()).is_some() { - return; - } - if let Some((_, alias)) = infer.index.alias(name, infer.side()) { - out.push((name.text.clone(), alias)); - } + visit(name, top); } Type::Union(types) => types.iter().for_each(|ty| inner(ty, top)), Type::Array(item) | Type::Variadic(item) => inner(item, top), @@ -323,7 +579,7 @@ fn with_params(name: &str, generics: &[SmolStr]) -> String { } /// `offset` is where the name is written, which decides what the guards around it rule out. -fn local_hover(infer: &Infer, id: LocalId, offset: u32, called: Option) -> String { +fn local_hover(infer: &Infer, detail: &mut Detail, id: LocalId, offset: u32, called: Option) -> String { let local = infer.ctx.resolution.local(id); let ty = called.unwrap_or_else(|| infer.local_type_at(id, offset)); let prefix = match local.kind { @@ -332,7 +588,7 @@ fn local_hover(infer: &Infer, id: LocalId, offset: u32, called: Option) -> LocalKind::LoopVar => "(loop variable) ", LocalKind::Local | LocalKind::LocalFunction => "local ", }; - let mut out = lua_block(&describe_value(infer, prefix, &local.name, &ty, None)); + let mut out = lua_block(&describe_value(infer, detail, prefix, &local.name, &ty, None)); let doc = match infer.ctx.decl(local.decl.start) { Some(Decl::Local { stmt, .. } | Decl::LocalFunction { stmt, .. }) => { render_doc(&infer.ctx.doc_at(stmt.span.start)) @@ -366,7 +622,13 @@ fn native_hover(name: &str, side: Option) -> Option { Some(out) } -fn global_hover(ws: &Workspace, infer: &Infer, name: &str, called: Option) -> Option { +fn global_hover( + ws: &Workspace, + infer: &Infer, + detail: &mut Detail, + name: &str, + called: Option, +) -> Option { let symbols = ws.index.globals_named(name, infer.ctx.file); let preferred = symbols .iter() @@ -376,14 +638,14 @@ fn global_hover(ws: &Workspace, infer: &Infer, name: &str, called: Option) return Some(hover); } let ty = infer.global_type(name); - return (!ty.is_unknown()).then(|| lua_block(&describe_value(infer, "(global) ", name, &ty, None))); + return (!ty.is_unknown()).then(|| lua_block(&describe_value(infer, detail, "(global) ", name, &ty, None))); }; // Going through `global_type` merges the table with members other files of the resource add. let ty = match called.unwrap_or_else(|| infer.global_type(name)) { Type::Unknown => symbol.ty.clone(), resolved => resolved, }; - let mut out = lua_block(&describe_value(infer, "(global) ", name, &ty, symbol.literal.as_deref())); + let mut out = lua_block(&describe_value(infer, detail, "(global) ", name, &ty, symbol.literal.as_deref())); if let Some(doc) = &symbol.doc { out.push_str("\n\n"); out.push_str(doc); @@ -403,7 +665,7 @@ fn global_hover(ws: &Workspace, infer: &Infer, name: &str, called: Option) Some(out) } -pub fn member_hover(infer: &Infer, info: &MemberInfo, owner: &Type) -> String { +fn member_hover(infer: &Infer, detail: &mut Detail, info: &MemberInfo, owner: &Type) -> String { let owner_label = owner_label(owner); let is_method = info.ty.as_fun().is_some_and(|f| f.is_method); let qualified = match (owner_label.is_empty(), is_method) { @@ -412,7 +674,7 @@ pub fn member_hover(infer: &Infer, info: &MemberInfo, owner: &Type) -> String { (false, false) => format!("{owner_label}.{}", info.name), }; let prefix = if matches!(info.kind, SymbolKind::Field) && info.ty.as_fun().is_none() { "(field) " } else { "" }; - let mut out = lua_block(&describe_value(infer, prefix, &qualified, &info.ty, info.literal.as_deref())); + let mut out = lua_block(&describe_value(infer, detail, prefix, &qualified, &info.ty, info.literal.as_deref())); if info.deprecated { out.push_str("\n\n**Deprecated**"); } @@ -454,39 +716,65 @@ fn owner_label(owner: &Type) -> String { } } -fn class_hover(infer: &Infer, class: &ClassDef) -> String { +/// The fields a class declares, as a hover lists them, with their types. +fn class_fields(infer: &Infer, class: &ClassDef) -> Vec<(String, Type)> { + let members = infer.members(&own_type(&class.name, &class.generics)); + let mut fields: Vec<(String, Type)> = + members.iter().map(|member| (format!("{}: {}", member.name, member.ty), member.ty.clone())).collect(); + fields.extend(class.literal_fields(infer.side()).map(|(key, value)| (format!("[{key}]: {value}"), value.clone()))); + fields +} + +/// Whether a class has fields for a hover to list. +fn has_fields(infer: &Infer, class: &ClassDef) -> bool { + !class_fields(infer, class).is_empty() || !class.indices(infer.side()).is_empty() +} + +/// `(class) Name : Parent`, with from level `from` on the fields it declares, and the types of +/// the fields it lists. +fn class_block(infer: &Infer, detail: &mut Detail, class: &ClassDef, from: u32) -> (String, Vec) { let mut declaration = format!("(class) {}", with_params(&class.name, &class.generics)); if !class.parent_types.is_empty() { let parents: Vec = class.parent_types.iter().map(Type::to_string).collect(); declaration.push_str(&format!(" : {}", parents.join(", "))); } - let members = infer.members(&own_type(&class.name, &class.generics)); - let mut fields: Vec<(String, Type)> = - members.iter().map(|member| (format!("{}: {}", member.name, member.ty), member.ty.clone())).collect(); - fields.extend(class.literal_fields(infer.side()).map(|(key, value)| (format!("[{key}]: {value}"), value.clone()))); + let fields = class_fields(infer, class); let indices = class.indices(infer.side()); - let mut shown: Vec<&Type> = Vec::new(); - if !fields.is_empty() || !indices.is_empty() { + let mut shown: Vec = Vec::new(); + if (!fields.is_empty() || !indices.is_empty()) && detail.reaches(from) { declaration.push_str(" {"); - for (field, ty) in fields.iter().take(MAX_OVERVIEW_FIELDS) { + let count = detail.take(from, fields.len(), MAX_OVERVIEW_FIELDS); + for (field, ty) in &fields[..count] { declaration.push_str(&format!("\n {field},")); - shown.push(ty); + shown.push(ty.clone()); } for (key, value) in &indices { declaration.push_str(&format!("\n [{key}]: {value},")); - shown.extend([key, value]); + shown.extend([(*key).clone(), (*value).clone()]); } - if fields.len() > MAX_OVERVIEW_FIELDS { - declaration.push_str(&format!("\n ...(+{})", fields.len() - MAX_OVERVIEW_FIELDS)); + if fields.len() > count { + declaration.push_str(&format!("\n ...(+{})", fields.len() - count)); } declaration.push_str("\n}"); } + (declaration, shown) +} + +fn class_hover(infer: &Infer, detail: &mut Detail, class: &ClassDef) -> String { + let (mut declaration, shown) = class_block(infer, detail, class, 1); let parents = class.parent_types.iter().filter_map(|parent| match parent { Type::Named(_, args) => Some(args.iter()), _ => None, }); - let used = parents.flatten().chain(shown); - push_aliases(&mut declaration, &nested_aliases(infer, used, std::slice::from_ref(&class.name), false)); + let used: Vec = parents.flatten().cloned().chain(shown).collect(); + let mut listed = vec![class.name.clone()]; + let room = detail.room(1, MAX_NESTED_ALIASES); + let aliases = alias_lines(detail, 1, used_aliases(infer, &used, &listed, false, room)); + push_aliases(&mut declaration, &aliases); + listed.extend(names(&aliases)); + let mut classes = Vec::new(); + used_classes(infer, &used, false, &listed, &mut classes); + push_classes(infer, detail, &mut declaration, &mut listed, classes, 2); let mut out = lua_block(&declaration); if let Some(doc) = &class.doc { out.push_str("\n\n"); @@ -497,7 +785,7 @@ fn class_hover(infer: &Infer, class: &ClassDef) -> String { /// A class or alias, preferring declarations for this file's side, then workspace declarations over /// the built-in library, then this file's over those of other files. -fn type_hover(infer: &Infer, name: &str) -> Option { +fn type_hover(infer: &Infer, detail: &mut Detail, name: &str) -> Option { let preference = |file: FileId, side: Option| { ( applies_on(side, infer.side()), @@ -507,12 +795,19 @@ fn type_hover(infer: &Infer, name: &str) -> Option { }; let classes = infer.index.class_defs(name).into_iter(); if let Some((_, class)) = classes.max_by_key(|(file, class)| preference(*file, class.side)) { - return Some(class_hover(infer, class)); + return Some(class_hover(infer, detail, class)); } let aliases = infer.index.alias_defs(name).into_iter(); let (_, alias) = aliases.max_by_key(|(file, alias)| preference(*file, alias.side))?; let mut definition = alias_definition(name, alias); - push_aliases(&mut definition, &nested_aliases(infer, [&alias.ty], &[SmolStr::new(name)], false)); + let mut listed = vec![SmolStr::new(name)]; + let room = detail.room(1, MAX_NESTED_ALIASES); + let aliases = alias_lines(detail, 1, used_aliases(infer, [&alias.ty], &listed, false, room)); + push_aliases(&mut definition, &aliases); + listed.extend(names(&aliases)); + let mut classes = Vec::new(); + used_classes(infer, [&alias.ty], false, &listed, &mut classes); + push_classes(infer, detail, &mut definition, &mut listed, classes, 2); let mut out = lua_block(&definition); if let Some(doc) = &alias.doc { out.push_str("\n\n"); @@ -679,23 +974,43 @@ fn string_hover(ws: &Workspace, infer: &Infer, doc: &Document, offset: u32) -> O Some((out, string.span)) } -pub fn hover(ws: &Workspace, doc: &Document, position: Position) -> Option { +/// A hover request, with the `level` that lua-language-server's client asks for. +#[derive(Deserialize)] +pub struct LevelHoverParams { + #[serde(flatten)] + pub params: HoverParams, + pub level: Option, +} + +/// A hover, with the deepest level that writes more of it, as lua-language-server answers. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LevelHover { + #[serde(flatten)] + pub hover: Hover, + pub max_level: u32, +} + +pub fn hover(ws: &Workspace, doc: &Document, position: Position, level: u32) -> Option { let offset = doc.offset(position); + let mut detail = Detail::new(level); let (text, span) = with_infer(ws, doc, |infer| { let Some(target) = target_at(infer, doc, offset) else { return super::native_argument::hover(ws, doc, offset).or_else(|| string_hover(ws, infer, doc, offset)); }; let called = called_overload(infer, doc, offset); + let detail = &mut detail; let text = match &target { - Target::Local(id, span) => Some(local_hover(infer, *id, span.start, called)), - Target::Global(name, _) => global_hover(ws, infer, name, called), + Target::Local(id, span) => Some(local_hover(infer, detail, *id, span.start, called)), + Target::Global(name, _) => global_hover(ws, infer, detail, name, called), Target::Member { info, owner, .. } => match called { - Some(ty) => Some(member_hover(infer, &MemberInfo { ty, ..info.clone() }, owner)), - None => Some(member_hover(infer, info, owner)), + Some(ty) => Some(member_hover(infer, detail, &MemberInfo { ty, ..info.clone() }, owner)), + None => Some(member_hover(infer, detail, info, owner)), }, - Target::Type(name, _) => type_hover(infer, name), + Target::Type(name, _) => type_hover(infer, detail, name), }; text.map(|t| (t, target.span())) })?; - Some(Hover { contents: HoverContents::Markup(markdown(text)), range: Some(doc.range(span)) }) + let hover = Hover { contents: HoverContents::Markup(markdown(text)), range: Some(doc.range(span)) }; + Some(LevelHover { hover, max_level: detail.max_level() }) } diff --git a/crates/qbx_lua_ls/src/server.rs b/crates/qbx_lua_ls/src/server.rs index 0fab234..081ebbc 100644 --- a/crates/qbx_lua_ls/src/server.rs +++ b/crates/qbx_lua_ls/src/server.rs @@ -40,6 +40,13 @@ pub struct ToggleSettings { pub enable: Option, } +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase", default)] +pub struct HoverSettings { + /// The level of hovers whose request asks for none. + pub verbosity: Option, +} + #[derive(Debug, Deserialize, Default, Clone)] #[serde(rename_all = "camelCase", default)] pub struct Settings { @@ -47,6 +54,7 @@ pub struct Settings { pub diagnostics: DiagnosticSettings, pub inlay_hints: ToggleSettings, pub semantic_tokens: ToggleSettings, + pub hover: HoverSettings, } impl Settings { @@ -755,9 +763,11 @@ impl Server { } req::ResolveCompletionItem::METHOD => reply(completion::resolve(params(raw)?)), req::HoverRequest::METHOD => { - let p: HoverParams = params(raw)?; - let doc = self.doc(&p.text_document_position_params.text_document.uri)?; - reply(hover::hover(&self.ws, doc, p.text_document_position_params.position)) + let p: hover::LevelHoverParams = params(raw)?; + let doc = self.doc(&p.params.text_document_position_params.text_document.uri)?; + let level = p.level.or(self.settings.hover.verbosity).unwrap_or(1); + let level = level.clamp(0, hover::MAX_LEVEL.into()) as u32; + reply(hover::hover(&self.ws, doc, p.params.text_document_position_params.position, level)) } req::SignatureHelpRequest::METHOD => { let p: SignatureHelpParams = params(raw)?; diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 27f3790..14a5ac7 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -188,6 +188,18 @@ impl Client { let result = self.request("textDocument/hover", self.position_params(relative, line, character)); result["contents"]["value"].as_str().unwrap_or_default().to_string() } + + /// The hover asked for at `level`, or at the server's own level without one, and the `maxLevel` + /// it is answered with. + fn hover_at_level(&mut self, relative: &str, line: u32, character: u32, level: Option) -> (String, u64) { + let mut params = self.position_params(relative, line, character); + if let Some(level) = level { + params["level"] = json!(level); + } + let result = self.request("textDocument/hover", params); + let text = result["contents"]["value"].as_str().unwrap_or_default().to_string(); + (text, result["maxLevel"].as_u64().expect("maxLevel")) + } } impl Drop for Client { @@ -9656,6 +9668,172 @@ print(clothes, nested) assert!(hover.contains("inner: table,"), "a table the index holds stays `table`: {hover}"); } +#[test] +fn hover_levels_write_out_the_classes_fields_use() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@class Test.Variation +---@field drawable number +---@field texture number + +---@alias Test.HideMethod \"none\"|\"resetFlag\" + +---@class Test.Clothes +---@field components { [string]: Test.Variation } +---@field hideHead Test.HideMethod +---@field coords vector3 + +---@type Test.Clothes +local clothes = { components = {}, hideHead = 'none', coords = vector3(0, 0, 0) } +print(clothes) +"; + client.open_with(CLIENT, text); + let (l, c) = pos(text, "print(clothes", 6); + let alias = "type Test.HideMethod = \"none\"|\"resetFlag\""; + let variation = "(class) Test.Variation {\n drawable: number,\n texture: number,\n}"; + + let (compact, max) = client.hover_at_level(CLIENT, l, c, Some(0)); + assert_eq!(compact, "```lua\nlocal clothes: Test.Clothes\n```"); + assert_eq!(max, 1); + + let (normal, max) = client.hover_at_level(CLIENT, l, c, Some(1)); + assert!(normal.contains("components: { [string]: Test.Variation },") && normal.contains(alias), "{normal}"); + assert!(!normal.contains("(class)"), "{normal}"); + assert_eq!(max, 2); + assert_eq!(client.hover_at_level(CLIENT, l, c, None), (normal.clone(), 2), "level 1 is the default"); + + let (detailed, max) = client.hover_at_level(CLIENT, l, c, Some(2)); + assert!(detailed.starts_with(&normal[..normal.len() - 4]), "{detailed}"); + assert!(detailed.contains(&format!("{alias}\n{variation}\n```")), "{detailed}"); + assert!(!detailed.contains("(class) vector3"), "classes of the built-in library are left out: {detailed}"); + assert_eq!(max, 2); + assert_eq!(client.hover_at_level(CLIENT, l, c, Some(4)), (detailed, 2), "nothing more to write out"); +} + +#[test] +fn hover_levels_list_more_fields_and_write_out_tables_and_functions() { + let mut client = Client::start(fixture_root()); + let fields: String = (1..=20).map(|n| format!("---@field f{n} number\n")).collect(); + let text = format!( + "\ +---@class Test.Wide +{fields} +---@type Test.Wide +local wide = {{}} +local nested = {{ inner = {{ value = 1, deeper = {{ flag = true }} }}, run = function(a) return a end }} +print(wide, nested) +" + ); + client.open_with(CLIENT, &text); + let (l, c) = pos(&text, "print(wide", 6); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(1)); + assert!(hover.contains("f14: number,\n ...(+6)\n}"), "{hover}"); + assert_eq!(max, 2); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(2)); + assert!(hover.contains("f20: number,\n}") && !hover.contains("...("), "{hover}"); + assert_eq!(max, 2); + + let (l, c) = pos(&text, "nested)", 0); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(1)); + assert!(hover.contains("inner: table,") && hover.contains("run: function,"), "{hover}"); + assert_eq!(max, 2); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(2)); + let inner = "inner: {\n value: integer = 1,\n deeper: table,\n },"; + assert!(hover.contains(inner) && hover.contains("run: fun(a"), "{hover}"); + assert_eq!(max, 3); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(3)); + assert!(hover.contains("deeper: {\n flag: boolean = true,\n },"), "{hover}"); + assert_eq!(max, 3); +} + +#[test] +fn hover_levels_write_out_tables_that_assignments_of_two_tables_merge() { + let mut client = Client::start(fixture_root()); + let text = "\ +local defaults = {} +defaults.Battery = {} +defaults.Battery.Enabled = false +TestPhoneConfig = {} +TestPhoneConfig.Battery = {} +TestPhoneConfig.Battery.Enabled = true +if not TestPhoneConfig.Battery then + TestPhoneConfig = defaults +end +print(TestPhoneConfig) +"; + client.open_with(CLIENT, text); + let (l, c) = pos(text, "print(TestPhoneConfig", 6); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(1)); + assert!(hover.contains("Battery: table,"), "{hover}"); + assert_eq!(max, 2); + let (hover, _) = client.hover_at_level(CLIENT, l, c, Some(2)); + assert!(hover.contains("Battery: {\n Enabled: boolean = true,\n },"), "{hover}"); +} + +#[test] +fn hover_levels_of_functions_types_and_plain_values() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Mode \"fast\"|\"slow\" + +---@class Test.Car +---@field speed number + +---Drives the car. +---@param car Test.Car +---@param mode Test.Mode +local function drive(car, mode) end +local count = 1 +drive(nil, 'fast') +print(count, GetPlayerPed(-1)) +"; + client.open_with(CLIENT, text); + let mode = "type Test.Mode = \"fast\"|\"slow\""; + let car = "(class) Test.Car {\n speed: number,\n}"; + let (l, c) = pos(text, "drive(nil", 0); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(0)); + assert!(hover.contains("local function drive(car: Test.Car, mode: Test.Mode)\n```"), "{hover}"); + assert!(hover.contains("Drives the car."), "level 0 keeps the description: {hover}"); + assert_eq!(max, 1); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(1)); + assert!(hover.contains(mode) && !hover.contains(car), "{hover}"); + assert_eq!(max, 2); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(2)); + assert!(hover.contains(&format!("{mode}\n{car}")), "{hover}"); + assert_eq!(max, 2); + + let (l, c) = pos(text, "---@param car Test.Car", 18); + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(0)); + assert_eq!((hover.lines().nth(1), max), (Some("(class) Test.Car"), 1), "{hover}"); + + for (needle, offset) in [("count, Get", 0), ("GetPlayerPed(-1)", 0)] { + let (l, c) = pos(text, needle, offset); + for level in [0, 1, 3] { + let (hover, max) = client.hover_at_level(CLIENT, l, c, Some(level)); + assert_eq!(max, 0, "{needle} at level {level}: {hover}"); + } + } +} + +#[test] +fn hover_level_defaults_to_the_verbosity_setting() { + let options = json!({ "hover": { "verbosity": 0 } }); + let mut client = Client::start_with_options(fixture_root(), json!({}), options); + let text = "\ +---@class Test.Point +---@field x number + +---@type Test.Point +local point = { x = 1 } +print(point) +"; + client.open_with(CLIENT, text); + let (l, c) = pos(text, "print(point", 6); + assert_eq!(client.hover_at_level(CLIENT, l, c, None), ("```lua\nlocal point: Test.Point\n```".into(), 1)); + let (hover, _) = client.hover_at_level(CLIENT, l, c, Some(1)); + assert!(hover.contains("x: number,"), "a level in the request wins: {hover}"); +} + #[test] fn calls_through_function_aliases_use_their_signature() { let mut client = Client::start(fixture_root()); From dbb6d3abc1e6ab6016eef21373919df10b182678 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:43 +0200 Subject: [PATCH 094/193] feat(rules): report code after an if none of whose branches finishes in unreachable-code --- crates/qbx_lua_analysis/src/checks/flow.rs | 97 ++++++++++++++++++++-- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_analysis/tests/rules.rs | 30 +++++++ docs/rules.md | 2 +- 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index a75bad4..5505b2b 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -82,23 +82,65 @@ impl Flow<'_, '_> { } } + /// `unreachable-code`: the statements after one that never finishes, up to a label, which a + /// `goto` may still reach. fn unreachable(&mut self, block: &Block) { - let exit = block.stmts.iter().position(|s| matches!(s.kind, StmtKind::Break | StmtKind::Goto(_))); - let Some(exit) = exit else { return }; - let Some(next) = block.stmts.get(exit + 1) else { return }; - if matches!(next.kind, StmtKind::Label(_)) { - return; - } - let end = block.stmts.last().map_or(next.span.end, |s| s.span.end); + let Some(exit) = block.stmts.iter().position(|stmt| self.never_finishes(stmt)) else { return }; + let rest = &block.stmts[exit + 1..]; + let reachable = rest.iter().position(|stmt| matches!(stmt.kind, StmtKind::Label(_))).unwrap_or(rest.len()); + let (Some(first), Some(last)) = (rest.first(), rest[..reachable].last()) else { return }; self.sink.report_with( rules::UNREACHABLE_CODE, - Span::new(next.span.start, end), + Span::new(first.span.start, last.span.end), "unreachable code", Some(Tag::Unnecessary), None, ); } + /// Whether running `stmt` never goes on to the statement after it: a `break` or `goto`, an `if` + /// with an `else` none of whose branches runs past its end, or a `while true` loop that nothing + /// leaves but a `return`. A loop with a `return` in it may still run zero times, and a `do` + /// block is left alone, as `do return end` skips the rest of a function on purpose. + fn never_finishes(&self, stmt: &Stmt) -> bool { + match &stmt.kind { + StmtKind::Break | StmtKind::Goto(_) => true, + StmtKind::If { branches, else_block: Some(else_block) } => { + branches.iter().map(|branch| &branch.block).chain([else_block]).all(|block| self.leaves(block)) + } + StmtKind::While { cond, body } => { + matches!(cond.unparen().kind, ExprKind::True | ExprKind::Number(_) | ExprKind::String(_)) + && !leaves_loop(body) + } + _ => false, + } + } + + /// Whether a branch of an `if` never runs past its end. Like lua-language-server, a call of + /// `error` or `os.exit` counts here, though not as a statement of its own. + fn leaves(&self, block: &Block) -> bool { + block + .stmts + .iter() + .any(|stmt| matches!(stmt.kind, StmtKind::Return(_)) || self.never_finishes(stmt) || self.exits(stmt)) + } + + /// A call of the global `error` or `os.exit`. + fn exits(&self, stmt: &Stmt) -> bool { + let StmtKind::Expr(Expr { kind: ExprKind::Call { callee, .. }, .. }) = &stmt.kind else { return false }; + let global = |name: &Name, wanted: &str| { + name.text == wanted + && matches!(self.input.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + }; + match &callee.kind { + ExprKind::Name(name) => global(name, "error"), + ExprKind::Field { base, name, .. } => { + name.text == "exit" && matches!(&base.kind, ExprKind::Name(base) if global(base, "os")) + } + _ => false, + } + } + fn balance(&mut self, targets: usize, exprs: &[Expr], stmt_span: Span) { let Some(last) = exprs.last() else { return }; if exprs.len() > targets { @@ -297,6 +339,45 @@ fn same_place(a: &Expr, b: &Expr) -> bool { } } +/// Whether the body of a loop can leave it other than by `return`: by a `break` of its own, or by a +/// `goto` to a label it does not have. +fn leaves_loop(body: &Block) -> bool { + fn labels<'a>(block: &'a Block, out: &mut Vec<&'a str>) { + for stmt in &block.stmts { + match &stmt.kind { + StmtKind::Label(name) => out.push(&name.text), + StmtKind::Do(body) + | StmtKind::While { body, .. } + | StmtKind::Repeat { body, .. } + | StmtKind::NumericFor { body, .. } + | StmtKind::GenericFor { body, .. } => labels(body, out), + StmtKind::If { branches, else_block } => { + branches.iter().map(|branch| &branch.block).chain(else_block).for_each(|block| labels(block, out)) + } + _ => {} + } + } + } + fn escapes(block: &Block, labels: &[&str], own: bool) -> bool { + block.stmts.iter().any(|stmt| match &stmt.kind { + StmtKind::Break => own, + StmtKind::Goto(name) => !labels.contains(&name.text.as_str()), + StmtKind::Do(body) => escapes(body, labels, own), + StmtKind::If { branches, else_block } => { + branches.iter().map(|branch| &branch.block).chain(else_block).any(|block| escapes(block, labels, own)) + } + StmtKind::While { body, .. } + | StmtKind::Repeat { body, .. } + | StmtKind::NumericFor { body, .. } + | StmtKind::GenericFor { body, .. } => escapes(body, labels, false), + _ => false, + }) + } + let mut inside = Vec::new(); + labels(body, &mut inside); + escapes(body, &inside, true) +} + /// An `if` with a branch that ends in `return`. fn can_return(stmt: &Stmt) -> bool { let StmtKind::If { branches, else_block } = &stmt.kind else { return false }; diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 2d32724..cfba331 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -170,7 +170,7 @@ pub static RULES: &[Rule] = &[ rule(UNDEFINED_LABEL, Correctness, ERROR, false, "A goto targets a label that is not visible."), rule(REDEFINED_LOCAL, Suspicious, WARN, false, "A local is declared twice in the same scope."), rule(SHADOWED_LOCAL, Style, OFF, false, "A local hides a local from an enclosing scope."), - rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a return, break or goto and can never run."), + rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a break or goto, an `if` with an `else` none of whose branches runs past its end, or a `while true` loop that only a return leaves, and can never run."), rule(REDUNDANT_RETURN, Style, HINT, false, "A function ends with a `return` that gives no values, which changes nothing."), rule(EMPTY_BLOCK, Style, INFO, false, "A block has no statements."), rule(TRAILING_SPACE, Style, HINT, true, "A line ends in spaces or tabs outside a comment or string."), diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 24ff3cf..4639bdd 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1549,6 +1549,36 @@ print(a, b)"; assert_eq!(messages[2], "unknown diagnostic code 'undefined-globl'; did you mean 'undefined-global'?"); } +#[test] +fn code_after_statements_that_never_finish() { + let source = "local c = math.random() > 0.5 +local function a() if c then return 1 else return 2 end print(1) end +local function b() if c then return 1 elseif c then return 2 else if c then return 3 else return 4 end end print(1) end +local function d() if c then error('x') else os.exit(1) end print(1) end +local function e() while true do if c then return end Wait(0) end print(1) end +local function f() for i = 1, 2 do if i then break else goto continue end print(i) ::continue:: end end +local function g() if c then return 1 else return 2 end ::later:: print(1) end +local function h() if c then return 1 else return 2 end print(1) ::later:: print(2) end +print(a, b, d, e, f, g, h) +if c then return else return end +print(1)"; + assert_eq!(reported_lines(source, "unreachable-code"), [2, 3, 4, 5, 6, 8, 11]); + for source in [ + "local function f() if math.random() > 0.5 then return 1 end print(1) end\nprint(f)", + "local function f() error('x') print(1) end\nprint(f)", + "local function f() do return end print(1) end\nprint(f)", + "local function f() while math.random() do Wait(0) end print(1) end\nprint(f)", + "local function f() while true do if math.random() then break end end print(1) end\nprint(f)", + "local function f() while true do goto out end ::out:: print(1) end\nprint(f)", + "local function f() while true do for _ = 1, 2 do goto done end end ::done:: print(1) end\nprint(f)", + "local function f(t) if t then for _, v in pairs(t) do return v end else return end print(1) end\nprint(f)", + "local function f() repeat Wait(0) until false print(1) end\nprint(f)", + "local function error() end\nlocal function f() if math.random() then error() else return end print(1) end\nprint(f)", + ] { + assert_eq!(reported_lines(source, "unreachable-code"), Vec::::new(), "{source}"); + } +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/rules.md b/docs/rules.md index ee8041f..1d3e18b 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -23,7 +23,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-label` | error | A `goto` targets a label outside its visible scope. | | `redefined-local` | warning | A local is declared twice in one scope. | | `shadowed-local` | off | A local hides one in an enclosing scope. | -| `unreachable-code` | warning | Code follows a control-flow statement that exits the path. | +| `unreachable-code` | warning | Code follows a statement that never finishes: a `break` or `goto`, an `if` with an `else` none of whose branches runs past its end, by `return`, `break`, `goto` or a call of `error` or `os.exit`, or a `while true` loop that only a `return` leaves. Code from a label on can still be reached by a `goto`. A loop with a `return` in it, a `do return end` and an `error()` call of its own are not exits, as in lua-language-server. | | `redundant-return` | hint | A `return` without values ends a function body, where the function returns anyway. One inside an `if` or `do` block, which skips the code after it, and one that ends the file are not reported. | | `empty-block` | info | A block has no statements. | | `trailing-space` | hint | A line ends in spaces or tabs, or holds nothing else. Whitespace inside a comment or a string, such as a long string that spans lines, is left alone, as the formatter leaves it. Fix available. | From 4bbd45ca10475fd79b424b855aaee2df37182939 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:45 +0200 Subject: [PATCH 095/193] feat(rules): report your own @deprecated in deprecated --- crates/qbx_lua_analysis/src/lua_ls_config.rs | 4 +- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_ls/README.md | 4 +- crates/qbx_lua_ls/src/features/deprecated.rs | 80 +++++++++++++++++++ crates/qbx_lua_ls/src/features/diagnostics.rs | 29 ++++--- crates/qbx_lua_ls/src/features/mod.rs | 1 + crates/qbx_lua_ls/src/indexer.rs | 5 ++ crates/qbx_lua_ls/tests/lsp.rs | 76 ++++++++++++++++++ docs/rules.md | 2 +- 9 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 crates/qbx_lua_ls/src/features/deprecated.rs diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 93e99b1..6610b5f 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -15,8 +15,8 @@ pub const FILE_NAMES: &[&str] = &[".luarc.json", ".luarc.jsonc", ".emmyrc.json"] const ALIASES: &[(&str, &[&str])] = &[("unused", &["unused-local", "unused-function", "unused-argument", "unused-loop-variable"])]; -/// Codes that check the same thing in both tools. `deprecated` only shares the name: projects often -/// turn off the broad LuaLS check, which must not turn off the narrow one here. +/// Codes that check the same thing in both tools. `deprecated` is left out: projects often turn off +/// the LuaLS check, which must not turn off the deprecated runtime functions reported here. const EQUIVALENT_CODES: &[&str] = &[ "undefined-global", "lowercase-global", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index cfba331..8666ae5 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -217,7 +217,7 @@ pub static RULES: &[Rule] = &[ rule(LOWERCASE_GLOBAL, Suspicious, WARN, false, "A global with a lowercase first letter is defined; this is usually a missing 'local'."), rule(IMPLICIT_GLOBAL, Suspicious, WARN, false, "A global is created from inside a function and never declared at file scope."), rule(BUILTIN_OVERWRITE, Suspicious, WARN, false, "A runtime global or native is overwritten."), - rule(DEPRECATED, Style, WARN, false, "A deprecated runtime function is used."), + rule(DEPRECATED, Style, WARN, false, "A deprecated runtime function is used or, in the language server, a global, field or method whose definitions are all marked @deprecated."), rule(LOOP_NEVER_YIELDS, FiveM, ERROR, false, "An infinite loop has no Wait and will freeze the game or server thread."), rule(SOURCE_AFTER_YIELD, FiveM, WARN, false, "The global 'source' is read after a yield or inside a deferred callback, where it may belong to another event."), rule(NATIVE_WRONG_SIDE, FiveM, ERROR, false, "A native or runtime global is used on a side that lacks it, such as a client-only native in a server script, or the server's `os` and `io` libraries in client or unguarded shared code."), diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index acfa01f..f5587ae 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -85,7 +85,9 @@ Available features depend on the editor's LSP client. `undefined-field` fields read from one, and `need-check-nil` a value that may be `nil` used where a missing value raises an error. `close-non-object` reports a `` local whose value cannot be closed, such as a number, and - `circle-doc-class` a `---@class` that inherits from itself through classes of any file. The opt-in + `circle-doc-class` a `---@class` that inherits from itself through classes of any file. + `deprecated` reports reads of globals, fields and methods whose definitions all have + `---@deprecated` above them, in any file, with the reason it gives. The opt-in `no-unknown` reports parameters, locals and loop variables that have no type. - Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. - QB-Core and ESX server callback completion, navigation and payload hints from local handlers. diff --git a/crates/qbx_lua_ls/src/features/deprecated.rs b/crates/qbx_lua_ls/src/features/deprecated.rs new file mode 100644 index 0000000..ea8ec33 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/deprecated.rs @@ -0,0 +1,80 @@ +//! `deprecated`: code reads a global, field or method whose definitions all carry `---@deprecated`, +//! as lua-language-server reports them: `OldName()`, `Lib.old()`, `value:old()` or `value["old"]`. +//! A `---@field` declares a field without defining it, so it neither counts as a definition nor +//! keeps the field from being deprecated. Locals are left alone, as LuaLS leaves them, and so are +//! the definitions in the bundled runtime stubs, which the linter reports. + +use std::sync::Arc; + +use qbx_lua_analysis::scope::GlobalRefKind; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::Span; + +use super::class_tables::{accesses, Key}; +use crate::index::{FileId, FileOrigin}; +use crate::indexer::deprecation_reason; +use crate::infer::{Infer, MemberInfo}; + +/// Each read of a deprecated global, field or method, with the message naming it and the reason +/// its `---@deprecated` gives. +pub fn deprecated_uses(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> { + let mut out = Vec::new(); + for global in infer.ctx.resolution.globals.iter().filter(|global| global.kind == GlobalRefKind::Read) { + let symbols = infer.index.globals_named(&global.name, infer.ctx.file); + let definitions = symbols.iter().map(|(file, symbol)| (Some(*file), symbol.deprecated, &symbol.doc)); + if let Some(reason) = deprecated(infer, definitions) { + out.push((global.span, message(&global.name, &reason))); + } + } + for access in accesses(infer, chunk, true) { + let (Some(base), Key::Name(name)) = (access.read, &access.key) else { continue }; + let members = infer.members_named(&access.owner, name); + // Almost no field is deprecated, so most reads end here. + if !members.iter().any(|member| member.deprecated) { + continue; + } + let defined = members.iter().filter(|member| !is_declared_field(infer, member)); + let definitions = defined.map(|member| (member.location.map(|(file, _)| file), member.deprecated, &member.doc)); + if let Some(reason) = deprecated(infer, definitions) { + let between = &infer.ctx.source[base.span.end as usize..access.span.start as usize]; + let shown = match base.unparen().dotted_path() { + Some(path) => format!("{path}{}{name}", if between.contains(':') { ':' } else { '.' }), + None => name.to_string(), + }; + out.push((access.span, message(&shown, &reason))); + } + } + out +} + +/// The reason given when every one of `definitions`, the file, deprecation and doc of each, is +/// deprecated, an empty one when none is given, or `None` when one is not or there are none. +fn deprecated<'d>( + infer: &Infer, + definitions: impl Iterator, bool, &'d Option>)>, +) -> Option { + let mut reason = None; + for (file, deprecated, doc) in definitions { + let stub = file.and_then(|file| infer.index.file(file)).is_some_and(|entry| entry.origin == FileOrigin::Stub); + if !deprecated || stub { + return None; + } + reason = reason.or_else(|| Some(doc.as_deref().and_then(deprecation_reason).unwrap_or_default().to_string())); + } + reason +} + +/// Whether a `---@field` of a class declares `member`, rather than code defining it. +fn is_declared_field(infer: &Infer, member: &MemberInfo) -> bool { + let Some((file, range)) = member.location else { return true }; + let Some(entry) = infer.index.file(file) else { return true }; + let fields = entry.index.classes.iter().flat_map(|class| &class.fields); + fields.into_iter().any(|field| field.name == member.name && field.range == range) +} + +fn message(name: &str, reason: &str) -> String { + match reason { + "" => format!("'{name}' is deprecated"), + reason => format!("'{name}' is deprecated: {reason}"), + } +} diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs index 1517456..64be8e8 100644 --- a/crates/qbx_lua_ls/src/features/diagnostics.rs +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -5,11 +5,11 @@ use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::directives::Suppressions; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::rules::{ - ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, CIRCLE_DOC_CLASS, CLOSE_NON_OBJECT, DISCARD_RETURNS, - IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, MISSING_GLOBAL_DOC, - MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, PARAM_TYPE_MISMATCH, - REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, UNDEFINED_DOC_NAME, - UNDEFINED_FIELD, + ASSIGN_TYPE_MISMATCH, CAST_LOCAL_TYPE, CAST_TYPE_MISMATCH, CIRCLE_DOC_CLASS, CLOSE_NON_OBJECT, DEPRECATED, + DISCARD_RETURNS, IMPOSSIBLE_COMPARISON, INCOMPLETE_SIGNATURE_DOC, INJECT_FIELD, INVISIBLE, MISSING_FIELDS, + MISSING_GLOBAL_DOC, MISSING_LOCAL_EXPORT_DOC, MISSING_PARAMETER, MISSING_RETURN, NEED_CHECK_NIL, NO_UNKNOWN, + PARAM_TYPE_MISMATCH, REDUNDANT_PARAMETER, REDUNDANT_RETURN_VALUE, RETURN_TYPE_MISMATCH, UNDECLARED_FIELD, + UNDEFINED_DOC_NAME, UNDEFINED_FIELD, }; use qbx_lua_analysis::summary::summarize; use qbx_lua_analysis::{ @@ -26,6 +26,7 @@ use super::class_cycles::circular_classes; use super::class_tables::missing_fields; use super::closes::unclosable_values; use super::comparisons::impossible_comparisons; +use super::deprecated::deprecated_uses; use super::discards::discarded_returns; use super::doc_names::undefined_doc_names; use super::injected_fields::injected_fields; @@ -85,8 +86,9 @@ impl<'a> CheckInput<'a> { } /// Adds to `found`, the linter's findings, those of rules qbx-lint registers but cannot check, -/// because they need the LuaCATS types only the server indexes, and the parts of `missing-parameter` -/// and `redundant-parameter` that depend on the handler a `@callback` wrapper call reaches. Inline +/// because they need the LuaCATS types only the server indexes, and the parts of `missing-parameter`, +/// `redundant-parameter` and `deprecated` that depend on the handler a `@callback` wrapper call +/// reaches or on the `---@deprecated` annotations of other files. Inline /// suppression comments apply to them as they do to the linter's own. Removes the linter's findings /// of parameters without `@param` that those types document. fn type_diagnostics( @@ -96,7 +98,7 @@ fn type_diagnostics( found: &mut Vec, ) { type Check = fn(&CheckInput) -> Vec<(Span, String)>; - let checks: [(&'static str, Check); 21] = [ + let checks: [(&'static str, Check); 22] = [ (UNDEFINED_DOC_NAME, |input| { let side = input.ws.index.file(input.doc.file).and_then(|f| f.side); undefined_doc_names(&input.ws.index, &input.doc.text, &input.doc.chunk, side) @@ -123,6 +125,7 @@ fn type_diagnostics( (MISSING_RETURN, |input| missing_returns(input.infer, &input.doc.chunk)), (REDUNDANT_RETURN_VALUE, |input| redundant_returns(input.infer, &input.doc.chunk)), (DISCARD_RETURNS, |input| discarded_returns(input.infer, &input.doc.chunk)), + (DEPRECATED, |input| deprecated_uses(input.infer, &input.doc.chunk)), (CAST_TYPE_MISMATCH, |input| mismatched_casts(input.infer)), (CAST_LOCAL_TYPE, |input| retyped_locals(input.infer, &input.doc.chunk)), (CLOSE_NON_OBJECT, |input| unclosable_values(input.infer, &input.doc.chunk)), @@ -156,9 +159,13 @@ fn type_diagnostics( } for (code, check) in checks { let Some(severity) = config.severity(code) else { continue }; - found.extend(check(&input).into_iter().filter(|(span, _)| !input.is_suppressed(code, *span)).map( - |(span, message)| qbx_lua_analysis::Diagnostic { code, severity, span, message, tag: None, fix: None }, - )); + let tag = (code == DEPRECATED).then_some(Tag::Deprecated); + found.extend( + check(&input) + .into_iter() + .filter(|(span, _)| !input.is_suppressed(code, *span)) + .map(|(span, message)| qbx_lua_analysis::Diagnostic { code, severity, span, message, tag, fix: None }), + ); } } diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs index d059190..9e4a611 100644 --- a/crates/qbx_lua_ls/src/features/mod.rs +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -10,6 +10,7 @@ pub mod code_action; pub mod comparisons; pub mod completion; pub mod definition; +pub mod deprecated; pub mod diagnostics; pub mod discards; pub mod doc_names; diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs index 86c776d..0893c65 100644 --- a/crates/qbx_lua_ls/src/indexer.rs +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -67,6 +67,11 @@ pub fn render_doc(doc: &DocGroup) -> Option> { (!out.is_empty()).then(|| Arc::from(out)) } +/// The reason that the `---@deprecated` of a doc `render_doc` wrote gives, empty when it gives none. +pub fn deprecation_reason(doc: &str) -> Option<&str> { + doc.lines().find_map(|line| line.strip_prefix("**Deprecated**")).map(str::trim) +} + /// Values listed by `---|` lines, one indented line each, with their descriptions as comments. pub fn described_values(values: &[DescribedValue]) -> String { let mut out = String::new(); diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 14a5ac7..8b2e2ff 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -7847,6 +7847,82 @@ tostring(5) ); } +#[test] +fn uses_of_deprecated_globals_fields_and_methods_are_reported() { + let mut client = Client::start(fixture_root()); + client.open_with( + "myresource/shared/config.lua", + "\ +---@deprecated use NewThing +function OldThing() end + +DepLib = {} +---@deprecated +function DepLib.old() end +function DepLib.new() end +---@deprecated use DepLib:fresh +function DepLib:stale() end + +---@class DepClass +---@field kept fun() +local DepClass = {} +---@deprecated +function DepClass:legacy() end +---@deprecated +function DepClass.kept() end +---@deprecated +function DepClass.twice() end +function DepClass.twice() end +", + ); + let text = "\ +OldThing() +local alias = OldThing +DepLib.old() +DepLib:stale() +DepLib.new() +print(DepLib['old'], alias) +---@type DepClass +local value = DepClass +value:legacy() +value.kept() +value.twice() +local M = {} +---@deprecated +function M.gone() end +M.gone() +---@deprecated +local function hidden() end +hidden() +RegisterServerEvent('x') +---@diagnostic disable-next-line: deprecated +OldThing() +"; + client.open_with(CLIENT, text); + let finding = + |needle: &str, message: &str| ("deprecated".to_string(), pos(text, needle, 0).0 as u64, message.to_string()); + assert_eq!( + findings(&mut client, CLIENT, &["deprecated"]), + [ + finding("OldThing()", "'OldThing' is deprecated: use NewThing"), + finding("local alias", "'OldThing' is deprecated: use NewThing"), + finding("DepLib.old()", "'DepLib.old' is deprecated"), + finding("DepLib:stale()", "'DepLib:stale' is deprecated: use DepLib:fresh"), + finding("print(DepLib", "'DepLib.old' is deprecated"), + finding("value:legacy()", "'value:legacy' is deprecated"), + finding("value.kept()", "'value.kept' is deprecated"), + finding( + "M.gone() +---@deprecated", + "'M.gone' is deprecated" + ), + finding("RegisterServerEvent", "'RegisterServerEvent' is deprecated"), + ], + "a `---@field` does not keep a field from being deprecated, another definition does, locals are left \ + alone, and the runtime's `RegisterServerEvent` is reported once, by the linter" + ); +} + #[test] fn returns_with_more_values_than_declared_are_reported() { let mut client = Client::start(fixture_root()); diff --git a/docs/rules.md b/docs/rules.md index 1d3e18b..019c5c1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -70,7 +70,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `lowercase-global` | warning | A global definition starts with a lowercase letter. | | `implicit-global` | warning | A function creates a global without a file-scope declaration. | | `builtin-overwrite` | warning | Code overwrites a known runtime global or native. | -| `deprecated` | warning | A call uses a deprecated runtime function. | +| `deprecated` | warning | Code uses a deprecated runtime function, such as `RegisterServerEvent`. qbx-lua-ls also reports reads of a global, field or method whose definitions all have `---@deprecated` above them, such as `Lib.old()` after `---@deprecated use Lib.new` `function Lib.old() end`, with the reason it gives. A `---@field` of a class does not count as a definition, and locals are not reported, as in lua-language-server. | ## FiveM and Qbox From 7c0e90c6da41e45b72748538ecd316e7d494b623 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:46 +0200 Subject: [PATCH 096/193] feat(rules): report table locals whose fields are only set in unused-local --- crates/qbx_lua_analysis/src/checks/locals.rs | 70 ++++++++++++++++++-- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_analysis/tests/rules.rs | 30 +++++++++ crates/qbx_lua_ls/tests/lsp.rs | 2 +- docs/rules.md | 2 +- 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/locals.rs b/crates/qbx_lua_analysis/src/checks/locals.rs index ae0fffc..751a0de 100644 --- a/crates/qbx_lua_analysis/src/checks/locals.rs +++ b/crates/qbx_lua_analysis/src/checks/locals.rs @@ -1,12 +1,17 @@ -use qbx_lua_syntax::ast::Attrib; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use rustc_hash::FxHashSet; use super::{FileInput, Sink}; use crate::diagnostic::Tag; +use crate::env::leading_doc_lines; use crate::rules; -use crate::scope::{Local, LocalKind}; +use crate::scope::{Local, LocalKind, LocalRef}; pub(super) fn check(input: &FileInput, sink: &mut Sink) { let prefix = input.config.ignore_unused_prefix.as_str(); + let mut shapes = Shapes { input, tables: FxHashSet::default(), field_sets: FxHashSet::default() }; + shapes.visit_block(&input.chunk.block); for local in &input.resolution.locals { if local.name.is_empty() { continue; @@ -16,7 +21,7 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { if ignored || local.kind == LocalKind::ImplicitSelf { continue; } - check_unused(local, sink); + check_unused(local, &shapes, sink); if let Some(previous) = local.redefines { let previous = input.resolution.local(previous); if previous.kind != LocalKind::Param || local.kind != LocalKind::Param { @@ -44,11 +49,20 @@ fn check_const_writes(local: &Local, sink: &mut Sink) { } } -fn check_unused(local: &Local, sink: &mut Sink) { - if local.is_read() || local.attrib == Some(Attrib::Close) { +fn check_unused(local: &Local, shapes: &Shapes, sink: &mut Sink) { + if local.attrib == Some(Attrib::Close) { return; } let name = &local.name; + if local.is_read() { + // Like lua-language-server, setting fields of a table that is never read is no use of it. + let sets_fields = |r: &LocalRef| shapes.field_sets.contains(&r.span.start); + if shapes.tables.contains(&local.decl.start) && local.refs.iter().filter(|r| !r.write).all(sets_fields) { + let message = format!("local '{name}' is never read; only its fields are set"); + sink.report_with(rules::UNUSED_LOCAL, local.decl, message, Some(Tag::Unnecessary), None); + } + return; + } let (code, message) = match local.kind { LocalKind::Param => (rules::UNUSED_ARGUMENT, format!("unused argument '{name}'")), LocalKind::LoopVar => (rules::UNUSED_LOOP_VARIABLE, format!("unused loop variable '{name}'")), @@ -61,3 +75,49 @@ fn check_unused(local: &Local, sink: &mut Sink) { }; sink.report_with(code, local.decl, message, Some(Tag::Unnecessary), None); } + +/// What the statements of the file tell about how their locals are used. +struct Shapes<'a> { + input: &'a FileInput<'a>, + /// The locals declared with a table constructor, by where their name starts, other than the + /// table a `---@class` annotation declares, whose fields its users read. + tables: FxHashSet, + /// The references that only set a field of the table they name: the `t` of `t.x = v`, + /// `t[k] = v`, `function t.f()` and `function t:m()`. + field_sets: FxHashSet, +} + +impl<'ast> Visitor<'ast> for Shapes<'_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::Local { names, exprs, .. } => { + let tables = names.iter().zip(exprs).filter(|(_, value)| matches!(value.kind, ExprKind::Table(_))); + let mut tables = tables.map(|(name, _)| name.name.span.start).peekable(); + if tables.peek().is_some() && !self.declares_class(stmt) { + self.tables.extend(tables); + } + } + StmtKind::Assign { targets, .. } => { + for target in targets { + if let ExprKind::Field { base, .. } | ExprKind::Index { base, .. } = &target.kind { + if let ExprKind::Name(name) = &base.kind { + self.field_sets.insert(name.span.start); + } + } + } + } + StmtKind::Function { name, .. } if name.path.len() + usize::from(name.method.is_some()) == 1 => { + self.field_sets.insert(name.base.span.start); + } + _ => {} + } + visit::walk_stmt(self, stmt); + } +} + +impl Shapes<'_> { + fn declares_class(&self, stmt: &Stmt) -> bool { + let docs = leading_doc_lines(self.input.source, &self.input.chunk.comments, stmt.span.start); + docs.iter().any(|line| line.trim_start().starts_with("@class")) + } +} diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 8666ae5..d24a863 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -162,7 +162,7 @@ pub static RULES: &[Rule] = &[ rule(SYNTAX_ERROR, Correctness, ERROR, false, "The file cannot be parsed by the CfxLua 5.4 runtime."), rule(UNDEFINED_GLOBAL, Correctness, WARN, false, "A global is read that no script in the resource, its imports, the runtime or the natives define."), rule(UNDEFINED_FIELD, Correctness, WARN, false, "A field that does not exist is read from a standard library table or, in the language server, from a value whose LuaCATS class or table type does not have it."), - rule(UNUSED_LOCAL, Suspicious, WARN, false, "A local variable is never read."), + rule(UNUSED_LOCAL, Suspicious, WARN, false, "A local variable is never read, or is declared with a table whose fields are set and never read."), rule(UNUSED_FUNCTION, Suspicious, WARN, false, "A local function is never used."), rule(UNUSED_ARGUMENT, Style, HINT, false, "A function parameter is never read."), rule(UNUSED_LOOP_VARIABLE, Style, HINT, false, "A loop variable is never read."), diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 4639bdd..1380335 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -487,6 +487,36 @@ fn unused_and_shadowing() { assert_eq!(codes_with("local a = 1\ndo local a = 2 print(a) end\nprint(a)", &config), ["shadowed-local"]); } +#[test] +fn tables_whose_fields_are_only_set_are_unused() { + let source = "local cache = {} +cache.a = 1 +cache['b'] = 2 +local handlers = { a = 1 } +function handlers.f() end +function handlers:m() end +RegisterNetEvent('x', function(k, v) handlers[k] = v end) +local list = {} +list[#list + 1] = 1 +local nested = {} +nested.a.b = 1 +local later +later = {} +later.a = 1 +---@class Lt.Kind +local Kind = {} +function Kind.new() end +local counter = {} +counter.n += 1 +local wrapped = setmetatable({}, {}) +wrapped.a = 1 +local returned = {} +returned.a = 1 +return returned"; + assert_eq!(reported_lines(source, "unused-local"), [1, 4]); + assert_eq!(findings(source, "unused-local")[0].1, "local 'cache' is never read; only its fields are set"); +} + #[test] fn ignore_prefix_silences_unused() { assert_eq!(codes("local _ignored = 1\nlocal function cb(_a, _b) end\ncb()"), Vec::<&str>::new()); diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs index 8b2e2ff..47d8d66 100644 --- a/crates/qbx_lua_ls/tests/lsp.rs +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -449,7 +449,7 @@ fn lists_every_rule_with_its_default_level() { "category": "suspicious", "default": "warning", "fixable": false, - "summary": "A local variable is never read.", + "summary": "A local variable is never read, or is declared with a table whose fields are set and never read.", }) ); assert_eq!(rule("shadowed-local")["default"], "off"); diff --git a/docs/rules.md b/docs/rules.md index 019c5c1..ea2de6c 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -15,7 +15,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `syntax-error` | error | Source rejected by the parser, including its nesting limit. | | `undefined-global` | warning | A read of a global absent from the known environment. | | `undefined-field` | warning | An unknown field on a standard library table. qbx-lua-ls also reports a field read from a value whose `---@class` or table type does not have it, such as `point.z` for a class with `x` and `y`, as TypeScript reports a property its type does not declare. A type has the fields that `inject-field` lets code set; global tables, the exports of a resource without a declared type, empty tables and values of unknown type have any. See [Undefined fields](reference.md#undefined-fields). | -| `unused-local` | warning | A local variable is never read. | +| `unused-local` | warning | A local variable is never read. A local declared with a table constructor that code only sets fields of, as `t.a = 1`, `t[k] = v` or `function t.f()` do, is not read either, as in lua-language-server; the table a `---@class` annotation declares is left alone. | | `unused-function` | warning | A local function is never used. | | `unused-argument` | hint | A function parameter is never read. | | `unused-loop-variable` | hint | A loop variable is never read. | From 89e7b31c8e4ad71eb4a7ddcee575e432dd38387b Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:47 +0200 Subject: [PATCH 097/193] feat(rules): report local functions only used by unused functions in unused-function --- crates/qbx_lua_analysis/src/checks/locals.rs | 79 ++++++++++++++++++-- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_analysis/tests/rules.rs | 32 ++++++++ docs/rules.md | 2 +- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/locals.rs b/crates/qbx_lua_analysis/src/checks/locals.rs index 751a0de..34d83b4 100644 --- a/crates/qbx_lua_analysis/src/checks/locals.rs +++ b/crates/qbx_lua_analysis/src/checks/locals.rs @@ -1,27 +1,39 @@ use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use super::{FileInput, Sink}; use crate::diagnostic::Tag; -use crate::env::leading_doc_lines; +use crate::env::{is_meta_file, leading_doc_lines}; use crate::rules; -use crate::scope::{Local, LocalKind, LocalRef}; +use crate::scope::{FuncId, Local, LocalId, LocalKind, LocalRef, Resolved}; pub(super) fn check(input: &FileInput, sink: &mut Sink) { let prefix = input.config.ignore_unused_prefix.as_str(); - let mut shapes = Shapes { input, tables: FxHashSet::default(), field_sets: FxHashSet::default() }; + let ignored = |local: &Local| local.name == "_" || (!prefix.is_empty() && local.name.starts_with(prefix)); + let mut shapes = + Shapes { input, tables: FxHashSet::default(), field_sets: FxHashSet::default(), functions: Vec::new() }; shapes.visit_block(&input.chunk.block); - for local in &input.resolution.locals { + // Definition files only declare signatures, which lua-language-server does not check for use. + let only_used_by_unused = match is_meta_file(input.source, input.chunk) { + true => FxHashSet::default(), + false => used_only_by_unused(input, &shapes, ignored), + }; + for (id, local) in input.resolution.locals.iter().enumerate() { if local.name.is_empty() { continue; } check_const_writes(local, sink); - let ignored = local.name == "_" || (!prefix.is_empty() && local.name.starts_with(prefix)); - if ignored || local.kind == LocalKind::ImplicitSelf { + if ignored(local) || local.kind == LocalKind::ImplicitSelf { continue; } check_unused(local, &shapes, sink); + if only_used_by_unused.contains(&(id as LocalId)) { + let code = + if local.kind == LocalKind::LocalFunction { rules::UNUSED_FUNCTION } else { rules::UNUSED_LOCAL }; + let message = format!("function '{}' is only used by functions that are never used", local.name); + sink.report_with(code, local.decl, message, Some(Tag::Unnecessary), None); + } if let Some(previous) = local.redefines { let previous = input.resolution.local(previous); if previous.kind != LocalKind::Param || local.kind != LocalKind::Param { @@ -37,6 +49,51 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { } } +/// The local functions, and locals declared with a function, that only functions which are never +/// used themselves read, as lua-language-server finds them: a read in the body of a function that +/// a local holds links that local to the one read, and any other read, in the main chunk or in a +/// function no local holds, such as a callback, uses it. A function that only calls itself is +/// never used either. +fn used_only_by_unused(input: &FileInput, shapes: &Shapes, ignored: impl Fn(&Local) -> bool) -> FxHashSet { + let resolution = input.resolution; + let starts: FxHashMap = + resolution.functions.iter().enumerate().map(|(id, function)| (function.span.start, id as FuncId)).collect(); + // The local that holds each function. + let mut holders: FxHashMap = FxHashMap::default(); + for (function, name) in &shapes.functions { + if let (Some(function), Some(Resolved::Local(local))) = (starts.get(function), resolution.resolve_at(*name)) { + holders.insert(*function, local); + } + } + let mut used = Vec::new(); + let mut links: FxHashMap> = FxHashMap::default(); + for &id in holders.values() { + let local = resolution.local(id); + if ignored(local) { + used.push(id); + continue; + } + for read in local.refs.iter().filter(|r| !r.write) { + match holders.get(&read.func) { + Some(by) => links.entry(*by).or_default().push(id), + None => { + used.push(id); + break; + } + } + } + } + let mut reached: FxHashSet = used.iter().copied().collect(); + while let Some(id) = used.pop() { + for next in links.get(&id).into_iter().flatten() { + if reached.insert(*next) { + used.push(*next); + } + } + } + holders.into_values().filter(|id| !reached.contains(id) && resolution.local(*id).is_read()).collect() +} + fn check_const_writes(local: &Local, sink: &mut Sink) { let Some(attrib @ (Attrib::Const | Attrib::Close)) = local.attrib else { return }; let label = if attrib == Attrib::Const { "const" } else { "close" }; @@ -85,18 +142,26 @@ struct Shapes<'a> { /// The references that only set a field of the table they name: the `t` of `t.x = v`, /// `t[k] = v`, `function t.f()` and `function t:m()`. field_sets: FxHashSet, + /// Where each function that a local holds starts, with where the name of that local starts. + functions: Vec<(u32, u32)>, } impl<'ast> Visitor<'ast> for Shapes<'_> { fn visit_stmt(&mut self, stmt: &'ast Stmt) { match &stmt.kind { StmtKind::Local { names, exprs, .. } => { + for (name, value) in names.iter().zip(exprs) { + if let ExprKind::Function(func) = &value.kind { + self.functions.push((func.span.start, name.name.span.start)); + } + } let tables = names.iter().zip(exprs).filter(|(_, value)| matches!(value.kind, ExprKind::Table(_))); let mut tables = tables.map(|(name, _)| name.name.span.start).peekable(); if tables.peek().is_some() && !self.declares_class(stmt) { self.tables.extend(tables); } } + StmtKind::LocalFunction { name, func } => self.functions.push((func.span.start, name.span.start)), StmtKind::Assign { targets, .. } => { for target in targets { if let ExprKind::Field { base, .. } | ExprKind::Index { base, .. } = &target.kind { diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index d24a863..f18ed6d 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -163,7 +163,7 @@ pub static RULES: &[Rule] = &[ rule(UNDEFINED_GLOBAL, Correctness, WARN, false, "A global is read that no script in the resource, its imports, the runtime or the natives define."), rule(UNDEFINED_FIELD, Correctness, WARN, false, "A field that does not exist is read from a standard library table or, in the language server, from a value whose LuaCATS class or table type does not have it."), rule(UNUSED_LOCAL, Suspicious, WARN, false, "A local variable is never read, or is declared with a table whose fields are set and never read."), - rule(UNUSED_FUNCTION, Suspicious, WARN, false, "A local function is never used."), + rule(UNUSED_FUNCTION, Suspicious, WARN, false, "A local function is never used, or only by itself and local functions that are never used."), rule(UNUSED_ARGUMENT, Style, HINT, false, "A function parameter is never read."), rule(UNUSED_LOOP_VARIABLE, Style, HINT, false, "A loop variable is never read."), rule(UNUSED_LABEL, Suspicious, WARN, false, "A label is never the target of a goto."), diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 1380335..1634a63 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -517,6 +517,38 @@ return returned"; assert_eq!(findings(source, "unused-local")[0].1, "local 'cache' is never read; only its fields are set"); } +#[test] +fn functions_only_used_by_unused_functions_are_unused() { + let source = "local function helper() end +local function wrapper() helper() end +local function recursive() recursive() end +local held = function() end +local function holder() held() end +local function first() end +local function second() first() end +local function third() second() end +local function a() end +local function b() a() end +local function c() b() end +c() +local function fromCallback() end +local function spawner() CreateThread(function() fromCallback() end) end +local function exported() end +local M = {} +function M.run() exported() end +local function viaKept() end +local function _kept() viaKept() end +return M"; + assert_eq!(reported_lines(source, "unused-function"), [1, 2, 3, 5, 6, 7, 8, 14]); + assert_eq!(reported_lines(source, "unused-local"), [4]); + assert_eq!( + findings(source, "unused-function")[0].1, + "function 'helper' is only used by functions that are never used" + ); + assert_eq!(findings(source, "unused-function")[1].1, "unused function 'wrapper'"); + assert_eq!(reported_lines("---@meta\nlocal function a() end\nlocal function b() a() end", "unused-function"), [3]); +} + #[test] fn ignore_prefix_silences_unused() { assert_eq!(codes("local _ignored = 1\nlocal function cb(_a, _b) end\ncb()"), Vec::<&str>::new()); diff --git a/docs/rules.md b/docs/rules.md index ea2de6c..88ca5f0 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -16,7 +16,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `undefined-global` | warning | A read of a global absent from the known environment. | | `undefined-field` | warning | An unknown field on a standard library table. qbx-lua-ls also reports a field read from a value whose `---@class` or table type does not have it, such as `point.z` for a class with `x` and `y`, as TypeScript reports a property its type does not declare. A type has the fields that `inject-field` lets code set; global tables, the exports of a resource without a declared type, empty tables and values of unknown type have any. See [Undefined fields](reference.md#undefined-fields). | | `unused-local` | warning | A local variable is never read. A local declared with a table constructor that code only sets fields of, as `t.a = 1`, `t[k] = v` or `function t.f()` do, is not read either, as in lua-language-server; the table a `---@class` annotation declares is left alone. | -| `unused-function` | warning | A local function is never used. | +| `unused-function` | warning | A local function is never used. One that only itself, or local functions that are never used, call is not used either, as in lua-language-server; a local declared with a function is reported under `unused-local` then. A call from a callback inside such a function, or from a function that starts with `ignore_unused_prefix`, counts as a use. `---@meta` files are not checked for these. | | `unused-argument` | hint | A function parameter is never read. | | `unused-loop-variable` | hint | A loop variable is never read. | | `unused-label` | warning | A label is never targeted by a `goto`. | From f9a6d5cdad295acba50ac2c8e226e43ff1ec1e84 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:49 +0200 Subject: [PATCH 098/193] feat(rules): add unused-vararg --- crates/qbx_lua_analysis/src/checks/flow.rs | 36 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 5 +-- crates/qbx_lua_analysis/src/rules.rs | 2 ++ .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 5 +++ crates/qbx_lua_analysis/tests/rules.rs | 22 ++++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index 5505b2b..34e2e7f 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -23,8 +23,9 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { for name in &input.resolution.undefined_gotos { sink.report(rules::UNDEFINED_LABEL, name.span, format!("no visible label '{}' for goto", name.text)); } - // Definition files only declare signatures, which may repeat a field. - let set_fields = sink.enabled(rules::DUPLICATE_SET_FIELD).is_some() && !is_meta_file(input.source, input.chunk); + // Definition files only declare signatures, which may repeat a field and leave `...` unused. + let meta = is_meta_file(input.source, input.chunk); + let set_fields = sink.enabled(rules::DUPLICATE_SET_FIELD).is_some() && !meta; let mut flow = Flow { input, sink, @@ -34,6 +35,7 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { set_fields: set_fields.then(FxHashMap::default), lines: None, suppressions: None, + meta, }; flow.visit_block(&input.chunk.block); } @@ -53,6 +55,7 @@ struct Flow<'a, 'b> { set_fields: Option>>>, lines: Option, suppressions: Option, + meta: bool, } /// The variable a table field is set through. @@ -378,6 +381,22 @@ fn leaves_loop(body: &Block) -> bool { escapes(body, &inside, true) } +/// Whether a function body uses its `...`, rather than one of a function inside it. +fn uses_vararg(body: &Block) -> bool { + struct Finder(bool); + impl<'ast> Visitor<'ast> for Finder { + fn visit_func_body(&mut self, _: &'ast FuncBody) {} + + fn visit_expr(&mut self, expr: &'ast Expr) { + self.0 |= matches!(expr.kind, ExprKind::Vararg); + visit::walk_expr(self, expr); + } + } + let mut finder = Finder(false); + finder.visit_block(body); + finder.0 +} + /// An `if` with a branch that ends in `return`. fn can_return(stmt: &Stmt) -> bool { let StmtKind::If { branches, else_block } = &stmt.kind else { return false }; @@ -418,6 +437,19 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { ); } } + // `unused-vararg`: like lua-language-server, an empty body, which declares a signature, is + // left alone. + if let Some(vararg) = func.vararg { + if !self.meta && !func.body.stmts.is_empty() && !uses_vararg(&func.body) { + self.sink.report_with( + rules::UNUSED_VARARG, + vararg, + "'...' is never used", + Some(Tag::Unnecessary), + None, + ); + } + } let outer = self.function; self.in_new_block(|flow| { flow.function = flow.block; diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 6610b5f..4b35bca 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -13,7 +13,7 @@ pub const FILE_NAMES: &[&str] = &[".luarc.json", ".luarc.jsonc", ".emmyrc.json"] /// EmmyLua codes that cover several qbx-lint rules. const ALIASES: &[(&str, &[&str])] = - &[("unused", &["unused-local", "unused-function", "unused-argument", "unused-loop-variable"])]; + &[("unused", &["unused-local", "unused-function", "unused-argument", "unused-loop-variable", "unused-vararg"])]; /// Codes that check the same thing in both tools. `deprecated` is left out: projects often turn off /// the LuaLS check, which must not turn off the deprecated runtime functions reported here. @@ -23,6 +23,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "unused-local", "unused-function", "unused-label", + "unused-vararg", "redefined-local", "unreachable-code", "empty-block", @@ -487,7 +488,7 @@ mod tests { assert!(settings.rules.contains(&("undefined-global".to_string(), Level::Error))); let settings = parse_luals(r#"{ "diagnostics": { "disable": ["unused"] } }"#); - assert_eq!(settings.rules.len(), 4); + assert_eq!(settings.rules.len(), 5); assert!(settings.rules.iter().all(|(code, level)| code.starts_with("unused-") && *level == Level::Off)); } diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index f18ed6d..17e0a27 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -60,6 +60,7 @@ pub const UNUSED_LOCAL: &str = "unused-local"; pub const UNUSED_FUNCTION: &str = "unused-function"; pub const UNUSED_ARGUMENT: &str = "unused-argument"; pub const UNUSED_LOOP_VARIABLE: &str = "unused-loop-variable"; +pub const UNUSED_VARARG: &str = "unused-vararg"; pub const UNUSED_LABEL: &str = "unused-label"; pub const UNDEFINED_LABEL: &str = "undefined-label"; pub const REDEFINED_LOCAL: &str = "redefined-local"; @@ -166,6 +167,7 @@ pub static RULES: &[Rule] = &[ rule(UNUSED_FUNCTION, Suspicious, WARN, false, "A local function is never used, or only by itself and local functions that are never used."), rule(UNUSED_ARGUMENT, Style, HINT, false, "A function parameter is never read."), rule(UNUSED_LOOP_VARIABLE, Style, HINT, false, "A loop variable is never read."), + rule(UNUSED_VARARG, Style, HINT, false, "A function takes `...` and never uses it."), rule(UNUSED_LABEL, Suspicious, WARN, false, "A label is never the target of a goto."), rule(UNDEFINED_LABEL, Correctness, ERROR, false, "A goto targets a label that is not visible."), rule(REDEFINED_LOCAL, Suspicious, WARN, false, "A local is declared twice in the same scope."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index e45bbd4..5e99c8f 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -53,3 +53,4 @@ shared/config.lua:45:11 warning doc-field-no-class shared/config.lua:50:14 warning unknown-operator shared/config.lua:54:10 warning unknown-cast-variable shared/config.lua:56:35 warning unknown-diag-code +shared/config.lua:59:20 hint unused-vararg diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index c2068c2..ffa1bd5 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -55,3 +55,8 @@ print(vector) ---@diagnostic disable-next-line: no-such-rule print(Config.Debug) + +local function log(...) + print(Config.Debug) +end +log() diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 1634a63..983b7e1 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -549,6 +549,28 @@ return M"; assert_eq!(reported_lines("---@meta\nlocal function a() end\nlocal function b() a() end", "unused-function"), [3]); } +#[test] +fn varargs_that_the_body_never_uses() { + let source = "local function a(...) print(1) end +local function b(x, ...) return x end +local function c(...) local function inner(...) print(...) end inner() end +local function d(...) end +local function e(...) print(...) end +local function f(...) return select('#', ...) end +local g = function(...) local t = { ... } return t end +print(a, b, c, d, e, f, g)"; + assert_eq!(reported_lines(source, "unused-vararg"), [1, 2, 3]); + assert_eq!(findings(source, "unused-vararg")[0].1, "'...' is never used"); + assert_eq!( + reported_lines( + "---@meta +function Stub(...) print(1) end", + "unused-vararg" + ), + Vec::::new() + ); +} + #[test] fn ignore_prefix_silences_unused() { assert_eq!(codes("local _ignored = 1\nlocal function cb(_a, _b) end\ncb()"), Vec::<&str>::new()); diff --git a/docs/reference.md b/docs/reference.md index c46f7da..e449bf8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `unused-vararg`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 88ca5f0..cf385b0 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -19,6 +19,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `unused-function` | warning | A local function is never used. One that only itself, or local functions that are never used, call is not used either, as in lua-language-server; a local declared with a function is reported under `unused-local` then. A call from a callback inside such a function, or from a function that starts with `ignore_unused_prefix`, counts as a use. `---@meta` files are not checked for these. | | `unused-argument` | hint | A function parameter is never read. | | `unused-loop-variable` | hint | A loop variable is never read. | +| `unused-vararg` | hint | A function takes `...` and its body never uses it; a `...` of a function inside it is that function's own. Empty bodies and `---@meta` files are not checked, as in lua-language-server. | | `unused-label` | warning | A label is never targeted by a `goto`. | | `undefined-label` | error | A `goto` targets a label outside its visible scope. | | `redefined-local` | warning | A local is declared twice in one scope. | From 770ceffeaeed0326cd07cd187a43902e135ff130 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:50 +0200 Subject: [PATCH 099/193] fix(rules): report a parameter redeclared at the top of its function as redefined-local --- crates/qbx_lua_analysis/src/rules.rs | 2 +- crates/qbx_lua_analysis/src/scope.rs | 11 +++++++++-- crates/qbx_lua_analysis/tests/rules.rs | 17 +++++++++++++++++ docs/rules.md | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 17e0a27..04d864f 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -170,7 +170,7 @@ pub static RULES: &[Rule] = &[ rule(UNUSED_VARARG, Style, HINT, false, "A function takes `...` and never uses it."), rule(UNUSED_LABEL, Suspicious, WARN, false, "A label is never the target of a goto."), rule(UNDEFINED_LABEL, Correctness, ERROR, false, "A goto targets a label that is not visible."), - rule(REDEFINED_LOCAL, Suspicious, WARN, false, "A local is declared twice in the same scope."), + rule(REDEFINED_LOCAL, Suspicious, WARN, false, "A local is declared twice in the same scope, such as a parameter again at the top of its function's body."), rule(SHADOWED_LOCAL, Style, OFF, false, "A local hides a local from an enclosing scope."), rule(UNREACHABLE_CODE, Suspicious, WARN, false, "Code follows a break or goto, an `if` with an `else` none of whose branches runs past its end, or a `while true` loop that only a return leaves, and can never run."), rule(REDUNDANT_RETURN, Style, HINT, false, "A function ends with a `return` that gives no values, which changes nothing."), diff --git a/crates/qbx_lua_analysis/src/scope.rs b/crates/qbx_lua_analysis/src/scope.rs index c6dc7c9..0be91a4 100644 --- a/crates/qbx_lua_analysis/src/scope.rs +++ b/crates/qbx_lua_analysis/src/scope.rs @@ -233,12 +233,17 @@ impl Resolver { fn block(&mut self, block: &Block, scope_end: u32) { self.scopes.push(Scope { names: Vec::new() }); + self.stmts(block, scope_end); + self.scopes.pop(); + } + + /// The statements of `block`, declaring their locals in the innermost scope. + fn stmts(&mut self, block: &Block, scope_end: u32) { self.push_labels(block); for stmt in &block.stmts { self.stmt(stmt, scope_end); } self.label_scopes.pop(); - self.scopes.pop(); } fn push_labels(&mut self, block: &Block) { @@ -394,7 +399,9 @@ impl Resolver { for param in &func.params { self.declare(param, LocalKind::Param, None, body_start, func.span.end, true); } - self.block(&func.body, func.span.end); + // The parameters and the locals of the body share one block, as in Lua, so a `local` of the + // body that takes the name of a parameter redefines it. + self.stmts(&func.body, func.span.end); self.scopes.pop(); self.func_stack.pop(); } diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 983b7e1..8af9ec7 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -485,6 +485,23 @@ fn unused_and_shadowing() { let mut config = FileConfig::default(); config.set("shadowed-local", Level::Warning); assert_eq!(codes_with("local a = 1\ndo local a = 2 print(a) end\nprint(a)", &config), ["shadowed-local"]); + + // The parameters and the locals of a function body share one block, as in Lua. + let source = "local function f(source, volume) + local source = source + local volume = volume / 100 + if source then local volume = 1 print(volume) end + for i = 1, 2 do local i = i print(i) end + return source, volume +end +local M = {} +function M:method() local self = self return self end +print(f, M)"; + assert_eq!(reported_lines(source, "redefined-local"), [2, 3, 9]); + assert_eq!(findings(source, "redefined-local")[0].1, "local 'source' is already declared in this scope"); + let shadows: Vec = + findings_with(source, "shadowed-local", &config).into_iter().map(|(line, _)| line).collect(); + assert_eq!(shadows, [4, 5], "a nested block, and the body of a loop, are blocks of their own"); } #[test] diff --git a/docs/rules.md b/docs/rules.md index cf385b0..e4d2a18 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -22,7 +22,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `unused-vararg` | hint | A function takes `...` and its body never uses it; a `...` of a function inside it is that function's own. Empty bodies and `---@meta` files are not checked, as in lua-language-server. | | `unused-label` | warning | A label is never targeted by a `goto`. | | `undefined-label` | error | A `goto` targets a label outside its visible scope. | -| `redefined-local` | warning | A local is declared twice in one scope. | +| `redefined-local` | warning | A local is declared twice in one scope. The parameters of a function and the locals at the top of its body share one, as in Lua, so `function(source) local source = source end` declares `source` twice; a local in a nested block or a loop body hides the outer one instead, which `shadowed-local` reports. | | `shadowed-local` | off | A local hides one in an enclosing scope. | | `unreachable-code` | warning | Code follows a statement that never finishes: a `break` or `goto`, an `if` with an `else` none of whose branches runs past its end, by `return`, `break`, `goto` or a call of `error` or `os.exit`, or a `while true` loop that only a `return` leaves. Code from a label on can still be reached by a `goto`. A loop with a `return` in it, a `do return end` and an `error()` call of its own are not exits, as in lua-language-server. | | `redundant-return` | hint | A `return` without values ends a function body, where the function returns anyway. One inside an `if` or `do` block, which skips the code after it, and one that ends the file are not reported. | From 504838c786d7806b5372287de7e977e297c9415a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:51 +0200 Subject: [PATCH 100/193] feat(rules): add newfield-call --- crates/qbx_lua_analysis/src/checks/layout.rs | 48 +++++++++++++++++-- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 6 +++ crates/qbx_lua_analysis/tests/rules.rs | 28 +++++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/layout.rs b/crates/qbx_lua_analysis/src/checks/layout.rs index 69da04d..1568740 100644 --- a/crates/qbx_lua_analysis/src/checks/layout.rs +++ b/crates/qbx_lua_analysis/src/checks/layout.rs @@ -1,5 +1,5 @@ -//! Checks of how code is laid out over lines: a call whose arguments start a line of their own, and -//! whitespace at the end of a line. +//! Checks of how code is laid out over lines: a call whose arguments start a line of their own, in a +//! statement or a table constructor, and whitespace at the end of a line. use qbx_lua_syntax::ast::*; use qbx_lua_syntax::visit::{self, Visitor}; @@ -11,14 +11,18 @@ use crate::rules; pub(super) fn check(input: &FileInput, sink: &mut Sink) { let calls = sink.enabled(rules::NEWLINE_CALL).is_some(); + let fields = sink.enabled(rules::NEWFIELD_CALL).is_some(); let spaces = sink.enabled(rules::TRAILING_SPACE).is_some(); - if !(calls || spaces) { + if !(calls || fields || spaces) { return; } let lines = LineIndex::new(input.source); if calls { NewlineCalls { source: input.source, lines: &lines, sink: &mut *sink }.visit_block(&input.chunk.block); } + if fields { + NewfieldCalls { source: input.source, lines: &lines, sink: &mut *sink }.visit_block(&input.chunk.block); + } if spaces { trailing_spaces(input, &lines, sink); } @@ -66,6 +70,44 @@ impl<'ast> Visitor<'ast> for NewlineCalls<'_, '_> { } } +/// `newfield-call`: an entry of a table constructor that ends a line, followed by one that starts +/// the next with `(`, a string or a table, is one call, so `{ print` followed by `("x") }` holds what +/// `print("x")` returns rather than `print` and `"x"`. Like lua-language-server, only entries without +/// a key are checked. +struct NewfieldCalls<'a, 's> { + source: &'a str, + lines: &'a LineIndex, + sink: &'a mut Sink<'s>, +} + +impl<'ast> Visitor<'ast> for NewfieldCalls<'_, '_> { + fn visit_expr(&mut self, expr: &'ast Expr) { + if let ExprKind::Table(fields) = &expr.kind { + for field in fields { + let TableField::Positional(value) = field else { continue }; + let (called, args_span) = match &value.kind { + ExprKind::Call { callee, args_span, .. } => (callee.span, *args_span), + ExprKind::MethodCall { base, method, args_span, .. } => (base.span.to(method.span), *args_span), + _ => continue, + }; + if self.lines.line_of(called.end) == self.lines.line_of(args_span.start) { + continue; + } + let text = called.text(self.source); + let called = match text.contains('\n') || text.chars().count() > 40 { + true => "the expression".to_string(), + false => format!("'{text}'"), + }; + let message = format!( + "{called} is called with the arguments on the next line, which make one entry of the table; put a ',' between them if they are two" + ); + self.sink.report(rules::NEWFIELD_CALL, value.span, message); + } + } + visit::walk_expr(self, expr); + } +} + /// `trailing-space`: spaces or tabs at the end of a line, as lua-language-server reports them. Those /// inside a comment or a string, such as a long string that spans lines, belong to it and are left /// alone, as the formatter leaves them. diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 4b35bca..8d68aa0 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -55,6 +55,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "inject-field", "undefined-field", "newline-call", + "newfield-call", "trailing-space", "redundant-return", "duplicate-doc-param", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 04d864f..a289340 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -71,6 +71,7 @@ pub const EMPTY_BLOCK: &str = "empty-block"; pub const TRAILING_SPACE: &str = "trailing-space"; pub const UNBALANCED_ASSIGNMENTS: &str = "unbalanced-assignments"; pub const NEWLINE_CALL: &str = "newline-call"; +pub const NEWFIELD_CALL: &str = "newfield-call"; pub const DUPLICATE_INDEX: &str = "duplicate-index"; pub const DUPLICATE_SET_FIELD: &str = "duplicate-set-field"; pub const DUPLICATE_ARGUMENT: &str = "duplicate-argument"; @@ -178,6 +179,7 @@ pub static RULES: &[Rule] = &[ rule(TRAILING_SPACE, Style, HINT, true, "A line ends in spaces or tabs outside a comment or string."), rule(UNBALANCED_ASSIGNMENTS, Suspicious, WARN, false, "An assignment or `local` statement has more values than targets, or leaves targets without a value."), rule(NEWLINE_CALL, Suspicious, WARN, false, "A parenthesized expression that starts a line, and is indexed or called, is read as the argument of a call to the expression that ends the line above."), + rule(NEWFIELD_CALL, Suspicious, WARN, false, "An entry of a table constructor is called with arguments that start on the next line, which were likely meant as an entry of their own."), rule(DUPLICATE_INDEX, Suspicious, WARN, false, "A table constructor sets the same key twice."), rule(DUPLICATE_SET_FIELD, Suspicious, WARN, false, "A function is assigned to the same table field twice in one block of a file, so the second replaces the first."), rule(DUPLICATE_ARGUMENT, Correctness, ERROR, false, "Two parameters of one function share a name."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 5e99c8f..9ff88b3 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -54,3 +54,4 @@ shared/config.lua:50:14 warning unknown-operator shared/config.lua:54:10 warning unknown-cast-variable shared/config.lua:56:35 warning unknown-diag-code shared/config.lua:59:20 hint unused-vararg +shared/config.lua:65:5 warning newfield-call diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index ffa1bd5..cd316d4 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -60,3 +60,9 @@ local function log(...) print(Config.Debug) end log() + +local entries = { + tostring + ('x'), +} +print(entries) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 8af9ec7..18f95c6 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1680,6 +1680,34 @@ print(1)"; } } +#[test] +fn table_entries_called_with_arguments_on_the_next_line() { + let source = "local list = { + print + ('x'), + tostring + 'y', + setmetatable + { 1 }, + list:method + (1), +} +local fine = { + print, ('x'), + print('x'), + named = print + ('x'), + print + ;('x'), +} +print(fine)"; + assert_eq!(reported_lines(source, "newfield-call"), [2, 4, 6, 8]); + assert_eq!( + findings(source, "newfield-call")[0].1, + "'print' is called with the arguments on the next line, which make one entry of the table; put a ',' between them if they are two" + ); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index e449bf8..4cd8704 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `unused-vararg`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `unused-vararg`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `newfield-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index e4d2a18..461d8e5 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -30,6 +30,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `trailing-space` | hint | A line ends in spaces or tabs, or holds nothing else. Whitespace inside a comment or a string, such as a long string that spans lines, is left alone, as the formatter leaves it. Fix available. | | `unbalanced-assignments` | warning | An assignment or `local` statement has mismatched target and value counts, as in `local a, b = 1`. A last value that is a call or `...` gives any number of values. | | `newline-call` | warning | A line starts with an expression in parentheses that is then indexed or called, such as `(value):method()` or `(function() end)()`, so Lua reads it as the argument of a call to the expression that ends the line above. A `;` before the `(` starts a new statement. | +| `newfield-call` | warning | An entry of a table constructor ends a line and the next line starts with `(`, a string or a table, so Lua reads both as one call: `{ print` followed by `("x") }` holds what `print("x")` returns. A `,` between them makes two entries. Like lua-language-server, entries with a key are not checked. | | `duplicate-index` | warning | A table constructor assigns the same key twice. | | `duplicate-set-field` | warning | A function is assigned to the same field of the same table twice in one block of a file, so the second replaces the first. A block is the main chunk, a function body, one branch of an `if`, or the code after an `if` with a branch that returns, such as an `IsDuplicityVersion()` or `lib.context` guard. A read of the field between the two, as a wrapper has, or an assignment to the table's variable or to a field it is in, also separates them. Definitions in other files or in `---@meta` files are not compared. | | `duplicate-argument` | error | Function parameters share a name. | From fa519bf0c0d976ddc52fa2a43ea121182f23b506 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:53 +0200 Subject: [PATCH 101/193] feat(rules): add ambiguity-1 --- crates/qbx_lua_analysis/src/checks/flow.rs | 47 +++++++++++++++++++ crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 3 ++ crates/qbx_lua_analysis/tests/rules.rs | 16 +++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 8 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/qbx_lua_analysis/src/checks/flow.rs b/crates/qbx_lua_analysis/src/checks/flow.rs index 34e2e7f..60b28e7 100644 --- a/crates/qbx_lua_analysis/src/checks/flow.rs +++ b/crates/qbx_lua_analysis/src/checks/flow.rs @@ -280,6 +280,30 @@ impl Flow<'_, '_> { suppressions.is_suppressed(rules::DUPLICATE_SET_FIELD, lines.line_of(offset)) } + /// `ambiguity-1`: `x + y or 0` adds before the `or`, though `x + (y or 0)` was likely meant, and + /// `x or 1 + y` adds after it, though `(x or 1) + y` was likely meant. As in lua-language-server, + /// the `or` must give a literal in the first case and take one in the second, where it reads as + /// a default; parentheses show which was meant. + fn ambiguity(&mut self, expr: &Expr, lhs: &Expr, rhs: &Expr) { + let source = self.input.source; + let text = |expr: &Expr| expr.span.text(source).split_whitespace().collect::>().join(" "); + let (computed, meant) = match (&lhs.kind, &rhs.kind) { + (ExprKind::Binary { op, lhs: a, rhs: b, .. }, _) + if is_arithmetic(*op) && is_literal(rhs) && !is_literal(b) => + { + (text(lhs), format!("{} {} ({} or {})", text(a), op.symbol(), text(b), text(rhs))) + } + (_, ExprKind::Binary { op, lhs: a, rhs: b, .. }) + if is_arithmetic(*op) && is_literal(a) && !is_operation(lhs) => + { + (text(rhs), format!("({} or {}) {} {}", text(lhs), text(a), op.symbol(), text(b))) + } + _ => return, + }; + let message = format!("'{computed}' is computed before the 'or'; write '{meant}' if that was meant"); + self.sink.report(rules::AMBIGUITY_1, expr.span, message); + } + /// `count-down-loop`: `for i = 10, 1` never runs, and `for i = #list, 1` never runs once its start /// is above 1. Both were meant to count down. fn count_down(&mut self, start: &Expr, limit: &Expr, step: Option<&Expr>) { @@ -326,6 +350,28 @@ fn number(expr: &Expr) -> Option { } } +/// An operator that binds tighter than `or` and gives a value that is no boolean. +fn is_arithmetic(op: BinOp) -> bool { + use BinOp::*; + matches!(op, Add | Sub | Mul | Div | IDiv | Mod | Pow | Concat | BAnd | BOr | BXor | Shl | Shr) +} + +fn is_literal(expr: &Expr) -> bool { + matches!( + expr.kind, + ExprKind::Number(_) + | ExprKind::String(_) + | ExprKind::JenkinsHash(_) + | ExprKind::True + | ExprKind::False + | ExprKind::Table(_) + ) +} + +fn is_operation(expr: &Expr) -> bool { + matches!(expr.kind, ExprKind::Binary { .. } | ExprKind::Unary { .. }) +} + /// `#list`, or arithmetic that starts with it, like `#list - 1`. fn starts_with_length(expr: &Expr) -> bool { match &expr.unparen().kind { @@ -535,6 +581,7 @@ impl<'ast> Visitor<'ast> for Flow<'_, '_> { fn visit_expr(&mut self, expr: &'ast Expr) { match &expr.kind { ExprKind::Table(fields) => self.duplicate_keys(fields), + ExprKind::Binary { op: BinOp::Or, lhs, rhs, .. } => self.ambiguity(expr, lhs, rhs), ExprKind::Binary { op: BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge, lhs, rhs, .. } if same_place(lhs, rhs) => { diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index 8d68aa0..dcb2f2e 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -56,6 +56,7 @@ const EQUIVALENT_CODES: &[&str] = &[ "undefined-field", "newline-call", "newfield-call", + "ambiguity-1", "trailing-space", "redundant-return", "duplicate-doc-param", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index a289340..0016832 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -72,6 +72,7 @@ pub const TRAILING_SPACE: &str = "trailing-space"; pub const UNBALANCED_ASSIGNMENTS: &str = "unbalanced-assignments"; pub const NEWLINE_CALL: &str = "newline-call"; pub const NEWFIELD_CALL: &str = "newfield-call"; +pub const AMBIGUITY_1: &str = "ambiguity-1"; pub const DUPLICATE_INDEX: &str = "duplicate-index"; pub const DUPLICATE_SET_FIELD: &str = "duplicate-set-field"; pub const DUPLICATE_ARGUMENT: &str = "duplicate-argument"; @@ -215,6 +216,7 @@ pub static RULES: &[Rule] = &[ rule(CLOSE_NON_OBJECT, Correctness, WARN, false, "A local has no value, or one that cannot be closed, such as a number or a string (language server only)."), rule(SELF_ASSIGNMENT, Suspicious, WARN, false, "A variable is assigned to itself."), rule(SELF_COMPARISON, Suspicious, WARN, false, "Both sides of a comparison are the same expression."), + rule(AMBIGUITY_1, Suspicious, WARN, false, "An `or` gives or takes a literal next to arithmetic that binds tighter, as in `x + y or 0` or `x or 1 + y`, where the parentheses were likely meant around the `or`."), rule(COUNT_DOWN_LOOP, Suspicious, WARN, true, "A numeric for loop counts up from a start above its end, or from #list to 1, without a negative step."), rule(IMPOSSIBLE_COMPARISON, Suspicious, INFO, false, "Both sides of an == or ~= have types that share no value, so the comparison always gives the same answer (language server only)."), rule(NEED_CHECK_NIL, Correctness, WARN, false, "A local whose declared type allows nil or false is indexed, called, used in arithmetic, concatenation, # or an ordering comparison, or given as a for bound without a check (language server only)."), diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected index 9ff88b3..6724588 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource.expected @@ -55,3 +55,4 @@ shared/config.lua:54:10 warning unknown-cast-variable shared/config.lua:56:35 warning unknown-diag-code shared/config.lua:59:20 hint unused-vararg shared/config.lua:65:5 warning newfield-call +shared/config.lua:70:15 warning ambiguity-1 diff --git a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua index cd316d4..1ac825b 100644 --- a/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua +++ b/crates/qbx_lua_analysis/tests/fixtures/bad_resource/shared/config.lua @@ -66,3 +66,6 @@ local entries = { ('x'), } print(entries) + +local total = Config.Count + Config.Extra or 0 +print(total) diff --git a/crates/qbx_lua_analysis/tests/rules.rs b/crates/qbx_lua_analysis/tests/rules.rs index 18f95c6..0b4dfa2 100644 --- a/crates/qbx_lua_analysis/tests/rules.rs +++ b/crates/qbx_lua_analysis/tests/rules.rs @@ -1708,6 +1708,22 @@ print(fine)"; ); } +#[test] +fn or_next_to_operators_that_bind_tighter() { + let source = "local x, y = 1, 2 +print(x + y or 0) +print(x or 1 + y) +print(x .. y or '') +print(x * y or {}) +print(x or 2 * y) +print(x + (y or 0), (x + y) or 0, (x or 1) + y) +print(x + 1 or 0, -x or 0, x == 1 or 0, x or y + 1, x + y or nil)"; + assert_eq!(reported_lines(source, "ambiguity-1"), [2, 3, 4, 5, 6]); + let messages: Vec = findings(source, "ambiguity-1").into_iter().map(|(_, m)| m).collect(); + assert_eq!(messages[0], "'x + y' is computed before the 'or'; write 'x + (y or 0)' if that was meant"); + assert_eq!(messages[1], "'1 + y' is computed before the 'or'; write '(x or 1) + y' if that was meant"); +} + #[test] fn returns_without_values_that_end_a_function() { let source = "local function a() return end diff --git a/docs/reference.md b/docs/reference.md index 4cd8704..c92ea90 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -102,7 +102,7 @@ qbx-lint needs anything beyond it. `--config` also accepts these files. | LuaLS / EmmyLua setting | Used as | | --- | --- | | `diagnostics.globals` | `globals`, without the names qbx-lint already knows: runtime globals, natives, and globals of imports such as `@ox_lib/init.lua`, so the manifest and client/server checks still apply to them | -| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `unused-vararg`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `newfield-call`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | +| `diagnostics.disable` | `off` for `undefined-global`, `lowercase-global`, `unused-local`, `unused-function`, `unused-label`, `unused-vararg`, `redefined-local`, `unreachable-code`, `empty-block`, `unbalanced-assignments`, `duplicate-index`, `duplicate-set-field`, `count-down-loop`, `missing-parameter`, `redundant-parameter`, `undefined-doc-name`, `undefined-doc-param`, `duplicate-doc-alias`, `duplicate-doc-field`, `missing-global-doc`, `missing-local-export-doc`, `incomplete-signature-doc`, `missing-fields`, `assign-type-mismatch`, `invisible`, `param-type-mismatch`, `return-type-mismatch`, `missing-return`, `redundant-return-value`, `discard-returns`, `cast-type-mismatch`, `cast-local-type`, `no-unknown`, `need-check-nil`, `inject-field`, `undefined-field`, `newline-call`, `newfield-call`, `ambiguity-1`, `trailing-space`, `redundant-return`, `duplicate-doc-param`, `doc-field-no-class`, `unknown-operator`, `unknown-cast-variable`, `unknown-diag-code`, `close-non-object` and `circle-doc-class`; EmmyLua's `unused` covers the `unused-*` rules | | `diagnostics.severity` | Levels for the same codes (`Error`, `Warning`, `Information`, `Hint`, with or without a trailing `!`) | | `diagnostics.neededFileStatus` | For the same codes, `Any` or `Opened` turns a rule that is off by default, such as `no-unknown` or `missing-global-doc`, on as a warning, unless `diagnostics.severity` gives its level. `None` turns a rule off, whatever its severity. With or without a trailing `!` | | `workspace.ignoreDir` | Exclusions. LuaLS entries are gitignore-style patterns; `.emmyrc.json` entries are directories from the root | diff --git a/docs/rules.md b/docs/rules.md index 461d8e5..247066a 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -66,6 +66,7 @@ set any rule to `off`, `hint`, `info`, `warning`, or `error`. | `close-non-object` | warning | A `` local has no value, or one that cannot be closed, such as a number, a string, `true` or a function. A value passes when part of its type is `nil`, `false`, a table or a class, which may have a `__close` metamethod, or unknown. Reported by qbx-lua-ls only. | | `self-assignment` | warning | A variable is assigned to itself. | | `self-comparison` | warning | A comparison has the same expression on both sides. | +| `ambiguity-1` | warning | An `or` sits next to arithmetic, concatenation or a bitwise operator, which binds tighter: `x + y or 0` adds before the `or`, where `x + (y or 0)` was likely meant, and `x or 1 + y` adds after it, where `(x or 1) + y` was likely meant. As in lua-language-server, the `or` has to give a literal in the first case, other than one the arithmetic ends with, and take one in the second. Parentheses around either part say which was meant. | | `count-down-loop` | warning | A numeric `for` loop starts above its end without a negative step: a literal start above a literal end, with no step or a positive one, which never runs, or `#list, 1` or arithmetic on `#list` such as `#list - 1, 1`, with no step, which never runs once the start is above 1. Fix available. | | `impossible-comparison` | info | An `==` or `~=` compares values whose declared types share no value, such as a string with a number, or a literal with a type that does not list it, so it always gives the same answer. Comparisons with `nil` are not reported. Reported by qbx-lua-ls only. | | `need-check-nil` | warning | A local whose declared type allows `nil` or `false`, such as the `string?` a function's `@return` declares, is indexed, called, used as a key to store a value under, used in arithmetic, concatenation, `#` or a `<`, `<=`, `>` or `>=` comparison, given as a numeric `for` bound, or passed for a parameter typed with a native handle name such as `Vehicle` that does not take `nil`, with no guard or cast ruling the missing value out. Every such read is reported. Reported by qbx-lua-ls only. | From 26eb25b9dcdf47efbad079338935a3edff82ee6a Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:54 +0200 Subject: [PATCH 102/193] feat(rules): add global-in-nil-env --- crates/qbx_lua_analysis/src/checks/globals.rs | 7 +++ crates/qbx_lua_analysis/src/checks/locals.rs | 5 +- crates/qbx_lua_analysis/src/lua_ls_config.rs | 1 + crates/qbx_lua_analysis/src/rules.rs | 2 + crates/qbx_lua_analysis/src/scope.rs | 53 ++++++++++++++++++- .../tests/fixtures/bad_resource.expected | 1 + .../fixtures/bad_resource/shared/config.lua | 5 ++ crates/qbx_lua_analysis/tests/rules.rs | 26 +++++++++ docs/reference.md | 2 +- docs/rules.md | 1 + 10 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/qbx_lua_analysis/src/checks/globals.rs b/crates/qbx_lua_analysis/src/checks/globals.rs index bf090a8..58348a0 100644 --- a/crates/qbx_lua_analysis/src/checks/globals.rs +++ b/crates/qbx_lua_analysis/src/checks/globals.rs @@ -32,6 +32,13 @@ pub(super) fn check(input: &FileInput, sink: &mut Sink) { GlobalRefKind::Write | GlobalRefKind::FunctionDecl => check_definition(input, global, sink), } } + for field in input.resolution.env_fields.iter().filter(|field| field.nil_env) { + sink.report( + rules::GLOBAL_IN_NIL_ENV, + field.span, + format!("'{}' is looked up in the local _ENV, which is nil here, so this raises an error", field.name), + ); + } Fields { input, sink }.visit_block(&input.chunk.block); } diff --git a/crates/qbx_lua_analysis/src/checks/locals.rs b/crates/qbx_lua_analysis/src/checks/locals.rs index 34d83b4..e79ada3 100644 --- a/crates/qbx_lua_analysis/src/checks/locals.rs +++ b/crates/qbx_lua_analysis/src/checks/locals.rs @@ -10,7 +10,10 @@ use crate::scope::{FuncId, Local, LocalId, LocalKind, LocalRef, Resolved}; pub(super) fn check(input: &FileInput, sink: &mut Sink) { let prefix = input.config.ignore_unused_prefix.as_str(); - let ignored = |local: &Local| local.name == "_" || (!prefix.is_empty() && local.name.starts_with(prefix)); + // Like lua-language-server, an `_ENV` local is in use by every name that is no local. + let ignored = |local: &Local| { + local.name == "_" || local.name == "_ENV" || (!prefix.is_empty() && local.name.starts_with(prefix)) + }; let mut shapes = Shapes { input, tables: FxHashSet::default(), field_sets: FxHashSet::default(), functions: Vec::new() }; shapes.visit_block(&input.chunk.block); diff --git a/crates/qbx_lua_analysis/src/lua_ls_config.rs b/crates/qbx_lua_analysis/src/lua_ls_config.rs index dcb2f2e..4001a20 100644 --- a/crates/qbx_lua_analysis/src/lua_ls_config.rs +++ b/crates/qbx_lua_analysis/src/lua_ls_config.rs @@ -20,6 +20,7 @@ const ALIASES: &[(&str, &[&str])] = const EQUIVALENT_CODES: &[&str] = &[ "undefined-global", "lowercase-global", + "global-in-nil-env", "unused-local", "unused-function", "unused-label", diff --git a/crates/qbx_lua_analysis/src/rules.rs b/crates/qbx_lua_analysis/src/rules.rs index 0016832..4b7ea8f 100644 --- a/crates/qbx_lua_analysis/src/rules.rs +++ b/crates/qbx_lua_analysis/src/rules.rs @@ -131,6 +131,7 @@ pub const IMPOSSIBLE_COMPARISON: &str = "impossible-comparison"; pub const NEED_CHECK_NIL: &str = "need-check-nil"; pub const LOWERCASE_GLOBAL: &str = "lowercase-global"; pub const IMPLICIT_GLOBAL: &str = "implicit-global"; +pub const GLOBAL_IN_NIL_ENV: &str = "global-in-nil-env"; pub const BUILTIN_OVERWRITE: &str = "builtin-overwrite"; pub const DEPRECATED: &str = "deprecated"; pub const LOOP_NEVER_YIELDS: &str = "fivem/loop-never-yields"; @@ -223,6 +224,7 @@ pub static RULES: &[Rule] = &[ rule(LOWERCASE_GLOBAL, Suspicious, WARN, false, "A global with a lowercase first letter is defined; this is usually a missing 'local'."), rule(IMPLICIT_GLOBAL, Suspicious, WARN, false, "A global is created from inside a function and never declared at file scope."), rule(BUILTIN_OVERWRITE, Suspicious, WARN, false, "A runtime global or native is overwritten."), + rule(GLOBAL_IN_NIL_ENV, Correctness, WARN, false, "A global is read or set where a local _ENV that is nil is in scope, which raises an error."), rule(DEPRECATED, Style, WARN, false, "A deprecated runtime function is used or, in the language server, a global, field or method whose definitions are all marked @deprecated."), rule(LOOP_NEVER_YIELDS, FiveM, ERROR, false, "An infinite loop has no Wait and will freeze the game or server thread."), rule(SOURCE_AFTER_YIELD, FiveM, WARN, false, "The global 'source' is read after a yield or inside a deferred callback, where it may belong to another event."), diff --git a/crates/qbx_lua_analysis/src/scope.rs b/crates/qbx_lua_analysis/src/scope.rs index 0be91a4..5cc8495 100644 --- a/crates/qbx_lua_analysis/src/scope.rs +++ b/crates/qbx_lua_analysis/src/scope.rs @@ -69,6 +69,16 @@ impl GlobalRef { } } +/// A name that would be a global, read or set while a local `_ENV` is in scope, which makes it a +/// field of that table instead. `local _ENV = _ENV` keeps the globals. +#[derive(Clone, Debug)] +pub struct EnvField { + pub name: SmolStr, + pub span: Span, + /// The `_ENV` local is declared without a value or as `nil`, so using the name raises an error. + pub nil_env: bool, +} + #[derive(Clone, Debug)] pub struct Function { pub span: Span, @@ -95,6 +105,7 @@ pub struct Resolution { pub functions: Vec, pub labels: Vec