Repository: dprint/dprint (language: rust)

Issue:
`dprint fmt **/glob` fails when config file in subdirectory does not match glob
Version: 0.40.2

I have a root configuration file `.dprint.jsonc` with the following content:
```jsonc
{
	"lineWidth": 80,
	"useTabs": true,
	"indentWidth": 4,
	"newLineKind": "lf",

	"typescript": {
		// typescript-specific settings
	},

	"json": {},

	"markdown": {},

	"prettier": {
		"associations": [
			// Prettier formats those better
			"packages/nvmedit/test/fixtures/**/*.json"
		]
	},

	"excludes": [
		// some excludes, doesn't matter
	],
	"plugins": [
		"https://plugins.dprint.dev/typescript-0.86.2.wasm",
		"https://plugins.dprint.dev/json-0.17.4.wasm",
		"https://plugins.dprint.dev/markdown-0.15.3.wasm",
		"https://plugins.dprint.dev/prettier-0.27.0.json@3557a62b4507c55a47d8cde0683195b14d13c41dda66d0f0b0e111aed107e2fe"
	]
}
```

and a nested config at `packages/config/config/.dprint.jsonc`:
```jsonc
{
	// TODO: Inherit from the root config once supported by dprint
	"lineWidth": 120,
	"useTabs": true,
	"indentWidth": 4,
	"newLineKind": "lf",

	"json": {},

	"includes": ["*.json", "devices/**/*.json"],

	"plugins": ["https://plugins.dprint.dev/json-0.17.4.wasm"]
}
```

When I just run `dprint fmt` this works fine. However when I try to format files in a specific directory that isn't matched by the nested config, e.g.
```
dprint fmt "packages/nvmedit/test/fixtures/**"
```
then I get an error from the nested config, even though it should not apply to the directory I'm trying to format:
```
$ dprint fmt "packages/nvmedit/test/fixtures/**"
No files found to format with the specified plugins at C:\Repositories\node-zwave-js\packages\config\config. You may want to try using `dprint output-file-paths` to see which files it's finding.
```

```
$ dprint output-file-paths "packages/nvmedit/test/fixtures/**"
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_500_json\ctrlr_backup_500_static_6.8x.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.11.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.12.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.15.2.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.16.2.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.17.2.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.18.1.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.18.2.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.18.3.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.18.4.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.19.0.json
C:\Repositories\node-zwave-js\packages\nvmedit\test\fixtures\nvm_700_json\ctrlr_backup_700_7.19.1.json
```

If I rename the nested config, so dprint doesn't pick it up, it works:
```
$ dprint fmt "packages/nvmedit/test/fixtures/**"
Formatted 11 files.
```


Discussion / hints:
We've run into this at Canva as well. I'm working on upgrading us to the latest version in our monorepo (from a very old version).

We have a bunch of tooling around dprint so we have some test fixtures that include just a `dprint.json` file on disk.
Our root `dprint.json` file is auto-generated - so to avoid formatting conflicts with generation we have `excludes: ['dprint.json']`.

So those fixture files are excluded and it causes the above error which fails the `dprint check`.

Fix patch:
diff --git a/crates/dprint/src/arg_parser.rs b/crates/dprint/src/arg_parser.rs
index 5cd30de84..e2e315c86 100644
--- a/crates/dprint/src/arg_parser.rs
+++ b/crates/dprint/src/arg_parser.rs
@@ -71,6 +71,28 @@ impl SubCommand {
       _ => false,
     }
   }
+
+  pub fn file_patterns(&self) -> Option<&FilePatternArgs> {
+    match self {
+      SubCommand::Check(a) => Some(&a.patterns),
+      SubCommand::Fmt(a) => Some(&a.patterns),
+      SubCommand::StdInFmt(a) => Some(&a.patterns),
+      SubCommand::OutputFilePaths(a) => Some(&a.patterns),
+      SubCommand::OutputFormatTimes(a) => Some(&a.patterns),
+      SubCommand::Config(_)
+      | SubCommand::ClearCache
+      | SubCommand::OutputResolvedConfig
+      | SubCommand::Version
+      | SubCommand::License
+      | SubCommand::Help(_)
+      | SubCommand::EditorInfo
+      | SubCommand::EditorService(_)
+      | SubCommand::Completions(_)
+      | SubCommand::Upgrade => None,
+      #[cfg(target_os = "windows")]
+      SubCommand::Hidden(_) => None,
+    }
+  }
 }
 
 #[derive(Debug, PartialEq, Eq)]
diff --git a/crates/dprint/src/commands/formatting.rs b/crates/dprint/src/commands/formatting.rs
index 5a4d9f53a..bb17261d0 100644
--- a/crates/dprint/src/commands/formatting.rs
+++ b/crates/dprint/src/commands/formatting.rs
@@ -2049,6 +2049,34 @@ mod test {
     assert_eq!(environment.read_file(&file_path4).unwrap(), "text_formatted");
   }
 
