Accept multiple label patterns in gn check

"gn check" now takes any number of label patterns and checks the targets
matching any of them. As before, label patterns bypass check_targets,
no_check_targets and no_check_generated_targets in .gn.

Target selection moves to GetTargetsToCheck() so that it can be unit
tested. RunCheck() still calls Builder::GetAllResolvedTargets() once.

Split out of https://gn-review.googlesource.com/c/gn/+/27840.

Bug: 577807023
Change-Id: Icd11cbf7fbdc7ffac41520ea3893f1d8da4f81c2
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/28520
Commit-Queue: Ryosuke Miyake <rysk@google.com>
Reviewed-by: Matt Stark <msta@google.com>
Reviewed-by: Takuto Ikuta <tikuta@google.com>
diff --git a/build/gen.py b/build/gen.py
index 2730b9e..237ae22 100755
--- a/build/gen.py
+++ b/build/gen.py
@@ -917,6 +917,7 @@
         'src/gn/bundle_data_unittest.cc',
         'src/gn/c_include_iterator_unittest.cc',
         'src/gn/check_includes_strict_exceptions_unittest.cc',
+        'src/gn/command_check_unittest.cc',
         'src/gn/command_format_unittest.cc',
         'src/gn/command_suggest_unittest.cc',
         'src/gn/commands_unittest.cc',
diff --git a/docs/reference.md b/docs/reference.md
index c9754c3..925db25 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -367,7 +367,7 @@
     given arguments set (which may affect the values of other
     arguments).
 ```
-### <a name="cmd_check"></a>**gn check &lt;out_dir&gt; [&lt;label_pattern&gt;] [\--force] [\--check-generated] [\--fix]**&nbsp;[Back to Top](#gn-reference)
+### <a name="cmd_check"></a>**gn check &lt;out_dir&gt; [&lt;label_pattern&gt;...] [\--force] [\--check-generated] [\--fix]**&nbsp;[Back to Top](#gn-reference)
 
 ```
   GN's include header checker validates that the includes for C-like source
@@ -377,9 +377,10 @@
   this command does not write out any build files. It's intended to be an easy
   way to manually trigger include file checking.
 
