use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tatara_lisp::{Atom, Sexp, Span, Spanned, SpannedForm};
pub trait Loader {
fn load(&self, name: &str) -> Result<Vec<(String, String)>, String>;
}
pub struct NoLoader;
impl Loader for NoLoader {
fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
Err(format!(
"cannot load bidama \"{name}\": no loader is installed. A program \
that uses packages must run with one — `blue_lang_pkg::LoadPath` \
reads BLUE_PATH, which `nix develop` and the bidama derivations \
populate."
))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FileId(usize);
#[derive(Clone, Debug)]
pub struct SourceFile {
pub id: FileId,
pub path: Option<PathBuf>,
pub text: String,
}
#[derive(Clone, Copy, Debug)]
pub struct Entry<'a> {
pub path: Option<&'a Path>,
pub text: &'a str,
}
impl<'a> Entry<'a> {
#[must_use]
pub fn anonymous(text: &'a str) -> Self {
Self { path: None, text }
}
}
#[derive(Debug)]
pub struct ResolvedProgram {
forms: Vec<Spanned>,
owner: Vec<FileId>,
files: Vec<SourceFile>,
}
impl ResolvedProgram {
pub const ENTRY: FileId = FileId(0);
fn new(entry: Entry<'_>) -> Self {
let mut program = Self {
forms: Vec::new(),
owner: Vec::new(),
files: Vec::new(),
};
let id = program.intern(entry.path.map(Path::to_path_buf), entry.text.to_owned());
debug_assert_eq!(id, Self::ENTRY);
program
}
fn intern(&mut self, path: Option<PathBuf>, text: String) -> FileId {
let id = FileId(self.files.len());
self.files.push(SourceFile { id, path, text });
id
}
fn push(&mut self, form: Spanned, owner: FileId) {
self.forms.push(form);
self.owner.push(owner);
}
#[must_use]
pub fn forms(&self) -> &[Spanned] {
&self.forms
}
#[must_use]
pub fn sexps(&self) -> Vec<Sexp> {
self.forms.iter().map(Spanned::to_sexp).collect()
}
#[must_use]
pub fn files(&self) -> &[SourceFile] {
&self.files
}
#[must_use]
pub fn owner_of(&self, top_level: usize) -> Option<FileId> {
self.owner.get(top_level).copied()
}
#[must_use]
pub fn file(&self, id: FileId) -> Option<&SourceFile> {
self.files.get(id.0)
}
pub fn retain(&mut self, keep: impl Fn(&Spanned) -> bool) {
let paired = std::mem::take(&mut self.forms)
.into_iter()
.zip(std::mem::take(&mut self.owner));
for (form, owner) in paired {
if keep(&form) {
self.push(form, owner);
}
}
}
#[must_use]
pub fn locate<'a>(&'a self, top_level: usize, span: Span, message: &'a str) -> Located<'a> {
let Some(file) = self.owner_of(top_level).and_then(|id| self.file(id)) else {
return Located {
origin: Origin::Unresolved,
line_col: None,
message,
};
};
Located {
origin: file.path.as_deref().map_or(Origin::Anonymous, Origin::File),
line_col: (!span.is_synthetic() && span.end <= file.text.len())
.then(|| Span::line_col(&file.text, span.start)),
message,
}
}
}
#[derive(Clone, Copy, Debug)]
enum Origin<'a> {
File(&'a Path),
Anonymous,
Unresolved,
}
#[derive(Clone, Copy, Debug)]
pub struct Located<'a> {
origin: Origin<'a>,
line_col: Option<(usize, usize)>,
message: &'a str,
}
impl std::fmt::Display for Located<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.origin {
Origin::File(p) => write!(f, "{}", p.display())?,
Origin::Anonymous => f.write_str("<anonymous>")?,
Origin::Unresolved => f.write_str("<unknown file>")?,
}
if let Some((line, col)) = self.line_col {
write!(f, ":{line}:{col}")?;
}
write!(f, ": {}", self.message)
}
}
fn use_target(form: &Spanned) -> Option<String> {
let [head, arg] = form.as_list()? else {
return None;
};
match (&head.form, &arg.form) {
(SpannedForm::Atom(Atom::Symbol(s)), SpannedForm::Atom(Atom::Str(name))) if s == "use" => {
Some(name.clone())
}
_ => None,
}
}
pub fn is_test_form(form: &Spanned) -> bool {
let Some(items) = form.as_list() else {
return false;
};
matches!(items.first().and_then(Spanned::as_symbol), Some("deftest"))
}
pub fn resolve_uses(
forms: Vec<Spanned>,
entry: Entry<'_>,
loader: &dyn Loader,
) -> Result<ResolvedProgram, String> {
let mut out = ResolvedProgram::new(entry);
let mut seen = BTreeSet::new();
expand(
forms,
ResolvedProgram::ENTRY,
loader,
&mut out,
&mut seen,
&[],
)?;
Ok(out)
}
fn expand(
forms: Vec<Spanned>,
owner: FileId,
loader: &dyn Loader,
out: &mut ResolvedProgram,
seen: &mut BTreeSet<String>,
chain: &[String],
) -> Result<(), String> {
for form in forms {
let Some(name) = use_target(&form) else {
out.push(form, owner);
continue;
};
if !seen.insert(name.clone()) {
continue;
}
let sources = loader.load(&name).map_err(|e| describe(chain, &name, &e))?;
let mut inner_chain = chain.to_vec();
inner_chain.push(name.clone());
for (label, src) in sources {
let parsed = blue_lang_syntax::parse_program_tree(&src)
.map_err(|e| describe(chain, &name, &format!("{label}: {e}")))?;
let parsed: Vec<Spanned> = parsed.into_iter().filter(|f| !is_test_form(f)).collect();
let id = out.intern(Some(PathBuf::from(label)), src);
expand(parsed, id, loader, out, seen, &inner_chain)?;
}
}
Ok(())
}
fn describe(chain: &[String], name: &str, reason: &str) -> String {
if chain.is_empty() {
return reason.to_owned();
}
format!("while loading {} -> {name}: {reason}", chain.join(" -> "))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
struct MemLoader(BTreeMap<&'static str, &'static str>);
impl Loader for MemLoader {
fn load(&self, name: &str) -> Result<Vec<(String, String)>, String> {
self.0
.get(name)
.map(|s| vec![(format!("{name}.b"), (*s).to_owned())])
.ok_or_else(|| format!("no bidama named \"{name}\""))
}
}
fn parse(src: &str) -> Vec<Spanned> {
blue_lang_syntax::parse_program_tree(src).expect("test source must parse")
}
fn resolve(src: &str, loader: &dyn Loader) -> Result<ResolvedProgram, String> {
resolve_uses(parse(src), Entry::anonymous(src), loader)
}
#[test]
fn a_use_is_replaced_by_the_packages_forms() {
let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n n * 2\nend")]));
let out = resolve("use(\"kazu\")\ndouble(21)", &loader).expect("resolves");
assert!(
out.forms().iter().all(|f| super::use_target(f).is_none()),
"a use form survived resolution and would reach the evaluator as \
an unbound function: {:?}",
out.forms()
);
assert!(
out.forms().len() > 1,
"the package's definitions must be spliced in, not dropped: {:?}",
out.forms()
);
}
#[test]
fn imports_are_transitive() {
let loader = MemLoader(BTreeMap::from([
("retsu", "use(\"kazu\")\ndef sum2(a, b)\n a + b\nend"),
("kazu", "def double(n)\n n * 2\nend"),
]));
let out = resolve("use(\"retsu\")", &loader).expect("resolves");
assert!(
out.forms().len() >= 2,
"the transitive dependency did not arrive: {:?}",
out.forms()
);
}
#[test]
fn a_package_is_loaded_at_most_once() {
let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n n * 2\nend")]));
let once = resolve("use(\"kazu\")", &loader).expect("resolves");
let twice = resolve("use(\"kazu\")\nuse(\"kazu\")", &loader).expect("resolves");
assert_eq!(
once.forms().len(),
twice.forms().len(),
"importing a package twice duplicated its definitions; two \
importers of one package must share it"
);
assert_eq!(
once.files().len(),
twice.files().len(),
"importing a package twice interned its source twice; the file \
table must have one entry per file, not one per import"
);
}
#[test]
fn a_dependency_cycle_terminates() {
let loader = MemLoader(BTreeMap::from([
("a", "use(\"b\")\ndef fa()\n 1\nend"),
("b", "use(\"a\")\ndef fb()\n 2\nend"),
]));
let out = resolve("use(\"a\")", &loader).expect("a cycle must resolve");
assert!(
!out.forms().is_empty(),
"a cycle resolved to nothing: {:?}",
out.forms()
);
}
#[test]
fn a_missing_package_names_itself_and_the_chain() {
let loader = MemLoader(BTreeMap::from([("retsu", "use(\"nowhere\")")]));
let err = resolve("use(\"retsu\")", &loader).expect_err("must fail");
assert!(
err.contains("nowhere"),
"the error must name the missing package: {err}"
);
assert!(
err.contains("retsu"),
"the error must name the import that pulled it in, or the reader \
cannot tell which of their own imports is at fault: {err}"
);
}
#[test]
fn the_default_loader_refuses_by_name() {
let err = resolve("use(\"kazu\")", &NoLoader).expect_err("must fail");
assert!(
err.contains("kazu"),
"NoLoader must name what was asked for: {err}"
);
}
#[test]
fn an_imported_packages_tests_are_not_inherited() {
let loader = MemLoader(BTreeMap::from([(
"kazu",
"def double(n)\n n * 2\nend\n\ntest \"doubles\"\n assert double(2) == 4\nend",
)]));
let out = resolve("use(\"kazu\")\ndouble(21)", &loader).expect("resolves");
assert!(
out.forms().iter().all(|f| !super::is_test_form(f)),
"an imported test block survived and would reach the evaluator as \
an unbound `deftest`: {:?}",
out.forms()
);
assert!(
out.forms().len() >= 2,
"filtering tests also removed the package's definitions: {:?}",
out.forms()
);
}
#[test]
fn the_entry_programs_own_tests_survive() {
let loader = MemLoader(BTreeMap::new());
let src = "def f(n)\n n\nend\n\ntest \"t\"\n assert f(1) == 1\nend";
let out = resolve(src, &loader).expect("resolves");
assert!(
out.forms().iter().any(super::is_test_form),
"the file's OWN tests were dropped; only imported ones should be: {:?}",
out.forms()
);
}
#[test]
fn a_program_without_imports_is_unchanged() {
let loader = MemLoader(BTreeMap::new());
let src = "def f(n)\n n + 1\nend\nf(1)";
let before: Vec<Sexp> = parse(src).iter().map(Spanned::to_sexp).collect();
let out = resolve(src, &loader).expect("resolves");
assert_eq!(format!("{before:?}"), format!("{:?}", out.sexps()));
}
#[test]
fn every_forms_span_indexes_the_file_it_came_from() {
let loader = MemLoader(BTreeMap::from([
("retsu", "use(\"kazu\")\ndef sum2(a, b)\n a + b\nend"),
("kazu", "def double(n)\n n * 2\nend"),
]));
let out = resolve("use(\"retsu\")\nsum2(double(1), 2)", &loader).expect("resolves");
assert_eq!(
out.files().len(),
3,
"expected the entry plus retsu plus kazu: {:?}",
out.files()
);
for (i, form) in out.forms().iter().enumerate() {
let file = out
.owner_of(i)
.and_then(|id| out.file(id))
.unwrap_or_else(|| panic!("form {i} has no owning file"));
assert!(
!form.span.is_synthetic(),
"form {i} has no position at all — its file was parsed through \
the spanless door"
);
let slice = file
.text
.get(form.span.start..form.span.end)
.unwrap_or_else(|| {
panic!(
"form {i}'s span {:?} is not a range in its owner ({} bytes)",
form.span,
file.text.len()
)
});
let reparsed = blue_lang_syntax::parse_program_tree(slice)
.unwrap_or_else(|e| panic!("form {i}: its own source does not re-parse: {e}"));
assert_eq!(
reparsed.iter().map(Spanned::to_sexp).collect::<Vec<_>>(),
vec![form.to_sexp()],
"form {i} sliced out of its owner ({}) re-parses to a different \
tree; slice was {slice:?}",
file.path
.as_deref()
.map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string())
);
}
assert!(out.forms().len() >= 3, "{:?}", out.forms());
}
#[test]
fn retain_drops_a_forms_owner_with_it() {
let loader = MemLoader(BTreeMap::from([("kazu", "def double(n)\n n * 2\nend")]));
let src = "test \"t\"\n assert 1 == 1\nend\nuse(\"kazu\")\ndouble(21)";
let mut out = resolve(src, &loader).expect("resolves");
assert!(
out.forms().iter().any(super::is_test_form),
"the fixture must contain the test block this drops"
);
out.retain(|f| !super::is_test_form(f));
assert!(!out.forms().iter().any(super::is_test_form));
for (i, form) in out.forms().iter().enumerate() {
let file = out
.owner_of(i)
.and_then(|id| out.file(id))
.unwrap_or_else(|| panic!("form {i} lost its owner"));
let slice = &file.text[form.span.start..form.span.end];
let reparsed = blue_lang_syntax::parse_program_tree(slice)
.map(|f| f.iter().map(Spanned::to_sexp).collect::<Vec<_>>())
.unwrap_or_default();
assert_eq!(
reparsed,
vec![form.to_sexp()],
"after retain, form {i} does not belong to the file it is \
attributed to: slice {slice:?} of {}",
file.path
.as_deref()
.map_or_else(|| "<anonymous>".to_string(), |p| p.display().to_string())
);
}
assert_eq!(out.forms().len(), 2, "{:?}", out.forms());
}
#[test]
fn an_unstamped_diagnostic_gets_no_position_rather_than_a_wrong_one() {
let loader = MemLoader(BTreeMap::new());
let out = resolve("def f(n)\n n\nend", &loader).expect("resolves");
let rendered = out
.locate(
blue_lang_check::Diagnostic::UNSTAMPED,
tatara_lisp::Span::new(0, 1),
"something went wrong",
)
.to_string();
assert_eq!(rendered, "<unknown file>: something went wrong");
assert!(
!rendered.contains(":1:1"),
"an unowned diagnostic borrowed a position from somewhere: {rendered}"
);
}
}