Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/qbx_lua_analysis/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,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(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 sets a field of a LuaCATS class to a value of the wrong type (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(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)."),
Expand Down
16 changes: 11 additions & 5 deletions crates/qbx_lua_ls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ 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 string values a parameter lists, through aliases and `@overload`s, as soon as its argument
starts or inside its string, and a function literal with the matching parameters where an
argument takes one. Call snippets stop in the quotes of those values, and end after a value
that picks an overload, so the handler written next matches it.
- 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 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 `=`,
`==`, `~=` or `return`, beside the names in scope once a word is typed.
- `---@` continuation on Enter inside LuaCATS annotation blocks, through on-type formatting.
- Reference hovers for literal control IDs in PAD natives and ped configuration flags
in `SetPedConfigFlag` / `GetPedConfigFlag`, using bundled Cfx documentation.
Expand All @@ -38,7 +43,8 @@ Available features depend on the editor's LSP client.
[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:
`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, `undeclared-field` for fields and keys
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
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
Expand Down
34 changes: 27 additions & 7 deletions crates/qbx_lua_ls/docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,35 @@ command, titled `Suggest callback names` or `Suggest values`, so they are sugges
insertion. Other clients get the snippet without the command.

Where an argument starts, right after the `(` or `,` in front of it, completion offers what it can
be. Values that its parameter lists are offered quoted, with the value as filter text. 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, and the function's parameters from the signature they fit best, so
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
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, and returns
`null` where the argument lists no values and takes no function. Inside a string argument, the
same values replace the string's contents.
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.

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
`---@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
where it is assigned again; types inferred from assigned values list nothing. Nothing is offered
in front of a value already written, such as the `other` of `local enabled = other` with the
cursor before it, or in a word the cursor is in. A space is a completion trigger character for
these items only: a request whose `context.triggerCharacter` is a space returns nothing else,
returns `null` where no type lists values or takes a function, and marks its list `isIncomplete`,
so that the client asks again once a word is typed and gets the names in scope beside the values.
`=` is no trigger character, so Enter right after a typed `=` still starts a new line. Inside a
string in one of those places, or on either side of the comparison, the string values replace the
string's contents.

The server requests file watches only when
`workspace.didChangeWatchedFiles.dynamicRegistration` is `true`. It watches Lua, lint config,
Expand Down
116 changes: 116 additions & 0 deletions crates/qbx_lua_ls/src/features/assignments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! `assign-type-mismatch` for variables: a `local` or an assignment with a `---@type` above it has
//! to store values of that type, and so does a later assignment to a local declared with
//! `---@type` or to a parameter documented with `@param`. As for `@return`, a value of a different
//! kind, or a literal the type does not list, is a mismatch. `class_tables` checks the fields of
//! class tables.

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 super::class_tables::{mismatched_fields, Classes};
use super::unknown_types::has_param_line;
use crate::infer::{Decl, Infer};
use crate::types::Type;

/// Each value stored in a class field or a variable whose type does not take it, with the message
/// 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() };
finder.visit_block(&chunk.block);
finder.out.retain(|(span, _)| !out.iter().any(|(reported, _)| reported == span));
out.append(&mut finder.out);
out
}

struct Finder<'a, 'b> {
infer: &'a Infer<'b>,
classes: Classes<'a, 'b>,
out: Vec<(Span, String)>,
}

impl Finder<'_, '_> {
/// The `---@type` above `stmt` for the name at `index`. `---@class Name` above a table
/// declares the class rather than a value of it.
fn stmt_type(&self, stmt: &Stmt, index: usize) -> Option<Type> {
let doc = self.infer.ctx.doc_at(stmt.span.start);
doc.type_at(index).filter(|_| doc.classes.is_empty()).cloned()
}

/// The type a local is declared with: the `---@type` above its `local` statement, or its
/// `@param` line. What the guards around an assignment tell about the local does not limit
/// what it may be given.
fn local_type(&self, id: LocalId) -> Option<Type> {
let ctx = self.infer.ctx;
let local = ctx.resolution.local(id);
match ctx.decl(local.decl.start)? {
Decl::Local { stmt, index } => self.stmt_type(stmt, *index),
Decl::Param { .. } if has_param_line(self.infer, local) => Some(self.infer.local_type(id)),
_ => None,
}
}

/// Reports each of `exprs` that the declared type of the target it is stored in does not take.
fn check(&mut self, targets: &[Target], exprs: &[Expr]) {
if targets.iter().all(|target| target.ty.is_none()) {
return;
}
let from = self.classes.file();
for (index, (target, (given, span))) in targets.iter().zip(self.classes.values(exprs)).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 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);
self.out.push((span, format!("Cannot assign `{shown}` to `{name}` of type `{expected}`")));
}
}
}
}

