Skip to content
This repository was archived by the owner on Sep 30, 2026. It is now read-only.
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
4 changes: 3 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ take precedence over the config file.
Files matching the config's `exclude` patterns are never indexed or diagnosed, even while they are
open. Files matching `ignore_diagnostics` are indexed like any other file, so definitions, hover
and completion still reach their symbols, but the server publishes no diagnostics for them.
`ignore_diagnostics` changes apply once the client reports the saved config file. Files added to
`ignore_diagnostics` and `imports` changes apply once the client reports the saved config file; a
resource outside the workspace that only `imports` names is indexed on the next `qbx/reindex` or
restart. Configured imports also count for definitions, hover and completion. Files added to
or removed from `exclude` leave or enter the index on the next `qbx/reindex` or restart.

## Client capabilities and file changes
Expand Down
24 changes: 17 additions & 7 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,17 +304,22 @@ impl Server {
/// Open documents are reparsed on every edit but only reindexed once something needs the index.
fn flush_index(&mut self) {
self.diagnostics_pending |= !self.dirty.is_empty();
let mut relink = false;
for uri in std::mem::take(&mut self.dirty) {
if let Some(doc) = self.docs.get_mut(&uri) {
// A full scan reallocates file IDs, including the reserved slots for manifests.
doc.file = self.ws.index.allocate(&doc.path);
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);
}
}
}
if relink {
self.ws.link_imports();
}
}

fn publish_dirty(&mut self) {
Expand Down Expand Up @@ -531,7 +536,9 @@ impl Server {
}

fn watched_files_changed(&mut self, changes: Vec<FileEvent>) {
let mut manifests_changed = false;
// Manifests, the configured imports and new files that a glob import names change which
// files each resource imports.
let mut relink = false;
let mut config_changed = false;
for change in changes {
let Some(path) = uri_to_path(&change.uri) else { continue };
Expand All @@ -543,22 +550,25 @@ impl Server {
// A manifest appearing or vanishing changes which resources count as installed.
qbx_lua_analysis::startup::clear_cache();
self.ws.reload_manifest(&path);
manifests_changed = true;
relink = true;
} else if change.typ == FileChangeType::DELETED {
self.ws.index.remove_file(&path);
} else if path.extension().is_some_and(|e| e == "lua") && !self.docs.contains_key(&change.uri) {
self.ws.index_path(&path, FileOrigin::Workspace, None);
} 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);
}
relink |= change.typ == FileChangeType::CREATED;
}
}
if manifests_changed {
self.ws.link_imports();
}
if config_changed {
if let Some(root) = self.ws.roots.first() {
self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default();
}
self.log_config_notes();
}
if relink || config_changed {
self.ws.link_imports();
}
self.workspace_stale = true;
self.dirty.extend(self.docs.keys().cloned());
}
Expand Down
57 changes: 30 additions & 27 deletions src/workspace.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
use std::path::{Path, PathBuf};

use lsp_types::Url;
use qbx_fivem_data::{known_import, Side, STUBS};
use qbx_fivem_data::{Side, STUBS};
use qbx_lua_analysis::glob::{is_glob, manifest_glob_match};
use qbx_lua_analysis::manifest::Manifest;
use qbx_lua_analysis::project::{
find_manifest_dir, is_manifest_file, lua_files_under, manifest_path, read_source, relative_slash_path, side_of,
split_import, ResourceEnv, ResourceLocator, UnresolvedImport,
find_manifest_dir, is_manifest_file, lua_files_under, manifest_path, read_source, relative_slash_path,
resource_imports, side_of, split_import, ResourceEnv, ResourceLocator,
};
use qbx_lua_analysis::scope::resolve;
use qbx_lua_analysis::Config;
use qbx_lua_syntax::{parse, SmolStr};
use rustc_hash::FxHashSet;

use crate::index::{FileEntry, FileId, FileOrigin, Index, ResourceEntry, ResourceId};
use crate::index::{normalize_path, FileEntry, FileId, FileOrigin, Index, ResourceEntry, ResourceId};
use crate::indexer::index_file;

