Back to blog

BASIC Interpreter Part 3: Copying values

Now time for part three of my Sinclair BASIC Interpreter. In the previous post I added the ability to assign data to a variable using the LET command. Now, it's time to use those variables in expressions. That means:

  • Assigning one variable to another's value (LET a=b)
  • Performing basic mathematical expressions
  • Assignment using those maths expressions (LET c=a*b)

Assigning variables

First things first, I need to update the enum I have for storing variables. Right now, I use an enum called Primitive, which has either an Int (i64) or a String option. To begin with, I'm going to try adding a third branch to the logic, called Assignment, which will also store a String - in this case it'll be a variable name. I'll also add a test to demonstrate this, which naturally fails right now (yay TDD).

#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Primitive {
    Int(i64),
    String(String),
    Assignment(String)
}

#[test]
fn it_assigns_one_variable_to_another() {
    let line = "10 LET a=b";
    let (_, result) = parse_line(line).unwrap();
    let expected: Line = (
        10,
        Command::Var((
            String::from("a"),
            Primitive::Assignment(String::from("b"))
        ))
    );
    assert_eq!(result, expected);
}

So as a first pass, I just want to assume that everything else is correct in the line, and whatever is on the other side is a variable name. So, with a small amount of validation (that the first character isn't a digit), I'm just using take_until1, separated by the equals sign, to collect everything as a String:

fn parse_assignment(i: &str) -> IResult<&str, (String, Primitive)> {
    let (i, _) = not(digit1)(i)?;
    let (i, id) = take_until1("=")(i)?;
    let (i, _) = tag("=")(i)?;
    Ok((i, (id.to_string(), Primitive::Assignment(i.to_string()))))
}

fn parse_var(i: &str) -> IResult<&str, (String, Primitive)> {
    alt((parse_int, parse_str, parse_assignment))(i)
}

This is extremely permissive in it's current form, so it needs to go at the very end of the alt combinator. But, it works - well, it passes the one test. But, when I run my entire test suite I find it's causes a regression. The parse should not accept strings with multi-character names, but this parser is permissive enough that it passes.

So, the next thing to do is to properly validate the variable name.

// Take everything until it hits a newline, if it does
fn consume_line(i: &str) -> IResult<&str, &str> {
    take_while(|c| c != '\n')(i)
}

fn parse_str_variable_name(i: &str) -> IResult<&str, String> {
    let (i, id) = terminated(
        verify(anychar, |c| c.is_alphabetic()),
        tag("$")
    )(i)?;
    let id = format!("{}$", id);
    Ok((i, id))
}

fn parse_int_variable_name(i: &str) -> IResult<&str, String> {
    map(
        preceded(not(digit1), alphanumeric1),
        String::from
    )(i)
}

fn parse_assignment(i: &str) -> IResult<&str, (String, Primitive)> {
    let (i, id) = alt((
        parse_str_variable_name,
        parse_int_variable_name
    ))(i)?;
    let (i, _) = tag("=")(i)?;
    let (i, assigned_variable) = consume_line(i)?;
    Ok((i, (id.to_string(), Primitive::Assignment(assigned_variable.to_string()))))
}

And that's worked (for what I want, anyway):
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

But if I execute the simple program from the last post, and dump out the stored variables, I see:

{"apple": Int(10), "b$": String("Hello"), "cat": Assignment("apple")}

Which isn't quite right, because we should be assigning by value, not by reference (which is effectively what's happening there). So, if I amend the execution loop to add a specific case for Assignment:

match item.1 {
    Command::Print(line) => println!("{}", line),
    Command::GoTo(line) => iter.jump_to_line(line),
    Command::Var((id, Primitive::Assignment(variable))) => {
        self.vars.insert(id, self.vars.get(&variable).unwrap().clone());
    }
    Command::Var((id, var)) => {
        self.vars.insert(id, var);
    }
    _ => panic!("Unrecognised command"),
}

So now instead when I encounter an Assignment, I lookup the actual value of the variable I'm assigning from, and inserting that as it's own value. Now, the output looks like:

{"b$": String("Hello"), "apple": Int(10), "cat": Int(10)}

Printing

Okay, now I know how to read a variable, and assign a variable, I should now be able to print one out too. I'm going to add yet-another-enum, to represent a print output, which can either be a Value, or a Variable:

#[derive(Debug, PartialEq, Eq, Clone)]
pub enum PrintOutput {
    Value(String),
    Variable(String)
}

#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Command {
    Print(PrintOutput),
    GoTo(usize),
    Var((String, Primitive)),
    None,
}

And then I have updated my parse for Print to read either a string, or a variable name:

fn parse_print_command(i: &str) -> IResult<&str, PrintOutput> {
    alt((
        map(alt((
            parse_str_variable_name,
            parse_int_variable_name
        )), PrintOutput::Variable),
        map(read_string, PrintOutput::Value)
    ))(i)
}

let (i, cmd) = match command {
        "PRINT" => map(parse_print_command, Command::Print)(i)?,
...
}

And then update the execution loop to use either of these new branches:

match item.1 {
    Command::Print(PrintOutput::Value(line)) => println!("{}", line),
    Command::Print(PrintOutput::Variable(variable)) => println!("{:?}", self.vars.get(&variable).unwrap()),
...
}

Now to test it with a new BASIC program:

10 LET a$="Hello"
20 LET b$="World"
30 PRINT a$
40 PRINT b$
╰─$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/basic-interpreter`
String("Hello")
String("World")

Quick addition: comments

And quickly, just because it'll be relatively simple, I'm going to also parse comments, which in BASIC are marked as REM:

fn match_command(i: &str) -> IResult<&str, &str> {
    alt((tag("PRINT"), tag("GO TO"), tag("LET"), tag("REM")))(i)
}

fn parse_command(i: &str) -> IResult<&str, Command> {
...
    let (i, cmd) = match command {
       ...
       "REM" => {
            let (i, _) = consume_line("\n")(i)?;
            (i, Command::Comment)
        },
    };
...
}

That's all I'll add to this part for now. But things are starting to come together! It won't be long before this can run the very-basic example program from chapter 2 of the reference manual:

10 REM temperature conversion
20 PRINT "deg F", "deg C"
30 PRINT
40 INPUT "Enter deg F", F
50 PRINT F,(F-32)*5/9
60 GO TO 40

As always, the source code is on Github (although it's in dire need of some cleanup).

One response to “BASIC Interpreter Part 3: Copying values”

  1. Lewis Dale says:

    I’m very glad I wrote a reasonably-good test suite for this now. I’ve started refactoring the parsers into their own module because they’re unwieldy now, but the tests have made it much easier to do