Skip to content
Open
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
133 changes: 119 additions & 14 deletions crates/pyrefly_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,87 @@ pub static GENERATED_FILE_CONFIG_OVERRIDE: LazyLock<
RwLock<SmallMap<InternedPath, ArcId<ConfigFile>>>,
> = LazyLock::new(|| RwLock::new(SmallMap::new()));

/// One or more path globs that select files for a [`SubConfig`].
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct SubConfigMatches(Vec<Glob>);

impl SubConfigMatches {
/// Creates a matcher from a non-empty collection of globs.
pub fn new(matches: Vec<Glob>) -> anyhow::Result<Self> {
if matches.is_empty() {
return Err(anyhow!(
"sub-config `matches` must contain at least one path or glob"
));
}
Ok(Self(matches))
}

fn with_root(self, root: &Path) -> Self {
Self(self.0.into_map(|pattern| pattern.from_root(root)))
}

fn matches(&self, path: &Path) -> bool {
self.0.iter().any(|pattern| pattern.matches(path))
}
}

impl From<Glob> for SubConfigMatches {
fn from(value: Glob) -> Self {
Self(vec![value])
}
}

impl Display for SubConfigMatches {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0.as_slice() {
[pattern] => pattern.fmt(f),
patterns => write!(f, "[{}]", patterns.iter().join(", ")),
}
}
}

impl<'de> Deserialize<'de> for SubConfigMatches {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(Glob),
Many(Vec<Glob>),
}

let matches = match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(pattern) => vec![pattern],
OneOrMany::Many(patterns) => patterns,
};
Self::new(matches).map_err(serde::de::Error::custom)
}
}

impl Serialize for SubConfigMatches {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self.0.as_slice() {
[pattern] => pattern.serialize(serializer),
patterns => patterns.serialize(serializer),
}
}
}

#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
pub struct SubConfig {
pub matches: Glob,
pub matches: SubConfigMatches,
#[serde(flatten)]
pub settings: ConfigBase,
}

impl SubConfig {
fn rewrite_with_path_to_config(&mut self, config_root: &Path) {
self.matches = self.matches.clone().from_root(config_root);
self.matches = self.matches.clone().with_root(config_root);
}
}

