Skip to main content

lovely/
check.rs

1use crate::config::Config;
2use crate::fsutil;
3use crate::{LovelyError, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Severity {
9    Error,
10    Warning,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Diagnostic {
15    pub id: &'static str,
16    pub severity: Severity,
17    pub message: String,
18    pub path: Option<PathBuf>,
19}
20
21#[derive(Debug, Default, Clone, PartialEq, Eq)]
22pub struct DiagnosticReport {
23    pub diagnostics: Vec<Diagnostic>,
24}
25
26impl DiagnosticReport {
27    pub fn push(&mut self, diagnostic: Diagnostic) {
28        self.diagnostics.push(diagnostic);
29    }
30
31    pub fn extend(&mut self, other: DiagnosticReport) {
32        self.diagnostics.extend(other.diagnostics);
33    }
34
35    pub fn has_errors(&self) -> bool {
36        self.diagnostics
37            .iter()
38            .any(|diagnostic| diagnostic.severity == Severity::Error)
39    }
40
41    pub fn is_empty(&self) -> bool {
42        self.diagnostics.is_empty()
43    }
44
45    pub fn render(&self) -> String {
46        if self.is_empty() {
47            return "No Lovely compatibility issues found.\n".to_string();
48        }
49
50        let mut out = String::new();
51        for diagnostic in &self.diagnostics {
52            let severity = match diagnostic.severity {
53                Severity::Error => "error",
54                Severity::Warning => "warning",
55            };
56            if let Some(path) = &diagnostic.path {
57                out.push_str(&format!(
58                    "{severity}[{}] {}: {}\n",
59                    diagnostic.id,
60                    path.display(),
61                    diagnostic.message
62                ));
63            } else {
64                out.push_str(&format!(
65                    "{severity}[{}] {}\n",
66                    diagnostic.id, diagnostic.message
67                ));
68            }
69        }
70        out
71    }
72}
73
74pub fn check_project(root: &Path, config: &Config, targets: &[String]) -> Result<DiagnosticReport> {
75    let source = root.join(&config.paths.source);
76    if !source.is_dir() {
77        return Err(LovelyError::Config(format!(
78            "source directory does not exist: {}",
79            source.display()
80        )));
81    }
82
83    let mut report = DiagnosticReport::default();
84    let requested = if targets.is_empty() {
85        vec!["web".to_string(), "desktop".to_string()]
86    } else {
87        targets.to_vec()
88    };
89
90    let files =
91        fsutil::collect_included_files(&source, &config.paths.includes, &config.paths.excludes)?;
92    let wants_web = requested
93        .iter()
94        .any(|target| target == "web" || target == "all");
95
96    for file in files {
97        let Some(ext) = file.extension().and_then(|ext| ext.to_str()) else {
98            continue;
99        };
100        if ext != "lua" {
101            continue;
102        }
103
104        let text = fs::read_to_string(&file).map_err(|err| LovelyError::io(&file, err))?;
105        if wants_web {
106            check_web_lua(&mut report, config, &file, &text);
107        }
108    }
109
110    Ok(report)
111}
112
113fn check_web_lua(report: &mut DiagnosticReport, config: &Config, path: &Path, text: &str) {
114    if text.contains("require(\"ffi\")")
115        || text.contains("require 'ffi'")
116        || text.contains("require('ffi')")
117    {
118        push_unless_allowed(
119            report,
120            config,
121            Diagnostic {
122                id: "web.ffi",
123                severity: Severity::Error,
124                message: "LuaJIT FFI cannot run in the web target.".to_string(),
125                path: Some(path.to_path_buf()),
126            },
127        );
128    }
129
130    for needle in ["package.loadlib", "io.popen", "os.execute"] {
131        if text.contains(needle) {
132            push_unless_allowed(
133                report,
134                config,
135                Diagnostic {
136                    id: "web.native",
137                    severity: Severity::Error,
138                    message: format!(
139                        "{needle} suggests native process or module usage, which is not web-portable."
140                    ),
141                    path: Some(path.to_path_buf()),
142                },
143            );
144        }
145    }
146
147    if text.contains("unpack(") && !text.contains("table.unpack(") {
148        push_unless_allowed(
149            report,
150            config,
151            Diagnostic {
152                id: "web.lua52_unpack",
153                severity: Severity::Warning,
154                message: "some LÖVE web runtimes use Lua 5.2+ semantics; prefer table.unpack over global unpack.".to_string(),
155                path: Some(path.to_path_buf()),
156            },
157        );
158    }
159
160    if text.contains("require(\"bit\")")
161        || text.contains("require 'bit'")
162        || text.contains("require('bit')")
163    {
164        push_unless_allowed(
165            report,
166            config,
167            Diagnostic {
168                id: "web.bit_module",
169                severity: Severity::Warning,
170                message: "bit may be unavailable in some LÖVE web runtimes; use bit32 or a compatibility shim.".to_string(),
171                path: Some(path.to_path_buf()),
172            },
173        );
174    }
175
176    for needle in ["love.audio.play(", "love.audio.stop(", "love.audio.pause("] {
177        if text.contains(needle) {
178            push_unless_allowed(
179                report,
180                config,
181                Diagnostic {
182                    id: "web.module_audio",
183                    severity: Severity::Warning,
184                    message: format!(
185                        "{needle} has crashed in some love.js builds; prefer Source methods like source:play()."
186                    ),
187                    path: Some(path.to_path_buf()),
188                },
189            );
190        }
191    }
192
193    if text.contains("newShader") && text.contains("varying") {
194        push_unless_allowed(
195            report,
196            config,
197            Diagnostic {
198                id: "web.shader_varying",
199                severity: Severity::Warning,
200                message:
201                    "custom shader varyings may need hoisting for strict WebGL implementations."
202                        .to_string(),
203                path: Some(path.to_path_buf()),
204            },
205        );
206    }
207}
208
209fn push_unless_allowed(report: &mut DiagnosticReport, config: &Config, diagnostic: Diagnostic) {
210    if config
211        .compatibility
212        .allow_warnings
213        .iter()
214        .any(|allowed| allowed == diagnostic.id)
215        && diagnostic.severity == Severity::Warning
216    {
217        return;
218    }
219    report.push(diagnostic);
220}