use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::meta::Project;
use crate::ops::{gha_escape, github_actions};
use crate::term::{DIM, ERROR, SUCCESS, WARN};
use anstream::eprintln;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Severity {
Error,
#[default]
Warning,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Location {
pub file: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone)]
pub struct Fix {
pub title: String,
pub file: String,
pub contents: String,
}
#[derive(Debug, Default)]
pub struct Finding {
pub code: &'static str,
pub message: String,
pub location: Option<Location>,
pub fix: Option<Fix>,
}
impl Finding {
pub fn located(mut self, at: Location) -> Self {
self.location = Some(at);
self
}
pub fn maybe_located(mut self, at: Option<Location>) -> Self {
self.location = at;
self
}
pub fn severity(&self) -> Severity {
severity_of(self.code)
}
}
impl Location {
pub fn in_file(file: impl Into<String>, src: &str, offset: usize) -> Location {
let (line, column) = day_build::line_col(src, offset);
Location {
file: file.into(),
line,
column,
}
}
pub fn head(file: impl Into<String>) -> Location {
Location {
file: file.into(),
line: 1,
column: 1,
}
}
}
pub fn severity_of(code: &str) -> Severity {
const ERRORS: &[&str] = &[
"day::lint::unknown-route",
"day::lint::unknown-target",
"day::lint::unknown-override",
"day::lint::unknown-function",
"day::lint::bad-format-option",
"day::lint::undeclared-permission",
"day::lint::duplicate-id",
"day::lint::vector-parse",
"day::lint::vector-unreadable",
"day::lint::store-unreadable",
"day::lint::shortcut-label",
];
if ERRORS.contains(&code) {
Severity::Error
} else {
Severity::Warning
}
}
#[derive(Debug, Clone)]
struct Hit {
text: String,
file: std::path::PathBuf,
line: usize,
column: usize,
}
impl Hit {
fn found(file: &Path, src: &str, text: &str) -> Hit {
let (line, column) = day_build::line_col(src, day_build::offset_in(src, text).unwrap_or(0));
Hit {
text: text.to_string(),
file: file.to_path_buf(),
line,
column,
}
}
fn location(&self, root: &Path) -> Location {
Location {
file: rel(root, &self.file),
line: self.line,
column: self.column,
}
}
}
fn rel(root: &Path, file: &Path) -> String {
file.strip_prefix(root)
.unwrap_or(file)
.to_string_lossy()
.replace('\\', "/")
}
fn texts(hits: &[Hit]) -> BTreeSet<String> {
hits.iter().map(|h| h.text.clone()).collect()
}
fn for_each_rs(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
for_each_rs(&p, f);
} else if p.extension().is_some_and(|x| x == "rs")
&& let Ok(src) = std::fs::read_to_string(&p)
{
f(&p, &src);
}
}
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn mid_identifier(src: &str, at: usize, pat: &str) -> bool {
pat.chars().next().is_some_and(is_ident_char)
&& src[..at].chars().next_back().is_some_and(is_ident_char)
}
fn matches_of<'a>(src: &'a str, pat: &'a str) -> impl Iterator<Item = usize> + 'a {
let mut from = 0usize;
std::iter::from_fn(move || {
while let Some(i) = src[from..].find(pat) {
let at = from + i;
from = at + pat.len();
if !mid_identifier(src, at, pat) {
return Some(at);
}
}
None
})
}
fn scan_res_str(dir: &Path, out: &mut Vec<Hit>) {
for_each_rs(dir, &mut |path, src| {
const PAT: &str = "res::str::";
for at in matches_of(src, PAT) {
let rest = &src[at + PAT.len()..];
let s = rest.strip_prefix("r#").unwrap_or(rest);
let end = s
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(s.len());
if end > 0 {
out.push(Hit::found(path, src, &s[..end]));
}
}
});
}
fn scan_permission_uses(dir: &Path, out: &mut Vec<Hit>) {
for_each_rs(dir, &mut |path, src| {
const PAT: &str = "Permission::";
for at in matches_of(src, PAT) {
let rest = &src[at + PAT.len()..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
if end > 0 {
out.push(Hit::found(path, src, &rest[..end]));
}
}
});
}
fn source_roots(root: &Path) -> Vec<std::path::PathBuf> {
let mut roots = Vec::new();
fn walk(dir: &Path, depth: usize, roots: &mut Vec<std::path::PathBuf>) {
if depth > 3 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if matches!(
name.as_str(),
"target" | "build" | "platform" | "resource" | "store" | "dayscript" | ".git"
) {
continue;
}
if name == "src" && dir.join("Cargo.toml").exists() {
roots.push(p);
continue;
}
walk(&p, depth + 1, roots);
}
}
walk(root, 0, &mut roots);
roots.sort();
roots
}
fn scan_key_like_literals(dir: &Path, out: &mut BTreeSet<String>) {
for_each_rs(dir, &mut |_, src| {
for lit in src.split('"').skip(1).step_by(2) {
if !lit.is_empty()
&& lit.len() <= 64
&& lit
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
out.insert(lit.to_string());
}
}
});
}
fn scan_sources(dir: &Path, pat: &str, out: &mut Vec<Hit>) {
for_each_rs(dir, &mut |path, src| {
for at in matches_of(src, pat) {
let rest = &src[at + pat.len()..];
if let Some(end) = rest.find('"') {
out.push(Hit::found(path, src, &rest[..end]));
}
}
});
}
fn route_first_segment(route: &str) -> &str {
route.split(['/', '?']).next().unwrap_or("")
}
fn scan_routes_macro_keys(dir: &Path, out: &mut Vec<Hit>) {
for_each_rs(dir, &mut |path, src| {
let mut rest = src;
while let Some(i) = rest.find("routes!") {
rest = &rest[i + "routes!".len()..];
let Some(open) = rest.find('{') else { continue };
let mut depth = 0usize;
let mut end = rest.len();
for (j, c) in rest[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + j;
break;
}
}
_ => {}
}
}
let mut body = &rest[open..end];
while let Some(k) = body.find("=> \"") {
body = &body[k + 4..];
if let Some(q) = body.find('"') {
out.push(Hit::found(path, src, &body[..q]));
body = &body[q..];
}
}
rest = &rest[end..];
}
});
}
fn check_screenshot_locales(
root: &Path,
dir: &Path,
app_locales: &[String],
findings: &mut Vec<Finding>,
) {
let lang = |t: &str| t.split(['-', '_']).next().unwrap_or(t).to_ascii_lowercase();
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
check_screenshot_locales(root, &p, app_locales, findings);
continue;
}
if !p.extension().is_some_and(|x| x == "yaml" || x == "yml") {
continue;
}
let file = p.file_name().map(|f| f.to_string_lossy().into_owned());
let file = file.as_deref().unwrap_or("dayscript");
let at = Location::head(rel(root, &p));
for (shot, meta) in crate::screenshot::script_screenshot_meta(&p) {
for (kind, text) in [("title", &meta.title), ("caption", &meta.caption)] {
let Some(text) = text else { continue };
let keys = text.locales();
if keys.is_empty() {
continue; }
for l in app_locales {
if !keys.iter().any(|k| lang(k) == lang(l)) {
findings.push(
Finding {
code: "day::lint::screenshot-locales",
message: format!(
"{file}: screenshot {shot:?} {kind} has no {l:?} — that \
locale's gallery page falls back to English"
),
..Default::default()
}
.located(at.clone()),
);
}
}
for k in &keys {
if !app_locales.iter().any(|l| lang(l) == lang(k)) {
findings.push(
Finding {
code: "day::lint::screenshot-locales",
message: format!(
"{file}: screenshot {shot:?} {kind} names {k:?}, which is not \
one of the app's locales ({})",
app_locales.join(", ")
),
..Default::default()
}
.located(at.clone()),
);
}
}
}
}
}
}
fn scan_script_routes(dir: &Path, out: &mut Vec<Hit>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
scan_script_routes(&p, out);
} else if p.extension().is_some_and(|x| x == "yaml" || x == "yml")
&& let Ok(src) = std::fs::read_to_string(&p)
{
for line in src.lines() {
let l = line.trim_start();
if l.starts_with("- deep_link:") {
if let Some(i) = l.rfind("url:") {
let rest = &l[i + "url:".len()..];
let v = rest
.split(',')
.next()
.unwrap_or(rest)
.trim()
.trim_end_matches(['}', ' '])
.trim()
.trim_matches(['"', '\'']);
if !v.is_empty() {
let route = v.split_once("://").map(|(_, r)| r).unwrap_or(v);
let route = route.split('?').next().unwrap_or(route);
out.push(Hit::found(&p, &src, route));
}
}
continue;
}
if !(l.starts_with("- navigate:") || l.starts_with("- assert_route:")) {
continue;
}
if let Some(i) = l.rfind("route:") {
let rest = &l[i + "route:".len()..];
let v = rest
.split(',')
.next()
.unwrap_or(rest)
.trim()
.trim_end_matches(['}', ' '])
.trim()
.trim_matches(['"', '\'']);
if !v.is_empty() {
out.push(Hit::found(&p, &src, v));
}
}
}
}
}
}
fn spellings<'a>(keys: impl Iterator<Item = &'a String>) -> BTreeSet<String> {
keys.flat_map(|k| [k.clone(), day_build::res_str_ident(k)])
.collect()
}
fn is_referenced(key: &str, used: &BTreeSet<String>, literals: &BTreeSet<String>) -> bool {
let ident = day_build::res_str_ident(key);
used.contains(key)
|| literals.contains(key)
|| used.contains(&ident)
|| literals.contains(&ident)
}
fn locate_in(file: &str, src: &str, needle: &str) -> Option<Location> {
src.find(needle).map(|at| Location::in_file(file, src, at))
}
fn allowed(code: &str, allow: &[String]) -> bool {
allow.iter().any(|a| {
let a = a.trim();
code == a || code.strip_prefix("day::lint::") == Some(a)
})
}
pub fn run(project: &Project, strict: bool, allow: &[String], json: bool, fix: bool) -> i32 {
let mut findings = collect(project);
if fix {
if !findings
.iter()
.any(|f| f.fix.is_some() && !allowed(f.code, allow))
{
eprintln!("{DIM}--fix{DIM:#} no finding proposes a fix that can be applied unattended");
}
for _ in 0..8 {
if apply_fixes(project, &findings, allow) == 0 {
break;
}
findings = collect(project);
}
}
if json {
return report_json(project, &findings, allow, strict);
}
report(&findings, allow, strict)
}
fn apply_fixes(project: &Project, findings: &[Finding], allow: &[String]) -> usize {
let mut applied = 0;
let mut written: BTreeSet<String> = BTreeSet::new();
for f in findings {
let Some(fix) = &f.fix else { continue };
if allowed(f.code, allow) || !written.insert(fix.file.clone()) {
continue;
}
let path = project.root.join(&fix.file);
match std::fs::write(&path, &fix.contents) {
Ok(()) => {
eprintln!(
"{SUCCESS}fixed{SUCCESS:#} {:<32} {}: {}",
f.code, fix.file, fix.title
);
applied += 1;
}
Err(e) => eprintln!("{ERROR}unfixed{ERROR:#} {:<32} {}: {e}", f.code, fix.file),
}
}
applied
}
fn collect(project: &Project) -> Vec<Finding> {
let mut findings: Vec<Finding> = Vec::new();
let manifest_src = std::fs::read_to_string(project.root.join("Day.toml")).ok();
lint_vectors(project, &mut findings);
if project
.manifest
.app
.targets
.iter()
.any(|t| t == "android-mdc")
{
let kotlin_arms = crate::bridge::kotlin_arm_crates(project);
if !kotlin_arms.is_empty() && !crate::bridge::android_compiles_kotlin(project) {
findings.push(Finding {
code: "day::lint::bridge-kotlin-plugin",
message: crate::bridge::kotlin_plugin_help(&kotlin_arms),
..Default::default()
});
}
}
let missing = crate::bridge::unresolved_link_libs(project);
if !missing.is_empty() {
findings.push(Finding {
code: "day::lint::bridge-link-missing",
message: crate::bridge::link_help(&missing),
..Default::default()
});
}
for t in &project.manifest.app.targets {
if !crate::external::known(project, t) {
findings.push(
Finding {
code: "day::lint::unknown-target",
message: format!("Day.toml: targets entry {t:?} is not a known target"),
..Default::default()
}
.maybe_located(
manifest_src
.as_deref()
.and_then(|src| locate_in("Day.toml", src, &format!("{t:?}"))),
),
);
}
}
{
use std::collections::BTreeSet;
let mut known: BTreeSet<&str> = BTreeSet::new();
for t in crate::targets::TARGETS {
known.insert(t.name); known.insert(t.toolkit); known.insert(t.os); }
known.insert("ohos");
for key in project.manifest.app.overrides.keys() {
if !known.contains(key.as_str()) {
findings.push(
Finding {
code: "day::lint::unknown-override",
message: format!(
"Day.toml: [app.{key}] does not name a known platform, toolkit, or \
target"
),
..Default::default()
}
.maybe_located(
manifest_src
.as_deref()
.and_then(|src| locate_in("Day.toml", src, &format!("[app.{key}]"))),
),
);
}
}
}
match crate::store::read(project) {
Ok(listing) => {
for p in crate::store::lint(project, &listing) {
findings.push(Finding {
code: p.code,
message: p.message,
location: p.file.map(Location::head),
fix: p.fix,
});
}
}
Err(e) => findings.push(Finding {
code: "day::lint::store-unreadable",
message: e,
..Default::default()
}),
}
{
let survey = crate::localize::survey(&project.root);
for (message, advice) in crate::localize::sync_findings(&survey) {
findings.push(Finding {
code: "day::lint::locale-sync",
message: format!("{message} — {advice}"),
..Default::default()
});
}
if !survey.fluent.is_empty() {
check_screenshot_locales(
&project.root,
&project.root.join("dayscript"),
&survey.fluent,
&mut findings,
);
}
}
{
let mut used = Vec::new();
for root in source_roots(&project.root) {
scan_permission_uses(&root, &mut used);
}
let mut seen_variants = BTreeSet::new();
used.retain(|h| seen_variants.insert(h.text.clone()));
used.sort_by(|a, b| a.text.cmp(&b.text));
let declared = &project.manifest.permissions.declared;
for hit in &used {
let variant = &hit.text;
let at = hit.location(&project.root);
let Some(spec) = day_build::permissions::find_variant(variant) else {
continue; };
match declared.get(spec.name) {
None => findings.push(Finding {
code: "day::lint::undeclared-permission",
message: format!(
"code requests Permission::{variant}, but Day.toml has no [permissions] \
entry for {:?} — iOS terminates an app that touches the API without its \
usage description",
spec.name
),
..Default::default()
}
.located(at.clone())),
Some(decl) if !decl.enabled() => findings.push(Finding {
code: "day::lint::undeclared-permission",
message: format!(
"code requests Permission::{variant}, but Day.toml declares {:?} = false",
spec.name
),
..Default::default()
}
.located(at.clone())),
Some(decl) if spec.needs_reason && decl.reason_for("ios").is_none() => findings
.push(Finding {
code: "day::lint::missing-reason",
message: format!(
"[permissions] {:?} has no reason — it is the text iOS and HarmonyOS \
show the user when they prompt",
spec.name
),
..Default::default()
}
.located(at.clone())),
Some(_) => {}
}
}
if let Some(plist) = crate::mobile::app_info_plist(project)
&& let Ok(text) = std::fs::read_to_string(&plist)
&& let Ok(plan) = crate::permissions::resolve(&project.manifest, "ios", &[])
{
let have = crate::plist::read_string_keys(&text);
let want = crate::permissions::apple_keys(&plan, false);
let missing: Vec<&String> = want
.iter()
.filter(|(k, v)| have.get(*k) != Some(*v))
.map(|(k, _)| k)
.collect();
if !missing.is_empty() {
findings.push(Finding {
code: "day::lint::stale-manifest",
message: format!(
"platform/ios/Runner/Info.plist is missing or out of date for {} — run \
`day build -p ios-uikit` to regenerate it",
missing
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
),
..Default::default()
});
}
}
}
let locales_dir = project.root.join("resource/locales");
let mut locales: BTreeMap<String, BTreeMap<String, Location>> = BTreeMap::new();
let mut locale_files: BTreeMap<String, String> = BTreeMap::new();
if let Ok(entries) = std::fs::read_dir(&locales_dir) {
for e in entries.flatten() {
if e.path().is_dir() {
let name = e.file_name().to_string_lossy().to_string();
let mut keys: BTreeMap<String, Location> = BTreeMap::new();
if let Ok(files) = std::fs::read_dir(e.path()) {
let mut paths: Vec<std::path::PathBuf> =
files.flatten().map(|f| f.path()).collect();
paths.sort();
for path in paths {
if path.extension().is_some_and(|x| x == "ftl")
&& let Ok(src) = std::fs::read_to_string(&path)
{
let file = rel(&project.root, &path);
locale_files.entry(name.clone()).or_insert(file.clone());
for (key, offset) in day_build::ftl_key_offsets(&src) {
keys.entry(key)
.or_insert_with(|| Location::in_file(&file, &src, offset));
}
}
}
}
locales.insert(name, keys);
}
}
}
let roots = source_roots(&project.root);
let mut used_keys = Vec::new();
for r in &roots {
scan_sources(r, "tr(\"", &mut used_keys);
scan_res_str(r, &mut used_keys);
}
let used = texts(&used_keys);
let mut first_use: BTreeMap<String, Location> = BTreeMap::new();
for h in &used_keys {
first_use
.entry(h.text.clone())
.or_insert_with(|| h.location(&project.root));
}
let mut literals: BTreeSet<String> = BTreeSet::new();
for r in &roots {
scan_key_like_literals(r, &mut literals);
}
let default_name = if locales.contains_key("en") {
"en".to_string()
} else {
locales.keys().next().cloned().unwrap_or_default()
};
if let Some(default_keys) = locales.get(&default_name).cloned() {
let spelled = spellings(default_keys.keys());
for k in &used {
if !spelled.contains(k) {
findings.push(
Finding {
code: "day::lint::unknown-key",
message: format!(
"tr({k:?}) has no message in resource/locales/{default_name}"
),
..Default::default()
}
.maybe_located(first_use.get(k).cloned()),
);
}
}
for (k, at) in &default_keys {
if k == "language_name" {
continue;
}
if !is_referenced(k, &used, &literals) {
findings.push(
Finding {
code: "day::lint::unused-key",
message: format!(
"resource/locales/{default_name}: {k} is never referenced"
),
..Default::default()
}
.located(at.clone()),
);
}
}
for (name, keys) in &locales {
if name == &default_name {
continue;
}
for k in default_keys.keys() {
if k.contains('.') {
continue;
}
if !keys.contains_key(k) {
findings.push(
Finding {
code: "day::lint::missing-translation",
message: format!("resource/locales/{name}: missing {k}"),
..Default::default()
}
.maybe_located(locale_files.get(name).map(Location::head)),
);
}
}
}
}
if let Ok(entries) = std::fs::read_dir(&locales_dir) {
for e in entries.flatten() {
if !e.path().is_dir() {
continue;
}
let locale = e.file_name().to_string_lossy().to_string();
let Ok(files) = std::fs::read_dir(e.path()) else {
continue;
};
for f in files.flatten() {
if f.path().extension().is_none_or(|x| x != "ftl") {
continue;
}
let Ok(src) = std::fs::read_to_string(f.path()) else {
continue;
};
let file = rel(&project.root, &f.path());
for call in day_build::function_calls(&src) {
findings.extend(lint_ftl_call(&locale, &file, &src, &call));
}
}
}
}
let mut declared_keys = Vec::new();
for r in &roots {
scan_sources(r, ".item(\"", &mut declared_keys);
scan_routes_macro_keys(r, &mut declared_keys);
}
if !declared_keys.is_empty() {
let declared = texts(&declared_keys);
let mut used_routes: Vec<(String, String, Option<Location>)> = Vec::new();
let mut nav_calls = Vec::new();
for r in &roots {
scan_sources(r, "navigate(\"", &mut nav_calls);
}
used_routes.extend(nav_calls.into_iter().map(|h| {
let at = h.location(&project.root);
("navigate".to_string(), h.text, Some(at))
}));
let mut script_routes = Vec::new();
scan_script_routes(&project.root.join("dayscript"), &mut script_routes);
used_routes.extend(script_routes.into_iter().map(|h| {
let at = h.location(&project.root);
("dayscript".to_string(), h.text, Some(at))
}));
used_routes.extend(project.manifest.shortcuts.iter().map(|s| {
let route = s.route.split('?').next().unwrap_or(&s.route).to_string();
let at = manifest_src
.as_deref()
.and_then(|src| locate_in("Day.toml", src, &format!("{:?}", s.route)));
("Day.toml [[shortcuts]]".to_string(), route, at)
}));
for (origin, route, at) in &used_routes {
let first = route_first_segment(route);
if !first.is_empty() && !declared.contains(first) {
findings.push(
Finding {
code: "day::lint::unknown-route",
message: format!(
"{origin}: route {route:?} starts with {first:?}, which no `.item(…)` \
or `routes! {{ … }}` declares"
),
..Default::default()
}
.maybe_located(at.clone()),
);
}
}
}
if !project.manifest.shortcuts.is_empty() {
match crate::shortcuts::resolved(project) {
Ok(list) if list.len() > 4 => findings.push(Finding {
code: "day::lint::shortcut-count",
message: format!(
"{} shortcuts declared; launchers show at most about four, so the rest \
may be dropped",
list.len()
),
..Default::default()
}),
Ok(_) => {}
Err(e) => findings.push(Finding {
code: "day::lint::shortcut-label",
message: e,
..Default::default()
}),
}
}
let mut ids = Vec::new();
for r in &roots {
scan_sources(r, ".id(\"", &mut ids);
}
let mut first_id: BTreeMap<String, Location> = BTreeMap::new();
for hit in &ids {
let at = hit.location(&project.root);
match first_id.get(&hit.text) {
Some(first) => findings.push(
Finding {
code: "day::lint::duplicate-id",
message: format!(
"element id {:?} is already used at {}:{}",
hit.text, first.file, first.line
),
..Default::default()
}
.located(at),
),
None => {
first_id.insert(hit.text.clone(), at);
}
}
}
findings
}
fn report(findings: &[Finding], allow: &[String], strict: bool) -> i32 {
let mut waived: BTreeMap<&str, (usize, &str)> = BTreeMap::new();
let gha = github_actions();
let mut active: Vec<&Finding> = Vec::new();
for f in findings {
if allowed(f.code, allow) {
let e = waived.entry(f.code).or_insert((0, f.message.as_str()));
e.0 += 1;
continue;
}
let where_ = match &f.location {
Some(at) => format!(" {DIM}({}:{}){DIM:#}", at.file, at.line),
None => String::new(),
};
match f.severity() {
Severity::Error => {
eprintln!(
"{ERROR}error{ERROR:#} {:<32} {}{where_}",
f.code, f.message
)
}
Severity::Warning => {
eprintln!("{WARN}warning{WARN:#} {:<32} {}{where_}", f.code, f.message)
}
}
if gha {
let place = match &f.location {
Some(at) => format!(",file={},line={},col={}", at.file, at.line, at.column),
None => String::new(),
};
println!(
"::{} title=day lint {}{place}::{}",
f.severity().as_str(),
f.code,
gha_escape(&f.message)
);
}
active.push(f);
}
for (code, (n, sample)) in &waived {
eprintln!("{DIM}allowed{DIM:#} {code:<32} {n} finding(s), e.g. {sample}");
}
if gha {
write_step_summary(&active, &waived);
}
let waived_n: usize = waived.values().map(|(n, _)| n).sum();
finish(findings.len() - waived_n, waived_n, strict)
}
fn report_json(project: &Project, findings: &[Finding], allow: &[String], strict: bool) -> i32 {
println!("{}", envelope(&project.root, findings, allow));
let waived = findings.iter().filter(|f| allowed(f.code, allow)).count();
if findings.len() > waived && strict {
crate::cli::ErrKind::Lint.exit_code()
} else {
0
}
}
fn envelope(root: &Path, findings: &[Finding], allow: &[String]) -> serde_json::Value {
use serde_json::json;
let rows: Vec<serde_json::Value> = findings
.iter()
.map(|f| {
let mut row = json!({
"code": f.code,
"severity": f.severity().as_str(),
"message": f.message,
"waived": allowed(f.code, allow),
});
let map = row.as_object_mut().expect("built as an object just above");
if let Some(at) = &f.location {
map.insert("file".into(), json!(at.file));
map.insert("line".into(), json!(at.line));
map.insert("column".into(), json!(at.column));
}
if let Some(fix) = &f.fix {
map.insert(
"fix".into(),
json!({ "title": fix.title, "file": fix.file, "contents": fix.contents }),
);
}
row
})
.collect();
json!({
"schema": 1,
"project": root.to_string_lossy(),
"findings": rows,
"counts": {
"errors": findings
.iter()
.filter(|f| !allowed(f.code, allow) && f.severity() == Severity::Error)
.count(),
"warnings": findings
.iter()
.filter(|f| !allowed(f.code, allow) && f.severity() == Severity::Warning)
.count(),
"waived": findings.iter().filter(|f| allowed(f.code, allow)).count(),
"fixable": findings
.iter()
.filter(|f| f.fix.is_some() && !allowed(f.code, allow))
.count(),
},
})
}
fn write_step_summary(active: &[&Finding], waived: &BTreeMap<&str, (usize, &str)>) {
let Ok(path) = std::env::var("GITHUB_STEP_SUMMARY") else {
return;
};
use std::fmt::Write as _;
let mut md = String::from("## day lint\n\n");
if active.is_empty() {
md.push_str("✅ no findings");
} else {
let _ = writeln!(md, "⚠️ {} finding(s)\n", active.len());
md.push_str("| code | finding |\n| --- | --- |\n");
for f in active {
let _ = writeln!(
md,
"| `{}` | {} |",
f.code,
f.message.replace('|', "\\|").replace('\n', "<br>")
);
}
}
for (code, (n, _)) in waived {
let _ = writeln!(md, "\n_{n} `{code}` finding(s) waived by `--allow`_");
}
md.push('\n');
if let Ok(mut file) = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)
{
use std::io::Write as _;
let _ = file.write_all(md.as_bytes());
}
}
fn lint_ftl_call(locale: &str, file: &str, src: &str, call: &day_build::FtlCall) -> Vec<Finding> {
let at = format!("resource/locales/{locale}: {}", call.key);
let bad = |opt: &str, val: &str, expected: &str| Finding {
code: "day::lint::bad-format-option",
message: format!("{at}: {}({opt}: {val:?}) — expected {expected}", call.name),
..Default::default()
};
let mut out = Vec::new();
match call.name.as_str() {
"NUMBER" => {
for (opt, val) in &call.named {
match opt.as_str() {
"style" => match val.as_str() {
"decimal" | "percent" => {}
"currency" => out.push(Finding {
code: "day::lint::unsupported-format-option",
message: format!(
"{at}: NUMBER(style: \"currency\") is not supported yet — \
it renders as a plain decimal"
),
..Default::default()
}),
other => out.push(bad("style", other, "\"decimal\" or \"percent\"")),
},
"useGrouping" => {
if !matches!(val.as_str(), "true" | "false") {
out.push(bad("useGrouping", val, "\"true\" or \"false\""));
}
}
"type" => {}
"currency" | "currencyDisplay" => out.push(Finding {
code: "day::lint::unsupported-format-option",
message: format!("{at}: NUMBER {opt} is not supported yet"),
..Default::default()
}),
"minimumIntegerDigits"
| "minimumFractionDigits"
| "maximumFractionDigits"
| "minimumSignificantDigits"
| "maximumSignificantDigits" => {
if val.parse::<u32>().is_err() {
out.push(bad(opt, val, "a digit count"));
}
}
other => out.push(bad(other, val, "a NUMBER option (ECMA-402 names)")),
}
}
}
"DATETIME" => {
for (opt, val) in &call.named {
match opt.as_str() {
"dateStyle" | "timeStyle" => {
if !matches!(val.as_str(), "full" | "long" | "medium" | "short" | "none") {
out.push(bad(opt, val, "full|long|medium|short|none"));
}
}
other => out.push(bad(other, val, "dateStyle or timeStyle")),
}
}
}
other => out.push(Finding {
code: "day::lint::unknown-function",
message: format!(
"{at}: unknown function {other}() — day provides NUMBER() and DATETIME()"
),
..Default::default()
}),
}
let at = Location::in_file(file, src, call.offset);
out.into_iter().map(|f| f.located(at.clone())).collect()
}
fn finish(n: usize, waived: usize, strict: bool) -> i32 {
let waived_note = match waived {
0 => String::new(),
w => format!(" ({w} allowed)"),
};
if n == 0 {
eprintln!("{SUCCESS}✓{SUCCESS:#} no lint findings{waived_note}");
0
} else {
eprintln!("{n} finding(s){waived_note}");
if strict {
crate::cli::ErrKind::Lint.exit_code()
} else {
0
}
}
}
fn lint_vectors(project: &Project, findings: &mut Vec<Finding>) {
let dir = project.root.join("resource/vectors");
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if fname.starts_with('.') {
continue;
}
let svg_path = if path.is_file() && fname.to_ascii_lowercase().ends_with(".svg") {
path.clone()
} else if path.is_dir() && fname.to_ascii_lowercase().ends_with(".symbolset") {
match std::fs::read_dir(&path).ok().and_then(|d| {
d.flatten()
.map(|e| e.path())
.find(|p| p.extension().and_then(|x| x.to_str()) == Some("svg"))
}) {
Some(inner) => inner,
None => {
findings.push(Finding {
code: "day::lint::vector-empty-symbolset",
message: format!("resource/vectors/{fname}: no inner .svg in the bundle"),
..Default::default()
});
continue;
}
}
} else {
continue;
};
let Ok(text) = std::fs::read_to_string(&svg_path) else {
findings.push(Finding {
code: "day::lint::vector-unreadable",
message: format!("resource/vectors/{fname}: unreadable"),
..Default::default()
});
continue;
};
let template = day_vector::classify(&text) == day_vector::SourceKind::SfTemplate;
let glyph = if template {
match day_vector::extract_variant(&text, "Regular", "M") {
Ok(g) => g,
Err(e) => {
findings.push(Finding {
code: "day::lint::vector-template",
message: format!("resource/vectors/{fname}: {e}"),
..Default::default()
});
continue;
}
}
} else {
text
};
if glyph.contains("<text") {
findings.push(Finding {
code: "day::lint::vector-text",
message: format!(
"resource/vectors/{fname}: glyph contains <text> — outline it (docs/vectors.md)"
),
..Default::default()
});
continue;
}
match day_vector::parse(glyph.as_bytes()) {
Err(e) => findings.push(Finding {
code: "day::lint::vector-parse",
message: format!("resource/vectors/{fname}: {e}"),
..Default::default()
}),
Ok(tree) => {
if project
.manifest
.app
.targets
.iter()
.any(|t| t == "android-mdc")
&& let Err(why) = day_vector::to_vector_drawable(&tree)
{
findings.push(Finding {
code: "day::lint::vector-raster-fallback",
message: format!(
"resource/vectors/{fname}: {why} is outside the VectorDrawable \
subset — Android ships a raster fallback"
),
..Default::default()
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ftl_function_lint() {
const SRC: &str = r#"
a = { NUMBER($n, style: "percent", minimumFractionDigits: 2) }
b = { NUMBER($n, style: "currency", currency: "USD") }
c = { NUMBER($n, stlye: "percent") }
d = { DATETIME($d, dateStyle: "extra-long") }
e = { PLATFORM() }
"#;
let calls = day_build::function_calls(SRC);
let src = SRC;
let findings: Vec<Finding> = calls
.iter()
.flat_map(|c| lint_ftl_call("en", "resource/locales/en/app.ftl", src, c))
.collect();
let codes: Vec<&str> = findings.iter().map(|f| f.code).collect();
assert_eq!(
codes,
[
"day::lint::unsupported-format-option", "day::lint::unsupported-format-option", "day::lint::bad-format-option", "day::lint::bad-format-option", "day::lint::unknown-function", ],
"{findings:?}"
);
let lines: Vec<usize> = findings
.iter()
.map(|f| f.location.as_ref().expect("every call has a place").line)
.collect();
assert_eq!(lines, [3, 3, 4, 5, 6], "{findings:?}");
}
#[test]
fn the_envelope_carries_place_fix_and_waiver() {
let findings = vec![
Finding {
code: "day::lint::unknown-target",
message: "not a target".into(),
location: Some(Location::in_file(
"Day.toml",
"[app]\nid = \"x\"\ntargets = [\"atari-tos\"]",
"[app]\nid = \"x\"\n".len(),
)),
fix: None,
},
Finding {
code: "day::lint::store-whitespace",
message: "trailing space".into(),
location: Some(Location::head("store/en/name.txt")),
fix: Some(Fix {
title: "Trim the surrounding whitespace".into(),
file: "store/en/name.txt".into(),
contents: "Name\n".into(),
}),
},
Finding {
code: "day::lint::store-placeholder",
message: "still TODO".into(),
..Default::default()
},
];
let allow = vec!["store-placeholder".into()];
let doc = envelope(Path::new("/app"), &findings, &allow);
let rows = doc["findings"].as_array().expect("findings is an array");
assert_eq!(rows[0]["severity"], "error");
assert_eq!(rows[0]["line"], 3);
assert_eq!(rows[0]["column"], 1);
assert_eq!(rows[0]["waived"], false);
assert!(rows[0].get("fix").is_none());
assert_eq!(rows[1]["fix"]["contents"], "Name\n");
assert_eq!(rows[1]["severity"], "warning");
assert_eq!(rows[2]["waived"], true);
assert!(rows[2].get("file").is_none(), "nothing to point at");
assert_eq!(doc["counts"]["errors"], 1);
assert_eq!(doc["counts"]["warnings"], 1);
assert_eq!(doc["counts"]["waived"], 1);
assert_eq!(doc["counts"]["fixable"], 1);
}
#[test]
fn a_waived_finding_is_never_rewritten() {
let f = Finding {
code: "day::lint::store-whitespace",
message: "trailing space".into(),
fix: Some(Fix {
title: "Trim".into(),
file: "store/en/name.txt".into(),
contents: "Name\n".into(),
}),
..Default::default()
};
let doc = envelope(Path::new("/app"), &[f], &["store-whitespace".to_string()]);
assert_eq!(doc["counts"]["fixable"], 0);
}
#[test]
fn severity_is_reserved_for_findings_about_something_that_does_not_exist() {
assert_eq!(severity_of("day::lint::unknown-route"), Severity::Error);
assert_eq!(severity_of("day::lint::unknown-target"), Severity::Error);
assert_eq!(severity_of("day::lint::unknown-key"), Severity::Warning);
assert_eq!(
severity_of("day::lint::missing-translation"),
Severity::Warning
);
assert_eq!(
severity_of("day::lint::store-placeholder"),
Severity::Warning
);
assert_eq!(
severity_of("day::lint::whatever-comes-next"),
Severity::Warning
);
}
#[test]
fn an_attribute_is_the_same_key_under_either_spelling() {
let catalog: Vec<String> = ["menu_group".into(), "menu_group.key".into()].into();
let spelled = spellings(catalog.iter());
assert!(
spelled.contains("menu_group.key"),
"the Fluent spelling stays valid"
);
assert!(
spelled.contains("menu_group_key"),
"so does the generated function's name"
);
assert!(!spelled.contains("menu_group_missing"));
let used: BTreeSet<String> = ["menu_group_key".to_string()].into_iter().collect();
let none = BTreeSet::new();
assert!(
is_referenced("menu_group.key", &used, &none),
"used via res::str"
);
assert!(!is_referenced("menu_undo.key", &used, &none));
let dotted: BTreeSet<String> = ["menu_group.key".to_string()].into_iter().collect();
assert!(is_referenced("menu_group.key", &dotted, &none));
assert!(is_referenced("menu_group.key", &none, &used));
}
#[test]
fn a_pattern_never_matches_inside_a_longer_identifier() {
let src = r#"
out.push_str("<g>");
label(tr("real_key"));
renavigate("not-a-route");
page.navigate("home");
"#;
let literals = |pat: &str| -> Vec<&str> {
matches_of(src, pat)
.map(|at| {
let rest = &src[at + pat.len()..];
&rest[..rest.find('"').unwrap_or(0)]
})
.collect()
};
assert_eq!(
literals("tr(\""),
["real_key"],
"`push_str(` is not a `tr(` call"
);
assert_eq!(
literals("navigate(\""),
["home"],
"`renavigate(` is a different function"
);
let dotted = r#"sidebar.item("home", …).item("stack", …)"#;
assert_eq!(matches_of(dotted, ".item(\"").count(), 2);
}
fn app(name: &str, files: &[(&str, &str)]) -> (std::path::PathBuf, Project) {
let dir = std::env::temp_dir().join(format!("day-lint-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("Cargo.toml");
std::fs::write(
dir.join("Day.toml"),
"schema = 1\n[app]\nid = \"dev.example.a\"\ntargets = [\"macos-appkit\"]\n",
)
.expect("Day.toml");
for (rel, text) in files {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, text).expect("write");
}
let project = crate::meta::find_project(Some(&dir)).expect("project");
(dir, project)
}
fn codes(project: &Project) -> Vec<&'static str> {
let mut out: Vec<&'static str> = collect(project).iter().map(|f| f.code).collect();
out.sort_unstable();
out
}
#[test]
fn a_shortcut_attribute_is_neither_unknown_nor_unused_nor_demanded_of_every_locale() {
let (dir, project) = app(
"attr",
&[
(
"resource/locales/en/app.ftl",
"menu_group = Group\n .key = g\nmenu_solo = Solo\n",
),
(
"resource/locales/fr/app.ftl",
"menu_group = Grouper\nmenu_solo = Solo\n",
),
(
"src/lib.rs",
"pub fn f() { res::str::menu_group(); res::str::menu_group_key(); \
res::str::menu_solo(); }\n",
),
],
);
assert_eq!(
codes(&project),
Vec::<&str>::new(),
"nothing is wrong with this app"
);
std::fs::write(
dir.join("resource/locales/fr/app.ftl"),
"menu_group = Grouper\n",
)
.expect("write");
assert_eq!(codes(&project), ["day::lint::missing-translation"]);
std::fs::write(
dir.join("resource/locales/fr/app.ftl"),
"menu_group = Grouper\nmenu_solo = Solo\n",
)
.expect("write");
std::fs::write(
dir.join("resource/locales/en/app.ftl"),
"menu_group = Group\n .key = g\n .hint = nobody reads this\nmenu_solo = Solo\n",
)
.expect("write");
assert_eq!(codes(&project), ["day::lint::unused-key"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn allow_matches_bare_and_qualified_codes() {
let allow = vec![
"store-placeholder".into(),
" day::lint::duplicate-id ".into(),
];
assert!(allowed("day::lint::store-placeholder", &allow));
assert!(allowed("day::lint::duplicate-id", &allow));
assert!(!allowed("day::lint::store-missing", &allow));
assert!(!allowed("day::store::store-placeholder", &allow));
assert!(!allowed("day::lint::store-placeholder", &[]));
}
#[test]
fn first_segment_extraction() {
assert_eq!(route_first_segment("stack/item-42?hint=x"), "stack");
assert_eq!(route_first_segment("controls"), "controls");
assert_eq!(route_first_segment("a?x=1"), "a");
assert_eq!(route_first_segment(""), "");
}
#[test]
fn routes_macro_key_extraction() {
let dir = std::env::temp_dir().join(format!("day-lint-routes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"day::routes! {\n pub(crate) enum Section { Home => \"home\", Stack => \"stack\" }\n}\nfn f() { let x = match y { A => \"not-a-key\" }; }\n",
)
.unwrap();
let mut out = Vec::new();
scan_routes_macro_keys(&dir, &mut out);
let mut keys: Vec<String> = out.iter().map(|h| h.text.clone()).collect();
keys.sort();
assert_eq!(keys, ["home", "stack"]);
assert!(out.iter().all(|h| h.line == 2), "{out:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn script_route_extraction() {
let dir = std::env::temp_dir().join(format!("day-lint-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("walk.yaml"),
"flow:\n - navigate: { route: controls }\n - assert_route: { route: \"stack/1\" }\n - tap: { id: x }\n - navigate: { route: 'tabs' }\n",
)
.unwrap();
let mut out = Vec::new();
scan_script_routes(&dir, &mut out);
let mut routes: Vec<String> = out.iter().map(|h| h.text.clone()).collect();
routes.sort();
assert_eq!(routes, ["controls", "stack/1", "tabs"]);
let mut lines: Vec<usize> = out.iter().map(|h| h.line).collect();
lines.sort();
assert_eq!(lines, [2, 3, 5], "{out:?}");
let f = dir.join("filtered.yaml");
std::fs::write(
&f,
"flow:\n - navigate: { route: webview, skip_on: [harmony-arkui] }\n",
)
.expect("write");
let mut hits = Vec::new();
scan_script_routes(&dir, &mut hits);
let routes: Vec<&str> = hits.iter().map(|h| h.text.as_str()).collect();
assert!(routes.contains(&"webview"), "{routes:?}");
assert!(
!routes.iter().any(|r| r.contains("skip_on")),
"the filter is not part of the route: {routes:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
}