diff options
Diffstat (limited to 'converge/src')
-rw-r--r-- | converge/src/main.rs | 456 |
1 files changed, 456 insertions, 0 deletions
diff --git a/converge/src/main.rs b/converge/src/main.rs new file mode 100644 index 0000000..20f2683 --- /dev/null +++ b/converge/src/main.rs @@ -0,0 +1,456 @@ +use std::str::FromStr; + +use camino::Utf8PathBuf; + +fn main() { + let mut args = std::env::args().skip(1); + let argc = args.len(); + if argc == 0 { + eprintln!("usage: converge <content file> [supporting files ...]"); + return; + } + + let content = match args.next() { + None => { + eprintln!("usage: converge <content file> [supporting files ...]"); + return; + } + Some(path) => Utf8PathBuf::from(path), + }; + + let supporting = args.map(Utf8PathBuf::from).collect(); + + let html = process(content, supporting); + println!("{html}") +} + +fn process(content_file: Utf8PathBuf, mut supporting: Vec<Utf8PathBuf>) -> cutie::Html { + println!("{content_file}"); + let raw = std::fs::read_to_string(&content_file).unwrap(); + + match Part::from_str(&raw) { + Err(PartError::NoSetup) => cutie::Html::parse(raw), + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + Ok(part) => { + let setup = part.setup; + let setup_path = match supporting + .iter() + .position(|p| p.file_name().unwrap() == setup) + { + None => { + eprintln!("failed to find setup file {setup}"); + std::process::exit(1); + } + Some(idx) => supporting.swap_remove(idx), + }; + + let mut html = process(setup_path, supporting); + + for Action { command, content } in part.actions { + let mut content_html = cutie::Html::parse(content); + + let ident = if let Identifier::Tag(tag) = command.identifier { + tag + } else { + panic!() + }; + + fn get_tag<'a>(html: &'a mut cutie::Html, ident: &str) -> &'a mut cutie::Tag { + match html.get_by_tag_name_mut(&ident) { + None => { + eprintln!("error processing file"); + eprintln!("failed to find element with tag {ident}"); + std::process::exit(1); + } + Some(tag) => tag, + } + } + + fn get_parent_tag<'a>( + html: &'a mut cutie::Html, + ident: &str, + ) -> &'a mut cutie::Tag { + match html.get_parent_that_contains_tag_name_mut(&ident) { + None => { + eprintln!("error processing file"); + eprintln!("failed to find element with tag {ident}"); + std::process::exit(1); + } + Some(tag) => tag, + } + } + + match command.opcode { + Opcode::ReplaceChildren => { + let tag = get_tag(&mut html, &ident); + tag.children = content_html.nodes; + } + Opcode::Push => { + let tag = get_tag(&mut html, &ident); + tag.children.extend(content_html.nodes); + } + Opcode::Before => { + println!("BEFORE"); + let predicate = |node: &cutie::Node| -> bool { + if let cutie::Node::Tag(tag) = node { + if tag.name == ident { + return true; + } + } + + false + }; + + let parent = get_parent_tag(&mut html, &ident); + match parent.children.iter().position(predicate) { + None => panic!(), + Some(idx) => { + for node in content_html.nodes.into_iter().rev() { + parent.children.insert(idx, node); + } + } + } + } + Opcode::Replace => { + let parent = get_parent_tag(&mut html, &ident); + match parent.by_tag_mut(&ident) { + None => { + eprintln!("error processing file"); + eprintln!("failed to find element with tag {ident}"); + std::process::exit(1); + } + Some(tag) => match content_html.nodes.swap_remove(0) { + cutie::Node::Tag(content_tag) => *tag = content_tag, + _ => { + eprintln!( + "OpCode was Replace but no HTML tag as first content" + ); + std::process::exit(1); + } + }, + } + } + } + } + + html + } + } +} + +pub struct Part { + setup: Utf8PathBuf, + actions: Vec<Action>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Action { + command: Command, + content: String, +} + +#[rustfmt::skip] // it was bothering me +impl FromStr for Part { + type Err = PartError; + + fn from_str(mut raw: &str) -> Result<Self, Self::Err> { + let setup = if let Some(stripped) = raw.strip_prefix("Setup: ") { + match stripped.find('\n') { + None => return Err(PartError::NoSetup), + Some(nl_idx) => { + let path = &stripped[..nl_idx]; + raw = &stripped[nl_idx + 1..]; + Utf8PathBuf::from(path) + } + } + } else { return Err(PartError::NoSetup) }; + + let mut actions = vec![]; + loop { + // Skip newlines between blocks + while let Some("\n") = raw.get(0..1) { raw = &raw[1..]; } + + let command = match extract_command_from_start(raw)? { + None => { + if raw.trim().is_empty() { + // permissive about the file ending in any whitespace + break; + } else { + let line = raw.split('\n').next().unwrap_or(raw).to_owned(); + return Err(PartError::IncorrectCommand { line }); + } + }, + Some(ExtractedCommand { command, after }) => { + raw = after; + command + } + }; + + // Check for 1-line actions + let (sla_line, sla_after) = match raw.find('\n') { + None => { + let line = raw.trim(); + if line.is_empty() { + return Err(PartError::EmptyAction); + } else { + (line, &raw[raw.len()..]) + } + }, + Some(nl_idx) => { + (raw[..nl_idx].trim(), &raw[nl_idx+1..]) + } + }; + + // We're a one-line action! + if !sla_line.is_empty() { + actions.push(Action { command, content: sla_line.to_owned() }); + raw = sla_after; + continue; + } else { + // not single-line, trim the front + raw = raw.trim_start(); + } + + // If we're here, it's a multiline action + let mut consumed = 0; + loop { + let wrk = &raw[consumed..]; + + match wrk.find("\n\n") { + None => { + consumed = raw.len(); + break; + }, + Some(dnl_idx) => { + consumed += dnl_idx; + + if extract_command_from_start(&wrk[dnl_idx+2..])?.is_some() { + break; + } else { + consumed += 2; + } + } + } + } + + let content = &raw[..consumed]; + + if content.trim().is_empty() { + return Err(PartError::EmptyAction); + } + + actions.push(Action { command, content: content.to_owned() }); + raw = &raw[consumed..]; + } + + Ok(Self { + setup, + actions + }) + } +} + +fn extract_command_from_start(raw: &str) -> Result<Option<ExtractedCommand>, PartError> { + // we don't change the start here. indexing back into raw with an index from + // line is safe. + let line = match raw.find('\n') { + None => raw, + Some(nl_idx) => &raw[..nl_idx], + }; + + Ok(match line.find('}') { + None => None, + Some(end_idx) => { + if line.starts_with('{') { + Some(ExtractedCommand { + command: raw[1..end_idx].parse()?, + after: &raw[end_idx + 1..], + }) + } else { + None + } + } + }) +} + +#[derive(Clone, Debug, PartialEq)] +struct Command { + identifier: Identifier, + opcode: Opcode, +} + +impl FromStr for Command { + type Err = PartError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let (raw_opcode, raw_identifier) = match s.split_once('-') { + None => ("", s.trim()), + Some((op, ident)) => (op.trim(), ident.trim()), + }; + + let opcode = match raw_opcode { + "" | "replace children" => Opcode::ReplaceChildren, + "replace" => Opcode::Replace, + "before" => Opcode::Before, + "push" => Opcode::Push, + _ => { + return Err(PartError::InvalidOperation { + op: raw_opcode.to_owned(), + }) + } + }; + + Ok(Command { + //TODO: allow operating with IDs + identifier: Identifier::Tag(raw_identifier.to_owned()), + opcode, + }) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum Identifier { + Tag(String), +} + +#[derive(Clone, Debug, PartialEq)] +enum Opcode { + ReplaceChildren, + Replace, + Before, + Push, +} + +struct ExtractedCommand<'r> { + command: Command, + after: &'r str, +} + +#[derive(Debug, thiserror::Error)] +pub enum PartError { + #[error("part needs to start with a setup! like this:\n\"Setup: <path>\"")] + NoSetup, + #[error("command has no content")] + EmptyAction, + #[error("the command-line is incorrect: {line}")] + IncorrectCommand { line: String }, + #[error("the operation {op} is invalid")] + InvalidOperation { op: String }, +} + +#[cfg(test)] +mod test { + use crate::{extract_command_from_start, Action, Command, Identifier, Opcode, Part}; + + macro_rules! str { + ($str:literal) => { + String::from($str) + }; + } + + macro_rules! cmd { + ($id:expr) => { + Command { + identifier: Identifier::Tag(String::from($id)), + opcode: Opcode::ReplaceChildren, + } + }; + } + + #[test] + fn extracts_command() { + let raw = "{cmd} single line"; + let ext = extract_command_from_start(raw).unwrap().unwrap(); + + assert_eq!(ext.command, cmd!("cmd")); + assert_eq!(ext.after, " single line"); + } + + #[test] + fn extracts_command_newline() { + let raw = "{cmd}\nnext line"; + let ext = extract_command_from_start(raw).unwrap().unwrap(); + + assert_eq!(ext.command, cmd!("cmd")); + assert_eq!(ext.after, "\nnext line"); + } + + #[test] + fn extracts_simple_part() { + let raw = "Setup: setup.html\n\n{cmd} content"; + let part: Part = raw.parse().unwrap(); + + assert_eq!(part.setup, "setup.html"); + assert_eq!( + part.actions, + vec![Action { + command: cmd!("cmd"), + content: str!("content") + }] + ) + } + + #[test] + fn extracts_simple_part_multiline() { + let raw = "Setup: setup.html\n\n{cmd}\ncontent"; + let part: Part = raw.parse().unwrap(); + + assert_eq!(part.setup, "setup.html"); + assert_eq!( + part.actions, + vec![Action { + command: cmd!("cmd"), + content: str!("content") + }] + ) + } + + #[test] + fn extracts_simple_part_multiple_singleline() { + let raw = "Setup: setup.html\n\n{cmd} content\n{cmd2} contents2"; + let part: Part = raw.parse().unwrap(); + + assert_eq!(part.setup, "setup.html"); + assert_eq!( + part.actions, + vec![ + Action { + command: cmd!("cmd"), + content: str!("content") + }, + Action { + command: cmd!("cmd2"), + content: str!("content2") + } + ] + ) + } + + #[test] + fn extracts_simple_part_multiple_actions() { + let raw = "Setup: setup.html\n\n{cmd} content\n{cmd2}\ncontent2\n{bait}\n\n{cmd3}\ncontent"; + let part: Part = raw.parse().unwrap(); + + assert_eq!(part.setup, "setup.html"); + assert_eq!( + part.actions, + vec![ + Action { + command: cmd!("cmd"), + content: str!("content") + }, + Action { + command: cmd!("cmd2"), + content: str!("content2\n{bait}") + }, + Action { + command: cmd!("cmd3"), + content: str!("content") + } + ] + ) + } +} |