Porytiles
Loading...
Searching...
No Matches
attributes_csv_loader.cpp
Go to the documentation of this file.
2
3#include <filesystem>
4#include <format>
5#include <fstream>
6#include <optional>
7#include <string>
8#include <unordered_map>
9#include <unordered_set>
10#include <vector>
11
18
19namespace {
20
21using namespace porytiles;
22
23struct CsvRow {
24 std::size_t metatile_id;
25 std::vector<std::string> field_cells; // one trimmed cell per schema value field, in schema order
26 std::optional<std::string> layer_type_token; // raw layer_type cell, nullopt when the column or cell is blank
27};
28
34[[nodiscard]] std::string expected_header_string(const Schema &schema)
35{
36 std::string header = "id";
37 // TODO: this should probably show pins too. Right now, if I use schema.fields(), the pin column prints without the
38 // "pin::" prefix. In #350 we'll fix the order dependency, and we should introduce a way to fetch pins from the
39 // schema so we can solve the problem here.
40 for (const Field &field : schema.value_fields()) {
41 header += "," + field.name();
42 }
43 return header;
44}
45
52[[nodiscard]] std::optional<std::string>
53extract_layer_type_token(const std::vector<std::string> &columns, std::optional<std::size_t> index)
54{
55 if (!index.has_value() || columns.size() <= index.value()) {
56 return std::nullopt;
57 }
58 std::string token = columns[index.value()];
59 trim(token);
60 if (token.empty()) {
61 return std::nullopt;
62 }
63 return token;
64}
65
71ChainableResult<CsvRow> parse_csv_row(
72 const std::string &line,
73 std::size_t line_index,
74 const std::filesystem::path &path,
75 const std::vector<std::string> &all_lines,
76 const Schema &schema,
77 const TextFormatter &format,
78 const FileHighlightPrinter &file_printer,
79 std::size_t max_columns,
80 std::optional<std::size_t> layer_type_token_index)
81{
82 const std::size_t field_count = schema.value_fields().size();
83 auto columns = split(line, ",");
84
85 if (columns.size() < 1 + field_count) {
86 std::vector<std::string> err_lines{};
87 err_lines.push_back(format.format(
88 "{}:{}: expected at least {} columns ({}), found {}",
89 FormatParam{path.string(), Style::bold},
90 FormatParam{line_index + 1, Style::bold},
91 FormatParam{1 + field_count},
92 FormatParam{expected_header_string(schema)},
93 FormatParam{columns.size()}));
94 err_lines.emplace_back();
95 err_lines.append_range(file_printer.print(all_lines, std::vector{line_index}));
96 return FormattableError{std::move(err_lines)};
97 }
98
99 // The row-level mirror of the header's unexpected-column check: a data row wider than the header shape means the
100 // CSV was written for a wider schema, and its extra cells must fail loudly instead of being silently dropped. The
101 // header derives @p max_columns as id + value fields + the recognized trailing pin columns.
102 if (columns.size() > max_columns) {
103 std::vector<std::string> err_lines{};
104 err_lines.push_back(format.format(
105 "{}:{}: expected at most {} columns, found {}",
106 FormatParam{path.string(), Style::bold},
107 FormatParam{line_index + 1, Style::bold},
108 FormatParam{max_columns},
109 FormatParam{columns.size()}));
110 err_lines.emplace_back();
111 err_lines.append_range(file_printer.print(all_lines, std::vector{line_index}));
112 return FormattableError{std::move(err_lines)};
113 }
114
115 for (std::size_t i = 0; i <= field_count; ++i) {
116 trim(columns[i]);
117 }
118
119 auto id_result = parse_int<int>(columns[0], 0);
120 if (!id_result.has_value()) {
121 std::vector<std::string> err_lines{};
122 err_lines.push_back(format.format(
123 "{}:{}: invalid metatile id '{}': {}",
124 FormatParam{path.string(), Style::bold},
125 FormatParam{line_index + 1, Style::bold},
126 FormatParam{columns[0], Style::bold},
127 FormatParam{id_result.error()}));
128 err_lines.emplace_back();
129 err_lines.append_range(file_printer.print(all_lines, std::vector{line_index}));
130 return FormattableError{std::move(err_lines)};
131 }
132
133 if (id_result.value() < 0) {
134 std::vector<std::string> err_lines{};
135 err_lines.push_back(format.format(
136 "{}:{}: metatile id '{}' cannot be negative",
137 FormatParam{path.string(), Style::bold},
138 FormatParam{line_index + 1, Style::bold},
139 FormatParam{columns[0], Style::bold}));
140 err_lines.emplace_back();
141 err_lines.append_range(file_printer.print(all_lines, std::vector{line_index}));
142 return FormattableError{std::move(err_lines)};
143 }
144
145 std::vector<std::string> field_cells{};
146 field_cells.reserve(field_count);
147 for (std::size_t i = 0; i < field_count; ++i) {
148 field_cells.push_back(columns[1 + i]);
149 }
150
151 // The active layer_type pin column may sit anywhere in the trailing region (a stale pin column can precede it), so
152 // its column index is resolved from the header and passed in rather than assumed adjacent to the fields.
153 return CsvRow{
154 static_cast<std::size_t>(id_result.value()),
155 std::move(field_cells),
156 extract_layer_type_token(columns, layer_type_token_index)};
157}
158
160 const std::filesystem::path &path,
161 const Schema &schema,
162 const ProviderMap &providers,
163 const TextFormatter &format,
164 const FileHighlightPrinter &file_printer,
165 const RolePinDefinitions &role_pins,
166 std::map<FieldRole, bool> &active_pin_column_present,
167 const UserDiagnostics &diag)
168{
169 if (!exists(path)) {
170 return FormattableError{"{}: file does not exist.", FormatParam{path.string(), Style::bold}};
171 }
172
173 const std::string expected_header = expected_header_string(schema);
174
175 // Slurp entire file into vector for FileHighlightPrinter support
176 std::vector<std::string> lines{};
177 {
178 std::ifstream stream{path};
179 std::string line_buf{};
180 while (std::getline(stream, line_buf)) {
181 std::ignore = trim_line_ending(line_buf);
182 lines.push_back(line_buf);
183 }
184 }
185
186 if (lines.empty()) {
187 return FormattableError{
188 "{}: file is empty, expected header '{}'",
189 FormatParam{path.string(), Style::bold},
190 FormatParam{expected_header, Style::bold}};
191 }
192
193 // Cross-check the header line (index 0) against the resolved schema: the columns must be 'id' followed by every
194 // schema field name in schema order, then any number of "pin::<role>" pin columns. Anything missing, mis-ordered,
195 // or extra is a schema mismatch and fails with a diagnostic naming the column and its position.
196 auto header_columns = split(lines[0], ",");
197 for (auto &col : header_columns) {
198 trim(col);
199 }
200
201 const std::size_t field_count = schema.value_fields().size();
202 auto make_header_error = [&](const std::string &message) -> FormattableError {
203 std::vector<std::string> err_lines{};
204 err_lines.push_back(message);
205 err_lines.emplace_back();
206 err_lines.push_back(format.format(
207 "Based on resolved attribute schema, expected header: '{}'", FormatParam{expected_header, Style::bold}));
208 err_lines.emplace_back();
209 err_lines.append_range(file_printer.print(lines, std::vector<std::size_t>{0}));
210 return FormattableError{std::move(err_lines)};
211 };
212
213 for (std::size_t i = 0; i <= field_count; ++i) {
214 const std::string &expected_column = i == 0 ? "id" : schema.value_fields()[i - 1].name();
215 if (i >= header_columns.size()) {
216 return make_header_error(format.format(
217 "{}:{}: invalid header: missing column '{}' at position {}",
218 FormatParam{path.string(), Style::bold},
219 FormatParam{"1", Style::bold},
220 FormatParam{expected_column, Style::bold},
221 FormatParam{i + 1}));
222 }
223 if (header_columns[i] != expected_column) {
224 return make_header_error(format.format(
225 "{}:{}: invalid header: expected column {} to be '{}' but found '{}'",
226 FormatParam{path.string(), Style::bold},
227 FormatParam{"1", Style::bold},
228 FormatParam{i + 1},
229 FormatParam{expected_column, Style::bold},
230 FormatParam{header_columns[i], Style::bold}));
231 }
232 }
233
234 // Classify the trailing columns after the value fields. A column's kind is read off its name, never its position.
235 // In a future update, we'll make it so that the CSV header row is not order dependent. Right now, you can't mix
236 // pins together with the regular fields.
237 std::optional<std::size_t> layer_type_apply_index;
238 std::optional<std::string> ignored_role_pin_column;
239 std::unordered_set<std::string> seen_trailing;
240 for (std::size_t j = 1 + field_count; j < header_columns.size(); ++j) {
241 const std::string &column = header_columns[j];
242
243 if (!seen_trailing.insert(column).second) {
244 return make_header_error(format.format(
245 "{}:{}: invalid header: duplicate column '{}' at position {}",
246 FormatParam{path.string(), Style::bold},
247 FormatParam{"1", Style::bold},
248 FormatParam{column, Style::bold},
249 FormatParam{j + 1}));
250 }
251
252 if (!is_pin_column_name(column)) {
253 // Invalid non-pin column present in the "pin region". Again, we should move away from this artificial
254 // trailing-pin-region concept. For now, we need to error here.
255 return make_header_error(format.format(
256 "{}:{}: invalid header: unexpected column '{}' at position {}",
257 FormatParam{path.string(), Style::bold},
258 FormatParam{"1", Style::bold},
259 FormatParam{column, Style::bold},
260 FormatParam{j + 1}));
261 }
262
263 const auto role = role_from_pin_column_name(column);
264 if (!role.has_value()) {
265 return make_header_error(format.format(
266 "{}:{}: invalid header: pin column '{}' at position {} names no known role; the only role is '{}'",
267 FormatParam{path.string(), Style::bold},
268 FormatParam{"1", Style::bold},
269 FormatParam{column, Style::bold},
270 FormatParam{j + 1},
271 FormatParam{to_string(FieldRole::layer_type), Style::bold}));
272 }
273
274 if (find_role_pin(role_pins, role.value()) == nullptr) {
275 // A well-formed pin column for a role that is not currently pinned: a stale column left behind after the
276 // pin was turned off. Its cells are ignored, with a one-time warning.
277 if (!ignored_role_pin_column.has_value()) {
278 ignored_role_pin_column = column;
279 }
280 continue;
281 }
282
283 switch (role.value()) {
284 case FieldRole::layer_type:
285 layer_type_apply_index = j;
286 break;
287 }
288 }
289
290 const std::size_t max_columns = header_columns.size();
291
292 // Record, per configured role, whether its active pin column was present in the header. The reader maps this onto
293 // the component so the decompiler's round-trip merge knows whether to preserve prior pin state (column present) or
294 // pin every row (column absent). A stale ignored column does not count as present.
295 for (const RolePinDefinition &pin : role_pins) {
296 active_pin_column_present[pin.role] = (pin.role == FieldRole::layer_type) && layer_type_apply_index.has_value();
297 }
298
299 // A stale pin column is present but not active: ignore its values and say so once for the whole file.
300 if (ignored_role_pin_column.has_value()) {
301 std::vector<std::string> warn_lines{};
302 warn_lines.push_back(format.format(
303 "{}: a '{}' column is present but no active role pin uses it. Its values are ignored and the layer type "
304 "will be inferred.",
305 FormatParam{path.string(), Style::bold},
306 FormatParam{ignored_role_pin_column.value(), Style::bold}));
307 warn_lines.push_back(format.format(
308 "Add a {} entry for the layer_type role to apply the column, or remove the column.",
309 FormatParam{"fieldmap.role_pins", Style::bold}));
310 diag.warning("role-pin-column", warn_lines);
311 }
312
313 // Parse data rows (starting at index 1)
314 std::map<std::size_t, MetatileAttribute> result{};
315 std::unordered_map<std::size_t, std::size_t> id_to_line_index{};
316
317 // Applies a filled layer_type cell as an explicit override. The token is only populated from the active pin
318 // column, so a nullopt token (blank cell, or no active column) leaves the layer type inferred. A bad token is a
319 // hard error with file context.
320 auto apply_explicit_layer_type =
321 [&](MetatileAttribute &attribute, const CsvRow &row, std::size_t line_index) -> ChainableResult<void> {
322 if (!row.layer_type_token.has_value()) {
323 return {};
324 }
325 auto layer_type = layer_type_from_csv_token(row.layer_type_token.value());
326 if (!layer_type.has_value()) {
327 std::vector<std::string> err_lines{};
328 err_lines.push_back(format.format(
329 "{}:{}: invalid layer_type '{}'",
330 FormatParam{path.string(), Style::bold},
331 FormatParam{line_index + 1, Style::bold},
332 FormatParam{row.layer_type_token.value(), Style::bold}));
333 err_lines.emplace_back();
334 err_lines.append_range(file_printer.print(lines, std::vector{line_index}));
335 return ChainableResult<void>{FormattableError{std::move(err_lines)}, layer_type};
336 }
337 attribute.explicit_layer_type(layer_type.value());
338 return {};
339 };
340
341 // Resolves one field cell to its numeric value. A provider-backed field goes through its provider (the ProviderMap
342 // membership contract makes a missing provider an internal bug, not a raw fallback); a raw field parses as an
343 // unsigned integer capped at the field's maximum, which the binary writer would otherwise silently mask away.
344 auto resolve_field_cell =
345 [&](const Field &field, const std::string &cell, std::size_t line_index) -> ChainableResult<std::uint32_t> {
346 if (field.has_provider()) {
347 const auto provider_it = providers.find(field.name());
348 if (provider_it == providers.end()) {
349 panic(
350 std::format(
351 "parse_attributes_csv: field '{}' has a provider spec but no provider was built for it",
352 field.name()));
353 }
354 auto lookup_result = provider_it->second->lookup(cell);
355 if (!lookup_result.has_value()) {
356 std::vector<std::string> err_lines{};
357 err_lines.push_back(format.format(
358 "{}:{}: unknown {} '{}'",
359 FormatParam{path.string(), Style::bold},
360 FormatParam{line_index + 1, Style::bold},
361 FormatParam{field.name()},
362 FormatParam{cell, Style::bold}));
363 err_lines.emplace_back();
364 err_lines.append_range(file_printer.print(lines, std::vector{line_index}));
365 return ChainableResult<std::uint32_t>{FormattableError{std::move(err_lines)}, lookup_result};
366 }
367 return lookup_result.value();
368 }
369
370 auto int_result = parse_int<long long>(cell, 0);
371 if (!int_result.has_value() || int_result.value() < 0) {
372 std::vector<std::string> err_lines{};
373 err_lines.push_back(format.format(
374 "{}:{}: invalid {} value '{}': expected an unsigned integer",
375 FormatParam{path.string(), Style::bold},
376 FormatParam{line_index + 1, Style::bold},
377 FormatParam{field.name()},
378 FormatParam{cell, Style::bold}));
379 err_lines.emplace_back();
380 err_lines.append_range(file_printer.print(lines, std::vector{line_index}));
381 return FormattableError{std::move(err_lines)};
382 }
383 if (int_result.value() > static_cast<long long>(field.max_value())) {
384 std::vector<std::string> err_lines{};
385 err_lines.push_back(format.format(
386 "{}:{}: {} value '{}' exceeds the field's maximum of {}",
387 FormatParam{path.string(), Style::bold},
388 FormatParam{line_index + 1, Style::bold},
389 FormatParam{field.name()},
390 FormatParam{cell, Style::bold},
391 FormatParam{field.max_value(), Style::bold}));
392 err_lines.emplace_back();
393 err_lines.append_range(file_printer.print(lines, std::vector{line_index}));
394 return FormattableError{std::move(err_lines)};
395 }
396 return static_cast<std::uint32_t>(int_result.value());
397 };
398
399 for (std::size_t line_index = 1; line_index < lines.size(); ++line_index) {
400 const auto &line = lines[line_index];
401
402 if (line.empty()) {
403 continue;
404 }
405
406 ChainableResult<CsvRow> row_result = parse_csv_row(
407 line, line_index, path, lines, schema, format, file_printer, max_columns, layer_type_apply_index);
408
409 if (!row_result.has_value()) {
411 FormattableError{"Failed to parse CSV row."}, row_result};
412 }
413
414 const auto &row = row_result.value();
415
416 if (id_to_line_index.contains(row.metatile_id)) {
417 const std::size_t original_line_index = id_to_line_index.at(row.metatile_id);
418 std::vector<std::string> err_lines{};
419
420 // Header for duplicate location
421 err_lines.push_back(format.format(
422 "{}:{}: duplicate metatile id '{}'",
423 FormatParam{path.string(), Style::bold},
424 FormatParam{line_index + 1, Style::bold},
425 FormatParam{row.metatile_id, Style::bold}));
426 err_lines.emplace_back();
427
428 // File context for duplicate
429 err_lines.append_range(file_printer.print(lines, std::vector{line_index}));
430 err_lines.emplace_back();
431
432 // Note about original location
433 err_lines.push_back(format.format(
434 "{} originally defined at line {}:",
435 FormatParam{"note:", Style::cyan | Style::bold},
436 FormatParam{original_line_index + 1}));
437
438 // File context for original
439 err_lines.append_range(file_printer.print(lines, std::vector{original_line_index}));
440
441 return FormattableError{std::move(err_lines)};
442 }
443 id_to_line_index.emplace(row.metatile_id, line_index);
444
445 MetatileAttribute attribute{};
446 attribute.layer_type(LayerType::normal);
447 for (std::size_t i = 0; i < field_count; ++i) {
448 const Field &field = schema.value_fields()[i];
449 auto value_result = resolve_field_cell(field, row.field_cells[i], line_index);
450 if (!value_result.has_value()) {
452 FormattableError{"Failed to resolve CSV field cell."}, value_result};
453 }
454 attribute.field(field.name(), value_result.value());
455 }
456 if (const auto applied = apply_explicit_layer_type(attribute, row, line_index); !applied.has_value()) {
458 FormattableError{"Failed to apply layer_type override."}, applied};
459 }
460 result.emplace(row.metatile_id, std::move(attribute));
461 }
462
463 return result;
464}
465
466} // namespace
467
468namespace porytiles {
469
471 const std::filesystem::path &path,
472 const Schema &schema,
473 const ProviderMap &providers,
474 const std::string &tileset_name) const
475{
476 // Resolve the role pins under the file's owning tileset scope. When compiling a secondary, the paired primary's CSV
477 // loads through this same loader with the primary's name, so its config resolves under the primary's scope. (The
478 // result type has a comma, so it cannot go through the PT_TRY_ASSIGN macros; unwrap by hand.)
479 auto role_pins_cv = config_->role_pins(ConfigScopeType::tileset, tileset_name);
480 if (!role_pins_cv.has_value()) {
481 return ChainableResult<AttributesCsvLoadResult>{FormattableError{"Failed to resolve role_pins."}, role_pins_cv};
482 }
483 const RolePinDefinitions &role_pins = role_pins_cv.value();
484
485 std::map<FieldRole, bool> active_pin_column_present;
486 auto parsed = parse_attributes_csv(
487 path, schema, providers, *format_, *file_printer_, role_pins, active_pin_column_present, *diag_);
488 if (!parsed.has_value()) {
489 return ChainableResult<AttributesCsvLoadResult>{FormattableError{"Failed to load attributes CSV."}, parsed};
490 }
491
492 return AttributesCsvLoadResult{std::move(parsed).value(), std::move(active_pin_column_present)};
493}
494
495} // namespace porytiles
ChainableResult< AttributesCsvLoadResult > load(const std::filesystem::path &path, const Schema &schema, const ProviderMap &providers, const std::string &tileset_name) const
Loads metatile attributes from a CSV file.
A result type that maintains a chainable sequence of errors for debugging and error reporting.
T & value() &
Returns a reference to the contained success value.
bool has_value() const
Checks whether the result contains a success value.
One named bit-field within a metatile attribute layout.
const std::string & name() const
std::uint32_t max_value() const
Returns the largest value the field can hold.
A service for printing file lines with highlighted lines and line numbers.
std::vector< std::string > print(const std::vector< std::string > &lines, const std::vector< std::size_t > &line_indices_to_highlight, std::size_t window_size=9) const
Prints lines with specified lines highlighted and line numbers shown.
A text parameter with associated styling for formatted output.
General-purpose error implementation with formatted message support.
Definition error.hpp:57
ChainableResult< ConfigValue< RolePinDefinitions > > role_pins(ConfigScopeType type, const std::string &scope) const
The attributes of a single metatile, modeled as a map of named field values.
const std::optional< LayerType > & explicit_layer_type() const
Returns the explicit (user-pinned) layer type, if one was set.
std::uint32_t field(std::string_view field_name) const
Returns the value of a named field, or 0 if the field is absent.
A validated metatile attribute layout: an ordered set of non-overlapping fields.
const std::vector< Field > & value_fields() const
Returns the fields that hold plain per-metatile values, excluding the layer_type-role field.
static const Style bold
Bold text formatting.
Abstract base class for applying text styling with context-aware formatting.
virtual std::string format(const std::string &format_str, const std::vector< FormatParam > &params) const
Formats a string with styled parameters using fmtlib syntax.
Abstract class for structured error reporting and diagnostic output.
virtual void warning(const std::string &tag, const std::vector< std::string > &lines) const =0
Display a tagged warning message.
bool is_pin_column_name(const std::string &name)
Reports whether a column name sits in the reserved pin namespace.
const RolePinDefinition * find_role_pin(const RolePinDefinitions &definitions, FieldRole role)
Finds the role pin definition for a given role, or nullptr when the role is not pinned.
std::vector< RolePinDefinition > RolePinDefinitions
An ordered list of role pin definitions; order is display/declaration order (also CSV column order).
void panic(const StringViewSourceLoc &s)
Unconditionally terminates the program with a panic message.
Definition panic.cpp:43
std::string & trim_line_ending(std::string &line)
Removes line ending characters from a string in-place.
ChainableResult< LayerType > layer_type_from_csv_token(const std::string &token)
Parses a CSV layer_type token into a LayerType.
Definition layer.hpp:150
std::map< std::string, std::unique_ptr< EnumMapProvider >, std::less<> > ProviderMap
Maps schema field names to the provider that names that field's values.
std::optional< FieldRole > role_from_pin_column_name(const std::string &name)
Parses the role out of a pin column name.
@ tileset
Configuration scoped to a specific tileset.
@ split
Split the animation into separate animations for each palette variant (not yet implemented).
void trim(std::string &string)
Removes leading and trailing whitespace from a string in-place.
Utility functions for string manipulation and formatting.
The result of loading an attributes CSV: the per-metatile attributes plus the per-role pin-column.
A user request to emit a trailing pin column for one schema role in attributes.csv.