/// Where a value is stored: the name it is written to, the type declared for it, and whether the
/// `---@type` of the statement that stores it declares that type.
struct Target {
name: Span,
ty: Option<Type>,
annotated: bool,
}

impl<'c> Visitor<'c> for Finder<'_, '_> {
fn visit_stmt(&mut self, stmt: &'c Stmt) {
match &stmt.kind {
StmtKind::Local { names, exprs, in_unpack: false } => {
let declared = |(index, name): (usize, &AttribName)| {
let ty = self.stmt_type(stmt, index);
Target { name: name.name.span, annotated: ty.is_some(), ty }
};
let targets: Vec<_> = names.iter().enumerate().map(declared).collect();
self.check(&targets, exprs);
}
StmtKind::Assign { targets, exprs } => {
// The `---@type` above the assignment, or else the type its target is declared with.
let declared = |(index, target): (usize, &Expr)| {
let own = self.stmt_type(stmt, index);
let annotated = own.is_some();
let ty = own.or_else(|| match &target.kind {
ExprKind::Name(name) => match self.infer.ctx.resolution.resolve_at(name.span.start) {
Some(Resolved::Local(id)) => self.local_type(id),
_ => None,
},
_ => None,
});
Target { name: target.span, ty, annotated }
};
let targets: Vec<_> = targets.iter().enumerate().map(declared).collect();
self.check(&targets, exprs);
}
_ => {}
}
visit::walk_stmt(self, stmt);
}
}
28 changes: 23 additions & 5 deletions crates/qbx_lua_ls/src/features/class_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,20 @@ impl<'a, 'b> Classes<'a, 'b> {
self.infer.expr(expr)
}

/// The values that `exprs` give a `return` or an assignment, with where each is written. A call
/// at the end gives every value it returns.
pub fn values(&self, exprs: &[Expr]) -> Vec<(Type, Span)> {
let mut values = Vec::new();
for (i, expr) in exprs.iter().enumerate() {
if i + 1 == exprs.len() && expr.is_call() {
values.extend(self.infer.expr_multi(expr).into_iter().map(|ty| (ty, expr.span)));
} else {
values.push((self.value_type(expr), expr.span));
}
}
values
}

/// Whether a field of type `ty` may be left out: `string?`, `string|nil`, `any` or no type.
pub fn admits_nil(&self, ty: &Type, from: FileId) -> bool {
self.admits_nil_at(ty, from, 0)
Expand Down Expand Up @@ -726,17 +740,21 @@ 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 let (true, Some(ty)) = (doc.classes.is_empty(), &doc.ty) {
for expr in exprs {
self.top_table(ty, expr);
if doc.classes.is_empty() {
for (index, expr) in exprs.iter().enumerate() {
if let Some(ty) = doc.type_at(index) {
self.top_table(ty, expr);
}
}
}
}
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() {
for (target, expr) in targets.iter().zip(exprs).filter(|(_, expr)| is_table(expr)) {
let expected = doc.ty.clone().unwrap_or_else(|| self.classes.infer.expr(target));
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));
self.top_table(&expected, expr);
}
}
Expand Down
47 changes: 39 additions & 8 deletions crates/qbx_lua_ls/src/features/comparisons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! it is deliberate.

