use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};
use std::rc::Rc;
use serde_json::{Value, json};
use crate::diagnostics::Source;
pub fn glacier_errors(app: &Path) -> Vec<Value> {
let mut errors = Vec::new();
for path in glacier_dialect::build::sources(app) {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
let name = path.file_name().unwrap_or_default().to_string_lossy();
match glacier_dialect::transpile_as(&text, &name, glacier_dialect::Mode::for_path(&path)) {
Ok(output) => {
for warning in &output.warnings {
let mut value = glacier_error(&relative(app, &path), warning);
value["severity"] = "warning".into();
errors.push(value);
}
}
Err(err) => errors.push(glacier_error(&relative(app, &path), &err)),
}
}
errors
}
pub fn glacier_error(file: &str, err: &glacier_dialect::Error) -> Value {
let mut out = json!({
"source": "glacier",
"severity": "error",
"file": file,
"line": err.line + 1,
"column": err.col + 1,
"message": err.message,
});
if let Some(expected) = expected_syntax(&err.message) {
out["expected"] = expected.into();
}
out
}
fn expected_syntax(message: &str) -> Option<String> {
let rest = &message[message.find("expected")?..];
let start = rest.find('`')? + 1;
let end = start + rest[start..].find('`')?;
Some(rest[start..end].to_owned())
}
pub fn build_errors(app: &Path) -> Result<Vec<Value>, String> {
let mut errors = glacier_errors(app);
if errors.iter().any(|e| e["severity"] == "error") {
return Ok(errors);
}
glacier_dialect::build::compile_all(app)?;
let output = Command::new("cargo")
.args(["check", "--all-targets", "--message-format", "json", "-q"])
.current_dir(app)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("running cargo: {e}"))?;
let stdout = output.stdout.expect("piped stdout");
let mut sources: HashMap<String, Option<Rc<Source>>> = HashMap::new();
let mut lookup = |file: &str| {
let path = if Path::new(file).is_absolute() {
file.to_owned()
} else {
app.join(file).display().to_string()
};
sources
.entry(path.clone())
.or_insert_with(|| crate::dialect::glacier_source(&path))
.clone()
};
let mut found = Vec::new();
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
let Ok(message) = serde_json::from_str::<Value>(&line) else {
continue;
};
if message["reason"] != "compiler-message" {
continue;
}
let diagnostic = &message["message"];
if !matches!(diagnostic["level"].as_str(), Some("error" | "warning")) {
continue;
}
let structured = crate::diagnostics::structured(diagnostic, &mut lookup);
if !found.contains(&structured) {
found.push(structured);
}
}
errors.extend(found);
Ok(errors)
}
pub fn models(app: &Path) -> Vec<Value> {
let dir = app.join("src/models");
let mut paths: Vec<_> = fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "glacier" || e == "rs"))
.filter(|p| p.file_stem().is_some_and(|s| s != "mod"))
.collect();
paths.sort();
let mut models = Vec::new();
for path in paths {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
let file = relative(app, &path);
if path.extension().is_some_and(|e| e == "rs") {
for name in rust_models(&text) {
models.push(json!({"name": name, "file": file, "language": "rust"}));
}
continue;
}
if let Some(model) = glacier_model(&text, &file) {
models.push(model);
}
}
models
}
fn glacier_model(text: &str, file: &str) -> Option<Value> {
let mut name = None;
let mut table = None;
let mut fields = Vec::new();
let mut associations = Vec::new();
for line in text.lines().map(str::trim) {
if let Some(rest) = line.strip_prefix("model ") {
let header = rest.strip_suffix(" do").unwrap_or(rest);
let mut parts = header.split(',').map(str::trim);
name = parts.next().map(str::to_owned);
table = parts
.find_map(|p| p.strip_prefix("table:"))
.map(|t| t.trim().trim_matches('"').to_owned());
} else if let Some(rest) = line.strip_prefix("field ") {
let Some((field, rest)) = rest.split_once(':') else {
continue;
};
let mut parts = split_top(rest);
let ty = parts.first().cloned().unwrap_or_default();
let options = parts.split_off(1.min(parts.len()));
fields.push(json!({"name": field.trim(), "type": ty, "options": options}));
} else if let Some((kind, rest)) = line
.strip_prefix("belongs_to ")
.or_else(|| line.strip_prefix("owned_by "))
.or_else(|| line.strip_prefix("tenant "))
.map(|r| ("belongs_to", r))
.or_else(|| line.strip_prefix("has_many ").map(|r| ("has_many", r)))
.or_else(|| line.strip_prefix("has_one ").map(|r| ("has_one", r)))
{
let parts = split_top(rest);
let target = parts.first().map(|p| p.trim_start_matches(':').to_owned());
associations.push(json!({
"kind": kind,
"name": target,
"options": parts.get(1..).map(<[String]>::to_vec).unwrap_or_default(),
}));
}
}
let name = name?;
let table =
table.unwrap_or_else(|| glacier_inflect::pluralize(&glacier_inflect::snake_case(&name)));
Some(json!({
"name": name,
"table": table,
"file": file,
"language": "glacier",
"fields": fields,
"associations": associations,
}))
}
fn rust_models(text: &str) -> Vec<String> {
let mut names = Vec::new();
let mut marked = false;
for line in text.lines().map(str::trim) {
if line.contains("glacier::model") || line.contains("Model)") || line.contains("Model,") {
marked = true;
} else if marked && let Some(rest) = line.strip_prefix("pub struct ") {
names.push(
rest.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or_default()
.to_owned(),
);
marked = false;
}
}
names
}
fn split_top(text: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut current = String::new();
for c in text.chars() {
match c {
'<' | '(' | '[' => depth += 1,
'>' | ')' | ']' => depth -= 1,
',' if depth == 0 => {
parts.push(current.trim().to_owned());
current.clear();
continue;
}
_ => {}
}
current.push(c);
}
if !current.trim().is_empty() {
parts.push(current.trim().to_owned());
}
parts
}
pub fn tasks(app: &Path) -> Vec<Value> {
let source = std::fs::read_to_string(app.join("src/tasks.glacier")).unwrap_or_default();
let mut tasks = Vec::new();
let mut about: Vec<&str> = Vec::new();
for line in source.lines().map(str::trim) {
if let Some(comment) = line.strip_prefix('#') {
about.push(comment.trim());
continue;
}
if let Some(rest) = line.strip_prefix("task \"")
&& let Some((name, rest)) = rest.split_once('"')
{
let args = rest
.trim_start_matches(',')
.trim_end()
.trim_end_matches("do")
.trim();
tasks.push(json!({
"name": name,
"args": args,
"about": about.join(" "),
"run": format!("glacier run {name}"),
}));
}
about.clear();
}
tasks
}
pub fn routes(app: &Path) -> Result<Vec<Value>, String> {
glacier_dialect::build::compile_all(app)?;
let output = Command::new("cargo")
.args(["run", "-q"])
.env("GLACIER_TASK", "routes")
.current_dir(app)
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("running cargo: {e}"))?;
if !output.status.success() {
return Err(format!(
"the app didn't build; run `check` for the errors\n{}",
String::from_utf8_lossy(&output.stderr)
.lines()
.rev()
.take(5)
.collect::<Vec<_>>()
.join("\n")
));
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let (method, path) = (parts.next()?, parts.next()?);
Some(json!({
"method": method,
"path": path,
"action": parts.collect::<Vec<_>>().join(" "),
}))
})
.collect())
}
pub fn app_task(app: &Path, task: &str, envs: &[(&str, &str)]) -> Result<Value, String> {
glacier_dialect::build::compile_all(app)?;
let output = Command::new("cargo")
.args(["run", "-q"])
.env("GLACIER_TASK", task)
.envs(envs.iter().copied())
.current_dir(app)
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("running cargo: {e}"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let last: Vec<&str> = stderr.lines().rev().take(8).collect();
return Err(last.into_iter().rev().collect::<Vec<_>>().join("\n"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(value) = serde_json::from_str(stdout.trim()) {
return Ok(value);
}
let json = stdout
.lines()
.rev()
.find(|l| l.starts_with('{'))
.unwrap_or_default();
serde_json::from_str(json).map_err(|_| format!("the `{task}` task printed no JSON:\n{stdout}"))
}
fn relative(app: &Path, path: &Path) -> String {
path.strip_prefix(app)
.unwrap_or(path)
.display()
.to_string()
.trim_start_matches("./")
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_expected_syntax() {
assert_eq!(
expected_syntax("expected `def name(args) -> Type do`").as_deref(),
Some("def name(args) -> Type do")
);
assert_eq!(expected_syntax("atoms only work in `render`"), None);
}
#[test]
fn reads_tasks_from_the_source() {
let app = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../guides/examples/jobs");
let tasks = tasks(&app);
assert_eq!(
tasks[0],
json!({
"name": "reports:requeue",
"args": "report_id: i64",
"about": "Sets a failed report back to pending, to be generated again.",
"run": "glacier run reports:requeue",
})
);
assert_eq!(tasks[1]["args"], "days: Option<i64>");
}
#[test]
fn reads_glacier_models() {
let model = glacier_model(
"model Post, table: \"blog_posts\" do\n field title: String, required, length(max: 200)\n field views: Option<i32>\n belongs_to :author, User\n has_many :comments, dependent: :destroy\nend\n",
"src/models/post.glacier",
)
.unwrap();
assert_eq!(model["name"], "Post");
assert_eq!(model["table"], "blog_posts");
assert_eq!(
model["fields"][0],
json!({"name": "title", "type": "String", "options": ["required", "length(max: 200)"]})
);
assert_eq!(model["fields"][1]["type"], "Option<i32>");
assert_eq!(
model["associations"],
json!([
{"kind": "belongs_to", "name": "author", "options": ["User"]},
{"kind": "has_many", "name": "comments", "options": ["dependent: :destroy"]}
])
);
let plain = glacier_model("model Tag do\nend\n", "x").unwrap();
assert_eq!(plain["table"], "tags");
}
}