Skip to content

Commit

Permalink
Improve exercise generator (#1797)
Browse files Browse the repository at this point in the history
- Add a clap-based CLI
  All user-input can now be entered upfront with cli flags
  or later by being prompted for it interactively.

- Limit output of rustfmt passed onto user
  (There are usually many duplicates when generating test cases.)

- Handle absence of canonical data
  (e.g. for custom exercises) not from problem-specifications)

piggyback:

- Make exercise difficulty in track config type-safe
senekor authored Nov 23, 2023

Verified

This commit was signed with the committer’s verified signature.
zmalatrax malatrax
1 parent b2f72ef commit ff92b1c
Showing 15 changed files with 487 additions and 267 deletions.
4 changes: 2 additions & 2 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ Please familiarize yourself with the [Exercism documentation about practice exer

[Exercism documentation about practice exercises]: https://exercism.org/docs/building/tracks/practice-exercises

Run `just add-practice-exercise` and you'll be prompted for the minimal
Run `just add-exercise` and you'll be prompted for the minimal
information required to generate the exercise stub for you.
After that, jump in the generated exercise and fill in any todos you find.
This includes most notably:
@@ -89,7 +89,7 @@ This includes their test suite and user-facing documentation.
Before proposing changes here,
check if they should be made `problem-specifications` instead.

Run `just update-practice-exercise` to update an exercise.
Run `just update-exercise` to update an exercise.
This outsources most work to `configlet sync --update`
and runs the test generator again.

13 changes: 4 additions & 9 deletions justfile
Original file line number Diff line number Diff line change
@@ -17,13 +17,8 @@ test:
cd rust-tooling && cargo test
# TODO format exercises

add-practice-exercise:
cd rust-tooling && cargo run --quiet --bin generate_exercise
add-exercise *args="":
cd rust-tooling/generate; cargo run --quiet --release -- add {{ args }}

update-practice-exercise:
cd rust-tooling && cargo run --quiet --bin generate_exercise update

# TODO remove. resets result of add-practice-exercise.
clean:
git restore config.json exercises/practice
git clean -- exercises/practice
update-exercise *args="":
cd rust-tooling/generate; cargo run --quiet --release -- update {{ args }}
127 changes: 126 additions & 1 deletion rust-tooling/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust-tooling/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[workspace]
members = ["generator", "ci-tests", "utils", "models"]
members = ["generate", "ci-tests", "utils", "models"]
resolver = "2"
18 changes: 0 additions & 18 deletions rust-tooling/ci-tests/tests/difficulties.rs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
[package]
name = "generator"
name = "generate"
version = "0.1.0"
edition = "2021"
description = "Generates exercise boilerplate, especially test cases"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = { version = "4.4.8", features = ["derive"] }
convert_case = "0.6.0"
glob = "0.3.1"
inquire = "0.6.2"
187 changes: 187 additions & 0 deletions rust-tooling/generate/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
use clap::{Parser, Subcommand};
use convert_case::{Case, Casing};
use glob::glob;
use inquire::{validator::Validation, Select, Text};
use models::track_config;

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct CliArgs {
#[command(subcommand)]
pub command: Command,
}

#[derive(Subcommand)]
pub enum Command {
Add(AddArgs),
Update(UpdateArgs),
}

impl std::fmt::Display for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Command::Add(_) => write!(f, "Add"),
Command::Update(_) => write!(f, "Update"),
}
}
}

#[derive(Parser)]
pub struct AddArgs {
#[arg(short, long)]
slug: Option<String>,

#[arg(short, long)]
name: Option<String>,

#[arg(short, long)]
difficulty: Option<Difficulty>,
}

pub struct FullAddArgs {
pub slug: String,
pub name: String,
pub difficulty: track_config::Difficulty,
}

impl AddArgs {
pub fn unwrap_args_or_prompt(self) -> FullAddArgs {
let slug = self.slug.unwrap_or_else(prompt_for_add_slug);
let name = self.name.unwrap_or_else(|| prompt_for_exercise_name(&slug));
let difficulty = self.difficulty.unwrap_or_else(prompt_for_difficulty).into();
FullAddArgs {
slug,
name,
difficulty,
}
}
}