use qbx_fivem_data::native;
use qbx_lua_analysis::scope::{LocalId, Resolved};
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;
Expand All @@ -25,19 +25,41 @@ const MAX_DEPTH: u32 = 16;
/// Each `==` and `~=` of `chunk` whose sides share no value, with a message naming both types and
/// the answer the comparison always gives.
pub fn impossible_comparisons(infer: &Infer, chunk: &Chunk) -> Vec<(Span, String)> {
let mut finder = Finder { infer, classes: Classes::new(infer), out: Vec::new() };
let mut finder = Finder { declared: Declared::new(infer), out: Vec::new() };
finder.visit_block(&chunk.block);
finder.out
}

struct Finder<'a, 'b> {
declared: Declared<'a, 'b>,
out: Vec<(Span, String)>,
}

/// Reads the types that expressions are declared to have.
pub struct Declared<'a, 'b> {
infer: &'a Infer<'b>,
classes: Classes<'a, 'b>,
out: Vec<(Span, String)>,
/// Whether a local that is assigned again keeps the type its annotation gives it.
keeps_annotations: bool,
}

impl Finder<'_, '_> {
impl<'a, 'b> Declared<'a, 'b> {
pub fn new(infer: &'a Infer<'b>) -> Self {
Self { infer, classes: Classes::new(infer), keeps_annotations: false }
}

/// Takes the `---@type` or `@param` of a local at its word, also where the local is assigned
/// again. Completion reads the values to offer for it from there, which no diagnostic can
/// rely on.
pub fn keeping_annotations(infer: &'a Infer<'b>) -> Self {
Self { keeps_annotations: true, ..Self::new(infer) }
}

/// The type of `expr` as far as it is declared, and `unknown` beyond that.
pub fn of(&self, expr: &Expr) -> Type {
self.declared(expr, 0)
}

fn declared(&self, expr: &Expr, depth: u32) -> Type {
if depth > MAX_DEPTH {
return Type::Unknown;
Expand Down Expand Up @@ -85,7 +107,7 @@ impl Finder<'_, '_> {
/// declaration is `unknown`, since its declared type may not be what it holds.
fn local(&self, id: LocalId, offset: u32, depth: u32) -> Type {
let local = self.infer.ctx.resolution.local(id);
if local.refs.iter().any(|r| r.write) {
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) {
Expand All @@ -101,13 +123,22 @@ impl Finder<'_, '_> {
self.infer.narrowed(id, offset, ty)
}

/// 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) {
Some(Decl::Local { stmt, index }) => self.infer.ctx.doc_at(stmt.span.start).type_at(*index).is_some(),
Some(Decl::Param { .. }) => has_param_line(self.infer, local),
_ => false,
}
}

/// What the `local` statement `stmt` gives the name at `index`: its `---@type`, or the declared
/// type of the value it takes. A literal written out is widened, as `local mode = 'dev'` is a
/// setting to change.
fn local_value(&self, id: LocalId, stmt: &Stmt, index: usize, depth: u32) -> Type {
let StmtKind::Local { exprs, in_unpack: false, .. } = &stmt.kind else { return Type::Unknown };
let doc = self.infer.ctx.doc_at(stmt.span.start);
if doc.ty.is_some() || !doc.classes.is_empty() {
if doc.type_at(index).is_some() || !doc.classes.is_empty() {
return self.infer.local_type(id);
}
match exprs.get(index) {
Expand Down Expand Up @@ -190,8 +221,8 @@ impl Finder<'_, '_> {
impl<'c> Visitor<'c> for Finder<'_, '_> {
fn visit_expr(&mut self, expr: &'c Expr) {
if let ExprKind::Binary { op: op @ (BinOp::Eq | BinOp::Ne), lhs, rhs, .. } = &expr.kind {
let (left, right) = (self.declared(lhs, 0), self.declared(rhs, 0));
if self.classes.never_equal(&left, &right) {
let (left, right) = (self.declared.of(lhs), self.declared.of(rhs));
if self.declared.classes.never_equal(&left, &right) {
let answer = if *op == BinOp::Eq { "false" } else { "true" };
self.out.push((expr.span, format!("Comparing `{left}` with `{right}` is always {answer}")));
}
Expand Down
Loading
Loading