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    /// Obsolete: Identification constructors now lower to their payload. Kept so
21    /// existing `[lints] identification_ctor = ...` keys still parse.
22    IdentificationCtor,
23    /// Plain `if option` / `while option` uses Lua truthiness (`Some(false)` is skipped).
24    OptionIf,
25    /// `?` on a value whose type is unknown; lowering assumes Result (`.err` / `.ok`).
26    AmbiguousTry,
27    /// Overlapping Option/Result method (`.map`, ...) without a typed binding.
28    AmbiguousMethod,
29    /// Nested inline `mod` without `#[factorio_rs::export]` is skipped when lowering.
30    SkippedMod,
31    /// Plain `if result` / `while result` is always truthy (Result is a table).
32    ResultIf,
33    /// `Err(nil)` / `Err(None)` collapses with Ok under the `.err == nil` discriminant.
34    ErrNil,
35    /// `?` on a call/method uses Result semantics; Option APIs need a typed binding or `.ok_or`.
36    OptionTry,
37    /// Rust integer `/` truncates; Lua `/` is always float division.
38    IntegerDiv,
39    /// Struct update `..rest` other than `Default::default()` drops fields silently.
40    StructRest,
41}
42
43impl LintId {
44    /// Config / diagnostic name (`unwrap`, `expect`, ...) for `Factorio.toml` `[lints]`.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Unwrap => "unwrap",
49            Self::Expect => "expect",
50            Self::FormatSpec => "format_spec",
51            Self::VariableIndex => "variable_index",
52            Self::IdentificationCtor => "identification_ctor",
53            Self::OptionIf => "option_if",
54            Self::AmbiguousTry => "ambiguous_try",
55            Self::AmbiguousMethod => "ambiguous_method",
56            Self::SkippedMod => "skipped_mod",
57            Self::ResultIf => "result_if",
58            Self::ErrNil => "err_nil",
59            Self::OptionTry => "option_try",
60            Self::IntegerDiv => "integer_div",
61            Self::StructRest => "struct_rest",
62        }
63    }
64
65    /// Rustc-style diagnostic code shown in reports (`E0001`, ...).
66    #[must_use]
67    pub const fn code(self) -> &'static str {
68        match self {
69            Self::Unwrap => "E0001",
70            Self::Expect => "E0002",
71            Self::FormatSpec => "E0003",
72            Self::VariableIndex => "E0004",
73            Self::IdentificationCtor => "E0005",
74            Self::OptionIf => "E0006",
75            Self::AmbiguousTry => "E0007",
76            Self::AmbiguousMethod => "E0008",
77            Self::SkippedMod => "E0009",
78            Self::ResultIf => "E0010",
79            Self::ErrNil => "E0011",
80            Self::OptionTry => "E0012",
81            Self::IntegerDiv => "E0013",
82            Self::StructRest => "E0014",
83        }
84    }
85
86    /// Default severity when unset in `Factorio.toml`.
87    ///
88    /// Most lints default to deny (they can miscompile). `format_spec` only
89    /// drops unsupported precision/width and still emits working Lua, so it
90    /// defaults to warn. `integer_div` defaults to warn because Factorio math is
91    /// often float and operand types are not fully tracked.
92    #[must_use]
93    pub const fn default_level(self) -> LintLevel {
94        match self {
95            Self::FormatSpec | Self::IntegerDiv => LintLevel::Warn,
96            Self::Unwrap
97            | Self::Expect
98            | Self::VariableIndex
99            | Self::OptionIf
100            | Self::AmbiguousTry
101            | Self::AmbiguousMethod
102            | Self::SkippedMod
103            | Self::ResultIf
104            | Self::ErrNil
105            | Self::OptionTry
106            | Self::StructRest => LintLevel::Deny,
107            // Constructors lower to payloads; keep the id for config compatibility only.
108            Self::IdentificationCtor => LintLevel::Allow,
109        }
110    }
111
112    /// Short help shown under ariadne reports.
113    #[must_use]
114    pub const fn help(self) -> &'static str {
115        match self {
116            Self::Unwrap => "use `if let Some(x) = ...` (or set `[lints] unwrap = \"allow\"`)",
117            Self::Expect => "use `if let Some(x) = ...` (or set `[lints] expect = \"allow\"`)",
118            #[allow(clippy::literal_string_with_formatting_args)]
119            Self::FormatSpec => "only `{}`, `{:?}`, and `{:#?}` are supported when lowering",
120            Self::VariableIndex => {
121                "literal indices are shifted `n -> n+1`; pass a 1-based index or use a literal"
122            }
123            Self::IdentificationCtor => {
124                "Identification constructors (`ForceID::Name(...)`) now lower to the payload; this lint is unused"
125            }
126            Self::OptionIf => {
127                "use `if let Some(x) = opt` or `opt.is_some()` (Lua truthiness skips `Some(false)`)"
128            }
129            Self::AmbiguousTry => {
130                "annotate as `Result` / `Option`, or convert with `.ok_or(...)?` for Options"
131            }
132            Self::AmbiguousMethod => {
133                "bind with an explicit `Result` / `Option` type so the correct helper is chosen"
134            }
135            Self::SkippedMod => {
136                "add `#[factorio_rs::export]` on the inline mod, or move items to a file module"
137            }
138            Self::ResultIf => {
139                "use `if let Ok(x) = result` or `result.is_ok()` (a Result table is always truthy in Lua)"
140            }
141            Self::ErrNil => {
142                "use a non-nil error payload (`String`, number, table); `Err(nil)` looks like Ok"
143            }
144            Self::OptionTry => {
145                "bind `let x: Option<_> = api(...); x?` or use `.ok_or(...)?`; for Result calls bind `let r: Result<...> = ...; r?`"
146            }
147            Self::IntegerDiv => {
148                "Lua `/` is float division; use a float operand (`n / 2.0`) or set `[lints] integer_div = \"allow\"`"
149            }
150            Self::StructRest => {
151                "only `..Default::default()` is ignored on purpose; copy fields explicitly or use that form"
152            }
153        }
154    }
155
156    /// All built-in lint identifiers.
157    #[must_use]
158    pub const fn all() -> &'static [Self] {
159        &[
160            Self::Unwrap,
161            Self::Expect,
162            Self::FormatSpec,
163            Self::VariableIndex,
164            Self::IdentificationCtor,
165            Self::OptionIf,
166            Self::AmbiguousTry,
167            Self::AmbiguousMethod,
168            Self::SkippedMod,
169            Self::ResultIf,
170            Self::ErrNil,
171            Self::OptionTry,
172            Self::IntegerDiv,
173            Self::StructRest,
174        ]
175    }
176
177    /// Parse a config key into a lint id.
178    #[must_use]
179    pub fn from_config_str(name: &str) -> Option<Self> {
180        Self::all().iter().copied().find(|id| id.as_str() == name)
181    }
182}
183
184impl std::fmt::Display for LintId {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.code())
187    }
188}
189
190/// How a lint is treated when it fires.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum LintLevel {
194    /// Do not emit the lint (disabled).
195    Allow,
196    /// Emit a warning; build still succeeds.
197    Warn,
198    /// Emit an error; build fails.
199    #[default]
200    Deny,
201}
202
203impl LintLevel {
204    #[must_use]
205    pub const fn as_str(self) -> &'static str {
206        match self {
207            Self::Allow => "allow",
208            Self::Warn => "warn",
209            Self::Deny => "deny",
210        }
211    }
212}
213
214/// Resolved lint levels from `Factorio.toml` `[lints]` (and defaults).
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct LintConfig {
217    levels: BTreeMap<LintId, LintLevel>,
218}
219
220impl Default for LintConfig {
221    fn default() -> Self {
222        Self {
223            levels: LintId::all()
224                .iter()
225                .map(|id| (*id, id.default_level()))
226                .collect(),
227        }
228    }
229}
230
231impl LintConfig {
232    /// All lints allowed (useful in unit tests that exercise transparent methods).
233    #[must_use]
234    pub fn allow_all() -> Self {
235        Self {
236            levels: LintId::all()
237                .iter()
238                .map(|id| (*id, LintLevel::Allow))
239                .collect(),
240        }
241    }
242
243    /// Apply overrides from a `[lints]` table (`unwrap = "allow"`, ...).
244    ///
245    /// # Errors
246    /// Returns the unknown lint name when a key is not a known [`LintId`].
247    pub fn with_overrides(
248        mut self,
249        overrides: &BTreeMap<String, LintLevel>,
250    ) -> Result<Self, String> {
251        for (name, level) in overrides {
252            let Some(id) = LintId::from_config_str(name) else {
253                let known = LintId::all()
254                    .iter()
255                    .copied()
256                    .map(LintId::as_str)
257                    .collect::<Vec<_>>()
258                    .join(", ");
259                return Err(format!("unknown lint `{name}` (known: {known})"));
260            };
261            self.levels.insert(id, *level);
262        }
263        Ok(self)
264    }
265
266    #[must_use]
267    pub fn level(&self, id: LintId) -> LintLevel {
268        self.levels
269            .get(&id)
270            .copied()
271            .unwrap_or_else(|| id.default_level())
272    }
273
274    #[must_use]
275    pub fn is_allowed(&self, id: LintId) -> bool {
276        matches!(self.level(id), LintLevel::Allow)
277    }
278
279    /// Allow a single lint (builder-style).
280    #[must_use]
281    pub fn allowing(mut self, id: LintId) -> Self {
282        self.levels.insert(id, LintLevel::Allow);
283        self
284    }
285}
286
287/// A single transpile diagnostic tied to a lint code.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct Diagnostic {
290    pub id: LintId,
291    pub level: LintLevel,
292    pub message: String,
293    pub loc: SourceLoc,
294}
295
296impl Diagnostic {
297    #[must_use]
298    pub fn new(
299        id: LintId,
300        level: LintLevel,
301        message: impl Into<String>,
302        loc: impl Into<SourceLoc>,
303    ) -> Self {
304        Self {
305            id,
306            level,
307            message: message.into(),
308            loc: loc.into(),
309        }
310    }
311
312    #[must_use]
313    pub const fn is_error(&self) -> bool {
314        matches!(self.level, LintLevel::Deny)
315    }
316}
317
318impl std::fmt::Display for Diagnostic {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        write!(
321            f,
322            "{} {}: {} ({}) at {}",
323            self.level.as_str(),
324            self.id.code(),
325            self.message,
326            self.id.as_str(),
327            self.loc
328        )
329    }
330}
331
332/// Raw `[lints]` table as deserialized from `Factorio.toml`.
333#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
334pub struct LintsTable {
335    #[serde(flatten)]
336    pub levels: BTreeMap<String, LintLevel>,
337}
338
339impl LintsTable {
340    /// Resolve into a [`LintConfig`], validating lint names.
341    ///
342    /// # Errors
343    /// Unknown lint identifiers.
344    pub fn into_config(self) -> Result<LintConfig, String> {
345        LintConfig::default().with_overrides(&self.levels)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn default_levels() {
355        let config = LintConfig::default();
356        assert_eq!(config.level(LintId::Unwrap), LintLevel::Deny);
357        assert_eq!(config.level(LintId::FormatSpec), LintLevel::Warn);
358        assert_eq!(config.level(LintId::ResultIf), LintLevel::Deny);
359        assert_eq!(config.level(LintId::ErrNil), LintLevel::Deny);
360        assert_eq!(config.level(LintId::OptionTry), LintLevel::Deny);
361        assert_eq!(config.level(LintId::IntegerDiv), LintLevel::Warn);
362        assert_eq!(config.level(LintId::StructRest), LintLevel::Deny);
363        assert_eq!(config.level(LintId::IdentificationCtor), LintLevel::Allow);
364    }
365
366    #[test]
367    fn overrides_allow_unwrap() {
368        let mut table = BTreeMap::new();
369        table.insert("unwrap".to_string(), LintLevel::Allow);
370        let config = LintConfig::default().with_overrides(&table).unwrap();
371        assert!(config.is_allowed(LintId::Unwrap));
372        assert!(!config.is_allowed(LintId::Expect));
373    }
374
375    #[test]
376    fn rejects_unknown_lint_name() {
377        let mut table = BTreeMap::new();
378        table.insert("not_a_lint".to_string(), LintLevel::Allow);
379        let err = LintConfig::default().with_overrides(&table).unwrap_err();
380        assert!(err.contains("not_a_lint"));
381    }
382
383    #[test]
384    fn lint_codes_are_stable() {
385        assert_eq!(LintId::Unwrap.code(), "E0001");
386        assert_eq!(LintId::Expect.code(), "E0002");
387        assert_eq!(LintId::FormatSpec.code(), "E0003");
388        assert_eq!(LintId::VariableIndex.code(), "E0004");
389        assert_eq!(LintId::IdentificationCtor.code(), "E0005");
390        assert_eq!(LintId::OptionIf.code(), "E0006");
391        assert_eq!(LintId::AmbiguousTry.code(), "E0007");
392        assert_eq!(LintId::AmbiguousMethod.code(), "E0008");
393        assert_eq!(LintId::SkippedMod.code(), "E0009");
394        assert_eq!(LintId::ResultIf.code(), "E0010");
395        assert_eq!(LintId::ErrNil.code(), "E0011");
396        assert_eq!(LintId::OptionTry.code(), "E0012");
397        assert_eq!(LintId::IntegerDiv.code(), "E0013");
398        assert_eq!(LintId::StructRest.code(), "E0014");
399    }
400}