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
94 changes: 94 additions & 0 deletions crates/qbx_lint/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,46 @@ fn ignored_diagnostics_keep_the_files_in_the_analysis() {
}
}

#[test]
fn configured_imports_define_globals_by_side_for_matching_resources() {
let fixture = Fixture::new();
let manifest = "fx_version 'cerulean'\ngame 'gta5'\n";
fixture.write("resources/lib/fxmanifest.lua", format!("{manifest}files {{ 'shared/**.lua', 'client/*.lua' }}\n"));
fixture.write("resources/lib/shared/deep/api.lua", "SharedApi = {}\n");
fixture.write("resources/lib/client/api.lua", "function ClientApi() end\n");
for resource in ["[lib]/shop", "other"] {
let scripts = "client_script 'client.lua'\nserver_script 'server.lua'\n";
fixture.write(&format!("resources/{resource}/fxmanifest.lua"), format!("{manifest}{scripts}"));
fixture.write(&format!("resources/{resource}/client.lua"), "print(SharedApi, ClientApi)\n");
fixture.write(&format!("resources/{resource}/server.lua"), "print(SharedApi, ClientApi)\n");
}
fixture.write(
"qbxlint.toml",
"[[overrides]]\nfiles = ['resources/[[]lib[]]/**']\n\
[overrides.imports]\nshared = ['@lib/shared/**.lua']\nclient = ['@lib/client/*.lua']\n",
);
let output = fixture.run(&["--format", "json", "--no-fail", "resources"]);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let mut undefined: Vec<String> = Vec::new();
for file in json["files"].as_array().unwrap() {
for diagnostic in file["diagnostics"].as_array().unwrap().iter().filter(|d| d["code"] == "undefined-global") {
let name = diagnostic["message"].as_str().unwrap().split('\'').nth(1).unwrap();
undefined.push(format!("{} {name}", file["path"].as_str().unwrap().replace('\\', "/")));
}
}
undefined.sort();
assert_eq!(
undefined,
[
"resources/[lib]/shop/server.lua ClientApi",
"resources/other/client.lua ClientApi",
"resources/other/client.lua SharedApi",
"resources/other/server.lua ClientApi",
"resources/other/server.lua SharedApi",
]
);
}

#[test]
fn relative_and_absolute_config_paths_apply_identical_exclusions_and_overrides() {
let fixture = Fixture::new();
Expand Down Expand Up @@ -193,6 +233,60 @@ fn relative_and_absolute_config_paths_apply_identical_exclusions_and_overrides()
assert_eq!(fixture.read("skip/broken.lua"), b"local n=1\nprint(n)\n");
}

#[test]
fn configured_imports_respect_excluded_files_and_directories() {
let fixture = Fixture::new();
fixture.write("resources/lib/fxmanifest.lua", "fx_version 'cerulean'\ngame 'gta5'\nfiles { 'api.lua' }\n");
fixture.write("resources/lib/api.lua", "ExcludedApi = {}\n");
fixture.write("resources/shop/fxmanifest.lua", "fx_version 'cerulean'\ngame 'gta5'\nclient_script 'client.lua'\n");
fixture.write("resources/shop/client.lua", "print(ExcludedApi)\n");
for excluded in ["resources/lib/api.lua", "resources/lib"] {
for pattern in ["@lib/*.lua", "@lib/api.lua"] {
fixture.write("qbxlint.toml", format!("exclude = ['{excluded}']\n[imports]\nclient = ['{pattern}']\n"));
let output = fixture.run(&["--format", "json", "--no-fail", "resources"]);
assert_success(&output);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let reported = json["files"].as_array().unwrap().iter().any(|f| {
f["path"].as_str().unwrap().replace('\\', "/").ends_with("shop/client.lua")
&& f["diagnostics"].as_array().unwrap().iter().any(|d| d["code"] == "undefined-global")
});
assert!(reported, "{pattern} leaked a global excluded by {excluded}: {json}");
}
}
}

