mod lines;
mod palette;
mod sets;
mod shading;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::fmt;
use std::str::FromStr;
use crate::{Document, Error, ErrorCode, Limits, Position, Sprite};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[non_exhaustive]
pub enum LintCode {
OrphanPixel,
Jaggy,
DoubleOutline,
StrayAlpha,
NearDuplicateColor,
FlatRamp,
LowContrast,
PillowShading,
Banding,
Mixel,
TooManyColors,
InconsistentSize,
InconsistentOutline,
InconsistentPalette,
InconsistentScale,
UnusedKey,
}
impl LintCode {
pub const ALL: [LintCode; 16] = [
LintCode::OrphanPixel,
LintCode::Jaggy,
LintCode::DoubleOutline,
LintCode::StrayAlpha,
LintCode::PillowShading,
LintCode::Banding,
LintCode::Mixel,
LintCode::TooManyColors,
LintCode::InconsistentSize,
LintCode::InconsistentOutline,
LintCode::InconsistentPalette,
LintCode::InconsistentScale,
LintCode::NearDuplicateColor,
LintCode::FlatRamp,
LintCode::LowContrast,
LintCode::UnusedKey,
];
pub fn as_str(self) -> &'static str {
match self {
LintCode::OrphanPixel => "orphan_pixel",
LintCode::Jaggy => "jaggy",
LintCode::DoubleOutline => "double_outline",
LintCode::StrayAlpha => "stray_alpha",
LintCode::PillowShading => "pillow_shading",
LintCode::Banding => "banding",
LintCode::Mixel => "mixel",
LintCode::TooManyColors => "too_many_colors",
LintCode::InconsistentSize => "inconsistent_size",
LintCode::InconsistentOutline => "inconsistent_outline",
LintCode::InconsistentPalette => "inconsistent_palette",
LintCode::InconsistentScale => "inconsistent_scale",
LintCode::NearDuplicateColor => "near_duplicate_color",
LintCode::FlatRamp => "flat_ramp",
LintCode::LowContrast => "low_contrast",
LintCode::UnusedKey => "unused_key",
}
}
}
impl fmt::Display for LintCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for LintCode {
type Err = Error;
fn from_str(name: &str) -> Result<Self, Error> {
LintCode::ALL
.into_iter()
.find(|c| c.as_str() == name)
.ok_or_else(|| {
let names: Vec<&str> = LintCode::ALL.iter().map(|c| c.as_str()).collect();
Error::new(
ErrorCode::Syntax,
format!(
"Unknown lint `{name}`; expected one of {}",
names.join(", ")
),
)
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Warning {
pub code: LintCode,
pub message: String,
pub position: Option<Position>,
pub file: Option<String>,
pub sprite: Option<String>,
pub pixels: Vec<(u32, u32)>,
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(file) = &self.file {
write!(f, "{file}, ")?;
}
match self.position {
Some(p) => write!(f, "line {}, column {}: {}", p.line, p.column, self.message),
None => f.write_str(&self.message),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Lint {
pub warnings: Vec<Warning>,
pub counts: Vec<(LintCode, usize)>,
}
impl Lint {
pub fn total(&self) -> usize {
self.counts.iter().map(|&(_, n)| n).sum()
}
pub fn count(&self, code: LintCode) -> usize {
self.counts
.iter()
.find(|&&(c, _)| c == code)
.map_or(0, |&(_, n)| n)
}
pub fn omitted(&self) -> usize {
self.total() - self.warnings.len()
}
pub fn is_clean(&self) -> bool {
self.counts.is_empty()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LintOptions {
pub allow: Vec<LintCode>,
pub orphan_contrast: u8,
pub pillow_offset: u32,
pub banding_length: usize,
pub max_colors_small: usize,
pub max_colors: usize,
pub min_color_distance: u32,
pub min_ramp_step: u32,
}
impl Default for LintOptions {
fn default() -> Self {
Self {
allow: Vec::new(),
orphan_contrast: 32,
pillow_offset: 10,
banding_length: 4,
max_colors_small: 8,
max_colors: 16,
min_color_distance: 300,
min_ramp_step: 600,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) enum Kind {
Palette,
Sprite,
Animation,
Tileset,
Tilemap,
Font,
Group,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct Sources {
pub files: Vec<String>,
pub file_allows: Vec<Vec<LintCode>>,
pub defs: Vec<Def>,
pub palette_uses: std::collections::BTreeSet<(String, char)>,
pub sets: Vec<Set>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Set {
pub kind: Kind,
pub name: String,
pub members: Vec<Member>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Member {
pub name: String,
pub pos: Position,
pub sources: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Def {
pub kind: Kind,
pub name: String,
pub pos: Position,
pub allow: Vec<LintCode>,
pub deps: Vec<(Kind, String)>,
pub detail: Detail,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum Detail {
Other,
Drawn(Vec<DrawnLayer>),
Texture(Vec<char>),
Palette {
from: Option<String>,
keys: Vec<(char, Position)>,
ramps: Vec<RampSource>,
},
}
pub(crate) type RampSource = (String, Position, Vec<(char, Position)>);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct DrawnLayer {
pub layers: usize,
pub rows: Option<Vec<Position>>,
}
type Index<'s> = HashMap<(Kind, &'s str), &'s Def>;
impl Sources {
fn index(&self) -> Index<'_> {
let mut index = HashMap::new();
for def in &self.defs {
index.entry((def.kind, def.name.as_str())).or_insert(def);
}
index
}
fn allows(&self, index: &Index, kind: Kind, name: &str, code: LintCode) -> bool {
self.allows_where(index, kind, name, code, true)
}
fn allows_where(
&self,
index: &Index,
kind: Kind,
name: &str,
code: LintCode,
file: bool,
) -> bool {
if let Some(def) = index.get(&(kind, name)).filter(|_| file) {
let file = self.file_allows.get(def.pos.file as usize);
if file.is_some_and(|f| f.contains(&code)) {
return true;
}
}
let mut seen = HashSet::new();
let mut stack = vec![(kind, name)];
while let Some((kind, name)) = stack.pop() {
if !seen.insert((kind, name)) {
continue;
}
let Some(def) = index.get(&(kind, name)) else {
continue;
};
if def.allow.contains(&code) {
return true;
}
stack.extend(def.deps.iter().map(|(kind, name)| (*kind, name.as_str())))
}
false
}
}
pub(super) struct Sink<'s> {
sources: &'s Sources,
index: Index<'s>,
options: &'s LintOptions,
limit: usize,
allowed: HashMap<(Kind, LintCode), HashMap<String, bool>>,
counts: [usize; LintCode::ALL.len()],
kept: BinaryHeap<Kept>,
found: usize,
}
struct Kept {
key: (bool, Option<Position>, LintCode, usize),
warning: Warning,
}
impl PartialEq for Kept {
fn eq(&self, other: &Self) -> bool {
self.key == other.key
}
}
impl Eq for Kept {}
impl PartialOrd for Kept {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Kept {
fn cmp(&self, other: &Self) -> Ordering {
self.key.cmp(&other.key)
}
}
pub(super) struct Found<'f> {
code: LintCode,
kind: Kind,
name: &'f str,
position: Option<Position>,
sprite: Option<&'f str>,
pixels: Vec<(u32, u32)>,
}
impl<'s> Sink<'s> {
fn new(sources: &'s Sources, options: &'s LintOptions, limit: usize) -> Self {
Self {
sources,
index: sources.index(),
options,
limit,
allowed: HashMap::new(),
counts: [0; LintCode::ALL.len()],
kept: BinaryHeap::new(),
found: 0,
}
}
pub(super) fn allowed(&mut self, code: LintCode, kind: Kind, name: &str) -> bool {
if self.options.allow.contains(&code) {
return true;
}
let known = self
.allowed
.get(&(kind, code))
.and_then(|names| names.get(name))
.copied();
known.unwrap_or_else(|| {
let allowed = self.sources.allows(&self.index, kind, name, code);
self.allowed
.entry((kind, code))
.or_default()
.insert(name.to_owned(), allowed);
allowed
})
}
pub(super) fn allowed_by_definition(&self, code: LintCode, kind: Kind, name: &str) -> bool {
self.options.allow.contains(&code)
|| self
.sources
.allows_where(&self.index, kind, name, code, false)
}
pub(super) fn push(&mut self, found: Found, message: impl FnOnce() -> String) {
let code = found.code;
if self.allowed(code, found.kind, found.name) {
return;
}
if let Some(i) = LintCode::ALL.iter().position(|&c| c == code) {
self.counts[i] += 1;
}
let key = (found.position.is_none(), found.position, code, self.found);
self.found += 1;
let full = self.kept.len() >= self.limit;
if full && self.kept.peek().is_none_or(|last| key >= last.key) {
return;
}
let file = found
.position
.filter(|p| p.file > 0)
.and_then(|p| self.sources.files.get(p.file as usize).cloned());
self.kept.push(Kept {
key,
warning: Warning {
code,
message: message(),
position: found.position,
file,
sprite: found.sprite.map(str::to_owned),
pixels: found.pixels,
},
});
if self.kept.len() > self.limit {
self.kept.pop();
}
}
fn finish(self) -> Lint {
Lint {
warnings: self
.kept
.into_sorted_vec()
.into_iter()
.map(|k| k.warning)
.collect(),
counts: LintCode::ALL
.into_iter()
.zip(self.counts)
.filter(|&(_, n)| n > 0)
.collect(),
}
}
}
pub(crate) fn run(document: &Document, options: &LintOptions, limits: &Limits) -> Lint {
let sources = &document.sources;
let mut sink = Sink::new(sources, options, limits.max_warnings);
let index = sources.index();
let mut scales = Vec::new();
for sprite in &document.sprites {
let Some(def) = index.get(&(Kind::Sprite, sprite.name.as_str())) else {
continue;
};
if let Detail::Drawn(layers) = &def.detail {
let canvas = lines::Canvas::new(sprite);
let report = lines::Report {
sprite,
def,
layers,
};
lines::check(&canvas, &report, options, &mut sink);
shading::check(&canvas, &report, options, &mut sink);
scales.push((shading::scale(&canvas), report));
}
}
shading::mixels(&scales, &mut sink);
sets::check(document, &index, &mut sink);
palette::check(document, &index, options, &mut sink);
sink.finish()
}
pub(super) fn pixel_position(
sprite: &Sprite,
layers: &[DrawnLayer],
x: u32,
y: u32,
) -> Option<Position> {
let seen = sprite.top_layer(x, y)?;
let mut first = 0;
for layer in layers {
if seen < first + layer.layers {
let start = layer.rows.as_ref()?.get(y as usize)?;
return Some(Position::in_file(
start.file,
start.line,
start.column + x as usize,
));
}
first += layer.layers;
}
None
}