+  #[test]
+  fn should_not_error_nested_config_no_matching_files_in_scope_cli_args() {
+    let file_path1 = "/sub_dir/file.txt";
+    let file_path2 = "/sub_dir/sub_dir/file.txt";
+    let file_path3 = "/sub_dir/more/ignored.txt_ps";
+    let environment = TestEnvironmentBuilder::with_initialized_remote_wasm_and_process_plugin()
+      .with_local_config("/sub_dir/dprint.json", |config| {
+        config.add_remote_wasm_plugin();
+      })
+      .with_local_config("/sub_dir/more/dprint.json", |config| {
+        config.add_remote_process_plugin();
+      })
+      .write_file(&file_path1, "here1")
+      .write_file(&file_path2, "here2")
+      .write_file(&file_path3, "here3")
+      .build();
+    // previously this was erroring in the sub directory
+    run_test_cli(vec!["fmt", "**/*.txt"], &environment).unwrap();
+    assert_eq!(environment.take_stdout_messages(), vec![get_plural_formatted_text(2)]);
+    assert_eq!(environment.read_file(&file_path1).unwrap(), "here1_formatted");
+    assert_eq!(environment.read_file(&file_path2).unwrap(), "here2_formatted");
+    assert_eq!(environment.read_file(&file_path3).unwrap(), "here3");
+
+    // now try with a pattern that doesn't match any file in any scope and it should error
+    let err = run_test_cli(vec!["fmt", "**/*.no_matching"], &environment).unwrap_err();
+    assert_no_files_found(&err, &environment);
+  }
+
   #[test]
   fn should_error_no_files_sub_dir_config() {
     let file_path1 = "/file.txt";
diff --git a/crates/dprint/src/paths.rs b/crates/dprint/src/paths.rs
index a3b1a7c68..431859903 100644
--- a/crates/dprint/src/paths.rs
+++ b/crates/dprint/src/paths.rs
@@ -47,13 +47,17 @@ pub struct FilesPathsByPlugins(HashMap<PluginNames, Vec<PathBuf>>);
 
 impl FilesPathsByPlugins {
   pub fn ensure_not_empty(&self, base_path: &CanonicalizedPathBuf) -> Result<(), NoFilesFoundError> {
-    if self.0.is_empty() {
+    if self.is_empty() {
       Err(NoFilesFoundError { base_path: base_path.clone() })
     } else {
       Ok(())
     }
   }
 
+  pub fn is_empty(&self) -> bool {
+    self.0.is_empty()
+  }
+
   pub fn into_vec(self) -> Vec<(PluginNames, Vec<PathBuf>)> {
     self.0.into_iter().collect()
   }
diff --git a/crates/dprint/src/resolution.rs b/crates/dprint/src/resolution.rs
index fcbc89455..6a8e9df63 100644
--- a/crates/dprint/src/resolution.rs
+++ b/crates/dprint/src/resolution.rs
@@ -36,6 +36,7 @@ use crate::environment::Environment;
 use crate::paths::get_and_resolve_file_paths;
 use crate::paths::get_file_paths_by_plugins;
 use crate::paths::FilesPathsByPlugins;
+use crate::paths::NoFilesFoundError;
 use crate::plugins::output_plugin_config_diagnostics;
 use crate::plugins::FormatConfig;
 use crate::plugins::InitializedPlugin;
@@ -358,27 +359,50 @@ impl<TEnvironment: Environment> PluginsScope<TEnvironment> {
   }
 }
 
-pub struct PluginsScopeAndPathsCollection<TEnvironment: Environment>(Vec<PluginsScopeAndPaths<TEnvironment>>);
+pub struct PluginsScopeAndPathsCollection<TEnvironment: Environment> {
+  environment: TEnvironment,
+  inner: Vec<PluginsScopeAndPaths<TEnvironment>>,
+}
 
 impl<TEnvironment: Environment> PluginsScopeAndPathsCollection<TEnvironment> {
   pub fn ensure_valid_for_cli_args(&self, cli_args: &CliArgs) -> Result<()> {
-    for scope in &self.0 {
+    for scope in &self.inner {
       scope.scope.ensure_valid_for_cli_args(cli_args)?;
-      if let Some(config) = scope.scope.config.as_ref() {
-        if !cli_args.sub_command.allow_no_files() {
-          scope.file_paths_by_plugins.ensure_not_empty(&config.base_path)?;
+    }
+
+    // ensure we found some files
+    if !cli_args.sub_command.allow_no_files() {
+      let has_cli_file_patterns = cli_args.sub_command.file_patterns().map(|p| !p.file_patterns.is_empty()).unwrap_or(false);
+      // when the user specifies a pattern on the command line, just ensure that one scope matched
+      if has_cli_file_patterns {
+        let all_empty = self.iter().all(|s| s.file_paths_by_plugins.is_empty());
+        if all_empty {
+          return Err(
+            NoFilesFoundError {
+              base_path: self.environment.cwd(),
+            }
+            .into(),
+          );
+        }
+      } else {
+        // if no args specified then ensure all scopes have files
+        for scope in &self.inner {
+          if let Some(config) = scope.scope.config.as_ref() {
+            scope.file_paths_by_plugins.ensure_not_empty(&config.base_path)?;
+          }
         }
       }
     }
+
     Ok(())
   }
 
   pub fn iter(&self) -> impl Iterator<Item = &PluginsScopeAndPaths<TEnvironment>> {
-    self.0.iter()
+    self.inner.iter()
   }
 
   pub fn into_iter(self) -> impl Iterator<Item = PluginsScopeAndPaths<TEnvironment>> {
-    self.0.into_iter()
+    self.inner.into_iter()
   }
 }
 
@@ -424,7 +448,10 @@ impl<'a, TEnvironment: Environment> PluginsAndPathsResolver<'a, TEnvironment> {
       result.extend(self.resolve_for_sub_config(config_file_path, &config, root_config_path).await?);
     }
 
-    Ok(PluginsScopeAndPathsCollection(result))
+    Ok(PluginsScopeAndPathsCollection {
+      environment: self.environment.clone(),
+      inner: result,
+    })
   }
 
   fn resolve_for_sub_config(


Test patch: