use std::{collections::HashSet, path::PathBuf};
use crate::{EmmyLibraryItem, Emmyrc, LuaFileInfo, load_workspace_files};
#[derive(Clone, Debug)]
pub enum WorkspaceImport {
All,
SubPaths(Vec<PathBuf>),
}
#[derive(Clone, Debug)]
pub struct WorkspaceFolder {
pub root: PathBuf,
pub import: WorkspaceImport,
pub is_library: bool,
}
impl WorkspaceFolder {
pub fn new(root: PathBuf, is_library: bool) -> Self {
Self {
root,
import: WorkspaceImport::All,
is_library,
}
}
pub fn with_sub_paths(root: PathBuf, sub_paths: Vec<PathBuf>, is_library: bool) -> Self {
Self {
root,
import: WorkspaceImport::SubPaths(sub_paths),
is_library,
}
}
}
pub fn build_workspace_folders(
workspace_folders: &[WorkspaceFolder],
emmyrc: &Emmyrc,
) -> Vec<WorkspaceFolder> {
let mut resolved = workspace_folders.to_vec();
resolved.extend(
emmyrc
.workspace
.workspace_roots
.iter()
.map(|root| WorkspaceFolder::new(PathBuf::from(root), false)),
);
resolved.extend(
emmyrc
.workspace
.library
.iter()
.map(|library| WorkspaceFolder::new(PathBuf::from(library.get_path()), true)),
);
resolved.extend(
emmyrc
.workspace
.package_dirs
.iter()
.filter_map(|package_dir| {
let package_path = PathBuf::from(package_dir);
let Some(parent) = package_path.parent() else {
log::warn!("package dir {:?} has no parent", package_path);
return None;
};
let Some(name) = package_path.file_name() else {
log::warn!("package dir {:?} has no file name", package_path);
return None;
};
Some(WorkspaceFolder::with_sub_paths(
parent.to_path_buf(),
vec![PathBuf::from(name)],
true,
))
}),
);
resolved
}
#[derive(Debug, Clone)]
pub struct WorkspaceFileMatcher {
include: Vec<String>,
entries: Vec<WorkspaceMatchEntry>,
watch_roots: HashSet<PathBuf>,
}
#[derive(Debug, Clone)]
struct WorkspaceMatchEntry {
root: PathBuf,
is_library: bool,
exclude: Vec<String>,
exclude_dir: Vec<PathBuf>,
}
pub fn collect_workspace_files(
workspaces: &[WorkspaceFolder],
emmyrc: &Emmyrc,
extra_include: Option<Vec<String>>,
extra_exclude: Option<Vec<String>>,
) -> Vec<LuaFileInfo> {
let matcher = build_workspace_file_matcher(workspaces, emmyrc, extra_include, extra_exclude);
let encoding = &emmyrc.workspace.encoding;
let mut files = Vec::new();
let mut loaded_paths = HashSet::new();
log::info!(
"collect_files from: {:?} match_pattern: {:?}, entries: {:?}",
workspaces,
matcher.source_file_globs(),
matcher.entries
);
for entry in &matcher.entries {
extend_loaded_files(
&mut files,
&mut loaded_paths,
load_workspace_files(
&entry.root,
matcher.source_file_globs(),
&entry.exclude,
&entry.exclude_dir,
Some(encoding),
)
.ok(),
);
}
log::info!("load files from workspace count: {:?}", files.len());
for file in &files {
log::debug!("loaded file: {:?}", file.path);
}
files
}
pub fn calculate_include_and_exclude(emmyrc: &Emmyrc) -> (Vec<String>, Vec<String>, Vec<PathBuf>) {
let mut include = vec!["**/*.lua".to_string()];
let mut exclude = Vec::new();
let mut exclude_dirs = Vec::new();
for extension in &emmyrc.runtime.extensions {
if extension.starts_with(".") {
include.push(format!("**/*{}", extension));
} else if extension.starts_with("*.") {
include.push(format!("**/{}", extension));
} else {
include.push(extension.clone());
}
}
for ignore_glob in &emmyrc.workspace.ignore_globs {
exclude.push(ignore_glob.clone());
}
for dir in &emmyrc.workspace.ignore_dir {
exclude_dirs.push(PathBuf::from(dir));
}
include.sort();
include.dedup();
exclude.sort();
exclude.dedup();
(include, exclude, exclude_dirs)
}
pub fn find_library_exclude(
library: &WorkspaceFolder,
emmyrc: &Emmyrc,
) -> (Vec<String>, Vec<PathBuf>) {
let mut exclude = Vec::new();
let mut exclude_dirs = Vec::new();
for lib in &emmyrc.workspace.library {
if let EmmyLibraryItem::Config(detail_config) = &lib {
let lib_path = PathBuf::from(&detail_config.path);
if lib_path == library.root {
exclude = detail_config.ignore_globs.clone();
exclude_dirs = detail_config.ignore_dir.iter().map(PathBuf::from).collect();
break;
}
}
}
(exclude, exclude_dirs)
}
fn build_workspace_file_matcher(
workspace_folders: &[WorkspaceFolder],
emmyrc: &Emmyrc,
extra_include: Option<Vec<String>>,
extra_exclude: Option<Vec<String>>,
) -> WorkspaceFileMatcher {
let (include, mut entries) =
build_workspace_matcher_parts(workspace_folders, emmyrc, extra_include, extra_exclude);
let watch_roots = entries.iter().map(|entry| entry.root.clone()).collect();
entries.sort_by_key(|entry| {
std::cmp::Reverse((entry.root.components().count(), entry.is_library))
});
WorkspaceFileMatcher {
include,
entries,
watch_roots,
}
}
fn build_workspace_matcher_parts(
workspace_folders: &[WorkspaceFolder],
emmyrc: &Emmyrc,
extra_include: Option<Vec<String>>,
extra_exclude: Option<Vec<String>>,
) -> (Vec<String>, Vec<WorkspaceMatchEntry>) {
let (mut include, mut exclude, exclude_dir) = calculate_include_and_exclude(emmyrc);
if let Some(extra_include) = extra_include {
include.extend(extra_include);
include.sort();
include.dedup();
}
if let Some(extra_exclude) = extra_exclude {
exclude.extend(extra_exclude);
exclude.sort();
exclude.dedup();
}
let mut entries = workspace_folders
.iter()
.cloned()
.flat_map(|workspace| {
WorkspaceMatchEntry::from_workspace(workspace, &exclude, &exclude_dir, emmyrc)
})
.collect::<Vec<_>>();
add_child_workspace_excludes(&mut entries);
(include, entries)
}
impl WorkspaceFileMatcher {
pub fn new(workspace_folders: &[WorkspaceFolder], emmyrc: &Emmyrc) -> Self {
let workspace_folders = build_workspace_folders(workspace_folders, emmyrc);
build_workspace_file_matcher(&workspace_folders, emmyrc, None, None)
}
pub fn is_match(&self, path: &std::path::Path) -> bool {
let include_set = match wax::any(self.include.iter().map(|s| s.as_str())) {
Ok(include_set) => include_set,
Err(_) => {
log::error!("Invalid include pattern");
return true;
}
};
for entry in &self.entries {
let Ok(relative_path) = path.strip_prefix(&entry.root) else {
continue;
};
if entry.exclude_dir.iter().any(|dir| path.starts_with(dir)) {
continue;
}
if !entry.exclude.is_empty() {
match wax::any(entry.exclude.iter().map(|s| s.as_str())) {
Ok(exclude_set) if wax::Pattern::is_match(&exclude_set, relative_path) => {
continue;
}
Ok(_) => {}
Err(_) => log::error!("Invalid exclude pattern"),
}
}
if wax::Pattern::is_match(&include_set, relative_path) {
return true;
}
}
false
}
pub fn watch_roots(&self) -> HashSet<PathBuf> {
self.watch_roots.clone()
}
pub fn source_file_globs(&self) -> &[String] {
&self.include
}
}
impl WorkspaceMatchEntry {
fn from_workspace(
workspace: WorkspaceFolder,
exclude: &[String],
exclude_dir: &[PathBuf],
emmyrc: &Emmyrc,
) -> Vec<Self> {
let is_library = workspace.is_library;
let mut exclude = exclude.to_vec();
let mut exclude_dir = exclude_dir.to_vec();
if is_library {
let (library_exclude, library_exclude_dir) = find_library_exclude(&workspace, emmyrc);
exclude.extend(library_exclude);
exclude.sort();
exclude.dedup();
exclude_dir.extend(library_exclude_dir);
exclude_dir.sort();
exclude_dir.dedup();
}
let roots = match workspace.import {
WorkspaceImport::All => vec![workspace.root],
WorkspaceImport::SubPaths(paths) => paths
.into_iter()
.map(|path| workspace.root.join(path))
.collect(),
};
roots
.into_iter()
.map(|root| Self::new(root, is_library, &exclude, &exclude_dir))
.collect()
}
fn new(root: PathBuf, is_library: bool, exclude: &[String], exclude_dir: &[PathBuf]) -> Self {
let exclude_dir = exclude_dir
.iter()
.filter(|dir| !root.starts_with(dir))
.cloned()
.collect();
Self {
root,
is_library,
exclude: exclude.to_vec(),
exclude_dir,
}
}
}
fn add_child_workspace_excludes(entries: &mut [WorkspaceMatchEntry]) {
let roots = entries
.iter()
.map(|entry| entry.root.clone())
.collect::<Vec<_>>();
for (idx, entry) in entries.iter_mut().enumerate() {
for (other_idx, root) in roots.iter().enumerate() {
if idx == other_idx {
continue;
}
if let Ok(relative) = root.strip_prefix(&entry.root)
&& relative.components().count() > 0
{
entry.exclude_dir.push(root.clone());
}
}
entry.exclude_dir.sort();
entry.exclude_dir.dedup();
}
}
fn extend_loaded_files(
files: &mut Vec<LuaFileInfo>,
loaded_paths: &mut HashSet<PathBuf>,
loaded: Option<Vec<LuaFileInfo>>,
) {
let Some(loaded) = loaded else {
return;
};
for file in loaded {
let normalized_path = PathBuf::from(&file.path)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(&file.path));
if loaded_paths.insert(normalized_path) {
files.push(file);
} else {
log::debug!("Skipping duplicate file: {:?}", file.path);
}
}
}
impl Default for WorkspaceFileMatcher {
fn default() -> Self {
Self {
include: vec!["**/*.lua".to_string()],
entries: Vec::new(),
watch_roots: HashSet::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Emmyrc;
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
static TEST_WORKSPACE_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestWorkspace {
root: PathBuf,
}
impl TestWorkspace {
fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let counter = TEST_WORKSPACE_COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"emmylua-collect-workspace-files-{}-{}-{}",
std::process::id(),
unique,
counter,
));
fs::create_dir_all(&root).unwrap();
Self { root }
}
fn write_file(&self, relative_path: &str) -> PathBuf {
let path = self.root.join(relative_path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "return true\n").unwrap();
path
}
fn path(&self, relative_path: &str) -> PathBuf {
self.root.join(relative_path)
}
}
impl Drop for TestWorkspace {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn loaded_paths(files: Vec<LuaFileInfo>) -> HashSet<PathBuf> {
files
.into_iter()
.map(|file| PathBuf::from(file.path))
.collect()
}
fn to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).unwrap()
}
fn emmyrc_from_json(json: &str) -> Emmyrc {
serde_json::from_str(json).unwrap()
}
#[test]
fn library_is_indexed_even_when_root_is_globally_ignored() {
let workspace = TestWorkspace::new();
let main_file = workspace.write_file("lua/main.lua");
let library_root = workspace.path(".test-deps/runtime/lua/vim");
let library_file = workspace.write_file(".test-deps/runtime/lua/vim/shared.lua");
let emmyrc = emmyrc_from_json(&format!(
r#"{{
"workspace": {{
"ignoreDir": [{}],
"library": [{}]
}}
}}"#,
json_string(&to_string(&workspace.path(".test-deps"))),
json_string(&to_string(&library_root)),
));
let files = collect_workspace_files(
&[
WorkspaceFolder::new(workspace.root.clone(), false),
WorkspaceFolder::new(library_root.clone(), true),
],
&emmyrc,
None,
None,
);
let loaded = loaded_paths(files);
assert!(loaded.contains(&main_file));
assert!(loaded.contains(&library_file));
}
#[test]
fn library_specific_ignores_still_apply() {
let workspace = TestWorkspace::new();
let library_root = workspace.path(".test-deps/runtime/lua/vim");
let kept_file = workspace.write_file(".test-deps/runtime/lua/vim/keep.lua");
let ignored_dir_file = workspace.write_file(".test-deps/runtime/lua/vim/tests/spec.lua");
let ignored_glob_file = workspace.write_file(".test-deps/runtime/lua/vim/async.spec.lua");
let emmyrc = emmyrc_from_json(&format!(
r#"{{
"workspace": {{
"ignoreDir": [{}],
"library": [{{
"path": {},
"ignoreDir": [{}],
"ignoreGlobs": ["**/*.spec.lua"]
}}]
}}
}}"#,
json_string(&to_string(&workspace.path(".test-deps"))),
json_string(&to_string(&library_root)),
json_string(&to_string(&library_root.join("tests"))),
));
let files = collect_workspace_files(
&[
WorkspaceFolder::new(workspace.root.clone(), false),
WorkspaceFolder::new(library_root.clone(), true),
],
&emmyrc,
None,
None,
);
let loaded = loaded_paths(files);
assert!(loaded.contains(&kept_file));
assert!(!loaded.contains(&ignored_dir_file));
assert!(!loaded.contains(&ignored_glob_file));
}
#[test]
fn global_ignore_globs_still_apply_to_libraries() {
let workspace = TestWorkspace::new();
let library_root = workspace.path(".test-deps/runtime/lua/vim");
let kept_file = workspace.write_file(".test-deps/runtime/lua/vim/keep.lua");
let ignored_file = workspace.write_file(".test-deps/runtime/lua/vim/tests/spec.skip.lua");
let emmyrc = emmyrc_from_json(&format!(
r#"{{
"workspace": {{
"ignoreDir": [{}],
"ignoreGlobs": ["**/*.skip.lua"],
"library": [{}]
}}
}}"#,
json_string(&to_string(&workspace.path(".test-deps"))),
json_string(&to_string(&library_root)),
));
let files = collect_workspace_files(
&[
WorkspaceFolder::new(workspace.root.clone(), false),
WorkspaceFolder::new(library_root.clone(), true),
],
&emmyrc,
None,
None,
);
let loaded = loaded_paths(files);
assert!(loaded.contains(&kept_file));
assert!(!loaded.contains(&ignored_file));
}
#[test]
fn configured_workspace_root_is_indexed_even_when_parent_is_globally_ignored() {
let workspace = TestWorkspace::new();
let main_file = workspace.write_file("lua/main.lua");
let configured_root = workspace.path(".generated/runtime");
let configured_file = workspace.write_file(".generated/runtime/shared.lua");
let emmyrc = emmyrc_from_json(&format!(
r#"{{
"workspace": {{
"ignoreDir": [{}],
"workspaceRoots": [{}]
}}
}}"#,
json_string(&to_string(&workspace.path(".generated"))),
json_string(&to_string(&configured_root)),
));
let workspace_folders = build_workspace_folders(
&[WorkspaceFolder::new(workspace.root.clone(), false)],
&emmyrc,
);
let files = collect_workspace_files(&workspace_folders, &emmyrc, None, None);
let loaded = loaded_paths(files);
assert!(loaded.contains(&main_file));
assert!(loaded.contains(&configured_file));
}
#[test]
fn workspace_root_is_indexed_even_when_parent_is_globally_ignored() {
let workspace = TestWorkspace::new();
let nested_root = workspace.path("packages/app");
let nested_file = workspace.write_file("packages/app/init.lua");
let emmyrc = emmyrc_from_json(&format!(
r#"{{
"workspace": {{
"ignoreDir": [{}]
}}
}}"#,
json_string(&to_string(&workspace.path("packages"))),
));
let files = collect_workspace_files(
&[WorkspaceFolder::new(nested_root.clone(), false)],
&emmyrc,
None,
None,
);
let loaded = loaded_paths(files);
assert!(loaded.contains(&nested_file));
}
#[test]
fn nested_global_ignore_dirs_still_apply_inside_library_roots() {
let workspace = TestWorkspace::new();
let library_root = workspace.path("libs/runtime/lua/vim");
let kept_file = workspace.write_file("libs/runtime/lua/vim/keep.lua");
let ignored_file = workspace.write_file("libs/runtime/lua/vim/tests/spec.lua");
let emmyrc = emmyrc_from_json(&format!(
r#"{{
"workspace": {{
"ignoreDir": [{}],
"library": [{}]
}}
}}"#,
json_string(&to_string(&library_root.join("tests"))),
json_string(&to_string(&library_root)),
));
let files = collect_workspace_files(
&[
WorkspaceFolder::new(workspace.root.clone(), false),
WorkspaceFolder::new(library_root.clone(), true),
],
&emmyrc,
None,
None,
);
let loaded = loaded_paths(files);
assert!(loaded.contains(&kept_file));
assert!(!loaded.contains(&ignored_file));
}
}