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
335/// Run-data boolean text (`BooleanValue::from_str`): case-insensitive, surrounding whitespace.
336const BOOLEAN_TEXT_PATTERN: &str =
337    r"^\s*(?:[Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[Yy][Ee][Ss]|[Nn][Oo])\s*$";
338
339/// The Show document: a Draft 2020-12 JSON Schema for the spec's evaluation-data inputs.
340///
341/// Standard keywords carry what JSON Schema expresses (`type`, `pattern`, `enum`,
342/// `default`, `examples`, `items`, ...); `x-lemma-*` keywords carry the rest, including
343/// the rule graph under `x-lemma-rules`. `$defs` holds one row object schema per
344/// instance-list row spec.
345pub fn show_document(engine: &Engine, show: &DomainShow) -> JsonValue {
346    let consumer = match engine.plan_matching_show(show) {
347        Some(plan) => Some(plan),
348        None if !show_has_instance_list(show) => None,
349        None => panic!(
350            "BUG: Show '{}' has instance-list inputs but no matching execution plan",
351            show.spec
352        ),
353    };
354    let mut rows = RowSchemas::new(engine, consumer);
355    let mut document = Map::new();
356    document.insert("$schema".to_string(), json!(JSON_SCHEMA_DIALECT));
357    document.insert("title".to_string(), json!(show.spec));
358    if let Some(commentary) = &show.commentary {
359        document.insert("description".to_string(), json!(commentary));
360    }
361    document.extend(input_object_schema(show, &mut rows));
362    let defs = rows.into_defs();
363    if !defs.is_empty() {
364        document.insert("$defs".to_string(), JsonValue::Object(defs));
365    }
366    if let Some(repository) = &show.repository {
367        document.insert("x-lemma-repository".to_string(), json!(repository));
368    }
369    if let Some(from) = &show.effective_from {
370        document.insert("x-lemma-effective-from".to_string(), to_json(from));
371    }
372    if let Some(to) = &show.effective_to {
373        document.insert("x-lemma-effective-to".to_string(), to_json(to));
374    }
375    document.insert("x-lemma-start-line".to_string(), json!(show.start_line));
376    if let Some(source_type) = &show.source_type {
377        document.insert("x-lemma-source-type".to_string(), to_json(source_type));
378    }
379    document.insert("x-lemma-meta".to_string(), to_json(&show.meta));
380    let types: IndexMap<&String, LemmaType> = show
381        .types
382        .iter()
383        .map(|(name, lemma_type)| (name, LemmaType::from(lemma_type)))
384        .collect();
385    document.insert("x-lemma-types".to_string(), to_json(&types));
386    let rules: IndexMap<&String, ShowRule> = show
387        .rules
388        .iter()
389        .map(|(name, rule)| (name, ShowRule::from(rule)))
390        .collect();
391    document.insert("x-lemma-rules".to_string(), to_json(&rules));
392    JsonValue::Object(document)
393}
394
395fn to_json<T: Serialize>(value: &T) -> JsonValue {
396    serde_json::to_value(value).expect("BUG: Show document parts serialize to JSON")
397}
398
399fn show_has_instance_list(show: &DomainShow) -> bool {
400    show.data.values().any(|data| {
401        matches!(
402            &data.lemma_type.specifications,
403            DomainTypeSpecification::List { element, .. }
404                if matches!(&element.specifications, DomainTypeSpecification::Spec { .. })
405        )
406    })
407}
408
409/// `type`, `additionalProperties`, `properties` and `required` for the data of `show`.
410/// `required`: data a rule needs that has no committed value. Lists are never required:
411/// an absent list is empty.
412fn input_object_schema(show: &DomainShow, rows: &mut RowSchemas) -> Map<String, JsonValue> {
413    let mut properties = Map::new();
414    let mut required = Vec::new();
415    for (name, data) in &show.data {
416        properties.insert(
417            name.clone(),
418            property_schema(data, rows, &data.needed_by_rules),
419        );
420        let is_list = matches!(
421            data.lemma_type.specifications,
422            DomainTypeSpecification::List { .. }
423        );
424        if data.value.is_none() && !data.needed_by_rules.is_empty() && !is_list {
425            required.push(name.clone());
426        }
427    }
428    required.sort();
429    let mut schema = Map::new();
430    schema.insert("type".to_string(), json!("object"));
431    schema.insert("additionalProperties".to_string(), json!(true));
432    schema.insert("properties".to_string(), JsonValue::Object(properties));
433    if !required.is_empty() {
434        schema.insert("required".to_string(), json!(required));
435    }
436    schema
437}
438
439/// Row `$defs` object: local types, plus any other `show.data` slots on the row
440/// (imported fields, local instance lists). `needed_by_rules` come from the
441/// consumer plan's instance-list [`NeededByRules::fields`], not the row Show.
442fn row_object_schema(
443    show: &DomainShow,
444    identity: &SpecIdentity,
445    rows: &mut RowSchemas,
446) -> Map<String, JsonValue> {
447    let mut properties = Map::new();
448    let mut required = Vec::new();
449    for (name, lemma_type) in &show.types {
450        let stub;
451        let entry = match show.data.get(name) {
452            Some(data) => data,
453            None => {
454                stub = DomainShowData {
455                    lemma_type: lemma_type.clone(),
456                    path: Vec::new(),
457                    value: None,
458                    suggestion: None,
459                    needed_by_rules: Vec::new(),
460                };
461                &stub
462            }
463        };
464        let is_list = matches!(
465            entry.lemma_type.specifications,
466            DomainTypeSpecification::List { .. }
467        );
468        if entry.value.is_none() && !is_list {
469            required.push(name.clone());
470        }
471        let needed = rows.row_field_needed_by_rules(identity, name);
472        properties.insert(name.clone(), property_schema(entry, rows, &needed));
473    }
474    for (name, data) in &show.data {
475        if properties.contains_key(name) {
476            continue;
477        }
478        let is_list = matches!(
479            data.lemma_type.specifications,
480            DomainTypeSpecification::List { .. }
481        );
482        if data.value.is_none() && !is_list {
483            required.push(name.clone());
484        }
485        let needed = rows.row_field_needed_by_rules(identity, name);
486        properties.insert(name.clone(), property_schema(data, rows, &needed));
487    }
488    required.sort();
489    let mut schema = Map::new();
490    schema.insert("type".to_string(), json!("object"));
491    schema.insert("additionalProperties".to_string(), json!(true));
492    schema.insert("properties".to_string(), JsonValue::Object(properties));
493    if !required.is_empty() {
494        schema.insert("required".to_string(), json!(required));
495    }
496    schema
497}
498
499fn property_schema(
500    data: &DomainShowData,
501    rows: &mut RowSchemas,
502    needed_by_rules: &[String],
503) -> JsonValue {
504    let mut schema = type_schema(&data.lemma_type.specifications, rows);
505    let help = specification_help(&data.lemma_type.specifications);
506    if !help.is_empty() {
507        schema.insert("description".to_string(), json!(help));
508    }
509    if let Some(value) = &data.value {
510        // A reference field is wiring, not a run-data literal. Emitting a partial
511        // default would replace source rows and drop those bindings.
512        if !rule_result_value_has_reference(value) {
513            schema.insert(
514                "default".to_string(),
515                input_literal(value, &data.lemma_type, rows),
516            );
517        }
518    }
519    if let Some(suggestion) = &data.suggestion {
520        schema.insert(
521            "examples".to_string(),
522            json!([input_literal(suggestion, &data.lemma_type, rows)]),
523        );
524    }
525    schema.insert(
526        "x-lemma-type".to_string(),
527        lemma_type_without_standard_keywords(&data.lemma_type),
528    );
529    let path: Vec<PathSegment> = data.path.iter().map(PathSegment::from).collect();
530    schema.insert("x-lemma-path".to_string(), to_json(&path));
531    schema.insert(
532        "x-lemma-needed-by-rules".to_string(),
533        json!(needed_by_rules),
534    );
535    if let Some(value) = &data.value {
536        schema.insert(
537            "x-lemma-value".to_string(),
538            to_json(&RuleResultValue::from(value)),
539        );
540    }
541    if let Some(suggestion) = &data.suggestion {
542        schema.insert(
543            "x-lemma-suggestion".to_string(),
544            to_json(&RuleResultValue::from(suggestion)),
545        );
546    }
547    JsonValue::Object(schema)
548}
549
550/// Standard keywords for one type. Lists are a JSON array: rows of an instance list are
551/// objects (`$defs` row schema), items of a value list are the element's literal.
552fn type_schema(
553    specification: &DomainTypeSpecification,
554    rows: &mut RowSchemas,
555) -> Map<String, JsonValue> {
556    let DomainTypeSpecification::List { element, .. } = specification else {
557        return scalar_data_schema(specification);
558    };
559    let mut schema = Map::new();
560    let items = match &element.specifications {
561        DomainTypeSpecification::Spec { spec } => rows.reference(spec),
562        DomainTypeSpecification::List { .. } => {
563            unreachable!("BUG: lists do not nest; planning rejects a list element list")
564        }
565        element => JsonValue::Object(scalar_data_schema(element)),
566    };
567    schema.insert("type".to_string(), json!("array"));
568    schema.insert("items".to_string(), items);
569    schema.insert(
570        "maxItems".to_string(),
571        json!(rows.engine.limits().max_list_rows),
572    );
573    schema
574}
575
576/// A scalar data slot. Measure and ratio data also accept a single-unit object (`{"eur": "84"}`).
577fn scalar_data_schema(specification: &DomainTypeSpecification) -> Map<String, JsonValue> {
578    let names: Vec<&str> = match specification {
579        DomainTypeSpecification::Measure { units, .. } => {
580            units.iter().map(|unit| unit.name.as_str()).collect()
581        }
582        DomainTypeSpecification::Ratio { units, .. } => {
583            units.iter().map(|unit| unit.name.as_str()).collect()
584        }
585        _ => return scalar_type_schema(specification),
586    };
587    let unit_map = json!({
588        "type": "object",
589        "minProperties": 1,
590        "maxProperties": 1,
591        "propertyNames": {"enum": names},
592        "additionalProperties": {"type": "string", "pattern": number_pattern()}
593    });
594    let mut schema = Map::new();
595    schema.insert(
596        "oneOf".to_string(),
597        json!([
598            JsonValue::Object(scalar_type_schema(specification)),
599            unit_map
600        ]),
601    );
602    schema
603}
604
605fn scalar_type_schema(specification: &DomainTypeSpecification) -> Map<String, JsonValue> {
606    let mut schema = Map::new();
607    match specification {
608        DomainTypeSpecification::Boolean { .. } => {
609            schema.insert(
610                "oneOf".to_string(),
611                json!([
612                    {"type": "boolean"},
613                    {"type": "string", "pattern": BOOLEAN_TEXT_PATTERN}
614                ]),
615            );
616        }
617        DomainTypeSpecification::Text {
618            length, options, ..
619        } => {
620            schema.insert("type".to_string(), json!("string"));
621            if !options.is_empty() {
622                schema.insert("enum".to_string(), json!(options));
623            }
624            if let Some(length) = length {
625                schema.insert("minLength".to_string(), json!(length));
626                schema.insert("maxLength".to_string(), json!(length));
627            }
628        }
629        DomainTypeSpecification::Number { .. } => {
630            schema.insert("type".to_string(), json!("string"));
631            schema.insert("pattern".to_string(), json!(number_pattern()));
632        }
633        DomainTypeSpecification::Measure { units, .. } => {
634            schema.insert("type".to_string(), json!("string"));
635            schema.insert("pattern".to_string(), json!(measure_pattern(units)));
636        }
637        DomainTypeSpecification::Ratio { units, .. } => {
638            schema.insert("type".to_string(), json!("string"));
639            schema.insert("pattern".to_string(), json!(ratio_pattern(units)));
640        }
641        DomainTypeSpecification::NumberRange { .. } => {
642            schema.insert("type".to_string(), json!("string"));
643            schema.insert("pattern".to_string(), json!(range_pattern(&number_body())));
644        }
645        DomainTypeSpecification::MeasureRange { units, .. } => {
646            schema.insert("type".to_string(), json!("string"));
647            schema.insert(
648                "pattern".to_string(),
649                json!(range_pattern(&measure_body(units))),
650            );
651        }
652        DomainTypeSpecification::RatioRange { units, .. } => {
653            schema.insert("type".to_string(), json!("string"));
654            schema.insert(
655                "pattern".to_string(),
656                json!(range_pattern(&ratio_body(units))),
657            );
658        }
659        DomainTypeSpecification::Date { .. }
660        | DomainTypeSpecification::Time { .. }
661        | DomainTypeSpecification::DateRange { .. }
662        | DomainTypeSpecification::TimeRange { .. } => {
663            schema.insert("type".to_string(), json!("string"));
664        }
665        DomainTypeSpecification::List { .. } => {
666            unreachable!("BUG: list schemas are built by type_schema")
667        }
668        DomainTypeSpecification::Spec { spec } => {
669            unreachable!("BUG: spec '{spec}' is a uses alias, never a Show data entry")
670        }
671        DomainTypeSpecification::Veto { .. } => {
672            unreachable!("BUG: veto sentinel type on a Show data entry")
673        }
674        DomainTypeSpecification::Undetermined => {
675            unreachable!("BUG: undetermined sentinel type on a Show data entry")
676        }
677    }
678    schema
679}
680
681fn specification_help(specification: &DomainTypeSpecification) -> String {
682    let mut leaf = specification;
683    while let DomainTypeSpecification::List { element, .. } = leaf {
684        leaf = &element.specifications;
685    }
686    match leaf {
687        DomainTypeSpecification::Boolean { help }
688        | DomainTypeSpecification::Measure { help, .. }
689        | DomainTypeSpecification::MeasureRange { help, .. }
690        | DomainTypeSpecification::Number { help, .. }
691        | DomainTypeSpecification::NumberRange { help, .. }
692        | DomainTypeSpecification::Ratio { help, .. }
693        | DomainTypeSpecification::RatioRange { help, .. }
694        | DomainTypeSpecification::Text { help, .. }
695        | DomainTypeSpecification::Date { help, .. }
696        | DomainTypeSpecification::DateRange { help, .. }
697        | DomainTypeSpecification::Time { help, .. }
698        | DomainTypeSpecification::TimeRange { help, .. } => help.clone(),
699        DomainTypeSpecification::Spec { spec } => spec.to_string(),
700        DomainTypeSpecification::List { .. } => {
701            unreachable!("BUG: list chain is unwound before the leaf match")
702        }
703        DomainTypeSpecification::Veto { .. } | DomainTypeSpecification::Undetermined => {
704            unreachable!("BUG: sentinel type on a Show data entry")
705        }
706    }
707}
708
709/// Run-data number text: an optional sign, digits with single underscores between them (1 to
710/// 28 digits per run), an optional fraction (a leading `.` needs at least one digit), and an
711/// optional exponent of one or two digits. Surrounding whitespace is added by the callers.
712/// Stricter than `NumberLiteral`, which also accepts underscores at the edges and doubled ones.
713fn number_body() -> String {
714    let digits = "[0-9](?:_?[0-9]){0,27}";
715    format!(r"[+-]?(?:{digits}(?:\.(?:{digits})?)?|\.{digits})(?:[eE][+-]?[0-9]{{1,2}})?")
716}
717
718/// A measure value body: a number, whitespace, one of the declared unit names.
719fn measure_body(units: &MeasureUnits) -> String {
720    let names: Vec<&str> = units.iter().map(|unit| unit.name.as_str()).collect();
721    assert!(!names.is_empty(), "BUG: planned measure type has no units");
722    format!(r"{}\s+({})", number_body(), alternation(&names))
723}
724
725/// A ratio value body: a number glued to `%` (only when `percent` is declared) or `%%`
726/// (only when `permille` is declared), or a number, whitespace, and a declared unit name.
727/// A bare number is not a ratio value.
728fn ratio_body(units: &RatioUnits) -> String {
729    let names: Vec<&str> = units.iter().map(|unit| unit.name.as_str()).collect();
730    assert!(!names.is_empty(), "BUG: planned ratio type has no units");
731    let mut alternatives = Vec::new();
732    if names.contains(&"permille") {
733        alternatives.push(format!("{}%%", number_body()));
734    }
735    if names.contains(&"percent") {
736        alternatives.push(format!("{}%", number_body()));
737    }
738    alternatives.push(format!(r"{}\s+({})", number_body(), alternation(&names)));
739    format!("(?:{})", alternatives.join("|"))
740}
741
742/// `pattern` for a number value.
743pub fn number_pattern() -> String {
744    format!(r"^\s*{}\s*$", number_body())
745}
746
747/// `pattern` for a measure value.
748pub fn measure_pattern(units: &MeasureUnits) -> String {
749    format!(r"^\s*{}\s*$", measure_body(units))
750}
751
752/// `pattern` for a ratio value.
753pub fn ratio_pattern(units: &RatioUnits) -> String {
754    format!(r"^\s*{}\s*$", ratio_body(units))
755}
756
757/// `pattern` for a range: two endpoints around `...`, each endpoint a value body.
758fn range_pattern(endpoint: &str) -> String {
759    format!(r"^\s*{endpoint}\s*\.\.\.\s*{endpoint}\s*$")
760}
761
762fn alternation(names: &[&str]) -> String {
763    names
764        .iter()
765        .map(|name| regex_escape(name))
766        .collect::<Vec<_>>()
767        .join("|")
768}
769
770fn regex_escape(text: &str) -> String {
771    let mut escaped = String::with_capacity(text.len());
772    for c in text.chars() {
773        if "\\^$.|?*+()[]{}/-".contains(c) {
774            escaped.push('\\');
775        }
776        escaped.push(c);
777    }
778    escaped
779}
780
781/// Run-data JSON for a committed value or suggestion: a boolean is a JSON boolean, a value
782/// list an array of element literals, an instance list an array of row objects keyed by
783/// field, everything else the literal string.
784fn rule_result_value_has_reference(value: &DomainRuleResultValue) -> bool {
785    if value.reference.is_some() {
786        return true;
787    }
788    if let Some(fields) = &value.fields {
789        if fields.values().any(rule_result_value_has_reference) {
790            return true;
791        }
792    }
793    if let Some(list) = &value.list {
794        if list.iter().any(rule_result_value_has_reference) {
795            return true;
796        }
797    }
798    false
799}
800
801enum InputLiteralTask<'a> {
802    Visit {
803        value: &'a DomainRuleResultValue,
804        lemma_type: Arc<DomainLemmaType>,
805    },
806    CollectArray {
807        len: usize,
808    },
809    CollectObject {
810        keys: Vec<String>,
811    },
812}
813
814fn input_literal(
815    value: &DomainRuleResultValue,
816    lemma_type: &DomainLemmaType,
817    rows: &RowSchemas,
818) -> JsonValue {
819    let mut tasks = vec![InputLiteralTask::Visit {
820        value,
821        lemma_type: Arc::new(lemma_type.clone()),
822    }];
823    let mut converted: Vec<JsonValue> = Vec::new();
824    while let Some(task) = tasks.pop() {
825        match task {
826            InputLiteralTask::Visit { value, lemma_type } => match &lemma_type.specifications {
827                DomainTypeSpecification::Boolean { .. } => converted.push(JsonValue::Bool(
828                    value
829                        .boolean
830                        .expect("BUG: boolean-typed Show value without boolean"),
831                )),
832                DomainTypeSpecification::List { element, .. } => {
833                    let items = value
834                        .list
835                        .as_ref()
836                        .expect("BUG: list-typed Show value without list");
837                    tasks.push(InputLiteralTask::CollectArray { len: items.len() });
838                    match &element.specifications {
839                        DomainTypeSpecification::Spec { spec } => {
840                            let row_show = rows.engine.show_spec(spec);
841                            for row in items.iter().rev() {
842                                let fields = row
843                                    .fields
844                                    .as_ref()
845                                    .expect("BUG: instance list Show row without fields");
846                                let mut keys = Vec::with_capacity(fields.len());
847                                let mut children = Vec::with_capacity(fields.len());
848                                for (name, field) in fields {
849                                    assert!(
850                                        field.reference.is_none(),
851                                        "BUG: input_literal on Show value with reference field '{name}'"
852                                    );
853                                    let field_type = row_show
854                                        .types
855                                        .get(name)
856                                        .or_else(|| row_show.data.get(name).map(|d| &d.lemma_type))
857                                        .unwrap_or_else(|| {
858                                            panic!("BUG: row field '{name}' missing from Show of '{spec}'")
859                                        });
860                                    keys.push(name.clone());
861                                    children.push((field, Arc::new(field_type.clone())));
862                                }
863                                tasks.push(InputLiteralTask::CollectObject { keys });
864                                for (field, field_type) in children.into_iter().rev() {
865                                    tasks.push(InputLiteralTask::Visit {
866                                        value: field,
867                                        lemma_type: field_type,
868                                    });
869                                }
870                            }
871                        }
872                        _ => {
873                            for item in items.iter().rev() {
874                                tasks.push(InputLiteralTask::Visit {
875                                    value: item,
876                                    lemma_type: Arc::clone(element),
877                                });
878                            }
879                        }
880                    }
881                }
882                _ => converted.push(JsonValue::String(
883                    value
884                        .result
885                        .clone()
886                        .expect("BUG: scalar Show value without result"),
887                )),
888            },
889            InputLiteralTask::CollectArray { len } => {
890                let items = converted.split_off(converted.len() - len);
891                converted.push(JsonValue::Array(items));
892            }
893            InputLiteralTask::CollectObject { keys } => {
894                let children = converted.split_off(converted.len() - keys.len());
895                converted.push(JsonValue::Object(keys.into_iter().zip(children).collect()));
896            }
897        }
898    }
899    converted
900        .pop()
901        .expect("BUG: Show value conversion yields exactly one JSON value")
902}
903
904/// The API `LemmaType` minus what standard keywords already state: `help` (`description`)
905/// and text `options`/`length` (`enum`, `minLength`/`maxLength`).
906fn lemma_type_without_standard_keywords(lemma_type: &DomainLemmaType) -> JsonValue {
907    let mut value = to_json(&LemmaType::from(lemma_type));
908    strip_standard_keywords(&mut value);
909    value
910}
911
912fn strip_standard_keywords(value: &mut JsonValue) {
913    let mut pending = vec![value];
914    while let Some(value) = pending.pop() {
915        let object = value
916            .as_object_mut()
917            .expect("BUG: LemmaType serializes to a JSON object");
918        object.remove("help");
919        if object.get("kind") == Some(&json!("text")) {
920            object.remove("options");
921            object.remove("length");
922        }
923        if let Some(element) = object.get_mut("element") {
924            pending.push(element);
925        }
926    }
927}
928
929/// Row object schemas of instance lists, one `$defs` entry per row spec. A self-list's row
930/// schema refers to itself.
931struct RowSchemas<'a> {
932    engine: &'a Engine,
933    /// Consumer plan whose instance-list [`crate::planning::execution_plan::NeededByRules::fields`]
934    /// annotate `$defs`.
935    consumer: Option<&'a ExecutionPlan>,
936    started: BTreeSet<SpecIdentity>,
937    pending: Vec<SpecIdentity>,
938    defs: BTreeMap<String, JsonValue>,
939}
940
941impl<'a> RowSchemas<'a> {
942    fn new(engine: &'a Engine, consumer: Option<&'a ExecutionPlan>) -> Self {
943        Self {
944            engine,
945            consumer,
946            started: BTreeSet::new(),
947            pending: Vec::new(),
948            defs: BTreeMap::new(),
949        }
950    }
951
952    /// Builds every row schema reached from the document. Row schemas that reference
953    /// further row specs queue them on `pending`, so the walk never nests.
954    fn into_defs(mut self) -> Map<String, JsonValue> {
955        while let Some(identity) = self.pending.pop() {
956            let show = self.engine.show_spec(&identity);
957            let mut schema = Map::new();
958            schema.insert("title".to_string(), json!(format!("{identity} row")));
959            schema.extend(row_object_schema(&show, &identity, &mut self));
960            self.defs
961                .insert(row_def_name(&identity), JsonValue::Object(schema));
962        }
963        self.defs.into_iter().collect()
964    }
965
966    /// Parent Show.rules keys that need `field` on rows of `identity`.
967    fn row_field_needed_by_rules(&self, identity: &SpecIdentity, field: &str) -> Vec<String> {
968        let Some(consumer) = self.consumer else {
969            return Vec::new();
970        };
971        consumer
972            .row_field_needed_by_rules(identity, field)
973            .into_iter()
974            .map(|rule_position| {
975                consumer
976                    .rules
977                    .get_index(rule_position as usize)
978                    .expect("BUG: needed_by_rules field consumer out of consumer.rules range")
979                    .1
980                    .path
981                    .input_key()
982            })
983            .collect()
984    }
985
986    /// `$ref` to the row schema of `identity`; the schema is queued on first use.
987    fn reference(&mut self, identity: &SpecIdentity) -> JsonValue {
988        let name = row_def_name(identity);
989        if self.started.insert(identity.clone()) {
990            self.pending.push(identity.clone());
991        }
992        json!({ "$ref": format!("#/$defs/{name}") })
993    }
994}
995
996/// `LemmaRow_` plus the row spec identity, injectively escaped to `[A-Za-z0-9_-]` (a valid
997/// OpenAPI component name): `_` becomes `__`, any other character `_<hex code point>_`.
998/// One name per identity, so documents of different specs agree on shared row specs.
999pub fn row_def_name(identity: &SpecIdentity) -> String {
1000    let mut name = String::from("LemmaRow_");
1001    for c in identity.to_string().chars() {
1002        if c.is_ascii_alphanumeric() || c == '-' {
1003            name.push(c);
1004        } else if c == '_' {
1005            name.push_str("__");
1006        } else {
1007            name.push_str(&format!("_{:x}_", c as u32));
1008        }
1009    }
1010    name
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016    use crate::parsing::ast::Span;
1017    use crate::parsing::source::{Source, SourceType};
1018    use crate::planning::semantics::parse_value_from_string;
1019
1020    const INPUTS: &str = r#"
1021spec inputs
1022data money: measure -> unit eur: 1 -> unit cent: 0.01
1023data price: money -> suggest 10 eur
1024data deposit: 250 cent
1025data rate: ratio -> suggest 21%
1026data bps: ratio -> unit basis_points: 10000 -> unit percent: 100 -> suggest 500 basis_points
1027data fee: 3%
1028data quantity: number -> suggest 12.5
1029data offset: -3
1030data express: boolean -> suggest true
1031data code: text -> option "A" -> option "B" -> suggest "B"
1032data label: text -> length 3
1033data start: date -> suggest 2026-01-01
1034data window: number range -> suggest 1...5
1035data tags: number list
1036  -> with 5, 10
1037rule r_price: price + deposit
1038rule r_rate: rate
1039rule r_bps: bps
1040rule r_fee: fee
1041rule r_quantity: quantity + offset
1042rule r_express: express
1043rule r_code: code
1044rule r_label: label
1045rule r_start: start
1046rule r_window: window
1047rule r_tags: tags -> sum
1048"#;
1049
1050    fn engine() -> Engine {
1051        let mut engine = Engine::new();
1052        engine
1053            .load([(SourceType::Volatile, INPUTS.to_string())])
1054            .expect("load inputs");
1055        engine
1056    }
1057
1058    fn document() -> (Engine, DomainShow, JsonValue) {
1059        let engine = engine();
1060        let show = engine.show(None, "inputs", None).expect("show inputs");
1061        let document = show_document(&engine, &show);
1062        (engine, show, document)
1063    }
1064
1065    fn source() -> Source {
1066        Source::new(
1067            SourceType::Volatile,
1068            Span {
1069                start: 0,
1070                end: 0,
1071                line: 1,
1072                col: 1,
1073            },
1074        )
1075    }
1076
1077    fn pattern_accepts(pattern: &str, literal: &str) -> bool {
1078        jsonschema::is_valid(
1079            &json!({ "type": "string", "pattern": pattern }),
1080            &json!(literal),
1081        )
1082    }
1083
1084    fn parses(literal: &str, specification: &DomainTypeSpecification) -> bool {
1085        parse_value_from_string(literal, specification, &source()).is_ok()
1086    }
1087
1088    fn decimal(text: &JsonValue) -> rust_decimal::Decimal {
1089        text.as_str()
1090            .expect("API decimal is a string")
1091            .parse()
1092            .expect("API decimal parses")
1093    }
1094
1095    /// The parsed literal names the same magnitude, in the same unit, as the API value.
1096    fn assert_literal_denotes(
1097        name: &str,
1098        literal: &str,
1099        value: &JsonValue,
1100        specification: &DomainTypeSpecification,
1101    ) {
1102        let parsed = parse_value_from_string(literal, specification, &source())
1103            .unwrap_or_else(|error| panic!("{name}: '{literal}' must parse: {error}"));
1104        assert_parsed_denotes(name, literal, &parsed, value);
1105    }
1106
1107    fn assert_parsed_denotes(
1108        name: &str,
1109        literal: &str,
1110        parsed: &crate::parsing::ast::Value,
1111        value: &JsonValue,
1112    ) {
1113        use crate::parsing::ast::Value;
1114        match parsed {
1115            Value::Number(magnitude) => {
1116                assert_eq!(*magnitude, decimal(&value["number"]), "{name}: '{literal}'");
1117            }
1118            Value::NumberWithUnit(magnitude, unit) => {
1119                let units = value
1120                    .get("measure")
1121                    .or_else(|| value.get("ratio"))
1122                    .unwrap_or_else(|| panic!("{name}: unit map in {value}"));
1123                assert_eq!(
1124                    *magnitude,
1125                    decimal(&units[unit]),
1126                    "{name}: '{literal}' vs {value}"
1127                );
1128            }
1129            Value::Range(left, right) => {
1130                assert_parsed_denotes(name, literal, left, &value["range"]["from"]);
1131                assert_parsed_denotes(name, literal, right, &value["range"]["to"]);
1132            }
1133            other => panic!("{name}: '{literal}' parsed to unexpected {other:?}"),
1134        }
1135    }
1136
1137    #[test]
1138    fn spec_level_keywords() {
1139        let (_, _, document) = document();
1140        assert_eq!(document["$schema"], JSON_SCHEMA_DIALECT);
1141        assert_eq!(document["title"], "inputs");
1142        assert_eq!(document["type"], "object");
1143        assert_eq!(document["additionalProperties"], true);
1144        assert!(document.get("$defs").is_none());
1145        assert_eq!(document["x-lemma-start-line"], 2);
1146        assert!(document["x-lemma-rules"]["r_price"].is_object());
1147    }
1148
1149    #[test]
1150    fn required_is_sorted_needed_inputs_without_committed_value() {
1151        let (_, _, document) = document();
1152        assert_eq!(
1153            document["required"],
1154            json!([
1155                "bps", "code", "express", "label", "price", "quantity", "rate", "start", "window"
1156            ])
1157        );
1158    }
1159
1160    #[test]
1161    fn standard_keywords_per_type() {
1162        let (_, _, document) = document();
1163        let properties = &document["properties"];
1164        assert_eq!(properties["express"]["oneOf"][0]["type"], "boolean");
1165        assert_eq!(properties["express"]["examples"], json!([true]));
1166        assert_eq!(properties["code"]["type"], "string");
1167        assert_eq!(properties["code"]["enum"], json!(["A", "B"]));
1168        assert_eq!(properties["label"]["minLength"], 3);
1169        assert_eq!(properties["label"]["maxLength"], 3);
1170        assert_eq!(properties["quantity"]["pattern"], number_pattern());
1171        assert_eq!(
1172            properties["price"]["oneOf"][1]["propertyNames"]["enum"],
1173            json!(["eur", "cent"])
1174        );
1175        assert_eq!(
1176            properties["rate"]["oneOf"][1]["propertyNames"]["enum"],
1177            json!(["percent", "permille"])
1178        );
1179        assert_eq!(
1180            properties["bps"]["oneOf"][1]["propertyNames"]["enum"],
1181            json!(["percent", "permille", "basis_points"])
1182        );
1183        assert_eq!(properties["start"]["type"], "string");
1184        assert!(properties["start"].get("pattern").is_none());
1185        assert!(properties["start"].get("format").is_none());
1186        assert_eq!(properties["window"]["type"], "string");
1187        assert!(properties["window"]["pattern"].is_string());
1188        let tags = &properties["tags"];
1189        assert_eq!(tags["type"], "array");
1190        assert_eq!(
1191            tags["items"],
1192            json!({ "type": "string", "pattern": number_pattern() })
1193        );
1194        assert_eq!(
1195            tags["maxItems"],
1196            crate::ResourceLimits::default().max_list_rows
1197        );
1198        assert_eq!(tags["default"], json!(["5", "10"]));
1199    }
1200
1201    #[test]
1202    fn x_lemma_type_omits_what_standard_keywords_state() {
1203        let (_, _, document) = document();
1204        for (name, property) in document["properties"].as_object().expect("properties") {
1205            let lemma_type = &property["x-lemma-type"];
1206            assert!(lemma_type.get("help").is_none(), "{name}: {lemma_type}");
1207            assert!(lemma_type.get("options").is_none(), "{name}: {lemma_type}");
1208            assert!(lemma_type.get("length").is_none(), "{name}: {lemma_type}");
1209            if let Some(element) = lemma_type.get("element") {
1210                assert!(element.get("help").is_none(), "{name}: {element}");
1211            }
1212            assert!(property.get("x-lemma-path").is_some(), "{name}");
1213            assert!(property.get("x-lemma-needed-by-rules").is_some(), "{name}");
1214        }
1215        assert_eq!(
1216            document["properties"]["price"]["x-lemma-type"]["kind"],
1217            "measure"
1218        );
1219        assert!(document["properties"]["price"]["x-lemma-type"]["units"].is_array());
1220    }
1221
1222    fn value_json_has_reference(value: &JsonValue) -> bool {
1223        if value.get("reference").is_some() {
1224            return true;
1225        }
1226        if let Some(fields) = value.get("fields").and_then(|v| v.as_object()) {
1227            if fields.values().any(value_json_has_reference) {
1228                return true;
1229            }
1230        }
1231        if let Some(list) = value.get("list").and_then(|v| v.as_array()) {
1232            if list.iter().any(value_json_has_reference) {
1233                return true;
1234            }
1235        }
1236        false
1237    }
1238
1239    /// The evaluation-data JSON a typed value stands for, derived from the API value alone.
1240    fn evaluate_data_json_of(value: &JsonValue) -> JsonValue {
1241        if let Some(boolean) = value.get("boolean") {
1242            return boolean.clone();
1243        }
1244        if let Some(items) = value.get("list") {
1245            return JsonValue::Array(
1246                items
1247                    .as_array()
1248                    .expect("list")
1249                    .iter()
1250                    .map(evaluate_data_json_of)
1251                    .collect(),
1252            );
1253        }
1254        if let Some(fields) = value.get("fields") {
1255            return JsonValue::Object(
1256                fields
1257                    .as_object()
1258                    .expect("fields")
1259                    .iter()
1260                    .filter(|(_, field)| field.get("reference").is_none())
1261                    .map(|(name, field)| (name.clone(), evaluate_data_json_of(field)))
1262                    .collect(),
1263            );
1264        }
1265        if value.get("reference").is_some() {
1266            panic!("BUG: reference field is not a run-data literal");
1267        }
1268        value["result"].clone()
1269    }
1270
1271    #[test]
1272    fn instance_list_with_references_has_value_but_no_default() {
1273        let mut engine = Engine::new();
1274        engine
1275            .load([(
1276                SourceType::Volatile,
1277                r#"
1278spec processing
1279data type: text
1280data weight: number
1281data enabled: boolean
1282spec line_item
1283data weight: number
1284data roast: boolean
1285uses processing list
1286  -> with weight: weight, type: "roast", enabled: roast
1287rule cost: processing -> where enabled -> count
1288"#
1289                .to_string(),
1290            )])
1291            .expect("load");
1292        let show = engine.show(None, "line_item", None).expect("show");
1293        let processing = show.data.get("processing").expect("processing");
1294        let value = processing.value.as_ref().expect("value");
1295        assert!(
1296            rule_result_value_has_reference(value),
1297            "expected reference in value: {value:?}"
1298        );
1299        let document = show_document(&engine, &show);
1300        let property = &document["properties"]["processing"];
1301        assert!(property.get("x-lemma-value").is_some(), "{property}");
1302        assert!(
1303            property.get("default").is_none(),
1304            "default must be omitted when fields reference data: {property}"
1305        );
1306    }
1307
1308    #[test]
1309    fn default_and_examples_agree_with_typed_value_and_suggestion() {
1310        let (_, _, document) = document();
1311        let mut checked = 0;
1312        for (name, property) in document["properties"].as_object().expect("properties") {
1313            match (property.get("default"), property.get("x-lemma-value")) {
1314                (Some(default), Some(value)) => {
1315                    assert_eq!(default, &evaluate_data_json_of(value), "{name}");
1316                    checked += 1;
1317                }
1318                (None, Some(value)) => {
1319                    assert!(
1320                        value_json_has_reference(value),
1321                        "{name}: value without default must carry a reference field"
1322                    );
1323                }
1324                (None, None) => {}
1325                other => {
1326                    panic!("{name}: default and x-lemma-value disagree on presence: {other:?}")
1327                }
1328            }
1329            match (property.get("examples"), property.get("x-lemma-suggestion")) {
1330                (Some(examples), Some(suggestion)) => {
1331                    assert_eq!(
1332                        examples,
1333                        &json!([evaluate_data_json_of(suggestion)]),
1334                        "{name}"
1335                    );
1336                    checked += 1;
1337                }
1338                (None, None) => {}
1339                other => panic!(
1340                    "{name}: examples and x-lemma-suggestion disagree on presence: {other:?}"
1341                ),
1342            }
1343        }
1344        assert_eq!(checked, 12);
1345    }
1346
1347    #[test]
1348    fn emitted_literals_match_pattern_and_denote_their_value() {
1349        let (_, show, document) = document();
1350        let mut checked = 0;
1351        for (name, property) in document["properties"].as_object().expect("properties") {
1352            let Some(pattern) = property["pattern"]
1353                .as_str()
1354                .or_else(|| property["oneOf"][0]["pattern"].as_str())
1355            else {
1356                continue;
1357            };
1358            let specification = &show.data[name].lemma_type.specifications;
1359            let mut literals: Vec<(&str, &JsonValue)> = Vec::new();
1360            if let Some(default) = property.get("default") {
1361                literals.push((
1362                    default.as_str().expect("scalar default"),
1363                    &property["x-lemma-value"],
1364                ));
1365            }
1366            if let Some(examples) = property.get("examples") {
1367                literals.push((
1368                    examples[0].as_str().expect("scalar example"),
1369                    &property["x-lemma-suggestion"],
1370                ));
1371            }
1372            for (literal, value) in literals {
1373                assert!(
1374                    pattern_accepts(pattern, literal),
1375                    "{name}: '{literal}' vs {pattern}"
1376                );
1377                assert_literal_denotes(name, literal, value, specification);
1378                checked += 1;
1379            }
1380        }
1381        assert_eq!(checked, 8);
1382    }
1383
1384    #[test]
1385    fn pattern_and_parser_agree_on_samples() {
1386        let (_, show, document) = document();
1387        let cases: [(&str, &[&str], &[&str]); 4] = [
1388            (
1389                "quantity",
1390                &["0", "12.5", "-3", "1_000", "1_000.25"],
1391                &["1,000", "12 eur", "abc", ""],
1392            ),
1393            (
1394                "price",
1395                &["10 eur", "2.5 cent", "-1 eur", "1_000 eur"],
1396                &["84eur", "10 usd", "10", "eur 10", "10 eur extra"],
1397            ),
1398            (
1399                "rate",
1400                &["21%", "5%%", "0.5 percent", "2 permille"],
1401                &["21", "21 %", "21 basis_points", "%"],
1402            ),
1403            (
1404                "bps",
1405                &["500 basis_points", "5%", "5%%", "5 percent"],
1406                &["500 bps", "5", "5 basis_point"],
1407            ),
1408        ];
1409        for (name, accepted, rejected) in cases {
1410            let property = &document["properties"][name];
1411            let pattern = property["pattern"]
1412                .as_str()
1413                .or_else(|| property["oneOf"][0]["pattern"].as_str())
1414                .expect("pattern");
1415            let specification = &show.data[name].lemma_type.specifications;
1416            for literal in accepted {
1417                assert!(
1418                    pattern_accepts(pattern, literal),
1419                    "{name}: pattern must accept '{literal}'"
1420                );
1421                assert!(
1422                    parses(literal, specification),
1423                    "{name}: parser must accept '{literal}'"
1424                );
1425            }
1426            for literal in rejected {
1427                assert!(
1428                    !pattern_accepts(pattern, literal),
1429                    "{name}: pattern must reject '{literal}'"
1430                );
1431                assert!(
1432                    !parses(literal, specification),
1433                    "{name}: parser must reject '{literal}'"
1434                );
1435            }
1436        }
1437    }
1438
1439    fn identity(repository: Option<&str>, spec: &str) -> SpecIdentity {
1440        SpecIdentity {
1441            repository: repository.map(str::to_string),
1442            spec: spec.to_string(),
1443            effective: crate::parsing::ast::EffectiveDate::Origin,
1444        }
1445    }
1446
1447    #[test]
1448    fn row_def_name_is_injective_and_component_safe() {
1449        assert_eq!(row_def_name(&identity(None, "line")), "LemmaRow_line");
1450        assert_eq!(
1451            row_def_name(&identity(None, "tax_bracket")),
1452            "LemmaRow_tax__bracket"
1453        );
1454        let names = [
1455            row_def_name(&identity(Some("@org/repo"), "line")),
1456            row_def_name(&identity(None, "_org_repo_line")),
1457            row_def_name(&identity(None, "line")),
1458        ];
1459        for name in &names {
1460            assert!(
1461                name.chars()
1462                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
1463                "{name}"
1464            );
1465        }
1466        assert_ne!(names[0], names[1]);
1467        assert_ne!(names[0], names[2]);
1468    }
1469}