@@ -10,10 +10,13 @@ | |||
#include <dds/util/output.hpp> | |||
#include <dds/util/time.hpp> | |||
#include <fansi/styled.hpp> | |||
#include <array> | |||
#include <set> | |||
using namespace dds; | |||
using namespace fansi::literals; | |||
namespace { | |||
@@ -23,12 +26,16 @@ struct state { | |||
}; | |||
void log_failure(const test_failure& fail) { | |||
dds_log(error, "Test '{}' failed! [exited {}]", fail.executable_path.string(), fail.retc); | |||
dds_log(error, | |||
"Test .br.yellow[{}] .br.red[{}] [Exited {}]"_styled, | |||
fail.executable_path.string(), | |||
fail.timed_out ? "TIMED OUT" : "FAILED", | |||
fail.retc); | |||
if (fail.signal) { | |||
dds_log(error, "Test execution received signal {}", fail.signal); | |||
} | |||
if (trim_view(fail.output).empty()) { | |||
dds_log(error, "(Test executable produced no output"); | |||
dds_log(error, "(Test executable produced no output)"); | |||
} else { | |||
dds_log(error, "Test output:\n{}[dds - test output end]", fail.output); | |||
} |
@@ -5,10 +5,12 @@ | |||
#include <dds/util/log.hpp> | |||
#include <dds/util/time.hpp> | |||
#include <fansi/styled.hpp> | |||
#include <range/v3/range/conversion.hpp> | |||
#include <range/v3/view/transform.hpp> | |||
using namespace dds; | |||
using namespace fansi::literals; | |||
fs::path create_archive_plan::calc_archive_file_path(const toolchain& tc) const noexcept { | |||
return _subdir / fmt::format("{}{}{}", "lib", _name, tc.archive_suffix()); | |||
@@ -55,7 +57,10 @@ void create_archive_plan::archive(const build_env& env) const { | |||
"Creating static library archive [{}] failed for '{}'", | |||
out_relpath, | |||
_qual_name); | |||
dds_log(error, "Subcommand FAILED: {}\n{}", quote_command(ar_cmd), ar_res.output); | |||
dds_log(error, | |||
"Subcommand FAILED: .bold.yellow[{}]\n{}"_styled, | |||
quote_command(ar_cmd), | |||
ar_res.output); | |||
throw_external_error< | |||
errc::archive_failure>("Creating static library archive [{}] failed for '{}'", | |||
out_relpath, |
@@ -8,6 +8,7 @@ | |||
#include <dds/util/string.hpp> | |||
#include <dds/util/time.hpp> | |||
#include <fansi/styled.hpp> | |||
#include <neo/assert.hpp> | |||
#include <range/v3/range/conversion.hpp> | |||
#include <range/v3/view/filter.hpp> | |||
@@ -20,6 +21,7 @@ | |||
using namespace dds; | |||
using namespace ranges; | |||
using namespace fansi::literals; | |||
namespace { | |||
@@ -51,7 +53,8 @@ do_compile(const compile_file_full& cf, build_env_ref env, compile_counter& coun | |||
// Generate a log message to display to the user | |||
auto source_path = cf.plan.source_path(); | |||
auto msg = fmt::format("[{}] Compile: {}", | |||
auto msg = fmt::format("[{}] Compile: .br.cyan[{}]"_styled, | |||
cf.plan.qualifier(), | |||
fs::relative(source_path, cf.plan.source().basis_path).string()); | |||
@@ -141,7 +144,7 @@ do_compile(const compile_file_full& cf, build_env_ref env, compile_counter& coun | |||
if (!compiled_okay) { | |||
dds_log(error, "Compilation failed: {}", source_path.string()); | |||
dds_log(error, | |||
"Subcommand FAILED [Exitted {}]: {}\n{}", | |||
"Subcommand .bold.red[FAILED] [Exited {}]: .bold.yellow[{}]\n{}"_styled, | |||
compile_retc, | |||
quote_command(cf.cmd_info.command), | |||
compiler_output); |
@@ -7,10 +7,13 @@ | |||
#include <dds/util/log.hpp> | |||
#include <dds/util/time.hpp> | |||
#include <fansi/styled.hpp> | |||
#include <algorithm> | |||
#include <chrono> | |||
using namespace dds; | |||
using namespace fansi::literals; | |||
fs::path link_executable_plan::calc_executable_path(build_env_ref env) const noexcept { | |||
return env.output_root / _out_subdir / (_name + env.toolchain.executable_suffix()); | |||
@@ -77,25 +80,27 @@ bool link_executable_plan::is_test() const noexcept { | |||
std::optional<test_failure> link_executable_plan::run_test(build_env_ref env) const { | |||
auto exe_path = calc_executable_path(env); | |||
auto msg = fmt::format("Run test: {:30}", fs::relative(exe_path, env.output_root).string()); | |||
auto msg = fmt::format("Run test: .br.cyan[{:30}]"_styled, | |||
fs::relative(exe_path, env.output_root).string()); | |||
dds_log(info, msg); | |||
using namespace std::chrono_literals; | |||
auto&& [dur, res] = timed<std::chrono::microseconds>( | |||
[&] { return run_proc({.command = {exe_path.string()}, .timeout = 10s}); }); | |||
if (res.okay()) { | |||
dds_log(info, "{} - PASSED - {:>9L}μs", msg, dur.count()); | |||
dds_log(info, "{} - .br.green[PASS] - {:>9L}μs", msg, dur.count()); | |||
return std::nullopt; | |||
} else { | |||
auto exit_msg = fmt::format(res.signal ? "signalled {}" : "exited {}", | |||
res.signal ? res.signal : res.retc); | |||
auto fail_str = res.timed_out ? "TIMEOUT" : "FAILED "; | |||
auto fail_str = res.timed_out ? ".br.yellow[TIME]"_styled : ".br.red[FAIL]"_styled; | |||
dds_log(error, "{} - {} - {:>9L}μs [{}]", msg, fail_str, dur.count(), exit_msg); | |||
test_failure f; | |||
f.executable_path = exe_path; | |||
f.output = res.output; | |||
f.retc = res.retc; | |||
f.signal = res.signal; | |||
f.timed_out = res.timed_out; | |||
return f; | |||
} | |||
} |
@@ -18,8 +18,9 @@ class library_plan; | |||
struct test_failure { | |||
fs::path executable_path; | |||
std::string output; | |||
int retc; | |||
int signal; | |||
int retc{}; | |||
int signal{}; | |||
bool timed_out = false; | |||
}; | |||
/** |
@@ -11,9 +11,12 @@ | |||
#include <dds/util/result.hpp> | |||
#include <boost/leaf/handle_exception.hpp> | |||
#include <fansi/styled.hpp> | |||
#include <json5/parse_data.hpp> | |||
#include <neo/url.hpp> | |||
using namespace fansi::literals; | |||
int dds::cli::cmd::handle_pkg_repo_remote_errors(std::function<int()> fn) { | |||
return boost::leaf::try_catch( | |||
[&] { | |||
@@ -38,13 +41,16 @@ int dds::cli::cmd::handle_pkg_repo_remote_errors(std::function<int()> fn) { | |||
}, | |||
[](const json5::parse_error& e, neo::url bad_url) { | |||
dds_log(error, | |||
"Error parsing JSON downloaded from URL [{}]: {}", | |||
"Error parsing JSON downloaded from URL [.br.red[{}]`]: {}"_styled, | |||
bad_url.to_string(), | |||
e.what()); | |||
return 1; | |||
}, | |||
[](dds::e_sqlite3_error_exc e, neo::url url) { | |||
dds_log(error, "Error accessing remote database [{}]: {}", url.to_string(), e.message); | |||
dds_log(error, | |||
"Error accessing remote database [.br.red[{}]`]: {}"_styled, | |||
url.to_string(), | |||
e.message); | |||
return 1; | |||
}, | |||
[](dds::e_sqlite3_error_exc e) { | |||
@@ -53,7 +59,7 @@ int dds::cli::cmd::handle_pkg_repo_remote_errors(std::function<int()> fn) { | |||
}, | |||
[](dds::e_system_error_exc e, dds::network_origin conn) { | |||
dds_log(error, | |||
"Error communicating with [{}://{}:{}]: {}", | |||
"Error communicating with [.br.red[{}://{}:{}]`]: {}"_styled, | |||
conn.protocol, | |||
conn.hostname, | |||
conn.port, | |||
@@ -62,8 +68,7 @@ int dds::cli::cmd::handle_pkg_repo_remote_errors(std::function<int()> fn) { | |||
}, | |||
[](matchv<pkg_repo_subcommand::remove>, e_nonesuch missing) { | |||
missing.log_error( | |||
"Cannot delete remote '{}', as no such remote repository is locally registered by " | |||
"that name."); | |||
"Cannot delete remote '.br.red[{}]', as no such remote repository is locally registered by that name."_styled); | |||
write_error_marker("repo-rm-no-such-repo"); | |||
return 1; | |||
}); |
@@ -2,11 +2,14 @@ | |||
#include <dds/util/log.hpp> | |||
#include <fansi/styled.hpp> | |||
using namespace dds; | |||
using namespace fansi::literals; | |||
void e_nonesuch::log_error(std::string_view fmt) const noexcept { | |||
dds_log(error, fmt, given); | |||
if (nearest) { | |||
dds_log(error, " (Did you mean '{}'?)", *nearest); | |||
dds_log(error, " (Did you mean '.br.yellow[{}]'?)"_styled, *nearest); | |||
} | |||
} |
@@ -9,6 +9,7 @@ | |||
#include <dds/util/log.hpp> | |||
#include <dds/util/result.hpp> | |||
#include <fansi/styled.hpp> | |||
#include <neo/event.hpp> | |||
#include <neo/io/stream/buffers.hpp> | |||
#include <neo/io/stream/file.hpp> | |||
@@ -23,6 +24,7 @@ | |||
#include <range/v3/view/transform.hpp> | |||
using namespace dds; | |||
using namespace fansi::literals; | |||
namespace nsql = neo::sqlite3; | |||
namespace { | |||
@@ -77,7 +79,10 @@ void pkg_remote::store(nsql::database_ref db) { | |||
void pkg_remote::update_pkg_db(nsql::database_ref db, | |||
std::optional<std::string_view> etag, | |||
std::optional<std::string_view> db_mtime) { | |||
dds_log(info, "Pulling repository contents for {} [{}]", _name, _base_url.to_string()); | |||
dds_log(info, | |||
"Pulling repository contents for .cyan[{}] [{}`]"_styled, | |||
_name, | |||
_base_url.to_string()); | |||
auto& pool = http_pool::global_pool(); | |||
auto url = _base_url; |
@@ -0,0 +1,35 @@ | |||
#pragma once | |||
#include <string> | |||
namespace fansi { | |||
enum class std_color { | |||
unspecified = -1, | |||
black = 0, | |||
red = 1, | |||
green = 2, | |||
yellow = 3, | |||
blue = 4, | |||
magent = 5, | |||
cyan = 6, | |||
white = 7, | |||
normal = 9, | |||
}; | |||
struct text_style { | |||
std_color fg_color = std_color::normal; | |||
std_color bg_color = std_color::normal; | |||
bool bright = false; | |||
bool bold = false; | |||
bool faint = false; | |||
bool italic = false; | |||
bool underline = false; | |||
bool reverse = false; | |||
bool strike = false; | |||
}; | |||
bool detect_should_style() noexcept; | |||
} // namespace fansi |
@@ -0,0 +1,173 @@ | |||
#include "./styled.hpp" | |||
#include "./style.hpp" | |||
#include "./writer.hpp" | |||
#include <magic_enum.hpp> | |||
#include <neo/buffer_algorithm/concat.hpp> | |||
#include <neo/event.hpp> | |||
#include <neo/string_io.hpp> | |||
#include <neo/ufmt.hpp> | |||
#include <neo/utility.hpp> | |||
#include <cctype> | |||
#include <charconv> | |||
#include <map> | |||
#include <vector> | |||
#if NEO_OS_IS_WINDOWS | |||
bool fansi::detect_should_style() noexcept { return false; } | |||
#else | |||
#include <unistd.h> | |||
bool fansi::detect_should_style() noexcept { return ::isatty(STDOUT_FILENO); } | |||
#endif | |||
using namespace fansi; | |||
using namespace neo::buffer_literals; | |||
namespace { | |||
const auto ANSI_CSI = "\x1b["_buf; | |||
// const auto ANSI_RESET = "0m"_buf; | |||
// const auto ANSI_BOLD = "1m"_buf; | |||
// const auto ANSI_RED = "32m"_buf; | |||
// const auto ANSI_GREEN = "32m"_buf; | |||
// const auto ANSI_YELLOW = "33m"_buf; | |||
// const auto ANSI_BLUE = "34m"_buf; | |||
// const auto ANSI_MAGENTA = "35m"_buf; | |||
// const auto ANSI_CYAN = "36m"_buf; | |||
// const auto ANSI_WHITE = "37m"_buf; | |||
// const auto ANSI_GRAY = "90m"_buf; | |||
constexpr text_style default_style{}; | |||
struct text_styler { | |||
std::string_view input; | |||
should_style should; | |||
text_writer out{}; | |||
std::string_view::iterator s_iter = input.cbegin(), s_place = s_iter, s_stop = input.cend(); | |||
bool do_style = (should == should_style::force) | |||
? true | |||
: (should == should_style::never ? false : detect_should_style()); | |||
std::vector<text_style> _style_stack = {default_style}; | |||
std::string_view slice(std::string_view::iterator it, | |||
std::string_view::iterator st) const noexcept { | |||
return input.substr(it - input.cbegin(), st - it); | |||
} | |||
std::string_view pending() const noexcept { return slice(s_place, s_iter); } | |||
std::string_view remaining() const noexcept { return slice(s_place, input.cend()); } | |||
std::string render() noexcept { | |||
while (s_iter != s_stop) { | |||
if (*s_iter == '`') { | |||
out.write(pending()); | |||
++s_iter; | |||
if (s_iter == s_stop) { | |||
neo::emit(ev_warning{"String ends with incomplete escape sequence"}); | |||
} else { | |||
out.putc(*s_iter); | |||
} | |||
++s_iter; | |||
s_place = s_iter; | |||
} else if (*s_iter == '.') { | |||
out.write(pending()); | |||
s_place = s_iter; | |||
++s_iter; | |||
if (s_iter == s_stop || !std::isalpha(*s_iter)) { | |||
// Just keep going | |||
continue; | |||
} | |||
s_place = s_iter; | |||
_push_style(); | |||
} else if (*s_iter == ']' && _style_stack.size() > 1) { | |||
out.write(pending()); | |||
s_place = ++s_iter; | |||
_pop_style(); | |||
} else { | |||
// Just keep scanning | |||
++s_iter; | |||
} | |||
} | |||
out.write(pending()); | |||
return out.take_string(); | |||
} | |||
void _push_style() noexcept { | |||
_read_style(); | |||
neo_assert(expects, | |||
*s_iter == '[', | |||
"Style sequence should be followed by an opening square brackent"); | |||
if (do_style) { | |||
out.put_style(_style_stack.back()); | |||
} | |||
s_place = ++s_iter; | |||
} | |||
void _read_style() noexcept { | |||
auto& style = _style_stack.emplace_back(_style_stack.back()); | |||
while (s_iter != s_stop) { | |||
if (*s_iter == neo::oper::any_of('[', '.')) { | |||
auto cls = pending(); | |||
s_place = s_iter; | |||
_apply_class(style, cls); | |||
if (*s_iter == '[') { | |||
return; | |||
} | |||
s_place = ++s_iter; | |||
} | |||
++s_iter; | |||
} | |||
} | |||
void _apply_class(text_style& style, std::string_view cls) const noexcept { | |||
auto color = magic_enum::enum_cast<std_color>(cls); | |||
if (color) { | |||
style.fg_color = *color; | |||
} | |||
#define CASE(Name) \ | |||
else if (cls == #Name) { \ | |||
style.Name = true; \ | |||
} | |||
CASE(bold) | |||
CASE(faint) | |||
CASE(italic) | |||
CASE(underline) | |||
CASE(reverse) | |||
CASE(strike) | |||
#undef CASE | |||
else if (cls == "br") { | |||
style.bright = true; | |||
} | |||
else { | |||
neo_assert(expects, false, "Invalid text style class in input string", cls); | |||
} | |||
} | |||
void _pop_style() noexcept { | |||
neo_assert(expects, | |||
_style_stack.size() > 1, | |||
"Unbalanced style: Extra closing square brackets"); | |||
_style_stack.pop_back(); | |||
out.put_style(_style_stack.back()); | |||
} | |||
}; // namespace | |||
} // namespace | |||
std::string fansi::stylize(std::string_view str, fansi::should_style should) { | |||
neo_assertion_breadcrumbs("Rendering text style string", str); | |||
return text_styler{str, should}.render(); | |||
} | |||
std::string_view detail::cached_rendering(const char* ptr) noexcept { | |||
thread_local std::map<const char*, std::string> cache; | |||
auto found = cache.find(ptr); | |||
if (found == cache.end()) { | |||
found = cache.emplace(ptr, stylize(ptr)).first; | |||
} | |||
return found->second; | |||
} |
@@ -0,0 +1,34 @@ | |||
#pragma once | |||
#include <cinttypes> | |||
#include <string> | |||
#include <string_view> | |||
namespace fansi { | |||
struct ev_warning { | |||
std::string_view message; | |||
}; | |||
enum class should_style { | |||
detect, | |||
force, | |||
never, | |||
}; | |||
std::string stylize(std::string_view text, should_style = should_style::detect); | |||
namespace detail { | |||
std::string_view cached_rendering(const char* ptr) noexcept; | |||
} | |||
inline namespace literals { | |||
inline namespace styled_literals { | |||
inline std::string_view operator""_styled(const char* str, std::size_t) { | |||
return detail::cached_rendering(str); | |||
} | |||
} // namespace styled_literals | |||
} // namespace literals | |||
} // namespace fansi |
@@ -0,0 +1,34 @@ | |||
#include "./styled.hpp" | |||
#include <catch2/catch.hpp> | |||
static std::string render(std::string_view fmt) { | |||
return fansi::stylize(fmt, fansi::should_style::force); | |||
} | |||
TEST_CASE("Stylize some text") { | |||
auto test = render("foo bar"); | |||
CHECK(test == "foo bar"); | |||
test = render("foo. bar."); | |||
CHECK(test == "foo. bar."); | |||
test = render("foo `.eggs"); | |||
CHECK(test == "foo .eggs"); | |||
test = render("foo `.bar[`]"); | |||
CHECK(test == "foo .bar[]"); | |||
test = render("foo .bold[bar] baz"); | |||
CHECK(test == "foo \x1b[1mbar\x1b[0m baz"); | |||
test = render("foo .bold.red[bar] baz"); | |||
CHECK(test == "foo \x1b[1;31mbar\x1b[0m baz"); | |||
test = render("foo .br.red[bar] baz"); | |||
CHECK(test == "foo \x1b[91mbar\x1b[0m baz"); | |||
test = render("foo .br.italic[bar] baz"); | |||
CHECK(test == "foo \x1b[3mbar\x1b[0m baz"); | |||
test = render("foo .red[I am a string with .bold[bold] text inside]"); | |||
CHECK(test == "foo \x1b[31mI am a string with \x1b[1mbold\x1b[0;31m text inside\x1b[0m"); | |||
} |
@@ -0,0 +1,101 @@ | |||
#include "./writer.hpp" | |||
#include <neo/buffer_algorithm/concat.hpp> | |||
#include <array> | |||
#include <charconv> | |||
using namespace fansi; | |||
using namespace neo::literals; | |||
namespace { | |||
int code_for_color(std_color col, bool bright) { | |||
return 30 + int(col) + ((bright && col != std_color::normal) ? 60 : 0); | |||
} | |||
} // namespace | |||
void text_writer::put_style(const text_style& new_style) noexcept { | |||
auto& prev_style = _style; | |||
bool unbold = false; | |||
std::string reset_then_enable = "0"; | |||
std::string set_toggles; | |||
using neo::dynbuf_concat; | |||
auto append_int = [&](std::string& out, int i) { | |||
std::array<char, 4> valbuf; | |||
auto res = std::to_chars(valbuf.data(), valbuf.data() + sizeof(valbuf), i); | |||
if (!out.empty()) { | |||
out.push_back(';'); | |||
} | |||
neo::dynbuf_concat(out, neo::as_buffer(valbuf, res.ptr - valbuf.data())); | |||
}; | |||
auto append_toggle = [&](bool my_state, bool prev_state, int on_val) { | |||
int off_val = on_val + 20; | |||
if (!my_state) { | |||
if (prev_state != my_state) { | |||
append_int(set_toggles, off_val); | |||
if (off_val == 21) { | |||
// ! Hack: Terminals disagree on the meaning of 21. ECMA says | |||
// "double-underline", but intuition tells us it would be bold-off, since it is | |||
// SGR Bold [1] plus twenty, as with all other toggles. | |||
unbold = true; | |||
} | |||
} | |||
} else { | |||
append_int(reset_then_enable, on_val); | |||
if (prev_state != my_state) { | |||
append_int(set_toggles, on_val); | |||
} | |||
} | |||
}; | |||
append_toggle(new_style.bold, prev_style.bold, 1); | |||
append_toggle(new_style.faint, prev_style.faint, 2); | |||
append_toggle(new_style.italic, prev_style.italic, 3); | |||
append_toggle(new_style.underline, prev_style.underline, 4); | |||
append_toggle(new_style.reverse, prev_style.reverse, 7); | |||
append_toggle(new_style.strike, prev_style.strike, 9); | |||
int fg_int = code_for_color(new_style.fg_color, new_style.bright); | |||
int bg_int = code_for_color(new_style.bg_color, new_style.bright) + 10; | |||
int prev_fg_int = code_for_color(prev_style.fg_color, prev_style.bright); | |||
int prev_bg_int = code_for_color(prev_style.bg_color, prev_style.bright) + 10; | |||
if (new_style.fg_color == std_color::normal) { | |||
// No need to change the foreground color for the reset, but maybe for the toggle | |||
if (fg_int != prev_fg_int) { | |||
append_int(set_toggles, fg_int); | |||
} | |||
} else { | |||
append_int(reset_then_enable, fg_int); | |||
if (fg_int != prev_fg_int) { | |||
append_int(set_toggles, fg_int); | |||
} | |||
} | |||
if (new_style.bg_color == std_color::normal) { | |||
// No need to change the background color for the reset, but maybe for the toggle | |||
if (bg_int != prev_bg_int) { | |||
append_int(set_toggles, bg_int); | |||
} | |||
} else { | |||
append_int(reset_then_enable, bg_int); | |||
if (bg_int != prev_bg_int) { | |||
append_int(set_toggles, bg_int); | |||
} | |||
} | |||
if (set_toggles.empty()) { | |||
// No changes necessary | |||
} else if (unbold || set_toggles.size() > reset_then_enable.size()) { | |||
dynbuf_concat(_buf, "\x1b[", reset_then_enable, "m"); | |||
} else { | |||
dynbuf_concat(_buf, "\x1b[", set_toggles, "m"); | |||
} | |||
_style = new_style; | |||
} |
@@ -0,0 +1,57 @@ | |||
#pragma once | |||
#include "./style.hpp" | |||
#include <neo/as_buffer.hpp> | |||
#include <neo/as_dynamic_buffer.hpp> | |||
#include <neo/buffer_algorithm/size.hpp> | |||
#include <neo/buffer_range.hpp> | |||
#include <initializer_list> | |||
#include <string> | |||
namespace fansi { | |||
class text_writer { | |||
std::string _buf; | |||
std::size_t _vis_size = 0; | |||
text_style _style; | |||
template <neo::buffer_range Bufs> | |||
void _write_raw(Bufs&& bufs, std::size_t s) noexcept { | |||
auto out = neo::as_dynamic_buffer(_buf).grow(s); | |||
neo::buffer_copy(out, bufs); | |||
} | |||
template <neo::buffer_range Bufs> | |||
void _write(Bufs&& bufs) noexcept { | |||
auto size = neo::buffer_size(bufs); | |||
_write_raw(bufs, size); | |||
_vis_size += size; | |||
} | |||
public: | |||
template <neo::buffer_range Buf> | |||
void write(Buf&& bufs) noexcept { | |||
_write(bufs); | |||
} | |||
template <neo::as_buffer_convertible B> | |||
requires(!neo::buffer_range<B>) void write(B&& b) noexcept { | |||
auto bufs = {neo::as_buffer(b)}; | |||
_write(bufs); | |||
} | |||
void write(std::initializer_list<neo::const_buffer> bufs) noexcept { return _write(bufs); } | |||
void putc(char c) noexcept { write(std::string_view(&c, 1)); } | |||
void put_style(const text_style&) noexcept; | |||
std::string take_string() noexcept { return std::move(_buf); } | |||
std::string_view string() const noexcept { return _buf; } | |||
auto visual_size() const noexcept { return _vis_size; } | |||
}; | |||
} // namespace fansi |
@@ -0,0 +1,9 @@ | |||
#include <fansi/writer.hpp> | |||
#include <catch2/catch.hpp> | |||
TEST_CASE("Write a string") { | |||
fansi::text_writer wr; | |||
wr.write("foo"); | |||
CHECK(wr.string() == "foo"); | |||
} |