Skip to main content

lemma/api/
show.rs

1//! Show document (a JSON Schema for evaluation-data inputs) and the ShowRule / ShowExpression
2//! shapes under its `x-lemma-rules`.
3
4use crate::api::types::LemmaType;
5use crate::api::value::RuleResultValue;
6use crate::literals::{MeasureUnits, RatioUnits};
7use crate::parsing::ast::{
8    ArithmeticComputation, CalendarPeriodUnit, ComparisonComputation, DateCalendarKind,
9    DateRelativeKind, MathematicalComputation, PrimitiveKind, RangeBound,
10};
11use crate::planning::execution_plan::{
12    ExecutionPlan, Show as DomainShow, ShowBranch as DomainShowBranch,
13    ShowConversionTarget as DomainConversionTarget, ShowData as DomainShowData,
14    ShowExpression as DomainShowExpression, ShowRule as DomainShowRule,
15};
16pub use crate::planning::execution_plan::{ShowListCommand, SortDirection};
17use crate::planning::semantics::{
18    LemmaType as DomainLemmaType, PathSegment as DomainPathSegment, SpecIdentity,
19    TypeSpecification as DomainTypeSpecification,
20};
21use crate::planning::show_expression::take_operand;
22use crate::result_value::{
23    type_scoped_result_value_from_literal, RuleResultValue as DomainRuleResultValue,
24};
25use crate::Engine;
26use indexmap::IndexMap;
27use serde::{Deserialize, Serialize};
28use serde_json::{json, Map, Value as JsonValue};
29use std::collections::{BTreeMap, BTreeSet};
30use std::sync::Arc;
31
32/// One `uses` hop on a Show data or rule path.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PathSegment {
35    pub uses: String,
36    #[serde(skip_serializing_if = "Option::is_none", default)]
37    pub repository: Option<String>,
38    pub spec: String,
39}
40
41impl From<&DomainPathSegment> for PathSegment {
42    fn from(segment: &DomainPathSegment) -> Self {
43        Self {
44            uses: segment.uses.clone(),
45            repository: segment.repository.clone(),
46            spec: segment.spec.clone(),
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ShowConversionTarget {
54    Type(PrimitiveKind),
55    Unit { unit_name: String },
56}
57
58impl From<&DomainConversionTarget> for ShowConversionTarget {
59    fn from(target: &DomainConversionTarget) -> Self {
60        match target {
61            DomainConversionTarget::Type(kind) => Self::Type(*kind),
62            DomainConversionTarget::Unit { unit_name } => Self::Unit {
63                unit_name: unit_name.clone(),
64            },
65        }
66    }
67}
68
69/// Resolved expression on a Show rule branch.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(tag = "type", rename_all = "snake_case")]
72pub enum ShowExpression {
73    Literal {
74        #[serde(flatten)]
75        value: Box<RuleResultValue>,
76    },
77    Data {
78        name: String,
79    },
80    Rule {
81        name: String,
82    },
83    And {
84        operands: Vec<ShowExpression>,
85    },
86    Not {
87        operand: Box<ShowExpression>,
88    },
89    Arithmetic {
90        first: Box<ShowExpression>,
91        steps: Vec<ShowArithmeticStep>,
92    },
93    Comparison {
94        op: ComparisonComputation,
95        left: Box<ShowExpression>,
96        right: Box<ShowExpression>,
97    },
98    UnitConversion {
99        operand: Box<ShowExpression>,
100        targets: Vec<ShowConversionTarget>,
101    },
102    Math {
103        op: MathematicalComputation,
104        operand: Box<ShowExpression>,
105    },
106    Veto {
107        #[serde(skip_serializing_if = "Option::is_none", default)]
108        message: Option<String>,
109    },
110    Now,
111    DateRelative {
112        kind: DateRelativeKind,
113        operand: Box<ShowExpression>,
114    },
115    DateCalendar {
116        kind: DateCalendarKind,
117        unit: CalendarPeriodUnit,
118        operand: Box<ShowExpression>,
119    },
120    RangeLiteral {
121        from: Box<ShowExpression>,
122        to: Box<ShowExpression>,
123    },
124    PastFutureRange {
125        kind: DateRelativeKind,
126        operand: Box<ShowExpression>,
127    },
128    RangeContainment {
129        value: Box<ShowExpression>,
130        range: Box<ShowExpression>,
131    },
132    RangeBound {
133        bound: RangeBound,
134        operand: Box<ShowExpression>,
135    },
136    IsVeto {
137        operand: Box<ShowExpression>,
138    },
139    SpecMember {
140        base: String,
141        member: String,
142    },
143    Member {
144        base: Box<ShowExpression>,
145        member: String,
146    },
147    RowField {
148        field: String,
149    },
150    RowValue,
151    RowFieldValue {
152        field: String,
153        value: Box<ShowExpression>,
154    },
155    ListCommand {
156        list: Box<ShowExpression>,
157        command: ShowListCommand,
158        #[serde(skip_serializing_if = "Option::is_none", default)]
159        direction: Option<SortDirection>,
160        args: Vec<ShowExpression>,
161    },
162}
163
164/// One operator step of an `arithmetic` node: `op operand`.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct ShowArithmeticStep {
167    pub op: ArithmeticComputation,
168    pub operand: ShowExpression,
169}
170
171impl From<&DomainShowExpression> for ShowExpression {
172    fn from(expression: &DomainShowExpression) -> Self {
173        expression.fold(convert_show_node)
174    }
175}
176
177/// One API node from its domain node and the API operands already converted, in
178/// [`DomainShowExpression::operands`] order.
179fn convert_show_node(node: &DomainShowExpression, operands: Vec<ShowExpression>) -> ShowExpression {
180    let mut operands = operands.into_iter();
181    match node {
182        DomainShowExpression::Literal(typed) => {
183            let domain_value = type_scoped_result_value_from_literal(
184                &typed.to_literal(),
185                typed.lemma_type.as_ref(),
186            )
187            .unwrap_or_else(|failure| {
188                panic!(
189                    "BUG: show branch literal failed type_scoped_result_value_from_literal: {}",
190                    crate::result_value::rule_result_value_failure_message(failure)
191                )
192            });
193            ShowExpression::Literal {
194                value: Box::new(RuleResultValue::from(&domain_value)),
195            }
196        }
197        DomainShowExpression::Data { name } => ShowExpression::Data { name: name.clone() },
198        DomainShowExpression::Rule { name } => ShowExpression::Rule { name: name.clone() },
199        DomainShowExpression::And { .. } => ShowExpression::And {
200            operands: operands.collect(),
201        },
202        DomainShowExpression::Not { .. } => ShowExpression::Not {
203            operand: Box::new(take_operand(&mut operands)),
204        },
205        DomainShowExpression::Arithmetic { steps, .. } => {
206            let first = Box::new(take_operand(&mut operands));
207            let steps = steps
208                .iter()
209                .map(|step| ShowArithmeticStep {
210                    op: step.op.clone(),
211                    operand: take_operand(&mut operands),
212                })
213                .collect();
214            ShowExpression::Arithmetic { first, steps }
215        }
216        DomainShowExpression::Comparison { op, .. } => {
217            let left = Box::new(take_operand(&mut operands));
218            let right = Box::new(take_operand(&mut operands));
219            ShowExpression::Comparison {
220                op: op.clone(),
221                left,
222                right,
223            }
224        }
225        DomainShowExpression::UnitConversion { targets, .. } => ShowExpression::UnitConversion {
226            operand: Box::new(take_operand(&mut operands)),
227            targets: targets.iter().map(ShowConversionTarget::from).collect(),
228        },
229        DomainShowExpression::Math { op, .. } => ShowExpression::Math {
230            op: op.clone(),
231            operand: Box::new(take_operand(&mut operands)),
232        },
233        DomainShowExpression::Veto { message } => ShowExpression::Veto {
234            message: message.clone(),
235        },
236        DomainShowExpression::Now => ShowExpression::Now,
237        DomainShowExpression::DateRelative { kind, .. } => ShowExpression::DateRelative {
238            kind: *kind,
239            operand: Box::new(take_operand(&mut operands)),
240        },
241        DomainShowExpression::DateCalendar { kind, unit, .. } => ShowExpression::DateCalendar {
242            kind: *kind,
243            unit: *unit,
244            operand: Box::new(take_operand(&mut operands)),
245        },
246        DomainShowExpression::RangeLiteral { .. } => {
247            let from = Box::new(take_operand(&mut operands));
248            let to = Box::new(take_operand(&mut operands));
249            ShowExpression::RangeLiteral { from, to }
250        }
251        DomainShowExpression::PastFutureRange { kind, .. } => ShowExpression::PastFutureRange {
252            kind: *kind,
253            operand: Box::new(take_operand(&mut operands)),
254        },
255        DomainShowExpression::RangeContainment { .. } => {
256            let value = Box::new(take_operand(&mut operands));
257            let range = Box::new(take_operand(&mut operands));
258            ShowExpression::RangeContainment { value, range }
259        }
260        DomainShowExpression::RangeBound { bound, .. } => ShowExpression::RangeBound {
261            bound: *bound,
262            operand: Box::new(take_operand(&mut operands)),
263        },
264        DomainShowExpression::IsVeto { .. } => ShowExpression::IsVeto {
265            operand: Box::new(take_operand(&mut operands)),
266        },
267        DomainShowExpression::SpecMember { base, member } => ShowExpression::SpecMember {
268            base: base.clone(),
269            member: member.clone(),
270        },
271        DomainShowExpression::Member { member, .. } => ShowExpression::Member {
272            base: Box::new(take_operand(&mut operands)),
273            member: member.clone(),
274        },
275        DomainShowExpression::RowField { field } => ShowExpression::RowField {
276            field: field.clone(),
277        },
278        DomainShowExpression::RowValue => ShowExpression::RowValue,
279        DomainShowExpression::RowFieldValue { field, .. } => ShowExpression::RowFieldValue {
280            field: field.clone(),
281            value: Box::new(take_operand(&mut operands)),
282        },
283        DomainShowExpression::ListCommand {
284            command, direction, ..
285        } => {
286            let list = Box::new(take_operand(&mut operands));
287            ShowExpression::ListCommand {
288                list,
289                command: *command,
290                direction: *direction,
291                args: operands.collect(),
292            }
293        }
294    }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct ShowBranch {
299    #[serde(skip_serializing_if = "Option::is_none", default)]
300    pub condition: Option<ShowExpression>,
301    pub result: ShowExpression,
302}
303
304impl From<&DomainShowBranch> for ShowBranch {
305    fn from(branch: &DomainShowBranch) -> Self {
306        Self {
307            condition: branch.condition.as_ref().map(ShowExpression::from),
308            result: ShowExpression::from(&branch.result),
309        }
310    }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ShowRule {
315    #[serde(rename = "type")]
316    pub lemma_type: LemmaType,
317    pub path: Vec<PathSegment>,
318    pub branches: Vec<ShowBranch>,
319    pub depends_on_rules: Vec<String>,
320}
321
322impl From<&DomainShowRule> for ShowRule {
323    fn from(rule: &DomainShowRule) -> Self {
324        Self {
325            lemma_type: LemmaType::from(&rule.lemma_type),
326            path: rule.path.iter().map(PathSegment::from).collect(),
327            branches: rule.branches.iter().map(ShowBranch::from).collect(),
328            depends_on_rules: rule.depends_on_rules.clone(),
329        }
330    }
331}
332
333const JSON_SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
334
335const TEXT_NOTE: &str = "Text values are limited to 1024 UTF-8 bytes.";
336const DATE_NOTE: &str = "Accepted forms: an RFC 3339 date-time with offset; a naive date-time `YYYY-MM-DDTHH:MM[:SS[.f]]`; a date `YYYY-MM-DD`; an ISO week `YYYY-Www` (week 1 to 53, 53 only in ISO years that have one); a year-month `YYYY-MM`; a year `YYYY` (1 to 9999). Exact separators follow the chrono parsers the engine uses.";
337const TIME_NOTE: &str = "Accepted forms: `HH:MM[:SS[.f]]`, optionally followed by `Z` or an offset `+HH:MM` / `-HH:MM`.";
338
339fn with_engine_note(help: &str, note: &str) -> String {
340    if help.is_empty() {
341        note.to_string()
342    } else {
343        format!("{help} {note}")
344    }
345}
346
347/// Run-data boolean text (`BooleanValue::from_str`): case-insensitive, surrounding whitespace.
348const BOOLEAN_TEXT_PATTERN: &str =
349    r"^\s*(?:[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[Yy][Ee][Ss]|[Nn][Oo])\s*$";
350
351/// The Show document: a Draft 2020-12 JSON Schema for the spec's evaluation-data inputs.
352///
353/// Standard keywords carry what JSON Schema expresses (`type`, `pattern`, `enum`,
354/// `default`, `examples`, `items`, ...); `x-lemma-*` keywords carry the rest, including
355/// the rule graph under `x-lemma-rules`. `$defs` holds one row object schema per
356/// instance-list row spec.
357pub fn show_document(engine: &Engine, show: &DomainShow) -> JsonValue {
358    let consumer = match engine.plan_matching_show(show) {
359        Some(plan) => Some(plan),
360        None if !show_has_instance_list(show) => None,
361        None => panic!(
362            "BUG: Show '{}' has instance-list inputs but no matching execution plan",
363            show.spec
364        ),
365    };
366    let mut rows = RowSchemas::new(engine, consumer);
367    let mut document = Map::new();
368    document.insert("$schema".to_string(), json!(JSON_SCHEMA_DIALECT));
369    document.insert("title".to_string(), json!(show.spec));
370    if let Some(commentary) = &show.commentary {
371        document.insert("description".to_string(), json!(commentary));
372    }
373    document.extend(input_object_schema(show, &mut rows));
374    let defs = rows.into_defs();
375    if !defs.is_empty() {
376        document.insert("$defs".to_string(), JsonValue::Object(defs));
377    }
378    if let Some(repository) = &show.repository {
379        document.insert("x-lemma-repository".to_string(), json!(repository));
380    }
381    if let Some(from) = &show.effective_from {
382        document.insert("x-lemma-effective-from".to_string(), to_json(from));
383    }
384    if let Some(to) = &show.effective_to {
385        document.insert("x-lemma-effective-to".to_string(), to_json(to));
386    }
387    document.insert("x-lemma-start-line".to_string(), json!(show.start_line));
388    if let Some(source_type) = &show.source_type {
389        document.insert("x-lemma-source-type".to_string(), to_json(source_type));
390    }
391    document.insert("x-lemma-meta".to_string(), to_json(&show.meta));
392    let types: IndexMap<&String, LemmaType> = show
393        .types
394        .iter()
395        .map(|(name, lemma_type)| (name, LemmaType::from(lemma_type)))
396        .collect();
397    document.insert("x-lemma-types".to_string(), to_json(&types));
398    let rules: IndexMap<&String, ShowRule> = show
399        .rules
400        .iter()
401        .map(|(name, rule)| (name, ShowRule::from(rule)))
402        .collect();
403    document.insert("x-lemma-rules".to_string(), to_json(&rules));
404    JsonValue::Object(document)
405}
406
407fn to_json<T: Serialize>(value: &T) -> JsonValue {
408    serde_json::to_value(value).expect("BUG: Show document parts serialize to JSON")
409}
410
411fn show_has_instance_list(show: &DomainShow) -> bool {
412    show.data.values().any(|data| {
413        matches!(
414            &data.lemma_type.specifications,
415            DomainTypeSpecification::List { element, .. }
416                if matches!(&element.specifications, DomainTypeSpecification::Spec { .. })
417        )
418    })
419}
420
421/// `type`, `additionalProperties`, `properties` and `required` for the data of `show`.
422/// `required`: data a rule needs that has no committed value. Lists are never required:
423/// an absent list is empty.
424fn input_object_schema(show: &DomainShow, rows: &mut RowSchemas) -> Map<String, JsonValue> {
425    let mut properties = Map::new();
426    let mut required = Vec::new();
427    for (name, data) in &show.data {
428        properties.insert(
429            name.clone(),
430            property_schema(data, rows, &data.needed_by_rules),
431        );
432        let is_list = matches!(
433            data.lemma_type.specifications,
434            DomainTypeSpecification::List { .. }
435        );
436        if data.value.is_none() && !data.needed_by_rules.is_empty() && !is_list {
437            required.push(name.clone());
438        }
439    }
440    required.sort();
441    let mut schema = Map::new();
442    schema.insert("type".to_string(), json!("object"));
443    schema.insert("additionalProperties".to_string(), json!(true));
444    schema.insert("properties".to_string(), JsonValue::Object(properties));
445    if !required.is_empty() {
446        schema.insert("required".to_string(), json!(required));
447    }
448    schema
449}
450
451/// Row `$defs` object: local types, plus any other `show.data` slots on the row
452/// (imported fields, local instance lists). `needed_by_rules` come from the
453/// consumer plan's instance-list [`NeededByRules::fields`], not the row Show.
454fn row_object_schema(
455    show: &DomainShow,
456    identity: &SpecIdentity,
457    rows: &mut RowSchemas,
458) -> Map<String, JsonValue> {
459    let mut properties = Map::new();
460    let mut required = Vec::new();
461    for (name, lemma_type) in &show.types {
462        let stub;
463        let entry = match show.data.get(name) {
464            Some(data) => data,
465            None => {
466                stub = DomainShowData {
467                    lemma_type: lemma_type.clone(),
468                    path: Vec::new(),
469                    value: None,
470                    suggestion: None,
471                    needed_by_rules: Vec::new(),
472                };
473                &stub
474            }
475        };
476        let is_list = matches!(
477            entry.lemma_type.specifications,
478            DomainTypeSpecification::List { .. }
479        );
480        if entry.value.is_none() && !is_list {
481            required.push(name.clone());
482        }
483        let needed = rows.row_field_needed_by_rules(identity, name);
484        properties.insert(name.clone(), property_schema(entry, rows, &needed));
485    }
486    for (name, data) in &show.data {
487        if properties.contains_key(name) {
488            continue;
489        }
490        let is_list = matches!(
491            data.lemma_type.specifications,
492            DomainTypeSpecification::List { .. }
493        );
494        if data.value.is_none() && !is_list {
495            required.push(name.clone());
496        }
497        let needed = rows.row_field_needed_by_rules(identity, name);
498        properties.insert(name.clone(), property_schema(data, rows, &needed));
499    }
500    required.sort();
501    let mut schema = Map::new();
502    schema.insert("type".to_string(), json!("object"));
503    schema.insert("additionalProperties".to_string(), json!(true));
504    schema.insert("properties".to_string(), JsonValue::Object(properties));
505    if !required.is_empty() {
506        schema.insert("required".to_string(), json!(required));
507    }
508    schema
509}
510
511fn property_schema(
512    data: &DomainShowData,
513    rows: &mut RowSchemas,
514    needed_by_rules: &[String],
515) -> JsonValue {
516    let mut schema = type_schema(&data.lemma_type.specifications, rows);
517    let help = specification_help(&data.lemma_type.specifications);
518    if !help.is_empty() {
519        schema.insert("description".to_string(), json!(help));
520    }
521    if let Some(value) = &data.value {
522        // A reference field is wiring, not a run-data literal. Emitting a partial
523        // default would replace source rows and drop those bindings.
524        if !rule_result_value_has_reference(value) {
525            schema.insert(
526                "default".to_string(),
527                input_literal(value, &data.lemma_type, rows),
528            );
529        }
530    }
531    if let Some(suggestion) = &data.suggestion {
532        schema.insert(
533            "examples".to_string(),
534            json!([input_literal(suggestion, &data.lemma_type, rows)]),
535        );
536    }
537    schema.insert(
538        "x-lemma-type".to_string(),
539        lemma_type_without_standard_keywords(&data.lemma_type),
540    );
541    let path: Vec<PathSegment> = data.path.iter().map(PathSegment::from).collect();
542    schema.insert("x-lemma-path".to_string(), to_json(&path));
543    schema.insert(
544        "x-lemma-needed-by-rules".to_string(),
545        json!(needed_by_rules),
546    );
547    if let Some(value) = &data.value {
548        schema.insert(
549            "x-lemma-value".to_string(),
550            to_json(&RuleResultValue::from(value)),
551        );
552    }
553    if let Some(suggestion) = &data.suggestion {
554        schema.insert(
555            "x-lemma-suggestion".to_string(),
556            to_json(&RuleResultValue::from(suggestion)),
557        );
558    }
559    JsonValue::Object(schema)
560}
561
562/// Standard keywords for one type. Lists are a JSON array: rows of an instance list are
563/// objects (`$defs` row schema), items of a value list are the element's literal.
564fn type_schema(
565    specification: &DomainTypeSpecification,
566    rows: &mut RowSchemas,
567) -> Map<String, JsonValue> {
568    let DomainTypeSpecification::List { element, .. } = specification else {
569        return scalar_data_schema(specification);
570    };
571    let mut schema = Map::new();
572    let items = match &element.specifications {
573        DomainTypeSpecification::Spec { spec } => rows.reference(spec),
574        DomainTypeSpecification::List { .. } => {
575            unreachable!("BUG: lists do not nest; planning rejects a list element list")
576        }
577        element => JsonValue::Object(scalar_data_schema(element)),
578    };
579    schema.insert("type".to_string(), json!("array"));
580    schema.insert("items".to_string(), items);
581    schema.insert(
582        "maxItems".to_string(),
583        json!(rows.engine.limits().max_list_rows),
584    );
585    schema
586}
587
588/// A scalar data slot. Measure and ratio data also accept a single-unit object (`{"eur": "84"}`).
589fn scalar_data_schema(specification: &DomainTypeSpecification) -> Map<String, JsonValue> {
590    let names: Vec<&str> = match specification {
591        DomainTypeSpecification::Measure { units, .. } => {
592            units.iter().map(|unit| unit.name.as_str()).collect()
593        }
594        DomainTypeSpecification::Ratio { units, .. } => {
595            units.iter().map(|unit| unit.name.as_str()).collect()
596        }
597        _ => return scalar_type_schema(specification),
598    };
599    let unit_map = json!({
600        "type": "object",
601        "minProperties": 1,
602        "maxProperties": 1,
603        "propertyNames": {"enum": names},
604        "additionalProperties": {"type": "string", "pattern": number_pattern()}
605    });
606    let mut schema = Map::new();
607    schema.insert(
608        "oneOf".to_string(),
609        json!([
610            JsonValue::Object(scalar_type_schema(specification)),
611            unit_map
612        ]),
613    );
614    schema
615}
616
617fn scalar_type_schema(specification: &DomainTypeSpecification) -> Map<String, JsonValue> {
618    let mut schema = Map::new();
619    match specification {
620        DomainTypeSpecification::Boolean { .. } => {
621            schema.insert(
622                "oneOf".to_string(),
623                json!([
624                    {"type": "boolean"},
625                    {"type": "string", "pattern": BOOLEAN_TEXT_PATTERN}
626                ]),
627            );
628        }
629        DomainTypeSpecification::Text {
630            length, options, ..
631        } => {
632            schema.insert("type".to_string(), json!("string"));
633            if !options.is_empty() {
634                schema.insert("enum".to_string(), json!(options));
635            }
636            if let Some(length) = length {
637                schema.insert("minLength".to_string(), json!(length));
638                schema.insert("maxLength".to_string(), json!(length));
639            }
640        }
641        DomainTypeSpecification::Number { .. } => {
642            schema.insert("type".to_string(), json!("string"));
643            schema.insert("pattern".to_string(), json!(number_pattern()));
644        }
645        DomainTypeSpecification::Measure { units, .. } => {
646            schema.insert("type".to_string(), json!("string"));
647            schema.insert("pattern".to_string(), json!(measure_pattern(units)));
648        }
649        DomainTypeSpecification::Ratio { units, .. } => {
650            schema.insert("type".to_string(), json!("string"));
651            schema.insert("pattern".to_string(), json!(ratio_pattern(units)));
652        }
653        DomainTypeSpecification::NumberRange { .. } => {
654            schema.insert("type".to_string(), json!("string"));
655            schema.insert("pattern".to_string(), json!(range_pattern(&number_body())));
656        }
657        DomainTypeSpecification::MeasureRange { units, .. } => {
658            schema.insert("type".to_string(), json!("string"));
659            schema.insert(
660                "pattern".to_string(),
661                json!(range_pattern(&measure_body(units))),
662            );
663        }
664        DomainTypeSpecification::RatioRange { units, .. } => {
665            schema.insert("type".to_string(), json!("string"));
666            schema.insert(
667                "pattern".to_string(),
668                json!(range_pattern(&ratio_body(units))),
669            );
670        }
671        DomainTypeSpecification::Date { .. }
672        | DomainTypeSpecification::Time { .. }
673        | DomainTypeSpecification::DateRange { .. }
674        | DomainTypeSpecification::TimeRange { .. } => {
675            schema.insert("type".to_string(), json!("string"));
676        }
677        DomainTypeSpecification::List { .. } => {
678            unreachable!("BUG: list schemas are built by type_schema")
679        }
680        DomainTypeSpecification::Spec { spec } => {
681            unreachable!("BUG: spec '{spec}' is a uses alias, never a Show data entry")
682        }
683        DomainTypeSpecification::Veto { .. } => {
684            unreachable!("BUG: veto sentinel type on a Show data entry")
685        }
686        DomainTypeSpecification::Undetermined => {
687            unreachable!("BUG: undetermined sentinel type on a Show data entry")
688        }
689    }
690    schema
691}
692
693fn specification_help(specification: &DomainTypeSpecification) -> String {
694    let mut leaf = specification;
695    while let DomainTypeSpecification::List { element, .. } = leaf {
696        leaf = &element.specifications;
697    }
698    match leaf {
699        DomainTypeSpecification::Boolean { help }
700        | DomainTypeSpecification::Measure { help, .. }
701        | DomainTypeSpecification::MeasureRange { help, .. }
702        | DomainTypeSpecification::Number { help, .. }
703        | DomainTypeSpecification::NumberRange { help, .. }
704        | DomainTypeSpecification::Ratio { help, .. }
705        | DomainTypeSpecification::RatioRange { help, .. } => help.clone(),
706        DomainTypeSpecification::Text { help, .. } => with_engine_note(help, TEXT_NOTE),
707        DomainTypeSpecification::Date { help, .. }
708        | DomainTypeSpecification::DateRange { help, .. } => with_engine_note(help, DATE_NOTE),
709        DomainTypeSpecification::Time { help, .. }
710        | DomainTypeSpecification::TimeRange { help, .. } => with_engine_note(help, TIME_NOTE),
711        DomainTypeSpecification::Spec { spec } => spec.to_string(),
712        DomainTypeSpecification::List { .. } => {
713            unreachable!("BUG: list chain is unwound before the leaf match")
714        }
715        DomainTypeSpecification::Veto { .. } | DomainTypeSpecification::Undetermined => {
716            unreachable!("BUG: sentinel type on a Show data entry")
717        }
718    }
719}
720
721/// Run-data number text: an optional sign, digits with single underscores between them (1 to
722/// 28 digits per run), an optional fraction (a leading `.` needs at least one digit), and an
723/// optional exponent of one or two digits. Surrounding whitespace is added by the callers.
724/// Stricter than `NumberLiteral`, which also accepts underscores at the edges and doubled ones.
725fn number_body() -> String {
726    let digits = "[0-9](?:_?[0-9]){0,27}";
727    format!(r"[+-]?(?:{digits}(?:\.(?:{digits})?)?|\.{digits})(?:[eE][+-]?[0-9]{{1,2}})?")
728}
729
730/// A measure value body: a number, whitespace, one of the declared unit names.
731fn measure_body(units: &MeasureUnits) -> String {
732    let names: Vec<&str> = units.iter().map(|unit| unit.name.as_str()).collect();
733    assert!(!names.is_empty(), "BUG: planned measure type has no units");
734    format!(r"{}\s+({})", number_body(), alternation(&names))
735}
736
737/// A ratio value body: a number glued to `%` (only when `percent` is declared) or `%%`
738/// (only when `permille` is declared), or a number, whitespace, and a declared unit name.
739/// A bare number is not a ratio value.
740fn ratio_body(units: &RatioUnits) -> String {
741    let names: Vec<&str> = units.iter().map(|unit| unit.name.as_str()).collect();
742    assert!(!names.is_empty(), "BUG: planned ratio type has no units");
743    let mut alternatives = Vec::new();
744    if names.contains(&"permille") {
745        alternatives.push(format!("{}%%", number_body()));
746    }
747    if names.contains(&"percent") {
748        alternatives.push(format!("{}%", number_body()));
749    }
750    alternatives.push(format!(r"{}\s+({})", number_body(), alternation(&names)));
751    format!("(?:{})", alternatives.join("|"))
752}
753
754/// `pattern` for a number value.
755pub fn number_pattern() -> String {
756    format!(r"^\s*{}\s*$", number_body())
757}
758
759/// `pattern` for a measure value.
760pub fn measure_pattern(units: &MeasureUnits) -> String {
761    format!(r"^\s*{}\s*$", measure_body(units))
762}
763
764/// `pattern` for a ratio value.
765pub fn ratio_pattern(units: &RatioUnits) -> String {
766    format!(r"^\s*{}\s*$", ratio_body(units))
767}
768
769/// `pattern` for a range: two endpoints around `...`, each endpoint a value body.
770fn range_pattern(endpoint: &str) -> String {
771    format!(r"^\s*{endpoint}\s*\.\.\.\s*{endpoint}\s*$")
772}
773
774fn alternation(names: &[&str]) -> String {
775    names
776        .iter()
777        .map(|name| regex_escape(name))
778        .collect::<Vec<_>>()
779        .join("|")
780}
781
782fn regex_escape(text: &str) -> String {
783    let mut escaped = String::with_capacity(text.len());
784    for c in text.chars() {
785        if "\\^$.|?*+()[]{}/-".contains(c) {
786            escaped.push('\\');
787        }
788        escaped.push(c);
789    }
790    escaped
791}
792
793/// Run-data JSON for a committed value or suggestion: a boolean is a JSON boolean, a value
794/// list an array of element literals, an instance list an array of row objects keyed by
795/// field, everything else the literal string.
796fn rule_result_value_has_reference(value: &DomainRuleResultValue) -> bool {
797    if value.reference.is_some() {
798        return true;
799    }
800    if let Some(fields) = &value.fields {
801        if fields.values().any(rule_result_value_has_reference) {
802            return true;
803        }
804    }
805    if let Some(list) = &value.list {
806        if list.iter().any(rule_result_value_has_reference) {
807            return true;
808        }
809    }
810    false
811}
812
813enum InputLiteralTask<'a> {
814    Visit {
815        value: &'a DomainRuleResultValue,
816        lemma_type: Arc<DomainLemmaType>,
817    },
818    CollectArray {
819        len: usize,
820    },
821    CollectObject {
822        keys: Vec<String>,
823    },
824}
825
826fn input_literal(
827    value: &DomainRuleResultValue,
828    lemma_type: &DomainLemmaType,
829    rows: &RowSchemas,
830) -> JsonValue {
831    let mut tasks = vec![InputLiteralTask::Visit {
832        value,
833        lemma_type: Arc::new(lemma_type.clone()),
834    }];
835    let mut converted: Vec<JsonValue> = Vec::new();
836    while let Some(task) = tasks.pop() {
837        match task {
838            InputLiteralTask::Visit { value, lemma_type } => match &lemma_type.specifications {
839                DomainTypeSpecification::Boolean { .. } => converted.push(JsonValue::Bool(
840                    value
841                        .boolean
842                        .expect("BUG: boolean-typed Show value without boolean"),
843                )),
844                DomainTypeSpecification::List { element, .. } => {
845                    let items = value
846                        .list
847                        .as_ref()
848                        .expect("BUG: list-typed Show value without list");
849                    tasks.push(InputLiteralTask::CollectArray { len: items.len() });
850                    match &element.specifications {
851                        DomainTypeSpecification::Spec { spec } => {
852                            let row_show = rows.engine.show_spec(spec);
853                            for row in items.iter().rev() {
854                                let fields = row
855                                    .fields
856                                    .as_ref()
857                                    .expect("BUG: instance list Show row without fields");
858                                let mut keys = Vec::with_capacity(fields.len());
859                                let mut children = Vec::with_capacity(fields.len());
860                                for (name, field) in fields {
861                                    assert!(
862                                        field.reference.is_none(),
863                                        "BUG: input_literal on Show value with reference field '{name}'"
864                                    );
865                                    let field_type = row_show
866                                        .types
867                                        .get(name)
868                                        .or_else(|| row_show.data.get(name).map(|d| &d.lemma_type))
869                                        .unwrap_or_else(|| {
870                                            panic!("BUG: row field '{name}' missing from Show of '{spec}'")
871                                        });
872                                    keys.push(name.clone());
873                                    children.push((field, Arc::new(field_type.clone())));
874                                }
875                                tasks.push(InputLiteralTask::CollectObject { keys });
876                                for (field, field_type) in children.into_iter().rev() {
877                                    tasks.push(InputLiteralTask::Visit {
878                                        value: field,
879                                        lemma_type: field_type,
880                                    });
881                                }
882                            }
883                        }
884                        _ => {
885                            for item in items.iter().rev() {
886                                tasks.push(InputLiteralTask::Visit {
887                                    value: item,
888                                    lemma_type: Arc::clone(element),
889                                });
890                            }
891                        }
892                    }
893                }
894                _ => converted.push(JsonValue::String(
895                    value
896                        .result
897                        .clone()
898                        .expect("BUG: scalar Show value without result"),
899                )),
900            },
901            InputLiteralTask::CollectArray { len } => {
902                let items = converted.split_off(converted.len() - len);
903                converted.push(JsonValue::Array(items));
904            }
905            InputLiteralTask::CollectObject { keys } => {
906                let children = converted.split_off(converted.len() - keys.len());
907                converted.push(JsonValue::Object(keys.into_iter().zip(children).collect()));
908            }
909        }
910    }
911    converted
912        .pop()
913        .expect("BUG: Show value conversion yields exactly one JSON value")
914}
915
916/// The API `LemmaType` minus what standard keywords already state: `help` (`description`)
917/// and text `options`/`length` (`enum`, `minLength`/`maxLength`).
918fn lemma_type_without_standard_keywords(lemma_type: &DomainLemmaType) -> JsonValue {
919    let mut value = to_json(&LemmaType::from(lemma_type));
920    strip_standard_keywords(&mut value);
921    value
922}
923
924fn strip_standard_keywords(value: &mut JsonValue) {
925    let mut pending = vec![value];
926    while let Some(value) = pending.pop() {
927        let object = value
928            .as_object_mut()
929            .expect("BUG: LemmaType serializes to a JSON object");
930        object.remove("help");
931        if object.get("kind") == Some(&json!("text")) {
932            object.remove("options");
933            object.remove("length");
934        }
935        if let Some(element) = object.get_mut("element") {
936            pending.push(element);
937        }
938    }
939}
940
941/// Row object schemas of instance lists, one `$defs` entry per row spec. A self-list's row
942/// schema refers to itself.
943struct RowSchemas<'a> {
944    engine: &'a Engine,
945    /// Consumer plan whose instance-list [`crate::planning::execution_plan::NeededByRules::fields`]
946    /// annotate `$defs`.
947    consumer: Option<&'a ExecutionPlan>,
948    started: BTreeSet<SpecIdentity>,
949    pending: Vec<SpecIdentity>,
950    defs: BTreeMap<String, JsonValue>,
951}
952
953impl<'a> RowSchemas<'a> {
954    fn new(engine: &'a Engine, consumer: Option<&'a ExecutionPlan>) -> Self {
955        Self {
956            engine,
957            consumer,
958            started: BTreeSet::new(),
959            pending: Vec::new(),
960            defs: BTreeMap::new(),
961        }
962    }
963
964    /// Builds every row schema reached from the document. Row schemas that reference
965    /// further row specs queue them on `pending`, so the walk never nests.
966    fn into_defs(mut self) -> Map<String, JsonValue> {
967        while let Some(identity) = self.pending.pop() {
968            let show = self.engine.show_spec(&identity);
969            let mut schema = Map::new();
970            schema.insert("title".to_string(), json!(format!("{identity} row")));
971            schema.extend(row_object_schema(&show, &identity, &mut self));
972            self.defs
973                .insert(row_def_name(&identity), JsonValue::Object(schema));
974        }
975        self.defs.into_iter().collect()
976    }
977
978    /// Parent Show.rules keys that need `field` on rows of `identity`.
979    fn row_field_needed_by_rules(&self, identity: &SpecIdentity, field: &str) -> Vec<String> {
980        let Some(consumer) = self.consumer else {
981            return Vec::new();
982        };
983        consumer
984            .row_field_needed_by_rules(identity, field)
985            .into_iter()
986            .map(|rule_position| {
987                consumer
988                    .rules
989                    .get_index(rule_position as usize)
990                    .expect("BUG: needed_by_rules field consumer out of consumer.rules range")
991                    .1
992                    .path
993                    .input_key()
994            })
995            .collect()
996    }
997
998    /// `$ref` to the row schema of `identity`; the schema is queued on first use.
999    fn reference(&mut self, identity: &SpecIdentity) -> JsonValue {
1000        let name = row_def_name(identity);
1001        if self.started.insert(identity.clone()) {
1002            self.pending.push(identity.clone());
1003        }
1004        json!({ "$ref": format!("#/$defs/{name}") })
1005    }
1006}
1007
1008/// `LemmaRow_` plus the row spec identity, injectively escaped to `[A-Za-z0-9_-]` (a valid
1009/// OpenAPI component name): `_` becomes `__`, any other character `_<hex code point>_`.
1010/// One name per identity, so documents of different specs agree on shared row specs.
1011pub fn row_def_name(identity: &SpecIdentity) -> String {
1012    let mut name = String::from("LemmaRow_");
1013    for c in identity.to_string().chars() {
1014        if c.is_ascii_alphanumeric() || c == '-' {
1015            name.push(c);
1016        } else if c == '_' {
1017            name.push_str("__");
1018        } else {
1019            name.push_str(&format!("_{:x}_", c as u32));
1020        }
1021    }
1022    name
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use crate::parsing::ast::Span;
1029    use crate::parsing::source::{Source, SourceType};
1030    use crate::planning::semantics::parse_value_from_string;
1031
1032    const INPUTS: &str = r#"
1033spec inputs
1034data money: measure -> unit eur: 1 -> unit cent: 0.01
1035data price: money -> suggest 10 eur
1036data deposit: 250 cent
1037data rate: ratio -> suggest 21%
1038data bps: ratio -> unit basis_points: 10000 -> unit percent: 100 -> suggest 500 basis_points
1039data fee: 3%
1040data quantity: number -> suggest 12.5
1041data offset: -3
1042data express: boolean -> suggest true
1043data code: text -> option "A" -> option "B" -> suggest "B"
1044data label: text -> length 3
1045data start: date -> suggest 2026-01-01
1046data window: number range -> suggest 1...5
1047data tags: number list
1048  -> with 5, 10
1049rule r_price: price + deposit
1050rule r_rate: rate
1051rule r_bps: bps
1052rule r_fee: fee
1053rule r_quantity: quantity + offset
1054rule r_express: express
1055rule r_code: code
1056rule r_label: label
1057rule r_start: start
1058rule r_window: window
1059rule r_tags: tags -> sum
1060"#;
1061
1062    fn engine() -> Engine {
1063        let mut engine = Engine::new();
1064        engine
1065            .load([(SourceType::Volatile, INPUTS.to_string())])
1066            .expect("load inputs");
1067        engine
1068    }
1069
1070    fn document() -> (Engine, DomainShow, JsonValue) {
1071        let engine = engine();
1072        let show = engine.show(None, "inputs", None).expect("show inputs");
1073        let document = show_document(&engine, &show);
1074        (engine, show, document)
1075    }
1076
1077    fn source() -> Source {
1078        Source::new(
1079            SourceType::Volatile,
1080            Span {
1081                start: 0,
1082                end: 0,
1083                line: 1,
1084                col: 1,
1085            },
1086        )
1087    }
1088
1089    fn pattern_accepts(pattern: &str, literal: &str) -> bool {
1090        jsonschema::is_valid(
1091            &json!({ "type": "string", "pattern": pattern }),
1092            &json!(literal),
1093        )
1094    }
1095
1096    fn parses(literal: &str, specification: &DomainTypeSpecification) -> bool {
1097        parse_value_from_string(literal, specification, &source()).is_ok()
1098    }
1099
1100    fn decimal(text: &JsonValue) -> rust_decimal::Decimal {
1101        text.as_str()
1102            .expect("API decimal is a string")
1103            .parse()
1104            .expect("API decimal parses")
1105    }
1106
1107    /// The parsed literal names the same magnitude, in the same unit, as the API value.
1108    fn assert_literal_denotes(
1109        name: &str,
1110        literal: &str,
1111        value: &JsonValue,
1112        specification: &DomainTypeSpecification,
1113    ) {
1114        let parsed = parse_value_from_string(literal, specification, &source())
1115            .unwrap_or_else(|error| panic!("{name}: '{literal}' must parse: {error}"));
1116        assert_parsed_denotes(name, literal, &parsed, value);
1117    }
1118
1119    fn assert_parsed_denotes(
1120        name: &str,
1121        literal: &str,
1122        parsed: &crate::parsing::ast::Value,
1123        value: &JsonValue,
1124    ) {
1125        use crate::parsing::ast::Value;
1126        match parsed {
1127            Value::Number(magnitude) => {
1128                assert_eq!(*magnitude, decimal(&value["number"]), "{name}: '{literal}'");
1129            }
1130            Value::NumberWithUnit(magnitude, unit) => {
1131                let units = value
1132                    .get("measure")
1133                    .or_else(|| value.get("ratio"))
1134                    .unwrap_or_else(|| panic!("{name}: unit map in {value}"));
1135                assert_eq!(
1136                    *magnitude,
1137                    decimal(&units[unit]),
1138                    "{name}: '{literal}' vs {value}"
1139                );
1140            }
1141            Value::Range(left, right) => {
1142                assert_parsed_denotes(name, literal, left, &value["range"]["from"]);
1143                assert_parsed_denotes(name, literal, right, &value["range"]["to"]);
1144            }
1145            other => panic!("{name}: '{literal}' parsed to unexpected {other:?}"),
1146        }
1147    }
1148
1149    #[test]
1150    fn spec_level_keywords() {
1151        let (_, _, document) = document();
1152        assert_eq!(document["$schema"], JSON_SCHEMA_DIALECT);
1153        assert_eq!(document["title"], "inputs");
1154        assert_eq!(document["type"], "object");
1155        assert_eq!(document["additionalProperties"], true);
1156        assert!(document.get("$defs").is_none());
1157        assert_eq!(document["x-lemma-start-line"], 2);
1158        assert!(document["x-lemma-rules"]["r_price"].is_object());
1159    }
1160
1161    #[test]
1162    fn required_is_sorted_needed_inputs_without_committed_value() {
1163        let (_, _, document) = document();
1164        assert_eq!(
1165            document["required"],
1166            json!([
1167                "bps", "code", "express", "label", "price", "quantity", "rate", "start", "window"
1168            ])
1169        );
1170    }
1171
1172    #[test]
1173    fn standard_keywords_per_type() {
1174        let (_, _, document) = document();
1175        let properties = &document["properties"];
1176        assert_eq!(properties["express"]["oneOf"][0]["type"], "boolean");
1177        assert_eq!(properties["express"]["examples"], json!([true]));
1178        assert_eq!(properties["code"]["type"], "string");
1179        assert_eq!(properties["code"]["enum"], json!(["A", "B"]));
1180        assert_eq!(properties["label"]["minLength"], 3);
1181        assert_eq!(properties["label"]["maxLength"], 3);
1182        assert_eq!(properties["quantity"]["pattern"], number_pattern());
1183        assert_eq!(
1184            properties["price"]["oneOf"][1]["propertyNames"]["enum"],
1185            json!(["eur", "cent"])
1186        );
1187        assert_eq!(
1188            properties["rate"]["oneOf"][1]["propertyNames"]["enum"],
1189            json!(["percent", "permille"])
1190        );
1191        assert_eq!(
1192            properties["bps"]["oneOf"][1]["propertyNames"]["enum"],
1193            json!(["percent", "permille", "basis_points"])
1194        );
1195        assert_eq!(properties["start"]["type"], "string");
1196        assert!(properties["start"].get("pattern").is_none());
1197        assert!(properties["start"].get("format").is_none());
1198        assert_eq!(properties["window"]["type"], "string");
1199        assert!(properties["window"]["pattern"].is_string());
1200        let tags = &properties["tags"];
1201        assert_eq!(tags["type"], "array");
1202        assert_eq!(
1203            tags["items"],
1204            json!({ "type": "string", "pattern": number_pattern() })
1205        );
1206        assert_eq!(
1207            tags["maxItems"],
1208            crate::ResourceLimits::default().max_list_rows
1209        );
1210        assert_eq!(tags["default"], json!(["5", "10"]));
1211    }
1212
1213    #[test]
1214    fn x_lemma_type_omits_what_standard_keywords_state() {
1215        let (_, _, document) = document();
1216        for (name, property) in document["properties"].as_object().expect("properties") {
1217            let lemma_type = &property["x-lemma-type"];
1218            assert!(lemma_type.get("help").is_none(), "{name}: {lemma_type}");
1219            assert!(lemma_type.get("options").is_none(), "{name}: {lemma_type}");
1220            assert!(lemma_type.get("length").is_none(), "{name}: {lemma_type}");
1221            if let Some(element) = lemma_type.get("element") {
1222                assert!(element.get("help").is_none(), "{name}: {element}");
1223            }
1224            assert!(property.get("x-lemma-path").is_some(), "{name}");
1225            assert!(property.get("x-lemma-needed-by-rules").is_some(), "{name}");
1226        }
1227        assert_eq!(
1228            document["properties"]["price"]["x-lemma-type"]["kind"],
1229            "measure"
1230        );
1231        assert!(document["properties"]["price"]["x-lemma-type"]["units"].is_array());
1232    }
1233
1234    fn value_json_has_reference(value: &JsonValue) -> bool {
1235        if value.get("reference").is_some() {
1236            return true;
1237        }
1238        if let Some(fields) = value.get("fields").and_then(|v| v.as_object()) {
1239            if fields.values().any(value_json_has_reference) {
1240                return true;
1241            }
1242        }
1243        if let Some(list) = value.get("list").and_then(|v| v.as_array()) {
1244            if list.iter().any(value_json_has_reference) {
1245                return true;
1246            }
1247        }
1248        false
1249    }
1250
1251    /// The evaluation-data JSON a typed value stands for, derived from the API value alone.
1252    fn evaluate_data_json_of(value: &JsonValue) -> JsonValue {
1253        if let Some(boolean) = value.get("boolean") {
1254            return boolean.clone();
1255        }
1256        if let Some(items) = value.get("list") {
1257            return JsonValue::Array(
1258                items
1259                    .as_array()
1260                    .expect("list")
1261                    .iter()
1262                    .map(evaluate_data_json_of)
1263                    .collect(),
1264            );
1265        }
1266        if let Some(fields) = value.get("fields") {
1267            return JsonValue::Object(
1268                fields
1269                    .as_object()
1270                    .expect("fields")
1271                    .iter()
1272                    .filter(|(_, field)| field.get("reference").is_none())
1273                    .map(|(name, field)| (name.clone(), evaluate_data_json_of(field)))
1274                    .collect(),
1275            );
1276        }
1277        if value.get("reference").is_some() {
1278            panic!("BUG: reference field is not a run-data literal");
1279        }
1280        value["result"].clone()
1281    }
1282
1283    #[test]
1284    fn instance_list_with_references_has_value_but_no_default() {
1285        let mut engine = Engine::new();
1286        engine
1287            .load([(
1288                SourceType::Volatile,
1289                r#"
1290spec processing
1291data type: text
1292data weight: number
1293data enabled: boolean
1294spec line_item
1295data weight: number
1296data roast: boolean
1297uses processing list
1298  -> with weight: weight, type: "roast", enabled: roast
1299rule cost: processing -> where enabled -> count
1300"#
1301                .to_string(),
1302            )])
1303            .expect("load");
1304        let show = engine.show(None, "line_item", None).expect("show");
1305        let processing = show.data.get("processing").expect("processing");
1306        let value = processing.value.as_ref().expect("value");
1307        assert!(
1308            rule_result_value_has_reference(value),
1309            "expected reference in value: {value:?}"
1310        );
1311        let document = show_document(&engine, &show);
1312        let property = &document["properties"]["processing"];
1313        assert!(property.get("x-lemma-value").is_some(), "{property}");
1314        assert!(
1315            property.get("default").is_none(),
1316            "default must be omitted when fields reference data: {property}"
1317        );
1318    }
1319
1320    #[test]
1321    fn default_and_examples_agree_with_typed_value_and_suggestion() {
1322        let (_, _, document) = document();
1323        let mut checked = 0;
1324        for (name, property) in document["properties"].as_object().expect("properties") {
1325            match (property.get("default"), property.get("x-lemma-value")) {
1326                (Some(default), Some(value)) => {
1327                    assert_eq!(default, &evaluate_data_json_of(value), "{name}");
1328                    checked += 1;
1329                }
1330                (None, Some(value)) => {
1331                    assert!(
1332                        value_json_has_reference(value),
1333                        "{name}: value without default must carry a reference field"
1334                    );
1335                }
1336                (None, None) => {}
1337                other => {
1338                    panic!("{name}: default and x-lemma-value disagree on presence: {other:?}")
1339                }
1340            }
1341            match (property.get("examples"), property.get("x-lemma-suggestion")) {
1342                (Some(examples), Some(suggestion)) => {
1343                    assert_eq!(
1344                        examples,
1345                        &json!([evaluate_data_json_of(suggestion)]),
1346                        "{name}"
1347                    );
1348                    checked += 1;
1349                }
1350                (None, None) => {}
1351                other => panic!(
1352                    "{name}: examples and x-lemma-suggestion disagree on presence: {other:?}"
1353                ),
1354            }
1355        }
1356        assert_eq!(checked, 12);
1357    }
1358
1359    #[test]
1360    fn emitted_literals_match_pattern_and_denote_their_value() {
1361        let (_, show, document) = document();
1362        let mut checked = 0;
1363        for (name, property) in document["properties"].as_object().expect("properties") {
1364            let Some(pattern) = property["pattern"]
1365                .as_str()
1366                .or_else(|| property["oneOf"][0]["pattern"].as_str())
1367            else {
1368                continue;
1369            };
1370            let specification = &show.data[name].lemma_type.specifications;
1371            let mut literals: Vec<(&str, &JsonValue)> = Vec::new();
1372            if let Some(default) = property.get("default") {
1373                literals.push((
1374                    default.as_str().expect("scalar default"),
1375                    &property["x-lemma-value"],
1376                ));
1377            }
1378            if let Some(examples) = property.get("examples") {
1379                literals.push((
1380                    examples[0].as_str().expect("scalar example"),
1381                    &property["x-lemma-suggestion"],
1382                ));
1383            }
1384            for (literal, value) in literals {
1385                assert!(
1386                    pattern_accepts(pattern, literal),
1387                    "{name}: '{literal}' vs {pattern}"
1388                );
1389                assert_literal_denotes(name, literal, value, specification);
1390                checked += 1;
1391            }
1392        }
1393        assert_eq!(checked, 8);
1394    }
1395
1396    #[test]
1397    fn pattern_and_parser_agree_on_samples() {
1398        let (_, show, document) = document();
1399        let cases: [(&str, &[&str], &[&str]); 4] = [
1400            (
1401                "quantity",
1402                &["0", "12.5", "-3", "1_000", "1_000.25"],
1403                &["1,000", "12 eur", "abc", ""],
1404            ),
1405            (
1406                "price",
1407                &["10 eur", "2.5 cent", "-1 eur", "1_000 eur"],
1408                &["84eur", "10 usd", "10", "eur 10", "10 eur extra"],
1409            ),
1410            (
1411                "rate",
1412                &["21%", "5%%", "0.5 percent", "2 permille"],
1413                &["21", "21 %", "21 basis_points", "%"],
1414            ),
1415            (
1416                "bps",
1417                &["500 basis_points", "5%", "5%%", "5 percent"],
1418                &["500 bps", "5", "5 basis_point"],
1419            ),
1420        ];
1421        for (name, accepted, rejected) in cases {
1422            let property = &document["properties"][name];
1423            let pattern = property["pattern"]
1424                .as_str()
1425                .or_else(|| property["oneOf"][0]["pattern"].as_str())
1426                .expect("pattern");
1427            let specification = &show.data[name].lemma_type.specifications;
1428            for literal in accepted {
1429                assert!(
1430                    pattern_accepts(pattern, literal),
1431                    "{name}: pattern must accept '{literal}'"
1432                );
1433                assert!(
1434                    parses(literal, specification),
1435                    "{name}: parser must accept '{literal}'"
1436                );
1437            }
1438            for literal in rejected {
1439                assert!(
1440                    !pattern_accepts(pattern, literal),
1441                    "{name}: pattern must reject '{literal}'"
1442                );
1443                assert!(
1444                    !parses(literal, specification),
1445                    "{name}: parser must reject '{literal}'"
1446                );
1447            }
1448        }
1449    }
1450
1451    fn identity(repository: Option<&str>, spec: &str) -> SpecIdentity {
1452        SpecIdentity {
1453            repository: repository.map(str::to_string),
1454            spec: spec.to_string(),
1455            effective: crate::parsing::ast::EffectiveDate::Origin,
1456        }
1457    }
1458
1459    #[test]
1460    fn row_def_name_is_injective_and_component_safe() {
1461        assert_eq!(row_def_name(&identity(None, "line")), "LemmaRow_line");
1462        assert_eq!(
1463            row_def_name(&identity(None, "tax_bracket")),
1464            "LemmaRow_tax__bracket"
1465        );
1466        let names = [
1467            row_def_name(&identity(Some("@org/repo"), "line")),
1468            row_def_name(&identity(None, "_org_repo_line")),
1469            row_def_name(&identity(None, "line")),
1470        ];
1471        for name in &names {
1472            assert!(
1473                name.chars()
1474                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
1475                "{name}"
1476            );
1477        }
1478        assert_ne!(names[0], names[1]);
1479        assert_ne!(names[0], names[2]);
1480    }
1481}