From 1a92f85c83677d4e7c0d73387bb809826e692336 Mon Sep 17 00:00:00 2001 From: lqdev Date: Sun, 20 Aug 2023 12:15:48 +0200 Subject: [PATCH] generate ids --- Cargo.lock | 14 ++++ content/index.tree | 3 + crates/treehouse/Cargo.toml | 2 +- crates/treehouse/src/cli/diagnostics.rs | 42 ------------ crates/treehouse/src/cli/fix.rs | 27 ++++---- crates/treehouse/src/cli/mod.rs | 1 - crates/treehouse/src/cli/parse.rs | 36 ++++------ crates/treehouse/src/cli/regenerate.rs | 41 ++++++----- crates/treehouse/src/html/attributes.rs | 40 +++++++++++ crates/treehouse/src/html/mod.rs | 18 +++++ crates/treehouse/src/html/tree.rs | 91 ++++++++++++++++++++++--- crates/treehouse/src/main.rs | 1 + crates/treehouse/src/state.rs | 90 ++++++++++++++++++++++++ 13 files changed, 297 insertions(+), 109 deletions(-) delete mode 100644 crates/treehouse/src/cli/diagnostics.rs create mode 100644 crates/treehouse/src/html/attributes.rs create mode 100644 crates/treehouse/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index fb0ff22..fd58a4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -944,6 +944,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96426c9936fd7a0124915f9185ea1d20aa9445cc9821142f0a73bc9207a2e186" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1102,6 +1111,9 @@ name = "toml_datetime" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -1110,6 +1122,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8123f27e969974a3dfba720fdb560be359f57b44302d280ba72e76a74480e8a" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", "winnow", ] diff --git a/content/index.tree b/content/index.tree index afedcdf..abc3e3f 100644 --- a/content/index.tree +++ b/content/index.tree @@ -164,3 +164,6 @@ % id = "01H87RD70VNVQ75DCWW5FQG9AR" - breaking + + % content.link = "secret" + - this block includes another block's content diff --git a/crates/treehouse/Cargo.toml b/crates/treehouse/Cargo.toml index 2d3c86d..ae97f16 100644 --- a/crates/treehouse/Cargo.toml +++ b/crates/treehouse/Cargo.toml @@ -18,7 +18,7 @@ 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" +toml_edit = { version = "0.19.14", features = ["serde"] } tower-http = { version = "0.4.3", features = ["fs"] } tower-livereload = "0.8.0" walkdir = "2.3.3" diff --git a/crates/treehouse/src/cli/diagnostics.rs b/crates/treehouse/src/cli/diagnostics.rs deleted file mode 100644 index 8bc3983..0000000 --- a/crates/treehouse/src/cli/diagnostics.rs +++ /dev/null @@ -1,42 +0,0 @@ -use anyhow::Context; -use codespan_reporting::{ - diagnostic::Diagnostic, - files::SimpleFiles, - term::termcolor::{ColorChoice, StandardStream}, -}; - -pub type Files = SimpleFiles; -pub type FileId = >::FileId; - -pub struct Diagnosis { - pub files: Files, - pub diagnostics: Vec>, -} - -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(()) - } -} diff --git a/crates/treehouse/src/cli/fix.rs b/crates/treehouse/src/cli/fix.rs index f517319..7a79941 100644 --- a/crates/treehouse/src/cli/fix.rs +++ b/crates/treehouse/src/cli/fix.rs @@ -3,8 +3,9 @@ use std::ops::Range; use anyhow::Context; use treehouse_format::ast::Branch; +use crate::state::{FileId, Treehouse}; + use super::{ - diagnostics::{Diagnosis, FileId}, parse::{self, parse_toml_with_diagnostics, parse_tree_with_diagnostics}, FixArgs, }; @@ -19,7 +20,7 @@ struct State { fixes: Vec, } -fn dfs_fix_branch(diagnosis: &mut Diagnosis, file_id: FileId, state: &mut State, branch: &Branch) { +fn dfs_fix_branch(treehouse: &mut Treehouse, file_id: FileId, state: &mut State, branch: &Branch) { let mut rng = rand::thread_rng(); let ulid = ulid::Generator::new() .generate_with_source(&mut rng) @@ -31,7 +32,7 @@ fn dfs_fix_branch(diagnosis: &mut Diagnosis, file_id: FileId, state: &mut State, // 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()) + parse_toml_with_diagnostics(treehouse, file_id, attributes.data.clone()) { if !toml.contains_key("id") { toml["id"] = toml_edit::value(ulid.to_string()); @@ -68,7 +69,7 @@ fn dfs_fix_branch(diagnosis: &mut Diagnosis, file_id: FileId, state: &mut State, // Then we fix child branches. for child in &branch.children { - dfs_fix_branch(diagnosis, file_id, state, child); + dfs_fix_branch(treehouse, file_id, state, child); } } @@ -100,15 +101,15 @@ fn fix_indent_in_generated_toml(toml: &str, min_indent_level: usize) -> String { } pub fn fix_file( - diagnosis: &mut Diagnosis, + treehouse: &mut Treehouse, file_id: FileId, ) -> Result { - parse_tree_with_diagnostics(diagnosis, file_id).map(|roots| { - let mut source = diagnosis.get_source(file_id).to_owned(); + parse_tree_with_diagnostics(treehouse, file_id).map(|roots| { + let mut source = treehouse.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); + dfs_fix_branch(treehouse, file_id, &mut state, branch); } // Doing a depth-first search of the branches yields fixes from the beginning of the file @@ -127,15 +128,15 @@ 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); + let mut treehouse = Treehouse::new(); + let file_id = treehouse.files.add(utf8_filename, file); - if let Ok(fixed) = fix_file(&mut diagnosis, file_id) { + if let Ok(fixed) = fix_file(&mut treehouse, 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)) + std::fs::write(backup_path, treehouse.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")?; @@ -143,7 +144,7 @@ pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> { println!("{fixed}"); } } else { - diagnosis.report()?; + treehouse.report_diagnostics()?; } Ok(()) diff --git a/crates/treehouse/src/cli/mod.rs b/crates/treehouse/src/cli/mod.rs index f2bece0..ca3e86c 100644 --- a/crates/treehouse/src/cli/mod.rs +++ b/crates/treehouse/src/cli/mod.rs @@ -1,4 +1,3 @@ -pub mod diagnostics; pub mod fix; mod parse; pub mod regenerate; diff --git a/crates/treehouse/src/cli/parse.rs b/crates/treehouse/src/cli/parse.rs index 5b12f31..2b728f0 100644 --- a/crates/treehouse/src/cli/parse.rs +++ b/crates/treehouse/src/cli/parse.rs @@ -3,17 +3,17 @@ use std::{ops::Range, str::FromStr}; use codespan_reporting::diagnostic::{Diagnostic, Label, LabelStyle, Severity}; use treehouse_format::ast::Roots; -use super::diagnostics::{Diagnosis, FileId}; +use crate::state::{toml_error_to_diagnostic, FileId, TomlError, Treehouse}; pub struct ErrorsEmitted; pub fn parse_tree_with_diagnostics( - diagnosis: &mut Diagnosis, + treehouse: &mut Treehouse, file_id: FileId, ) -> Result { - let input = diagnosis.get_source(file_id); + let input = treehouse.get_source(file_id); Roots::parse(&mut treehouse_format::pull::Parser { input, position: 0 }).map_err(|error| { - diagnosis.diagnostics.push(Diagnostic { + treehouse.diagnostics.push(Diagnostic { severity: Severity::Error, code: Some("tree".into()), message: error.kind.to_string(), @@ -30,28 +30,20 @@ pub fn parse_tree_with_diagnostics( } pub fn parse_toml_with_diagnostics( - diagnosis: &mut Diagnosis, + treehouse: &mut Treehouse, file_id: FileId, range: Range, ) -> Result { - let input = &diagnosis.get_source(file_id)[range.clone()]; + let input = &treehouse.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![], - }); + treehouse + .diagnostics + .push(toml_error_to_diagnostic(TomlError { + message: error.message().to_owned(), + span: error.span(), + file_id, + input_range: range.clone(), + })); ErrorsEmitted }) } diff --git a/crates/treehouse/src/cli/regenerate.rs b/crates/treehouse/src/cli/regenerate.rs index d68a133..7eb1b7d 100644 --- a/crates/treehouse/src/cli/regenerate.rs +++ b/crates/treehouse/src/cli/regenerate.rs @@ -19,7 +19,7 @@ use walkdir::WalkDir; use crate::{cli::parse::parse_tree_with_diagnostics, html::tree::branches_to_html}; -use super::diagnostics::{Diagnosis, FileId}; +use crate::state::{FileId, Treehouse}; #[derive(Default)] struct Generator { @@ -39,23 +39,23 @@ impl Generator { fn register_template( handlebars: &mut Handlebars<'_>, - diagnosis: &mut Diagnosis, + treehouse: &mut Treehouse, name: &str, path: &Path, ) -> anyhow::Result { let source = std::fs::read_to_string(path) .with_context(|| format!("cannot read template file {path:?}"))?; - let file_id = diagnosis + let file_id = treehouse .files .add(path.to_string_lossy().into_owned(), source); - let file = diagnosis + let file = treehouse .files .get(file_id) .expect("file was just added to the list"); let source = file.source(); if let Err(error) = handlebars.register_template_string(name, source) { Self::wrangle_handlebars_error_into_diagnostic( - diagnosis, + treehouse, file_id, error.line_no, error.column_no, @@ -66,18 +66,18 @@ impl Generator { } fn wrangle_handlebars_error_into_diagnostic( - diagnosis: &mut Diagnosis, + treehouse: &mut Treehouse, file_id: FileId, line: Option, column: Option, message: String, ) -> anyhow::Result<()> { if let (Some(line), Some(column)) = (line, column) { - let line_range = diagnosis + let line_range = treehouse .files .line_range(file_id, line) .expect("file was added to the list"); - diagnosis.diagnostics.push(Diagnostic { + treehouse.diagnostics.push(Diagnostic { severity: Severity::Error, code: Some("template".into()), message, @@ -90,7 +90,7 @@ impl Generator { notes: vec![], }) } else { - let file = diagnosis + let file = treehouse .files .get(file_id) .expect("file should already be in the list"); @@ -99,13 +99,13 @@ impl Generator { Ok(()) } - fn generate_all_files(&self, dirs: &Dirs<'_>) -> anyhow::Result { - let mut diagnosis = Diagnosis::new(); + fn generate_all_files(&self, dirs: &Dirs<'_>) -> anyhow::Result { + let mut treehouse = Treehouse::new(); let mut handlebars = Handlebars::new(); let tree_template = Self::register_template( &mut handlebars, - &mut diagnosis, + &mut treehouse, "tree", &dirs.template_dir.join("tree.hbs"), )?; @@ -123,7 +123,7 @@ impl Generator { let source = match std::fs::read_to_string(path) { Ok(source) => source, Err(error) => { - diagnosis.diagnostics.push(Diagnostic { + treehouse.diagnostics.push(Diagnostic { severity: Severity::Error, code: None, message: format!("{utf8_filename}: cannot read file: {error}"), @@ -133,19 +133,18 @@ impl Generator { continue; } }; - let file_id = diagnosis.files.add(utf8_filename.into_owned(), source); + let file_id = treehouse.files.add(utf8_filename.into_owned(), source); - if let Ok(roots) = parse_tree_with_diagnostics(&mut diagnosis, file_id) { + if let Ok(roots) = parse_tree_with_diagnostics(&mut treehouse, file_id) { let mut tree = String::new(); - let source = diagnosis.get_source(file_id); - branches_to_html(&mut tree, &roots.branches, source); + branches_to_html(&mut tree, &mut treehouse, file_id, &roots.branches); 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, + &mut treehouse, tree_template, error.line_no, error.column_no, @@ -159,7 +158,7 @@ impl Generator { } } - Ok(diagnosis) + Ok(treehouse) } } @@ -187,9 +186,9 @@ pub fn regenerate(dirs: &Dirs<'_>) -> anyhow::Result<()> { info!("generating standalone pages"); let mut generator = Generator::default(); generator.add_directory_rec(dirs.content_dir)?; - let diagnosis = generator.generate_all_files(dirs)?; + let treehouse = generator.generate_all_files(dirs)?; - diagnosis.report()?; + treehouse.report_diagnostics()?; Ok(()) } diff --git a/crates/treehouse/src/html/attributes.rs b/crates/treehouse/src/html/attributes.rs new file mode 100644 index 0000000..9953d6e --- /dev/null +++ b/crates/treehouse/src/html/attributes.rs @@ -0,0 +1,40 @@ +use serde::Deserialize; + +/// Branch attributes. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +pub struct Attributes { + /// Unique identifier of the branch. + /// + /// Note that this must be unique to the _entire_ site, not just a single tree. + /// This is because trees may be embedded within each other using [`Content::Link`]. + #[serde(default)] + pub id: String, + + /// Controls how the block should be presented. + #[serde(default)] + pub content: Content, +} + +/// Controls for block content presentation. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Content { + /// Children are stored inline in the block. Nothing special happens. + #[default] + Inline, + + /// Link to another tree. + /// + /// When JavaScript is enabled, the tree's roots will be embedded inline into the branch and + /// loaded lazily. + /// + /// Without JavaScript, the tree will be linked with an `` element. + /// + /// The string provided as an argument is relative to the `content` root and should not contain + /// any file extensions. For example, to link to `content/my-article.tree`, + /// use `content.link = "my-article"`. + /// + /// Note that `Link` branches must not contain any children. If a `Link` branch does contain + /// children, an `attribute`-type error is raised. + Link(String), +} diff --git a/crates/treehouse/src/html/mod.rs b/crates/treehouse/src/html/mod.rs index e95633d..7470bcc 100644 --- a/crates/treehouse/src/html/mod.rs +++ b/crates/treehouse/src/html/mod.rs @@ -1,2 +1,20 @@ +use std::fmt::{self, Display, Write}; + +pub mod attributes; mod markdown; pub mod tree; + +pub struct EscapeAttribute<'a>(&'a str); + +impl<'a> Display for EscapeAttribute<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for c in self.0.chars() { + if c == '"' { + f.write_str(""")?; + } else { + f.write_char(c)?; + } + } + Ok(()) + } +} diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs index 62d56cb..14cf661 100644 --- a/crates/treehouse/src/html/tree.rs +++ b/crates/treehouse/src/html/tree.rs @@ -1,13 +1,81 @@ +use std::fmt::Write; + +use codespan_reporting::diagnostic::{Diagnostic, Label, LabelStyle, Severity}; use treehouse_format::{ast::Branch, pull::BranchKind}; -use super::markdown; +use crate::{ + html::EscapeAttribute, + state::{toml_error_to_diagnostic, FileId, TomlError, Treehouse}, +}; -pub fn branch_to_html(s: &mut String, branch: &Branch, source: &str) { - s.push_str(if !branch.children.is_empty() { - "
  • " +use super::{attributes::Attributes, markdown}; + +pub fn branch_to_html(s: &mut String, treehouse: &mut Treehouse, file_id: FileId, branch: &Branch) { + let source = treehouse.get_source(file_id); + + let mut successfully_parsed = true; + let mut attributes = if let Some(attributes) = &branch.attributes { + toml_edit::de::from_str(&source[attributes.data.clone()]).unwrap_or_else(|error| { + treehouse + .diagnostics + .push(toml_error_to_diagnostic(TomlError { + message: error.message().to_owned(), + span: error.span(), + file_id, + input_range: attributes.data.clone(), + })); + successfully_parsed = false; + Attributes::default() + }) } else { - "
  • " - }); + Attributes::default() + }; + let successfully_parsed = successfully_parsed; + + // Only check for attribute validity if the attributes were parsed successfully. + if successfully_parsed { + let attribute_warning_span = branch + .attributes + .as_ref() + .map(|attributes| attributes.percent.clone()) + .unwrap_or(branch.kind_span.clone()); + if attributes.id.is_empty() { + attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno()); + treehouse.diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: Some("attr".into()), + message: "branch does not have an `id` attribute".into(), + labels: vec![Label { + style: LabelStyle::Primary, + file_id, + range: attribute_warning_span, + message: String::new(), + }], + notes: vec![ + format!( + "note: a generated id `{}` will be used, but this id is unstable and will not persist across generations", + attributes.id + ), + format!("help: run `treehouse fix {}` to add missing ids to branches", treehouse.get_filename(file_id)), + ], + }); + } + } + + // Reborrow because the closure requires unique access (it adds a new diagnostic.) + let source = treehouse.get_source(file_id); + + let class = if !branch.children.is_empty() { + "branch" + } else { + "leaf" + }; + write!( + s, + "
  • ", + EscapeAttribute(&attributes.id) + ) + .unwrap(); { if !branch.children.is_empty() { s.push_str(match branch.kind { @@ -42,17 +110,22 @@ pub fn branch_to_html(s: &mut String, branch: &Branch, source: &str) { if !branch.children.is_empty() { s.push_str(""); - branches_to_html(s, &branch.children, source); + branches_to_html(s, treehouse, file_id, &branch.children); s.push_str(""); } } s.push_str("
  • "); } -pub fn branches_to_html(s: &mut String, branches: &[Branch], source: &str) { +pub fn branches_to_html( + s: &mut String, + treehouse: &mut Treehouse, + file_id: FileId, + branches: &[Branch], +) { s.push_str("
      "); for child in branches { - branch_to_html(s, child, source); + branch_to_html(s, treehouse, file_id, child); } s.push_str("
    "); } diff --git a/crates/treehouse/src/main.rs b/crates/treehouse/src/main.rs index 18ebdac..14ceba9 100644 --- a/crates/treehouse/src/main.rs +++ b/crates/treehouse/src/main.rs @@ -10,6 +10,7 @@ use log::{error, info}; mod cli; mod html; +mod state; async fn fallible_main() -> anyhow::Result<()> { let args = ProgramArgs::parse(); diff --git a/crates/treehouse/src/state.rs b/crates/treehouse/src/state.rs new file mode 100644 index 0000000..ddec40a --- /dev/null +++ b/crates/treehouse/src/state.rs @@ -0,0 +1,90 @@ +use std::ops::Range; + +use anyhow::Context; +use codespan_reporting::{ + diagnostic::{Diagnostic, Label, LabelStyle, Severity}, + files::SimpleFiles, + term::termcolor::{ColorChoice, StandardStream}, +}; +use ulid::Ulid; + +pub type Files = SimpleFiles; +pub type FileId = >::FileId; + +/// Treehouse compilation context. +pub struct Treehouse { + pub files: Files, + pub diagnostics: Vec>, + + missingno_generator: ulid::Generator, +} + +impl Treehouse { + pub fn new() -> Self { + Self { + files: Files::new(), + diagnostics: vec![], + + missingno_generator: ulid::Generator::new(), + } + } + + /// 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() + } + + /// Get the name of a file, assuming it was previously registered. + pub fn get_filename(&self, file_id: FileId) -> &str { + self.files + .get(file_id) + .expect("file should have been registered previously") + .name() + } + + pub fn report_diagnostics(&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(()) + } + + pub fn next_missingno(&mut self) -> Ulid { + self.missingno_generator + .generate() + .expect("just how much disk space do you have?") + } +} + +pub struct TomlError { + pub message: String, + pub span: Option>, + pub file_id: FileId, + pub input_range: Range, +} + +pub fn toml_error_to_diagnostic(error: TomlError) -> Diagnostic { + Diagnostic { + severity: Severity::Error, + code: Some("toml".into()), + message: error.message, + labels: error + .span + .map(|span| Label { + style: LabelStyle::Primary, + file_id: error.file_id, + range: error.input_range.start + span.start..error.input_range.start + span.end, + message: String::new(), + }) + .into_iter() + .collect(), + notes: vec![], + } +}