If you want indent to work at compile-time, then you will require N to also be a compile time value, or for indent to be called as part of a constexpr sub-expression.
Since this is for the purpose of streaming to some file-backed stream object FileWrite, the latter is out -- which means that you need N to be at compile-time (e.g. pass it as a template argument).
This will change your signature to:
template <std::size_t N>
consteval auto indent() -> std::string_view
The second part of the problem is that you want this to return a std::string_view. The complication here is that constexpr contexts don't allow static variables -- and thus anything you create within the context will have automatic storage duration. Technically speaking, you can't just simply create an array in the function and return a string_view of it -- since this would lead to a dangling pointer (and thus UB) due to the storage going out-of-scope at the end of the function. So you will need to workaround this.
The easiest way is to use a template of a struct that holds a static array (in this case std::array so we can return it from a function):
template<std::size_t N>
struct indent_string_holder
{
// +1 for a null-terminator.
// The '+1' can be removed since it's not _technically_ needed since
// it's a string_view -- but this can be useful for C interop.
static constexpr std::array<char,N+1> value = make_indent_string<N>();
};
This make_indent_string<N>() is now just a simple wrapper that creates a std::array and fills it with tabs:
// Thanks to @Barry's suggestion to use 'fill' rather than
// index_sequence
template <std::size_t N>
consteval auto make_indent_string() -> std::array<char,N+1>
{
auto result = std::array<char,N+1>{};
result.fill('\t');
result.back() = '\0';
return result;
}
And then indent<N> just becomes a wrapper around the holder:
template <std::size_t N>
consteval auto indent() -> std::string_view
{
const auto& str = indent_string_holder<N>::value;
// -1 on the size if we added the null-terminator.
// This could also be just string_view{str.data()} with the
// terminator
return std::string_view{str.data(), str.size() - 1u};
}
We can do a simple test to see if this works at compile-time, which it should:
static_assert(indent<5>() == "\t\t\t\t\t");
Live Example
If you check the assembly, you will also see that indent<5>() produces the correct compile-time string as desired:
indent_string_holder<5ul>::value:
.asciz "\t\t\t\t\t"
Although this works, it would actually probably be much simpler for you to write indent<N>() in terms of FileWrite (or whatever the base class is -- assuming this is ostream) instead of returning a string_view. Unless you are doing buffered writing to these streams, the cost of writing a few single characters should be minimal compared to the cost of flushing the data -- which should make this negligible.
If this is acceptible, then it would actually be much easier since you can now write it as a recursive function that passes \t to your stream object, and then calls indent<N-1>(...), e.g.:
template <std::size_t N>
auto indent(FileWrite& f) -> FileWrite&
{
if constexpr (N > 0) {
f << '\t'; // Output a single tab
return indent<N-1>(f);
}
return f;
}
This changes the use to now be like:
FileWrite f(path);
f<< "<root>\n"sv;
indent<1>(f) << "<nested1>\n"sv;
indent<2>(f) << "<nested2>\n"sv;
indent<3>(f) << "<nested3>\n"sv;
indent<4>(f) << "<nested4>\n"sv;
But the implementation is much easier to grok and understand IMO, compared to producing a string at compile-time.
Realistically, at this point it might just be cleaner to write:
auto indent(FileWrite& f, std::size_t n) -> FileWrite&
{
for (auto i = 0u; i < n; ++i) { f << '\t'; }
return f;
}
which is likely what most people would expect to read; although it does come at the minimal cost of the loop (provided the optimizer does not unroll this).