#[test]
fn configured_ox_lib_imports_supply_extensions_and_cache_checks_by_side() {
let fixture = Fixture::new();
fixture.write(
"shop/fxmanifest.lua",
"fx_version 'cerulean'\ngame 'gta5'\nclient_script 'client.lua'\nserver_script 'server.lua'\n",
);
fixture.write("shop/client.lua", "print(table.contains({}, 1), PlayerPedId())\n");
fixture.write("shop/server.lua", "print(table.contains({}, 1))\n");
for side in ["shared", "client", "server"] {
fixture.write(
"qbxlint.toml",
format!("[rules]\n'qbox/prefer-cache' = 'warning'\n[imports]\n{side} = ['@ox_lib/init.lua']\n"),
);
let output = fixture.run(&["--format", "json", "--no-fail", "shop"]);
assert_success(&output);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
for file in json["files"].as_array().unwrap() {
let path = file["path"].as_str().unwrap().replace('\\', "/");
let client = path.ends_with("/client.lua");
if !client && !path.ends_with("/server.lua") {
continue;
}
let codes: Vec<&str> =
file["diagnostics"].as_array().unwrap().iter().map(|d| d["code"].as_str().unwrap()).collect();
let available = side == "shared" || side == if client { "client" } else { "server" };
assert_eq!(codes.contains(&"undefined-field"), !available, "{side}: {file}");
assert_eq!(codes.contains(&"qbox/prefer-cache"), client && available, "{side}: {file}");
}
}
}

#[test]
fn lua_ls_settings_apply_when_no_qbxlint_toml_exists() {
let fixture = Fixture::new();
Expand Down
2 changes: 1 addition & 1 deletion crates/qbx_lua_analysis/src/checks/fivem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const MAY_RUN_CALLBACKS: &[&str] =

pub(super) fn check(input: &FileInput, sink: &mut Sink) {
let uses_ox_lib_cache =
input.resource.is_some_and(|r| r.name != "ox_lib" && r.manifest.imports_path("@ox_lib/init.lua", Side::Client))
input.resource.is_some_and(|r| r.name != "ox_lib" && r.env.imports_path("@ox_lib/init.lua", Side::Client))
&& input.side != Some(Side::Server);
let mut checker = FiveM {
input,
Expand Down
2 changes: 1 addition & 1 deletion crates/qbx_lua_analysis/src/checks/globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ impl Fields<'_, '_> {
let resource_defines = self.input.resource.is_some_and(|r| {
r.env.defines_field(table, &field.text)
|| (OX_LIB_STD_EXTENSIONS.contains(&(table, field.text.as_str()))
&& r.manifest.imports_path("@ox_lib/init.lua", self.input.side.unwrap_or(Side::Shared)))
&& r.env.imports_path("@ox_lib/init.lua", self.input.side.unwrap_or(Side::Shared)))
});
if !defined_here && !resource_defines {
self.sink.report(rules::UNDEFINED_FIELD, field.span, format!("'{table}' has no field '{}'", field.text));
Expand Down
69 changes: 69 additions & 0 deletions crates/qbx_lua_analysis/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ use std::path::{Path, PathBuf};

use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use qbx_fivem_data::Side;
use serde::Deserialize;

use crate::diagnostic::Severity;
use crate::project::split_import;
use crate::{lua_ls_config, rules};

pub const CONFIG_FILE_NAMES: &[&str] = &["qbxlint.toml", ".qbxlint.toml"];
Expand Down Expand Up @@ -42,22 +44,43 @@ struct RawConfig {
ignore_unused_prefix: Option<String>,
rules: BTreeMap<String, Level>,
overrides: Vec<RawOverride>,
imports: Imports,
format: qbx_lua_fmt::FormatOptions,
}

/// Files a resource runs without an fxmanifest.lua entry, for example through
/// `load(LoadResourceFile(...))`, as `@resource/path` patterns grouped by the side they run on.
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
struct Imports {
shared: Vec<String>,
client: Vec<String>,
server: Vec<String>,
}

impl Imports {
fn entries(&self) -> impl Iterator<Item = (&str, Side)> {
[(&self.shared, Side::Shared), (&self.client, Side::Client), (&self.server, Side::Server)]
.into_iter()
.flat_map(|(patterns, side)| patterns.iter().map(move |p| (p.as_str(), side)))
}
}

#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
struct RawOverride {
files: Vec<String>,
globals: Vec<String>,
rules: BTreeMap<String, Level>,
imports: Imports,
}

#[derive(Clone, Debug)]
struct Override {
files: GlobSet,
globals: Vec<String>,
rules: BTreeMap<String, Level>,
imports: Imports,
}

#[derive(Clone, Debug)]
Expand All @@ -74,6 +97,7 @@ pub struct Config {
pub notes: Vec<String>,
rules: BTreeMap<String, Level>,
overrides: Vec<Override>,
imports: Imports,
}

const DEFAULT_EXCLUDES: &[&str] = &["**/node_modules/**", "**/.git/**", "**/[[]builders[]]/**"];
Expand Down Expand Up @@ -178,6 +202,15 @@ impl Config {
return Err(format!("unknown rule '{code}'"));
}
}
let imports = std::iter::once(&raw.imports).chain(raw.overrides.iter().map(|o| &o.imports));
for (pattern, _) in imports.flat_map(Imports::entries) {
let lua = pattern.ends_with(".lua") || pattern.ends_with('*');
if split_import(pattern).is_none() || !lua {
return Err(format!(
"import '{pattern}' must name Lua files as '@resource/path', such as '@lib/shared/**.lua'"
));
}
}
let exclude = build_globset(DEFAULT_EXCLUDES.iter().copied().chain(raw.exclude.iter().map(String::as_str)))?;
let ignore_diagnostics = build_gitignore(&root, &raw.ignore_diagnostics)?;
let overrides = raw
Expand All @@ -188,6 +221,7 @@ impl Config {
files: build_globset(o.files.iter().map(String::as_str))?,
globals: o.globals,
rules: o.rules,
imports: o.imports,
})
})
.collect::<Result<Vec<_>, String>>()?;
Expand All @@ -202,6 +236,7 @@ impl Config {
notes: Vec::new(),
rules: raw.rules,
overrides,
imports: raw.imports,
})
}

