use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
const CALLBACKS: &[&str] = &[
"validate",
"before_validation",
"before_save",
"before_create",
"before_update",
"after_create",
"after_update",
"after_save",
"before_destroy",
"after_destroy",
"pruning",
];
#[derive(Debug, Clone, PartialEq)]
pub struct Symbol {
pub kind: &'static str,
pub name: String,
pub parent: Option<String>,
pub file: String,
pub line: usize,
pub column: usize,
pub text: String,
pub end_line: Option<usize>,
}
impl Symbol {
pub fn to_json(&self) -> Value {
let mut out = json!({
"kind": self.kind,
"name": self.name,
"file": self.file,
"line": self.line,
"column": self.column,
"text": self.text,
});
if let Some(parent) = &self.parent {
out["parent"] = parent.clone().into();
}
if let Some(end) = self.end_line {
out["end_line"] = end.into();
}
out
}
pub fn qualified(&self) -> String {
match (&self.parent, self.kind) {
(Some(parent), "action") => format!("{parent}#{}", self.name),
(Some(parent), _) => format!("{parent}.{}", self.name),
(None, _) => self.name.clone(),
}
}
}
pub fn files(app: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for dir in ["src", "tests"] {
collect(&app.join(dir), &mut out);
}
out.sort();
out
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else if path
.extension()
.is_some_and(|e| e == "glacier" || e == "rs")
{
out.push(path);
}
}
}
pub fn index(app: &Path) -> Vec<Symbol> {
let mut symbols = Vec::new();
for path in files(app) {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
let file = relative(app, &path);
if path.extension().is_some_and(|e| e == "glacier") {
symbols.extend(glacier_symbols(&text, &file));
} else if !glacier_dialect::is_generated(&text) {
symbols.extend(rust_symbols(&text, &file));
}
}
symbols
}
pub fn glacier_symbols(text: &str, file: &str) -> Vec<Symbol> {
let lines: Vec<&str> = text.lines().collect();
let mut symbols: Vec<Symbol> = Vec::new();
let mut container: Option<(&'static str, String)> = None;
for (i, raw) in lines.iter().enumerate() {
let line = raw.trim();
let column = raw.len() - raw.trim_start().len() + 1;
if let Some(rest) = line.strip_prefix("implements ")
&& let Some((kind @ "module", name)) = container.clone()
{
let behaviour = match rest.trim() {
"Controller" => "controller",
"Model" => "model",
"Job" => "job",
"Mailer" => "mailer",
"Middleware" => "middleware",
"Client" => "client",
_ => kind,
};
if let Some(symbol) = symbols
.iter_mut()
.rev()
.find(|s| s.parent.is_none() && s.name == name)
{
symbol.kind = behaviour;
}
container = Some((behaviour, name));
}
let mut push = |kind: &'static str, name: &str, parent: Option<String>, block: bool| {
symbols.push(Symbol {
kind,
name: name.to_owned(),
parent,
file: file.to_owned(),
line: i + 1,
column,
text: line.to_owned(),
end_line: block.then(|| block_end(&lines, i)).flatten(),
});
};
let parent = container.as_ref().map(|(_, name)| name.clone());
let word = |rest: &str| -> String {
rest.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or_default()
.to_owned()
};
for (keyword, kind) in [
("model ", "model"),
("controller ", "controller"),
("service ", "service"),
("serializer ", "serializer"),
("job ", "job"),
("mailer ", "mailer"),
("client ", "client"),
("middleware ", "middleware"),
("module ", "module"),
] {
if column == 1
&& let Some(rest) = line.strip_prefix(keyword)
{
let name = word(rest);
push(kind, &name, None, true);
container = Some((kind, name));
}
}
if let Some(rest) = line.strip_prefix("factory ") {
push("factory", &word(rest), None, true);
} else if let Some(rest) = line.strip_prefix("trait :") {
push("trait", &word(rest), None, true);
} else if let Some(rest) = line.strip_prefix("test \"") {
let name = rest.split('"').next().unwrap_or_default();
push("test", name, None, true);
} else if let Some(rest) = line.strip_prefix("field ") {
push("field", &word(rest), parent, false);
} else if let Some(rest) = line.strip_prefix("has_attached :") {
push("field", &word(rest), parent, false);
} else if let Some(rest) = line.strip_prefix("arg ") {
push("field", &word(rest), parent, false);
} else if let Some(rest) = line
.strip_prefix("belongs_to :")
.or_else(|| line.strip_prefix("owned_by :"))
.or_else(|| line.strip_prefix("tenant :"))
.or_else(|| line.strip_prefix("has_many :"))
{
push("association", &word(rest), parent, false);
} else if let Some(rest) = line
.strip_prefix("scope ")
.or_else(|| line.strip_prefix("default_scope "))
{
let name = if line.starts_with("default_scope") {
"default_scope".to_owned()
} else {
word(rest)
};
push("scope", &name, parent, false);
} else if let Some(rest) = line
.strip_prefix("def ")
.or_else(|| line.strip_prefix("defp "))
{
let kind = match container.as_ref().map(|(k, _)| *k) {
Some("controller") if line.starts_with("def ") => "action",
Some(_) => "method",
None => "function",
};
push(kind, &word(rest), parent, true);
} else if let Some(rest) = line.strip_prefix("changeset ") {
push("changeset", &word(rest), parent, true);
} else if line == "prune do" || (line.starts_with("prune :") && line.ends_with(" do")) {
let name = line
.strip_prefix("prune :")
.map_or("prune".to_owned(), word);
push("prune", &name, parent, true);
} else if let Some(callback) = CALLBACKS
.iter()
.find(|c| line == format!("{c} do") || line.starts_with(&format!("{c} :")))
{
push("callback", callback, parent, line.ends_with(" do"));
}
}
symbols
}
fn block_end(lines: &[&str], i: usize) -> Option<usize> {
let indent = lines[i].len() - lines[i].trim_start().len();
lines
.iter()
.enumerate()
.skip(i + 1)
.find(|(_, l)| l.trim() == "end" && l.len() - l.trim_start().len() == indent)
.map(|(n, _)| n + 1)
}
fn rust_symbols(text: &str, file: &str) -> Vec<Symbol> {
let mut symbols = Vec::new();
let mut model_next = false;
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
let column = raw.len() - raw.trim_start().len() + 1;
if line.contains("glacier::model") || line.contains("derive(") && line.contains("Model") {
model_next = true;
continue;
}
let name_after = |prefix: &str| {
line.strip_prefix(prefix).map(|rest| {
rest.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or_default()
.to_owned()
})
};
let found = if let Some(name) = name_after("pub struct ").or_else(|| name_after("struct "))
{
let kind = if model_next {
"model"
} else if file.contains("mailers/") {
"mailer"
} else if file.contains("clients/") {
"client"
} else if file.contains("middleware/") {
"middleware"
} else {
"struct"
};
model_next = false;
Some((kind, name))
} else if let Some(rest) = line.strip_prefix("impl Job for ") {
Some(("job", rest.trim_end_matches(" {").trim().to_owned()))
} else {
[
"pub async fn ",
"async fn ",
"pub fn ",
"fn ",
"pub(crate) fn ",
"pub(crate) async fn ",
]
.iter()
.find_map(|p| name_after(p))
.map(|name| ("function", name))
};
if let Some((kind, name)) = found
&& !name.is_empty()
{
symbols.push(Symbol {
kind,
name,
parent: None,
file: file.to_owned(),
line: i + 1,
column,
text: line.to_owned(),
end_line: None,
});
}
}
symbols
}
pub fn lookup<'a>(symbols: &'a [Symbol], name: &str) -> Vec<&'a Symbol> {
let exact: Vec<&Symbol> = symbols.iter().filter(|s| s.qualified() == name).collect();
if !exact.is_empty() {
return exact;
}
symbols.iter().filter(|s| s.name == name).collect()
}
pub fn references(app: &Path, name: &str) -> Vec<Value> {
let mut found = Vec::new();
for path in files(app) {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if glacier_dialect::is_generated(&text) {
continue;
}
let file = relative(app, &path);
for (i, line) in text.lines().enumerate() {
for column in word_positions(line, name) {
found.push(json!({
"file": file,
"line": i + 1,
"column": column + 1,
"text": line.trim(),
}));
}
}
}
found
}
fn word_positions(line: &str, word: &str) -> Vec<usize> {
let is_ident = |c: char| c.is_alphanumeric() || c == '_';
line.match_indices(word)
.filter(|(at, _)| {
let before = line[..*at].chars().next_back();
let after = line[at + word.len()..].chars().next();
!before.is_some_and(is_ident) && !after.is_some_and(is_ident)
})
.map(|(at, _)| at)
.collect()
}
pub fn rename(app: &Path, from: &str, to: &str, apply: bool) -> Result<Vec<Value>, String> {
let valid = |w: &str| {
!w.is_empty()
&& w.chars().all(|c| c.is_alphanumeric() || c == '_')
&& !w.starts_with(|c: char| c.is_ascii_digit())
};
if !valid(from) || !valid(to) {
return Err("rename takes identifiers (letters, digits, underscores)".into());
}
let mut changes = Vec::new();
for path in files(app) {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if glacier_dialect::is_generated(&text) {
continue;
}
let file = relative(app, &path);
let mut renamed = String::with_capacity(text.len());
let mut changed = false;
for (i, line) in text.split_inclusive('\n').enumerate() {
let positions = word_positions(line, from);
if positions.is_empty() {
renamed.push_str(line);
continue;
}
let mut new_line = String::new();
let mut last = 0;
for at in &positions {
new_line.push_str(&line[last..*at]);
new_line.push_str(to);
last = at + from.len();
}
new_line.push_str(&line[last..]);
changes.push(json!({
"file": file,
"line": i + 1,
"before": line.trim_end(),
"after": new_line.trim_end(),
}));
renamed.push_str(&new_line);
changed = true;
}
if apply && changed {
fs::write(&path, renamed).map_err(|e| format!("writing {file}: {e}"))?;
}
}
Ok(changes)
}
pub fn callbacks(app: &Path, model: &str) -> Result<Value, String> {
let symbols = index(app);
let Some(definition) = symbols
.iter()
.find(|s| s.kind == "model" && s.name == model)
else {
return Err(format!("no model named `{model}`"));
};
let text = fs::read_to_string(app.join(&definition.file))
.map_err(|e| format!("reading {}: {e}", definition.file))?;
let lines: Vec<&str> = text.lines().collect();
let own: Vec<&Symbol> = symbols
.iter()
.filter(|s| s.file == definition.file && s.kind == "callback")
.collect();
let block = |name: &str| -> Vec<Value> {
own.iter()
.filter(|s| s.name == name)
.map(|s| {
let body = match s.end_line {
Some(end) => lines[s.line - 1..end].join("\n"),
None => s.text.clone(),
};
json!({"callback": name, "file": s.file, "line": s.line, "code": body})
})
.collect()
};
let associations: Vec<&str> = lines
.iter()
.map(|l| l.trim())
.filter(|l| l.starts_with("belongs_to ") || l.starts_with("has_many "))
.collect();
let touches: Vec<String> = associations
.iter()
.filter(|l| l.starts_with("belongs_to ") && l.split(',').any(|p| p.trim() == "touch"))
.map(|l| l.to_string())
.collect();
let dependents: Vec<String> = associations
.iter()
.filter(|l| l.contains("dependent:"))
.map(|l| l.to_string())
.collect();
let nested: Vec<String> = associations
.iter()
.filter(|l| l.split(',').any(|p| p.trim() == "nested"))
.map(|l| l.to_string())
.collect();
let step = |name: &str, detail: &str| json!({"step": name, "detail": detail});
let mut save = vec![];
save.extend(block("before_validation"));
save.push(step(
"validations",
"the fields' validations (required, length, format, …)",
));
save.extend(block("validate"));
save.extend(block("before_save"));
save.extend(block("before_create"));
save.extend(block("before_update"));
save.push(step(
"write",
"INSERT for a new record, UPDATE otherwise (only changed fields)",
));
for touch in &touches {
save.push(step("touch", touch));
}
save.extend(block("after_create"));
save.extend(block("after_update"));
save.extend(block("after_save"));
for association in &nested {
save.push(step(
"nested attributes",
&format!("on update(params) with <name>_attributes: {association}"),
));
}
let mut destroy = vec![];
destroy.extend(block("before_destroy"));
for dependent in &dependents {
destroy.push(step("dependent", dependent));
}
destroy.push(step("delete", "DELETE the record"));
for touch in &touches {
destroy.push(step("touch", touch));
}
destroy.extend(block("after_destroy"));
Ok(json!({
"model": model,
"file": definition.file,
"note": "before_create/after_create run for new records, before_update/after_update for \
existing ones. A callback returning an error stops the chain.",
"save": save,
"destroy": destroy,
}))
}
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::*;
const POST: &str = "model Post do\n field title: String, required\n belongs_to :author, User, touch\n has_many :comments, dependent: :destroy\n scope published, where(published: true)\n\n before_save do\n self.slug = self.title.to_lowercase()\n end\n\n def excerpt(self) -> String do\n self.title.clone()\n end\n\n changeset draft do\n cast :title\n end\nend\n";
#[test]
fn indexes_a_model() {
let symbols = glacier_symbols(POST, "src/models/post.glacier");
let found: Vec<(&str, String)> = symbols.iter().map(|s| (s.kind, s.qualified())).collect();
assert_eq!(
found,
[
("model", "Post".into()),
("field", "Post.title".into()),
("association", "Post.author".into()),
("association", "Post.comments".into()),
("scope", "Post.published".into()),
("callback", "Post.before_save".into()),
("method", "Post.excerpt".into()),
("changeset", "Post.draft".into()),
]
);
let model = &symbols[0];
assert_eq!((model.line, model.end_line), (1, Some(18)));
let callback = &symbols[5];
assert_eq!((callback.line, callback.end_line), (7, Some(9)));
}
#[test]
fn indexes_controllers_and_tests() {
let symbols = glacier_symbols(
"controller Posts do\n def show(ctx) do\n end\n\n defp helper() -> i32 do\n 1\n end\nend\n",
"c",
);
assert_eq!(symbols[1].qualified(), "Posts#show");
assert_eq!(symbols[2].kind, "method");
let tests = glacier_symbols("test \"lists posts\" do\nend\n", "t");
assert_eq!(
(tests[0].kind, tests[0].name.as_str()),
("test", "lists posts")
);
}
#[test]
fn indexes_a_glacier_job() {
let symbols = glacier_symbols(
"job SendReport do\n queue: :mailers\n arg report_id: i64\n\n def perform do\n end\n\n defp helper do\n end\nend\n",
"src/jobs/send_report.glacier",
);
let found: Vec<(&str, String)> = symbols.iter().map(|s| (s.kind, s.qualified())).collect();
assert!(
found.contains(&("job", "SendReport".to_owned())),
"{found:?}"
);
assert!(
found.contains(&("field", "SendReport.report_id".to_owned())),
"{found:?}"
);
assert!(
found.contains(&("method", "SendReport.perform".to_owned())),
"{found:?}"
);
let mailer = glacier_symbols(
"mailer ReportMailer do\n def ready(report: &Report) do\n mail to: \"a\", subject: \"b\"\n end\nend\n",
"src/mailers/report_mailer.glacier",
);
let found: Vec<(&str, String)> = mailer.iter().map(|s| (s.kind, s.qualified())).collect();
assert!(
found.contains(&("mailer", "ReportMailer".to_owned())),
"{found:?}"
);
assert!(
found.contains(&("method", "ReportMailer.ready".to_owned())),
"{found:?}"
);
}
#[test]
fn modules_take_the_kind_they_implement() {
let found = glacier_symbols(
"module PostsController do\n implements Controller\n\n def index do\n end\nend\n",
"src/controllers/posts_controller.glacier",
);
assert_eq!(found[0].kind, "controller");
assert_eq!(found[0].name, "PostsController");
assert_eq!(found[1].kind, "action", "{found:?}");
let plain = glacier_symbols(
"module Helpers do\n def a do\n end\nend\n",
"src/helpers.glacier",
);
assert_eq!(plain[0].kind, "module");
}
#[test]
fn names_mailers_and_clients_by_their_folder() {
let kinds = |file: &str| {
rust_symbols("pub struct Thing;\n", file)
.into_iter()
.map(|s| s.kind)
.collect::<Vec<_>>()
};
assert_eq!(kinds("src/mailers/order_mailer.rs"), ["mailer"]);
assert_eq!(kinds("src/clients/stripe.rs"), ["client"]);
assert_eq!(kinds("src/middleware/require_admin.rs"), ["middleware"]);
assert_eq!(kinds("src/money.rs"), ["struct"]);
}
#[test]
fn finds_whole_words_only() {
assert_eq!(word_positions("title: x.title, subtitle", "title"), [0, 9]);
assert_eq!(word_positions("field :title", "title"), [7]);
}
}