Skip to main content

razeri_plugin_contract/
number_format.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **How a source writes an amount** — the picture a design states, read once
6//! and handed to every engine as a small set of facts rather than as text.
7//!
8//! A file writes a price `$2,899.99 `. The dollar sign is not part of the
9//! number, the comma is not a decimal point, and the trailing space is not
10//! anything at all. Handed to an engine as it stands, that text converts to
11//! nothing on one engine, to `2` on another and to an error on a third. So the
12//! design says how the source writes it, and this is what that statement is.
13//!
14//! ## The picture
15//!
16//! A format is written the way a spreadsheet writes one: the value with its
17//! digits replaced by digit places.
18//!
19//! ```text
20//! $#,##0.00      $2,899.99        a dollar sign, thousands in groups of
21//!                                 three, two places after the point
22//! #,##0          2,899            a whole number written in groups
23//! 0.000          1.158            three places after the point and no
24//!                                 grouping
25//! #,##0.00 kr    2 899,00 kr      no — see below
26//! ```
27//!
28//! - `0` and `#` are digit places. Both mean *digits go here*; a reader does
29//!   not care which, and the two are allowed side by side because that is how
30//!   the picture is normally written.
31//! - `,` is the thousands mark and `.` is the decimal point. They mean that
32//!   whichever characters the picture uses, so a picture writing them the
33//!   other way round is refused rather than read backwards. A source that
34//!   writes `2.899,99` is not covered today, and the refusal says so.
35//! - Everything before the first digit place is the symbol that opens the
36//!   value, and everything after the last digit place is the symbol that
37//!   closes it. Either may be empty.
38//! - A space around the whole value is always allowed and never written in
39//!   the picture. `$2,899.99 ` and `$2,899.99` are the same value.
40//! - A minus sign in front of the value is always allowed. A source that
41//!   writes a negative in brackets instead says so with a second picture
42//!   after a semicolon: `$#,##0.00;($#,##0.00)`.
43//!
44//! ## What an engine does with it
45//!
46//! The picture names the marks that are not part of the number. A reader takes
47//! those marks out and converts what is left, so the work every engine has to
48//! do is the same work, and the value that lands is the same value. What
49//! differs between engines is only how each one spells *take this out* and
50//! *convert this, and answer nothing if you cannot* —
51//! [`read_with_safe_cast`], [`read_with_pattern_guard`] and
52//! [`read_with_digit_check`] are those three answers, written once here so an
53//! engine states its own vocabulary rather than its own rules.
54
55use std::fmt;
56
57use crate::DataType;
58use crate::sid::normalize_trim;
59use crate::sid_spelling::{Concat, SidSpelling};
60
61/// How the source writes a value below zero.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Negatives {
64    /// A minus sign in front of the value — `-2,899.99`. The ordinary case,
65    /// and what a picture with no second half means.
66    Minus,
67    /// Brackets around the whole value — `($2,899.99)`, the way a ledger
68    /// writes one. Stated with a second picture after a semicolon.
69    Parentheses,
70}
71
72/// What is wrong with a picture, in the words the refusal uses.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum NumberFormatError {
75    /// A picture saying nothing at all.
76    Empty,
77    /// A picture with no digit place in it.
78    NoDigits,
79    /// A thousands mark after the decimal point.
80    MarkAfterPoint,
81    /// More than one decimal point.
82    TwoPoints,
83    /// A thousands mark with something other than three digit places after it.
84    GroupIsNotThree(usize),
85    /// A digit place after the symbol that closes the value.
86    DigitsAfterSymbol,
87    /// More than two pictures separated by semicolons.
88    TooManyPictures,
89    /// A second picture that is not the first one in brackets.
90    NegativeIsNotBracketed,
91}
92
93impl fmt::Display for NumberFormatError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            Self::Empty => f.write_str(
97                "the format is empty. Write how the source writes the value, with its digits \
98                 replaced by digit places: `$#,##0.00` for `$2,899.99`, `0.000` for `1.158`",
99            ),
100            Self::NoDigits => f.write_str(
101                "the format holds no digit place, so there is no number in it. A digit place is \
102                 `0` or `#`: `$#,##0.00` for `$2,899.99`",
103            ),
104            Self::MarkAfterPoint => f.write_str(
105                "the format puts a `,` after the `.`. In a picture `,` is the thousands mark and \
106                 `.` is the decimal point, whichever characters the source itself uses, so the \
107                 thousands come first. A source that writes `2.899,99` is not covered today",
108            ),
109            Self::TwoPoints => f.write_str(
110                "the format has two decimal points, and a number has one. If one of them is the \
111                 thousands mark, write it as `,`: `$#,##0.00` is two thousand eight hundred and \
112                 ninety-nine point nine nine",
113            ),
114            Self::GroupIsNotThree(places) => write!(
115                f,
116                "the format puts {places} digit {} after a `,`, and a thousands mark \
117                 separates groups of three. Write `#,##0` for a number written `2,899`",
118                if *places == 1 { "place" } else { "places" }
119            ),
120            Self::DigitsAfterSymbol => f.write_str(
121                "the format has digit places on both sides of the symbol. The symbol opens the \
122                 value or closes it (`$#,##0.00` or `#,##0.00 kr`) and a number written in two \
123                 halves is not covered",
124            ),
125            Self::TooManyPictures => f.write_str(
126                "the format holds more than two pictures. One picture says how the source writes \
127                 a value; a second, after a `;`, says how it writes a negative one",
128            ),
129            Self::NegativeIsNotBracketed => f.write_str(
130                "the second picture is not the first one in brackets. It says the source writes a \
131                 negative the way a ledger does, so it is the first picture inside `(` and `)`: \
132                 `$#,##0.00;($#,##0.00)`. A source that writes a leading minus needs no second \
133                 picture at all",
134            ),
135        }
136    }
137}
138
139impl std::error::Error for NumberFormatError {}
140
141/// **How a source writes an amount, read once.**
142///
143/// Built from the authored picture and never rebuilt: every engine is handed
144/// the same facts, so two engines cannot disagree about what the design said.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct NumberFormat {
147    spelled: String,
148    opens_with: String,
149    closes_with: String,
150    grouped: bool,
151    decimals: usize,
152    negatives: Negatives,
153}
154
155/// The characters a picture reserves for itself.
156const DIGIT_PLACES: [char; 2] = ['#', '0'];
157const THOUSANDS_MARK: char = ',';
158const DECIMAL_POINT: char = '.';
159
160impl NumberFormat {
161    /// Read a picture, or say what is wrong with it.
162    ///
163    /// # Errors
164    ///
165    /// Nothing at all, no digit place, a thousands mark in the wrong place or
166    /// with the wrong number of places after it, two decimal points, digits on
167    /// both sides of the symbol, or a second picture that is not the first one
168    /// in brackets.
169    pub fn parse(spelled: &str) -> Result<Self, NumberFormatError> {
170        if spelled.is_empty() {
171            return Err(NumberFormatError::Empty);
172        }
173        let mut pictures = spelled.split(';');
174        let positive = pictures.next().unwrap_or_default();
175        let negative = pictures.next();
176        if pictures.next().is_some() {
177            return Err(NumberFormatError::TooManyPictures);
178        }
179        let mut read = Self::read_one(positive)?;
180        if let Some(negative) = negative {
181            if negative != format!("({positive})") {
182                return Err(NumberFormatError::NegativeIsNotBracketed);
183            }
184            read.negatives = Negatives::Parentheses;
185        }
186        spelled.clone_into(&mut read.spelled);
187        Ok(read)
188    }
189
190    /// One picture — the symbol that opens the value, the number, the symbol
191    /// that closes it.
192    fn read_one(picture: &str) -> Result<Self, NumberFormatError> {
193        let is_body = |c: char| DIGIT_PLACES.contains(&c) || c == THOUSANDS_MARK;
194        let first =
195            picture.find(|c| DIGIT_PLACES.contains(&c)).ok_or(NumberFormatError::NoDigits)?;
196        let opens_with = picture[..first].to_owned();
197        let rest = &picture[first..];
198
199        // The number runs to the end of the digit places, the thousands marks
200        // and the one decimal point with its places.
201        let mut body = String::new();
202        let mut seen_point = false;
203        let mut closes_at = rest.len();
204        for (at, character) in rest.char_indices() {
205            if is_body(character) {
206                if seen_point && character == THOUSANDS_MARK {
207                    return Err(NumberFormatError::MarkAfterPoint);
208                }
209                body.push(character);
210                continue;
211            }
212            if character == DECIMAL_POINT {
213                if seen_point {
214                    return Err(NumberFormatError::TwoPoints);
215                }
216                seen_point = true;
217                body.push(character);
218                continue;
219            }
220            closes_at = at;
221            break;
222        }
223        let closes_with = rest[closes_at..].to_owned();
224        if closes_with.contains(DIGIT_PLACES) {
225            return Err(NumberFormatError::DigitsAfterSymbol);
226        }
227
228        let (whole, fraction) = match body.split_once(DECIMAL_POINT) {
229            Some((whole, fraction)) => (whole, fraction),
230            None => (body.as_str(), ""),
231        };
232        if fraction.contains(THOUSANDS_MARK) {
233            return Err(NumberFormatError::MarkAfterPoint);
234        }
235        let grouped = whole.contains(THOUSANDS_MARK);
236        if grouped {
237            for group in whole.split(THOUSANDS_MARK).skip(1) {
238                if group.chars().count() != 3 {
239                    return Err(NumberFormatError::GroupIsNotThree(group.chars().count()));
240                }
241            }
242        }
243
244        Ok(Self {
245            spelled: picture.to_owned(),
246            opens_with,
247            closes_with,
248            grouped,
249            decimals: fraction.chars().count(),
250            negatives: Negatives::Minus,
251        })
252    }
253
254    /// The picture as the design wrote it — what a refusal quotes back.
255    #[must_use]
256    pub fn spelled(&self) -> &str {
257        &self.spelled
258    }
259
260    /// The symbol in front of the value, empty where there is none.
261    #[must_use]
262    pub fn opens_with(&self) -> &str {
263        &self.opens_with
264    }
265
266    /// The symbol after the value, empty where there is none.
267    #[must_use]
268    pub fn closes_with(&self) -> &str {
269        &self.closes_with
270    }
271
272    /// Whether the source writes the thousands in groups.
273    #[must_use]
274    pub fn grouped(&self) -> bool {
275        self.grouped
276    }
277
278    /// How many places the source writes after the decimal point.
279    #[must_use]
280    pub fn decimals(&self) -> usize {
281        self.decimals
282    }
283
284    /// How the source writes a value below zero.
285    #[must_use]
286    pub fn negatives(&self) -> Negatives {
287        self.negatives
288    }
289
290    /// **The marks that are not part of the number**, in the order a reader
291    /// takes them out.
292    ///
293    /// The symbol that opens the value, the symbol that closes it, the
294    /// thousands mark, and the brackets where the source writes a negative in
295    /// them. What is left is digits, at most one decimal point and at most a
296    /// leading minus.
297    #[must_use]
298    pub fn marks(&self) -> Vec<String> {
299        let mut marks = Vec::new();
300        if !self.opens_with.is_empty() {
301            marks.push(self.opens_with.clone());
302        }
303        if !self.closes_with.is_empty() {
304            marks.push(self.closes_with.clone());
305        }
306        if self.grouped {
307            marks.push(THOUSANDS_MARK.to_string());
308        }
309        if self.negatives == Negatives::Parentheses {
310            marks.push("(".to_owned());
311            marks.push(")".to_owned());
312        }
313        marks
314    }
315
316    /// **The shape a value has to have**, as a regular expression, for an
317    /// engine whose own conversion raises rather than answering nothing.
318    ///
319    /// It is built from the same picture the marks come from, so the guard and
320    /// the reading cannot hold two opinions about one spelling. The value is
321    /// matched with its surrounding spaces already gone.
322    #[must_use]
323    pub fn shape_pattern(&self) -> String {
324        let digits = if self.grouped { "[0-9][0-9,]*" } else { "[0-9]+" };
325        let fraction = if self.decimals > 0 { "([.][0-9]+)?" } else { "" };
326        let (open, close) = match self.negatives {
327            Negatives::Minus => ("-?", ""),
328            Negatives::Parentheses => ("[(]?", "[)]?"),
329        };
330        format!(
331            "^{open}{}{digits}{fraction}{}{close}$",
332            optional_literal(&self.opens_with),
333            optional_literal(&self.closes_with),
334        )
335    }
336}
337
338impl fmt::Display for NumberFormat {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        f.write_str(&self.spelled)
341    }
342}
343
344/// One literal run, made safe for a regular expression and made optional.
345///
346/// Optional because a file that writes `$6.62` on most lines and `6.62` on one
347/// has still written a price on both, and a reader that demanded the symbol
348/// would set aside a row nothing is wrong with.
349fn optional_literal(text: &str) -> String {
350    if text.is_empty() {
351        return String::new();
352    }
353    let mut pattern = String::new();
354    for character in text.chars() {
355        if character.is_ascii_alphanumeric() {
356            pattern.push(character);
357        } else {
358            pattern.push('[');
359            pattern.push(character);
360            pattern.push(']');
361        }
362    }
363    format!("({pattern})?")
364}
365
366/// **Whether this is a type an amount can land in.**
367///
368/// A picture says how a number is written down. On a name or a day there is
369/// nothing for it to be about, so an engine asked to read one into anything
370/// else states nothing and razeri refuses the column by name.
371#[must_use]
372pub fn is_a_number(target: &DataType) -> bool {
373    matches!(target, DataType::Integer(_) | DataType::Decimal(_) | DataType::Float(_))
374}
375
376/// **The value with the picture's marks taken out**, in this engine's own
377/// spelling of `TRIM` and `REPLACE`.
378///
379/// Those two words are already written down per engine, on the vocabulary the
380/// surrogate key is derived through, and a second table of the same two words
381/// would be a second answer to one question.
382///
383/// Parameters:
384/// - `format`: the picture the design stated;
385/// - `spelling`: this engine's vocabulary;
386/// - `expr`: an already-rendered text expression.
387#[must_use]
388pub fn strip_marks(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> String {
389    let replace = spelling.lower.replace;
390    let prefix = spelling.lower.pattern_literal_prefix;
391    format.marks().iter().fold(normalize_trim(spelling, expr), |text, mark| {
392        format!("{replace}({text}, {prefix}'{}', {prefix}'')", mark.replace('\'', "''"))
393    })
394}
395
396/// **The one thing the picture's marks cannot come out of a value without.**
397///
398/// Taking the thousands mark out of `2.899,99` leaves `2.89999`, which
399/// converts on every engine to a price nobody wrote — the worst answer
400/// available, because it is a number and it is wrong. The picture says the
401/// thousands come before the decimal point, so a value writing them after it
402/// is not written that way, and this is that sentence as a condition.
403///
404/// `LIKE` and nothing else, because this has to hold on the engines with no
405/// way to match a pattern as much as on the ones that have. `None` where the
406/// picture names no thousands mark: there is nothing to take out of the wrong
407/// place.
408fn separators_in_order(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Option<String> {
409    if !format.grouped() {
410        return None;
411    }
412    let prefix = spelling.lower.pattern_literal_prefix;
413    Some(format!(
414        "{} NOT LIKE {prefix}'%{DECIMAL_POINT}%{THOUSANDS_MARK}%'",
415        normalize_trim(spelling, expr)
416    ))
417}
418
419/// **How an engine asks whether a text holds a character of a class**, for
420/// [`read_with_safe_cast`] and [`read_with_digit_check`].
421///
422/// The readers hold a value to the picture's shape. Flat, each rule is one
423/// test beside the next, each naming the value once and nesting nothing, so a
424/// value is no deeper than its marks' own `REPLACE`s: an engine whose parser
425/// holds a hundred levels — `SQLite` 3.45's — reads a wide table. Two of the
426/// rules need a class of characters (a digit; anything but a digit, a point
427/// or a minus), and that is what an engine is asked for here.
428#[derive(Clone, Copy)]
429pub enum ClassTest<'a> {
430    /// **The engine's own test**: given a text and a bracket class — `[0-9]`
431    /// or `[^0-9.-]`, the spelling `GLOB`, SQL Server's `LIKE` and every
432    /// regular expression share — a condition true where the text holds at
433    /// least one character of the class: `x GLOB '*[0-9]*'`,
434    /// `x LIKE '%[0-9]%'`, `REGEXP_LIKE(x, '[0-9]')`.
435    Flat(&'a dyn Fn(&str, &str) -> String),
436    /// **No such test**: the value's digits are folded with `REPLACE` into
437    /// one shape and that is asked with `LIKE` alone, about twenty calls deep
438    /// over the value. Ask for it only where the engine has neither a bracket
439    /// class nor a regular expression.
440    Folded,
441}
442
443/// **The picture's shape as flat tests** — the rules [`shape_conditions`]
444/// states, each asked on its own: the symbol in `LIKE`'s words over the
445/// value, and the digits, the point and the minus over the value with its
446/// marks out, two of them through the engine's class test.
447fn flat_shape_conditions(
448    format: &NumberFormat,
449    spelling: SidSpelling,
450    expr: &str,
451    holds: &dyn Fn(&str, &str) -> String,
452) -> Vec<String> {
453    let prefix = spelling.lower.pattern_literal_prefix;
454    let value = normalize_trim(spelling, expr);
455    let stripped = strip_marks(format, spelling, expr);
456    let not_like = |subject: &str, pattern: &str| {
457        format!("{subject} NOT LIKE {prefix}'{}'", pattern.replace('\'', "''"))
458    };
459    let mut conditions = symbol_conditions(format, spelling, &value);
460    conditions.push(holds(&stripped, "[0-9]"));
461    conditions.push(format!("NOT {}", holds(&stripped, "[^0-9.-]")));
462    let bracketed = format.negatives() == Negatives::Parentheses;
463    conditions.push(not_like(&stripped, if bracketed { "%-%" } else { "_%-%" }));
464    if format.decimals() > 0 {
465        for shape in ["%.%.%", ".%", "-.%", "%."] {
466            conditions.push(not_like(&stripped, shape));
467        }
468    } else {
469        conditions.push(not_like(&stripped, "%.%"));
470    }
471    if !bracketed && !format.opens_with().is_empty() {
472        conditions.push(not_like(&value, "_%-%"));
473    }
474    conditions
475}
476
477/// **A symbol at most once, and only at its own end of the value** — after a
478/// leading minus, or inside an opening bracket, where the picture writes a
479/// negative that way. A symbol holding `%`, `_` or `[` is not held to its
480/// place: those are `LIKE`'s own wildcards somewhere.
481fn symbol_conditions(format: &NumberFormat, spelling: SidSpelling, value: &str) -> Vec<String> {
482    let prefix = spelling.lower.pattern_literal_prefix;
483    let like = |pattern: &str| format!("{value} LIKE {prefix}'{}'", pattern.replace('\'', "''"));
484    let not_like =
485        |pattern: &str| format!("{value} NOT LIKE {prefix}'{}'", pattern.replace('\'', "''"));
486    let mut conditions = Vec::new();
487    let bracketed = format.negatives() == Negatives::Parentheses;
488    let plain = |symbol: &str| !symbol.contains(['%', '_', '['].as_slice());
489    let opens = format.opens_with();
490    if !opens.is_empty() && plain(opens) {
491        let before = if bracketed { "(" } else { "-" };
492        conditions.push(not_like(&format!("%{opens}%{opens}%")));
493        conditions.push(format!(
494            "({} OR {} OR {})",
495            not_like(&format!("%{opens}%")),
496            like(&format!("{opens}%")),
497            like(&format!("{before}{opens}%")),
498        ));
499    }
500    let closes = format.closes_with();
501    if !closes.is_empty() && plain(closes) {
502        let after = if bracketed { ")" } else { "" };
503        conditions.push(not_like(&format!("%{closes}%{closes}%")));
504        conditions.push(format!(
505            "({} OR {} OR {})",
506            not_like(&format!("%{closes}%")),
507            like(&format!("%{closes}")),
508            like(&format!("%{closes}{after}")),
509        ));
510    }
511    conditions
512}
513
514/// **A digit before the first thousands mark** (`fr-1021-02`): `,123` opens
515/// with the mark, after nothing or after a leading minus, bracket or symbol,
516/// and is not written the picture's way — the pattern-guard engines' shape
517/// asks for a digit there, and so does every reading. Nothing where the
518/// picture names no thousands mark.
519fn thousands_after_a_digit(
520    format: &NumberFormat,
521    spelling: SidSpelling,
522    expr: &str,
523) -> Vec<String> {
524    if !format.grouped() {
525        return Vec::new();
526    }
527    let prefix = spelling.lower.pattern_literal_prefix;
528    let value = normalize_trim(spelling, expr);
529    let sign = if format.negatives() == Negatives::Parentheses { "(" } else { "-" };
530    let opens = format.opens_with();
531    let mut openings = vec![String::new(), sign.to_owned()];
532    if !opens.is_empty() && !opens.contains(['%', '_', '['].as_slice()) {
533        openings.push(opens.to_owned());
534        openings.push(format!("{sign}{opens}"));
535    }
536    openings
537        .iter()
538        .map(|opening| {
539            format!(
540                "{value} NOT LIKE {prefix}'{}'",
541                format!("{opening}{THOUSANDS_MARK}%").replace('\'', "''")
542            )
543        })
544        .collect()
545}
546
547/// **The picture's shape, as conditions an engine with no pattern match can
548/// hold a value to** — `LIKE` and nothing else.
549///
550/// [`NumberFormat::shape_pattern`] is the shape a pattern-matching engine
551/// holds a value to before its marks come out. An engine that converts with a
552/// cast that answers nothing, or checks the digits left, used to hold none:
553/// the marks came out wherever they stood, so `12$`, `$$12`, `+5` and `.5`
554/// read as numbers there while a pattern-matching engine set them aside
555/// (`fr-1019-86`). These are the same rules in `LIKE`'s words:
556///
557/// - a symbol at most once, and only at its own end of the value — after a
558///   leading minus, or inside an opening bracket, where the picture writes a
559///   negative that way;
560/// - with the marks out, nothing left but digits, at most one decimal point
561///   and at most a minus, so a plus sign, an exponent or a word is not a
562///   number here;
563/// - a digit before the decimal point, and at least one after it;
564/// - no decimal point at all where the picture has no places;
565/// - a minus only as the value's first character, and none where the picture
566///   writes a negative in brackets.
567///
568/// **The value is named as few times as the rules allow**, because it is often
569/// a long expression of its own and every mention repeats it. The digits,
570/// the point and the minus are asked in one condition ([`canonical_shape`]):
571/// a value of the picture's shape comes out as the text `x0` and nothing
572/// else. Only a symbol opening the value needs the minus asked again, of the
573/// value as written, since `$-12` and `-$12` are the same once it is out.
574///
575/// A symbol holding `%`, `_` or `[` is not held to its place: those are
576/// `LIKE`'s own wildcards somewhere.
577fn shape_conditions(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Vec<String> {
578    let prefix = spelling.lower.pattern_literal_prefix;
579    let value = normalize_trim(spelling, expr);
580    let stripped = strip_marks(format, spelling, expr);
581    let mut conditions = symbol_conditions(format, spelling, &value);
582    conditions.push(format!("{} LIKE {prefix}'x0'", canonical_shape(format, spelling, &stripped)));
583    if format.negatives() != Negatives::Parentheses && !format.opens_with().is_empty() {
584        conditions.push(format!("{value} NOT LIKE {prefix}'_%-%'"));
585    }
586    conditions
587}
588
589/// **A value with its marks out, written as the shape it has**: `x0` for one
590/// of digits — with a point between two runs of them where the picture has
591/// places, and a leading minus where it writes a negative that way — and
592/// anything else for any other value.
593///
594/// Every digit becomes `0`, a run of zeros becomes one, `0.0` becomes `0`, the
595/// text is put behind a letter, and a minus straight after the letter comes
596/// out: `-1,234.50` is `x0`, while `.5` is left `x.0`, `5.` is `x0.`, `1.2.3`
597/// is `x0.0`, `1e3` is `x0e0` and `--5` is `x-0`. Eight halvings take a run of
598/// up to 256 digits to one, far past any number a column holds. `REPLACE`,
599/// joining and `LIKE` alone, so it reads the same on every engine, a large
600/// object included; the letter keeps an engine that reads an empty text as
601/// nothing from comparing nothing.
602fn canonical_shape(format: &NumberFormat, spelling: SidSpelling, stripped: &str) -> String {
603    let replace = spelling.lower.replace;
604    let prefix = spelling.lower.pattern_literal_prefix;
605    let swap = |text: String, from: &str, to: &str| {
606        format!("{replace}({text}, {prefix}'{from}', {prefix}'{to}')")
607    };
608    let zeros =
609        (1..10u8).fold(stripped.to_owned(), |text, digit| swap(text, &digit.to_string(), "0"));
610    let run = (0..8).fold(zeros, |text, _| swap(text, "00", "0"));
611    let pointed =
612        if format.decimals() > 0 { swap(run, &format!("0{DECIMAL_POINT}0"), "0") } else { run };
613    let lettered = join_text(spelling, &format!("{prefix}'x'"), &pointed);
614    if format.negatives() == Negatives::Parentheses {
615        // A bracketed picture writes no minus, so one left is not its shape.
616        lettered
617    } else {
618        swap(lettered, "x-", "x")
619    }
620}
621
622/// **Two texts joined**, in this engine's own words for joining strings
623/// ([`SidSpelling::concat`]).
624fn join_text(spelling: SidSpelling, left: &str, right: &str) -> String {
625    match spelling.concat {
626        Concat::Function(name) | Concat::FunctionBinary(name) => format!("{name}({left}, {right})"),
627        Concat::Plus => format!("{left} + {right}"),
628        Concat::Operator(operator) => format!("{left} {operator} {right}"),
629    }
630}
631
632/// **The sign a bracketed negative carries**, as a factor to multiply by.
633///
634/// A picture that says nothing about brackets needs no factor: the minus sign
635/// the source writes survives the marks coming out, and the conversion reads
636/// it.
637fn bracket_sign(format: &NumberFormat, spelling: SidSpelling, expr: &str) -> Option<String> {
638    if format.negatives() != Negatives::Parentheses {
639        return None;
640    }
641    Some(format!("CASE WHEN {} LIKE '(%' THEN -1 ELSE 1 END", normalize_trim(spelling, expr)))
642}
643
644/// Multiply the reading by the sign, where the picture asks for one.
645fn signed(reading: String, sign: Option<String>) -> String {
646    match sign {
647        None => reading,
648        Some(sign) => format!("({reading}) * ({sign})"),
649    }
650}
651
652/// **For an engine whose own conversion answers nothing for a value it cannot
653/// read** — `TRY_CAST`, `SAFE_CAST`, `TRY_CONVERT` and their neighbours.
654///
655/// The value is held to the picture's shape first, in `LIKE`'s words and the
656/// engine's class test (the rules [`NumberFormat::shape_pattern`] states);
657/// then the marks come out and the engine converts what is left. A value
658/// outside the shape, or one the conversion cannot read, answers nothing,
659/// which is what razeri reads as *this row did not parse*.
660///
661/// Parameters:
662/// - `format`: the picture the design stated;
663/// - `spelling`: this engine's vocabulary;
664/// - `expr`: an already-rendered text expression;
665/// - `safe_cast`: this engine's conversion, given the stripped text;
666/// - `class`: how this engine tests a text for a character of a class —
667///   [`ClassTest::Flat`] unless it has no such test.
668#[must_use]
669pub fn read_with_safe_cast(
670    format: &NumberFormat,
671    spelling: SidSpelling,
672    expr: &str,
673    safe_cast: &dyn Fn(&str) -> String,
674    class: ClassTest<'_>,
675) -> String {
676    let reading = safe_cast(&strip_marks(format, spelling, expr));
677    guarded_reading(format, spelling, expr, &reading, class)
678}
679
680/// The reading behind the picture's shape, the thousands mark held in front
681/// of the decimal point and behind a digit, and the bracket's sign.
682fn guarded_reading(
683    format: &NumberFormat,
684    spelling: SidSpelling,
685    expr: &str,
686    reading: &str,
687    class: ClassTest<'_>,
688) -> String {
689    let mut conditions = match class {
690        ClassTest::Flat(holds) => flat_shape_conditions(format, spelling, expr, holds),
691        ClassTest::Folded => shape_conditions(format, spelling, expr),
692    };
693    conditions.extend(separators_in_order(format, spelling, expr));
694    conditions.extend(thousands_after_a_digit(format, spelling, expr));
695    let guarded = format!("CASE WHEN {} THEN {reading} END", conditions.join(" AND "));
696    signed(guarded, bracket_sign(format, spelling, expr))
697}
698
699/// **For an engine whose own conversion raises, or answers a number for text
700/// that is not one** — the shape is checked first, and a value that fails the
701/// check is never handed to the conversion at all.
702///
703/// Parameters:
704/// - `format`: the picture the design stated;
705/// - `spelling`: this engine's vocabulary;
706/// - `expr`: an already-rendered text expression;
707/// - `cast`: this engine's conversion, given the stripped text;
708/// - `matches`: this engine's own test of a value against a regular
709///   expression, given the value and the pattern as a quoted literal.
710#[must_use]
711pub fn read_with_pattern_guard(
712    format: &NumberFormat,
713    spelling: SidSpelling,
714    expr: &str,
715    cast: &dyn Fn(&str) -> String,
716    matches: &dyn Fn(&str, &str) -> String,
717) -> String {
718    let trimmed = normalize_trim(spelling, expr);
719    let pattern = format!("'{}'", format.shape_pattern().replace('\'', "''"));
720    let reading = cast(&strip_marks(format, spelling, expr));
721    // The pattern already holds the thousands mark in front of the decimal
722    // point, so this arm needs no second condition for it.
723    let guarded = format!("CASE WHEN {} THEN {reading} END", matches(&trimmed, &pattern));
724    signed(guarded, bracket_sign(format, spelling, expr))
725}
726
727/// **For an engine with neither a conversion that answers nothing nor a way to
728/// match a whole pattern** — the value's shape, asked in `LIKE`'s words and
729/// the engine's class test, is what says whether it was a number.
730///
731/// With the picture's marks already out, a value that was a number holds
732/// digits, at most one decimal point with a digit either side, and at most a
733/// leading minus — the same conditions [`read_with_safe_cast`] holds a value
734/// to, and a value that fails them is never handed to the conversion.
735///
736/// Parameters:
737/// - `format`: the picture the design stated;
738/// - `spelling`: this engine's vocabulary;
739/// - `expr`: an already-rendered text expression;
740/// - `cast`: this engine's conversion, given the stripped text;
741/// - `class`: how this engine tests a text for a character of a class —
742///   [`ClassTest::Flat`] unless it has no such test.
743#[must_use]
744pub fn read_with_digit_check(
745    format: &NumberFormat,
746    spelling: SidSpelling,
747    expr: &str,
748    cast: &dyn Fn(&str) -> String,
749    class: ClassTest<'_>,
750) -> String {
751    let reading = cast(&strip_marks(format, spelling, expr));
752    guarded_reading(format, spelling, expr, &reading, class)
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    /// SQL Server's class test, the shape every test here asks with.
760    fn like_class(text: &str, class: &str) -> String {
761        format!("{text} LIKE '%{class}%'")
762    }
763    const LIKE_CLASS: fn(&str, &str) -> String = like_class;
764
765    #[test]
766    fn a_price_picture_says_a_symbol_thousands_and_two_places() {
767        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
768        assert_eq!(format.opens_with(), "$");
769        assert_eq!(format.closes_with(), "");
770        assert!(format.grouped());
771        assert_eq!(format.decimals(), 2);
772        assert_eq!(format.negatives(), Negatives::Minus);
773        assert_eq!(format.marks(), vec!["$".to_owned(), ",".to_owned()]);
774    }
775
776    #[test]
777    fn a_symbol_may_close_the_value_instead_of_opening_it() {
778        let format = NumberFormat::parse("#,##0.00 kr").expect("a lawful picture");
779        assert_eq!(format.opens_with(), "");
780        assert_eq!(format.closes_with(), " kr");
781        assert_eq!(format.marks(), vec![" kr".to_owned(), ",".to_owned()]);
782    }
783
784    #[test]
785    fn a_plain_picture_names_no_marks_at_all() {
786        let format = NumberFormat::parse("0.000").expect("a lawful picture");
787        assert!(!format.grouped());
788        assert_eq!(format.decimals(), 3);
789        assert!(format.marks().is_empty());
790        assert_eq!(format.shape_pattern(), "^-?[0-9]+([.][0-9]+)?$");
791    }
792
793    #[test]
794    fn a_ledger_negative_is_the_same_picture_in_brackets() {
795        let format = NumberFormat::parse("$#,##0.00;($#,##0.00)").expect("a lawful picture");
796        assert_eq!(format.negatives(), Negatives::Parentheses);
797        assert_eq!(
798            format.marks(),
799            vec!["$".to_owned(), ",".to_owned(), "(".to_owned(), ")".to_owned()]
800        );
801        assert_eq!(format.spelled(), "$#,##0.00;($#,##0.00)");
802    }
803
804    #[test]
805    fn the_shape_holds_the_symbol_and_the_groups_where_the_picture_put_them() {
806        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
807        assert_eq!(format.shape_pattern(), "^-?([$])?[0-9][0-9,]*([.][0-9]+)?$");
808    }
809
810    #[test]
811    fn the_reading_holds_the_thousands_mark_in_front_of_the_decimal_point() {
812        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
813        let read = read_with_safe_cast(
814            &format,
815            crate::ANSI_SID_SPELLING,
816            "v",
817            &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
818            ClassTest::Folded,
819        );
820        // Taking the thousands mark out of `2.899,99` leaves `2.89999`, which
821        // converts to a price nobody wrote. The condition is what stops it.
822        assert!(read.contains("TRIM(v) NOT LIKE '%.%,%'"), "{read}");
823    }
824
825    #[test]
826    fn a_picture_with_no_thousands_mark_needs_no_such_condition() {
827        let format = NumberFormat::parse("0.00").expect("a lawful picture");
828        let read = read_with_safe_cast(
829            &format,
830            crate::ANSI_SID_SPELLING,
831            "v",
832            &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
833            ClassTest::Folded,
834        );
835        assert!(!read.contains("'%.%,%'"), "no thousands mark to put in its place: {read}");
836        assert!(read.ends_with("THEN TRY_CAST(TRIM(v) AS DECIMAL(12, 2)) END"), "{read}");
837    }
838
839    /// **A safe-cast reading holds the value to the picture's shape first**
840    /// (`fr-1019-86`): a symbol at most once and at its own end, nothing but
841    /// digits, a point and a minus left, a digit either side of the point, a
842    /// minus only first. Executed on SQL
843    /// Server, `DuckDB` and `SQLite` over `12$`, `$$12`, `+5`, `.5` and `$-12`,
844    /// each set aside on all three.
845    #[test]
846    fn a_safe_cast_reading_holds_the_value_to_the_pictures_shape() {
847        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
848        let read = read_with_safe_cast(
849            &format,
850            crate::ANSI_SID_SPELLING,
851            "v",
852            &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
853            ClassTest::Folded,
854        );
855        for guard in [
856            "TRIM(v) NOT LIKE '%$%$%'",
857            "TRIM(v) LIKE '-$%'",
858            "'00', '0'), '0.0', '0'))",
859            "'x-', 'x') LIKE 'x0'",
860            "TRIM(v) NOT LIKE '_%-%'",
861        ] {
862            assert!(read.contains(guard), "no {guard}: {read}");
863        }
864        let closing = NumberFormat::parse("#,##0.00 kr").expect("a lawful picture");
865        let read = read_with_safe_cast(
866            &closing,
867            crate::ANSI_SID_SPELLING,
868            "v",
869            &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
870            ClassTest::Folded,
871        );
872        assert!(read.contains("TRIM(v) LIKE '% kr'"), "{read}");
873    }
874
875    #[test]
876    fn a_whole_numbers_digit_check_leaves_no_room_for_a_decimal_point() {
877        let whole = NumberFormat::parse("#0").expect("a lawful picture");
878        let read = read_with_digit_check(
879            &whole,
880            crate::ANSI_SID_SPELLING,
881            "v",
882            &|text| format!("CAST({text} AS INTEGER)"),
883            ClassTest::Folded,
884        );
885        // `1.5` is not a whole number, and an engine with no safe conversion
886        // would otherwise answer `1`.
887        assert!(read.contains("'x-', 'x') LIKE 'x0'") && !read.contains("'0.0', '0')"), "{read}");
888        let fractional = NumberFormat::parse("#0.0").expect("a lawful picture");
889        let read = read_with_digit_check(
890            &fractional,
891            crate::ANSI_SID_SPELLING,
892            "v",
893            &|text| format!("CAST({text} AS DECIMAL(18, 6))"),
894            ClassTest::Folded,
895        );
896        assert!(read.contains("'0.0', '0')), 'x-', 'x') LIKE 'x0'"), "{read}");
897    }
898
899    /// **The value is named a fixed few times, however its shape is asked.**
900    /// It is often a long expression of its own — a column read through a
901    /// guard of its width — and every mention repeats it, so a check that
902    /// named it once per digit doubled a whole load's text.
903    #[test]
904    fn a_reading_names_the_value_a_fixed_few_times() {
905        let value = "COLUMN_VALUE";
906        for picture in ["#0", "#0.0", "0.00"] {
907            let format = NumberFormat::parse(picture).expect("a lawful picture");
908            let read = read_with_safe_cast(
909                &format,
910                crate::ANSI_SID_SPELLING,
911                value,
912                &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
913                ClassTest::Folded,
914            );
915            // The shape and the reading itself.
916            assert_eq!(read.matches(value).count(), 2, "{picture}: {read}");
917        }
918    }
919
920    /// How deep the calls in `sql` nest, string literals left out: the
921    /// measure an engine's parser stack holds a reading to.
922    fn depth(sql: &str) -> usize {
923        let (mut depth, mut deepest, mut quoted) = (0usize, 0usize, false);
924        for c in sql.chars() {
925            match c {
926                '\'' => quoted = !quoted,
927                '(' if !quoted => {
928                    depth += 1;
929                    deepest = deepest.max(depth);
930                }
931                ')' if !quoted => depth = depth.saturating_sub(1),
932                _ => {}
933            }
934        }
935        deepest
936    }
937
938    /// **The shared reading is flat** (`fr-1021-01`): a value is no deeper
939    /// than its picture's marks' own `REPLACE`s and the test around them, so
940    /// an engine with a shallow parser stack reads a wide table. Folded, a
941    /// `$#,##0.00` reading nested 23 deep over the value.
942    #[test]
943    fn the_shared_reading_is_flat() {
944        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
945        let read = read_with_safe_cast(
946            &format,
947            crate::ANSI_SID_SPELLING,
948            "v",
949            &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
950            ClassTest::Flat(&LIKE_CLASS),
951        );
952        assert!(depth(&read) <= 6, "{} deep: {read}", depth(&read));
953    }
954
955    /// **A value that opens with the thousands mark is not a number**
956    /// (`fr-1021-02`): `,123` is set aside, as the pattern-guard engines set it
957    /// aside, never read as `123`.
958    #[test]
959    fn a_value_opening_with_the_thousands_mark_is_set_aside() {
960        let format = NumberFormat::parse("$#,##0.00").expect("a lawful picture");
961        for class in [ClassTest::Flat(&LIKE_CLASS), ClassTest::Folded] {
962            let read = read_with_safe_cast(
963                &format,
964                crate::ANSI_SID_SPELLING,
965                "v",
966                &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
967                class,
968            );
969            for opening in ["',%'", "'-,%'", "'$,%'", "'-$,%'"] {
970                assert!(read.contains(&format!("TRIM(v) NOT LIKE {opening}")), "{opening}: {read}");
971            }
972        }
973    }
974
975    #[test]
976    fn a_bracketed_negative_is_read_as_a_sign_rather_than_a_mark() {
977        let format = NumberFormat::parse("$#,##0.00;($#,##0.00)").expect("a lawful picture");
978        let read = read_with_safe_cast(
979            &format,
980            crate::ANSI_SID_SPELLING,
981            "v",
982            &|text| format!("TRY_CAST({text} AS DECIMAL(12, 2))"),
983            ClassTest::Folded,
984        );
985        assert!(read.contains("CASE WHEN TRIM(v) LIKE '(%' THEN -1 ELSE 1 END"), "{read}");
986        assert!(read.contains("REPLACE(REPLACE(REPLACE(REPLACE(TRIM(v)"), "{read}");
987    }
988
989    #[test]
990    fn a_picture_with_no_digits_is_refused() {
991        assert_eq!(NumberFormat::parse("$"), Err(NumberFormatError::NoDigits));
992        assert_eq!(NumberFormat::parse(""), Err(NumberFormatError::Empty));
993    }
994
995    #[test]
996    fn a_source_writing_the_separators_the_other_way_round_is_refused_by_name() {
997        let refused = NumberFormat::parse("#.##0,00").expect_err("not covered");
998        assert_eq!(refused, NumberFormatError::MarkAfterPoint);
999        assert!(refused.to_string().contains("2.899,99"), "{refused}");
1000    }
1001
1002    #[test]
1003    fn a_thousands_mark_separates_groups_of_three() {
1004        assert_eq!(NumberFormat::parse("#,##0,0"), Err(NumberFormatError::GroupIsNotThree(1)));
1005    }
1006
1007    #[test]
1008    fn two_decimal_points_are_refused() {
1009        assert_eq!(NumberFormat::parse("0.0.0"), Err(NumberFormatError::TwoPoints));
1010    }
1011
1012    #[test]
1013    fn digits_on_both_sides_of_the_symbol_are_refused() {
1014        assert_eq!(NumberFormat::parse("0 kr 0"), Err(NumberFormatError::DigitsAfterSymbol));
1015    }
1016
1017    #[test]
1018    fn a_second_picture_that_is_not_the_first_in_brackets_is_refused() {
1019        assert_eq!(
1020            NumberFormat::parse("$#,##0.00;-$#,##0.00"),
1021            Err(NumberFormatError::NegativeIsNotBracketed)
1022        );
1023        assert_eq!(NumberFormat::parse("0;(0);0"), Err(NumberFormatError::TooManyPictures));
1024    }
1025}