| #if 0 // Invoke with /bin/sh or simply add executable bit on this file on Unix. |
| B=${0%%.cc}; [ "$B" -nt "$0" ] || c++ -std=c++17 -o"$B" "$0" && exec "$B" "$@"; |
| #endif |
| // Copyright 2023 Henner Zeller <h.zeller@acm.org> |
| // |
| // 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 |
| // |
| // http://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. |
| |
| // Location: https://github.com/hzeller/dev-tools (2024-12-23) |
| |
| // Script to run clang-tidy on files in a bazel project while caching the |
| // results as clang-tidy can be pretty slow. The clang-tidy output messages |
| // are content-addressed in a hash(cc-file-content) cache file. |
| // Should run on any system with a shell that provides 2>/dev/null redirect. |
| // |
| // Invocation without parameters simply uses the .clang-tidy config to run on |
| // all *.{cc,h} files. Additional parameters passed to this script are passed |
| // to clang-tidy as-is. Typical use could be for instance |
| // run-clang-tidy-cached.cc --checks="-*,modernize-use-override" --fix |
| // |
| // Note: useful environment variables to configure are |
| // CLANG_TIDY = binary to run; default would just be clang-tidy. |
| // CLANG_TIDY_CONFIG = override configuration file in kConfig.clang_tidy_file |
| // CACHE_DIR = where to put the cached content; default ~/.cache |
| // CLANG_TIDY_JOBS = Number of tasks to run in parallel. |
| |
| // This file shall be c++17 self-contained; not using any re2 or absl niceties. |
| #include <unistd.h> |
| |
| #include <algorithm> |
| #include <cerrno> |
| #include <cinttypes> |
| #include <csignal> |
| #include <cstdint> |
| #include <cstdio> |
| #include <cstdlib> |
| #include <cstring> |
| #include <filesystem> |
| #include <fstream> |
| #include <functional> |
| #include <initializer_list> |
| #include <iostream> |
| #include <list> |
| #include <map> |
| #include <mutex> |
| #include <regex> |
| #include <string> |
| #include <string_view> |
| #include <system_error> |
| #include <thread> |
| #include <unordered_set> |
| #include <utility> |
| #include <vector> |
| |
| // These are the configuration defaults. |
| // Dont' change anything here, override for your project below in kConfig. |
| struct ConfigValues { |
| // Prefix for the cache to write to. If empty, use name of toplevel directory. |
| // Typical would be name of project with underscore suffix. |
| std::string_view cache_prefix; |
| |
| // Directory to start recurse for sources createing a file list. |
| // Some projects e.g. start at "src/". |
| std::string_view start_dir; |
| |
| // Regular expression matching files that should be included in file list. |
| std::string_view file_include_re = ".*"; |
| |
| // Regular expxression matching files that should be excluded from file list. |
| // If searching from toplevel, make sure to include at least ".git/". |
| std::string_view file_exclude_re = "^(\\.git|\\.github|build)/"; |
| |
| // A file in the toplevel of the project that should exist, typically |
| // something used to set up the build environment, such as MODULE.bazel, |
| // CMakeLists.txt or similar. |
| // It is used two-fold: |
| // - As validation if this script is started in the correct directory. |
| // - To take changes there into account as that build file might provide |
| // additional dependencies which might change clang-tidy outcome. |
| // (if revisit_brokenfiles_if_build_config_newer is on). |
| // (Default configuration: just .clang-tidy as this should always be there) |
| std::string_view toplevel_build_file = ".clang-tidy"; |
| |
| // Bazel projects have some complicated paths where generated and external |
| // sources reside. Setting this to true will tell this script canonicalize |
| // these in the output. Set to true if this is a bazel project. |
| bool is_bazel_project = false; |
| |
| // If the compilation DB or toplevel_build_file changed in timestamp, it |
| // might be worthwhile revisiting sources that previously had issues. |
| // This flag enables that. |
| // |
| // It is good to set once the project is 'clean' and there are only a |
| // few problematic sources to begin with, otherwise every update of the |
| // compilation DB will re-trigger revisiting all of them. |
| bool revisit_brokenfiles_if_build_config_newer = true; |
| |
| // Revisit a source file if any of its include files changed content. Say |
| // foo.cc includes bar.h. Reprocess foo.cc with clang-tidy when bar.h changed, |
| // even if foo.cc is unchanged. This will find issues in which foo.cc relies |
| // on something bar.h provides. |
| // Usually good to keep on, but it can result in situations in which a header |
| // that is included by a lot of other files results in lots of reprocessing. |
| bool revisit_if_any_include_changes = true; |
| |
| // Clang tidy configuration: clang tidy files with checks. This can be |
| // overriden with environment variable CLANG_TIDY_CONFIG |
| std::string_view clang_tidy_file = ".clang-tidy"; |
| }; |
| |
| // --------------[ Project-specific configuration ]-------------- |
| static constexpr ConfigValues kConfig = { |
| .cache_prefix = "verible_", |
| .file_exclude_re = ".git/|.github/" // stuff we're not interested in |
| "|vscode/" // some generated code in there |
| "|tree-operations_test" // very slow to process. |
| "|symbol-table_test", // ... |
| .toplevel_build_file = "MODULE.bazel", |
| .is_bazel_project = true, |
| }; |
| // -------------------------------------------------------------- |
| |
| // More clang-tidy config. |
| static constexpr std::string_view kExtraArgs[] = { |
| "-Wno-unknown-pragmas", "-Wno-unknown-warning-option"}; |
| |
| // All the extensions we consider |
| inline bool IsOneOf(std::string_view s, |
| std::initializer_list<std::string_view> options) { |
| return std::any_of(options.begin(), options.end(), |
| [s](std::string_view value) { return value == s; }); |
| } |
| |
| // Files that look like relevant include files. |
| inline bool IsIncludeExtension(std::string_view extension) { |
| return IsOneOf(extension, {".h", ".hpp", ".hxx", ".inl"}); |
| } |
| |
| // Filter for source files to be considered. |
| inline bool ConsiderExtension(const std::string_view ext) { |
| return IsOneOf(ext, {".cc", ".cpp", ".cxx"}) || IsIncludeExtension(ext); |
| } |
| |
| namespace { |
| |
| namespace fs = std::filesystem; |
| using file_time = std::filesystem::file_time_type; |
| using ReIt = std::sregex_iterator; |
| using hash_t = uint64_t; |
| using filepath_contenthash_t = std::pair<fs::path, hash_t>; |
| |
| // Some helpers |
| std::string GetContent(FILE *f) { |
| std::string result; |
| if (!f) { |
| return result; // ¯\_(ツ)_/¯ best effort. |
| } |
| char buf[4096]; |
| while (const size_t r = fread(buf, 1, sizeof(buf), f)) { |
| result.append(buf, r); |
| } |
| fclose(f); |
| return result; |
| } |
| |
| std::string GetContent(const fs::path &f) { |
| FILE *file_to_read = fopen(f.string().c_str(), "r"); |
| if (!file_to_read) { |
| fprintf(stderr, "%s: can't open: %s\n", f.string().c_str(), |
| strerror(errno)); |
| return {}; // ¯\_(ツ)_/¯ best effort. |
| } |
| return GetContent(file_to_read); |
| } |
| |
| std::string_view EnvWithFallback(const char *var, std::string_view fallback) { |
| const char *value = getenv(var); |
| return value ? value : fallback; |
| } |
| |
| std::string_view GetClangTidyConfig() { |
| return EnvWithFallback("CLANG_TIDY_CONFIG", kConfig.clang_tidy_file); |
| } |
| |
| std::string GetCommandOutput(const std::string &prog) { |
| return GetContent(popen(prog.c_str(), "r")); // NOLINT |
| } |
| |
| hash_t hashContent(const std::string &s) { return std::hash<std::string>()(s); } |
| std::string ToHex(uint64_t value, int show_lower_nibbles = 16) { |
| char out[16 + 1]; |
| snprintf(out, sizeof(out), "%016" PRIx64, value); |
| return out + (16 - show_lower_nibbles); |
| } |
| |
| // Mapping filepath_contenthash_t to an actual location in the file system. |
| class ContentAddressedStore { |
| public: |
| explicit ContentAddressedStore(const fs::path &project_base_dir) |
| : content_dir(project_base_dir / "contents") { |
| fs::create_directories(content_dir); |
| } |
| |
| // Given filepath contenthash, return the path to read/write from. |
| fs::path PathFor(const filepath_contenthash_t &c) const { |
| // Name is human readable, the content hash makes it unique. |
| std::string name_with_contenthash = c.first.filename().string(); |
| name_with_contenthash.append("-").append(ToHex(c.second)); |
| return content_dir / name_with_contenthash; |
| } |
| |
| std::string GetContentFor(const filepath_contenthash_t &c) const { |
| return GetContent(PathFor(c)); |
| } |
| |
| // Check if this needs to be recreated, either because it is not there, |
| // or is not empty and does not fit freshness requirements. |
| bool NeedsRefresh(const filepath_contenthash_t &c, |
| file_time min_freshness) const { |
| const fs::path content_hash_file = PathFor(c); |
| if (!fs::exists(content_hash_file)) { |
| return true; |
| } |
| |
| // If file exists but is broken (i.e. has a non-zero size with messages), |
| // consider recreating if if older than compilation db. |
| const bool timestamp_trigger = |
| kConfig.revisit_brokenfiles_if_build_config_newer && |
| (fs::file_size(content_hash_file) > 0 && |
| fs::last_write_time(content_hash_file) < min_freshness); |
| return timestamp_trigger; |
| } |
| |
| private: |
| const fs::path content_dir; |
| }; |
| |
| class ClangTidyRunner { |
| public: |
| ClangTidyRunner(const std::string &cache_prefix, int argc, char **argv) |
| : clang_tidy_(EnvWithFallback("CLANG_TIDY", "clang-tidy")), |
| clang_tidy_args_(AssembleArgs(argc, argv)) { |
| project_cache_dir_ = AssembleProjectCacheDir(cache_prefix); |
| } |
| |
| const fs::path &project_cache_dir() const { return project_cache_dir_; } |
| |
| // Given a work-queue in/out-file, process it. Using system() for portability. |
| // Empties work_queue. |
| void RunClangTidyOn(ContentAddressedStore &output_store, |
| std::list<filepath_contenthash_t> *work_queue) { |
| if (work_queue->empty()) { |
| return; |
| } |
| const char *jobs_env_str = getenv("CLANG_TIDY_JOBS"); |
| const int jobs_env_num = jobs_env_str ? atoi(jobs_env_str) : -1; |
| const int kJobs = (jobs_env_num > 0 ? jobs_env_num |
| : std::thread::hardware_concurrency()); |
| std::cerr << work_queue->size() << " files to process (w/ " << kJobs |
| << " jobs)..."; |
| |
| const bool print_progress = isatty(STDERR_FILENO); |
| if (!print_progress) { |
| std::cerr << "\n"; |
| } |
| |
| const std::string uniquifier = "." + std::to_string(getpid()); |
| std::mutex queue_access_lock; |
| auto clang_tidy_runner = [&]() { |
| for (;;) { |
| filepath_contenthash_t work; |
| { |
| const std::lock_guard<std::mutex> lock(queue_access_lock); |
| if (work_queue->empty()) { |
| return; |
| } |
| if (print_progress) { |
| fprintf(stderr, "%5d\b\b\b\b\b", (int)(work_queue->size())); |
| } |
| work = work_queue->front(); |
| work_queue->pop_front(); |
| } |
| const fs::path final_out = output_store.PathFor(work); |
| const std::string tmp_out = final_out.string() + uniquifier + ".tmp"; |
| // Putting the file to clang-tidy early in the command line so that |
| // it is easy to find with `ps` or `top`. |
| const std::string command = clang_tidy_ + " '" + work.first.string() + |
| "'" + clang_tidy_args_ + "> '" + tmp_out + |
| "' 2>/dev/null"; |
| const int r = system(command.c_str()); |
| #ifdef WIFSIGNALED |
| // NOLINTBEGIN |
| if (WIFSIGNALED(r) && |
| (WTERMSIG(r) == SIGINT || WTERMSIG(r) == SIGQUIT)) { |
| std::error_code ignored_error; |
| fs::remove(tmp_out, ignored_error); |
| break; // got Ctrl-C |
| } |
| // NOLINTEND |
| #endif |
| RepairFilenameOccurences(tmp_out, tmp_out); |
| fs::rename(tmp_out, final_out); // atomic replacement |
| } |
| }; |
| |
| std::vector<std::thread> workers; |
| for (auto i = 0; i < kJobs; ++i) { |
| workers.emplace_back(clang_tidy_runner); // NOLINT |
| } |
| for (auto &t : workers) { |
| t.join(); |
| } |
| if (print_progress) { |
| fprintf(stderr, " \n"); // Clean out progress counter. |
| } |
| } |
| |
| private: |
| static fs::path GetCacheBaseDir() { |
| if (const char *from_env = getenv("CACHE_DIR")) { |
| return fs::path{from_env}; |
| } |
| if (const char *home = getenv("HOME")) { |
| if (auto cdir = fs::path(home) / ".cache/"; fs::exists(cdir)) { |
| return cdir; |
| } |
| } |
| return fs::path{EnvWithFallback("TMPDIR", "/tmp")}; |
| } |
| |
| static std::string AssembleArgs(int argc, char **argv) { |
| std::string result = " --quiet"; |
| result.append(" '--config-file=") |
| .append(GetClangTidyConfig()) |
| .append("'"); |
| for (const std::string_view arg : kExtraArgs) { |
| result.append(" --extra-arg='").append(arg).append("'"); |
| } |
| for (int i = 1; i < argc; ++i) { |
| result.append(" '").append(argv[i]).append("'"); |
| } |
| return result; |
| } |
| |
| fs::path AssembleProjectCacheDir(const std::string &cache_prefix) const { |
| const fs::path cache_dir = GetCacheBaseDir() / "clang-tidy"; |
| |
| // Use major version as part of name of our configuration specific dir. |
| auto version = GetCommandOutput(clang_tidy_ + " --version"); |
| if (version.empty()) { |
| std::cerr << "Could not invoke " << clang_tidy_ << "; is it in PATH ?\n"; |
| exit(EXIT_FAILURE); |
| } |
| std::smatch version_match; |
| const std::string major_version = |
| std::regex_search(version, version_match, |
| std::regex{"version ([0-9]+)"}) |
| ? version_match[1].str() |
| : "UNKNOWN"; |
| |
| // Make sure directory filename depends on .clang-tidy content. |
| hash_t cache_unique_id = hashContent(version + clang_tidy_args_); |
| cache_unique_id ^= hashContent(GetContent(GetClangTidyConfig())); |
| return cache_dir / fs::path(cache_prefix + "v" + major_version + "_" + |
| ToHex(cache_unique_id, 8)); |
| } |
| |
| // Fix filename paths found in logfiles that are not emitted relative to |
| // project root in the log - remove that prefix. |
| // (bazel has its own, so if this is bazel, also bazel-specific fix up that). |
| static void RepairFilenameOccurences(const fs::path &infile, |
| const fs::path &outfile) { |
| static const std::regex sFixPathsRe = []() { |
| std::string canonicalize_expr = "(^|\\n)("; // fix names at start of line |
| if (kConfig.is_bazel_project) { |
| auto bzroot = GetCommandOutput("bazel info execution_root 2>/dev/null"); |
| if (!bzroot.empty()) { |
| bzroot.pop_back(); // remove newline. |
| canonicalize_expr += bzroot + "/|"; |
| } |
| } |
| canonicalize_expr += fs::current_path().string() + "/"; // $(pwd)/ |
| canonicalize_expr += |
| ")?(\\./)?"; // Some start with, or have a trailing ./ |
| return std::regex{canonicalize_expr}; |
| }(); |
| |
| const auto in_content = GetContent(infile); |
| std::fstream out_stream(outfile, std::ios::out); |
| out_stream << std::regex_replace(in_content, sFixPathsRe, "$1"); |
| } |
| |
| const std::string clang_tidy_; |
| const std::string clang_tidy_args_; |
| fs::path project_cache_dir_; |
| }; |
| |
| class FileGatherer { |
| public: |
| FileGatherer(ContentAddressedStore &store, std::string_view search_dir) |
| : store_(store), root_dir_(search_dir.empty() ? "." : search_dir) {} |
| |
| // Find all the files we're interested in, and assemble a list of |
| // paths that need refreshing. |
| std::list<filepath_contenthash_t> BuildWorkList(file_time min_freshness) { |
| // Gather all *.cc and *.h files; remember content hashes of includes. |
| static const std::regex include_re(std::string{kConfig.file_include_re}); |
| static const std::regex exclude_re(std::string{kConfig.file_exclude_re}); |
| std::map<std::string, hash_t> header_hashes; |
| for (const auto &dir_entry : fs::recursive_directory_iterator(root_dir_)) { |
| const fs::path &p = dir_entry.path().lexically_normal(); |
| if (!fs::is_regular_file(p)) { |
| continue; |
| } |
| const std::string file = p.string(); |
| if (!kConfig.file_include_re.empty() && |
| !std::regex_search(file, include_re)) { |
| continue; |
| } |
| if (!kConfig.file_exclude_re.empty() && |
| std::regex_search(file, exclude_re)) { |
| continue; |
| } |
| const auto extension = p.extension(); |
| if (ConsiderExtension(extension.string())) { |
| files_of_interest_.emplace_back(p, 0); // <- hash to be filled later. |
| } |
| // Remember content hash of header, so that we can make changed headers |
| // influence the hash of a file including this. |
| if (kConfig.revisit_if_any_include_changes && |
| IsIncludeExtension(extension.string())) { |
| // Since the files might be included sloppily without prefix path, |
| // just keep track of the basename (but since there might be collisions, |
| // accomodate all of them by xor-ing the hashes). |
| const std::string just_basename = p.filename(); |
| header_hashes[just_basename] ^= hashContent(GetContent(p)); |
| } |
| } |
| std::cerr << files_of_interest_.size() << " files of interest.\n"; |
| |
| // Create content hash address for the cache and build list of work items. |
| // If we want to revisit if headers changed, make hash dependent on them. |
| std::list<filepath_contenthash_t> work_queue; |
| const std::regex inc_re( |
| R"""(#\s*include\s+"([0-9a-zA-Z_/-]+\.[a-zA-Z]+)")"""); |
| for (filepath_contenthash_t &work_file : files_of_interest_) { |
| const auto content = GetContent(work_file.first); |
| work_file.second = hashContent(content); |
| if (kConfig.revisit_if_any_include_changes) { |
| // Update the hash with all the hashes from all include files. |
| for (ReIt it(content.begin(), content.end(), inc_re); it != ReIt(); |
| ++it) { |
| const std::string &header_path = (*it)[1].str(); |
| const std::string header_basename = fs::path(header_path).filename(); |
| const auto found = header_hashes.find(header_basename); |
| if (found != header_hashes.end()) { |
| work_file.second ^= found->second; |
| } |
| } |
| } |
| // Recreate if we don't have it yet or if it contains findings but is |
| // older than build environment. Maybe something got fixed: revisit file. |
| if (store_.NeedsRefresh(work_file, min_freshness)) { |
| work_queue.emplace_back(work_file); |
| } |
| } |
| return work_queue; |
| } |
| |
| // Tally up findings for files of interest and assemble in one file. |
| // (BuildWorkList() needs to be called first). |
| size_t CreateReport(const fs::path &cache_dir, |
| std::string_view symlink_detail, |
| std::string_view symlink_summary) const { |
| // Make it possible to keep independent reports for different invocation |
| // locations (e.g. two checkouts of the same project) using the same cache. |
| const std::string suffix = ToHex(hashContent(fs::current_path().string())); |
| const fs::path tidy_outfile = cache_dir / ("tidy.out-" + suffix); |
| const fs::path tidy_summary = cache_dir / ("tidy-summary.out-" + suffix); |
| |
| // Assemble the separate outputs into a single file. Tally up per-check. |
| // The names have at least one dash in them. |
| const std::regex check_re("(?:^|\n).*(\\[[a-zA-Z.]+-[a-zA-Z.-]+\\])\n"); |
| std::map<std::string, int> checks_seen; |
| std::unordered_set<std::string> line_already_seen; // de-dup |
| std::ofstream tidy_collect(tidy_outfile); |
| for (const filepath_contenthash_t &f : files_of_interest_) { |
| const std::string tidy = store_.GetContentFor(f); |
| if (!tidy.empty()) { |
| tidy_collect << f.first.string() << ":\n" << tidy; |
| } |
| for (ReIt it(tidy.begin(), tidy.end(), check_re); it != ReIt(); ++it) { |
| if (line_already_seen.insert((*it)[0]).second) { |
| checks_seen[(*it)[1].str()]++; |
| } |
| } |
| } |
| tidy_collect.close(); |
| std::error_code ignored_error; |
| fs::remove(symlink_detail, ignored_error); |
| fs::create_symlink(tidy_outfile, symlink_detail, ignored_error); |
| |
| // Report headline. |
| if (checks_seen.empty()) { |
| std::cerr << "No clang-tidy complaints. 😎\n"; |
| } else { |
| std::cerr << "Details: " << symlink_detail << "\n" |
| << "Summary: " << symlink_summary << "\n"; |
| std::cerr << "---- Summary ----\n"; |
| } |
| |
| // Produce a ordered-by-count report. |
| using check_count_t = std::pair<std::string, int>; |
| std::vector<check_count_t> by_count(checks_seen.begin(), checks_seen.end()); |
| std::stable_sort(by_count.begin(), by_count.end(), |
| [](const check_count_t &a, const check_count_t &b) { |
| return b.second < a.second; // reverse count |
| }); |
| |
| FILE *summary_file = fopen(tidy_summary.c_str(), "wb"); |
| for (const auto &counts : by_count) { |
| fprintf(stdout, "%5d %s\n", counts.second, counts.first.c_str()); |
| fprintf(summary_file, "%5d %s\n", counts.second, counts.first.c_str()); |
| } |
| fclose(summary_file); |
| |
| fs::remove(symlink_summary, ignored_error); |
| fs::create_symlink(tidy_summary, symlink_summary, ignored_error); |
| |
| return checks_seen.size(); |
| } |
| |
| private: |
| ContentAddressedStore &store_; |
| const std::string root_dir_; |
| std::vector<filepath_contenthash_t> files_of_interest_; |
| }; |
| } // namespace |
| |
| int main(int argc, char *argv[]) { |
| // Test that key files exist and remember their last change. |
| if (!fs::exists(GetClangTidyConfig())) { |
| std::cerr << "Need a " << GetClangTidyConfig() << " config file.\n"; |
| return EXIT_FAILURE; |
| } |
| |
| std::error_code ec; |
| const auto toplevel_build_ts = |
| fs::last_write_time(kConfig.toplevel_build_file, ec); |
| if (ec.value() != 0) { |
| std::cerr << "Script needs to be executed in toplevel project dir.\n"; |
| return EXIT_FAILURE; |
| } |
| |
| auto compdb_ts = fs::last_write_time("compile_commands.json", ec); |
| if (ec.value() != 0) { |
| compdb_ts = fs::last_write_time("compile_flags.txt", ec); |
| } |
| if (ec.value() != 0) { |
| std::cerr << "No compilation db compile_commands.json or compile_flags.txt " |
| << "found; create that first. For cmake projects, often simply\n" |
| << "\tln -s build/compile_commands.json .\n"; |
| return EXIT_FAILURE; |
| } |
| |
| const auto build_env_latest_change = std::max(toplevel_build_ts, compdb_ts); |
| |
| std::string cache_prefix{kConfig.cache_prefix}; |
| if (cache_prefix.empty()) { |
| // Cache prefix not set, choose name of directory |
| cache_prefix = fs::current_path().filename().string() + "_"; |
| } |
| ClangTidyRunner runner(cache_prefix, argc, argv); |
| ContentAddressedStore store(runner.project_cache_dir()); |
| std::cerr << "Cache dir " << runner.project_cache_dir() << "\n"; |
| |
| FileGatherer cc_file_gatherer(store, kConfig.start_dir); |
| auto work_list = cc_file_gatherer.BuildWorkList(build_env_latest_change); |
| |
| // Now the expensive part... |
| runner.RunClangTidyOn(store, &work_list); |
| |
| const std::string detailed_report = cache_prefix + "clang-tidy.out"; |
| const std::string summary = cache_prefix + "clang-tidy.summary"; |
| const size_t tidy_count = cc_file_gatherer.CreateReport( |
| runner.project_cache_dir(), detailed_report, summary); |
| |
| return tidy_count == 0 ? EXIT_SUCCESS : EXIT_FAILURE; |
| } |