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