use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::meta::Project;
use crate::term::{SUCCESS, WARN};
use anstream::eprintln;
#[derive(Debug)]
pub struct Finding {
pub code: &'static str,
pub message: String,
}
fn scan_res_str(dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
scan_res_str(&p, out);
} else if p.extension().is_some_and(|x| x == "rs")
&& let Ok(src) = std::fs::read_to_string(&p)
{
let pat = "res::str::";
let mut rest = src.as_str();
while let Some(i) = rest.find(pat) {
rest = &rest[i + 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(s[..end].to_string());
}
}
}
}
}
fn scan_sources(dir: &Path, pat: &str, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
scan_sources(&p, pat, out);
} else if p.extension().is_some_and(|x| x == "rs")
&& let Ok(src) = std::fs::read_to_string(&p)
{
let mut rest = src.as_str();
while let Some(i) = rest.find(pat) {
rest = &rest[i + pat.len()..];
if let Some(end) = rest.find('"') {
out.push(rest[..end].to_string());
rest = &rest[end..];
}
}
}
}
}
fn route_first_segment(route: &str) -> &str {
route.split(['/', '?']).next().unwrap_or("")
}
fn scan_routes_macro_keys(dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
scan_routes_macro_keys(&p, out);
} else if p.extension().is_some_and(|x| x == "rs")
&& let Ok(src) = std::fs::read_to_string(&p)
{
let mut rest = src.as_str();
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(body[..q].to_string());
body = &body[q..];
}
}
rest = &rest[end..];
}
}
}
}
fn scan_script_routes(dir: &Path, out: &mut Vec<String>) {
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("- navigate:") || l.starts_with("- assert_route:")) {
continue;
}
if let Some(i) = l.rfind("route:") {
let v = l[i + "route:".len()..]
.trim()
.trim_end_matches(['}', ' '])
.trim()
.trim_matches(['"', '\'']);
if !v.is_empty() {
out.push(v.to_string());
}
}
}
}
}
}
pub fn run(project: &Project, strict: bool) -> i32 {
let mut findings: Vec<Finding> = Vec::new();
for t in &project.manifest.app.targets {
if crate::targets::find(t).is_none() {
findings.push(Finding {
code: "day::lint::unknown-target",
message: format!("Day.toml: targets entry {t:?} is not a known target"),
});
}
}
{
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); if let Some(platform) = t.name.split('-').next() {
known.insert(platform); }
}
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"
),
});
}
}
}
let locales_dir = project.root.join("resource/locales");
let mut locales: BTreeMap<String, BTreeSet<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 = BTreeSet::new();
if let Ok(files) = std::fs::read_dir(e.path()) {
for f in files.flatten() {
if f.path().extension().is_some_and(|x| x == "ftl")
&& let Ok(src) = std::fs::read_to_string(f.path())
{
keys.extend(day_build::message_keys(&src));
}
}
}
locales.insert(name, keys);
}
}
}
let mut used_keys = Vec::new();
scan_sources(&project.root.join("src"), "tr(\"", &mut used_keys);
scan_res_str(&project.root.join("src"), &mut used_keys);
let used: BTreeSet<String> = used_keys.into_iter().collect();
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() {
for k in &used {
if !default_keys.contains(k) {
findings.push(Finding {
code: "day::lint::unknown-key",
message: format!("tr({k:?}) has no message in resource/locales/{default_name}"),
});
}
}
for k in &default_keys {
if !used.contains(k) {
findings.push(Finding {
code: "day::lint::unused-key",
message: format!("resource/locales/{default_name}: {k} is never referenced"),
});
}
}
for (name, keys) in &locales {
if name == &default_name {
continue;
}
for k in &default_keys {
if !keys.contains(k) {
findings.push(Finding {
code: "day::lint::missing-translation",
message: format!("resource/locales/{name}: missing {k}"),
});
}
}
}
}
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_some_and(|x| x == "ftl") {
continue;
}
let Ok(src) = std::fs::read_to_string(f.path()) else {
continue;
};
for call in day_build::function_calls(&src) {
findings.extend(lint_ftl_call(&locale, &call));
}
}
}
}
let mut declared_keys = Vec::new();
scan_sources(&project.root.join("src"), ".item(\"", &mut declared_keys);
scan_routes_macro_keys(&project.root.join("src"), &mut declared_keys);
if !declared_keys.is_empty() {
let declared: BTreeSet<String> = declared_keys.into_iter().collect();
let mut used_routes: Vec<(String, String)> = Vec::new();
let mut nav_calls = Vec::new();
scan_sources(&project.root.join("src"), "navigate(\"", &mut nav_calls);
used_routes.extend(nav_calls.into_iter().map(|r| ("navigate".to_string(), r)));
let mut script_routes = Vec::new();
scan_script_routes(&project.root.join("dayscript"), &mut script_routes);
used_routes.extend(
script_routes
.into_iter()
.map(|r| ("dayscript".to_string(), r)),
);
for (origin, route) 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"
),
});
}
}
}
let mut ids = Vec::new();
scan_sources(&project.root.join("src"), ".id(\"", &mut ids);
let mut seen = BTreeSet::new();
for id in &ids {
if !seen.insert(id.clone()) {
findings.push(Finding {
code: "day::lint::duplicate-id",
message: format!("element id {id:?} used more than once"),
});
}
}
for f in &findings {
eprintln!("{WARN}warning{WARN:#} {:<32} {}", f.code, f.message);
}
finish(findings.len(), strict)
}
fn lint_ftl_call(locale: &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),
};
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"
),
}),
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"),
}),
"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()"
),
}),
}
out
}
fn finish(n: usize, strict: bool) -> i32 {
if n == 0 {
eprintln!("{SUCCESS}✓{SUCCESS:#} no lint findings");
0
} else {
eprintln!("{n} finding(s)");
if strict { 10 } else { 0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ftl_function_lint() {
let calls = day_build::function_calls(
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 findings: Vec<Finding> = calls.iter().flat_map(|c| lint_ftl_call("en", 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:?}"
);
}
#[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);
out.sort();
assert_eq!(out, ["home", "stack"]);
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);
out.sort();
assert_eq!(out, ["controls", "stack/1", "tabs"]);
std::fs::remove_dir_all(&dir).ok();
}
}