Lector v1.0.0
C++ library for parsing command line arguments.
text.hpp
1// Copyright © 2026, Alexandre Coderre-Chabot.
2
3// This file is part of Lector (https://github.com/acodcha/lector), a C++ library for parsing
4// command line arguments. Lector is licensed under the MIT License (https://mit-license.org).
5
6// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
7// associated documentation files (the "Software"), to deal in the Software without restriction,
8// including without limitation the rights to use, copy, modify, merge, publish, distribute,
9// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
10// furnished to do so, subject to the following conditions:
11// - The above copyright notice and this permission notice shall be included in all copies or
12// substantial portions of the Software.
13// - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
14// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
15// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
16// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM
17// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
19#ifndef LECTOR_TEXT_HPP
20#define LECTOR_TEXT_HPP
21
22#include <algorithm>
23#include <cctype>
24#include <cstddef>
25#include <limits>
26#include <stdexcept>
27#include <string>
28#include <string_view>
29#include <utility>
30#include <vector>
31
32/// @brief The Lector library's namespace.
33namespace lector {
34
35/// @brief Returns whether a given character is the leading byte of a UTF-8 character. All UTF-8
36/// characters measure either one, two, three, or four bytes. UTF-8 characters that measure only one
37/// byte are the ASCII characters. UTF-8 character that measure two, three, or four bytes are
38/// multi-byte characters and consist of a leading byte with a specific binary pattern and one or
39/// more continuation bytes of the binary pattern 10xxxxxx.
40///
41/// 1. One-byte UTF-8 characters are the ASCII characters. Their first bit is 0 and their binary
42/// pattern is therefore 0xxxxxxx.
43///
44/// 2. Two-byte UTF-8 characters have a leading byte with the binary pattern 110xxxxx and one
45/// continuation byte with the binary pattern 10xxxxxx. Together, the two bytes therefore have
46/// the binary pattern 110xxxxx 10xxxxxx.
47///
48/// 3. Three-byte UTF-8 characters have a leading byte with the binary pattern 1110xxxx and two
49/// continuation bytes with the binary pattern 10xxxxxx. Together, the three bytes therefore have
50/// the binary pattern 1110xxxx 10xxxxxx 10xxxxxx.
51///
52/// 4. Four-byte UTF-8 characters have a leading byte with the binary pattern 11110xxx and three
53/// continuation bytes with the binary pattern 10xxxxxx. Together, the four bytes therefore have
54/// the binary pattern 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx.
55/// @param[in] character The character to check.
56/// @return True if the character is a leading byte; false if the character is a continuation byte.
57[[nodiscard]] inline bool is_leading_byte(const char character) {
58 // Cast to an unsigned character to avoid undefined behavior with bitwise operations on signed
59 // characters. The binary pattern 10xxxxxx that identifies a continuation byte ranges from 0x80 to
60 // 0xBF in hexadecimal notation.
61 return (static_cast<unsigned char>(character) & 0xC0) != 0x80;
62}
63
64/// @brief Counts and returns the number of UTF-8 code points in a string of text. The number of
65/// UTF-8 code points is a useful approximation of the number of graphemes in the string, where
66/// ASCII characters and multi-byte UTF-8 characters are each counted as one unit of length.
67/// @param[in] text The string of text whose UTF-8 code points are to be counted.
68/// @return The number of UTF-8 code points in the string of text.
69[[nodiscard]] inline std::size_t code_points(const std::string_view text) {
70 std::size_t count{0UL};
71 for (const char character : text) {
72 if (lector::is_leading_byte(character)) {
73 ++count;
74 }
75 }
76 return count;
77}
78
79/// @brief Finds the exact byte [begin, end) index interval in a string of text where a specified
80/// code point resides.
81/// @param[in] text The string of text to parse.
82/// @param[in] code_point_index The index of the code point in the string of text.
83/// @return A pair that contains the begin and end byte indices of the specified code point. The end
84/// index is the classical C++ "one past the end" index. If the specified code point index is out of
85/// bounds, both returned indices are set to one past the end index of the string, which is the size
86/// of the string.
87[[nodiscard]] inline std::pair<std::size_t, std::size_t> byte_interval(
88 const std::string_view text, const std::size_t code_point_index) {
89 std::size_t current_code_point_index{0UL};
90 std::size_t begin_byte_index{text.size()};
91 for (std::size_t current_byte_index{0UL}; current_byte_index < text.size();
92 ++current_byte_index) {
93 if (lector::is_leading_byte(text.at(current_byte_index))) {
94 if (current_code_point_index == code_point_index) {
95 begin_byte_index = current_byte_index;
96 } else if (current_code_point_index == code_point_index + static_cast<std::size_t>(1UL)) {
97 // In this case, this is the start of the next code point, and therefore the end of the
98 // requested code point.
99 return std::pair<std::size_t, std::size_t>{begin_byte_index, current_byte_index};
100 }
101 ++current_code_point_index;
102 }
103 }
104 if (begin_byte_index < text.size()) {
105 // In this case, the requested code point is found, but it is the last code point in the string.
106 return std::pair<std::size_t, std::size_t>{begin_byte_index, text.size()};
107 }
108 // In this case, the requested code point index is out of bounds.
109 return std::pair<std::size_t, std::size_t>{text.size(), text.size()};
110}
111
112/// @brief Computes and returns the length of the longest word in a string of text. The length of a
113/// word is measured by its number of UTF-8 code points.
114/// @param[in] text The string of text whose longest word length is to be computed.
115/// @return The length of the longest word in the string of text.
116[[nodiscard]] inline std::size_t longest_word_length(const std::string_view text) {
117 std::size_t current_longest_word_length{0UL};
118 std::size_t index{0UL};
119 while (index < text.length()) {
120 // Skip over any whitespaces.
121 while (index < text.length() && std::isspace(static_cast<unsigned char>(text[index])) != 0) {
122 ++index;
123 }
124 // Return if the end of the string has been reached after skipping whitespaces.
125 if (index >= text.length()) {
126 break;
127 }
128 // The index now points to the start of the current word.
129 const std::size_t current_word_start{index};
130 // Find the end of the current word.
131 while (index < text.length() && std::isspace(static_cast<unsigned char>(text[index])) == 0) {
132 ++index;
133 }
134 // Obtain the current word.
135 const std::string_view current_word{
136 text.substr(current_word_start, index - current_word_start)};
137 // Compute the length of the current word.
138 const std::size_t current_word_length{lector::code_points(current_word)};
139 // Update the longest word length.
140 current_longest_word_length = std::max(current_longest_word_length, current_word_length);
141 }
142 return current_longest_word_length;
143}
144
145/// @brief Tokenizes a string of text into a vector of strings of text, where each string in the
146/// vector corresponds to a word in the original string. Words are defined as sequences of
147/// non-whitespace characters, and whitespace characters are used as delimiters. The function does
148/// not modify the original string and returns views into it, so the original string must remain
149/// valid for the lifetime of the returned vector.
150/// @param[in] text The string of text to be tokenized.
151/// @return A vector of strings of text, each corresponding to a word in the original string.
152[[nodiscard]] inline std::vector<std::string_view> tokenize(const std::string_view text) {
153 std::vector<std::string_view> words;
154 std::size_t begin_index{0UL};
155 while (begin_index < text.size()) {
156 while (begin_index < text.size()
157 && std::isspace(static_cast<unsigned char>(text[begin_index])) != 0) {
158 ++begin_index;
159 }
160 if (begin_index == text.size()) {
161 break;
162 }
163 std::size_t end_index{begin_index};
164 while (
165 end_index < text.size() && std::isspace(static_cast<unsigned char>(text[end_index])) == 0) {
166 ++end_index;
167 }
168 words.push_back(text.substr(begin_index, end_index - begin_index));
169 begin_index = end_index;
170 }
171 return words;
172}
173
174/// @brief Joins a vector of strings where each string corresponds to a line of text into a single
175/// string of text, with newline characters inserted between the lines, and the lines left-aligned.
176/// @param[in] lines Vector of strings to be joined and left-aligned.
177/// @return The joined and left-aligned string of text.
178[[nodiscard]] inline std::string join_and_left_align(const std::vector<std::string>& lines) {
179 // Handle the empty case immediately to prevent underflow later.
180 if (lines.empty()) {
181 return std::string{};
182 }
183 // Calculate the exact total size.
184 std::size_t total_size{0UL};
185 for (const std::string& line : lines) {
186 total_size += line.size();
187 }
188 // Add space for the newline separators (one less than the total number of lines).
189 total_size += lines.size() - static_cast<std::size_t>(1UL);
190 // Create and allocate the resulting text.
191 std::string text;
192 text.reserve(total_size);
193 // Append the first line.
194 text.append(lines.front());
195 // Append subsequent lines prefixed by a newline.
196 for (std::size_t line_index{1UL}; line_index < lines.size(); ++line_index) {
197 text.push_back('\n');
198 text.append(lines.at(line_index));
199 }
200 return text;
201}
202
203/// @brief Wraps a string of text to a line length and returns the result as a sequence of strings
204/// of text where each string in the sequence represents one line of text.
205/// @param[in] text The string of text to wrap.
206/// @param[in] line_length The desired line length to use when wrapping. Must be strictly greater
207/// than zero. Very long words whose lengths exceed this line length are hyphenated.
208/// @return The resulting sequence of strings of text that contains one string per line.
209/// @throws std::invalid_argument if the desired line length is zero.
210[[nodiscard]] inline std::vector<std::string> wrap(
211 const std::string_view text, const std::size_t line_length) {
212 // Ensure the line length is valid.
213 if (line_length <= static_cast<std::size_t>(0UL)) {
214 throw std::invalid_argument("Invalid line length. Must be strictly greater than zero.");
215 }
216 // Tokenize the input string of text.
217 const std::vector<std::string_view> words{lector::tokenize(text)};
218 // Process the tokenized input string of text and assemble the wrapped lines.
219 std::vector<std::string> lines;
220 std::string current_line;
221 std::size_t current_line_code_point_size{0UL};
222 for (const std::string_view current_word : words) {
223 // Measure the current word.
224 const std::size_t current_word_code_point_size{lector::code_points(current_word)};
225 const std::size_t space_needed_for_hyphen{
226 (current_line_code_point_size > static_cast<std::size_t>(0UL)) ?
227 static_cast<std::size_t>(1UL) :
228 static_cast<std::size_t>(0UL)};
229 // Check if the current word fits on the current line.
230 if (current_line_code_point_size > static_cast<std::size_t>(0UL)
231 && current_line_code_point_size + current_word_code_point_size + space_needed_for_hyphen
232 <= line_length) {
233 // In this case, the current word fits on the current line.
234 current_line.push_back(' ');
235 current_line.append(current_word);
236 current_line_code_point_size += current_word_code_point_size + space_needed_for_hyphen;
237 } else {
238 // In this case, the current word does not fit on the current line and must be wrapped to the
239 // next line.
240 if (current_line_code_point_size > static_cast<std::size_t>(0UL)) {
241 lines.push_back(std::move(current_line));
242 current_line.clear();
243 current_line_code_point_size = static_cast<std::size_t>(0UL);
244 }
245 // Check if the current word needs to be hyphenated.
246 if (current_word_code_point_size <= line_length) {
247 // In this case, the current word fits completely on an empty line and does not need to be
248 // hyphenated.
249 current_line = current_word;
250 current_line_code_point_size = current_word_code_point_size;
251 } else {
252 // In this case, the current word is too long and must be hyphenated.
253 std::string_view remaining_word{current_word};
254 std::size_t remaining_code_point_size{current_word_code_point_size};
255 // Iterate until the remaining portion of the current word fits on a line, and repeat as
256 // necessary; a very long word might need to be hyphenated multiple times.
257 while (remaining_code_point_size > line_length) {
258 // If the line_length is 1, no hyphen is used. Otherwise, take "line length - 1" code
259 // points to save 1 character for the hyphen.
260 const std::size_t chunk_code_point_size{line_length == static_cast<std::size_t>(1UL) ?
261 static_cast<std::size_t>(1UL) :
262 line_length - static_cast<std::size_t>(1UL)};
263 const std::size_t split_byte_index{
264 lector::byte_interval(remaining_word, chunk_code_point_size).first};
265 std::string split_line(
266 remaining_word.substr(static_cast<std::size_t>(0UL), split_byte_index));
267 if (line_length > static_cast<std::size_t>(1UL)) {
268 split_line.push_back('-');
269 }
270 lines.push_back(std::move(split_line));
271 remaining_word = remaining_word.substr(split_byte_index);
272 remaining_code_point_size -= chunk_code_point_size;
273 }
274 // The remaining slice of the word seeds the subsequent line.
275 if (remaining_code_point_size > static_cast<std::size_t>(0UL)) {
276 current_line = remaining_word;
277 current_line_code_point_size = remaining_code_point_size;
278 }
279 }
280 }
281 }
282 // Push the final built line if it is not empty.
283 if (current_line_code_point_size > static_cast<std::size_t>(0UL)) {
284 lines.push_back(std::move(current_line));
285 }
286 // Return the wrapped lines.
287 return lines;
288}
289
290/// @brief Left-aligns and wraps a string of text to a line length.
291/// @param[in] text The string of text to wrap and left-align.
292/// @param[in] line_length The desired line length to use when wrapping. Must be strictly greater
293/// than zero. Very long words whose lengths exceed this line length are hyphenated.
294/// @return The resulting wrapped and left-aligned string of text.
295/// @throws std::invalid_argument if the desired line length is zero.
296[[nodiscard]] inline std::string wrap_and_left_align(
297 const std::string_view text, const std::size_t line_length) {
298 return lector::join_and_left_align(lector::wrap(text, line_length));
299}
300
301/// @brief Combines two strings of text, each representing a column, into a single vector of strings
302/// that contains one line per string of text, with the lines formatted such that the two columns
303/// are left-aligned and spaced a short distance apart.
304/// @param[in] first_column_text The string of text for the first column.
305/// @param[in] first_column_width The desired width of the first column. Very long words whose
306/// length exceeds this width are hyphenated.
307/// @param[in] second_column_text The string of text for the second column.
308/// @param[in] second_column_width The desired width of the second column. Very long words whose
309/// length exceeds this width are hyphenated.
310/// @return The vector of strings that contains the combined text.
311[[nodiscard]] inline std::string combine_and_left_align(
312 const std::string_view first_column_text, const std::size_t first_column_width,
313 const std::string_view second_column_text, const std::size_t second_column_width) {
314 // Use a gutter width of two spaces.
315 constexpr std::size_t gutter_width{2UL};
316 // Wrap and split both columns.
317 const std::vector<std::string> first_column{lector::wrap(first_column_text, first_column_width)};
318 const std::vector<std::string> second_column{
319 lector::wrap(second_column_text, second_column_width)};
320 // Determine the total number of rows required.
321 const std::size_t rows{std::max(first_column.size(), second_column.size())};
322 // Pre-allocate memory for the result. A safe and highly efficient upper bound is the byte size of
323 // both original input strings, plus the maximum possible padding spaces and newlines per row.
324 std::string result;
325 result.reserve(first_column_text.length() + second_column_text.length()
326 + (rows * (first_column_width + gutter_width + static_cast<std::size_t>(1UL))));
327 // Combine the rows line by line.
328 for (std::size_t row_index{0UL}; row_index < rows; ++row_index) {
329 // Append a newline character for every row after the first to separate them without leaving a
330 // trailing newline at the very end of the string.
331 if (row_index > static_cast<std::size_t>(0UL)) {
332 result.push_back('\n');
333 }
334 // Grab the string for column 1 if it exists on this row; otherwise, use an empty string.
335 const std::string_view first_cell{
336 row_index < first_column.size() ? std::string_view{first_column.at(row_index)} :
337 std::string_view{}};
338 result.append(first_cell);
339 // If column 2 has text on this row, pad column 1 and append column 2. Otherwise, if column 2 is
340 // exhausted, skip this padding to avoid unnecessary trailing whitespace.
341 if (row_index < second_column.size()) {
342 const std::size_t first_cell_length{lector::code_points(first_cell)};
343 const std::size_t padding{first_column_width + gutter_width - first_cell_length};
344 result.append(padding, ' ');
345 result.append(second_column.at(row_index));
346 }
347 }
348 return result;
349}
350
351} // namespace lector
352
353#endif // LECTOR_TEXT_HPP
The Lector library's namespace.
Definition arguments.hpp:45
bool is_leading_byte(const char character)
Returns whether a given character is the leading byte of a UTF-8 character. All UTF-8 characters meas...
Definition text.hpp:57
std::string combine_and_left_align(const std::string_view first_column_text, const std::size_t first_column_width, const std::string_view second_column_text, const std::size_t second_column_width)
Combines two strings of text, each representing a column, into a single vector of strings that contai...
Definition text.hpp:311
std::string wrap_and_left_align(const std::string_view text, const std::size_t line_length)
Left-aligns and wraps a string of text to a line length.
Definition text.hpp:296
std::size_t code_points(const std::string_view text)
Counts and returns the number of UTF-8 code points in a string of text. The number of UTF-8 code poin...
Definition text.hpp:69
std::size_t longest_word_length(const std::string_view text)
Computes and returns the length of the longest word in a string of text. The length of a word is meas...
Definition text.hpp:116
std::pair< std::size_t, std::size_t > byte_interval(const std::string_view text, const std::size_t code_point_index)
Finds the exact byte [begin, end) index interval in a string of text where a specified code point res...
Definition text.hpp:87
std::string join_and_left_align(const std::vector< std::string > &lines)
Joins a vector of strings where each string corresponds to a line of text into a single string of tex...
Definition text.hpp:178
std::vector< std::string_view > tokenize(const std::string_view text)
Tokenizes a string of text into a vector of strings of text, where each string in the vector correspo...
Definition text.hpp:152
std::vector< std::string > wrap(const std::string_view text, const std::size_t line_length)
Wraps a string of text to a line length and returns the result as a sequence of strings of text where...
Definition text.hpp:210