-  The <label_pattern> can take exact labels or patterns that match more than
-  one (although not general regular expressions). If specified, only those
-  matching targets will be checked. See "gn help label_pattern" for details.
+  Each <label_pattern> can be an exact label or a pattern that matches more
+  than one (although not general regular expressions). If specified, only
+  those matching targets will be checked. See "gn help label_pattern" for
+  details.
 ```
 
 #### **Command-specific switches**
diff --git a/src/gn/command_check.cc b/src/gn/command_check.cc
index 8c032d2..13eaf09 100644
--- a/src/gn/command_check.cc
+++ b/src/gn/command_check.cc
@@ -4,7 +4,10 @@
 
 #include <stddef.h>
 
+#include <optional>
 #include <tuple>
+#include <utility>
+#include <vector>
 
 #include "base/command_line.h"
 #include "base/strings/stringprintf.h"
@@ -57,7 +60,7 @@
 const char kCheck[] = "check";
 const char kCheck_HelpShort[] = "check: Check header dependencies.";
 const char kCheck_Help[] =
-    R"(gn check <out_dir> [<label_pattern>] [--force] [--check-generated] [--fix]
+    R"(gn check <out_dir> [<label_pattern>...] [--force] [--check-generated] [--fix]
 
   GN's include header checker validates that the includes for C-like source
   files match the build dependency graph.
@@ -66,9 +69,10 @@
   this command does not write out any build files. It's intended to be an easy
   way to manually trigger include file checking.
 
-  The <label_pattern> can take exact labels or patterns that match more than
-  one (although not general regular expressions). If specified, only those
-  matching targets will be checked. See "gn help label_pattern" for details.
+  Each <label_pattern> can be an exact label or a pattern that matches more
+  than one (although not general regular expressions). If specified, only
+  those matching targets will be checked. See "gn help label_pattern" for
+  details.
 
 Command-specific switches
 
@@ -187,10 +191,71 @@
       Check only the files in targets in the //foo directory tree.
 )";
 
+namespace {
+
+// Returns the targets matching label_patterns. Returns std::nullopt if the
+// patterns are invalid or match no target.
+std::optional<std::vector<const Target*>> ResolveCandidates(
+    Setup* setup,
+    const std::vector<std::string>& label_patterns,
+    bool default_toolchain_only) {
+  UniqueVector<const Target*> target_matches;
+  UniqueVector<const Config*> config_matches;
+  UniqueVector<const Toolchain*> toolchain_matches;
+  UniqueVector<SourceFile> file_matches;
+  if (!ResolveFromCommandLineInput(
+          setup, label_patterns, default_toolchain_only, &target_matches,
+          &config_matches, &toolchain_matches, &file_matches))
+    return std::nullopt;
+
+  if (target_matches.size() == 0) {
+    OutputString("No matching targets.\n");
+    return std::nullopt;
+  }
+  return target_matches.release();
+}
+
+}  // namespace
+
+std::optional<TargetsToCheck> GetTargetsToCheck(
+    Setup* setup,
+    const std::vector<const Target*>& all_targets,
+    const std::vector<std::string>& label_patterns,
+    bool default_toolchain_only) {
+  TargetsToCheck result;
+  if (label_patterns.empty()) {
+    result.candidates = all_targets;
+  } else if (auto resolved = ResolveCandidates(setup, label_patterns,
+                                               default_toolchain_only)) {
+    result.candidates = std::move(*resolved);
+  } else {
+    return std::nullopt;
+  }
+
+  if (!label_patterns.empty()) {
+    // An explicit check of targets bypasses the lists in the .gn file.
+    result.to_check = result.candidates;
+    return result;
+  }
+
+  if (setup->check_patterns()) {
+    FilterTargetsByPatterns(result.candidates, *setup->check_patterns(),
+                            &result.to_check);
+  } else if (setup->no_check_patterns()) {
+    FilterOutTargetsByPatterns(result.candidates, *setup->no_check_patterns(),
+                               &result.to_check);
+  } else {
+    result.to_check = result.candidates;
+  }
+  FilterTargetsByPatterns(result.to_check, setup->no_check_generated_patterns(),
+                          &result.no_check_generated);
+  return result;
+}
+
 int RunCheck(const std::vector<std::string>& args) {
-  if (args.size() != 1 && args.size() != 2) {
+  if (args.empty()) {
     Err(Location(), "Unknown command format. See \"gn help check\"",
-        "Usage: \"gn check <out_dir> [<target_label>]\"")
+        "Usage: \"gn check <out_dir> [<label_pattern>...]\"")
         .PrintToStdout();
     return 1;
   }
@@ -204,82 +269,43 @@
 
   const base::CommandLine* cmdline = base::CommandLine::ForCurrentProcess();
   bool default_toolchain_only = cmdline->HasSwitch(switches::kDefaultToolchain);
-
-  std::vector<const Target*> all_targets =
-      setup->builder().GetAllResolvedTargets();
-
-  bool filtered_by_build_config = false;
-  std::vector<const Target*> targets_to_check;
-  if (args.size() > 1) {
-    // Compute the targets to check.
-    std::vector<std::string> inputs(args.begin() + 1, args.end());
-    UniqueVector<const Target*> target_matches;
-    UniqueVector<const Config*> config_matches;
-    UniqueVector<const Toolchain*> toolchain_matches;
-    UniqueVector<SourceFile> file_matches;
-    if (!ResolveFromCommandLineInput(setup, inputs, default_toolchain_only,
-                                     &target_matches, &config_matches,
-                                     &toolchain_matches, &file_matches))
-      return 1;
-
-    if (target_matches.size() == 0) {
-      OutputString("No matching targets.\n");
-      return 1;
-    }
-    targets_to_check.insert(targets_to_check.begin(), target_matches.begin(),
-                            target_matches.end());
-  } else {
-    // No argument means to check everything allowed by the filter in
-    // the build config file.
-    if (setup->check_patterns()) {
-      FilterTargetsByPatterns(all_targets, *setup->check_patterns(),
-                              &targets_to_check);
-      filtered_by_build_config = targets_to_check.size() != all_targets.size();
-    } else if (setup->no_check_patterns()) {
-      FilterOutTargetsByPatterns(all_targets, *setup->no_check_patterns(),
-                                 &targets_to_check);
-      filtered_by_build_config = targets_to_check.size() != all_targets.size();
-    } else {
-      // No global filter, check everything.
-      targets_to_check = all_targets;
-    }
-  }
-
   bool force = cmdline->HasSwitch("force");
   bool check_generated = cmdline->HasSwitch("check-generated");
   bool check_system =
       setup->check_system_includes() || cmdline->HasSwitch("check-system");
   bool fix = cmdline->HasSwitch("fix");
 
-  // Like check_targets and no_check_targets, no_check_generated_targets is not
-  // used when a label pattern is given. It only matters when generated files
-  // are checked.
-  std::vector<const Target*> no_check_generated;
-  if (args.size() == 1 && check_generated) {
-    FilterTargetsByPatterns(targets_to_check,
-                            setup->no_check_generated_patterns(),
-                            &no_check_generated);
-  }
+  std::vector<std::string> label_patterns(args.begin() + 1, args.end());
+  std::vector<const Target*> all_targets =
+      setup->builder().GetAllResolvedTargets();
+  std::optional<TargetsToCheck> targets = GetTargetsToCheck(
+      setup, all_targets, label_patterns, default_toolchain_only);
+  if (!targets)
+    return 1;
+
+  // no_check_generated_targets only matters when generated files are checked.
+  if (!check_generated)
+    targets->no_check_generated.clear();
 
   if (!CheckPublicHeaders(&setup->build_settings(), all_targets,
-                          targets_to_check, no_check_generated, force,
+                          targets->to_check, targets->no_check_generated, force,
                           check_generated, check_system, fix, setup))
     return 1;
 
   if (!base::CommandLine::ForCurrentProcess()->HasSwitch(switches::kQuiet)) {
-    if (filtered_by_build_config) {
+    if (targets->to_check.size() != targets->candidates.size()) {
       // Tell the user about the implicit filtering since this is obscure.
       OutputString(base::StringPrintf(
           "%d targets out of %d checked based on the check_targets or "
           "no_check_targets defined in \".gn\".\n",
-          static_cast<int>(targets_to_check.size()),
-          static_cast<int>(all_targets.size())));
+          static_cast<int>(targets->to_check.size()),
+          static_cast<int>(targets->candidates.size())));
     }
-    if (!no_check_generated.empty()) {
+    if (!targets->no_check_generated.empty()) {
       OutputString(base::StringPrintf(
           "Generated files of %d targets not checked based on the "
           "no_check_generated_targets defined in \".gn\".\n",
-          static_cast<int>(no_check_generated.size())));
+          static_cast<int>(targets->no_check_generated.size())));
     }
     OutputString("Header dependency check OK\n", DECORATION_GREEN);
   }
diff --git a/src/gn/command_check_unittest.cc b/src/gn/command_check_unittest.cc
new file mode 100644
index 0000000..33c0247
--- /dev/null
+++ b/src/gn/command_check_unittest.cc
@@ -0,0 +1,174 @@
+// Copyright 2026 The GN Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <map>
+#include <optional>
+#include <set>
+#include <string>
+#include <vector>
+
+#include "base/command_line.h"
+#include "base/files/file_util.h"
+#include "base/files/scoped_temp_dir.h"
+#include "gn/commands.h"
+#include "gn/filesystem_utils.h"
+#include "gn/setup.h"
+#include "gn/source_file.h"
+#include "gn/switches.h"
+#include "gn/target.h"
+#include "gn/test_with_scheduler.h"
+#include "util/test/test.h"
+
+namespace {
+
+using CommandCheckTest = TestWithScheduler;
+
+// //:a depends on //:b, which depends on //:d. //:a also has a data dep on
+// //:c. //:unrelated is not reachable from //:a.
+constexpr char kDotFile[] = R"(
+buildconfig = "//BUILDCONFIG.gn"
+no_check_targets = [ "//:d" ]
+no_check_generated_targets = [ "//:b" ]
+)";
+
+constexpr char kBuildConfig[] = R"(
+set_default_toolchain("//toolchain:default")
+)";
+
+constexpr char kToolchain[] = R"(
+toolchain("default") {
+  tool("cxx") {
+    command = "cxx"
+    outputs = [ "{{source_out_dir}}/{{source_file_part}}.o" ]
+  }
+  tool("link") {
+    command = "link"
+    outputs = [ "{{root_out_dir}}/{{target_output_name}}{{output_extension}}" ]
+  }
+  tool("stamp") {
+    command = "stamp"
+  }
+}
+)";
+
+constexpr char kBuildFile[] = R"(
+executable("a") {
+  sources = [ "a.cc" ]
+  deps = [ ":b" ]
+  data_deps = [ ":c" ]
+}
+source_set("b") {
+  sources = [ "b.h" ]
+  deps = [ ":d" ]
+}
+source_set("c") {
+  sources = [ "c.h" ]
+}
+source_set("d") {
+  sources = [ "d.h" ]
+}
+source_set("unrelated") {
+  sources = [ "unrelated.h" ]
+}
+)";
+
+class TestProject {
+ public:
+  explicit TestProject(const char* dot_file = kDotFile) {
+    EXPECT_TRUE(in_temp_dir_.CreateUniqueTempDir());
+    EXPECT_TRUE(build_temp_dir_.CreateUniqueTempDir());
+    base::FilePath in_path = base::MakeAbsoluteFilePath(in_temp_dir_.GetPath());
+    base::FilePath build_path =
+        base::MakeAbsoluteFilePath(build_temp_dir_.GetPath());
+
+    const std::map<std::string, std::string> files = {
+        {".gn", dot_file},
+        {"BUILDCONFIG.gn", kBuildConfig},
+        {"toolchain/BUILD.gn", kToolchain},
+        {"BUILD.gn", kBuildFile},
+    };
+    for (const auto& [name, content] : files) {
+      base::FilePath full_path = in_path.AppendASCII(name);
+      base::CreateDirectory(full_path.DirName());
+      WriteFile(full_path, content, nullptr);
+    }
+
+    base::CommandLine cmdline(base::CommandLine::NO_PROGRAM);
+    cmdline.AppendSwitchPath(switches::kRoot, in_path);
+    EXPECT_TRUE(setup_.DoSetup(FilePathToUTF8(build_path), true, cmdline));
+    EXPECT_TRUE(setup_.Run());
+  }
+
+  Setup* setup() { return &setup_; }
+  std::vector<const Target*> all_targets() {
+    return setup_.builder().GetAllResolvedTargets();
+  }
+
+ private:
+  base::ScopedTempDir in_temp_dir_;
+  base::ScopedTempDir build_temp_dir_;
+  Setup setup_;
+};
+
+std::set<std::string> Labels(const std::vector<const Target*>& targets) {
+  std::set<std::string> labels;
+  for (const Target* target : targets)
+    labels.insert(target->label().GetUserVisibleName(false));
+  return labels;
+}
+
+}  // namespace
+
+TEST_F(CommandCheckTest, NoLabelPatterns) {
+  TestProject project;
+  std::optional<commands::TargetsToCheck> targets =
+      commands::GetTargetsToCheck(project.setup(), project.all_targets(), {},
+                                  /*default_toolchain_only=*/false);
+  ASSERT_TRUE(targets);
+
+  EXPECT_EQ(
+      (std::set<std::string>{"//:a", "//:b", "//:c", "//:d", "//:unrelated"}),
+      Labels(targets->candidates));
+  EXPECT_EQ((std::set<std::string>{"//:a", "//:b", "//:c", "//:unrelated"}),
+            Labels(targets->to_check));
+  EXPECT_EQ((std::set<std::string>{"//:b"}),
+            Labels(targets->no_check_generated));
+}
+
+TEST_F(CommandCheckTest, LabelPatternsBypassDotFileLists) {
+  TestProject project;
+  std::optional<commands::TargetsToCheck> targets = commands::GetTargetsToCheck(
+      project.setup(), project.all_targets(), {"//:b", "//:d"},
+      /*default_toolchain_only=*/false);
+  ASSERT_TRUE(targets);
+
+  EXPECT_EQ((std::set<std::string>{"//:b", "//:d"}),
+            Labels(targets->candidates));
+  EXPECT_EQ((std::set<std::string>{"//:b", "//:d"}), Labels(targets->to_check));
+  EXPECT_TRUE(targets->no_check_generated.empty());
+}
+
+TEST_F(CommandCheckTest, NoLabelPatternsCheckTargets) {
+  TestProject project(R"(
+buildconfig = "//BUILDCONFIG.gn"
+check_targets = [ "//:a", "//:c" ]
+)");
+  std::optional<commands::TargetsToCheck> targets =
+      commands::GetTargetsToCheck(project.setup(), project.all_targets(), {},
+                                  /*default_toolchain_only=*/false);
+  ASSERT_TRUE(targets);
+
+  EXPECT_EQ(
+      (std::set<std::string>{"//:a", "//:b", "//:c", "//:d", "//:unrelated"}),
+      Labels(targets->candidates));
+  EXPECT_EQ((std::set<std::string>{"//:a", "//:c"}), Labels(targets->to_check));
+  EXPECT_TRUE(targets->no_check_generated.empty());
+}
+
+TEST_F(CommandCheckTest, NoMatchingTargets) {
+  TestProject project;
+  EXPECT_FALSE(commands::GetTargetsToCheck(
+      project.setup(), project.all_targets(), {"//nonexistent/*"},
+      /*default_toolchain_only=*/false));
+}
diff --git a/src/gn/commands.h b/src/gn/commands.h
index efa9775..60de9e4 100644
--- a/src/gn/commands.h
+++ b/src/gn/commands.h
@@ -416,6 +416,32 @@
     UniqueVector<const Toolchain*>* toolchain_matches,
     UniqueVector<SourceFile>* file_matches);
 
+// The targets that "gn check" checks. See GetTargetsToCheck().
+struct TargetsToCheck {
+  // All targets if no label pattern is given, or else the targets matching the
+  // label patterns.
+  std::vector<const Target*> candidates;
+
+  // The candidates allowed by check_targets or no_check_targets in the .gn
+  // file.
+  std::vector<const Target*> to_check;
+
+  // The targets in |to_check| that match no_check_generated_targets.
+  std::vector<const Target*> no_check_generated;
+};
+
+// Computes the targets that "gn check" checks. |all_targets| must be all
+// resolved targets in the build; they are the candidates if |label_patterns|
+// is empty. Label patterns bypass the .gn lists: |to_check| gets all candidates
+// and |no_check_generated| stays empty.
+//
+// On failure, prints the error and returns std::nullopt.
+std::optional<TargetsToCheck> GetTargetsToCheck(
+    Setup* setup,
+    const std::vector<const Target*>& all_targets,
+    const std::vector<std::string>& label_patterns,
+    bool default_toolchain_only);
+
 // Runs the header checker. All targets in the build should be given in
 // all_targets, and the specific targets to check should be in to_check.
 //