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