Expand Down Expand Up @@ -2630,7 +2701,7 @@ mod tests {
source_db: Default::default(),
target_configs: Default::default(),
sub_configs: vec![SubConfig {
matches: Glob::new("sub/project/**".to_owned()).unwrap(),
matches: Glob::new("sub/project/**".to_owned()).unwrap().into(),
settings: ConfigBase {
extras: Default::default(),
errors: Some(ErrorDisplayConfig::new(HashMap::from_iter([
Expand Down Expand Up @@ -2999,7 +3070,7 @@ mod tests {
target_configs: Default::default(),
build_system: Default::default(),
sub_configs: vec![SubConfig {
matches: Glob::new("sub/project/**".to_owned()).unwrap(),
matches: Glob::new("sub/project/**".to_owned()).unwrap().into(),
settings: Default::default(),
}],
coverage: CoverageConfig {
Expand Down Expand Up @@ -3079,7 +3150,7 @@ mod tests {
source_db: Default::default(),
target_configs: Default::default(),
sub_configs: vec![SubConfig {
matches: sub_config_matches,
matches: sub_config_matches.into(),
settings: Default::default(),
}],
coverage: CoverageConfig {
Expand Down Expand Up @@ -3176,6 +3247,38 @@ mod tests {
assert!(err.to_string().contains("missing field `matches`"));
}

#[test]
fn test_deserializing_sub_config_multiple_matches() {
let config = ConfigFile::parse_config(
r#"
ignore-errors-in-generated-code = false

[[sub-config]]
matches = ["src/first.py", "tests/second.py"]
ignore-errors-in-generated-code = true
"#,
)
.unwrap();

assert!(config.ignore_errors_in_generated_code(&test_path("src/first.py")));
assert!(config.ignore_errors_in_generated_code(&test_path("tests/second.py")));
assert!(!config.ignore_errors_in_generated_code(&test_path("src/other.py")));
}

#[test]
fn test_deserializing_sub_config_rejects_empty_matches() {
let err = ConfigFile::parse_config(
r#"
[[sub-config]]
matches = []
ignore-errors-in-generated-code = true
"#,
)
.unwrap_err();

assert!(err.to_string().contains("at least one path or glob"));
}

#[test]
fn test_baseline_config_parsing() {
let config_str = r#"
Expand Down Expand Up @@ -3337,7 +3440,7 @@ output-format = "omit-errors"
},
sub_configs: vec![
SubConfig {
matches: Glob::new("**/highest/**".to_owned()).unwrap(),
matches: Glob::new("**/highest/**".to_owned()).unwrap().into(),
settings: ConfigBase {
replace_imports_with_any: Some(vec![
ModuleWildcard::new("highest").unwrap(),
Expand All @@ -3347,7 +3450,7 @@ output-format = "omit-errors"
},
},
SubConfig {
matches: Glob::new("**/priority*".to_owned()).unwrap(),
matches: Glob::new("**/priority*".to_owned()).unwrap().into(),
settings: ConfigBase {
replace_imports_with_any: Some(vec![
ModuleWildcard::new("second").unwrap(),
Expand Down Expand Up @@ -3405,7 +3508,7 @@ output-format = "omit-errors"
..Default::default()
},
sub_configs: vec![SubConfig {
matches: Glob::new("**".to_owned()).unwrap(),
matches: Glob::new("**".to_owned()).unwrap().into(),
settings: ConfigBase {
errors: Some(ErrorDisplayConfig::new(HashMap::from([
(ErrorKind::BadAssignment, Severity::Warn),
Expand Down Expand Up @@ -3494,7 +3597,7 @@ output-format = "omit-errors"
..Default::default()
},
sub_configs: vec![SubConfig {
matches: Glob::new("**".to_owned()).unwrap(),
matches: Glob::new("**".to_owned()).unwrap().into(),
settings: ConfigBase {
infer_with_first_use: Some(true),
..Default::default()
Expand Down Expand Up @@ -3621,7 +3724,7 @@ output-format = "omit-errors"
..Default::default()
},
sub_configs: vec![SubConfig {
matches: Glob::new("sub/**".to_owned()).unwrap(),
matches: Glob::new("sub/**".to_owned()).unwrap().into(),
settings: ConfigBase {
errors: Some(ErrorDisplayConfig::new(HashMap::from([(
ErrorKind::BadReturn,
Expand Down Expand Up @@ -3670,7 +3773,7 @@ output-format = "omit-errors"
..Default::default()
},
sub_configs: vec![SubConfig {
matches: Glob::new("strict/**".to_owned()).unwrap(),
matches: Glob::new("strict/**".to_owned()).unwrap().into(),
settings: ConfigBase {
errors: Some(ErrorDisplayConfig::new(HashMap::from([(
ErrorKind::BadAssignment,
Expand Down Expand Up @@ -3702,7 +3805,7 @@ output-format = "omit-errors"
..Default::default()
},
sub_configs: vec![SubConfig {
matches: Glob::new("sub/**".to_owned()).unwrap(),
matches: Glob::new("sub/**".to_owned()).unwrap().into(),
settings: ConfigBase {
check_unannotated_defs: Some(false),
..Default::default()
Expand Down Expand Up @@ -4049,7 +4152,7 @@ output-format = "omit-errors"
let mut config = ConfigFile {
preset: Some(Preset::Legacy),
sub_configs: vec![SubConfig {
matches: Glob::new("tests/**".to_owned()).unwrap(),
matches: Glob::new("tests/**".to_owned()).unwrap().into(),
settings: ConfigBase {
errors: Some(ErrorDisplayConfig::new(HashMap::from([(
ErrorKind::BadOverride,
Expand Down Expand Up @@ -5081,7 +5184,9 @@ matches = "generated/**"
assert_eq!(config.coverage.includes, Some(shared_glob("covered")));
assert_eq!(
config.sub_configs[0].matches,
Glob::new(shared.join("generated/**").to_string_lossy().into_owned()).unwrap()
Glob::new(shared.join("generated/**").to_string_lossy().into_owned())
.unwrap()
.into()
);
assert_eq!(
config.python_environment.site_package_path,
Expand Down
2 changes: 1 addition & 1 deletion crates/pyrefly_config/src/migration/pyright.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ impl ExecEnv {
..Default::default()
};
Ok(SubConfig {
matches: Glob::new(self.root)?,
matches: Glob::new(self.root)?.into(),
settings,
})
}
Expand Down
2 changes: 1 addition & 1 deletion crates/pyrefly_config/src/migration/sub_configs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ impl ConfigOptionMigrater for SubConfigs {
})
.map(|(matches, errors)| -> anyhow::Result<SubConfig> {
Ok(SubConfig {
matches: matches?,
matches: matches?.into(),
settings: ConfigBase {
errors,
..Default::default()
Expand Down
14 changes: 10 additions & 4 deletions website/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1607,7 +1607,7 @@ Examples:
filepath glob matching. Only certain config options are allowed to be overridden, and a need
to override other configs means you likely need to use a separate config file for your subdirectory.
You can have as many SubConfigs as you want in a project, and even multiple separate SubConfigs
that can apply to a given file when the `matches` glob pattern matches.
that can apply to a given file when any of their `matches` glob patterns match.

#### **SubConfig Allowed Overrides**

Expand Down Expand Up @@ -1636,15 +1636,21 @@ and would like to see the option supported.

A SubConfig has two or more entries:

- a `matches` key, with a [Filesystem Glob](#filesystem-globbing) detailing which files the config
applies to.
- a `matches` key containing either one [Filesystem Glob](#filesystem-globbing) string or a
non-empty list of glob strings. A list applies the SubConfig when any entry matches.
- at least one of the [SubConfig allowed overrides](#subconfig-allowed-overrides)

```toml
[[sub-config]]
matches = ["src/first.py", "tests/second.py"]
ignore-errors-in-generated-code = true
```

#### **SubConfig Option Selection**

Since you can have more than one SubConfig matching a file, we need to define a resolution order
to determine which SubConfig's option should be selected. Pyrefly does this by filtering
SubConfigs whose `matches` does not match the given file, then takes the first non-null
SubConfigs where none of the `matches` entries match the given file, then takes the first non-null
value that can be found in the order the SubConfigs appear in your configuration.

If no SubConfigs match, or there are no non-null config options present, then we take
Expand Down
Loading