add treehouse fix command that generates ulids for you

This commit is contained in:
りき萌 2023-08-19 22:39:02 +02:00
parent 7e84005a6b
commit f16a84f3de
12 changed files with 449 additions and 64 deletions

View file

@ -18,8 +18,11 @@ handlebars = "4.3.7"
pulldown-cmark = { version = "0.9.3", default-features = false }
serde = { version = "1.0.183", features = ["derive"] }
tokio = { version = "1.32.0", features = ["full"] }
toml_edit = "0.19.14"
tower-http = { version = "0.4.3", features = ["fs"] }
tower-livereload = "0.8.0"
walkdir = "2.3.3"
ulid = "1.0.0"
rand = "0.8.5"

View file

@ -0,0 +1,42 @@
use anyhow::Context;
use codespan_reporting::{
diagnostic::Diagnostic,
files::SimpleFiles,
term::termcolor::{ColorChoice, StandardStream},
};
pub type Files = SimpleFiles<String, String>;
pub type FileId = <Files as codespan_reporting::files::Files<'static>>::FileId;
pub struct Diagnosis {
pub files: Files,
pub diagnostics: Vec<Diagnostic<FileId>>,
}
impl Diagnosis {
pub fn new() -> Self {
Self {
files: Files::new(),
diagnostics: vec![],
}
}
/// Get the source code of a file, assuming it was previously registered.
pub fn get_source(&self, file_id: FileId) -> &str {
self.files
.get(file_id)
.expect("file should have been registered previously")
.source()
}
pub fn report(&self) -> anyhow::Result<()> {
let writer = StandardStream::stderr(ColorChoice::Auto);
let config = codespan_reporting::term::Config::default();
for diagnostic in &self.diagnostics {
codespan_reporting::term::emit(&mut writer.lock(), &config, &self.files, diagnostic)
.context("could not emit diagnostic")?;
}
Ok(())
}
}

View file

@ -0,0 +1,150 @@
use std::ops::Range;
use anyhow::Context;
use treehouse_format::ast::Branch;
use super::{
diagnostics::{Diagnosis, FileId},
parse::{self, parse_toml_with_diagnostics, parse_tree_with_diagnostics},
FixArgs,
};
struct Fix {
range: Range<usize>,
replacement: String,
}
#[derive(Default)]
struct State {
fixes: Vec<Fix>,
}
fn dfs_fix_branch(diagnosis: &mut Diagnosis, file_id: FileId, state: &mut State, branch: &Branch) {
let mut rng = rand::thread_rng();
let ulid = ulid::Generator::new()
.generate_with_source(&mut rng)
.expect("failed to generate ulid for block"); // (wtf moment. you know how big the 80-bit combination space is?)
let indent = " ".repeat(branch.indent_level);
if let Some(attributes) = branch.attributes.clone() {
// Scenario: Attributes need to be parsed as TOML and the id attribute has to be added into
// the top-level table. Then we also need to pretty-print everything to match the right
// indentation level.
if let Ok(mut toml) =
parse_toml_with_diagnostics(diagnosis, file_id, attributes.data.clone())
{
if !toml.contains_key("id") {
toml["id"] = toml_edit::value(ulid.to_string());
toml.key_decor_mut("id")
.unwrap()
.set_prefix(" ".repeat(branch.indent_level + 2));
}
let mut toml_string = toml.to_string();
// This is incredibly janky and barely works.
let leading_spaces: usize = toml_string.chars().take_while(|&c| c == ' ').count();
match leading_spaces {
0 => toml_string.insert(0, ' '),
1 => (),
_ => toml_string.replace_range(0..leading_spaces - 1, ""),
}
let toml_string = fix_indent_in_generated_toml(&toml_string, branch.indent_level);
state.fixes.push(Fix {
range: attributes.data.clone(),
replacement: toml_string,
})
}
} else {
// Scenario: No attributes at all.
// In this case we can do a fast path where we generate the `% id = "whatever"` string
// directly, not going through toml_edit.
state.fixes.push(Fix {
range: branch.kind_span.start..branch.kind_span.start,
replacement: format!("% id = \"{ulid}\"\n{indent}"),
});
}
// Then we fix child branches.
for child in &branch.children {
dfs_fix_branch(diagnosis, file_id, state, child);
}
}
fn fix_indent_in_generated_toml(toml: &str, min_indent_level: usize) -> String {
let toml = toml.trim_end();
let mut result = String::with_capacity(toml.len());
for (i, line) in toml.lines().enumerate() {
if line.is_empty() {
result.push('\n');
} else {
let desired_line_indent_level = if i == 0 { 1 } else { min_indent_level + 2 };
let leading_spaces: usize = line.chars().take_while(|&c| c == ' ').count();
let needed_indentation = desired_line_indent_level.saturating_sub(leading_spaces);
for _ in 0..needed_indentation {
result.push(' ');
}
result.push_str(line);
result.push('\n');
}
}
for _ in 0..min_indent_level {
result.push(' ');
}
result
}
pub fn fix_file(
diagnosis: &mut Diagnosis,
file_id: FileId,
) -> Result<String, parse::ErrorsEmitted> {
parse_tree_with_diagnostics(diagnosis, file_id).map(|roots| {
let mut source = diagnosis.get_source(file_id).to_owned();
let mut state = State::default();
for branch in &roots.branches {
dfs_fix_branch(diagnosis, file_id, &mut state, branch);
}
// Doing a depth-first search of the branches yields fixes from the beginning of the file
// to its end. The most efficient way to apply all the fixes then is to reverse their order,
// which lets us modify the source string in place because the fix ranges always stay
// correct.
for fix in state.fixes.iter().rev() {
source.replace_range(fix.range.clone(), &fix.replacement);
}
source
})
}
pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> {
let utf8_filename = fix_args.file.to_string_lossy().into_owned();
let file = std::fs::read_to_string(&fix_args.file).context("cannot read file to fix")?;
let mut diagnosis = Diagnosis::new();
let file_id = diagnosis.files.add(utf8_filename, file);
if let Ok(fixed) = fix_file(&mut diagnosis, file_id) {
if fix_args.apply {
// Try to write the backup first. If writing that fails, bail out without overwriting
// the source file.
if let Some(backup_path) = fix_args.backup {
std::fs::write(backup_path, diagnosis.get_source(file_id))
.context("cannot write backup; original file will not be overwritten")?;
}
std::fs::write(&fix_args.file, fixed).context("cannot overwrite original file")?;
} else {
println!("{fixed}");
}
} else {
diagnosis.report()?;
}
Ok(())
}

