1use std::fmt::Write as _;
26use std::path::Path;
27use std::process::Command;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use super::{Answer, Op, codes};
34use crate::check_keys::relative;
35use crate::project::{FILE, Project};
36use crate::{check_keys, check_text};
37
38pub const FENCE_LINTS: [&str; 3] = [
40 "clippy::disallowed_types",
41 "clippy::disallowed_methods",
42 "clippy::float_arithmetic",
43];
44
45pub const NEVER: &[&str] = &[
49 "shuyi",
50 "shuyi-ui",
51 "shuyi-platform",
52 "shuyi-app",
53 "shuyi-cli",
54 "macroquad",
55 "miniquad",
56 "quad-*",
57 "gilrs",
58 "gilrs-core",
59 "cpal",
60 "rodio",
61 "kira",
62 "alsa",
63 "alsa-sys",
64 "coreaudio-rs",
65 "coreaudio-sys",
66 "oboe",
67 "symphonia",
68 "lewton",
69 "hound",
70 "rand",
71 "getrandom",
72];
73
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct LintInput {}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "kebab-case")]
82pub enum LintCheck {
83 CheckKeys,
85 CheckText,
87 RulesFence,
89 RulesLayers,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct Finding {
97 pub check: LintCheck,
99 pub file: String,
101 pub line: Option<u32>,
103 pub column: Option<u32>,
105 pub message: String,
107 pub code: String,
109 pub help: Option<String>,
111 pub command: Option<String>,
114}
115
116impl Finding {
117 fn new(check: LintCheck, file: impl Into<String>, code: codes::Code, message: String) -> Self {
120 Self {
121 check,
122 file: file.into(),
123 line: None,
124 column: None,
125 message,
126 code: code.0.to_owned(),
127 help: codes::help_of(code.0).map(str::to_owned),
128 command: None,
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
135#[serde(deny_unknown_fields)]
136pub struct LintOutput {
137 pub findings: Vec<Finding>,
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct Ran {
145 pub success: bool,
147 pub stdout: String,
149 pub stderr: String,
151}
152
153pub trait Cargo {
155 fn run(&self, root: &Path, args: &[&str]) -> Result<Ran, String>;
157}
158
159#[derive(Debug, Clone, Copy, Default)]
162pub struct ThisMachine;
163
164impl Cargo for ThisMachine {
165 fn run(&self, root: &Path, args: &[&str]) -> Result<Ran, String> {
166 let cargo = crate::cargo();
167 let out = Command::new(&cargo)
168 .args(args)
169 .current_dir(root)
170 .output()
171 .map_err(|e| format!("cannot run {}: {e}", cargo.to_string_lossy()))?;
172 Ok(Ran {
173 success: out.status.success(),
174 stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
175 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
176 })
177 }
178}
179
180pub fn lint(cargo: &dyn Cargo, project: &Project, _input: &LintInput) -> Answer<LintOutput> {
182 let config = &project.config;
183 let root = &project.root;
184 let mut findings = Vec::new();
185 let mut limits = Vec::new();
186 match &config.check_keys {
187 Some(rules) => findings.extend(
188 check_keys::run(rules, root)
189 .iter()
190 .map(|e| reported(LintCheck::CheckKeys, codes::HARD_CODED_KEY, e)),
191 ),
192 None => limits.push(format!("check-keys wasn't run: {FILE} has no `check_keys`")),
193 }
194 match &config.check_text {
195 Some(rules) => findings.extend(text_findings(rules, root)),
196 None => limits.push(format!("check-text wasn't run: {FILE} has no `check_text`")),
197 }
198 match &config.rules {
199 Some(package) => match fence(cargo, root, package, &mut limits) {
200 Ok(found) => findings.extend(found),
201 Err(e) => return Answer::failed(Op::Lint, codes::TOOLCHAIN, e),
202 },
203 None => limits.push(format!(
204 "the rules crate's fence wasn't checked: {FILE} names no rules package (`rules`)"
205 )),
206 }
207 let ok = findings.is_empty();
208 Answer::new(Op::Lint, ok, LintOutput { findings }, limits)
209}
210
211fn text_findings(rules: &crate::project::CheckText, root: &Path) -> Vec<Finding> {
214 let (hits, errors) = check_text::run(rules, root);
215 let mut found: Vec<Finding> = errors
216 .iter()
217 .map(|e| reported(LintCheck::CheckText, codes::SCREEN_TEXT, e))
218 .collect();
219 if hits.len() > rules.max_literals {
220 found.extend(hits.iter().map(|hit| {
221 let message = format!(
222 "screen text written as a literal, {:?} ({} in {}, at most {} allowed) — {}",
223 hit.text,
224 hits.len(),
225 rules.dir,
226 rules.max_literals,
227 rules.hint
228 );
229 Finding {
230 line: u32::try_from(hit.line).ok(),
231 ..Finding::new(LintCheck::CheckText, &hit.file, codes::SCREEN_TEXT, message)
232 }
233 }));
234 }
235 found.extend(
236 check_text::run_names(rules, root)
237 .iter()
238 .map(|e| reported(LintCheck::CheckText, codes::SCREEN_TEXT, e)),
239 );
240 found
241}
242
243pub fn reported(check: LintCheck, code: codes::Code, error: &str) -> Finding {
247 let (place, message) = error.split_once(": ").unwrap_or(("", error));
248 let (file, line) = match place.rsplit_once(':') {
249 Some((file, line)) if line.parse::<u32>().is_ok() => (file, line.parse().ok()),
250 _ => (place, None),
251 };
252 let code = if message.starts_with("cannot read") {
253 codes::FILE
254 } else {
255 code
256 };
257 Finding {
258 line,
259 ..Finding::new(check, file, code, message.to_owned())
260 }
261}
262
263fn fence(
267 cargo: &dyn Cargo,
268 root: &Path,
269 package: &str,
270 limits: &mut Vec<String>,
271) -> Result<Vec<Finding>, String> {
272 let clippy = cargo.run(
273 root,
274 &[
275 "clippy",
276 "--quiet",
277 "-p",
278 package,
279 "--message-format=json",
280 "--",
281 "-D",
282 "warnings",
283 ],
284 )?;
285 let mut found = clippy_findings(root, &clippy.stdout);
286 if let Some(error) = compile_error(root, &clippy.stdout) {
287 limits.push(format!(
288 "the rules crate's fence was checked only in part: {package} doesn't compile ({error})"
289 ));
290 } else if !clippy.success && found.is_empty() && !has_messages(&clippy.stdout) {
291 limits.push(format!(
292 "the rules crate's fence wasn't checked: cargo clippy -p {package} failed: {}",
293 last_line(&clippy.stderr)
294 ));
295 }
296 let tree = cargo.run(
297 root,
298 &[
299 "tree",
300 "-p",
301 package,
302 "--edges",
303 "normal,build",
304 "--target",
305 "all",
306 "--prefix",
307 "none",
308 "--format",
309 "{p}",
310 ],
311 )?;
312 if tree.success {
313 found.extend(tree_findings(root, package, &tree.stdout));
314 } else {
315 limits.push(format!(
316 "the rules crate's layers weren't checked: cargo tree -p {package} failed: {}",
317 last_line(&tree.stderr)
318 ));
319 }
320 Ok(found)
321}
322
323fn last_line(text: &str) -> &str {
325 text.lines()
326 .rev()
327 .map(str::trim)
328 .find(|line| !line.is_empty())
329 .unwrap_or("no output")
330}
331
332fn messages(stdout: &str) -> impl Iterator<Item = Value> + '_ {
334 stdout
335 .lines()
336 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
337 .filter(|line| line["reason"] == "compiler-message")
338 .map(|mut line| line["message"].take())
339}
340
341fn has_messages(stdout: &str) -> bool {
343 messages(stdout).next().is_some()
344}
345
346fn shown(root: &Path, path: &str) -> String {
349 relative(root, Path::new(path))
350}
351
352fn place(root: &Path, message: &Value) -> (String, Option<u32>, Option<u32>) {
354 let spans = message["spans"].as_array().map_or(&[][..], Vec::as_slice);
355 let Some(span) = spans
356 .iter()
357 .find(|span| span["is_primary"] == true)
358 .or_else(|| spans.first())
359 else {
360 return (String::new(), None, None);
361 };
362 let number = |key: &str| span[key].as_u64().and_then(|n| u32::try_from(n).ok());
363 (
364 shown(root, span["file_name"].as_str().unwrap_or_default()),
365 number("line_start"),
366 number("column_start"),
367 )
368}
369
370fn reason(message: &Value) -> Option<&str> {
373 const CLIPPYS: [&str; 4] = [
374 "for further information",
375 "`-D ",
376 "to override",
377 "the lint level",
378 ];
379 message["children"]
380 .as_array()?
381 .iter()
382 .filter(|child| child["level"] == "note")
383 .filter(|child| child["spans"].as_array().is_none_or(Vec::is_empty))
384 .filter_map(|child| child["message"].as_str())
385 .find(|text| !CLIPPYS.iter().any(|clippy| text.starts_with(clippy)))
386}
387
388pub fn clippy_findings(root: &Path, stdout: &str) -> Vec<Finding> {
393 let mut found: Vec<Finding> = Vec::new();
394 for message in messages(stdout) {
395 let name = message["code"]["code"].as_str().unwrap_or_default();
396 if !FENCE_LINTS.contains(&name) {
397 continue;
398 }
399 let (file, line, column) = place(root, &message);
400 let text = message["message"].as_str().unwrap_or_default();
401 let text = match reason(&message) {
402 Some(reason) => format!("{text}: {reason}"),
403 None => text.to_owned(),
404 };
405 let finding = Finding {
406 line,
407 column,
408 ..Finding::new(LintCheck::RulesFence, file, codes::RULES_FENCE, text)
409 };
410 if !found.contains(&finding) {
411 found.push(finding);
412 }
413 }
414 found
415}
416
417fn compile_error(root: &Path, stdout: &str) -> Option<String> {
420 messages(stdout).find_map(|message| {
421 let code = message["code"]["code"].as_str();
422 let compiler = code.is_none_or(|code| {
423 code.strip_prefix('E')
424 .is_some_and(|n| n.bytes().all(|b| b.is_ascii_digit()))
425 });
426 (message["level"] == "error" && compiler).then(|| {
427 let text = message["message"].as_str().unwrap_or_default();
428 match place(root, &message) {
429 (file, Some(line), _) => format!("{file}:{line}: {text}"),
430 _ => text.to_owned(),
431 }
432 })
433 })
434}
435
436fn is_never(name: &str) -> bool {
438 NEVER.iter().any(|never| match never.strip_suffix('*') {
439 Some(prefix) => name.starts_with(prefix),
440 None => name == *never,
441 })
442}
443
444pub fn tree_findings(root: &Path, package: &str, stdout: &str) -> Vec<Finding> {
449 let manifest = stdout
450 .lines()
451 .next()
452 .and_then(|first| first.rsplit_once(" ("))
453 .and_then(|(_, path)| path.strip_suffix(')'))
454 .map_or_else(
455 || "Cargo.toml".to_owned(),
456 |dir| {
457 let dir = shown(root, dir);
458 if dir.is_empty() {
459 "Cargo.toml".to_owned()
460 } else {
461 format!("{dir}/Cargo.toml")
462 }
463 },
464 );
465 let mut names: Vec<&str> = stdout
466 .lines()
467 .filter_map(|line| line.split_whitespace().next())
468 .filter(|name| is_never(name))
469 .collect();
470 names.sort_unstable();
471 names.dedup();
472 names
473 .into_iter()
474 .map(|name| {
475 let message = format!(
476 "{package} depends on {name}: the rules stay pure, so screens, devices and tools \
477 go in crates/game, and the only luck is the SimRng kept in the state"
478 );
479 Finding {
480 command: Some(format!("cargo tree -p {package} -i {name}")),
481 ..Finding::new(
482 LintCheck::RulesLayers,
483 manifest.clone(),
484 codes::RULES_DEPENDENCY,
485 message,
486 )
487 }
488 })
489 .collect()
490}
491
492pub fn usage(command: &str) -> String {
494 format!("usage: {command} lint [--json]")
495}
496
497pub fn text(output: &LintOutput) -> String {
499 if output.findings.is_empty() {
500 return "lint: OK\n".to_owned();
501 }
502 let mut out = format!("lint: {} finding(s)\n", output.findings.len());
503 for finding in &output.findings {
504 let _ = write!(out, " {}", finding.file);
506 if let Some(line) = finding.line {
507 let _ = write!(out, ":{line}");
508 if let Some(column) = finding.column {
509 let _ = write!(out, ":{column}");
510 }
511 }
512 let _ = writeln!(out, ": error[{}]: {}", finding.code, finding.message);
513 if let Some(command) = &finding.command {
514 let _ = writeln!(out, " see the way in: {command}");
515 }
516 }
517 let mut seen: Vec<&str> = Vec::new();
518 for finding in &output.findings {
519 if let Some(help) = &finding.help
520 && !seen.contains(&finding.code.as_str())
521 {
522 seen.push(&finding.code);
523 let _ = writeln!(out, "help[{}]: {help}", finding.code);
524 }
525 }
526 out
527}
528
529#[cfg(test)]
530mod tests;