#![allow(clippy::result_large_err)]
use std::{
ffi::OsStr,
fs::read,
fs::write,
path::{Path, PathBuf},
};
use crate::fmt::*;
use anyhow::{bail, Result};
use attr::{Attr, BadAttrError};
use clap::Parser;
use ignore::Walk;
mod attr;
mod fmt;
mod text_pos;
fn main() {
use yansi::Paint;
if let Err(e) = run() {
eprintln!("{}: {}", "error".red().bold(), e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
use yansi::Paint;
let args = Opt::parse();
for e in Walk::new(&args.root) {
let e = e?;
if let Some(t) = e.file_type() {
if t.is_file() {
let path = e.path();
if path.extension() != Some(OsStr::new("rs")) {
continue;
}
let rel_path = path.strip_prefix(&args.root).unwrap_or(path);
if let Some(base) = path.parent() {
let input = String::from_utf8(read(path)?)?;
match apply(&args.root, base, &input) {
Ok(result) => {
if let Some(text) = result.text {
eprintln!("{}: {}", "update".green().bold(), rel_path.display());
for log in result.logs {
if log.is_modified {
eprintln!(" <-- {}", log.source_rel_path.display());
}
}
if !args.dry_run {
write(path, text)?;
}
}
}
Err(e) => {
bail!("{}", e.to_error_message(rel_path, &input));
}
}
}
}
}
}
Ok(())
}
fn make_pair<'a>(
start: &mut Option<Attr<'a>>,
attr: Result<Attr<'a>, BadAttrError>,
) -> Result<Option<(Attr<'a>, Attr<'a>)>, ApplyError<'a>> {
match attr {
Ok(attr) => {
if attr.action == attr::Action::Start {
if let Some(start) = start.replace(attr) {
Err(ApplyError::MissingAttr(start))
} else {
Ok(None)
}
} else {
let end = attr;
if let Some(start) = start.take() {
if let Some(mismatch) = start.mismatch(&end) {
Err(ApplyError::MismatchAttr {
start,
end,
mismatch,
})
} else {
Ok(Some((start, end)))
}
} else {
Err(ApplyError::MissingAttr(end))
}
}
}
Err(e) => Err(ApplyError::BadAttr(e)),
}
}
fn trim<'a, 'b>(
text: &'a str,
start: &Attr<'b>,
end: &Attr<'b>,
) -> Result<&'a str, ApplyError<'b>> {
let index_start = match start.arg {
attr::ActionArg::None => 0,
attr::ActionArg::Line(line) => line_offset(text, line),
attr::ActionArg::LineRev(line) => line_offset_rev(text, line),
attr::ActionArg::Text(p) => {
if let Some(index) = text.find(p) {
index
} else {
return Err(ApplyError::TextNofFound(start.clone()));
}
}
};
let index_end = match end.arg {
attr::ActionArg::None => text.len(),
attr::ActionArg::Line(line) => line_offset(text, line),
attr::ActionArg::LineRev(line) => line_offset_rev(text, line),
attr::ActionArg::Text(p) => {
if let Some(index) = text.rfind(p) {
index
} else {
return Err(ApplyError::TextNofFound(end.clone()));
}
}
};
let index_start = index_end - text[index_start..index_end].trim_start().len();
let index_end = index_start + text[index_start..index_end].trim_end().len();
Ok(&text[index_start..index_end])
}
fn line_offset(text: &str, mut line: usize) -> usize {
if line <= 1 {
return 0;
}
line -= 1;
for (index, c) in text.char_indices() {
if c == '\n' {
line -= 1;
if line == 0 {
return index + 1;
}
}
}
text.len()
}
fn line_offset_rev(text: &str, mut line: usize) -> usize {
if line == 0 {
return text.len();
}
for (index, c) in text.char_indices().rev() {
if c == '\n' {
line -= 1;
if line == 0 {
return index;
}
}
}
0
}
fn is_modified(text_new: &str, text_old: &str, start: &Attr, end: &Attr) -> bool {
let old_text = &text_old[start.range.end..end.range.start];
if !old_text.starts_with('\n') {
return true;
}
text_new != &old_text[1..]
}
fn apply<'a>(root: &Path, base: &Path, input: &'a str) -> Result<ApplyResult, ApplyError<'a>> {
let mut logs = Vec::new();
let mut attr_start = None;
let mut text = String::new();
let mut text_is_modified = false;
let mut last_offset = 0;
for attr in Attr::find_iter(input) {
if let Some((start, end)) = make_pair(&mut attr_start, attr)? {
text.push_str(&input[last_offset..start.range.end]);
text.push('\n');
let source = start.path;
match include(root, base, source) {
Ok(s) => {
let source_rel_path = s.rel_path;
let text_new = to_doc_comment(
trim(&s.text, &start, &end)?,
start.kind.doc_comment_prefix(),
);
let is_modified = is_modified(&text_new, input, &start, &end);
text_is_modified |= is_modified;
text.push_str(&text_new);
logs.push(LogEntry {
source_rel_path,
is_modified,
});
}
Err(e) => {
return Err(ApplyError::SourceRead {
attr: start,
reason: e.to_string(),
});
}
}
last_offset = end.range.start;
}
}
text.push_str(&input[last_offset..]);
let text = if text_is_modified { Some(text) } else { None };
Ok(ApplyResult { text, logs })
}
struct IncludeResult {
rel_path: PathBuf,
text: String,
}
fn include(root: &Path, base: &Path, source: &str) -> Result<IncludeResult> {
let source = base.join(source);
if let Ok(rel_path) = source.canonicalize()?.strip_prefix(&root.canonicalize()?) {
Ok(IncludeResult {
rel_path: rel_path.to_path_buf(),
text: String::from_utf8(read(&source)?)?,
})
} else {
bail!("source is out of root");
}
}
fn to_doc_comment(s: &str, prefix: &str) -> String {
let mut r = String::new();
let mut buf = String::new();
for line in s.lines() {
buf.clear();
buf.push_str(prefix);
buf.push_str(line);
r.push_str(buf.trim_end());
r.push('\n');
}
r
}
#[derive(Parser)]
struct Opt {
#[arg(long)]
root: PathBuf,
#[arg(long = "dry-run")]
dry_run: bool,
}
struct ApplyResult {
text: Option<String>,
logs: Vec<LogEntry>,
}
struct LogEntry {
source_rel_path: PathBuf,
is_modified: bool,
}
enum ApplyError<'a> {
BadAttr(BadAttrError),
MissingAttr(Attr<'a>),
MismatchAttr {
start: Attr<'a>,
end: Attr<'a>,
mismatch: attr::Mismatch,
},
TextNofFound(Attr<'a>),
SourceRead {
attr: Attr<'a>,
reason: String,
},
}
impl<'a> ApplyError<'a> {
fn to_error_message(&self, rel_path: &Path, input: &str) -> String {
match self {
ApplyError::BadAttr(e) => e.message(rel_path, input),
ApplyError::MissingAttr(attr) => {
let msg = match attr.action {
attr::Action::Start => "missing end attribute",
attr::Action::End => "missing start attribute",
};
format!("{}\n{}", msg, attr.message(rel_path, input))
}
ApplyError::MismatchAttr {
start,
end,
mismatch,
} => {
let start_line = start.line(input);
let end_line = end.line(input);
format!(
"{}\n{}\n{}\n{}",
mismatch.message(),
fmt_link(rel_path, start_line),
fmt_link(rel_path, end_line),
fmt_source(vec![
(start_line, &input[start.range()]),
(end_line, &input[end.range()])
])
)
}
ApplyError::TextNofFound(attr) => {
let msg = match attr.action {
attr::Action::Start => "start text not found",
attr::Action::End => "end text not found",
};
format!("{}\n{}", msg, attr.message(rel_path, input))
}
ApplyError::SourceRead { attr, reason } => format!(
"cannot read `{}` ({})\n{}",
attr.path,
reason,
attr.message(rel_path, input)
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::{
fs::{read, read_dir, DirEntry},
path::Path,
};
#[test]
fn test_convert_file() -> Result<()> {
use yansi::Paint;
let dir = Path::new("./tests/data");
for e in read_dir(dir)? {
let e = e?;
if let Some((input, expected)) = to_input_expected(e) {
eprint!("test {} ... ", input);
match check_convert_file(dir, &dir.join(input), &dir.join(expected)) {
Ok(_) => {
eprintln!("{}", "ok".green());
}
Err(e) => {
eprintln!("{}", "FAILED".red());
bail!("{}", e)
}
}
}
}
Ok(())
}
fn to_input_expected(e: DirEntry) -> Option<(String, String)> {
if !e.file_type().ok()?.is_file() {
return None;
}
let path = e.path();
let name = path.file_name()?.to_str()?;
if !name.ends_with(".rs") || name.ends_with(".expected.rs") {
return None;
}
let mut name_expected = path.file_stem()?.to_str()?.to_string();
name_expected += ".expected.rs";
Some((name.to_string(), name_expected))
}
fn check_convert_file(dir: &Path, input_path: &Path, expected_path: &Path) -> Result<()> {
let input_str = String::from_utf8(read(input_path)?)?;
let expected_str = String::from_utf8(read(expected_path)?)?;
let input_rel_path = input_path.strip_prefix(dir).unwrap_or(input_path);
match apply(dir, dir, &input_str) {
Ok(x) => {
let output_str = if let Some(text) = &x.text {
text
} else {
&input_str
};
let output_str = output_str.trim();
let expected_str = expected_str.trim();
if output_str != expected_str {
bail!(
"mismatch result\nexpected:\n{}\n\nactual:\n{}",
expected_str,
output_str
);
}
Ok(())
}
Err(e) => {
bail!("{}", e.to_error_message(input_rel_path, &input_str))
}
}
}
}