Task Implementation

Now we’ll implement tasks for compiling a grammar and parsing. Add task as a public module to pie/examples/parser_dev/main.rs:

Create the pie/examples/parser_dev/task.rs file and add to it:

use std::io::Read;
use std::path::{Path, PathBuf};

use pie::{Context, Task};

use crate::parse::CompiledGrammar;

/// Tasks for compiling a grammar and parsing files with it.
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub enum Tasks {
  CompileGrammar { grammar_file_path: PathBuf },
  Parse { compiled_grammar_task: Box<Tasks>, program_file_path: PathBuf, rule_name: String }
}

impl Tasks {
  /// Create a [`Self::CompileGrammar`] task that compiles the grammar in file `grammar_file_path`.
  pub fn compile_grammar(grammar_file_path: impl Into<PathBuf>) -> Self {
    Self::CompileGrammar { grammar_file_path: grammar_file_path.into() }
  }

  /// Create a [`Self::Parse`] task that uses the compiled grammar returned by requiring `compiled_grammar_task` to
  /// parse the program in file `program_file_path`, starting parsing with `rule_name`.
  pub fn parse(
    compiled_grammar_task: &Tasks,
    program_file_path: impl Into<PathBuf>,
    rule_name: impl Into<String>
  ) -> Self {
    Self::Parse {
      compiled_grammar_task: Box::new(compiled_grammar_task.clone()),
      program_file_path: program_file_path.into(),
      rule_name: rule_name.into()
    }
  }
}

/// Outputs for [`Tasks`].
#[derive(Clone, Eq, PartialEq, Debug)]
pub enum Outputs {
  CompiledGrammar(CompiledGrammar),
  Parsed(Option<String>)
}

We create a Tasks enum with:

  • A CompileGrammar variant for compiling a grammar from a file.
  • A Parse variant that uses the compiled grammar returned from another task to parse a program in a file, starting parsing with a specific rule given by name.

compile_grammar and parse are convenience functions for creating these variants. We derive Clone, Eq, Hash and Debug as these are required for tasks.

We create an Outputs enum for storing the results of these tasks, and derive the required traits.

Since both tasks will require a file, and we’re using Strings as errors, we will implement a convenience function for this. Add to pie/examples/parser_dev/task.rs:

fn require_file_to_string<C: Context<Tasks>>(context: &mut C, path: impl AsRef<Path>) -> Result<String, String> {
  let path = path.as_ref();
  let mut file = context.require_file(path)
    .map_err(|e| format!("Opening file '{}' for reading failed: {}", path.display(), e))?
    .ok_or_else(|| format!("File '{}' does not exist", path.display()))?;
  let mut text = String::new();
  file.read_to_string(&mut text)
    .map_err(|e| format!("Reading file '{}' failed: {}", path.display(), e))?;
  Ok(text)
}

require_file_to_string is like context.require_file, but converts all errors to String.

Now we implement Task for Tasks. Add to pie/examples/parser_dev/task.rs:

impl Task for Tasks {
  type Output = Result<Outputs, String>;

  fn execute<C: Context<Self>>(&self, context: &mut C) -> Self::Output {
    match self {
      Tasks::CompileGrammar { grammar_file_path } => {
        let grammar_text = require_file_to_string(context, grammar_file_path)?;
        let compiled_grammar = CompiledGrammar::new(&grammar_text, Some(grammar_file_path.to_string_lossy().as_ref()))?;
        Ok(Outputs::CompiledGrammar(compiled_grammar))
      }
      Tasks::Parse { compiled_grammar_task, program_file_path, rule_name } => {
        let Ok(Outputs::CompiledGrammar(compiled_grammar)) = context.require_task(compiled_grammar_task.as_ref()) else {
          // Return `None` if compiling grammar failed. Don't propagate the error, otherwise the error would be
          // duplicated for all `Parse` tasks.
          return Ok(Outputs::Parsed(None));
        };
        let program_text = require_file_to_string(context, program_file_path)?;
        let output = compiled_grammar.parse(&program_text, rule_name, Some(program_file_path.to_string_lossy().as_ref()))?;
        Ok(Outputs::Parsed(Some(output)))
      }
    }
  }
}

The output is Result<Outputs, String>: either an Outputs if the task succeeds, or a String if not. In execute we match our variant and either compile a grammar or parse, which are mostly straightforward. In the Parse variant, we require the compile grammar task, but don’t propagate its errors and instead return Ok(Outputs::Parsed(None)). We do this to prevent duplicate errors. If we propagated the error, the grammar compilation error would be duplicated into every parse task.

Confirm the code compiles with cargo build --example parser_dev. We won’t test this code as we’ll use these tasks in the main function next.