From 20414da634f0b2bfc6c09dd1bc14cd8d301e55dd Mon Sep 17 00:00:00 2001 From: Yuya Nishihara Date: Mon, 16 Dec 2024 19:45:58 +0900 Subject: [PATCH] config: add function to resolve conditional tables This provides an inline version of Git's includeIf. The idea is that each config layer has an optional key "--when" to enable the layer, and a layer may have multiple sub-layer tables "--scope" to inline stuff. Layers can be nested, but that wouldn't be useful in practice. I choose "--" prefix to make these meta keys look special (and are placed earlier in lexicographical order), but I don't have strong opinion about that. We can use whatever names that are unlikely to conflict with the other config sections. resolve() isn't exported as a StackedConfig method because it doesn't make sense to call resolve() on "resolved" StackedConfig. I'll add a newtype in CLI layer to distinguish raw config from resolved one. Most consumers will just see the "resolved" config. #616 --- lib/src/config.rs | 2 + lib/src/config_resolver.rs | 445 +++++++++++++++++++++++++++++++++++++ lib/src/lib.rs | 1 + 3 files changed, 448 insertions(+) create mode 100644 lib/src/config_resolver.rs diff --git a/lib/src/config.rs b/lib/src/config.rs index 58ff111ad0..0c8475ea14 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -33,6 +33,8 @@ use thiserror::Error; use toml_edit::DocumentMut; use toml_edit::ImDocument; +pub use crate::config_resolver::resolve; +pub use crate::config_resolver::ConfigResolutionContext; use crate::file_util::IoResultExt as _; use crate::file_util::PathError; diff --git a/lib/src/config_resolver.rs b/lib/src/config_resolver.rs new file mode 100644 index 0000000000..5fa76b055f --- /dev/null +++ b/lib/src/config_resolver.rs @@ -0,0 +1,445 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Post-processing functions for [`StackedConfig`]. + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::de::IntoDeserializer as _; +use serde::Deserialize as _; +use toml_edit::DocumentMut; + +use crate::config::ConfigGetError; +use crate::config::ConfigItem; +use crate::config::ConfigLayer; +use crate::config::ConfigValue; +use crate::config::StackedConfig; + +// Prefixed by "--" so these keys look unusual. It's also nice that "-" is +// placed earlier than the other keys in lexicographical order. +const SCOPE_CONDITION_KEY: &str = "--when"; +const SCOPE_TABLE_KEY: &str = "--scope"; + +/// Parameters to enable scoped config tables conditionally. +#[derive(Clone, Debug)] +pub struct ConfigResolutionContext<'a> { + /// Home directory. `~` will be substituted with this path. + pub home_dir: Option<&'a Path>, + /// Repository path, which is usually `/.jj/repo`. + pub repo_path: Option<&'a Path>, +} + +/// Conditions to enable the parent table. +/// +/// - Each predicate is tested separately, and the results are intersected. +/// - `None` means there are no constraints. (i.e. always `true`) +// TODO: introduce fileset-like DSL? +// TODO: add support for fileset-like pattern prefixes? it might be a bit tricky +// if path canonicalization is involved. +#[derive(Clone, Debug, Default, serde::Deserialize)] +#[serde(default, rename_all = "kebab-case")] +struct ScopeCondition { + /// Paths to match the repository path prefix. + pub repositories: Option>, + // TODO: maybe add "workspaces"? +} + +impl ScopeCondition { + fn from_value( + value: ConfigValue, + context: &ConfigResolutionContext, + ) -> Result { + Self::deserialize(value.into_deserializer())? + .expand_paths(context) + .map_err(serde::de::Error::custom) + } + + fn expand_paths(mut self, context: &ConfigResolutionContext) -> Result { + for path in self.repositories.as_mut().into_iter().flatten() { + // TODO: might be better to compare paths in canonicalized form? + if let Some(new_path) = expand_home(path, context.home_dir)? { + *path = new_path; + } + // TODO: better to skip relative paths instead of an error? The rule + // differs across platforms. "/" is relative path on Windows. + if path.is_relative() { + return Err("Relative path in --when.repositories"); + } + } + Ok(self) + } + + fn matches(&self, context: &ConfigResolutionContext) -> bool { + matches_path_prefix(self.repositories.as_deref(), context.repo_path) + } +} + +fn expand_home(path: &Path, home_dir: Option<&Path>) -> Result, &'static str> { + match path.strip_prefix("~") { + Ok(tail) => { + let home_dir = home_dir.ok_or("Cannot expand ~ (home directory is unknown)")?; + Ok(Some(home_dir.join(tail))) + } + Err(_) => Ok(None), + } +} + +fn matches_path_prefix(candidates: Option<&[PathBuf]>, actual: Option<&Path>) -> bool { + match (candidates, actual) { + (Some(candidates), Some(actual)) => candidates.iter().any(|base| actual.starts_with(base)), + (Some(_), None) => false, // actual path yet known (e.g. not in workspace) + (None, _) => true, // no constraints + } +} + +/// Evaluates condition for each layer and scope, flattens scoped tables. +/// Returns new config that only contains enabled layers and tables. +pub fn resolve( + source_config: &StackedConfig, + context: &ConfigResolutionContext, +) -> Result { + let mut source_layers_stack: Vec> = + source_config.layers().iter().rev().cloned().collect(); + let mut resolved_layers: Vec> = Vec::new(); + while let Some(mut source_layer) = source_layers_stack.pop() { + if !source_layer.data.contains_key(SCOPE_CONDITION_KEY) + && !source_layer.data.contains_key(SCOPE_TABLE_KEY) + { + resolved_layers.push(source_layer); // reuse original table + continue; + } + + let layer_mut = Arc::make_mut(&mut source_layer); + let condition = pop_scope_condition(layer_mut, context)?; + if !condition.matches(context) { + continue; + } + let tables = pop_scope_tables(layer_mut)?; + // tables.iter() does not implement DoubleEndedIterator as of toml_edit + // 0.22.22. + let frame = source_layers_stack.len(); + for table in tables { + let layer = ConfigLayer { + source: source_layer.source, + path: source_layer.path.clone(), + data: DocumentMut::from(table), + }; + source_layers_stack.push(Arc::new(layer)); + } + source_layers_stack[frame..].reverse(); + resolved_layers.push(source_layer); + } + let mut resolved_config = StackedConfig::empty(); + resolved_config.extend_layers(resolved_layers); + Ok(resolved_config) +} + +fn pop_scope_condition( + layer: &mut ConfigLayer, + context: &ConfigResolutionContext, +) -> Result { + let Some(item) = layer.data.remove(SCOPE_CONDITION_KEY) else { + return Ok(ScopeCondition::default()); + }; + let value = item + .clone() + .into_value() + .expect("Item::None should not exist in table"); + ScopeCondition::from_value(value, context).map_err(|err| ConfigGetError::Type { + name: SCOPE_CONDITION_KEY.to_owned(), + error: err.into(), + source_path: layer.path.clone(), + }) +} + +fn pop_scope_tables(layer: &mut ConfigLayer) -> Result { + let Some(item) = layer.data.remove(SCOPE_TABLE_KEY) else { + return Ok(toml_edit::ArrayOfTables::new()); + }; + match item { + ConfigItem::ArrayOfTables(tables) => Ok(tables), + _ => Err(ConfigGetError::Type { + name: SCOPE_TABLE_KEY.to_owned(), + error: format!("Expected an array of tables, but is {}", item.type_name()).into(), + source_path: layer.path.clone(), + }), + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use indoc::indoc; + + use super::*; + use crate::config::ConfigSource; + + #[test] + fn test_expand_home() { + let home_dir = Some(Path::new("/home/dir")); + assert_eq!( + expand_home("~".as_ref(), home_dir).unwrap(), + Some(PathBuf::from("/home/dir")) + ); + assert_eq!(expand_home("~foo".as_ref(), home_dir).unwrap(), None); + assert_eq!(expand_home("/foo/~".as_ref(), home_dir).unwrap(), None); + assert_eq!( + expand_home("~/foo".as_ref(), home_dir).unwrap(), + Some(PathBuf::from("/home/dir/foo")) + ); + assert!(expand_home("~/foo".as_ref(), None).is_err()); + } + + #[test] + fn test_condition_default() { + let condition = ScopeCondition::default(); + + let context = ConfigResolutionContext { + home_dir: None, + repo_path: None, + }; + assert!(condition.matches(&context)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new("/foo")), + }; + assert!(condition.matches(&context)); + } + + #[test] + fn test_condition_repo_path() { + let condition = ScopeCondition { + repositories: Some(["/foo", "/bar"].map(PathBuf::from).into()), + }; + + let context = ConfigResolutionContext { + home_dir: None, + repo_path: None, + }; + assert!(!condition.matches(&context)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new("/foo")), + }; + assert!(condition.matches(&context)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new("/fooo")), + }; + assert!(!condition.matches(&context)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new("/foo/baz")), + }; + assert!(condition.matches(&context)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new("/bar")), + }; + assert!(condition.matches(&context)); + } + + #[test] + fn test_condition_repo_path_windows() { + let condition = ScopeCondition { + repositories: Some(["c:/foo", r"d:\bar/baz"].map(PathBuf::from).into()), + }; + + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new(r"c:\foo")), + }; + assert_eq!(condition.matches(&context), cfg!(windows)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new(r"c:\foo\baz")), + }; + assert_eq!(condition.matches(&context), cfg!(windows)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new(r"d:\foo")), + }; + assert!(!condition.matches(&context)); + let context = ConfigResolutionContext { + home_dir: None, + repo_path: Some(Path::new(r"d:/bar\baz")), + }; + assert_eq!(condition.matches(&context), cfg!(windows)); + } + + fn new_user_layer(text: &str) -> ConfigLayer { + ConfigLayer::parse(ConfigSource::User, text).unwrap() + } + + #[test] + fn test_resolve_transparent() { + let mut source_config = StackedConfig::empty(); + source_config.add_layer(ConfigLayer::empty(ConfigSource::Default)); + source_config.add_layer(ConfigLayer::empty(ConfigSource::User)); + + let context = ConfigResolutionContext { + home_dir: None, + repo_path: None, + }; + let resolved_config = resolve(&source_config, &context).unwrap(); + assert_eq!(resolved_config.layers().len(), 2); + assert!(Arc::ptr_eq( + &source_config.layers()[0], + &resolved_config.layers()[0] + )); + assert!(Arc::ptr_eq( + &source_config.layers()[1], + &resolved_config.layers()[1] + )); + } + + #[test] + fn test_resolve_table_order() { + let mut source_config = StackedConfig::empty(); + source_config.add_layer(new_user_layer(indoc! {" + a = 'a #0' + [[--scope]] + a = 'a #0.0' + [[--scope]] + a = 'a #0.1' + [[--scope.--scope]] + a = 'a #0.1.0' + [[--scope]] + a = 'a #0.2' + "})); + source_config.add_layer(new_user_layer(indoc! {" + a = 'a #1' + [[--scope]] + a = 'a #1.0' + "})); + + let context = ConfigResolutionContext { + home_dir: None, + repo_path: None, + }; + let resolved_config = resolve(&source_config, &context).unwrap(); + assert_eq!(resolved_config.layers().len(), 7); + insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); + insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.0'"); + insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.1'"); + insta::assert_snapshot!(resolved_config.layers()[3].data, @"a = 'a #0.1.0'"); + insta::assert_snapshot!(resolved_config.layers()[4].data, @"a = 'a #0.2'"); + insta::assert_snapshot!(resolved_config.layers()[5].data, @"a = 'a #1'"); + insta::assert_snapshot!(resolved_config.layers()[6].data, @"a = 'a #1.0'"); + } + + #[test] + #[cfg(not(windows))] // '/' is not absolute path on Windows + fn test_resolve_repo_path() { + let mut source_config = StackedConfig::empty(); + source_config.add_layer(new_user_layer(indoc! {" + a = 'a #0' + [[--scope]] + --when.repositories = ['/foo'] + a = 'a #0.1 foo' + [[--scope]] + --when.repositories = ['/foo', '/bar'] + a = 'a #0.2 foo|bar' + [[--scope]] + --when.repositories = [] + a = 'a #0.3 none' + "})); + source_config.add_layer(new_user_layer(indoc! {" + --when.repositories = ['~/baz'] + a = 'a #1 baz' + [[--scope]] + --when.repositories = ['/foo'] # should never be enabled + a = 'a #1.1 baz&foo' + "})); + + let context = ConfigResolutionContext { + home_dir: Some(Path::new("/home/dir")), + repo_path: None, + }; + let resolved_config = resolve(&source_config, &context).unwrap(); + assert_eq!(resolved_config.layers().len(), 1); + insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); + + let context = ConfigResolutionContext { + home_dir: Some(Path::new("/home/dir")), + repo_path: Some(Path::new("/foo/.jj/repo")), + }; + let resolved_config = resolve(&source_config, &context).unwrap(); + assert_eq!(resolved_config.layers().len(), 3); + insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); + insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.1 foo'"); + insta::assert_snapshot!(resolved_config.layers()[2].data, @"a = 'a #0.2 foo|bar'"); + + let context = ConfigResolutionContext { + home_dir: Some(Path::new("/home/dir")), + repo_path: Some(Path::new("/bar/.jj/repo")), + }; + let resolved_config = resolve(&source_config, &context).unwrap(); + assert_eq!(resolved_config.layers().len(), 2); + insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); + insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #0.2 foo|bar'"); + + let context = ConfigResolutionContext { + home_dir: Some(Path::new("/home/dir")), + repo_path: Some(Path::new("/home/dir/baz/.jj/repo")), + }; + let resolved_config = resolve(&source_config, &context).unwrap(); + assert_eq!(resolved_config.layers().len(), 2); + insta::assert_snapshot!(resolved_config.layers()[0].data, @"a = 'a #0'"); + insta::assert_snapshot!(resolved_config.layers()[1].data, @"a = 'a #1 baz'"); + } + + #[test] + fn test_resolve_invalid_condition() { + let new_config = |text: &str| { + let mut config = StackedConfig::empty(); + config.add_layer(new_user_layer(text)); + config + }; + let context = ConfigResolutionContext { + home_dir: Some(Path::new("/home/dir")), + repo_path: Some(Path::new("/foo/.jj/repo")), + }; + assert_matches!( + resolve(&new_config("--when.repositories = 0"), &context), + Err(ConfigGetError::Type { .. }) + ); + assert_matches!( + resolve( + &new_config("--when.repositories = ['relative/path']"), + &context + ), + Err(ConfigGetError::Type { .. }) + ); + } + + #[test] + fn test_resolve_invalid_scoped_tables() { + let new_config = |text: &str| { + let mut config = StackedConfig::empty(); + config.add_layer(new_user_layer(text)); + config + }; + let context = ConfigResolutionContext { + home_dir: Some(Path::new("/home/dir")), + repo_path: Some(Path::new("/foo/.jj/repo")), + }; + assert_matches!( + resolve(&new_config("[--scope]"), &context), + Err(ConfigGetError::Type { .. }) + ); + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 38422979bc..677b60829d 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -34,6 +34,7 @@ pub mod backend; pub mod commit; pub mod commit_builder; pub mod config; +mod config_resolver; pub mod conflicts; pub mod copies; pub mod dag_walk;