📜 Add better documentation across the compiler. (#3)

These changes pay particular attention to API endpoints, to try to
ensure that any rustdocs generated are detailed and sensible. A good
next step, eventually, might be to include doctest examples, as well.
For the moment, it's not clear that they would provide a lot of value,
though.

In addition, this does a couple refactors to simplify the code base in
ways that make things clearer or, at least, briefer.
This commit is contained in:
2023-05-13 14:34:48 -05:00
parent f4594bf2cc
commit 1fbfd0c2d2
28 changed files with 1550 additions and 432 deletions

View File

@@ -2,15 +2,28 @@ use crate::eval::Value;
use internment::ArcIntern;
use std::sync::Arc;
/// An evaluation environment, which maps variable names to their
/// current values.
///
/// One key difference between `EvalEnvironment` and `HashMap` is that
/// `EvalEnvironment` uses an `extend` mechanism to add keys, rather
/// than an `insert`. This difference allows you to add mappings for
/// a subcomputation while still retaining the old version without those
/// keys, which is really handy for implementing variable scoping.
pub struct EvalEnvironment {
inner: Arc<EvalEnvInternal>,
}
pub enum EvalEnvInternal {
enum EvalEnvInternal {
Empty,
Value(ArcIntern<String>, Value, Arc<EvalEnvInternal>),
}
/// Errors that can happen when looking up a variable.
///
/// This enumeration may be extended in the future, depending on if we
/// get more subtle with our keys. But for now, this is just a handy
/// way to make lookup failures be `thiserror::Error`s.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum LookupError {
#[error("Could not find variable '{0}' in environment")]
@@ -24,28 +37,38 @@ impl Default for EvalEnvironment {
}
impl EvalEnvironment {
/// Create a new, empty environment.
pub fn empty() -> Self {
EvalEnvironment {
inner: Arc::new(EvalEnvInternal::Empty),
}
}
/// Extend the environment with a new mapping.
///
/// Note the types: the result of this method is a new `EvalEnvironment`,
/// with its own lifetime, and the original environment is left unmodified.
pub fn extend(&self, name: ArcIntern<String>, value: Value) -> Self {
EvalEnvironment {
inner: Arc::new(EvalEnvInternal::Value(name, value, self.inner.clone())),
}
}
/// Look up a variable in the environment, returning an error if it isn't there.
pub fn lookup(&self, n: ArcIntern<String>) -> Result<Value, LookupError> {
self.inner.lookup(n)
}
}
impl EvalEnvInternal {
/// Look up a variable in the environment, returning an error if it isn't there.
fn lookup(&self, n: ArcIntern<String>) -> Result<Value, LookupError> {
match self {
// if this is an empty dictionary, never mind, couldn't find it
EvalEnvInternal::Empty => Err(LookupError::CouldNotFind(n)),
// is this the key we have right here? if yes, return our value
EvalEnvInternal::Value(name, value, _) if *name == n => Ok(value.clone()),
// otherwise, recurse up our chain of environments
EvalEnvInternal::Value(_, _, rest) => rest.lookup(n),
}
}
@@ -70,6 +93,9 @@ mod tests {
assert!(tester.lookup(arced("baz")).is_err());
}
// added this test to make sure that our nesting property works propertly.
// it's not a big deal now, but it'll be really handy later when we add any
// kind of variable scoping.
#[test]
fn nested() {
let tester = EvalEnvironment::default();

View File

@@ -1,19 +1,39 @@
use crate::eval::value::Value;
/// Errors that can occur running primitive operations in the evaluators.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum PrimOpError {
#[error("Math error (underflow or overflow) computing {0} operator")]
MathFailure(&'static str),
/// This particular variant covers the case in which a primitive
/// operator takes two arguments that are supposed to be the same,
/// but they differ. (So, like, all the math operators.)
#[error("Type mismatch ({1} vs {2}) computing {0} operator")]
TypeMismatch(String, Value, Value),
/// This variant covers when an operator must take a particular
/// type, but the user has provided a different one.
#[error("Bad type for operator {0}: {1}")]
BadTypeFor(&'static str, Value),
/// Probably obvious from the name, but just to be very clear: this
/// happens when you pass three arguments to a two argument operator,
/// etc. Technically that's a type error of some sort, but we split
/// it out.
#[error("Illegal number of arguments for {0}: {1} arguments found")]
BadArgCount(String, usize),
#[error("Unknown primitive operation {0}")]
UnknownPrimOp(String),
}
// Implementing primitives in an interpreter like this is *super* tedious,
// and the only way to make it even somewhat manageable is to use macros.
// This particular macro works for binary operations, and assumes that
// you've already worked out that the `calculate` call provided two arguments.
//
// In those cases, it will rul the operations we know about, and error if
// it doesn't.
//
// This macro then needs to be instantiated for every type, which is super
// fun.
macro_rules! run_op {
($op: ident, $left: expr, $right: expr) => {
match $op {
@@ -23,15 +43,15 @@ macro_rules! run_op {
.map(Into::into),
"-" => $left
.checked_sub($right)
.ok_or(PrimOpError::MathFailure("+"))
.ok_or(PrimOpError::MathFailure("-"))
.map(Into::into),
"*" => $left
.checked_mul($right)
.ok_or(PrimOpError::MathFailure("+"))
.ok_or(PrimOpError::MathFailure("*"))
.map(Into::into),
"/" => $left
.checked_div($right)
.ok_or(PrimOpError::MathFailure("+"))
.ok_or(PrimOpError::MathFailure("/"))
.map(Into::into),
_ => Err(PrimOpError::UnknownPrimOp($op.to_string())),
}
@@ -41,6 +61,8 @@ macro_rules! run_op {
impl Value {
fn binary_op(operation: &str, left: &Value, right: &Value) -> Result<Value, PrimOpError> {
match left {
// for now we only have one type, but in the future this is
// going to be very irritating.
Value::I64(x) => match right {
Value::I64(y) => run_op!(operation, x, *y),
// _ => Err(PrimOpError::TypeMismatch(
@@ -52,6 +74,14 @@ impl Value {
}
}
/// Calculate the result of running the given primitive on the given arguments.
///
/// This can cause errors in a whole mess of ways, so be careful about your
/// inputs. For example, addition only works when the two values have the exact
/// same type, so expect an error if you try to do so. In addition, this
/// implementation catches and raises an error on overflow or underflow, so
/// its worth being careful to make sure that your inputs won't cause either
/// condition.
pub fn calculate(operation: &str, values: Vec<Value>) -> Result<Value, PrimOpError> {
if values.len() == 2 {
Value::binary_op(operation, &values[0], &values[1])

View File

@@ -1,5 +1,10 @@
use std::fmt::Display;
/// Values in the interpreter.
///
/// Yes, this is yet another definition of a structure called `Value`, which
/// are almost entirely identical. However, it's nice to have them separated
/// by type so that we don't mix them up.
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
I64(i64),