Defining the parser: debug impls and testing

As the final part of the parser, we need to write some tests. To do so, we will create a database, set the input source text, run the parser, and check the result. Before we can do that, though, we have to address one question: how do we inspect the value of an interned type like Expression?

The DebugWithDb trait

Because an interned type like Expression just stores an integer, the traditional Debug trait is not very useful. To properly print a Expression, you need to access the salsa database to find out what its value is. To solve this, salsa provides a DebugWithDb trait that acts like the regular Debug, but takes a database as argument. For types that implement this trait, you can invoke the debug method. This returns a temporary that implements the ordinary Debug trait, allowing you to write something like


#![allow(unused)]
fn main() {
eprintln!("Expression = {:?}", expr.debug(db));
}

and get back the output you expect.

Implementing the DebugWithDb trait

For now, unfortunately, you have to implement the DebugWithDb trait manually, as we do not provide a derive. This is tedious but not difficult. Here is an example of implementing the trait for Expression:


#![allow(unused)]
fn main() {
impl DebugWithDb<dyn crate::Db + '_> for Expression {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>, db: &dyn crate::Db) -> std::fmt::Result {
        match self.data(db) {
            ExpressionData::Op(a, b, c) => f
                .debug_tuple("ExpressionData::Op")
                .field(&a.debug(db)) // use `a.debug(db)` for interned things
                .field(&b.debug(db))
                .field(&c.debug(db))
                .finish(),
            ExpressionData::Number(a) => {
                f.debug_tuple("Number")
                    .field(a) // use just `a` otherwise
                    .finish()
            }
            ExpressionData::Variable(a) => f.debug_tuple("Variable").field(&a.debug(db)).finish(),
            ExpressionData::Call(a, b) => f
                .debug_tuple("Call")
                .field(&a.debug(db))
                .field(&b.debug(db))
                .finish(),
        }
    }
}
}

Some things to note:

  • The data method gives access to the full enum from the database.
  • The Formatter methods (e.g., debug_tuple) can be used to provide consistent output.
  • When printing the value of a field, use .field(&a.debug(db)) for fields that are themselves interned or entities, and use .field(&a) for fields that just implement the ordinary Debug trait.

Forwarding to the ordinary Debug trait

For consistency, it is sometimes useful to have a DebugWithDb implementation even for types, like Op, that are just ordinary enums. You can do that like so:


#![allow(unused)]
fn main() {
impl DebugWithDb<dyn crate::Db + '_> for Op {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>, _db: &dyn crate::Db) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl DebugWithDb<dyn crate::Db + '_> for Diagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>, _db: &dyn crate::Db) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

#[salsa::tracked]
pub struct Function {
    #[id]
    name: FunctionId,
    args: Vec<VariableId>,
    body: Expression,
}

#[salsa::accumulator]
pub struct Diagnostics(Diagnostic);

#[derive(Clone, Debug)]
pub struct Diagnostic {
    pub position: usize,
    pub message: String,
}
}

Writing the unit test

Now that we have our DebugWithDb impls in place, we can write a simple unit test harness. The parse_string function below creates a database, sets the source text, and then invokes the parser:


#![allow(unused)]
fn main() {
/// Create a new database with the given source text and parse the result.
/// Returns the statements and the diagnostics generated.
#[cfg(test)]
fn parse_string(source_text: &str) -> String {
    use salsa::debug::DebugWithDb;

    // Create the database
    let mut db = crate::db::Database::default();

    // Create the source program
    let source_program = SourceProgram::new(&mut db, source_text.to_string());

    // Invoke the parser
    let statements = parse_statements(&db, source_program);

    // Read out any diagnostics
    let accumulated = parse_statements::accumulated::<Diagnostics>(&db, source_program);

    // Format the result as a string and return it
    format!("{:#?}", (statements, accumulated).debug(&db))
}
}

Combined with the expect-test crate, we can then write unit tests like this one:


#![allow(unused)]
fn main() {
#[test]
fn parse_print() {
    let actual = parse_string("print 1 + 2");
    let expected = expect_test::expect![[r#"
        (
            [
                ExpressionData::Op(
                    Number(
                        OrderedFloat(
                            1.0,
                        ),
                    ),
                    Add,
                    Number(
                        OrderedFloat(
                            2.0,
                        ),
                    ),
                ),
            ],
            [],
        )"#]];
    expected.assert_eq(&actual);
}
}