lovely-packager 0.1.0

A LÖVE >= 11 distribution toolchain for web, desktop, and Steam builds.
Documentation
use crate::config::Config;
use crate::fsutil;
use crate::{LovelyError, Result};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    Error,
    Warning,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
    pub id: &'static str,
    pub severity: Severity,
    pub message: String,
    pub path: Option<PathBuf>,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DiagnosticReport {
    pub diagnostics: Vec<Diagnostic>,
}

impl DiagnosticReport {
    pub fn push(&mut self, diagnostic: Diagnostic) {
        self.diagnostics.push(diagnostic);
    }

    pub fn extend(&mut self, other: DiagnosticReport) {
        self.diagnostics.extend(other.diagnostics);
    }

    pub fn has_errors(&self) -> bool {
        self.diagnostics
            .iter()
            .any(|diagnostic| diagnostic.severity == Severity::Error)
    }

    pub fn is_empty(&self) -> bool {
        self.diagnostics.is_empty()
    }

    pub fn render(&self) -> String {
        if self.is_empty() {
            return "No Lovely compatibility issues found.\n".to_string();
        }

        let mut out = String::new();
        for diagnostic in &self.diagnostics {
            let severity = match diagnostic.severity {
                Severity::Error => "error",
                Severity::Warning => "warning",
            };
            if let Some(path) = &diagnostic.path {
                out.push_str(&format!(
                    "{severity}[{}] {}: {}\n",
                    diagnostic.id,
                    path.display(),
                    diagnostic.message
                ));
            } else {
                out.push_str(&format!(
                    "{severity}[{}] {}\n",
                    diagnostic.id, diagnostic.message
                ));
            }
        }
        out
    }
}

pub fn check_project(root: &Path, config: &Config, targets: &[String]) -> Result<DiagnosticReport> {
    let source = root.join(&config.paths.source);
    if !source.is_dir() {
        return Err(LovelyError::Config(format!(
            "source directory does not exist: {}",
            source.display()
        )));
    }

    let mut report = DiagnosticReport::default();
    let requested = if targets.is_empty() {
        vec!["web".to_string(), "desktop".to_string()]
    } else {
        targets.to_vec()
    };

    let files =
        fsutil::collect_included_files(&source, &config.paths.includes, &config.paths.excludes)?;
    let wants_web = requested
        .iter()
        .any(|target| target == "web" || target == "all");

    for file in files {
        let Some(ext) = file.extension().and_then(|ext| ext.to_str()) else {
            continue;
        };
        if ext != "lua" {
            continue;
        }

        let text = fs::read_to_string(&file).map_err(|err| LovelyError::io(&file, err))?;
        if wants_web {
            check_web_lua(&mut report, config, &file, &text);
        }
    }

    Ok(report)
}

fn check_web_lua(report: &mut DiagnosticReport, config: &Config, path: &Path, text: &str) {
    if text.contains("require(\"ffi\")")
        || text.contains("require 'ffi'")
        || text.contains("require('ffi')")
    {
        push_unless_allowed(
            report,
            config,
            Diagnostic {
                id: "web.ffi",
                severity: Severity::Error,
                message: "LuaJIT FFI cannot run in the web target.".to_string(),
                path: Some(path.to_path_buf()),
            },
        );
    }

    for needle in ["package.loadlib", "io.popen", "os.execute"] {
        if text.contains(needle) {
            push_unless_allowed(
                report,
                config,
                Diagnostic {
                    id: "web.native",
                    severity: Severity::Error,
                    message: format!(
                        "{needle} suggests native process or module usage, which is not web-portable."
                    ),
                    path: Some(path.to_path_buf()),
                },
            );
        }
    }

    if text.contains("unpack(") && !text.contains("table.unpack(") {
        push_unless_allowed(
            report,
            config,
            Diagnostic {
                id: "web.lua52_unpack",
                severity: Severity::Warning,
                message: "some LÖVE web runtimes use Lua 5.2+ semantics; prefer table.unpack over global unpack.".to_string(),
                path: Some(path.to_path_buf()),
            },
        );
    }

    if text.contains("require(\"bit\")")
        || text.contains("require 'bit'")
        || text.contains("require('bit')")
    {
        push_unless_allowed(
            report,
            config,
            Diagnostic {
                id: "web.bit_module",
                severity: Severity::Warning,
                message: "bit may be unavailable in some LÖVE web runtimes; use bit32 or a compatibility shim.".to_string(),
                path: Some(path.to_path_buf()),
            },
        );
    }

    for needle in ["love.audio.play(", "love.audio.stop(", "love.audio.pause("] {
        if text.contains(needle) {
            push_unless_allowed(
                report,
                config,
                Diagnostic {
                    id: "web.module_audio",
                    severity: Severity::Warning,
                    message: format!(
                        "{needle} has crashed in some love.js builds; prefer Source methods like source:play()."
                    ),
                    path: Some(path.to_path_buf()),
                },
            );
        }
    }

    if text.contains("newShader") && text.contains("varying") {
        push_unless_allowed(
            report,
            config,
            Diagnostic {
                id: "web.shader_varying",
                severity: Severity::Warning,
                message:
                    "custom shader varyings may need hoisting for strict WebGL implementations."
                        .to_string(),
                path: Some(path.to_path_buf()),
            },
        );
    }
}

fn push_unless_allowed(report: &mut DiagnosticReport, config: &Config, diagnostic: Diagnostic) {
    if config
        .compatibility
        .allow_warnings
        .iter()
        .any(|allowed| allowed == diagnostic.id)
        && diagnostic.severity == Severity::Warning
    {
        return;
    }
    report.push(diagnostic);
}