use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Reference {
pub module: String,
pub function: Option<String>,
}
#[derive(Debug, Default, PartialEq)]
pub struct ElixirFile {
pub defines: Vec<String>,
pub refs: Vec<Reference>,
}
pub(super) fn is_ident(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn closing(chars: &[char], from: usize, closer: &[char], interpolates: bool) -> usize {
let mut i = from;
while i < chars.len() {
if interpolates && chars[i] == '\\' {
i += 2;
} else if interpolates && chars[i..].starts_with(&['#', '{']) {
i = interpolation_end(chars, i + 2);
} else if chars[i..].starts_with(closer) {
return i + closer.len();
} else {
i += 1;
}
}
chars.len()
}
fn interpolation_end(chars: &[char], from: usize) -> usize {
let mut depth = 1usize;
let mut i = from;
while i < chars.len() {
match chars[i] {
'"' => {
i = closing(chars, i + 1, &['"'], true);
continue;
}
'{' => depth += 1,
'}' if depth == 1 => return i + 1,
'}' => depth -= 1,
_ => {}
}
i += 1;
}
chars.len()
}
fn sigil_end(chars: &[char], i: usize) -> usize {
let name_end = i
+ 1
+ chars[i + 1..]
.iter()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let Some(&open) = chars.get(name_end).filter(|_| name_end > i + 1) else {
return i;
};
let interpolates = chars[i + 1].is_ascii_lowercase();
let heredoc = [open; 3];
if matches!(open, '"' | '\'') && chars[name_end..].starts_with(&heredoc) {
return closing(chars, name_end + 3, &heredoc, interpolates);
}
let close = match open {
'(' => ')',
'[' => ']',
'{' => '}',
'<' => '>',
'"' | '\'' | '/' | '|' => open,
_ => return i,
};
closing(chars, name_end + 1, &[close], interpolates)
}
fn literal_end(chars: &[char], i: usize) -> usize {
let after_ident = i > 0 && is_ident(chars[i - 1]);
match chars[i..] {
['#', ..] => i + chars[i..].iter().take_while(|&&c| c != '\n').count(),
[q @ ('"' | '\''), b, c, ..] if q == b && q == c => closing(chars, i + 3, &[q; 3], true),
[q @ ('"' | '\''), ..] => closing(chars, i + 1, &[q], true),
['~', ..] => sigil_end(chars, i),
['?', '\\', _, ..] if !after_ident => i + 3,
['?', _, ..] if !after_ident => i + 2,
_ => i,
}
}
pub(super) fn code_only(source: &str) -> String {
let chars: Vec<char> = source.chars().collect();
let mut out = String::with_capacity(source.len());
let mut i = 0;
while i < chars.len() {
let end = literal_end(&chars, i).min(chars.len());
if end == i {
out.push(chars[i]);
i += 1;
continue;
}
out.extend(
chars[i..end]
.iter()
.map(|&c| if c == '\n' { '\n' } else { ' ' }),
);
i = end;
}
out
}
fn module_name(text: &str) -> &str {
if !text.starts_with(|c: char| c.is_ascii_uppercase()) {
return "";
}
let mut end = 0;
for segment in text.split('.') {
let name_len = segment.find(|c| !is_ident(c)).unwrap_or(segment.len());
if !segment.starts_with(|c: char| c.is_ascii_uppercase()) {
break;
}
end += name_len + usize::from(end > 0);
if name_len < segment.len() {
break;
}
}
&text[..end]
}
#[derive(Default)]
struct Nesting(Vec<(usize, String)>);
impl Nesting {
fn leave(&mut self, indent: usize) {
while self.0.last().is_some_and(|(open, _)| *open >= indent) {
self.0.pop();
}
}
fn current(&self) -> Option<&str> {
self.0.last().map(|(_, name)| name.as_str())
}
fn enter(&mut self, indent: usize, name: &str) -> String {
let full = match self.current() {
Some(outer) => format!("{outer}.{name}"),
None => name.to_string(),
};
self.0.push((indent, full.clone()));
full
}
}
fn modules_of(code: &str) -> (Vec<String>, String) {
let mut nesting = Nesting::default();
let mut defines = Vec::new();
let mut resolved = String::with_capacity(code.len());
for line in code.lines() {
let content = line.trim_start();
if !content.is_empty() {
nesting.leave(line.len() - content.len());
}
let declared = ["defmodule ", "defprotocol "]
.iter()
.find_map(|keyword| content.strip_prefix(keyword))
.map(|rest| module_name(rest.trim_start()))
.filter(|name| !name.is_empty());
if let Some(name) = declared {
defines.push(nesting.enter(line.len() - content.len(), name));
}
match nesting.current() {
Some(current) => resolved.push_str(&line.replace("__MODULE__", current)),
None => resolved.push_str(line),
}
resolved.push('\n');
}
(defines, resolved)
}
#[derive(Default)]
pub(super) struct Aliases(HashMap<String, String>);
impl Aliases {
fn expand(&self, name: &str) -> String {
let (first, rest) = name.split_once('.').map_or((name, ""), |(f, r)| (f, r));
match self.0.get(first) {
Some(full) if rest.is_empty() => full.clone(),
Some(full) => format!("{full}.{rest}"),
None => name.to_string(),
}
}
fn add(&mut self, full: String, short: Option<&str>) {
let short = short
.or_else(|| full.rsplit('.').next())
.unwrap_or_default()
.to_string();
if short != full {
self.0.insert(short, full);
}
}
fn declare(&mut self, statement: &str) {
let base = module_name(statement);
if base.is_empty() {
return;
}
let base_full = self.expand(base);
let rest = &statement[base.len()..];
if let Some(group) = rest.strip_prefix(".{") {
let members = &group[..group.find('}').unwrap_or(group.len())];
for member in members.split(',').map(|m| module_name(m.trim())) {
if !member.is_empty() {
self.add(format!("{base_full}.{member}"), None);
}
}
return;
}
let renamed = rest
.trim_start()
.strip_prefix(',')
.and_then(|options| options.trim_start().strip_prefix("as:"))
.map(|name| module_name(name.trim_start()))
.filter(|name| !name.is_empty());
self.add(base_full, renamed);
}
pub(super) fn of(code: &str) -> (Self, String) {
let mut aliases = Self::default();
let mut rest = code.to_string();
for (at, _) in code.match_indices("alias") {
let whole_word = !code[..at].ends_with(is_ident);
let statement = code[at + "alias".len()..].strip_prefix(char::is_whitespace);
let Some(statement) = statement.filter(|_| whole_word) else {
continue;
};
aliases.declare(statement.trim_start());
let end = at + statement_len(&code[at..]);
rest.replace_range(at..end, &blank(&code[at..end]));
}
(aliases, rest)
}
}
fn statement_len(text: &str) -> usize {
let line = text.find('\n').unwrap_or(text.len());
match text[..line].contains(".{") {
true => text.find('}').map_or(text.len(), |close| close + 1),
false => line,
}
}
fn blank(text: &str) -> String {
text.chars()
.map(|c| match c {
'\n' => "\n".to_string(),
_ => " ".repeat(c.len_utf8()),
})
.collect()
}
fn called(after: &str) -> Option<&str> {
let name = after.strip_prefix('.')?;
let end = name
.find(|c| !is_ident(c) && c != '?' && c != '!')
.unwrap_or(name.len());
name.starts_with(|c: char| c.is_ascii_lowercase() || c == '_')
.then(|| &name[..end])
}
fn references(code: &str, aliases: &Aliases) -> Vec<Reference> {
let mut refs = Vec::new();
let mut rest = code;
let mut before = ' ';
while let Some(c) = rest.chars().next() {
let name = module_name(rest);
if name.is_empty() || is_ident(before) || before == ':' || before == '.' {
before = c;
rest = &rest[c.len_utf8()..];
continue;
}
let after = &rest[name.len()..];
let is_keyword = after.starts_with(':') && !after.starts_with("::");
if !is_keyword {
refs.push(Reference {
module: aliases.expand(name),
function: called(after).map(str::to_string),
});
}
before = '_';
rest = after;
}
refs.sort();
refs.dedup();
refs
}
pub fn parse(source: &str) -> ElixirFile {
let (defines, code) = modules_of(&code_only(source));
let (aliases, used) = Aliases::of(&code);
ElixirFile {
refs: references(&used, &aliases),
defines,
}
}
#[cfg(test)]
#[path = "elixir_test.rs"]
mod tests;