From 89c8eb0ef22bd74bc3038c7d208589acbfd4d54a Mon Sep 17 00:00:00 2001
From: Andrew Walbran <qwandor@google.com>
Date: Thu, 30 Mar 2023 12:42:21 +0100
Subject: [PATCH] Add utility to build exercise starting directory from
 Markdown.

---
 Cargo.lock            | 144 ++++++++++++++++++++++++++++++++++++++++
 Cargo.toml            |   1 +
 exerciser/Cargo.toml  |  14 ++++
 exerciser/src/main.rs | 151 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 310 insertions(+)
 create mode 100644 exerciser/Cargo.toml
 create mode 100644 exerciser/src/main.rs

diff --git a/Cargo.lock b/Cargo.lock
index 9aab17b2..39ce3cf0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,15 @@
 # It is not intended for manual editing.
 version = 3
 
+[[package]]
+name = "aho-corasick"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "allocator-example"
 version = "0.1.0"
@@ -9,6 +18,23 @@ dependencies = [
  "buddy_system_allocator",
 ]
 
+[[package]]
+name = "anyhow"
+version = "1.0.70"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi 0.1.19",
+ "libc",
+ "winapi",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.1.0"
@@ -167,6 +193,19 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "env_logger"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
+dependencies = [
+ "atty",
+ "humantime",
+ "log",
+ "regex",
+ "termcolor",
+]
+
 [[package]]
 name = "errno"
 version = "0.3.0"
@@ -188,6 +227,16 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "exerciser"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "log",
+ "pretty_env_logger",
+ "pulldown-cmark",
+]
+
 [[package]]
 name = "fastrand"
 version = "1.9.0"
@@ -350,6 +399,15 @@ version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
 
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "hermit-abi"
 version = "0.2.6"
@@ -413,6 +471,15 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
 
+[[package]]
+name = "humantime"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f"
+dependencies = [
+ "quick-error",
+]
+
 [[package]]
 name = "hyper"
 version = "0.14.25"
@@ -839,6 +906,16 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
 
+[[package]]
+name = "pretty_env_logger"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d"
+dependencies = [
+ "env_logger",
+ "log",
+]
+
 [[package]]
 name = "proc-macro-hack"
 version = "0.5.20+deprecated"
@@ -854,6 +931,23 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "pulldown-cmark"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "unicase",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
 [[package]]
 name = "quote"
 version = "1.0.26"
@@ -962,6 +1056,23 @@ dependencies = [
  "bitflags",
 ]
 
+[[package]]
+name = "regex"
+version = "1.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.6.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
+
 [[package]]
 name = "reqwest"
 version = "0.11.16"
@@ -1263,6 +1374,15 @@ dependencies = [
  "utf-8",
 ]
 
+[[package]]
+name = "termcolor"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.40"
@@ -1370,6 +1490,15 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
 
+[[package]]
+name = "unicase"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
+dependencies = [
+ "version_check",
+]
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.13"
@@ -1420,6 +1549,12 @@ version = "0.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
 
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
 [[package]]
 name = "want"
 version = "0.3.0"
@@ -1534,6 +1669,15 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
 
+[[package]]
+name = "winapi-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi",
+]
+
 [[package]]
 name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
