Skip to main content

factorio_ir/
lint.rs

1//! Transpile-time safety lints with stable identifiers for `Factorio.toml`.
2
3use std::collections::BTreeMap;
4
5use serde::Deserialize;
6
7use crate::span::SourceLoc;
8
9/// Stable lint identifier used in diagnostics and `[lints]` config keys.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub enum LintId {
12    /// `.unwrap()` is a no-op in Lua (nil is not checked).
13    Unwrap,
14    /// `.expect(...)` is a no-op in Lua (message discarded, nil not checked).
15    Expect,
16    /// Non-`?` format specs (e.g. `{:.2}`) are ignored when lowering.
17    FormatSpec,
18    /// Non-literal array indices are not shifted for Lua's 1-based tables.
19    VariableIndex,
20    /// Identification enum constructors (e.g. `ForceID::Name(...)`) are not lowered; use `.into()`.
21    IdentificationCtor,
22}
23
24impl LintId {
25    /// Config / diagnostic name (`unwrap`, `expect`, ...) for `Factorio.toml` `[lints]`.
26    #[must_use]
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::Unwrap => "unwrap",
30            Self::Expect => "expect",
31            Self::FormatSpec => "format_spec",
32            Self::VariableIndex => "variable_index",
33            Self::IdentificationCtor => "identification_ctor",
34        }
35    }
36
37    /// Rustc-style diagnostic code shown in reports (`E0001`, ...).
38    #[must_use]
39    pub const fn code(self) -> &'static str {
40        match self {
41            Self::Unwrap => "E0001",
42            Self::Expect => "E0002",
43            Self::FormatSpec => "E0003",
44            Self::VariableIndex => "E0004",
45            Self::IdentificationCtor => "E0005",
46        }
47    }
48
49    /// Default severity when unset in `Factorio.toml`.
50    ///
51    /// Most lints default to deny (they can miscompile). `format_spec` only
52    /// drops unsupported precision/width and still emits working Lua, so it
53    /// defaults to warn.
54    #[must_use]
55    pub const fn default_level(self) -> LintLevel {
56        match self {
57            Self::FormatSpec => LintLevel::Warn,
58            Self::Unwrap | Self::Expect | Self::VariableIndex | Self::IdentificationCtor => {
59                LintLevel::Deny
60            }
61        }
62    }
63
64    /// Short help shown under ariadne reports.
65    #[must_use]
66    pub const fn help(self) -> &'static str {
67        match self {
68            Self::Unwrap => "use `if let Some(x) = ...` (or set `[lints] unwrap = \"allow\"`)",
69            Self::Expect => "use `if let Some(x) = ...` (or set `[lints] expect = \"allow\"`)",
70            #[allow(clippy::literal_string_with_formatting_args)]
71            Self::FormatSpec => "only `{}`, `{:?}`, and `{:#?}` are supported when lowering",
72            Self::VariableIndex => {
73                "literal indices are shifted `n -> n+1`; pass a 1-based index or use a literal"
74            }
75            Self::IdentificationCtor => {
76                "pass a payload with `.into()` instead, e.g. `force.into()` or `\"enemy\".into()`"
77            }
78        }
79    }
80
81    /// All built-in lint identifiers.
82    #[must_use]
83    pub const fn all() -> &'static [Self] {
84        &[
85            Self::Unwrap,
86            Self::Expect,
87            Self::FormatSpec,
88            Self::VariableIndex,
89            Self::IdentificationCtor,
90        ]
91    }
92
93    /// Parse a config key into a lint id.
94    #[must_use]
95    pub fn from_config_str(name: &str) -> Option<Self> {
96        Self::all().iter().copied().find(|id| id.as_str() == name)
97    }
98}
99
100impl std::fmt::Display for LintId {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(self.code())
103    }
104}
105
106/// How a lint is treated when it fires.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum LintLevel {
110    /// Do not emit the lint (disabled).
111    Allow,
112    /// Emit a warning; build still succeeds.
113    Warn,
114    /// Emit an error; build fails.
115    #[default]
116    Deny,
117}
118
119impl LintLevel {
120    #[must_use]
121    pub const fn as_str(self) -> &'static str {
122        match self {
123            Self::Allow => "allow",
124            Self::Warn => "warn",
125            Self::Deny => "deny",
126        }
127    }
128}
129
130/// Resolved lint levels from `Factorio.toml` `[lints]` (and defaults).
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct LintConfig {
133    levels: BTreeMap<LintId, LintLevel>,
134}
135
136impl Default for LintConfig {
137    fn default() -> Self {
138        Self {
139            levels: LintId::all()
140                .iter()
141                .map(|id| (*id, id.default_level()))
142                .collect(),
143        }
144    }
145}
146
147impl LintConfig {
148    /// All lints allowed (useful in unit tests that exercise transparent methods).
149    #[must_use]
150    pub fn allow_all() -> Self {
151        Self {
152            levels: LintId::all()
153                .iter()
154                .map(|id| (*id, LintLevel::Allow))
155                .collect(),
156        }
157    }
158
159    /// Apply overrides from a `[lints]` table (`unwrap = "allow"`, ...).
160    ///
161    /// # Errors
162    /// Returns the unknown lint name when a key is not a known [`LintId`].
163    pub fn with_overrides(
164        mut self,
165        overrides: &BTreeMap<String, LintLevel>,
166    ) -> Result<Self, String> {
167        for (name, level) in overrides {
168            let Some(id) = LintId::from_config_str(name) else {
169                let known = LintId::all()
170                    .iter()
171                    .copied()
172                    .map(LintId::as_str)
173                    .collect::<Vec<_>>()
174                    .join(", ");
175                return Err(format!("unknown lint `{name}` (known: {known})"));
176            };
177            self.levels.insert(id, *level);
178        }
179        Ok(self)
180    }
181
182    #[must_use]
183    pub fn level(&self, id: LintId) -> LintLevel {
184        self.levels
185            .get(&id)
186            .copied()
187            .unwrap_or_else(|| id.default_level())
188    }
189
190    #[must_use]
191    pub fn is_allowed(&self, id: LintId) -> bool {
192        matches!(self.level(id), LintLevel::Allow)
193    }
194
195    /// Allow a single lint (builder-style).
196    #[must_use]
197    pub fn allowing(mut self, id: LintId) -> Self {
198        self.levels.insert(id, LintLevel::Allow);
199        self
200    }
201}
202
203/// A single transpile diagnostic tied to a lint code.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Diagnostic {
206    pub id: LintId,
207    pub level: LintLevel,
208    pub message: String,
209    pub loc: SourceLoc,
210}
211
212impl Diagnostic {
213    #[must_use]
214    pub fn new(
215        id: LintId,
216        level: LintLevel,
217        message: impl Into<String>,
218        loc: impl Into<SourceLoc>,
219    ) -> Self {
220        Self {
221            id,
222            level,
223            message: message.into(),
224            loc: loc.into(),
225        }
226    }
227
228    #[must_use]
229    pub const fn is_error(&self) -> bool {
230        matches!(self.level, LintLevel::Deny)
231    }
232}
233
234impl std::fmt::Display for Diagnostic {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        write!(
237            f,
238            "{} {}: {} ({}) at {}",
239            self.level.as_str(),
240            self.id.code(),
241            self.message,
242            self.id.as_str(),
243            self.loc
244        )
245    }
246}
247
248/// Raw `[lints]` table as deserialized from `Factorio.toml`.
249#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
250pub struct LintsTable {
251    #[serde(flatten)]
252    pub levels: BTreeMap<String, LintLevel>,
253}
254
255impl LintsTable {
256    /// Resolve into a [`LintConfig`], validating lint names.
257    ///
258    /// # Errors
259    /// Unknown lint identifiers.
260    pub fn into_config(self) -> Result<LintConfig, String> {
261        LintConfig::default().with_overrides(&self.levels)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn default_levels() {
271        let config = LintConfig::default();
272        assert_eq!(config.level(LintId::Unwrap), LintLevel::Deny);
273        assert_eq!(config.level(LintId::FormatSpec), LintLevel::Warn);
274    }
275
276    #[test]
277    fn overrides_allow_unwrap() {
278        let mut table = BTreeMap::new();
279        table.insert("unwrap".to_string(), LintLevel::Allow);
280        let config = LintConfig::default().with_overrides(&table).unwrap();
281        assert!(config.is_allowed(LintId::Unwrap));
282        assert!(!config.is_allowed(LintId::Expect));
283    }
284
285    #[test]
286    fn rejects_unknown_lint_name() {
287        let mut table = BTreeMap::new();
288        table.insert("not_a_lint".to_string(), LintLevel::Allow);
289        let err = LintConfig::default().with_overrides(&table).unwrap_err();
290        assert!(err.contains("not_a_lint"));
291    }
292
293    #[test]
294    fn lint_codes_are_stable() {
295        assert_eq!(LintId::Unwrap.code(), "E0001");
296        assert_eq!(LintId::Expect.code(), "E0002");
297        assert_eq!(LintId::FormatSpec.code(), "E0003");
298        assert_eq!(LintId::VariableIndex.code(), "E0004");
299        assert_eq!(LintId::IdentificationCtor.code(), "E0005");
300    }
301}