Expand Down Expand Up @@ -239,6 +274,15 @@ impl Config {
}
FileConfig { rules, globals, ignore_unused_prefix: self.ignore_unused_prefix.clone() }
}

/// The configured `imports` of the resource whose manifest is `manifest_path`. The scripts of a
/// resource share their globals, so an override adds its imports to every resource whose
/// manifest its `files` patterns match.
pub fn imports_for(&self, manifest_path: &Path) -> Vec<(&str, Side)> {
let relative = self.relative(manifest_path);
let overrides = self.overrides.iter().filter(|o| o.files.is_match(relative)).map(|o| &o.imports);
std::iter::once(&self.imports).chain(overrides).flat_map(Imports::entries).collect()
}
}

fn build_globset<'a>(patterns: impl Iterator<Item = &'a str>) -> Result<GlobSet, String> {
Expand Down Expand Up @@ -339,6 +383,31 @@ mod tests {
assert!(!config.is_excluded(Path::new("/repo/vendor/lib.lua")));
}

#[test]
fn imports_apply_to_resources_whose_manifest_an_override_matches() {
let config = Config::parse(
r#"
[imports]
shared = ["@lib/shared/**.lua"]
[[overrides]]
files = ["resources/[[]lib[]]/**"]
imports = { client = ["@lib/client/*.lua"], server = ["@oxmysql/lib/MySQL.lua"] }
"#,
PathBuf::from("/repo"),
)
.unwrap();
let everywhere = [("@lib/shared/**.lua", Side::Shared)];
assert_eq!(config.imports_for(Path::new("/repo/resources/chat/fxmanifest.lua")), everywhere);
assert_eq!(
config.imports_for(Path::new("/repo/resources/[lib]/shop/fxmanifest.lua")),
[everywhere[0], ("@lib/client/*.lua", Side::Client), ("@oxmysql/lib/MySQL.lua", Side::Server)]
);
for pattern in ["lib/shared/a.lua", "@lib", "@lib/web/app.js"] {
let error = Config::parse(&format!("imports = {{ shared = ['{pattern}'] }}"), PathBuf::new()).unwrap_err();
assert!(error.contains(pattern), "{error}");
}
}

#[test]
fn rejects_unknown_rules() {
assert!(Config::parse("[rules]\n\"nope\" = \"off\"", PathBuf::new()).unwrap_err().contains("unknown rule"));
Expand Down
Loading
Loading