Haio 0.2.X
Homebrew Asset IO/CDN
Loading...
Searching...
No Matches
parser.cpp
Go to the documentation of this file.
1#include <haio_cli.hpp>
3#include <haio_string.hpp>
4
5#include <boost/spirit/home/x3.hpp>
6
7#include <algorithm>
8#include <cctype>
9#include <stdexcept>
10#include <vector>
11
12namespace x3 = boost::spirit::x3;
13
14namespace Haio::Cli {
15namespace {
16
17struct PrefixSpec {
18 std::string prefix;
19 std::string value;
20};
21
22std::string lower(std::string_view value) {
23 std::string out(value);
24 std::ranges::transform(out, out.begin(), [](unsigned char c) {
25 return static_cast<char>(std::tolower(c));
26 });
27 return out;
28}
29
30bool isGeneratorPrefix(std::string_view prefix) {
31 const auto key = lower(prefix);
32 return std::ranges::find(Lexer::generatorKinds, key) != std::ranges::end(Lexer::generatorKinds);
33}
34
35std::optional<Format> knownFormat(std::string_view name) {
36 try {
37 const auto format = formatFromName(name);
38 if (format == Format::RAW && !lower(name).starts_with("raw")) return std::nullopt;
39 return format;
40 } catch (const std::exception&) {
41 return std::nullopt;
42 }
43}
44
45std::optional<PrefixSpec> parsePrefixedSpec(std::string_view token) {
46 PrefixSpec spec;
47 auto first = token.begin();
48 const auto last = token.end();
49
50 const auto prefixRule = x3::lexeme[+(x3::char_ - ':' - '/' - '\\')];
51 const auto valueRule = x3::lexeme[*x3::char_];
52 if (!x3::parse(first, last, prefixRule, spec.prefix)) return std::nullopt;
53 if (!x3::parse(first, last, x3::lit(':'))) return std::nullopt;
54 if (!x3::parse(first, last, valueRule, spec.value) || first != last) return std::nullopt;
55
56 if (spec.prefix.empty() || spec.value.empty()) return spec;
57 if (!knownFormat(spec.prefix) && !isGeneratorPrefix(spec.prefix)) return std::nullopt;
58 return spec;
59}
60
61Format formatFromPathOrRaw(std::string_view path) {
62 try {
63 return formatFromExtension(path);
64 } catch (const std::exception&) {
65 return Format::RAW;
66 }
67}
68
69void setError(Command& command, std::string message, std::string token = {}) {
70 command.error.message = std::move(message);
71 command.error.token = std::move(token);
72}
73
74bool isOption(std::string_view token) {
75 return token.starts_with('-') && token != "-";
76}
77
78std::string unescapeDoubleQuoted(std::string_view text) {
79 std::string out;
80 out.reserve(text.size());
81 for (size_t i = 0; i < text.size(); i++) {
82 if (text[i] == '\\' && i + 1 < text.size()) {
83 out.push_back(text[++i]);
84 } else {
85 out.push_back(text[i]);
86 }
87 }
88 return out;
89}
90
91std::string normalizeLexeme(std::string text) {
92 if (text == "\\(") return "(";
93 if (text == "\\)") return ")";
94 if (text.size() >= 2 && text.front() == '\'' && text.back() == '\'') {
95 return text.substr(1, text.size() - 2);
96 }
97 if (text.size() >= 2 && text.front() == '"' && text.back() == '"') {
98 return unescapeDoubleQuoted(std::string_view(text).substr(1, text.size() - 2));
99 }
100 return text;
101}
102
103TokenType generatorType(std::string_view prefix) {
104 const auto key = lower(prefix);
105 return key == "gradient" || key == "radial-gradient" ? TokenType::GeneratorGradient : TokenType::GeneratorXc;
106}
107
108bool addToken(Command& command, Token token) {
109 constexpr size_t maxTokens = 32;
110 if (command.tokens.size() >= maxTokens) {
111 setError(command, "too many convert tokens", token.value);
112 return false;
113 }
114 command.tokens.push_back(std::move(token));
115 return true;
116}
117
118bool tokenizeGenerator(Command& command, const PrefixSpec& spec, std::string sizeArg, std::string token) {
119 if (command.hasInput) {
120 setError(command, "multiple convert inputs are not supported yet", token);
121 return false;
122 }
123
124 command.hasInput = true;
125 command.hasGenerator = true;
126 return addToken(command, Token{.type = generatorType(spec.prefix), .value = spec.value, .arg = std::move(sizeArg)});
127}
128
129bool tokenizeInput(Command& command, std::string token) {
130 if (command.hasInput) {
131 setError(command, "multiple convert inputs are not supported yet", token);
132 return false;
133 }
134
135 command.hasInput = true;
136 command.inputPath = token;
137 command.inputFormat = formatFromPathOrRaw(token);
138
139 if (const auto spec = parsePrefixedSpec(token); spec && knownFormat(spec->prefix)) {
140 if (spec->value.empty()) {
141 setError(command, "missing path after format prefix", token);
142 return false;
143 }
144 command.inputPath = spec->value;
145 command.inputFormatName = spec->prefix;
146 command.inputFormat = *knownFormat(spec->prefix);
147 }
148
149 return addToken(command, Token{.type = TokenType::InputFile, .value = command.inputPath, .arg = command.inputFormatName, .format = command.inputFormat});
150}
151
152bool tokenizeOutput(Command& command, std::string token) {
153 command.outputPath = token;
154
155 if (const auto spec = parsePrefixedSpec(token); spec && knownFormat(spec->prefix)) {
156 if (spec->value.empty()) {
157 setError(command, "missing path after format prefix", token);
158 return false;
159 }
160 command.outputPath = spec->value;
161 command.outputFormatName = spec->prefix;
162 command.outputFormat = *knownFormat(spec->prefix);
163 } else if (command.outputFormatName.empty()) {
164 command.outputFormat = formatFromPathOrRaw(token);
165 }
166
167 command.outputIsStdout = command.outputPath == "-";
168
169 return addToken(command, Token{.type = TokenType::OutputFile, .value = command.outputPath, .arg = command.outputFormatName, .format = command.outputFormat});
170}
171
172bool tokenizeSource(Command& command, std::string token, std::string pendingSize) {
173 if (const auto spec = parsePrefixedSpec(token); spec && isGeneratorPrefix(spec->prefix)) {
174 return tokenizeGenerator(command, *spec, std::move(pendingSize), std::move(token));
175 }
176
177 /**
178 * -size belongs to a generator, the way imagemagick means it: it is the size
179 * something is created at, and a file already has one. scaling a file is -resize,
180 * which is a different question and says so.
181 */
182 if (!pendingSize.empty()) {
183 setError(command, "-size is the size a generator is created at; use -resize to scale a picture",
184 pendingSize);
185 return false;
186 }
187
188 return tokenizeInput(command, std::move(token));
189}
190
191/**
192 * the words an option takes.
193 *
194 * the table counts them, so this is the same work whatever the option is: what
195 * followed the equals sign, or the next word along. -crop is the one that can do
196 * without, and it looks before it takes, so it never comes through here.
197 */
198std::optional<std::string> valueOf(Command& command, const Lexer::Word& word, size_t& i,
199 std::span<const std::string> args) {
200 if (word.hasValue) return std::string(word.value);
201 if (word.option->args == 0) return std::string{};
202 if (i + 1 >= args.size()) {
203 setError(command, "missing convert option argument", std::string(word.spelling));
204 return std::nullopt;
205 }
206 return args[++i];
207}
208
209bool addCrop(Command& command, std::string value, bool required) {
210 std::optional<Rect> rect;
211 if (!value.empty()) {
212 Rect parsed;
213 if (!parseCropGeometryToken(value, parsed) && !parseRectToken(value, parsed)) {
214 setError(command, "invalid crop geometry", value);
215 return false;
216 }
217 rect = parsed;
218 } else if (required) {
219 setError(command, "missing convert option argument", "-crop");
220 return false;
221 }
222
223 return addToken(command, Token{.type = TokenType::FilterCrop, .value = std::move(value), .format = Format::RAW, .rect = rect});
224}
225
226bool addResize(Command& command, std::string value, std::string option) {
227 // a share first, since "30%" is not a size and never parses as one
228 if (const auto share = String::getPercent(value); share != 0) {
229 return addToken(command, Token{.type = TokenType::FilterResize, .value = std::move(value),
230 .format = Format::RAW, .percent = share});
231 }
232
233 Size size;
234 if (!parseSizeToken(value, size)) {
235 setError(command, "invalid resize size, which is either WxH or a share such as 30% or 30pct",
236 value.empty() ? option : value);
237 return false;
238 }
239 return addToken(command, Token{.type = TokenType::FilterResize, .value = std::move(value), .format = Format::RAW, .size = size});
240}
241
242/** a count, which has to be a whole positive number and nothing else */
243std::optional<size_t> countOf(const std::string& value) {
244 try {
245 const auto number = String::getInt(value);
246 if (number <= 0) return std::nullopt;
247 return static_cast<size_t>(number);
248 } catch (const std::exception&) {
249 return std::nullopt;
250 }
251}
252
253bool addRadius(Command& command, std::string value) {
254 int radius = 0;
255 try {
256 radius = String::getInt(value);
257 } catch (const std::exception&) {
258 setError(command, "invalid radius", value);
259 return false;
260 }
261 return addToken(command, Token{.type = TokenType::FilterRadius, .value = std::move(value), .format = Format::RAW, .radius = radius});
262}
263
264bool addFormat(Command& command, std::string value) {
265 const auto format = knownFormat(value);
266 if (!format) {
267 setError(command, "unknown output format", value);
268 return false;
269 }
270 command.outputFormat = *format;
271 command.outputFormatName = value;
272 return addToken(command, Token{.type = TokenType::FilterFormat, .value = std::move(value), .format = *format});
273}
274
275/**
276 * the colour stored inside the output, which is not the same question as the
277 * container: a tga holds any of six and a ktx2 any of three, and until now the answer
278 * was whichever one the registry happened to try first.
279 */
280bool addPixelFormat(Command& command, std::string value) {
281 const auto color = colorNamed(value);
282 if (!color) {
283 setError(command, "unknown pixel format, which is a colour such as rgba8888, bgr888, rgb565 or yuv420", value);
284 return false;
285 }
286
287 command.outputColor = *color;
288 command.outputColorName = value;
289 return addToken(command, Token{.type = TokenType::FilterPixFmt, .value = std::move(value), .color = *color});
290}
291
292bool addFx(Command& command, std::string value) {
293 return addToken(command, Token{.type = TokenType::FilterFx, .value = std::move(value)});
294}
295
296/**
297 * a setting: it waits for the operation that wants it, and is an error if none comes.
298 * this is what makes the order mean something rather than being decoration.
299 */
300bool addFilter(Command& command, std::string value, std::string option) {
301 if (!Haio::ditherNamed(value)) {
302 command.error = ParseError{"filter takes nearest, bayer, floyd or strict", value};
303 return false;
304 }
305 if (command.pendingFilter) {
306 command.error = ParseError{"this filter replaces one nothing has used yet",
307 command.pendingFilter->value};
308 return false;
309 }
310 command.pendingFilter = Command::Pending{std::move(option), std::move(value)};
311 return true;
312}
313
314/** another setting, and this one is optional rather than required */
315bool addLimit(Command& command, std::string value, std::string option) {
316 /**
317 * the strategy is written out, the same way the filter is.
318 *
319 * "sort:16" and "spread:16" give visibly different pictures of the same
320 * photograph: one spends the budget where there is the most area, the other where
321 * there is the most that is new. there is no answer that is right often enough to
322 * be assumed, and a bare "16" would be haio choosing what somebody's picture
323 * looks like.
324 */
325 const auto colon = std::string_view{value}.find(':');
326 if (colon == std::string_view::npos) {
327 command.error = ParseError{"limit needs to say which colours to keep, "
328 "as in -limit spread:16 or -limit sort:16", value};
329 return false;
330 }
331
332 const auto named = Haio::limitNamed(std::string_view{value}.substr(0, colon));
333 if (!named) {
334 command.error = ParseError{"limit takes sort or spread before its colon", value};
335 return false;
336 }
337
338 const auto most = countOf(std::string(std::string_view{value}.substr(colon + 1)));
339 if (!most) {
340 command.error = ParseError{"limit takes a number of colours, counted from one", value};
341 return false;
342 }
343 if (command.pendingLimit) {
344 command.error = ParseError{"this limit replaces one nothing has used yet",
345 command.pendingLimit->value};
346 return false;
347 }
348
349 command.pendingLimitHow = *named;
350 command.pendingLimit = Command::Pending{std::move(option), std::move(value)};
351 return true;
352}
353
354/** an operation: it takes the settings waiting for it, and needs a filter */
355bool addPalette(Command& command, std::string value) {
356 if (!command.pendingFilter) {
357 command.error = ParseError{"putting a picture into a palette needs a filter first, "
358 "as in -filter bayer before -palete", value};
359 return false;
360 }
361 const auto how = Haio::ditherNamed(command.pendingFilter->value);
362 command.pendingFilter.reset();
363
364 size_t most = 0;
365 auto limitHow = Haio::Limit::Spread;
366 if (command.pendingLimit) {
367 const auto spelled = std::string_view{command.pendingLimit->value};
368 most = *countOf(std::string(spelled.substr(spelled.find(':') + 1)));
369 limitHow = command.pendingLimitHow;
370 command.pendingLimit.reset();
371 }
372
373 return addToken(command, Token{.type = TokenType::FilterPalette, .value = std::move(value),
374 .format = Format::RAW, .dither = *how,
375 .limit = most, .limitHow = limitHow});
376}
377
378/**
379 * -crop is the only option that may be written with nothing after it, so it is the
380 * only one that has to look before it takes.
381 *
382 * what it looks for is a geometry, because the word after it is just as likely to be
383 * the output path: "convert in.png -crop out.ppm" is a crop of everything, and
384 * "convert in.png -crop 10x out.ppm" is a geometry somebody got wrong. the two are
385 * told apart by whether anything else follows.
386 */
387bool readCrop(Command& command, const Lexer::Word& word, size_t& i, std::span<const std::string> args) {
388 std::string value{word.value};
389
390 if (!word.hasValue && i + 1 < args.size() && !isOption(args[i + 1])) {
391 Rect ignored;
392 const std::string_view next = args[i + 1];
393 if (parseCropGeometryToken(next, ignored) || parseRectToken(next, ignored)) {
394 value = args[++i];
395 } else if (i + 2 < args.size()) {
396 setError(command, "invalid crop geometry", std::string(next));
397 return false;
398 }
399 }
400 return addCrop(command, std::move(value), false);
401}
402
403/**
404 * an option, read by the table and then handed to whoever knows what it means.
405 *
406 * everything above the switch is the same for all of them, which is the point of
407 * writing the count down: the parser no longer has to be told, once per option, that
408 * a value may arrive after a space or after an equals sign.
409 */
410bool readOption(Command& command, const Lexer::Word& word, size_t& i,
411 std::span<const std::string> args, std::string& pendingSize) {
412 /**
413 * -size was written for the generator that has to come next, so an option
414 * arriving first is the mistake -- not whatever that option then turns out to be
415 * wrong about, which is a confusing thing to be told instead.
416 */
417 if (!pendingSize.empty() && word.option->kind != Lexer::Opt::Size) {
418 setError(command, "-size is the size a generator is created at; use -resize to scale a picture",
419 pendingSize);
420 return false;
421 }
422
423 if (word.option->kind == Lexer::Opt::Crop) return readCrop(command, word, i, args);
424
425 const auto value = valueOf(command, word, i, args);
426 if (!value) return false;
427
428 const auto option = std::string(word.spelling);
429 switch (word.option->kind) {
430 case Lexer::Opt::Size: pendingSize = *value; return true;
431 case Lexer::Opt::CropRect: return addCrop(command, *value, true);
432 case Lexer::Opt::Resize: return addResize(command, *value, option);
433 case Lexer::Opt::Radius: return addRadius(command, *value);
434 case Lexer::Opt::Format: return addFormat(command, *value);
435 case Lexer::Opt::PixFmt: return addPixelFormat(command, *value);
436 case Lexer::Opt::Filter: return addFilter(command, *value, option);
437 case Lexer::Opt::Limit: return addLimit(command, *value, option);
438 case Lexer::Opt::Palette: return addPalette(command, *value);
439 case Lexer::Opt::Fx: return addFx(command, *value);
440 // taken before the value was read, because its value is optional
441 case Lexer::Opt::Crop: break;
442 }
443 return true;
444}
445
446Command parseTokens(std::span<const std::string> args) {
447 Command command;
448 if (args.size() <= 2) {
449 setError(command, "missing convert input");
450 return command;
451 }
452
453 std::string pendingSize;
454 bool hasOutput = false;
455
456 for (size_t i = 1; i < args.size(); i++) {
457 const std::string_view token = args[i];
458
459 if (const auto word = Lexer::optionOf(token)) {
460 if (!readOption(command, *word, i, args, pendingSize)) return command;
461 continue;
462 }
463
464 if (isOption(token)) {
465 setError(command, "unknown convert option", std::string(token));
466 return command;
467 }
468
469 if (!command.hasInput) {
470 if (!tokenizeSource(command, std::string(token), std::move(pendingSize))) return command;
471 pendingSize.clear();
472 continue;
473 }
474
475 if (!pendingSize.empty()) {
476 setError(command, "-size is the size a generator is created at; use -resize to scale a picture",
477 pendingSize);
478 return command;
479 }
480
481 if (hasOutput) {
482 setError(command, "multiple convert outputs are not supported yet", std::string(token));
483 return command;
484 }
485
486 if (!tokenizeOutput(command, std::string(token))) return command;
487 hasOutput = true;
488 }
489
490 // nothing followed it, so no generator was ever created at that size
491 if (!pendingSize.empty()) {
492 setError(command, "nothing used this -size; it has to come before a generator such as xc:",
493 pendingSize);
494 return command;
495 }
496
497 if (!command.hasInput) {
498 setError(command, "missing convert input");
499 return command;
500 }
501
502 if (!hasOutput) {
503 setError(command, "missing convert output");
504 return command;
505 }
506
507 return command;
508}
509
510} // namespace
511
512ParseError::operator bool() const noexcept {
513 return !message.empty();
514}
515
516bool parseSizeToken(std::string_view text, Size& out) {
517 return String::tryGetSize(text, out);
518}
519
520bool parseCropGeometryToken(std::string_view text, Rect& out) {
521 return String::tryGetCropGeometry(text, out);
522}
523
524bool parseRectToken(std::string_view text, Rect& out) {
525 return String::tryGetRect(text, out);
526}
527
528std::vector<std::string> lexCommandLine(std::string_view text) {
529 std::vector<std::string> args;
530 auto first = text.begin();
531 const auto last = text.end();
532 if (!x3::parse(first, last, Lexer::cmdline, args) || first != last) {
533 throw std::runtime_error("invalid convert command line");
534 }
535
536 std::ranges::transform(args, args.begin(), normalizeLexeme);
537 return args;
538}
539
540Command parseCommandLine(std::string_view text) {
541 return parseTokens(lexCommandLine(text));
542}
543
544Command parseArgs(int argc, char* argv[]) {
545 std::vector<std::string> args;
546 args.reserve(static_cast<size_t>(std::max(argc, 0)));
547 for (int i = 0; i < argc; i++) {
548 args.emplace_back(argv[i]);
549 }
550
551 auto command = parseTokens(args);
552 if (command.error) return command;
553
554 /**
555 * the line is over and something is still waiting to be used.
556 *
557 * it is checked here rather than where it was written, because a setting is not
558 * wrong when it is read: it is wrong only once the line ends without anything
559 * having taken it, which is exactly what "-scale 200% -filter point" does.
560 */
561 for (const auto* waiting : {&command.pendingFilter, &command.pendingLimit}) {
562 if (!*waiting) continue;
563 command.error = ParseError{"nothing used this " + (*waiting)->option
564 + "; it has to come before what it applies to",
565 (*waiting)->value};
566 return command;
567 }
568 return command;
569}
570
571} // namespace Haio::Cli
constexpr std::string_view generatorKinds[]
constexpr std::optional< Word > optionOf(std::string_view token)
bool parseSizeToken(std::string_view text, Size &out)
Definition parser.cpp:516
bool parseCropGeometryToken(std::string_view text, Rect &out)
Definition parser.cpp:520
Command parseCommandLine(std::string_view text)
Definition parser.cpp:540
std::vector< std::string > lexCommandLine(std::string_view text)
Definition parser.cpp:528
Command parseArgs(int argc, char *argv[])
Definition parser.cpp:544
bool parseRectToken(std::string_view text, Rect &out)
Definition parser.cpp:524
bool tryGetRect(std::string_view value, Rect &out)
Definition string.cpp:73
bool tryGetSize(std::string_view value, Size &out)
Definition string.cpp:40
int getPercent(std::string_view value)
Definition string.cpp:52
int getInt(std::string_view value)
Definition string.cpp:34
bool tryGetCropGeometry(std::string_view value, Rect &out)
Definition string.cpp:94
Format formatFromExtension(std::string_view path)
Definition format.cpp:33
constexpr Format formatFromName(std::string_view name)
constexpr std::optional< Color > colorNamed(std::string_view name)
std::optional< Dither > ditherNamed(std::string_view name)
Definition quantise.cpp:56
std::optional< Limit > limitNamed(std::string_view name)
Definition quantise.cpp:64
std::string message
Definition haio_cli.hpp:34