#[derive(Parser)]
pub struct UpdateArgs {
/// slug of the exercise to update
#[arg(short, long)]
slug: Option<String>,
}

impl UpdateArgs {
pub fn unwrap_slug_or_prompt(self) -> String {
self.slug.unwrap_or_else(prompt_for_update_slug)
}
}

pub fn prompt_for_update_slug() -> String {
let implemented_exercises = glob("exercises/practice/*")
.unwrap()
.filter_map(Result::ok)
.map(|path| path.file_name().unwrap().to_str().unwrap().to_string())
.collect::<Vec<_>>();

Select::new(
"Which exercise would you like to update?",
implemented_exercises,
)
.prompt()
.unwrap()
}

pub fn prompt_for_add_slug() -> String {
let implemented_exercises = glob("exercises/concept/*")
.unwrap()
.chain(glob("exercises/practice/*").unwrap())
.filter_map(Result::ok)
.map(|path| path.file_name().unwrap().to_str().unwrap().to_string())
.collect::<Vec<_>>();

let todo_with_spec = glob("problem-specifications/exercises/*")
.unwrap()
.filter_map(Result::ok)
.map(|path| path.file_name().unwrap().to_str().unwrap().to_string())
.filter(|e| !implemented_exercises.contains(e))
.collect::<Vec<_>>();

println!("(suggestions are from problem-specifications)");
Text::new("What's the slug of your exercise?")
.with_autocomplete(move |input: &_| {
let mut slugs = todo_with_spec.clone();
slugs.retain(|e| e.starts_with(input));
Ok(slugs)
})
.with_validator(|input: &str| {
if input.is_empty() {
Ok(Validation::Invalid("The slug must not be empty.".into()))
} else if !input.is_case(Case::Kebab) {
Ok(Validation::Invalid(
"The slug must be in kebab-case.".into(),
))
} else {
Ok(Validation::Valid)
}
})
.with_validator(move |input: &str| {
if !implemented_exercises.contains(&input.to_string()) {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(
"An exercise with this slug already exists.".into(),
))
}
})
.prompt()
.unwrap()
}

pub fn prompt_for_exercise_name(slug: &str) -> String {
Text::new("What's the name of your exercise?")
.with_initial_value(&slug.to_case(Case::Title))
.prompt()
.unwrap()
}

/// Mostly a clone of the `Difficulty` enum from `models::track_config`.
/// The purpose of this is that we can implement cli-specific traits in this crate.
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
#[repr(u8)]
pub enum Difficulty {
Easy = 1,
Medium = 4,
// I'm not sure why there are two medium difficulties
Medium2 = 7,
Hard = 10,
}

impl From<Difficulty> for track_config::Difficulty {
fn from(value: Difficulty) -> Self {
match value {
Difficulty::Easy => track_config::Difficulty::Easy,
Difficulty::Medium => track_config::Difficulty::Medium,
Difficulty::Medium2 => track_config::Difficulty::Medium2,
Difficulty::Hard => track_config::Difficulty::Hard,
}
}
}

impl std::fmt::Display for Difficulty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Difficulty::Easy => write!(f, "Easy (1)"),
Difficulty::Medium => write!(f, "Medium (4)"),
Difficulty::Medium2 => write!(f, "Medium (7)"),
Difficulty::Hard => write!(f, "Hard (10)"),
}
}
}

pub fn prompt_for_difficulty() -> Difficulty {
Select::new(
"What's the difficulty of your exercise?",
vec![
Difficulty::Easy,
Difficulty::Medium,
Difficulty::Medium2,
Difficulty::Hard,
],
)
.prompt()
.unwrap()
}
Original file line number Diff line number Diff line change
@@ -76,7 +76,7 @@ fn generate_example_rs(fn_name: &str) -> String {
)
}

static TEST_TEMPLATE: &str = include_str!("../default_test_template.tera");
static TEST_TEMPLATE: &str = include_str!("../templates/default_test_template.tera");

