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