Compare commits

4 Commits
Author SHA1 Message Date
Clifford Yapp d47064bd07 Improve symlink path handling 2026-06-30 23:05:06 -04:00
Clifford Yapp 3da354c59e dirsync is unneeded 2026-06-30 02:14:38 -04:00
Clifford Yapp ce607b157a Checkpoint some experimental work 2026-06-30 01:54:22 -04:00
Clifford Yapp 841fa3850a Rework to set the stage for parallel processing of multiple files
First cut at multithreading
Set up to use a thread pool
ws
Checkpoint reworking tests to reset each time they are run.
Correct ordering of path replacing
We want to do the full path replacements before the input string, since
the input may be a subset of the full paths and mess up other matches.
Use a variable for the filename
Set up the symlink test
Sort to make sure that regardless of what the routines generate, we get the order we need for replacement
Add a .gitignore
Use one runner script and pass in params
More cleanup, add in test for binary manipulation.
Get more tests up and running.
Get the rest of the tests going
Fix EXPCHAR check
Don't increment the replace op counter if we have the same string incoming.
Add dirsync utility
Adjustment to threading for strclear
Adjust set of test paths for inputs
We need our in-txt file paths in the tests to match what the C++ code is going
to decode, so use CMake's REAL_PATH to be sure.
ws, reduce depth of fix_symlink code
Use minimal glob instead of regex, make an option to disable fixing symlinks
Always ignore hidden unless an option is supplied.
Add support for skipping '.' prefixed filenames.
Sigh.  Right, Windows.  Try to do hidden file skipping the right way.
Improve queue
2026-06-30 01:00:27 -04:00
14 changed files with 1034 additions and 434 deletions
+40
View File
@@ -0,0 +1,40 @@
# Common build directories used in the source tree
/build*
/.build*
/*_build*
/*-build*
/out
# Temporary files
*~
\#*\#
.\#*
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-v][a-z]
[._]sw[a-p]
# OS specific files
.DS_Store
.DS_Store?
# CMake supports some configuration files to allow users a convenient way to pre-define
# sets of configuration options:
# https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html
/CMakeUserPresets.json
# Visual Studio (note that CMakePresets.json is recommended instead of
# CMakeSettings.json for Visual Studio going forward)
# https://learn.microsoft.com/en-us/cpp/build/cmake-presets-vs
# https://learn.microsoft.com/en-us/cpp/build/customize-cmake-settings
/.vs
.vscode
/CMakeSettings.json
#Qt Creator
CMakeLists.txt.user
# Misc
.clang-format
+8 -8
View File
@@ -36,17 +36,11 @@
# Minimum required version of CMake
cmake_minimum_required(VERSION 3.18)
# set CMake project name
project(STRCLEAR)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include_directories(
${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR}
)
if(NOT DEFINED BIN_DIR)
set(BIN_DIR bin)
endif(NOT DEFINED BIN_DIR)
@@ -54,13 +48,20 @@ endif(NOT DEFINED BIN_DIR)
include(CheckCXXCompilerFlag)
check_cxx_compiler_flag(-O3 O3_COMPILER_FLAG)
find_package(Threads REQUIRED)
add_executable(strclear strclear.cpp)
target_include_directories(strclear PRIVATE
${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(strclear PRIVATE Threads::Threads)
if (O3_COMPILER_FLAG)
# If we have the O3 flag, use it
target_compile_options(strclear PRIVATE "-O3")
endif (O3_COMPILER_FLAG)
install(TARGETS strclear DESTINATION ${BIN_DIR})
option(STRCLEAR_BUILD_TESTS "Build strclear tests" ON)
if (STRCLEAR_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
@@ -72,4 +73,3 @@ endif ()
# indent-tabs-mode: t
# End:
# ex: shiftwidth=2 tabstop=8
-31
View File
@@ -51,34 +51,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
strclear also uses strnstr, which has the following license:
Copyright (c) 2005-2018 Rich Felker
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This is a length limited version of strstr, based on the OpenBSD
implementation:
OpenBSD: strstr.c,v 1.9 2020/04/16 12:37:52 claudio Exp
https://github.com/libressl-portable/openbsd/blob/master/src/lib/libc/string/strstr.c
+26 -5
View File
@@ -1,6 +1,27 @@
strclear is a basic utility with two main jobs - clearing strings in binary
files, and replacing them in text files. It is used as part of the bundling
process with BRL-CAD, to help make sure we don't have stray build-time paths
accidentally resulting in programs working when they shouldn't by referencing
build-directory resources.
strclear
========
`strclear` removes or replaces embedded strings in files. BRL-CAD uses it
when staging third party dependencies, primarily to remove build-time paths
from binaries and to rewrite paths in text metadata such as CMake package files
and pkg-config files.
Supported modes:
* `strclear -B <file>` classifies one file. It returns `0` for binary input
and `1` for text input, matching the historical BRL-CAD CMake contract.
* `strclear --classify [--files <list> | <file> ...]` emits one JSON Lines
record per input path: `{"type":"TEXT","path":"..."}` or
`{"type":"BINARY","path":"..."}`.
* `strclear [-v] <file> <target> [replacement]` rewrites one file. Binary
files clear matching bytes to NUL by default; text files replace `target`
with `replacement`, or remove `target` if no replacement is supplied.
* `strclear [-v] --files <list> <target> [replacement]` applies the same
operation to each path listed in the file.
* `--text-only` skips binary inputs, and `--binary-only` skips text inputs.
* `-p` expands a filesystem path target into common path forms before matching.
* `-b -c` and `-r` are accepted for compatibility with existing BRL-CAD CMake
logic. `-b -c` forces binary clearing, and `-r` forces text replacement.
The `--classify` output is intentionally JSONL so build systems can parse it
without ambiguous path escaping rules.
-12
View File
@@ -1,12 +0,0 @@
Right now this tool is one file per operation, but it
would be better to be able to feed in a list of files.
We could process them in parallel to determine text or
binary, and perform the appropriate operation on them.
Can still keep single file modes for manual operations
or testing, but since the parent build should be able
to feed us all the files at once it would simplify the
parent build logic to be able to just hand us the list
and take care of it. Should also speed things up a lot
since parallel processing ought to be an option.
+521 -183
View File
@@ -34,110 +34,264 @@
*/
/** @file strclear.cpp
*
* Two modes for this tool:
* This tool has two primary jobs:
*
* Given a binary (or text) file and a string, replace any instances of the
* string in the binary with null chars.
* Given a binary file and a string, replace any instances of the string in the
* binary with null chars (or a different character specified by an option.)
*
* Given a text file, a target string and a replacement string, replace all
* instances of the target string with the replacement string.
* TODO: For the moment, the replacement string can't contain the target string.
* Given a text file, a target string and an optional replacement string,
* replace all instances of the target string with the replacement string (or
* remove the target string if there is no replacement string - it is
* "replaced" with the empty string.)
*
* The --classify mode emits JSON Lines records for build systems that need to
* batch text/binary detection.
*/
#include <algorithm>
#include <atomic>
#include <cctype>
#include <condition_variable>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <iterator>
#include <map>
#include <mutex>
#include <queue>
#include <sstream>
#include <set>
#include <string>
#include <thread>
#include <vector>
#include "cxxopts.hpp"
extern "C" char *
strnstr(const char *h, const char *n, size_t hlen);
struct process_opts {
bool binary_only = false;
bool binary_test_mode = false;
bool clear_mode = false;
bool classify_mode = false;
bool force_binary = false;
bool force_text = false;
bool path_mode = false;
bool text_only = false;
bool verbose = false;
char clear_char = '\0';
std::vector<std::string> tgt_strs;
std::string replace_str;
};
inline std::vector<std::string> expand_path_forms(const std::string &input) {
static std::string
json_escape(const std::string &input)
{
std::ostringstream out;
for (unsigned char c : input) {
switch (c) {
case '"':
out << "\\\"";
break;
case '\\':
out << "\\\\";
break;
case '\b':
out << "\\b";
break;
case '\f':
out << "\\f";
break;
case '\n':
out << "\\n";
break;
case '\r':
out << "\\r";
break;
case '\t':
out << "\\t";
break;
default:
if (c < 0x20) {
out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << (int)c << std::dec;
} else {
out << c;
}
}
}
return out.str();
}
template <typename T>
class WorkQueue {
std::queue<T> q;
std::mutex m;
std::condition_variable cv;
bool closed = false;
public:
bool push(T v) {
std::lock_guard<std::mutex> lg(m);
if (closed) return false;
q.push(std::move(v));
cv.notify_one();
return true;
}
bool pop(T &out) {
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return closed || !q.empty(); });
if (q.empty()) return false;
out = std::move(q.front());
q.pop();
return true;
}
void close() {
std::lock_guard<std::mutex> lg(m);
closed = true;
cv.notify_all();
}
};
inline std::vector<std::string>
expand_path_forms(const std::string &input) {
namespace fs = std::filesystem;
std::vector<std::string> forms;
if (!input.length())
return forms;
forms.push_back(input); // Always include original
auto add_form = [&](const fs::path &p) {
std::string s = p.string();
if (!s.empty())
forms.push_back(s);
};
auto add_partial_canonical = [&](const fs::path &p) {
std::error_code ec;
fs::path probe = fs::absolute(p, ec);
if (ec)
probe = p;
fs::path suffix;
while (!probe.empty() && !fs::exists(probe, ec)) {
fs::path parent = probe.parent_path();
if (parent == probe)
break;
if (suffix.empty())
suffix = probe.filename();
else
suffix = probe.filename() / suffix;
probe = parent;
ec.clear();
}
if (probe.empty() || !fs::exists(probe, ec))
return;
ec.clear();
fs::path canon = fs::canonical(probe, ec);
if (ec || canon.empty())
return;
if (!suffix.empty())
canon /= suffix;
add_form(canon);
};
try {
fs::path p(input);
if (fs::exists(p) && (fs::is_regular_file(p) || fs::is_symlink(p) || fs::is_directory(p))) {
// Absolute form
std::error_code ec;
auto abs = fs::absolute(p, ec).string();
if (!abs.empty() && abs != input) forms.push_back(abs);
fs::path p(input);
// Canonical form (resolves symlinks, may fail if not accessible)
ec.clear();
auto canon = fs::canonical(p, ec).string();
if (!ec && !canon.empty() && canon != input && canon != abs) forms.push_back(canon);
// Always include the original spelling, plus cheap normalized forms.
add_form(p);
std::error_code ec;
fs::path abs = fs::absolute(p, ec);
if (!ec)
add_form(abs);
add_form(p.lexically_normal());
if (!ec)
add_form(abs.lexically_normal());
// Normalized (lexically, does not resolve symlinks)
auto norm = p.lexically_normal().string();
if (!norm.empty() && norm != input && norm != abs && norm != canon) forms.push_back(norm);
}
ec.clear();
if (fs::exists(p, ec)) {
fs::path canon = fs::canonical(p, ec);
if (!ec)
add_form(canon);
}
add_partial_canonical(p);
} catch (...) {
// Ignore errors (broken symlink, permission denied, etc).
// Ignore errors (broken symlink, permission denied, etc).
}
// Match longer paths first so shorter path spellings do not partially
// replace longer ones.
std::sort(forms.begin(), forms.end(),
[](const std::string &a, const std::string &b) {
if (a.size() != b.size())
return a.size() > b.size(); // longer first
return a > b; // lexicographical descending
}
);
forms.erase(std::unique(forms.begin(), forms.end()), forms.end());
return forms;
}
int
process_binary(std::string &fname, std::vector<std::string> &target_strs, char clear_char, bool verbose, bool path_mode)
inline std::vector<std::string>
expand_target_strings(const std::vector<std::string> &targets, bool path_mode)
{
std::vector<std::string> expanded;
if (!path_mode) {
expanded = targets;
} else {
for (const auto &target : targets) {
std::vector<std::string> forms = expand_path_forms(target);
expanded.insert(expanded.end(), forms.begin(), forms.end());
}
}
std::sort(expanded.begin(), expanded.end(),
[](const std::string &a, const std::string &b) {
if (a.size() != b.size())
return a.size() > b.size();
return a > b;
});
expanded.erase(std::unique(expanded.begin(), expanded.end()), expanded.end());
return expanded;
}
int
process_binary(const std::string &fname, process_opts &p)
{
// Keep track of how many times we change the file - that's our return code
int change_cnt = 0;
// Read binary contents
std::ifstream input_fs;
input_fs.open(fname, std::ios::binary);
if (!input_fs.is_open()) {
std::cerr << "Unable to open file " << fname << "\n";
return -1;
return change_cnt;
}
std::vector<char> bin_contents(std::istreambuf_iterator<char>(input_fs), {});
input_fs.close();
std::set<std::string> tgt_strs;
if (path_mode) {
for (const auto& t : target_strs) {
auto expanded = expand_path_forms(t);
tgt_strs.insert(expanded.begin(), expanded.end());
}
} else {
tgt_strs.insert(target_strs.begin(), target_strs.end());
}
// Set up vectors of target and array of null chars
int grcnt = 0;
std::set<std::string>::iterator t_it;
for (t_it = tgt_strs.begin(); t_it != tgt_strs.end(); ++t_it) {
std::vector<char> search_chars(t_it->begin(), t_it->end());
std::vector<char> null_chars(search_chars.size(), clear_char);
// Process all target strings
for (const auto &tgt : p.tgt_strs) {
std::vector<char> search_chars(tgt.begin(), tgt.end());
std::vector<char> null_chars(search_chars.size(), p.clear_char);
// Find instances of target string in binary, and replace any we find
auto position = bin_contents.begin();
int rcnt = 0;
while ((position = std::search(position, bin_contents.end(), search_chars.begin(), search_chars.end())) != bin_contents.end()) {
std::copy(null_chars.begin(), null_chars.end(), position);
rcnt++;
if (verbose && rcnt == 1)
std::cout << fname << ":\n";
if (verbose) {
std::string cchar(1, clear_char);
if (clear_char == '\0')
cchar = std::string("\\0");
std::cout << "\tclearing instance #" << rcnt << " of " << *t_it << " with the '" << cchar << "' char\n";
}
position += search_chars.size();
// For clear ops we count by decrementing so we know what to print
// - a file is only cleared or replaced, not both, and a binary
// file can only be cleared.
change_cnt--;
}
grcnt += rcnt;
}
if (!grcnt)
if (change_cnt == 0)
return 0;
// If we changed the contents, write them back out
@@ -145,61 +299,53 @@ process_binary(std::string &fname, std::vector<std::string> &target_strs, char c
output_fs.open(fname, std::ios::binary);
if (!output_fs.is_open()) {
std::cerr << "Unable to write updated file contents for " << fname << "\n";
return -1;
return change_cnt;
}
std::copy(bin_contents.begin(), bin_contents.end(), std::ostreambuf_iterator<char>(output_fs));
output_fs.close();
return 0;
return change_cnt;
}
int
process_text(std::string &fname, std::string &target_str, std::string &replace_str, bool verbose, bool path_mode)
process_text(const std::string &fname, process_opts &p)
{
// Make sure the replacement doesn't contain the target. If we need that
// we'll have to be more sophisticated about the replacement logic, but
// for now just be simple
auto loop_check = std::search(replace_str.begin(), replace_str.end(), target_str.begin(), target_str.end());
if (loop_check != replace_str.end()) {
std::cerr << "Replacement string \"" << replace_str << "\" contains target string \"" << target_str << "\" - unsupported.\n";
return -1;
}
std::set<std::string> tgt_strs;
if (path_mode) {
auto expanded = expand_path_forms(target_str);
tgt_strs.insert(expanded.begin(), expanded.end());
} else {
tgt_strs.insert(target_str);
}
// Keep track of how many times we change the file - that's our return code
int change_cnt = 0;
// Read text contents
std::ifstream input_fs(fname);
if (!input_fs.is_open()) {
std::cerr << "Unable to open file " << fname << "\n";
return change_cnt;
}
std::stringstream fbuffer;
fbuffer << input_fs.rdbuf();
std::string nfile_contents = fbuffer.str();
input_fs.close();
if (!nfile_contents.length())
return 0;
return change_cnt;
// For replace ops we count by incrementing and clear opts we count by
// decrementing, so we know what to print in the tally. A file is only
// cleared or replaced, not both, and which it is depends on the
// replacement string.
int rincr = (p.replace_str.length()) ? 1 : -1;
// Use index and std::string::find for O(N) replacement
bool changed = false;
std::set<std::string>::iterator t_it;
for (t_it = tgt_strs.begin(); t_it != tgt_strs.end(); ++t_it) {
for (const auto &tgt : p.tgt_strs) {
// If we're replacing a string with itself, that's a no-op
if (tgt == p.replace_str)
continue;
size_t pos = 0;
int rcnt = 0;
while ((pos = nfile_contents.find(*t_it, pos)) != std::string::npos) {
nfile_contents.replace(pos, t_it->size(), replace_str);
rcnt++;
changed = true;
if (verbose && rcnt == 1)
std::cout << fname << ":\n";
if (verbose)
std::cout << "\treplacing instance #" << rcnt << " of " << *t_it << " with " << replace_str << "\n";
pos += replace_str.size();
while ((pos = nfile_contents.find(tgt, pos)) != std::string::npos) {
nfile_contents.replace(pos, tgt.size(), p.replace_str);
pos += p.replace_str.size();
change_cnt += rincr;
}
}
if (!changed)
if (change_cnt == 0)
return 0;
// If we changed the contents, write them back out
@@ -207,15 +353,15 @@ process_text(std::string &fname, std::string &target_str, std::string &replace_s
output_fs.open(fname, std::ios::trunc);
if (!output_fs.is_open()) {
std::cerr << "Unable to write updated file contents for " << fname << "\n";
return -1;
exit(-1);
}
output_fs << nfile_contents;
output_fs.close();
return 0;
return change_cnt;
}
// Text vs. binary file heuristic (generated with GPT-4.1 assistance)
bool is_binary(std::ifstream &file, size_t max_check = 4096, double nontext_threshold = 0.1)
bool
is_binary(std::ifstream &file, size_t max_check = 4096, double nontext_threshold = 0.1)
{
size_t n_read = 0;
size_t n_nontext = 0;
@@ -225,10 +371,10 @@ bool is_binary(std::ifstream &file, size_t max_check = 4096, double nontext_thre
// Null byte: almost always binary
if (c == '\0')
return true;
// Accept printable ASCII (32–126), CR, LF, TAB, FF
// Accept printable ASCII, CR, LF, TAB and FF.
if ((c >= 32 && c <= 126) || c == '\n' || c == '\r' || c == '\t' || c == '\f')
continue;
// Accept valid 8-bit UTF-8 lead bytes (for text, this is not 100% but helps)
// Treat common UTF-8 lead bytes as text unless other bytes dominate.
if ((unsigned char)c >= 0xC2 && (unsigned char)c <= 0xF4)
continue;
n_nontext++;
@@ -237,35 +383,85 @@ bool is_binary(std::ifstream &file, size_t max_check = 4096, double nontext_thre
if (n_read == 0)
return false; // empty file: treat as text
// If more than 10% non-text, guess binary
return (double)n_nontext / n_read > nontext_threshold;
}
void
process_files(std::map<std::string, std::atomic<int>> &op_tally, std::set<std::string> &files, process_opts &p)
{
if (files.empty() || !p.tgt_strs.size())
return;
unsigned int num_threads = (unsigned int)(0.5 * (double)std::thread::hardware_concurrency());
if (num_threads == 0)
num_threads = 4;
num_threads = std::min(num_threads, (unsigned int)files.size());
// Pre-populate op_tally entries so worker threads only update atomics.
for (const auto &fname : files) {
(void)op_tally[fname]; // default-construct atomic<int> (0)
op_tally[fname].store(0, std::memory_order_relaxed);
}
WorkQueue<std::string> wq;
for (const auto &fname : files) {
bool ok = wq.push(fname);
(void)ok; // with our usage we haven't called close yet; ok should be true
}
wq.close();
auto worker = [&]() {
std::string fname;
while (wq.pop(fname)) {
std::ifstream check_fs(fname, std::ios::binary);
if (!check_fs.is_open()) {
std::cerr << "Error: unable to open " << fname << "\n";
op_tally[fname].store(0, std::memory_order_relaxed);
continue;
}
bool binary_mode = (p.force_binary) ? true : is_binary(check_fs);
check_fs.close();
int result = 0;
if (binary_mode && p.force_text) {
std::cerr << "Error: string replacement indicated, but file is binary: " << fname << "\n";
op_tally[fname].store(0, std::memory_order_relaxed);
continue;
}
if (binary_mode && !p.text_only)
result = process_binary(fname, p);
if (!binary_mode && !p.binary_only)
result = process_text(fname, p);
op_tally[fname].store(result, std::memory_order_relaxed);
}
};
std::vector<std::thread> threads;
threads.reserve(num_threads);
for (unsigned int i = 0; i < num_threads; ++i)
threads.emplace_back(worker);
for (auto &t : threads)
t.join();
}
int
main(int argc, const char *argv[])
{
bool binary_mode = false;
bool binary_test_mode = false;
bool clear_mode = false;
char clear_char = '\0';
bool swap_mode = false;
bool text_mode = false;
bool path_mode = false;
bool verbose = false;
process_opts p;
bool legacy_binary_mode = false;
std::string file_list;
cxxopts::Options options(argv[0],
"A program to clear or replace strings in files.\n"
"Clear or replace strings in files.\n"
"\n"
"When the -p option is activated, a target string supplied for clearing\n"
"or replacement appears to be a filesystem path (e.g., an existing file\n"
"or directory), this tool will automatically search for and operate on\n"
"all recognized forms of that path within the file. This includes:\n"
" - the original path string as supplied\n"
" - its absolute path form\n"
" - its canonical (fully resolved, with symlinks removed) form\n"
" - its normalized (syntactically simplified) form\n"
"This ensures that both relative and absolute references, as well as\n"
"symlinked and normalized forms of the same file, are detected and processed.\n"
"strclear -B <filename>\n"
"strclear --classify [--files <filelist> | <filename> ...]\n"
"strclear <filename> <target_str> [replacement_str]\n"
"strclear -f <filelist> <target_str> [replacement_str]\n"
"\n"
"-B returns 0 for binary input and 1 for text input.\n"
"--classify emits one JSON record per input path.\n"
);
std::vector<std::string> nonopts;
@@ -275,38 +471,40 @@ main(int argc, const char *argv[])
options
.set_width(70)
.add_options()
("B,is_binary","Test the file to see if it is a binary file.)", cxxopts::value<bool>(binary_test_mode))
("b,binary", "Treat the input file as binary. (Note that only string clearing is supported with binary files.)", cxxopts::value<bool>(binary_mode))
("c,clear", "Replace strings in files by overwriting a specified character (defaults to NULL.) Note: If the target string is a path and -p is specified, all equivalent path forms are processed.", cxxopts::value<bool>(clear_mode))
("clear_char", "Specify a character to use when clearing strings in files", cxxopts::value<char>(clear_char))
("r,replace", "Replace one string with another (text mode only). Note: If the target string is a path and -p is specified, all equivalent path forms are processed.", cxxopts::value<bool>(swap_mode))
("p,paths", "Expand a target string that is a file path into all recognized forms (original, absolute, canonical, normalized) for searching and replacing/clearing.", cxxopts::value<bool>(path_mode))
("t,text", "Refuse to run unless the input file is a text file.", cxxopts::value<bool>(text_mode))
("v,verbose", "Verbose reporting during processing", cxxopts::value<bool>(verbose))
("h,help", "Print help")
("B,is_binary", "Test one file: return 0 for binary input and 1 for text input.", cxxopts::value<bool>(p.binary_test_mode))
("t,text", "Compatibility alias: force text replacement mode.", cxxopts::value<bool>(p.force_text))
("text-only", "Skip inputs that are binary files.", cxxopts::value<bool>(p.text_only))
("b,binary", "Compatibility alias: force binary clear mode.", cxxopts::value<bool>(legacy_binary_mode))
("binary-only", "Skip inputs that are text files.", cxxopts::value<bool>(p.binary_only))
("c,clear", "Compatibility option for binary clear mode.", cxxopts::value<bool>(p.clear_mode))
("classify", "Classify files as TEXT or BINARY, one JSON record per path.", cxxopts::value<bool>(p.classify_mode))
("r,replace", "Compatibility option for text replacement mode.", cxxopts::value<bool>())
("f,files", "Provide a list of files to process.", cxxopts::value<std::string>(file_list))
("clear-char", "Specify a character to use when clearing strings in files", cxxopts::value<char>(p.clear_char))
("clear_char", "Specify a character to use when clearing strings in files", cxxopts::value<char>(p.clear_char))
("p,paths", "Expand target strings that are file paths into recognized forms (original, absolute, canonical, normalized).", cxxopts::value<bool>(p.path_mode))
("v,verbose", "Verbose reporting during processing", cxxopts::value<bool>(p.verbose))
("h,help", "Print help")
;
auto result = options.parse(argc, argv);
nonopts = result.unmatched();
// Do we want help?
if (result.count("help")) {
std::cout << options.help({""}) << std::endl;
return 0;
}
if (binary_mode && text_mode) {
std::cerr << "Error: need to specify either binary or text mode, not both\n";
return -1;
}
if (!clear_mode && !swap_mode && !binary_test_mode) {
std::cerr << "Error: need to specify either clear mode (-c), replace mode (-r), or binary file test mode (-B)\n";
return -1;
}
if (clear_mode && swap_mode) {
std::cerr << "Error: need to specify either clear or replace mode, not both\n";
return -1;
nonopts = result.unmatched();
if (legacy_binary_mode)
p.force_binary = true;
if (p.force_binary)
p.binary_only = false;
if (result.count("replace")) {
if (p.clear_mode) {
std::cerr << "Error: need to specify either clear or replace mode, not both.\n";
return -1;
}
p.force_text = true;
}
}
catch (const cxxopts::exceptions::exception& e)
@@ -315,70 +513,211 @@ main(int argc, const char *argv[])
return -1;
}
// Unless the goal is strictly to test file type, we need at least two args
if (nonopts.size() < 2 && !binary_test_mode) {
/////////////////////////////////////////
// Do some option checking and validation
/////////////////////////////////////////
if (p.classify_mode && p.binary_test_mode) {
std::cerr << "Error: specify either -B or --classify, not both.\n";
return -1;
}
if (p.classify_mode && (legacy_binary_mode || p.clear_mode || p.force_text || p.text_only || p.binary_only || p.path_mode)) {
std::cerr << "Error: --classify cannot be combined with rewrite mode options.\n";
return -1;
}
// binary_only ∩ text_only == NULL set
if ((p.binary_only || p.force_binary) && (p.text_only || p.force_text)) {
std::cerr << "Error: can specify binary-only or text-only, not both.\n";
std::cout << options.help({""}) << std::endl;
return -1;
}
// If we only have a filename and a single string, the only thing we can
// do is treat the file as binary and replace the string
if (nonopts.size() == 2 && swap_mode) {
std::cerr << "Error: string replacement mode indicated, but no replacement specified\n";
// If we're testing whether a file is binary, we only take one argument
if (p.binary_test_mode && nonopts.size() != 1) {
std::cerr << "Error: -B accepts exactly one file path as input.\n";
std::cout << options.help({""}) << std::endl;
return -1;
}
// For swap_mode, we need exactly 3 nonopts (file, target, replace)
if (swap_mode && nonopts.size() != 3) {
std::cerr << "Error: replacing string in text file - need file, target string and replacement string as arguments.\n";
return -1;
}
std::string fname(nonopts[0]);
// Determine if the file is a binary or text file, if we've not been told
// to treat it as binary explicitly with -b. If we've been told text mode
// we still check to make sure we really have a text file before processing.
if (!binary_mode) {
std::ifstream check_fs(fname, std::ios::binary);
if (p.binary_test_mode) {
std::ifstream check_fs(nonopts[0], std::ios::binary);
if (!check_fs.is_open()) {
std::cerr << "Error: unable to open " << fname << "\n";
std::cerr << "Unable to open file " << nonopts[0] << "\n";
return -1;
}
binary_mode = is_binary(check_fs);
bool binary_mode = is_binary(check_fs);
check_fs.close();
return binary_mode ? 0 : 1;
}
// If all we're supposed to do is determine the type, return success (0) if
// the file is binary, else 1
if (binary_test_mode)
return (binary_mode) ? 0 : 1;
if (p.classify_mode) {
std::vector<std::string> files;
if (file_list.length()) {
if (nonopts.size()) {
std::cerr << "Error: specify either --files or file paths for --classify, not both.\n";
return -1;
}
std::ifstream instream(file_list);
if (!instream.is_open()) {
std::cerr << "Error: Could not open " << file_list << "\n";
return -1;
}
std::string line;
while (std::getline(instream, line)) {
if (line.length())
files.push_back(line);
}
instream.close();
} else {
for (const auto &fname : nonopts)
files.push_back(fname);
}
if (files.empty()) {
std::cerr << "Error: --classify needs at least one file path.\n";
return -1;
}
int ret = 0;
for (const auto &fname : files) {
std::ifstream check_fs(fname, std::ios::binary);
if (!check_fs.is_open()) {
std::cerr << "Unable to open file " << fname << "\n";
ret = -1;
continue;
}
bool binary_mode = is_binary(check_fs);
check_fs.close();
std::cout << "{\"type\":\"" << (binary_mode ? "BINARY" : "TEXT") << "\",\"path\":\"" << json_escape(fname) << "\"}\n";
}
return ret;
}
if (binary_mode && swap_mode) {
std::cerr << "Error: string replacement indicated, but file is binary\n";
// Everything else needs at least a filename or file list and a target string
if ((file_list.length() && !p.binary_only && !p.force_binary) && (nonopts.size() != 1 && nonopts.size() != 2)) {
std::cerr << "Error: when using a file list we need a target string and (optionally) a replacement string.\n";
std::cout << options.help({""}) << std::endl;
return -1;
}
if ((file_list.length() && (p.binary_only || p.force_binary)) && nonopts.empty()) {
std::cerr << "Error: binary file-list processing needs at least one target string.\n";
return -1;
}
if ((!file_list.length() && !p.binary_only && !p.force_binary) && (nonopts.size() != 2 && nonopts.size() != 3)) {
std::cerr << "Error: we need a file, a target string and (optionally) a replacement string.\n";
std::cout << options.help({""}) << std::endl;
return -1;
}
if ((!file_list.length() && p.binary_only) && (nonopts.size() != 2)) {
std::cerr << "Error: when in binary-only mode we only accept a filename, a target string and (optionally) a --clear-char character.\n";
return -1;
}
if ((!file_list.length() && p.force_binary) && (nonopts.size() < 2)) {
std::cerr << "Error: when in binary mode we need a filename and one or more target strings.\n";
return -1;
}
if (p.force_text && !file_list.length() && nonopts.size() != 3) {
std::cerr << "Error: replacing string in text file - need file, target string and replacement string as arguments.\n";
return -1;
}
if (p.force_text && file_list.length() && nonopts.size() != 2) {
std::cerr << "Error: replacing strings in a text file list needs target string and replacement string arguments.\n";
return -1;
}
// If we're in binary mode we're just nulling out the target string(s).
if (binary_mode) {
std::vector<std::string> target_strs;
for (size_t i = 1; i < nonopts.size(); i++) {
if (nonopts[i].length())
target_strs.push_back(nonopts[i]);
std::set<std::string> files;
std::string target_str;
if (!file_list.length()) {
files.insert(nonopts[0]);
target_str = nonopts[1];
if (p.force_binary) {
for (size_t i = 1; i < nonopts.size(); i++)
p.tgt_strs.push_back(nonopts[i]);
} else {
p.replace_str = (nonopts.size() > 2) ? std::string(nonopts[2]) : std::string("");
}
if (!target_strs.size()) {
std::cerr << "Target strings cannot be empty.\n";
} else {
std::ifstream instream(file_list);
if (!instream.is_open()) {
std::cerr << "Error: Could not open " << file_list << "\n";
return -1;
}
return process_binary(fname, target_strs, clear_char, verbose, path_mode);
std::string line;
while (std::getline(instream, line))
files.insert(line);
instream.close();
target_str = nonopts[0];
if (p.force_binary || p.binary_only) {
for (size_t i = 0; i < nonopts.size(); i++)
p.tgt_strs.push_back(nonopts[i]);
} else if (!p.binary_only) {
p.replace_str = (nonopts.size() > 1) ? std::string(nonopts[1]) : std::string("");
}
}
std::string target_str(nonopts[1]);
if (!target_str.length()) {
std::cerr << "Target string cannot be empty.\n";
if (!p.force_binary && !target_str.length()) {
std::cerr << "Error: empty target string supplied\n";
return -1;
}
std::string replace_str = (swap_mode) ? std::string(nonopts[2]) : std::string("");
return process_text(fname, target_str, replace_str, verbose, path_mode);
if (!p.force_binary && !(file_list.length() && p.binary_only)) {
p.tgt_strs.clear();
p.tgt_strs.push_back(target_str);
}
p.tgt_strs = expand_target_strings(p.tgt_strs, p.path_mode);
std::map<std::string, std::atomic<int>> op_tally;
process_files(op_tally, files, p);
if (p.verbose) {
// Verify we did something on some file before we print anything
bool did_op = false;
for (const auto &kv : op_tally) {
if (kv.second.load(std::memory_order_relaxed) != 0) {
did_op = true;
break;
}
}
// If we did something interesting, report it - otherwise just note
// that nothing happened.
if (did_op) {
std::string cchar(1, p.clear_char);
if (p.clear_char == '\0')
cchar = std::string("\\0");
std::cout << "Summary:\n";
std::cout << " Original target string: " << target_str << "\n";
if (p.path_mode) {
std::cout << " Expanded path targets: \n";
for (const auto &t : p.tgt_strs) {
if (t == target_str)
continue;
std::cout << " : " << t << "\n";
}
}
if (p.clear_char != '\0')
std::cout << " Clear char: " << cchar << "\n";
if (p.replace_str.length())
std::cout << " Replacement string: " << p.replace_str << "\n";
std::cout << "----------Processed Paths-------\n";
// print tally
for (const auto &kv : op_tally) {
int v = kv.second.load(std::memory_order_relaxed);
if (!v)
continue;
std::cout << kv.first << ": ";
if (v < 0) {
std::cout << " cleared " << -1*v << " instances\n";
} else {
std::cout << "replaced " << v << " instances\n";
}
}
}
}
return 0;
}
// Local Variables:
@@ -389,4 +728,3 @@ main(int argc, const char *argv[])
// c-file-style: "stroustrup"
// End:
// ex: shiftwidth=4 tabstop=8
+167 -183
View File
@@ -2,234 +2,218 @@
# strclear tests
###################################################
# Counting characters in a binary file is a little outside
# CMake's wheelhouse, so add a utility to do it.
add_executable(charcnt charcnt.cpp)
# Directory for test artifacts
set(TESTDIR "${CMAKE_CURRENT_BINARY_DIR}/testfiles")
file(MAKE_DIRECTORY "${TESTDIR}")
# Helper function to compare file contents
function(assert_file_contains file expected_text)
file(READ "${file}" file_content)
string(FIND "${file_content}" "${expected_text}" found)
if(found EQUAL -1)
message(FATAL_ERROR "File ${file} does not contain expected text: '${expected_text}'\nActual content:\n${file_content}")
endif()
endfunction()
# Helper function to assert file does NOT contain text
function(assert_file_not_contains file unexpected_text)
file(READ "${file}" file_content)
string(FIND "${file_content}" "${unexpected_text}" found)
if(NOT found EQUAL -1)
message(FATAL_ERROR "File ${file} still contains text: '${unexpected_text}'\nActual content:\n${file_content}")
endif()
endfunction()
# We need to use this rather than cmake_path(NORMALIZE) to resolve symlinks.
# Do this AFTER file(MAKE_DIRECTORY) is called.
file(REAL_PATH "${TESTDIR}" TESTDIR)
# --- Test 1: Non-filesystem string replace ---
set(TEXT1 "This is a test string foo and bar.\nAnother foo here.")
set(FILE1 "${TESTDIR}/plain.txt")
file(WRITE "${FILE1}" "${TEXT1}")
# Set up a template to use as input. Since the contents are altered, the test
# needs to stage it each time - that's why we write it out with the .in suffix.
set(PLAINFILE "plain.txt")
file(WRITE "${TESTDIR}/${PLAINFILE}.in" "This is a test string foo and bar.\nAnother foo here.")
add_test(NAME BasicStringReplace
COMMAND strclear -r "${FILE1}" foo baz
)
set_tests_properties(BasicStringReplace PROPERTIES
FIXTURES_SETUP basic_replace
)
add_test(NAME VerifyBasicStringReplace
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE1} -Dexpected=baz -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifyBasicStringReplace PROPERTIES
FIXTURES_REQUIRED basic_replace
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${PLAINFILE}
-DTNUM=1 -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=2 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
# --- Test 2: Path expansion (filesystem) ---
set(REL_PATH "relpath.txt")
set(ABS_PATH "${TESTDIR}/abspath.txt")
set(FILE2 "${TESTDIR}/withpaths.txt")
get_filename_component(ABS_PATH_FOR_FILE "${ABS_PATH}" ABSOLUTE)
set(TEXT2 "Here is a path: ${REL_PATH}\nAnd here is another: ${ABS_PATH_FOR_FILE}\n")
file(WRITE "${FILE2}" "${TEXT2}")
file(WRITE "${TESTDIR}/${REL_PATH}" "dummy")
file(WRITE "${ABS_PATH}" "dummy")
# Set up the template for the to-be-altered file. For this test we want to
# point to a file that is present - we can just target the plain.in file from
# the first test, since that is already placed on the filesystem by CMake.
set(WITHPATHSFILE "withpaths.txt")
get_filename_component(ABS_PATH_FOR_FILE "${TESTDIR}/${PLAINFILE}.in" ABSOLUTE)
set(WITHPATHS_TXT "Relative: ${PLAINFILE}.in\nAbsolute: ${ABS_PATH_FOR_FILE}\n")
file(WRITE "${TESTDIR}/${WITHPATHSFILE}.in" "${WITHPATHS_TXT}")
add_test(NAME PathExpansionReplace
COMMAND strclear -r -p "${FILE2}" "${REL_PATH}" CLEARED
)
set_tests_properties(PathExpansionReplace PROPERTIES
FIXTURES_SETUP path_replace
)
add_test(NAME VerifyPathExpansionReplace
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE2} -Dexpected=CLEARED -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifyPathExpansionReplace PROPERTIES
FIXTURES_REQUIRED path_replace
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${WITHPATHSFILE}
-DTNUM=2 -DTGTSTR=${PLAINFILE}.in -DEXPSTR=CLEARED -DEXPCNT=2 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v\;-p
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
# --- Test 3: Symlink path expansion ---
set(SRC_FILE "${TESTDIR}/target.txt")
set(SYM_PATH "${TESTDIR}/symlink.txt")
set(FILE3 "${TESTDIR}/withsymlink.txt")
set(TEXT3 "Original: ${SRC_FILE}\nSymlink: ${SYM_PATH}\n")
file(WRITE "${FILE3}" "${TEXT3}")
file(WRITE "${SRC_FILE}" "dummy")
# IFF the OS supports making symlinks, we can do this test - find out
set(SYMFILE "symlink.txt")
file(CREATE_LINK "${TESTDIR}/${PLAINFILE}.in" "${TESTDIR}/${SYMFILE}" SYMBOLIC RESULT symlink_ret)
if(symlink_ret EQUAL 0)
# Set up the template for the to-be-altered file.
set(WITHSYMFILE "withsymlink.txt")
set(WITHSYMLINK_TXT "Original: ${TESTDIR}/${PLAINFILE}.in\nSymlink: ${TESTDIR}/${SYMFILE}\n")
file(WRITE "${TESTDIR}/${WITHSYMFILE}.in" "${WITHSYMLINK_TXT}")
file(CREATE_LINK "${SRC_FILE}" "${SYM_PATH}" SYMBOLIC)
add_test(NAME SymlinkPathExpansionReplace
COMMAND strclear -r -p "${FILE3}" "${SYM_PATH}" "CLEAN"
)
set_tests_properties(SymlinkPathExpansionReplace PROPERTIES
FIXTURES_SETUP symlink_replace
)
add_test(NAME VerifySymlinkPathExpansionReplace
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE3} -Dexpected=CLEAN -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifySymlinkPathExpansionReplace PROPERTIES
FIXTURES_REQUIRED symlink_replace
)
add_test(NAME SymlinkPathExpansionReplace
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${WITHSYMFILE}
-DTNUM=3 -DTGTSTR=${SYMFILE} -DEXPSTR=CLEARED -DEXPCNT=2 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v\;-p
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
endif()
# --- Test 4: No Replacement Occurs ---
set(FILE_NOMATCH "${TESTDIR}/no_match.txt")
set(TEXT_NOMATCH "Just a normal text file.")
file(WRITE "${FILE_NOMATCH}" "${TEXT_NOMATCH}")
set(NOMATCHFILE "no_match.txt")
file(WRITE "${TESTDIR}/${NOMATCHFILE}.in" "Just a normal text file.")
add_test(NAME NoReplacementIfNoMatch
COMMAND strclear -r "${FILE_NOMATCH}" "foo" "bar"
)
set_tests_properties(NoReplacementIfNoMatch PROPERTIES
FIXTURES_SETUP no_replace
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${NOMATCHFILE}
-DTNUM=4 -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=0 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
add_test(NAME VerifyNoReplacementIfNoMatch
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE_NOMATCH} -Dexpected=${TEXT_NOMATCH} -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifyNoReplacementIfNoMatch PROPERTIES
FIXTURES_REQUIRED no_replace
)
# --- Test 5: Multiple Occurrences ---
set(FILE_MULTI "${TESTDIR}/multi.txt")
set(TEXT_MULTI "foo bar foo baz foo")
file(WRITE "${FILE_MULTI}" "${TEXT_MULTI}")
add_test(NAME MultipleReplacements
COMMAND strclear -r "${FILE_MULTI}" foo CLEARED
)
set_tests_properties(MultipleReplacements PROPERTIES
FIXTURES_SETUP multi_replace
)
add_test(NAME VerifyMultipleReplacements
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE_MULTI} -Dexpected=CLEARED -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifyMultipleReplacements PROPERTIES
FIXTURES_REQUIRED multi_replace
# --- Test 5: Many Occurrences ---
set(MULTIFILE "multi.txt")
file(WRITE "${TESTDIR}/${MULTIFILE}.in" "foo bar foo baz ")
foreach(i RANGE 100)
file(APPEND "${TESTDIR}/${MULTIFILE}.in" "foo ")
endforeach()
add_test(NAME ManyOccurrences
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${MULTIFILE}
-DTNUM=5 -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=103 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
# --- Test 6: Empty Search String (should fail) ---
set(FILE_EMPTYSEARCH "${TESTDIR}/emptysearch.txt")
file(WRITE "${FILE_EMPTYSEARCH}" "some text")
add_test(NAME EmptySearchString
COMMAND strclear -r "${FILE_EMPTYSEARCH}" "" replacement
)
set_tests_properties(EmptySearchString PROPERTIES
WILL_FAIL TRUE
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${NOMATCHFILE}
-DTNUM=6 -DTGTSTR= -DEXPSTR=CLEARED -DEXPCNT=0 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
# --- Test 7: File with Only Search String ---
set(FILE_ONLYSEARCH "${TESTDIR}/onlysearch.txt")
file(WRITE "${FILE_ONLYSEARCH}" "foo")
set(ONLYFILE "onlysearch.txt")
file(WRITE "${TESTDIR}/${ONLYFILE}.in" "foo")
add_test(NAME OnlySearchString
COMMAND strclear -r "${FILE_ONLYSEARCH}" foo gone
)
set_tests_properties(OnlySearchString PROPERTIES
FIXTURES_SETUP onlysearch_replace
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${ONLYFILE}
-DTNUM=7 -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=1 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
add_test(NAME VerifyOnlySearchString
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE_ONLYSEARCH} -Dexpected=gone -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifyOnlySearchString PROPERTIES
FIXTURES_REQUIRED onlysearch_replace
)
# --- Test 8: Large File Replacement ---
set(FILE_LARGE "${TESTDIR}/large.txt")
set(SEARCH_LARGE "foo")
set(REPLACE_LARGE "bar")
file(WRITE "${FILE_LARGE}" "")
foreach(i RANGE 1 1000)
file(APPEND "${FILE_LARGE}" "foo this is line ${i}\n")
# --- Test 8: Large(ish) File Replacement ---
set(LARGEFILE "large.txt")
file(WRITE "${TESTDIR}/${LARGEFILE}.in" "Autogenerated large file:\n")
foreach(i RANGE 1000)
file(APPEND "${TESTDIR}/${LARGEFILE}.in" "Line foo: foo${i}foo\n")
endforeach()
add_test(NAME LargeFileReplacement
COMMAND strclear -r "${FILE_LARGE}" foo bar
)
set_tests_properties(LargeFileReplacement PROPERTIES
FIXTURES_SETUP large_replace
add_test(NAME LargeFile
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${LARGEFILE}
-DTNUM=8 -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=3003 -DQUIET_RUN=1 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
add_test(NAME VerifyLargeFileReplacement
COMMAND ${CMAKE_COMMAND} -Dfile=${FILE_LARGE} -Dexpected=bar -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
)
set_tests_properties(VerifyLargeFileReplacement PROPERTIES
FIXTURES_REQUIRED large_replace
# --- Test 9: Binary File Alteration ---
set(BINARYFILE "binary_file.bin")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/${BINARYFILE}" DESTINATION ${TESTDIR})
file(RENAME "${TESTDIR}/${BINARYFILE}" "${TESTDIR}/${BINARYFILE}.in")
add_test(NAME BinaryFile
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${BINARYFILE}
-DTNUM=9 -DTGTSTR=foo -DEXPSTR=NULL -DEXPCNT=6 -DQUIET_RUN=1 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-DCHARCNT=$<TARGET_FILE:charcnt>
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
# --- Test 9: Unicode/UTF-8 Replacement ---
# Disabled for now - we don't support Unicode yet
#set(FILE_UTF8 "${TESTDIR}/utf8.txt")
## Note: 💧 is a multi-byte character; CMake will encode as UTF-8
#file(WRITE "${FILE_UTF8}" "αβγ foo δει foo ζ")
#add_test(NAME UnicodeReplacement
# COMMAND strclear -r "${FILE_UTF8}" foo 💧
#)
#set_tests_properties(UnicodeReplacement PROPERTIES
# FIXTURES_SETUP unicode_replace
#)
#
#add_test(NAME VerifyUnicodeReplacement
# COMMAND ${CMAKE_COMMAND} -Dfile=${FILE_UTF8} -Dexpected=💧 -P "${CMAKE_CURRENT_LIST_DIR}/verify_contains.cmake"
#)
#set_tests_properties(VerifyUnicodeReplacement PROPERTIES
# FIXTURES_REQUIRED unicode_replace
#)
# --- Test 10: Read-only File (should fail) ---
set(FILE_RO "${TESTDIR}/readonly.txt")
file(WRITE "${FILE_RO}" "foo bar")
execute_process(COMMAND chmod a-w "${FILE_RO}")
add_test(NAME ReadOnlyFile
COMMAND strclear -r "${FILE_RO}" foo bar
)
set_tests_properties(ReadOnlyFile PROPERTIES
WILL_FAIL TRUE
# --- Test 10: Binary File Alteration, alternate character ---
set(CLEAR_CHAR "X")
add_test(NAME BinaryFileClearChar
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata -DTDIR=${TESTDIR} -DTFILE=${BINARYFILE}
-DTNUM=10 -DTGTSTR=b -DEXPSTR=NULL -DEXPCHAR=${CLEAR_CHAR} -DEXPCNT=2 -DQUIET_RUN=1 -DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v\;--clear-char=${CLEAR_CHAR}
-DCHARCNT=$<TARGET_FILE:charcnt>
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_test.cmake"
)
# --- Test 11: Help and Version Output ---
# --- Test 11: Mixed Input List via --files option ---
set(MIXTXTFILE "mix_input_list.txt")
file(WRITE "${TESTDIR}/${MIXTXTFILE}.in" "This is a text input for mixed lists. foo.")
set(MIXBINFILE "mix_input_list.bin")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/${BINARYFILE}" DESTINATION ${TESTDIR})
file(RENAME "${TESTDIR}/${BINARYFILE}" "${TESTDIR}/${MIXBINFILE}.in")
add_test(NAME MixedList
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata
-DTDIR=${TESTDIR} -DTNUM=11 -DCPYCNT=1 -DQUIET_RUN=1
-DTBINFILE=${MIXBINFILE} -DEXPNULLCNT=6 -DCHARCNT=$<TARGET_FILE:charcnt>
-DTTXTFILE=${MIXTXTFILE} -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=1
-DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_mixed_list.cmake"
)
# --- Test 12: Parallel Processing (large mixed list, stress test) ---
add_test(NAME ParallelProcessing
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata
-DTDIR=${TESTDIR} -DTNUM=12 -DCPYCNT=50 -DQUIET_RUN=1
-DTBINFILE=${MIXBINFILE} -DEXPNULLCNT=6 -DCHARCNT=$<TARGET_FILE:charcnt>
-DTTXTFILE=${MIXTXTFILE} -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=1
-DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_mixed_list.cmake"
)
# --- Test 13: Text file only filtering from a mixed list ---
add_test(NAME TextOnlyFiltering
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata
-DTDIR=${TESTDIR} -DTNUM=13 -DCPYCNT=1 -DQUIET_RUN=1
-DTBINFILE=${MIXBINFILE} -DEXPNULLCNT=3 -DCHARCNT=$<TARGET_FILE:charcnt>
-DTTXTFILE=${MIXTXTFILE} -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=1
-DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v\;--text-only
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_mixed_list.cmake"
)
# --- Test 14: Binary file only filtering from a mixed list ---
add_test(NAME BinaryOnlyFiltering
COMMAND ${CMAKE_COMMAND} -DSDIR=${CMAKE_CURRENT_SOURCE_DIR}/testdata
-DTDIR=${TESTDIR} -DTNUM=14 -DCPYCNT=1 -DQUIET_RUN=1
-DTBINFILE=${MIXBINFILE} -DEXPNULLCNT=6 -DCHARCNT=$<TARGET_FILE:charcnt>
-DTTXTFILE=${MIXTXTFILE} -DTGTSTR=foo -DEXPSTR=CLEARED -DEXPCNT=0
-DSTRCLEAR=$<TARGET_FILE:strclear> -DSTRCLEAR_OPTS=-v\;--binary-only
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_mixed_list.cmake"
)
# --- Test 15: Binary/text classification contracts ---
add_test(NAME ClassifyModes
COMMAND ${CMAKE_COMMAND}
-DTDIR=${TESTDIR}
-DBINARY_SRC=${CMAKE_CURRENT_SOURCE_DIR}/${BINARYFILE}
-DSTRCLEAR=$<TARGET_FILE:strclear>
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_classify.cmake"
)
# --- Test 16: BRL-CAD compatibility options ---
add_test(NAME CompatibilityModes
COMMAND ${CMAKE_COMMAND}
-DTDIR=${TESTDIR}
-DBINARY_SRC=${CMAKE_CURRENT_SOURCE_DIR}/${BINARYFILE}
-DCHARCNT=$<TARGET_FILE:charcnt>
-DSTRCLEAR=$<TARGET_FILE:strclear>
-P "${CMAKE_CURRENT_SOURCE_DIR}/run_compat_modes.cmake"
)
# --- Test 17: Help output ---
add_test(NAME StrclearHelp
COMMAND strclear --help
COMMAND $<TARGET_FILE:strclear> -v --help
)
set_tests_properties(StrclearHelp PROPERTIES
PASS_REGULAR_EXPRESSION "usage|help|clear"
PASS_REGULAR_EXPRESSION "usage|help|clear"
)
# --- Test 12: Nonexistent File (should fail) ---
set(NONEXISTENT_FILE "${TESTDIR}/does_not_exist.txt")
# --- Test 18: Nonexistent file ---
add_test(NAME NonexistentFile
COMMAND strclear -r "${NONEXISTENT_FILE}" foo bar
COMMAND $<TARGET_FILE:strclear> -v "${TESTDIR}/does_not_exist.txt" foo bar
)
set_tests_properties(NonexistentFile PROPERTIES
WILL_FAIL TRUE
# --- Test 19: Read-only file should fail ---
# Make a read-only
set(READONLYFILE "${TESTDIR}/readonly.txt")
file(WRITE "${READONLYFILE}" "Read_only")
file(CHMOD "${READONLYFILE}" PERMISSIONS OWNER_READ GROUP_READ WORLD_READ)
add_test(NAME ReadOnlyFile
COMMAND $<TARGET_FILE:strclear> -v "${READONLYFILE}" Read_only Written
)
set_tests_properties(ReadOnlyFile PROPERTIES WILL_FAIL TRUE)
# Local Variables:
# tab-width: 8
Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
/* C H A R C N T . C P P
* BRL-CAD
*
* Published in 2025 by the United States Government.
* This work is in the public domain.
*/
#include <climits>
#include <fstream>
#include <iostream>
#include <string>
#include <cstring>
int
main(int argc, const char **argv)
{
if (argc != 2 && argc != 3) {
std::cerr << "Usage: charcnt <filename> [char]\n";
return -1;
}
char c = '\0';
if (argc == 3) {
if (strlen(argv[2]) != 1) {
std::cerr << "Error - second argument (if present) must be a single char\n";
return -1;
} else {
c = argv[2][0];
}
}
std::ifstream fs(argv[1], std::ios::binary);
if (!fs.is_open()) {
std::cerr << "Error: Could not open file " << argv[1] << "\n";
return -1;
}
// Iterate, and count all the nulls
unsigned long long nc = 0;
char byte;
while (fs.get(byte))
nc = (byte == c) ? nc + 1 : nc;
fs.close();
std::string rstr;
if (c == '\0') {
rstr = std::string("null");
} else {
rstr = c;
}
std::cout << "Found " << nc << " " << rstr << " characters\n";
if (nc > INT_MAX)
std::cout << "Error - more than " << INT_MAX << " chars found!\n";
return (int)nc;
}
// Local Variables:
// tab-width: 8
// mode: C++
// c-basic-offset: 4
// indent-tabs-mode: t
// c-file-style: "stroustrup"
// End:
// ex: shiftwidth=4 tabstop=8 cino=N-s
+40
View File
@@ -0,0 +1,40 @@
set(TEXT_FILE "${TDIR}/classify_text.txt")
set(BINARY_FILE "${TDIR}/classify_binary.bin")
set(FILE_LIST "${TDIR}/classify_files.txt")
file(WRITE "${TEXT_FILE}" "plain text\n")
execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different "${BINARY_SRC}" "${BINARY_FILE}")
file(WRITE "${FILE_LIST}" "${TEXT_FILE}\n${BINARY_FILE}\n")
execute_process(
COMMAND "${STRCLEAR}" -B "${TEXT_FILE}"
RESULT_VARIABLE text_result
)
if(NOT text_result EQUAL 1)
message(FATAL_ERROR "-B should return 1 for text files, got ${text_result}")
endif()
execute_process(
COMMAND "${STRCLEAR}" -B "${BINARY_FILE}"
RESULT_VARIABLE binary_result
)
if(NOT binary_result EQUAL 0)
message(FATAL_ERROR "-B should return 0 for binary files, got ${binary_result}")
endif()
execute_process(
COMMAND "${STRCLEAR}" --classify --files "${FILE_LIST}"
RESULT_VARIABLE classify_result
OUTPUT_VARIABLE classify_output
)
if(NOT classify_result EQUAL 0)
message(FATAL_ERROR "--classify failed: ${classify_result}")
endif()
string(FIND "${classify_output}" "\"type\":\"TEXT\"" text_type)
string(FIND "${classify_output}" "\"path\":\"${TEXT_FILE}\"" text_path)
string(FIND "${classify_output}" "\"type\":\"BINARY\"" binary_type)
string(FIND "${classify_output}" "\"path\":\"${BINARY_FILE}\"" binary_path)
if(text_type EQUAL -1 OR text_path EQUAL -1 OR binary_type EQUAL -1 OR binary_path EQUAL -1)
message(FATAL_ERROR "Unexpected --classify output:\n${classify_output}")
endif()
+33
View File
@@ -0,0 +1,33 @@
set(TEXT_FILE "${TDIR}/compat_replace.txt")
set(BINARY_FILE "${TDIR}/compat_clear.bin")
file(WRITE "${TEXT_FILE}" "foo one foo two\n")
execute_process(
COMMAND "${STRCLEAR}" -r "${TEXT_FILE}" "foo" "bar"
RESULT_VARIABLE replace_result
)
if(NOT replace_result EQUAL 0)
message(FATAL_ERROR "Compatibility -r mode failed: ${replace_result}")
endif()
file(READ "${TEXT_FILE}" text_content)
if(NOT "${text_content}" STREQUAL "bar one bar two\n")
message(FATAL_ERROR "Compatibility -r mode produced unexpected text:\n${text_content}")
endif()
execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different "${BINARY_SRC}" "${BINARY_FILE}")
execute_process(
COMMAND "${STRCLEAR}" -b -c "${BINARY_FILE}" "foo" "/path/not/present"
RESULT_VARIABLE clear_result
)
if(NOT clear_result EQUAL 0)
message(FATAL_ERROR "Compatibility -b -c mode failed: ${clear_result}")
endif()
execute_process(
COMMAND "${CHARCNT}" "${BINARY_FILE}"
RESULT_VARIABLE null_count
)
if(NOT null_count EQUAL 6)
message(FATAL_ERROR "Compatibility -b -c mode expected 6 NUL chars, found ${null_count}")
endif()
+69
View File
@@ -0,0 +1,69 @@
# Reset input files
set(TF "${TDIR}/${TTXTFILE}${TNUM}")
set(BF "${TDIR}/${TBINFILE}${TNUM}")
foreach (i RANGE ${CPYCNT})
execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TDIR}/${TTXTFILE}.in" "${TF}_${i}")
execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TDIR}/${TBINFILE}.in" "${BF}_${i}")
endforeach()
# report original file contents
if (NOT QUIET_RUN)
file(READ "${TF}" orig_file_content)
message("Original text file contents:\n\n${orig_file_content}\n")
execute_process(
COMMAND "${CHARCNT}" "${BF}"
RESULT_VARIABLE count
)
message("Original binary null char count: ${count}")
endif()
# assemble file list to pass to strclear
set(flist)
file(REMOVE "${TDIR}/${TNUM}.txt")
foreach (i RANGE ${CPYCNT})
file(APPEND ${TDIR}/${TNUM}.txt "${TF}_${i}\n")
file(APPEND ${TDIR}/${TNUM}.txt "${BF}_${i}\n")
endforeach()
# Run strclear
message("WORKING_DIRECTORY ${TDIR}")
message("${STRCLEAR} ${STRCLEAR_OPTS} --files ${TDIR}/${TNUM}.txt ${TGTSTR} ${EXPSTR}")
execute_process(
COMMAND "${STRCLEAR}" ${STRCLEAR_OPTS} --files ${TDIR}/${TNUM}.txt "${TGTSTR}" ${EXPSTR}
WORKING_DIRECTORY ${TDIR}
)
# Check file contents
file(STRINGS ${TDIR}/${TNUM}.txt ilist)
foreach (f ${ilist})
if (NOT EXISTS ${f})
message(FATAL_ERROR "file ${f} not found")
endif()
if ("${f}" MATCHES ".*bin.*")
execute_process(
COMMAND ${CHARCNT} ${f}
RESULT_VARIABLE bcount
OUTPUT_VARIABLE bout
)
if(NOT ${bcount} EQUAL ${EXPNULLCNT})
message(FATAL_ERROR "File ${f} does not contain ${EXPNULLCNT} null chars - found ${bcount}")
endif()
else()
file(READ ${f} file_content)
# Match all occurrences of CLEARED and store them in a list
string(REPLACE "\n" "" file_content "${file_content}")
string(REGEX MATCHALL "${EXPSTR}" MATCHES "${file_content}")
list(LENGTH MATCHES tcount)
if(NOT ${tcount} EQUAL ${EXPCNT})
message(FATAL_ERROR "File ${f} does not contain expected text: ${EXPCNT}x'${EXPSTR}'\nActual content:\n${file_content}")
endif()
endif()
endforeach()
# Local Variables:
# tab-width: 8
# mode: cmake
# indent-tabs-mode: t
# End:
# ex: shiftwidth=2 tabstop=8
+64
View File
@@ -0,0 +1,64 @@
# Reset input file
set(TF "${TDIR}/${TFILE}${TNUM}")
execute_process(COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TDIR}/${TFILE}.in" "${TF}")
# report original file contents
if (NOT QUIET_RUN)
file(READ "${TF}" orig_file_content)
message("Original file contents:\n\n${orig_file_content}\n")
endif()
# Run strclear
message("WORKING_DIRECTORY ${TDIR}")
message("${STRCLEAR} ${STRCLEAR_OPTS} ${TF} ${TGTSTR} ${EXPSTR}")
execute_process(
COMMAND "${STRCLEAR}" ${STRCLEAR_OPTS} "${TF}" "${TGTSTR}" ${EXPSTR}
WORKING_DIRECTORY ${TDIR}
)
# Check output file contents
file(READ "${TF}" file_content)
# If we were assigned a charcnt executable, look for NULL characters.
# Otherwise, count EXPSTR instances
if (CHARCNT)
message("${CHARCNT} ${TF} ${EXPCHAR}")
execute_process(
COMMAND "${CHARCNT}" "${TF}" ${EXPCHAR}
RESULT_VARIABLE count
)
if (count LESS 0)
message(FATAL_ERROR "charcnt unable to run successfully\n")
endif()
else()
# Match all occurrences of CLEARED and store them in a list
string(REPLACE "\n" "" file_content "${file_content}")
string(REGEX MATCHALL "${EXPSTR}" MATCHES "${file_content}")
list(LENGTH MATCHES count)
endif()
file(READ "${TF}" file_content)
if(NOT ${count} EQUAL ${EXPCNT})
if (CHARCNT)
set(nstr)
if (NOT EXPCHAR)
set(nstr "null")
else()
set(nstr "${EXPCHAR}")
endif()
message(FATAL_ERROR "File ${TF} does not contain ${EXPCNT} null chars - found ${count}")
else()
message(FATAL_ERROR "File ${TF} does not contain expected text: ${EXPCNT}x'${EXPSTR}'\nActual content:\n${file_content}")
endif()
else()
if (NOT QUIET_RUN)
message("Final file contents:\n\n${file_content}\n")
endif()
endif()
# Local Variables:
# tab-width: 8
# mode: cmake
# indent-tabs-mode: t
# End:
# ex: shiftwidth=2 tabstop=8
-12
View File
@@ -1,12 +0,0 @@
if(NOT DEFINED file)
message(FATAL_ERROR "Must define -Dfile argument")
endif()
if(NOT DEFINED expected)
message(FATAL_ERROR "Must define -Dexpected argument")
endif()
file(READ "${file}" file_content)
string(FIND "${file_content}" "${expected}" found)
if(found EQUAL -1)
message(FATAL_ERROR "File ${file} does not contain expected text: '${expected}'\nActual content:\n${file_content}")
endif()