diff --git a/Cargo.toml b/Cargo.toml
index 68284911..18691deb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
 [workspace]
 members = [
+  "exerciser",
   "src/exercises",
   "src/bare-metal/useful-crates/allocator-example",
   "src/bare-metal/useful-crates/zerocopy-example",
diff --git a/exerciser/Cargo.toml b/exerciser/Cargo.toml
new file mode 100644
index 00000000..135b2100
--- /dev/null
+++ b/exerciser/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "exerciser"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+authors = ["Andrew Walbran <qwandor@google.com>"]
+description = "A tool for extracting starter code for exercises from Markdown files."
+repository = "https://github.com/google/comprehensive-rust"
+
+[dependencies]
+anyhow = "1.0.68"
+log = "0.4.17"
+pretty_env_logger = "0.4.0"
+pulldown-cmark = { version = "0.9.2", default-features = false }
diff --git a/exerciser/src/main.rs b/exerciser/src/main.rs
new file mode 100644
index 00000000..e6812ded
--- /dev/null
+++ b/exerciser/src/main.rs
@@ -0,0 +1,151 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use anyhow::Context;
+use log::{info, trace};
+use pulldown_cmark::{CowStr, Event, Parser, Tag};
+use std::{
+    env::args,
+    fs::{create_dir, create_dir_all, read_to_string, File},
+    io::Write,
+    path::Path,
+    process::exit,
+};
+
+const INCLUDE_START: &str = "{{#include ";
+const INCLUDE_END: &str = "}}";
+
+fn main() -> anyhow::Result<()> {
+    pretty_env_logger::init();
+
+    let args = args().collect::<Vec<_>>();
+
+    if args.len() != 3 {
+        eprintln!("Usage:");
+        eprintln!(
+            "  {} <src/exercises/exercise.md> <output directory>",
+            args[0]
+        );
+        exit(1);
+    }
+
+    let input_filename = Path::new(&args[1]);
+    let output_directory = Path::new(&args[2]);
+
+    create_dir(output_directory).with_context(|| {
+        format!("Failed to create output directory {:?}", output_directory)
+    })?;
+
+    let input_directory = input_filename
+        .parent()
+        .with_context(|| "Input file has no parent directory.")?;
+    let input_contents = read_to_string(input_filename)
+        .with_context(|| format!("Failed to open {:?}", input_filename))?;
+    let parser = Parser::new(&input_contents);
+
+    let mut next_filename: Option<String> = None;
+    let mut current_file: Option<File> = None;
+    for event in parser {
+        trace!("{:?}", event);
+        match event {
+            Event::Code(x) => {
+                info!("{}:", x);
+                next_filename = Some(x.into_string());
+            }
+            Event::Start(Tag::CodeBlock(x)) => {
+                info!("Start {:?}", x);
+                if let Some(filename) = &next_filename {
+                    let full_filename = output_directory.join(filename);
+                    info!("Opening {:?}", full_filename);
+                    if let Some(directory) = full_filename.parent() {
+                        create_dir_all(directory)?;
+                    }
+                    current_file = Some(File::create(full_filename)?);
+                    next_filename = None;
+                }
+            }
+            Event::Text(text) => {
+                info!("Text: {:?}", text);
+                if let Some(output_file) = &mut current_file {
+                    write_output(text, input_directory, output_file)?;
+                }
+            }
+            Event::End(Tag::CodeBlock(x)) => {
+                info!("End   {:?}", x);
+                current_file = None;
+            }
+            _ => {}
+        }
+    }
+
+    Ok(())
+}
+
+fn write_output(
+    text: CowStr,
+    input_directory: &Path,
+    output_file: &mut File,
+) -> anyhow::Result<()> {
+    for line in text.lines() {
+        info!("Line: {:?}", line);
+        if let (Some(start), Some(end)) =
+            (line.find(INCLUDE_START), line.find(INCLUDE_END))
+        {
+            let include = line[start + INCLUDE_START.len()..end].trim();
+            info!("Include {:?}", include);
+            if let Some(colon) = include.find(":") {
+                write_include(
+                    &include[0..colon],
+                    Some(&include[colon + 1..]),
+                    input_directory,
+                    output_file,
+                )?;
+            } else {
+                write_include(include, None, input_directory, output_file)?;
+            }
+        } else {
+            output_file.write(line.as_bytes())?;
+            output_file.write(b"\n")?;
+        }
+    }
+
+    Ok(())
+}
+
+fn write_include(
+    include_filename: &str,
+    section: Option<&str>,
+    input_directory: &Path,
+    output_file: &mut File,
+) -> anyhow::Result<()> {
+    let full_include_filename = input_directory.join(include_filename);
+    let input_file = read_to_string(full_include_filename)?;
+    if let Some(section) = section {
+        let start_anchor = format!("ANCHOR: {}", section);
+        let end_anchor = format!("ANCHOR_END: {}", section);
+        for line in input_file
+            .lines()
+            .skip_while(|line| !line.contains(&start_anchor))
+            .skip(1)
+            .take_while(|line| !line.contains(&end_anchor))
+        {
+            output_file.write(line.as_bytes())?;
+            output_file.write(b"\n")?;
+        }
+    } else {
+        output_file.write(input_file.as_bytes())?;
+    }
+
+    Ok(())
+}