View file

@ -1,5 +1,10 @@
pub mod diagnostics;
pub mod fix;
mod parse;
pub mod regenerate;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
@ -12,6 +17,9 @@ pub struct ProgramArgs {
pub enum Command {
/// Regenerate the website.
Regenerate(#[clap(flatten)] RegenerateArgs),
/// Populate missing metadata in blocks.
Fix(#[clap(flatten)] FixArgs),
}
#[derive(Args)]
@ -20,3 +28,19 @@ pub struct RegenerateArgs {
#[clap(short, long)]
pub serve: bool,
}
#[derive(Args)]
pub struct FixArgs {
/// Which file to fix. The fixed file will be printed into stdout so that you have a chance to
/// see the changes.
pub file: PathBuf,
/// If you're happy with the suggested changes, specifying this will apply them to the file
/// (overwrite it in place.)
#[clap(long)]
pub apply: bool,
/// Write the previous version back to the specified path.
#[clap(long)]
pub backup: Option<PathBuf>,
}

View file

@ -0,0 +1,57 @@
use std::{ops::Range, str::FromStr};
use codespan_reporting::diagnostic::{Diagnostic, Label, LabelStyle, Severity};
use treehouse_format::ast::Roots;
use super::diagnostics::{Diagnosis, FileId};
pub struct ErrorsEmitted;
pub fn parse_tree_with_diagnostics(
diagnosis: &mut Diagnosis,
file_id: FileId,
) -> Result<Roots, ErrorsEmitted> {
let input = diagnosis.get_source(file_id);
Roots::parse(&mut treehouse_format::pull::Parser { input, position: 0 }).map_err(|error| {
diagnosis.diagnostics.push(Diagnostic {
severity: Severity::Error,
code: Some("tree".into()),
message: error.kind.to_string(),
labels: vec![Label {
style: LabelStyle::Primary,
file_id,
range: error.range,
message: String::new(),
}],
notes: vec![],
});
ErrorsEmitted
})
}
pub fn parse_toml_with_diagnostics(
diagnosis: &mut Diagnosis,
file_id: FileId,
range: Range<usize>,
) -> Result<toml_edit::Document, ErrorsEmitted> {
let input = &diagnosis.get_source(file_id)[range.clone()];
toml_edit::Document::from_str(input).map_err(|error| {
diagnosis.diagnostics.push(Diagnostic {
severity: Severity::Error,
code: Some("toml".into()),
message: error.message().to_owned(),
labels: error
.span()
.map(|span| Label {
style: LabelStyle::Primary,
file_id,
range: range.start + span.start..range.start + span.end,
message: String::new(),
})
.into_iter()
.collect(),
notes: vec![],
});
ErrorsEmitted
})
}

View file

@ -7,8 +7,7 @@ use anyhow::{bail, Context};
use axum::Router;
use codespan_reporting::{
diagnostic::{Diagnostic, Label, LabelStyle, Severity},
files::{Files as _, SimpleFiles},
term::termcolor::{ColorChoice, StandardStream},
files::Files as _,
};
use copy_dir::copy_dir;
use handlebars::Handlebars;
@ -16,24 +15,17 @@ use log::{debug, info};
use serde::Serialize;
use tower_http::services::ServeDir;
use tower_livereload::LiveReloadLayer;
use treehouse_format::ast::Roots;
use walkdir::WalkDir;
use crate::html::tree::branches_to_html;
use crate::{cli::parse::parse_tree_with_diagnostics, html::tree::branches_to_html};
use super::diagnostics::{Diagnosis, FileId};
#[derive(Default)]
struct Generator {
tree_files: Vec<PathBuf>,
}
type Files = SimpleFiles<String, String>;
type FileId = <Files as codespan_reporting::files::Files<'static>>::FileId;
pub struct Diagnosis {
pub files: Files,
pub diagnostics: Vec<Diagnostic<FileId>>,
}
impl Generator {
fn add_directory_rec(&mut self, directory: &Path) -> anyhow::Result<()> {
for entry in WalkDir::new(directory) {
@ -108,10 +100,7 @@ impl Generator {
}
fn generate_all_files(&self, dirs: &Dirs<'_>) -> anyhow::Result<Diagnosis> {
let mut diagnosis = Diagnosis {
files: Files::new(),
diagnostics: vec![],
};
let mut diagnosis = Diagnosis::new();
let mut handlebars = Handlebars::new();
let tree_template = Self::register_template(
@ -145,51 +134,28 @@ impl Generator {
}
};
let file_id = diagnosis.files.add(utf8_filename.into_owned(), source);
let source = diagnosis
.files
.get(file_id)
.expect("file was just added to the list")
.source();
let parse_result = Roots::parse(&mut treehouse_format::pull::Parser {
input: source,
position: 0,
});
if let Ok(roots) = parse_tree_with_diagnostics(&mut diagnosis, file_id) {
let mut tree = String::new();
let source = diagnosis.get_source(file_id);
branches_to_html(&mut tree, &roots.branches, source);
match parse_result {
Ok(roots) => {
let mut tree = String::new();
branches_to_html(&mut tree, &roots.branches, source);
let template_data = TemplateData { tree };
let templated_html = match handlebars.render("tree", &template_data) {
Ok(html) => html,
Err(error) => {
Self::wrangle_handlebars_error_into_diagnostic(
&mut diagnosis,
tree_template,
error.line_no,
error.column_no,
error.desc,
)?;
continue;
}
};
let template_data = TemplateData { tree };
let templated_html = match handlebars.render("tree", &template_data) {
Ok(html) => html,
Err(error) => {
Self::wrangle_handlebars_error_into_diagnostic(
&mut diagnosis,
tree_template,
error.line_no,
error.column_no,
error.desc,
)?;
continue;
}
};
std::fs::write(target_path, templated_html)?;
}
Err(error) => diagnosis.diagnostics.push(Diagnostic {
severity: Severity::Error,
code: Some("tree".into()),
message: error.kind.to_string(),
labels: vec![Label {
style: LabelStyle::Primary,
file_id,
range: error.range,
message: String::new(),
}],
notes: vec![],
}),
std::fs::write(target_path, templated_html)?;
}
}
@ -223,12 +189,7 @@ pub fn regenerate(dirs: &Dirs<'_>) -> anyhow::Result<()> {
generator.add_directory_rec(dirs.content_dir)?;
let diagnosis = generator.generate_all_files(dirs)?;
let writer = StandardStream::stderr(ColorChoice::Auto);
let config = codespan_reporting::term::Config::default();
for diagnostic in &diagnosis.diagnostics {
codespan_reporting::term::emit(&mut writer.lock(), &config, &diagnosis.files, diagnostic)
.context("could not emit diagnostic")?;
}
diagnosis.report()?;
Ok(())
}

View file

@ -2,6 +2,7 @@ use std::path::Path;
use clap::Parser;
use cli::{
fix::fix_file_cli,
regenerate::{self, regenerate_or_report_error, Dirs},
Command, ProgramArgs,
};
@ -32,6 +33,8 @@ async fn fallible_main() -> anyhow::Result<()> {
regenerate::web_server().await?;
}
}
Command::Fix(fix_args) => fix_file_cli(fix_args)?,
}
Ok(())