Skip to main content

backhopper_core/model/
normalised.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Erlang source text reduced to a reformatting-proof, single-line form.
6
7use std::fmt;
8use std::sync::Arc;
9
10use serde::{Deserialize, Serialize};
11
12use crate::errors::NameError;
13
14const EMPTY_BODY: &str = "%empty%";
15const BARE_QUESTION_MARK: &str = "%bare_question_mark%";
16
17/// Comments stripped, whitespace runs collapsed to one space, ends
18/// trimmed, raw line breaks inside literals written as escapes: enough
19/// to equate reformatting without equating texts that differ.
20fn normalise(text: &str) -> String {
21    let mut out = String::with_capacity(text.len());
22    let mut chars = text.chars();
23    let mut in_string = false;
24    let mut in_atom = false;
25    while let Some(c) = chars.next() {
26        if in_string || in_atom {
27            push_literal_char(&mut out, c);
28            match c {
29                '\\' => {
30                    if let Some(next) = chars.next() {
31                        push_literal_char(&mut out, next);
32                    }
33                }
34                '"' if in_string => in_string = false,
35                '\'' if in_atom => in_atom = false,
36                _ => {}
37            }
38            continue;
39        }
40        match c {
41            '%' => {
42                for next in chars.by_ref() {
43                    if next == '\n' {
44                        break;
45                    }
46                }
47                if !out.ends_with(' ') {
48                    out.push(' ');
49                }
50            }
51            '"' => {
52                in_string = true;
53                out.push(c);
54            }
55            '\'' => {
56                in_atom = true;
57                out.push(c);
58            }
59            '$' => {
60                out.push(c);
61                if let Some(next) = chars.next() {
62                    push_literal_char(&mut out, next);
63                }
64            }
65            c if c.is_whitespace() => {
66                if !out.ends_with(' ') {
67                    out.push(' ');
68                }
69            }
70            c => out.push(c),
71        }
72    }
73    out.trim().to_owned()
74}
75
76fn push_literal_char(out: &mut String, c: char) {
77    match c {
78        '\n' => out.push_str("\\n"),
79        '\r' => out.push_str("\\r"),
80        c => out.push(c),
81    }
82}
83
84fn not_normalised(kind: &'static str, text: &str) -> NameError {
85    NameError::Invalid {
86        kind,
87        value: text.to_owned(),
88        reason: "not in normalised form",
89    }
90}
91
92/// The body of a `-define`, normalised. An empty body is the token
93/// `%empty%`, which cannot be an Erlang token sequence, so no text a
94/// snapshot line holds reads as an absent body.
95#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
96#[serde(try_from = "String", into = "String")]
97pub struct NormalisedMacroBody(Arc<str>);
98
99impl NormalisedMacroBody {
100    #[must_use]
101    pub fn from_source(source: &str) -> Self {
102        let text = normalise(source);
103        let text = match text.as_str() {
104            "" => EMPTY_BODY,
105            "?" => BARE_QUESTION_MARK,
106            other => other,
107        };
108        Self(Arc::from(text))
109    }
110
111    pub fn from_stored(text: &str) -> Result<Self, NameError> {
112        let body = Self::from_source(text);
113        if body.as_str() == text {
114            Ok(body)
115        } else {
116            Err(not_normalised("macro body", text))
117        }
118    }
119
120    #[must_use]
121    pub fn as_str(&self) -> &str {
122        &self.0
123    }
124
125    /// True when the body is only string literals, so it cannot change
126    /// what a use of the macro compiles against.
127    #[must_use]
128    pub fn is_string_literal(&self) -> bool {
129        let mut chars = self.0.chars();
130        let mut in_string = false;
131        let mut seen = false;
132        while let Some(c) = chars.next() {
133            match (in_string, c) {
134                (true, '\\') => {
135                    chars.next();
136                }
137                (true, '"') => in_string = false,
138                (true, _) => {}
139                (false, '"') => {
140                    in_string = true;
141                    seen = true;
142                }
143                (false, c) if c.is_whitespace() => {}
144                (false, _) => return false,
145            }
146        }
147        seen && !in_string
148    }
149}
150
151impl fmt::Display for NormalisedMacroBody {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.write_str(self.as_str())
154    }
155}
156
157impl TryFrom<String> for NormalisedMacroBody {
158    type Error = NameError;
159
160    fn try_from(text: String) -> Result<Self, NameError> {
161        Self::from_stored(&text)
162    }
163}
164
165impl From<NormalisedMacroBody> for String {
166    fn from(body: NormalisedMacroBody) -> Self {
167        body.as_str().to_owned()
168    }
169}
170
171/// A record field's default expression, normalised. A field without a
172/// default is `None` on the field, never an empty default.
173#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
174#[serde(try_from = "String", into = "String")]
175pub struct NormalisedDefault(Arc<str>);
176
177impl NormalisedDefault {
178    #[must_use]
179    pub fn from_source(source: &str) -> Option<Self> {
180        let text = normalise(source);
181        (!text.is_empty()).then(|| Self(Arc::from(text)))
182    }
183
184    pub fn from_stored(text: &str) -> Result<Self, NameError> {
185        Self::from_source(text)
186            .filter(|default| default.as_str() == text)
187            .ok_or_else(|| not_normalised("record field default", text))
188    }
189
190    #[must_use]
191    pub fn as_str(&self) -> &str {
192        &self.0
193    }
194}
195
196impl fmt::Display for NormalisedDefault {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        f.write_str(self.as_str())
199    }
200}
201
202impl TryFrom<String> for NormalisedDefault {
203    type Error = NameError;
204
205    fn try_from(text: String) -> Result<Self, NameError> {
206        Self::from_stored(&text)
207    }
208}
209
210impl From<NormalisedDefault> for String {
211    fn from(default: NormalisedDefault) -> Self {
212        default.as_str().to_owned()
213    }
214}