Skip to main content

fml_syntax/
format.rs

1//! Formatter: canonical layout for FML, keeping every comment.
2//!
3//! Declarations and rules go one per line; rule blocks and concept maps are indented. Spacing
4//! inside a statement follows the style of the Mapping Language IG examples (`source src : Type`,
5//! `a -> b`, `where (x)`, `create('X')`). Blank lines between statements are kept, collapsed to
6//! one, and sections of the file are separated by one blank line.
7//!
8//! A statement that has a comment in its middle is written as it was, so no comment ever moves
9//! away from the code it describes. Output is re-parsed and compared token by token with the
10//! input; the formatter returns an error instead of output that would change the map.
11
12use std::fmt;
13
14use rowan::NodeOrToken;
15
16use crate::SyntaxKind::{self, *};
17use crate::ast::{AstNode, SourceFile, unescape};
18use crate::{Diagnostic, SyntaxNode, SyntaxToken, parse};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct FormatOptions {
22    /// Spaces per indentation level.
23    pub indent_width: usize,
24    /// Indent with tabs instead of spaces.
25    pub use_tabs: bool,
26}
27
28impl Default for FormatOptions {
29    fn default() -> Self {
30        Self {
31            indent_width: 2,
32            use_tabs: false,
33        }
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum FormatError {
39    /// The source has syntax errors; it is not formatted so nothing is lost.
40    Syntax(Vec<Diagnostic>),
41    /// The formatted text would not parse back to the same map. This is a formatter bug.
42    Unstable(String),
43}
44
45impl fmt::Display for FormatError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            FormatError::Syntax(diagnostics) => {
49                write!(f, "cannot format: {} syntax error(s)", diagnostics.len())
50            }
51            FormatError::Unstable(reason) => {
52                write!(f, "formatter produced different code: {reason}")
53            }
54        }
55    }
56}
57
58impl std::error::Error for FormatError {}
59
60/// Formats FML source text.
61pub fn format(text: &str, options: &FormatOptions) -> Result<String, FormatError> {
62    let parse = parse(text);
63    if parse.has_errors() {
64        return Err(FormatError::Syntax(parse.diagnostics().to_vec()));
65    }
66    let mut output = format_tree(&parse.tree(), options);
67    if uses_crlf(text) {
68        output = output.replace("\r\n", "\n").replace('\n', "\r\n");
69    }
70    verify(&parse.syntax(), &output)?;
71    Ok(output)
72}
73
74/// Whether the text's first line ends with `\r\n`; the formatter keeps that line ending.
75fn uses_crlf(text: &str) -> bool {
76    text.find('\n')
77        .is_some_and(|index| text[..index].ends_with('\r'))
78}
79
80/// Formats a parsed tree without checking it first. Prefer [`format`], which refuses trees with
81/// syntax errors and verifies the result.
82pub fn format_tree(file: &SourceFile, options: &FormatOptions) -> String {
83    let mut formatter = Formatter {
84        options,
85        out: String::new(),
86        depth: 0,
87        last: None,
88    };
89    formatter.items(file.syntax(), Container::File);
90    let mut out = formatter.out;
91    let trimmed = out.trim_end().len();
92    out.truncate(trimmed);
93    if !out.is_empty() {
94        out.push('\n');
95    }
96    out
97}
98
99/// Checks that `output` holds the same tokens and comments as `original`.
100fn verify(original: &SyntaxNode, output: &str) -> Result<(), FormatError> {
101    let reparsed = parse(output);
102    if reparsed.has_errors() {
103        return Err(FormatError::Unstable("output has syntax errors".into()));
104    }
105    let tokens = |node: &SyntaxNode| -> Vec<(SyntaxKind, String)> {
106        node.descendants_with_tokens()
107            .filter_map(NodeOrToken::into_token)
108            .filter(|t| t.kind() != Whitespace)
109            .map(|t| (t.kind(), t.text().trim_end().to_owned()))
110            .collect()
111    };
112    let before = tokens(original);
113    let after = tokens(&reparsed.syntax());
114    if before.len() != after.len() {
115        return Err(FormatError::Unstable(format!(
116            "{} tokens became {}",
117            before.len(),
118            after.len()
119        )));
120    }
121    if let Some((a, b)) = before.iter().zip(&after).find(|(a, b)| a != b) {
122        return Err(FormatError::Unstable(format!("`{}` became `{}`", a.1, b.1)));
123    }
124    Ok(())
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum Container {
129    File,
130    Block,
131}
132
133/// Where a statement belongs in the file, for blank lines between sections.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135enum Section {
136    Metadata,
137    ConceptMap,
138    Map,
139    Uses,
140    Imports,
141    Constants,
142    Group,
143    Other,
144}
145
146impl Section {
147    fn of(kind: SyntaxKind) -> Section {
148        match kind {
149            Metadata => Section::Metadata,
150            ConceptMap => Section::ConceptMap,
151            MapDeclaration => Section::Map,
152            UsesDeclaration => Section::Uses,
153            ImportsDeclaration => Section::Imports,
154            ConstantDeclaration => Section::Constants,
155            Group => Section::Group,
156            _ => Section::Other,
157        }
158    }
159
160    /// Two statements of this section are separated by a blank line even if written together.
161    fn spaced(self) -> bool {
162        matches!(self, Section::Group | Section::ConceptMap)
163    }
164}
165
166enum Item {
167    Node(SyntaxNode),
168    Comment(SyntaxToken),
169}
170
171struct Entry {
172    item: Item,
173    /// Line breaks in the source between the previous element and this one.
174    newlines: usize,
175}
176
177struct Formatter<'a> {
178    options: &'a FormatOptions,
179    out: String,
180    depth: usize,
181    /// The last token written on the current line, for spacing decisions.
182    last: Option<SyntaxToken>,
183}
184
185impl Formatter<'_> {
186    fn newline(&mut self, blank: bool) {
187        let trimmed = self.out.trim_end_matches([' ', '\t']).len();
188        self.out.truncate(trimmed);
189        if self.out.is_empty() {
190            return;
191        }
192        self.out.push('\n');
193        if blank {
194            self.out.push('\n');
195        }
196        for _ in 0..self.depth {
197            if self.options.use_tabs {
198                self.out.push('\t');
199            } else {
200                self.out
201                    .extend(std::iter::repeat_n(' ', self.options.indent_width));
202            }
203        }
204        self.last = None;
205    }
206
207    /// Writes the statements and comments that are direct children of `node`: the whole file, or
208    /// the part of a block between its braces.
209    fn items(&mut self, node: &SyntaxNode, container: Container) {
210        let entries = collect_entries(node, container);
211        let sections: Vec<Option<Section>> = (0..entries.len())
212            .map(|i| {
213                entries[i..].iter().find_map(|e| match &e.item {
214                    Item::Node(n) => Some(Section::of(n.kind())),
215                    Item::Comment(_) => None,
216                })
217            })
218            .collect();
219        let mut previous: Option<(Section, bool)> = None;
220        for (index, entry) in entries.iter().enumerate() {
221            let first = index == 0;
222            let same_line = entry.newlines == 0 && (!first || container == Container::Block);
223            if let (Item::Comment(comment), true) = (&entry.item, same_line) {
224                self.out.push(' ');
225                self.out.push_str(comment.text());
226                self.last = None;
227                continue;
228            }
229            let mut blank = entry.newlines >= 2 && !first;
230            if container == Container::File
231                && let (Some((previous_section, previous_was_node)), Some(section)) =
232                    (previous, sections[index])
233                && (previous_section != section || (section.spaced() && previous_was_node))
234            {
235                blank = true;
236            }
237            self.newline(blank);
238            match &entry.item {
239                Item::Comment(comment) => {
240                    self.out.push_str(comment.text().trim_end());
241                    self.last = None;
242                    if let Some(section) = sections[index] {
243                        previous = Some((section, false));
244                    }
245                }
246                Item::Node(node) => {
247                    self.node(node);
248                    previous = Some((Section::of(node.kind()), true));
249                }
250            }
251        }
252    }
253
254    fn node(&mut self, node: &SyntaxNode) {
255        if node.kind() == Error || has_loose_comments(node) {
256            return self.verbatim(node);
257        }
258        if matches!(node.kind(), RuleBlock | ConceptMap) {
259            return self.block(node);
260        }
261        for element in node.children_with_tokens() {
262            match element {
263                NodeOrToken::Node(child) => self.node(&child),
264                NodeOrToken::Token(token) if !token.kind().is_trivia() => self.token(&token),
265                NodeOrToken::Token(_) => {}
266            }
267        }
268    }
269
270    /// A node with `{ … }`: tokens before the brace, then one item per line, indented.
271    fn block(&mut self, node: &SyntaxNode) {
272        let mut inside = false;
273        for element in node.children_with_tokens() {
274            match element {
275                NodeOrToken::Token(token) if token.kind() == LBrace && !inside => {
276                    inside = true;
277                    self.token(&token);
278                    let empty = !node
279                        .children_with_tokens()
280                        .skip_while(|e| e.kind() != LBrace)
281                        .skip(1)
282                        .any(|e| e.kind() != RBrace && e.kind() != Whitespace);
283                    if empty {
284                        continue;
285                    }
286                    self.depth += 1;
287                    self.items(node, Container::Block);
288                    self.depth -= 1;
289                    self.newline(false);
290                }
291                NodeOrToken::Token(token) if token.kind() == RBrace => {
292                    self.out.push('}');
293                    self.last = Some(token);
294                    inside = false;
295                }
296                _ if inside => {}
297                NodeOrToken::Node(child) => self.node(&child),
298                NodeOrToken::Token(token) if !token.kind().is_trivia() => self.token(&token),
299                NodeOrToken::Token(_) => {}
300            }
301        }
302    }
303
304    fn token(&mut self, token: &SyntaxToken) {
305        if let Some(last) = &self.last
306            && space_between(last, token)
307        {
308            self.out.push(' ');
309        }
310        self.out.push_str(token.text());
311        self.last = Some(token.clone());
312    }
313
314    /// Writes a node exactly as it appears in the source.
315    fn verbatim(&mut self, node: &SyntaxNode) {
316        let first = node
317            .descendants_with_tokens()
318            .filter_map(NodeOrToken::into_token)
319            .find(|t| !t.kind().is_trivia());
320        if let (Some(last), Some(first)) = (&self.last, &first)
321            && space_between(last, first)
322        {
323            self.out.push(' ');
324        }
325        self.out.push_str(node.text().to_string().trim());
326        self.last = node
327            .descendants_with_tokens()
328            .filter_map(NodeOrToken::into_token)
329            .filter(|t| !t.kind().is_trivia())
330            .last();
331    }
332}
333
334fn collect_entries(node: &SyntaxNode, container: Container) -> Vec<Entry> {
335    let mut entries = Vec::new();
336    let mut newlines = 0;
337    let mut inside = container == Container::File;
338    for element in node.children_with_tokens() {
339        match element {
340            NodeOrToken::Token(token) if token.kind() == LBrace && !inside => inside = true,
341            NodeOrToken::Token(token) if token.kind() == RBrace && inside => break,
342            _ if !inside => {}
343            NodeOrToken::Token(token) if token.kind() == Whitespace => {
344                newlines += line_breaks(token.text());
345            }
346            NodeOrToken::Token(token) if token.kind().is_comment() => entries.push(Entry {
347                item: Item::Comment(token),
348                newlines: std::mem::take(&mut newlines),
349            }),
350            NodeOrToken::Token(_) => {}
351            NodeOrToken::Node(child) => entries.push(Entry {
352                item: Item::Node(child),
353                newlines: std::mem::take(&mut newlines),
354            }),
355        }
356    }
357    entries
358}
359
360fn line_breaks(text: &str) -> usize {
361    text.matches('\n').count() + text.matches('\r').count() - text.matches("\r\n").count()
362}
363
364/// Whether `node` has a comment that the item layout does not place: one inside a statement
365/// rather than between the statements of a block. Nested blocks place their own comments, so
366/// their contents are not searched.
367fn has_loose_comments(node: &SyntaxNode) -> bool {
368    let is_block = matches!(node.kind(), RuleBlock | ConceptMap);
369    let mut after_brace = false;
370    for element in node.children_with_tokens() {
371        match element {
372            NodeOrToken::Token(token) => {
373                after_brace |= token.kind() == LBrace;
374                if token.kind().is_comment() && !(is_block && after_brace) {
375                    return true;
376                }
377            }
378            NodeOrToken::Node(child) => {
379                // Statements inside a block are laid out, and checked, one by one.
380                if !(is_block && after_brace) && contains_comment_outside_blocks(&child) {
381                    return true;
382                }
383            }
384        }
385    }
386    false
387}
388
389fn contains_comment_outside_blocks(node: &SyntaxNode) -> bool {
390    if matches!(node.kind(), RuleBlock | ConceptMap) {
391        return false;
392    }
393    node.children_with_tokens().any(|element| match element {
394        NodeOrToken::Token(token) => token.kind().is_comment(),
395        NodeOrToken::Node(child) => contains_comment_outside_blocks(&child),
396    })
397}
398
399/// Renders a FHIRPath expression in canonical form: one space around binary operators, none
400/// inside paths and calls, comments dropped, strings single-quoted. This is the form StructureMap
401/// stores expressions in, and the one the HL7 reference compiler produces.
402pub fn expression_text(node: &SyntaxNode) -> String {
403    let mut out = String::new();
404    let mut last: Option<SyntaxToken> = None;
405    for token in node
406        .descendants_with_tokens()
407        .filter_map(NodeOrToken::into_token)
408        .filter(|t| !t.kind().is_trivia())
409    {
410        if let Some(previous) = &last
411            && space_between(previous, &token)
412        {
413            out.push(' ');
414        }
415        if token.kind() == DoubleQuotedString {
416            out.push_str(&quote_fhirpath_string(&unescape(token.text())));
417        } else {
418            out.push_str(token.text());
419        }
420        last = Some(token);
421    }
422    out
423}
424
425/// Writes `value` as a single-quoted FHIRPath string literal.
426pub fn quote_fhirpath_string(value: &str) -> String {
427    let mut out = String::with_capacity(value.len() + 2);
428    out.push('\'');
429    for c in value.chars() {
430        match c {
431            '\'' => out.push_str("\\'"),
432            '\\' => out.push_str("\\\\"),
433            '\n' => out.push_str("\\n"),
434            '\r' => out.push_str("\\r"),
435            '\t' => out.push_str("\\t"),
436            '\u{c}' => out.push_str("\\f"),
437            c => out.push(c),
438        }
439    }
440    out.push('\'');
441    out
442}
443
444fn space_between(previous: &SyntaxToken, next: &SyntaxToken) -> bool {
445    let previous_parent = previous.parent().map(|p| p.kind());
446    let next_parent = next.parent().map(|p| p.kind());
447    let same_parent = |kind: SyntaxKind| previous_parent == Some(kind) && next_parent == Some(kind);
448    match (previous.kind(), next.kind()) {
449        (_, Comma | Semicolon | RParen | RBracket | Dot | DotDot) => false,
450        (LParen | LBracket | Dot | DotDot | Percent, _) => false,
451        (_, LBracket) => false,
452        (Plus | Minus, _) if previous_parent == Some(PrefixExpr) => false,
453        (LBrace, RBrace) => false,
454        (_, Colon) if next_parent == Some(IdentityList) => false,
455        (_, LParen) => !matches!(next_parent, Some(ArgumentList | ParameterList)),
456        _ if same_parent(TypeMode) || same_parent(ConceptMapCode) => false,
457        _ if same_parent(ConceptMapRelation) => false,
458        _ if same_parent(QualifiedName) => false,
459        _ => true,
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    fn fmt(text: &str) -> String {
468        format(text, &FormatOptions::default()).unwrap_or_else(|e| panic!("{e}: {e:?}"))
469    }
470
471    #[test]
472    fn normalizes_layout() {
473        let source = r#"/// url='http://x/Map'
474uses "http://hl7.org/fhir/StructureDefinition/Patient" alias P as source
475uses "http://hl7.org/fhir/StructureDefinition/Patient" as target
476group G(source src:P,target tgt) extends Base <<type+>> {
477src.name as n where (n.use='official')->tgt.name as t then {
478n.family->t.family;
479}"named";
480src.a->tgt.b=create('Coding') as c,c.code=(src.x+1);
481}
482group H(source s, target t){}
483"#;
484        insta::assert_snapshot!(fmt(source), @r#"
485        /// url = 'http://x/Map'
486
487        uses "http://hl7.org/fhir/StructureDefinition/Patient" alias P as source
488        uses "http://hl7.org/fhir/StructureDefinition/Patient" as target
489
490        group G(source src : P, target tgt) extends Base <<type+>> {
491          src.name as n where (n.use = 'official') -> tgt.name as t then {
492            n.family -> t.family;
493          } "named";
494          src.a -> tgt.b = create('Coding') as c, c.code = (src.x + 1);
495        }
496
497        group H(source s, target t) {}
498        "#);
499    }
500
501    #[test]
502    fn keeps_comments() {
503        let source = "// header\n\nuses \"u\" as source // why\n\n// group doc\ngroup G(source s, target t) { // opens\n  // first\n  s.a -> t.a; // trailing\n\n\n  s.b /* inline */ -> t.b;\n  // last\n}\n";
504        insta::assert_snapshot!(fmt(source), @r#"
505        // header
506
507        uses "u" as source // why
508
509        // group doc
510        group G(source s, target t) { // opens
511          // first
512          s.a -> t.a; // trailing
513
514          s.b /* inline */ -> t.b;
515          // last
516        }
517        "#);
518    }
519
520    #[test]
521    fn idempotent() {
522        let source =
523            "group G(source s, target t) {\n  s.a as v -> t.a = v, t.b = 'x' share k \"r\";\n}\n";
524        let once = fmt(source);
525        assert_eq!(fmt(&once), once);
526    }
527
528    #[test]
529    fn keeps_crlf_line_endings() {
530        let source =
531            "uses \"u\" as source\r\n\r\n\r\ngroup G(source s, target t) {\r\n  s.a->t.a;\r\n}\r\n";
532        assert_eq!(
533            fmt(source),
534            "uses \"u\" as source\r\n\r\ngroup G(source s, target t) {\r\n  s.a -> t.a;\r\n}\r\n"
535        );
536    }
537
538    #[test]
539    fn refuses_broken_input() {
540        assert!(matches!(
541            format("group G(source s { s.a -> ", &FormatOptions::default()),
542            Err(FormatError::Syntax(_))
543        ));
544    }
545}