From cd82b3b00a670a9c8d0d171c7b08a5fc00f2c651 Mon Sep 17 00:00:00 2001 From: TheFortex Date: Fri, 25 Sep 2026 23:10:10 +0300 Subject: [PATCH 1/2] feat: add child_pattern support to sync rules for directory entry points --- .github/workflows/ci.yml | 2 + src/glob.rs | 4 ++ src/snapshot/metadata.rs | 54 ++++++++++++++++ src/snapshot_middleware/dir.rs | 15 ++++- src/snapshot_middleware/mod.rs | 99 +++++++++++++++++++++++++----- src/snapshot_middleware/project.rs | 63 +++++++++++++++++++ 6 files changed, 220 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29c2460b..57025236 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,11 @@ name: CI on: + workflow_dispatch: push: branches: - master + - "feat/**" pull_request: branches: diff --git a/src/glob.rs b/src/glob.rs index c0146863..7406131e 100644 --- a/src/glob.rs +++ b/src/glob.rs @@ -25,6 +25,10 @@ impl Glob { pub fn is_match>(&self, path: P) -> bool { self.matcher.is_match(path) } + + pub fn as_str(&self) -> &str { + self.inner.glob() + } } impl PartialEq for Glob { diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index a1bf0afe..8dd9c5ae 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -205,6 +205,20 @@ impl InstanceContext { pub fn get_user_sync_rule(&self, path: &Path) -> Option<&SyncRule> { self.sync_rules.iter().find(|&rule| rule.matches(path)) } + + /// Returns the first sync rule whose child_pattern matches the given file name. + pub fn get_user_sync_rule_for_child(&self, file_name: &str) -> Option<&SyncRule> { + self.sync_rules + .iter() + .find(|rule| rule.matches_child(file_name)) + } + + /// Returns whether any user sync rule has a child_pattern matching this file name. + pub fn is_child_pattern_match(&self, file_name: &str) -> bool { + self.sync_rules + .iter() + .any(|rule| rule.matches_child(file_name)) + } } impl Default for InstanceContext { @@ -329,6 +343,14 @@ pub struct SyncRule { /// If not specified, the file extension is the only thing cut off. #[serde(skip_serializing_if = "Option::is_none")] pub suffix: Option, + /// A pattern matching child init files within a directory for this SyncRule. + #[serde( + rename = "child_pattern", + alias = "childPattern", + default, + skip_serializing_if = "Option::is_none" + )] + pub child_pattern: Option, /// The 'base' of the glob above, allowing it to be used /// relative to a path instead of absolute. #[serde(skip)] @@ -351,6 +373,38 @@ impl SyncRule { } } + /// Returns whether this rule matches the given child file name. + pub fn matches_child(&self, file_name: &str) -> bool { + if let Some(pattern) = &self.child_pattern { + return pattern.is_match(file_name); + } + if let Some(suffix) = &self.suffix { + let expected = format!("init{suffix}"); + return file_name == expected; + } + false + } + + /// Returns whether this rule matches the given child path. + pub fn matches_child_path(&self, path: &Path) -> bool { + if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) { + if !self.matches_child(file_name) { + return false; + } + if !self.base_path.as_os_str().is_empty() { + if let Ok(suffix) = path.strip_prefix(&self.base_path) { + if let Some(exclude) = &self.exclude { + if exclude.is_match(suffix) { + return false; + } + } + } + } + return true; + } + false + } + pub fn file_name_for_path<'a>(&self, path: &'a Path) -> anyhow::Result<&'a str> { if let Some(suffix) = &self.suffix { let file_name = path diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index b9f62743..2fb21845 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -61,7 +61,7 @@ pub fn snapshot_dir_no_meta( } let normalized_path = vfs.canonicalize(path)?; - let relevant_paths = vec![ + let mut relevant_paths = vec![ normalized_path.clone(), // TODO: We shouldn't need to know about Lua existing in this // middleware. Should we figure out a way for that function to add @@ -77,6 +77,19 @@ pub fn snapshot_dir_no_meta( normalized_path.join("init.csv"), ]; + for rule in &context.sync_rules { + if let Some(child_pattern) = &rule.child_pattern { + let pattern_str = child_pattern.as_str(); + if !pattern_str.contains('*') + && !pattern_str.contains('?') + && !pattern_str.contains('[') + && !pattern_str.contains('{') + { + relevant_paths.push(normalized_path.join(pattern_str)); + } + } + } + let snapshot = InstanceSnapshot::new() .name(name) .class_name("Folder") diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index fadce5f6..622b536d 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -73,14 +73,11 @@ pub fn snapshot_from_vfs( }; if meta.is_dir() { - let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path)?; - // TODO: Support user defined init paths - // If and when we do, make sure to go support it in - // `Project::set_file_name`, as right now it special-cases - // `default.project.json` as an `init` path. + let (middleware, dir_name, init_path) = + get_dir_middleware(vfs, path, &context.sync_rules)?; match middleware { Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name), - _ => middleware.snapshot(context, vfs, &init_path, dir_name), + _ => middleware.snapshot_init(context, vfs, &init_path, dir_name), } } else { let file_name = path @@ -97,19 +94,24 @@ pub fn snapshot_from_vfs( _ => {} } + if context.is_child_pattern_match(file_name) { + return Ok(None); + } + snapshot_from_path(context, vfs, path) } } /// Gets the appropriate middleware for a directory by checking for `init` -/// files. This uses an intrinsic priority list and for compatibility, -/// that order should be left unchanged. +/// files. This first checks user-defined sync rules with `child_pattern` or `suffix`, +/// then falls back to an intrinsic priority list. /// /// Returns the middleware, the name of the directory, and the path to /// the init location. fn get_dir_middleware<'path>( vfs: &Vfs, dir_path: &'path Path, + sync_rules: &[SyncRule], ) -> anyhow::Result<(Middleware, &'path str, PathBuf)> { let dir_name = dir_path .file_name() @@ -117,6 +119,31 @@ fn get_dir_middleware<'path>( .to_str() .ok_or_else(|| anyhow::anyhow!("File name was not valid UTF-8: {}", dir_path.display()))?; + for default_project_name in DEFAULT_PROJECT_NAMES { + let project_path = dir_path.join(default_project_name); + if vfs.metadata(&project_path).with_not_found()?.is_some() { + return Ok((Middleware::Project, dir_name, project_path)); + } + } + + if let Ok(entries) = vfs.read_dir(dir_path) { + let mut child_entries = Vec::new(); + for entry in entries { + if let Ok(entry) = entry { + let entry_path = entry.path().to_path_buf(); + child_entries.push(entry_path); + } + } + + for rule in sync_rules { + for entry_path in &child_entries { + if rule.matches_child_path(entry_path) { + return Ok((rule.middleware, dir_name, entry_path.clone())); + } + } + } + } + static INIT_PATHS: OnceLock> = OnceLock::new(); let order = INIT_PATHS.get_or_init(|| { vec![ @@ -132,13 +159,6 @@ fn get_dir_middleware<'path>( ] }); - for default_project_name in DEFAULT_PROJECT_NAMES { - let project_path = dir_path.join(default_project_name); - if vfs.metadata(&project_path).with_not_found()?.is_some() { - return Ok((Middleware::Project, dir_name, project_path)); - } - } - for (middleware, name) in order { let test_path = dir_path.join(name); if vfs.metadata(&test_path).with_not_found()?.is_some() { @@ -349,6 +369,50 @@ impl Middleware { ) } + /// Creates a snapshot for an init file inside a directory, usurping the + /// directory and attaching the directory's other children. + pub fn snapshot_init( + &self, + context: &InstanceContext, + vfs: &Vfs, + init_path: &Path, + name: &str, + ) -> anyhow::Result> { + let mut output = match self { + Self::ServerScript | Self::ServerScriptDir => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::Server) + } + Self::ClientScript | Self::ClientScriptDir => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::Client) + } + Self::PluginScript | Self::PluginScriptDir => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::Plugin) + } + Self::ModuleScript | Self::ModuleScriptDir => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::Module) + } + Self::LegacyClientScript => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::LegacyClient) + } + Self::LegacyServerScript => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::LegacyServer) + } + Self::RunContextClientScript => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::RunContextClient) + } + Self::RunContextServerScript => { + snapshot_lua_init(context, vfs, init_path, name, ScriptType::RunContextServer) + } + Self::Csv | Self::CsvDir => snapshot_csv_init(context, vfs, init_path, name), + Self::Project => snapshot_project(context, vfs, init_path, name), + _ => self.snapshot(context, vfs, init_path, name), + }; + if let Ok(Some(ref mut snapshot)) = output { + snapshot.metadata.middleware = Some(*self); + } + output + } + /// Attempts to return a middleware that should be used for the given path. /// /// Returns `Err` only if the Vfs cannot read information about the path. @@ -363,7 +427,7 @@ impl Middleware { }; if meta.is_dir() { - let (middleware, _, _) = get_dir_middleware(vfs, path)?; + let (middleware, _, _) = get_dir_middleware(vfs, path, sync_rules)?; Ok(Some(middleware)) } else { for rule in sync_rules.iter().chain(default_sync_rules()) { @@ -390,6 +454,7 @@ macro_rules! sync_rule { include: Glob::new($pattern).unwrap(), exclude: None, suffix: None, + child_pattern: None, base_path: PathBuf::new(), } }; @@ -399,6 +464,7 @@ macro_rules! sync_rule { include: Glob::new($pattern).unwrap(), exclude: None, suffix: Some($suffix.into()), + child_pattern: None, base_path: PathBuf::new(), } }; @@ -408,6 +474,7 @@ macro_rules! sync_rule { include: Glob::new($pattern).unwrap(), exclude: Some(Glob::new($exclude).unwrap()), suffix: Some($suffix.into()), + child_pattern: None, base_path: PathBuf::new(), } }; diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index 09d286de..86d09176 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -1144,4 +1144,67 @@ mod test { insta::assert_yaml_snapshot!(instance_snapshot); } + + #[test] + fn sync_rule_child_pattern_dir() { + let _ = env_logger::try_init(); + + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot( + "/foo", + VfsSnapshot::dir([ + ( + "default.project.json", + VfsSnapshot::file( + r#" + { + "name": "child-pattern-test", + "syncRules": [ + { + "pattern": "**/*.local.luau", + "child_pattern": "init.local.luau", + "use": "legacyClientScript" + } + ], + "tree": { + "$path": "src" + } + } + "#, + ), + ), + ( + "src", + VfsSnapshot::dir([( + "MyLocalComponent", + VfsSnapshot::dir([ + ("init.local.luau", VfsSnapshot::file("print('hello')")), + ("ChildModule.luau", VfsSnapshot::file("return {}")), + ]), + )]), + ), + ]), + ) + .unwrap(); + + let vfs = Vfs::new(imfs); + + let instance_snapshot = snapshot_project( + &InstanceContext::default(), + &vfs, + Path::new("/foo/default.project.json"), + "child_pattern_test", + ) + .expect("snapshot error") + .expect("snapshot returned no instances"); + + assert_eq!(instance_snapshot.children.len(), 1); + let comp = &instance_snapshot.children[0]; + assert_eq!(comp.name, "MyLocalComponent"); + assert_eq!(comp.class_name, "LocalScript"); + assert_eq!(comp.children.len(), 1); + let child = &comp.children[0]; + assert_eq!(child.name, "ChildModule"); + assert_eq!(child.class_name, "ModuleScript"); + } } From 8bd64a3f81fd8a6c9690e4b8564ac36c0c2ad461 Mon Sep 17 00:00:00 2001 From: TheFortex Date: Fri, 25 Sep 2026 23:20:04 +0300 Subject: [PATCH 2/2] style: fix rustfmt line wrapping in get_dir_middleware call --- src/snapshot_middleware/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index 622b536d..97c99ace 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -73,8 +73,7 @@ pub fn snapshot_from_vfs( }; if meta.is_dir() { - let (middleware, dir_name, init_path) = - get_dir_middleware(vfs, path, &context.sync_rules)?; + let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path, &context.sync_rules)?; match middleware { Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name), _ => middleware.snapshot_init(context, vfs, &init_path, dir_name),