const MAX_INDEXED_FILE_BYTES: u64 = 2 * 1024 * 1024;
Expand Down Expand Up @@ -129,7 +130,9 @@ impl Workspace {
fn index_dependencies(&mut self) -> usize {
let mut wanted: Vec<(PathBuf, SmolStr)> = Vec::new();
for resource in &self.index.resources {
let imports = resource.manifest.imports().filter_map(|s| split_import(&s.pattern)).map(|(name, _)| name);
let configured = self.lint_config.imports_for(&resource.manifest_path).into_iter().map(|(p, _)| p);
let patterns = resource.manifest.imports().map(|s| s.pattern.as_str()).chain(configured);
let imports = patterns.filter_map(split_import).map(|(name, _)| name);
let dependencies = resource.manifest.dependencies.iter().map(|d| d.value.as_str());
for name in imports.chain(dependencies) {
wanted.push((resource.root.clone(), SmolStr::new(name.trim_start_matches('/'))));
Expand Down Expand Up @@ -257,18 +260,29 @@ impl Workspace {

pub fn link_imports(&mut self) {
for id in 0..self.index.resources.len() {
let mut imports = Vec::new();
for script in self.index.resources[id].manifest.imports() {
let Some((resource, file)) = split_import(&script.pattern) else { continue };
let Some((_, target)) = self.index.resource_by_name(resource) else { continue };
if let Some(file_id) = self.index.file_id(&target.root.join(file)) {
imports.push((file_id, script.side));
}
}
let entry = &self.index.resources[id];
let imports = resource_imports(&entry.manifest, &entry.manifest_path, &self.lint_config)
.into_iter()
.flat_map(|(pattern, side)| self.import_files(pattern).into_iter().map(move |file| (file, side)))
.collect();
self.index.resources[id].imports = imports;
}
}

/// The indexed files an `@resource/path` import names, where the path may be a manifest glob.
fn import_files(&self, pattern: &str) -> Vec<FileId> {
let Some((name, file)) = split_import(pattern) else { return Vec::new() };
let Some((_, target)) = self.index.resource_by_name(name) else { return Vec::new() };
if !is_glob(file) {
let id = self.index.file_id(&target.root.join(file));
return id.filter(|id| self.index.file(*id).is_some()).into_iter().collect();
}
// Open documents keep the path spelling the editor sent, so compare normalized paths.
let root = normalize_path(&target.root);
let matches = |path: &Path| manifest_glob_match(file, &relative_slash_path(&root, &normalize_path(path)));
target.files.iter().copied().filter(|id| self.index.file(*id).is_some_and(|f| matches(&f.path))).collect()
}

/// The lint environment of a resource, assembled from the per-file summaries in the index.
pub fn resource_env(&self, resource: ResourceId) -> ResourceEnv {
let mut env = ResourceEnv::default();
Expand All @@ -277,20 +291,9 @@ impl Workspace {
for file in entry.files.iter().filter_map(|id| self.index.file(*id)) {
env.add_summary(&file.index.summary, file.side);
}
for script in entry.manifest.imports().filter(|s| s.pattern.ends_with(".lua")) {
let resolved = split_import(&script.pattern)
.and_then(|(name, file)| Some(self.index.resource_by_name(name)?.1.root.join(file)))
.and_then(|path| self.index.file(self.index.file_id(&path)?));
if let Some(file) = resolved {
env.add_summary(&file.index.summary, Some(script.side));
}
match known_import(&script.pattern) {
Some(known) => known.globals.iter().for_each(|g| env.add_global(&SmolStr::new(g), Some(script.side))),
None if resolved.is_none() => {
env.unresolved_imports.push(UnresolvedImport { path: script.pattern.clone(), side: script.side })
}
None => {}
}
for (pattern, side) in resource_imports(&entry.manifest, &entry.manifest_path, &self.lint_config) {
let files = self.import_files(pattern).into_iter().filter_map(|id| self.index.file(id));
env.add_import(pattern, side, files.map(|file| &file.index.summary));
}
env
}
Expand Down
85 changes: 85 additions & 0 deletions tests/workspace_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,41 @@ fn scan_reloads_manifests_and_removes_deleted_and_excluded_state() {
assert!(entry.side.is_none());
}

#[test]
fn configured_imports_define_globals_by_side_and_follow_the_config() {
let fixture = Fixture::new();
fixture.write(
"qbxlint.toml",
"[[overrides]]\nfiles = ['[[]lib[]]/**']\n\
[overrides.imports]\nshared = ['@lib/shared/**.lua']\nclient = ['@lib/client/*.lua']\n",
);
fixture.write("lib/fxmanifest.lua", "files { 'shared/**.lua', 'client/*.lua' }");
fixture.write("lib/shared/deep/api.lua", "SharedApi = {}\n");
fixture.write("lib/client/api.lua", "function ClientApi() end\n");
fixture.write("[lib]/shop/fxmanifest.lua", "client_script 'client.lua'\nserver_script 'server.lua'\n");
fixture.write("other/fxmanifest.lua", "client_script 'client.lua'\n");
let undefined = |ws: &mut Workspace, relative: &str| -> Vec<String> {
let doc = fixture.document(ws, relative, "print(SharedApi, ClientApi)\n");
let found = diagnostics::diagnostics(ws, &doc, &[], &ws.crossrefs());
let undefined = found.into_iter().filter(|d| d.code == Some(NumberOrString::String("undefined-global".into())));
undefined.map(|d| d.message.split('\'').nth(1).unwrap().to_string()).collect()
};

let mut ws = fixture.workspace("");
assert_eq!(undefined(&mut ws, "[lib]/shop/client.lua"), Vec::<String>::new());
assert_eq!(undefined(&mut ws, "[lib]/shop/server.lua"), ["ClientApi"]);
assert_eq!(undefined(&mut ws, "other/client.lua"), ["SharedApi", "ClientApi"]);
let file = |ws: &Workspace, relative: &str| ws.index.file_id(&fixture.0.join(relative)).unwrap();
let client = file(&ws, "[lib]/shop/client.lua");
assert_eq!(ws.index.globals_named("ClientApi", client).len(), 1, "definitions reach the imported file");
assert_eq!(ws.index.globals_named("ClientApi", file(&ws, "[lib]/shop/server.lua")).len(), 0);

fixture.write("qbxlint.toml", "");
ws.scan();
assert_eq!(undefined(&mut ws, "[lib]/shop/client.lua"), ["SharedApi", "ClientApi"]);
assert_eq!(ws.index.globals_named("ClientApi", file(&ws, "[lib]/shop/client.lua")).len(), 0);
}

#[test]
fn scan_rediscovers_missing_external_dependencies_and_prunes_removed_libraries() {
let fixture = Fixture::new();
Expand Down Expand Up @@ -262,6 +297,56 @@ impl Drop for Client {
}
}

#[test]
fn new_glob_imports_are_linked_for_closed_open_and_unsaved_documents() {
for (open, saved, watched) in [(false, true, true), (true, true, true), (true, false, false)] {
let fixture = Fixture::new();
fixture.write("qbxlint.toml", "[imports]\nclient = ['@lib/*.lua']\n");
fixture.write("lib/fxmanifest.lua", "files { '*.lua' }\n");
fixture.write("lib/old.lua", "OldApi = true\n");
fixture.write("shop/fxmanifest.lua", "client_script 'client.lua'\n");
fixture.write("shop/client.lua", "print(NewApi)\n");
let mut client = Client::start(&fixture.0);
// Finish the initial scan before introducing the imported file.
client.request("qbx/status", Value::Null);
let caller = path_to_uri(&fixture.0.join("shop/client.lua"));
let imported = path_to_uri(&fixture.0.join("lib/new.lua"));
client.open(&caller, "print(NewApi)\n");
if saved {
fixture.write("lib/new.lua", "NewApi = true\n");
}
if open {
client.open(&imported, "NewApi = true\n");
}
if watched {
client.notify("workspace/didChangeWatchedFiles", json!({"changes": [{"uri": imported, "type": 1}]}));
}
let definitions = client.request(
"textDocument/definition",
json!({
"textDocument": {"uri": caller}, "position": {"line": 0, "character": 8}
}),
);
let definitions = definitions.as_array().expect("new glob import must have a definition");
assert!(definitions.iter().any(|definition| definition["uri"] == imported.as_str()), "{definitions:?}");
}
}

#[test]
fn configured_exact_imports_respect_exclusions() {
let fixture = Fixture::new();
fixture.write("qbxlint.toml", "exclude = ['lib/api.lua']\n[imports]\nclient = ['@lib/api.lua']\n");
fixture.write("lib/fxmanifest.lua", "files { 'api.lua' }\n");
fixture.write("lib/api.lua", "ExcludedApi = true\n");
fixture.write("shop/fxmanifest.lua", "client_script 'client.lua'\n");
fixture.write("shop/client.lua", "print(ExcludedApi)\n");
let ws = fixture.workspace("");
let caller = ws.index.file_id(&fixture.0.join("shop/client.lua")).unwrap();
let resource = ws.index.file(caller).unwrap().resource.unwrap();
assert!(!ws.resource_env(resource).defines("ExcludedApi", Some(Side::Client)));
assert!(ws.index.globals_named("ExcludedApi", caller).is_empty());
}

#[test]
fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() {
let fixture = Fixture::new();
Expand Down
Loading