fn extend_single_cases(single_cases: &mut Vec<SingleTestCase>, cases: Vec<TestCase>) {
for case in cases {
@@ -96,7 +96,9 @@ fn to_hex(value: &tera::Value, _args: &HashMap<String, tera::Value>) -> tera::Re

fn generate_tests(slug: &str, fn_names: Vec<String>) -> String {
let cases = {
let mut cases = get_canonical_data(slug).cases;
let mut cases = get_canonical_data(slug)
.map(|data| data.cases)
.unwrap_or_default();
cases.extend_from_slice(&get_additional_test_cases(slug));
cases
};
@@ -122,8 +124,10 @@ fn generate_tests(slug: &str, fn_names: Vec<String>) -> String {
let rendered = rendered.trim_start();

let mut child = Command::new("rustfmt")
.args(["--color=always"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn process");

@@ -138,8 +142,19 @@ fn generate_tests(slug: &str, fn_names: Vec<String>) -> String {
if rustfmt_out.status.success() {
String::from_utf8(rustfmt_out.stdout).unwrap()
} else {
// if rustfmt fails, still return the unformatted
// content to be written to the file
let rustfmt_error = String::from_utf8(rustfmt_out.stderr).unwrap();
let mut last_16_error_lines = rustfmt_error.lines().rev().take(16).collect::<Vec<_>>();
last_16_error_lines.reverse();
let last_16_error_lines = last_16_error_lines.join("\n");

println!(
"{last_16_error_lines}\
^ last 16 lines of errors from rustfmt
Check the test template (.meta/test_template.tera)
It probably generates invalid Rust code."
);

// still return the unformatted content to be written to the file
rendered.into()
}
}
129 changes: 129 additions & 0 deletions rust-tooling/generate/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use std::path::PathBuf;

use clap::Parser;
use cli::{AddArgs, FullAddArgs, UpdateArgs};
use models::track_config::{self, TRACK_CONFIG};

mod cli;
mod exercise_generation;

fn main() {
utils::fs::cd_into_repo_root();

let cli_args = cli::CliArgs::parse();

match cli_args.command {
cli::Command::Add(args) => add_exercise(args),
cli::Command::Update(args) => update_exercise(args),
}
}

fn add_exercise(args: AddArgs) {
let FullAddArgs {
slug,
name,
difficulty,
} = args.unwrap_args_or_prompt();

let config = track_config::PracticeExercise::new(slug.clone(), name, difficulty);

let mut track_config = TRACK_CONFIG.clone();
track_config.exercises.practice.push(config);
let mut new_config = serde_json::to_string_pretty(&track_config)
.unwrap()
.to_string();
new_config += "\n";
std::fs::write("config.json", new_config).unwrap();

println!(
"\
Added your exercise to config.json.
You can add practices, prerequisites and topics if you like."
);

make_configlet_generate_what_it_can(&slug);

let is_update = false;
generate_exercise_files(&slug, is_update);
}

fn update_exercise(args: UpdateArgs) {
let slug = args.unwrap_slug_or_prompt();

make_configlet_generate_what_it_can(&slug);

let is_update = true;
generate_exercise_files(&slug, is_update);
}

fn make_configlet_generate_what_it_can(slug: &str) {
let status = std::process::Command::new("just")
.args([
"configlet",
"sync",
"--update",
"--yes",
"--docs",
"--metadata",
"--tests",
"include",
"--exercise",
slug,
])
.status()
.unwrap();
if !status.success() {
panic!("configlet sync failed");
}
}

fn generate_exercise_files(slug: &str, is_update: bool) {
let fn_names = if is_update {
read_fn_names_from_lib_rs(slug)
} else {
vec!["TODO".to_string()]
};

let exercise = exercise_generation::new(slug, fn_names);

let exercise_path = PathBuf::from("exercises/practice").join(slug);

if !is_update {
std::fs::write(exercise_path.join(".gitignore"), exercise.gitignore).unwrap();
std::fs::write(exercise_path.join("Cargo.toml"), exercise.manifest).unwrap();
std::fs::create_dir(exercise_path.join("src")).ok();
std::fs::write(exercise_path.join("src/lib.rs"), exercise.lib_rs).unwrap();
std::fs::write(exercise_path.join(".meta/example.rs"), exercise.example).unwrap();
}

let template_path = exercise_path.join(".meta/test_template.tera");
if std::fs::metadata(&template_path).is_err() {
std::fs::write(template_path, exercise.test_template).unwrap();
}

std::fs::create_dir(exercise_path.join("tests")).ok();
std::fs::write(
exercise_path.join(format!("tests/{slug}.rs")),
exercise.tests,
)
.unwrap();
}

fn read_fn_names_from_lib_rs(slug: &str) -> Vec<String> {
let lib_rs =
std::fs::read_to_string(format!("exercises/practice/{}/src/lib.rs", slug)).unwrap();

lib_rs
.split("fn ")
.skip(1)
.map(|f| {
let tmp = f.split_once('(').unwrap().0;
// strip generics
if let Some((res, _)) = tmp.split_once('<') {
res.to_string()
} else {
tmp.to_string()
}
})
.collect()
}
225 changes: 0 additions & 225 deletions rust-tooling/generator/src/bin/generate_exercise.rs

This file was deleted.

1 change: 0 additions & 1 deletion rust-tooling/generator/src/lib.rs

This file was deleted.

1 change: 1 addition & 0 deletions rust-tooling/models/Cargo.toml
Original file line number Diff line number Diff line change
@@ -11,5 +11,6 @@ ignore = "0.4.20"
once_cell = "1.18.0"
serde = { version = "1.0.188", features = ["derive"] }
serde_json = { version = "1.0.105", features = ["preserve_order"] }
serde_repr = "0.1.17"
utils = { version = "0.1.0", path = "../utils" }
uuid = { version = "1.6.1", features = ["v4"] }
4 changes: 2 additions & 2 deletions rust-tooling/models/src/problem_spec.rs
Original file line number Diff line number Diff line change
@@ -38,11 +38,11 @@ pub struct SingleTestCase {
pub expected: serde_json::Value,
}

pub fn get_canonical_data(slug: &str) -> CanonicalData {
pub fn get_canonical_data(slug: &str) -> Option<CanonicalData> {
let path = std::path::PathBuf::from("problem-specifications/exercises")
.join(slug)
.join("canonical-data.json");
let contents = std::fs::read_to_string(&path).unwrap();
let contents = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(contents.as_str()).unwrap_or_else(|e| {
panic!(
"should deserialize canonical data for {}: {e}",
17 changes: 14 additions & 3 deletions rust-tooling/models/src/track_config.rs
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ use std::collections::HashMap;

use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};

pub static TRACK_CONFIG: Lazy<TrackConfig> = Lazy::new(|| {
let config = include_str!("../../../config.json");
@@ -75,13 +76,23 @@ pub enum ConceptExerciseStatus {
Wip,
}

#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum Difficulty {
Easy = 1,
Medium = 4,
// I'm not sure why there are two medium difficulties
Medium2 = 7,
Hard = 10,
}

#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConceptExercise {
pub slug: String,
pub uuid: String,
pub name: String,
pub difficulty: u8,
pub difficulty: Difficulty,
pub concepts: Vec<String>,
pub prerequisites: Vec<String>,
pub status: ConceptExerciseStatus,
@@ -101,14 +112,14 @@ pub struct PracticeExercise {
pub uuid: String,
pub practices: Vec<String>,
pub prerequisites: Vec<String>,
pub difficulty: u8,
pub difficulty: Difficulty,
pub topics: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<PracticeExerciseStatus>,
}

impl PracticeExercise {
pub fn new(slug: String, name: String, difficulty: u8) -> Self {
pub fn new(slug: String, name: String, difficulty: Difficulty) -> Self {
Self {
slug,
name,

0 comments on commit ff92b1c

Please sign in to comment.