Skip to main content

fml_model/
print.rs

1//! StructureMap → FML.
2//!
3//! The output compiles back to the same StructureMap: simple copies and batch identity rules are
4//! written in their short forms, generated rule names are left out, documentation becomes
5//! comments. The text is then laid out by the formatter.
6
7use std::fmt::Write;
8
9use fml_syntax::{FormatOptions, format, quote_fhirpath_string};
10use serde_json::Value;
11
12use crate::compile::{
13    AUTO_VARIABLE, BATCH_UNNAMED_PREFIX, SOURCE_ELEMENT_EXTENSION, TARGET_ELEMENT_EXTENSION,
14    make_id, source_path,
15};
16use crate::model::{Group, Parameter, ParameterValue, Rule, Source, StructureMap, Target};
17
18/// Writes a StructureMap as FML.
19pub fn print(map: &StructureMap) -> String {
20    let mut printer = Printer { out: String::new() };
21    printer.map(map);
22    let text = printer.out;
23    format(&text, &FormatOptions::default()).unwrap_or(text)
24}
25
26struct Printer {
27    out: String,
28}
29
30impl Printer {
31    fn line(&mut self, depth: usize, text: &str) {
32        for _ in 0..depth {
33            self.out.push_str("  ");
34        }
35        self.out.push_str(text);
36        self.out.push('\n');
37    }
38
39    /// Documentation as comments, written so the compiler reads back the same text. The compiler
40    /// trims each comment and joins the comments before a statement with `\r\n`, so text made of
41    /// trimmed single lines becomes `//` lines and anything else one `/* */` block.
42    fn documentation(&mut self, depth: usize, documentation: Option<&str>) {
43        let Some(documentation) = documentation.filter(|d| !d.is_empty()) else {
44            return;
45        };
46        let as_lines = documentation
47            .split("\r\n")
48            .all(|part| !part.contains(['\n', '\r']) && part == part.trim());
49        if as_lines {
50            for part in documentation.split("\r\n") {
51                self.line(depth, format!("// {part}").trim_end());
52            }
53        } else if !documentation.contains("*/") && documentation == documentation.trim() {
54            self.line(depth, &format!("/* {documentation} */"));
55        } else {
56            for line in documentation.lines() {
57                self.line(depth, format!("// {}", line.trim()).trim_end());
58            }
59        }
60    }
61
62    fn map(&mut self, map: &StructureMap) {
63        let metadata = |key: &str, value: &Option<String>| {
64            value
65                .as_ref()
66                .map(|v| format!("/// {key} = {}", quote_fhirpath_string(v)))
67        };
68        let mut header: Vec<String> = Vec::new();
69        header.extend(metadata("url", &map.url));
70        header.extend(metadata("version", &map.version));
71        header.extend(metadata("name", &map.name));
72        header.extend(metadata("title", &map.title));
73        header.extend(metadata("status", &map.status));
74        if let Some(description) = &map.description
75            && map.title.as_ref() != Some(description)
76        {
77            header.push(
78                if description.contains(['\n', '\r'])
79                    && !description.contains("\"\"\"")
80                    && !description.ends_with('"')
81                {
82                    format!("/// description = \"\"\"{description}\"\"\"")
83                } else {
84                    format!("/// description = {}", quote_fhirpath_string(description))
85                },
86            );
87        }
88        if let Some(experimental) = map.experimental {
89            header.push(format!("/// experimental = {experimental}"));
90        }
91        header.extend(metadata("date", &map.date));
92        header.extend(metadata("publisher", &map.publisher));
93        header.extend(metadata("purpose", &map.purpose));
94        header.extend(metadata("copyright", &map.copyright));
95        for line in header {
96            self.line(0, &line);
97        }
98        self.out.push('\n');
99
100        for contained in &map.contained {
101            if contained.get("resourceType").and_then(Value::as_str) == Some("ConceptMap") {
102                self.concept_map(contained);
103            }
104        }
105        for structure in &map.structure {
106            self.documentation(0, structure.documentation.as_deref());
107            let alias = structure
108                .alias
109                .as_ref()
110                .map(|a| format!(" alias {}", name(a)))
111                .unwrap_or_default();
112            self.line(
113                0,
114                &format!(
115                    "uses {}{alias} as {}",
116                    double_quoted(&structure.url),
117                    structure.mode
118                ),
119            );
120        }
121        self.out.push('\n');
122        for import in &map.import {
123            self.line(0, &format!("imports {}", double_quoted(import)));
124        }
125        self.out.push('\n');
126        for constant in &map.constants {
127            self.line(
128                0,
129                &format!("let {} = {};", name(&constant.name), constant.value),
130            );
131        }
132        self.out.push('\n');
133        for group in &map.group {
134            self.group(group);
135            self.out.push('\n');
136        }
137    }
138
139    fn concept_map(&mut self, concept_map: &Value) {
140        let id = concept_map.get("id").and_then(Value::as_str).unwrap_or("");
141        self.line(0, &format!("conceptmap {} {{", double_quoted(id)));
142        let groups: Vec<&Value> = concept_map
143            .get("group")
144            .and_then(Value::as_array)
145            .map(|g| g.iter().collect())
146            .unwrap_or_default();
147        let text = |value: &Value, key: &str| -> String {
148            value
149                .get(key)
150                .and_then(Value::as_str)
151                .unwrap_or("")
152                .to_owned()
153        };
154        // One prefix per system, in order of appearance: s, t, u, …
155        let mut prefixes: Vec<(String, char)> = Vec::new();
156        let prefix_for = |system: String, prefixes: &mut Vec<(String, char)>| -> char {
157            if let Some((_, p)) = prefixes.iter().find(|(s, _)| *s == system) {
158                return *p;
159            }
160            let next = (b's' + prefixes.len() as u8) as char;
161            prefixes.push((system, next));
162            next
163        };
164        let mut lines = Vec::new();
165        for group in &groups {
166            let source = prefix_for(text(group, "source"), &mut prefixes);
167            if let Some(mode) = group
168                .get("unmapped")
169                .and_then(|u| u.get("mode"))
170                .and_then(Value::as_str)
171            {
172                let mode = if mode == "use-source-code" {
173                    "provided"
174                } else {
175                    mode
176                };
177                lines.push(format!("unmapped for {source} = {mode}"));
178            }
179            let Some(target_system) = group.get("target").and_then(Value::as_str) else {
180                continue;
181            };
182            let target = prefix_for(target_system.to_owned(), &mut prefixes);
183            for element in group
184                .get("element")
185                .and_then(Value::as_array)
186                .into_iter()
187                .flatten()
188            {
189                let code = text(element, "code");
190                for mapping in element
191                    .get("target")
192                    .and_then(Value::as_array)
193                    .into_iter()
194                    .flatten()
195                {
196                    let relation = match mapping
197                        .get("relationship")
198                        .or_else(|| mapping.get("equivalence"))
199                        .and_then(Value::as_str)
200                        .unwrap_or("related-to")
201                    {
202                        "equivalent" | "equal" => "==",
203                        "not-related-to" | "disjoint" | "unmatched" => "!=",
204                        "source-is-narrower-than-target" | "narrower" | "specializes" => "<=",
205                        "source-is-broader-than-target" | "wider" | "subsumes" => ">=",
206                        _ => "-",
207                    };
208                    lines.push(format!(
209                        "{source}:{} {relation} {target}:{}",
210                        code_text(&code),
211                        code_text(&text(mapping, "code"))
212                    ));
213                }
214            }
215        }
216        for (system, prefix) in &prefixes {
217            self.line(1, &format!("prefix {prefix} = {}", double_quoted(system)));
218        }
219        self.out.push('\n');
220        for line in lines {
221            self.line(1, &line);
222        }
223        self.line(0, "}");
224        self.out.push('\n');
225    }
226
227    fn group(&mut self, group: &Group) {
228        self.documentation(0, group.documentation.as_deref());
229        let mut header = format!("group {}(", name(&group.name));
230        for (index, input) in group.input.iter().enumerate() {
231            if index > 0 {
232                header.push_str(", ");
233            }
234            let _ = write!(header, "{} {}", input.mode, name(&input.name));
235            if let Some(type_name) = &input.type_name {
236                let _ = write!(header, " : {type_name}");
237            }
238        }
239        header.push(')');
240        if let Some(extends) = &group.extends {
241            let _ = write!(header, " extends {}", name(extends));
242        }
243        match group.type_mode.as_deref() {
244            Some("types") => header.push_str(" <<types>>"),
245            Some("type-and-types") => header.push_str(" <<type+>>"),
246            _ => {}
247        }
248        header.push_str(" {");
249        self.line(0, &header);
250        self.rules(&group.rule, 1);
251        self.line(0, "}");
252    }
253
254    fn rules(&mut self, rules: &[Rule], depth: usize) {
255        let mut index = 0;
256        while index < rules.len() {
257            let end = batch_end(rules, index);
258            if end > index {
259                self.batch(&rules[index..=end], depth);
260                index = end + 1;
261            } else {
262                self.rule(&rules[index], depth);
263                index += 1;
264            }
265        }
266    }
267
268    /// `src -> tgt : a, b, c "prefix";`
269    fn batch(&mut self, rules: &[Rule], depth: usize) {
270        let first = &rules[0];
271        self.documentation(depth, first.documentation.as_deref());
272        let prefix = batch_prefix(first).unwrap_or_default();
273        let fields: Vec<String> = rules
274            .iter()
275            .map(|r| name(r.source[0].element.as_deref().unwrap_or("")))
276            .collect();
277        let rule_name = if prefix == BATCH_UNNAMED_PREFIX {
278            String::new()
279        } else {
280            format!(" {}", double_quoted(&prefix))
281        };
282        self.line(
283            depth,
284            &format!(
285                "{} -> {} : {}{rule_name};",
286                first.source[0].context,
287                first.target[0].context.as_deref().unwrap_or(""),
288                fields.join(", ")
289            ),
290        );
291    }
292
293    fn rule(&mut self, rule: &Rule, depth: usize) {
294        self.documentation(depth, rule.documentation.as_deref());
295        let abbreviate = is_abbreviated_copy(rule);
296        let sources: Vec<String> = rule
297            .source
298            .iter()
299            .map(|s| source_text(s, abbreviate))
300            .collect();
301        let mut text = sources.join(", ");
302        if !rule.target.is_empty() {
303            let targets: Vec<String> = rule
304                .target
305                .iter()
306                .map(|t| target_text(t, abbreviate))
307                .collect();
308            let _ = write!(text, " -> {}", targets.join(", "));
309        }
310        if !rule.dependent.is_empty() && !abbreviate {
311            let calls: Vec<String> = rule
312                .dependent
313                .iter()
314                .map(|d| {
315                    let arguments: Vec<String> = d.parameter.iter().map(parameter_text).collect();
316                    format!("{}({})", name(&d.name), arguments.join(", "))
317                })
318                .collect();
319            let _ = write!(text, " then {}", calls.join(", "));
320            if !rule.rule.is_empty() {
321                text.push_str(" {");
322            }
323        } else if !rule.rule.is_empty() {
324            text.push_str(" then {");
325        }
326        let name_suffix = match &rule.name {
327            Some(rule_name) if Some(rule_name) != generated_name(rule).as_ref() => {
328                format!(" {}", double_quoted(rule_name))
329            }
330            _ => String::new(),
331        };
332        if rule.rule.is_empty() {
333            self.line(depth, &format!("{text}{name_suffix};"));
334        } else {
335            self.line(depth, &text);
336            self.rules(&rule.rule, depth + 1);
337            self.line(depth, &format!("}}{name_suffix};"));
338        }
339    }
340}
341
342/// The name the compiler generates for an unnamed rule, which the printer can leave out.
343fn generated_name(rule: &Rule) -> Option<String> {
344    let [source] = rule.source.as_slice() else {
345        return None;
346    };
347    let path = source_path(source);
348    if path.is_empty() {
349        return None;
350    }
351    Some(match &source.type_name {
352        Some(type_name) => {
353            let mut chars = type_name.chars();
354            let capitalized: String = chars
355                .next()
356                .map(|c| c.to_uppercase().chain(chars).collect())
357                .unwrap_or_default();
358            path + &capitalized
359        }
360        None => path,
361    })
362}
363
364/// The dependent group older reference compilers (before 2025) added to every simple copy.
365const LEGACY_DEFAULT_GROUP: &str = "DefaultMappingGroupAnonymousAlias";
366
367/// Whether the rule's dependents are only the legacy implicit default-group call.
368fn has_only_legacy_default_dependent(rule: &Rule) -> bool {
369    match rule.dependent.as_slice() {
370        [] => true,
371        [dependent] => {
372            dependent.name == LEGACY_DEFAULT_GROUP
373                && dependent
374                    .parameter
375                    .iter()
376                    .all(|p| matches!(&p.value, ParameterValue::Id(id) if id == AUTO_VARIABLE))
377        }
378        _ => false,
379    }
380}
381
382/// The simple copy form, written `src.a -> tgt.a`: both sides bound to the automatic variable and
383/// an argument-less `create`.
384fn is_abbreviated_copy(rule: &Rule) -> bool {
385    let ([source], [target]) = (rule.source.as_slice(), rule.target.as_slice()) else {
386        return false;
387    };
388    source.element.is_some()
389        && source.variable.as_deref() == Some(AUTO_VARIABLE)
390        && target.context.is_some()
391        && target.element.is_some()
392        && target.variable.as_deref() == Some(AUTO_VARIABLE)
393        && matches!(target.transform.as_deref(), None | Some("create"))
394        && target.parameter.is_empty()
395        && has_only_legacy_default_dependent(rule)
396        && rule.rule.is_empty()
397}
398
399/// A rule that a batch identity rule produces: `src.a -> tgt.a` with the automatic variable.
400fn is_identity(rule: &Rule) -> bool {
401    let ([source], [target]) = (rule.source.as_slice(), rule.target.as_slice()) else {
402        return false;
403    };
404    is_abbreviated_copy(rule)
405        && source.extension.is_empty()
406        && target.extension.is_empty()
407        && source.type_name.is_none()
408        && source.min.is_none()
409        && source.list_mode.is_none()
410        && source.default_value.is_none()
411        && source.condition.is_none()
412        && source.check.is_none()
413        && source.log_message.is_none()
414        && target.list_mode.is_empty()
415        && source.element == target.element
416}
417
418/// The batch name prefix of an identity rule named `prefix` + field.
419fn batch_prefix(rule: &Rule) -> Option<String> {
420    if !is_identity(rule) {
421        return None;
422    }
423    let suffix = make_id(rule.source[0].element.as_deref()?);
424    let name = rule.name.as_deref()?;
425    let prefix = name.strip_suffix(suffix.as_str())?;
426    (!suffix.is_empty() && !prefix.is_empty()).then(|| prefix.to_owned())
427}
428
429/// The last index of a batch identity run starting at `start`, or `start` when there is none.
430fn batch_end(rules: &[Rule], start: usize) -> usize {
431    let Some(prefix) = batch_prefix(&rules[start]) else {
432        return start;
433    };
434    let contexts = |rule: &Rule| {
435        (
436            rule.source[0].context.clone(),
437            rule.target[0].context.clone(),
438        )
439    };
440    let first = contexts(&rules[start]);
441    let mut end = start;
442    for (index, rule) in rules.iter().enumerate().skip(start + 1) {
443        if rule.documentation.is_some()
444            || batch_prefix(rule).as_deref() != Some(prefix.as_str())
445            || contexts(rule) != first
446        {
447            break;
448        }
449        end = index;
450    }
451    // A single rule is a batch only when its name could not come from the plain form.
452    if end == start && prefix != BATCH_UNNAMED_PREFIX {
453        return start;
454    }
455    end
456}
457
458fn source_text(source: &Source, abbreviate: bool) -> String {
459    let mut text = source.context.clone();
460    for segment in source.element.iter().chain(
461        source
462            .extension
463            .iter()
464            .filter(|e| e.url == SOURCE_ELEMENT_EXTENSION)
465            .filter_map(|e| e.value_string.as_ref()),
466    ) {
467        text.push('.');
468        text.push_str(&name(segment));
469    }
470    if let Some(type_name) = &source.type_name {
471        let _ = write!(text, " : {type_name}");
472    }
473    if let Some(min) = source.min {
474        let _ = write!(text, " {min}..{}", source.max.as_deref().unwrap_or("*"));
475    }
476    if let Some(mode) = &source.list_mode {
477        let _ = write!(text, " {mode}");
478    }
479    if let Some(default) = &source.default_value {
480        let _ = write!(text, " default ({default})");
481    }
482    if !abbreviate && let Some(variable) = &source.variable {
483        let _ = write!(text, " as {}", name(variable));
484    }
485    if let Some(condition) = &source.condition {
486        let _ = write!(text, " where ({condition})");
487    }
488    if let Some(check) = &source.check {
489        let _ = write!(text, " check ({check})");
490    }
491    if let Some(log) = &source.log_message {
492        let _ = write!(text, " log ({log})");
493    }
494    text
495}
496
497fn target_text(target: &Target, abbreviate: bool) -> String {
498    let mut text = String::new();
499    if let Some(context) = &target.context {
500        text.push_str(context);
501        for segment in target.element.iter().chain(
502            target
503                .extension
504                .iter()
505                .filter(|e| e.url == TARGET_ELEMENT_EXTENSION)
506                .filter_map(|e| e.value_string.as_ref()),
507        ) {
508            text.push('.');
509            text.push_str(&name(segment));
510        }
511    }
512    if !abbreviate && let Some(transform) = &target.transform {
513        if target.context.is_some() {
514            text.push_str(" = ");
515        }
516        let values: Vec<&ParameterValue> = target.parameter.iter().map(|p| &p.value).collect();
517        match (transform.as_str(), values.as_slice()) {
518            ("copy", [_]) => text.push_str(&parameter_text(&target.parameter[0])),
519            ("evaluate", [ParameterValue::String(expression)]) => {
520                let _ = write!(text, "({expression})");
521            }
522            ("evaluate", [_, ParameterValue::String(expression)]) => {
523                let _ = write!(
524                    text,
525                    "evaluate({}, {expression})",
526                    parameter_text(&target.parameter[0])
527                );
528            }
529            (transform, _) => {
530                let arguments: Vec<String> = target.parameter.iter().map(parameter_text).collect();
531                let _ = write!(text, "{transform}({})", arguments.join(", "));
532            }
533        }
534    }
535    if !abbreviate && let Some(variable) = &target.variable {
536        let _ = write!(text, " as {}", name(variable));
537    }
538    for mode in &target.list_mode {
539        let _ = write!(text, " {mode}");
540        if mode == "share"
541            && let Some(id) = &target.list_rule_id
542        {
543            let _ = write!(text, " {}", name(id));
544        }
545    }
546    text
547}
548
549fn parameter_text(parameter: &Parameter) -> String {
550    match &parameter.value {
551        ParameterValue::Id(id) => id.split('.').map(name).collect::<Vec<_>>().join("."),
552        ParameterValue::String(value) => quote_fhirpath_string(value),
553        ParameterValue::Boolean(value) => value.to_string(),
554        ParameterValue::Integer(value) => value.to_string(),
555        ParameterValue::Decimal(value) => value.to_string(),
556        ParameterValue::Date(value) | ParameterValue::DateTime(value) => format!("@{value}"),
557        ParameterValue::Time(value) => format!("@T{value}"),
558    }
559}
560
561/// An identifier, in backticks when it is not a plain name.
562fn name(text: &str) -> String {
563    let plain = text
564        .chars()
565        .next()
566        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
567        && text.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
568    if plain {
569        text.to_owned()
570    } else {
571        format!("`{}`", text.replace('\\', "\\\\").replace('`', "\\`"))
572    }
573}
574
575fn code_text(code: &str) -> String {
576    let plain = !code.is_empty() && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
577    let starts_like_name = code
578        .chars()
579        .next()
580        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
581    if plain && (starts_like_name || code.chars().all(|c| c.is_ascii_digit())) {
582        code.to_owned()
583    } else {
584        double_quoted(code)
585    }
586}
587
588fn double_quoted(text: &str) -> String {
589    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
590}