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