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}