diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f24bd..3f14ab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: run: cargo test --workspace --all-features - name: No-default features run: cargo test --workspace --no-default-features + - name: Examples + run: cargo test --examples msrv: name: "Check MSRV: 1.54.0" needs: smoke diff --git a/.gitignore b/.gitignore index a9d37c5..f759ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,16 @@ -target +### Created by https://www.gitignore.io +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.toml b/Cargo.toml index 5ba3f70..d783920 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,3 @@ pre-release-replacements = [ ] [dependencies] - -[dev-dependencies] -pretty_assertions = "1.0.0" -duct = "0.13" diff --git a/README.md b/README.md index cdb5382..294cbd1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ # roff-rs +[![Build Status](https://github.com/rust-cli/roff-rs/workflows/pipeline/badge.svg)][CI] [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] ![License](https://img.shields.io/crates/l/roff.svg) [![crates.io](https://img.shields.io/crates/v/roff.svg)][Crates.io] -[Crates.io]: https://crates.io/crates/roff -[Documentation]: https://docs.rs/roff/ - [Roff](http://man7.org/linux/man-pages/man7/roff.7.html) generation library. ## Examples @@ -98,3 +96,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. + +[CI]: https://github.com/rust-cli/roff-rs/actions +[Crates.io]: https://crates.io/crates/roff +[Documentation]: https://docs.rs/roff/ diff --git a/examples/demo.rs b/examples/demo.rs new file mode 100644 index 0000000..639d75c --- /dev/null +++ b/examples/demo.rs @@ -0,0 +1,57 @@ +use roff::*; + +// View this example by running `cargo run --example demo | man -l -`. + +fn main() { + let page = Roff::new("corrupt", ManSection::Executable) + .date("2021-12-25") + .manual("General Commands Manual") + .source("corrupt v1") + .section( + "name", + &["corrupt - modify files by randomly changing bits"], + ) + .section( + "SYNOPSIS", + &[ + bold("corrupt"), + " ".into(), + "[".into(), + bold("-n"), + " ".into(), + italic("BITS"), + "]".into(), + " ".into(), + "[".into(), + bold("--bits"), + " ".into(), + italic("BITS"), + "]".into(), + " ".into(), + italic("file"), + "...".into(), + ], + ) + .section( + "description", + &[ + bold("corrupt"), + " modifies files by toggling a randomly chosen bit.".into(), + ], + ) + .section( + "options", + &[list( + &[ + bold("-n"), + ", ".into(), + bold("--bits"), + "=".into(), + italic("BITS"), + ], + &["Set the number of bits to modify. ", "Default is one bit."], + )], + ); + + println!("{}", page.render()); +} diff --git a/src/lib.rs b/src/lib.rs index 0947e28..87835e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,28 +1,123 @@ use std::fmt::Write; -#[derive(PartialEq, Eq)] -pub struct Roff { +/// Title line for a manpage. +pub struct Title { title: String, - section: i8, + section: ManSection, + date: Option, + source: Option, + manual: Option, +} + +impl Title { + pub fn new(title: &str, section: ManSection) -> Self { + Title { + title: title.into(), + section, + date: None, + source: None, + manual: None, + } + } +} + +impl Troffable for Title { + fn render(&self) -> String { + let manual = self.manual.as_deref().unwrap_or_default(); + let date = self.date.as_deref().unwrap_or_default(); + let source = self.source.as_deref().unwrap_or_default(); + + format!( + r#".TH "{}" "{}" "{}" "{}" "{}""#, + self.title.to_uppercase(), + self.section.value(), + date, + source, + manual + ) + } +} + +/// Manpage sections. +/// +/// The most common is [`ManSection::Executable`], and is the recommended default. +#[derive(Clone, Copy)] +pub enum ManSection { + /// Executable programs or shell commands + Executable, + /// System calls (functions provided by the kernel) + SystemCalls, + /// Library calls (functions within program libraries) + LibraryCalls, + /// Special files (usually found in /dev) + SpecialFiles, + /// File formats and conventions, e.g. /etc/passwd + FileFormats, + /// Games + Games, + /// Miscellaneous (including macro packages and conventions), e.g. man(7), groff(7) + Miscellaneous, + /// System administration commands (usually only for root) + SystemAdministrationCommands, + /// Kernel routines [Non standard] + KernelRoutines, +} + +impl ManSection { + pub fn value(&self) -> i8 { + match self { + ManSection::Executable => 1, + ManSection::SystemCalls => 2, + ManSection::LibraryCalls => 3, + ManSection::SpecialFiles => 4, + ManSection::FileFormats => 5, + ManSection::Games => 6, + ManSection::Miscellaneous => 7, + ManSection::SystemAdministrationCommands => 8, + ManSection::KernelRoutines => 9, + } + } +} + +pub struct Roff { + title: Title, content: Vec
, } impl Roff { - pub fn new(title: &str, section: i8) -> Self { + pub fn new(title: &str, section: ManSection) -> Self { Roff { - title: title.into(), - section, + title: Title::new(title, section), content: Vec::new(), } } + /// Date of the last nontrivial change to the manpage. Should be formatted + /// in `YYYY-MM-DD`. + pub fn date(mut self, date: impl Into) -> Self { + self.title.date = Some(date.into()); + self + } + + /// The source of the command, function or system call. + pub fn source(mut self, source: impl Into) -> Self { + self.title.source = Some(source.into()); + self + } + + /// The title of the manual. + pub fn manual(mut self, manual: impl Into) -> Self { + self.title.manual = Some(manual.into()); + self + } + pub fn section<'a, C, I>(mut self, title: &str, content: I) -> Self where I: IntoIterator, C: Troffable + 'a, { let title = title.into(); - let content = content.into_iter().map(|x| x.render()).collect(); + let content = content.into_iter().map(Troffable::render).collect(); self.content.push(Section { title, content }); self @@ -33,13 +128,18 @@ impl Troffable for Roff { fn render(&self) -> String { let mut res = String::new(); - writeln!( - &mut res, - ".TH {} {}", - self.title.to_uppercase(), - self.section - ) - .unwrap(); + writeln!(&mut res, "{}", self.title.render()).unwrap(); + + // Compatibility settings: + // + // Set sentence_space_size to 0 to prevent extra space between sentences separated + // by a newline the alternative is to add \& at the end of the line + writeln!(&mut res, ".ss \\n[.ss] 0").unwrap(); + // Disable hyphenation + writeln!(&mut res, ".nh").unwrap(); + // Disable justification (adjust text to the left margin only) + writeln!(&mut res, ".ad l").unwrap(); + for section in &self.content { writeln!(&mut res, "{}", escape(§ion.render())).unwrap(); } @@ -48,7 +148,6 @@ impl Troffable for Roff { } } -#[derive(PartialEq, Eq)] struct Section { title: String, content: String, @@ -58,7 +157,7 @@ impl Troffable for Section { fn render(&self) -> String { let mut res = String::new(); - writeln!(&mut res, ".SH {}", self.title.to_uppercase()).unwrap(); + writeln!(&mut res, ".SH \"{}\"", self.title.to_uppercase()).unwrap(); res.push_str(&self.content); res @@ -101,10 +200,14 @@ pub fn italic(input: &str) -> String { format!(r"\fI{}\fP", input) } -pub fn list(header: &'_ [C1], content: &'_ [C2]) -> String { +pub fn list(header: &[C1], content: &[C2]) -> String { format!(".TP\n{}\n{}", header.render(), content.render()) } -fn escape(input: &str) -> String { +pub fn escape(input: &str) -> String { input.replace("-", r"\-") } + +pub fn paragraph(input: &str) -> String { + format!("\n.sp\n{}", input) +} diff --git a/tests/demo.rs b/tests/demo.rs index 2b51994..8609397 100644 --- a/tests/demo.rs +++ b/tests/demo.rs @@ -1,21 +1,31 @@ -extern crate duct; -extern crate roff; -#[macro_use] -extern crate pretty_assertions; +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use roff::*; fn roff_to_ascii(input: &str) -> String { - duct::cmd("troff", &["-a", "-mman"]) - .stdin_bytes(input) - .stdout_capture() - .read() - .unwrap() + let mut cmd = Command::new("troff") + .args(["-a", "-mman"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + + if let Some(ref mut stdin) = cmd.stdin { + stdin.write_all(input.as_bytes()).unwrap(); + } + + String::from_utf8(cmd.wait_with_output().unwrap().stdout).unwrap() } #[test] fn demo() { - use roff::*; - - let page = Roff::new("corrupt", 1) + let page = Roff::new("corrupt", ManSection::Executable) + .date("2021-12-25") + .manual("General Commands Manual") + .source("corrupt v1") .section( "name", &["corrupt - modify files by randomly changing bits"], @@ -64,7 +74,7 @@ fn demo() { // use std::io::Write; // let mut f = ::std::fs::File::create("./tests/demo.generated.troff").unwrap(); - // f.write_all(&page.render().as_bytes()); + // f.write_all(&page.render().as_bytes()).unwrap(); assert_eq!( roff_to_ascii(include_str!("./demo.troff")), diff --git a/tests/demo.troff b/tests/demo.troff index d02b16d..6938c78 100644 --- a/tests/demo.troff +++ b/tests/demo.troff @@ -1,16 +1,14 @@ -.TH CORRUPT 1 -.SH NAME +.TH "CORRUPT" "1" "2021-12-25" "corrupt v1" "General Commands Manual" +.ss \n[.ss] 0 +.nh +.ad l +.SH "NAME" corrupt \- modify files by randomly changing bits -.SH SYNOPSIS -.B corrupt -[\fB\-n\fR \fIBITS\fR] -[\fB\-\-bits\fR \fIBITS\fR] -.IR file ... -.SH DESCRIPTION -.B corrupt -modifies files by toggling a randomly chosen bit. -.SH OPTIONS +.SH "SYNOPSIS" +\fBcorrupt\fP [\fB\-n\fP \fIBITS\fP] [\fB\-\-bits\fP \fIBITS\fP] \fIfile\fP... +.SH "DESCRIPTION" +\fBcorrupt\fP modifies files by toggling a randomly chosen bit. +.SH "OPTIONS" .TP -.BR \-n ", " \-\-bits =\fIBITS\fR -Set the number of bits to modify. -Default is one bit. \ No newline at end of file +\fB\-n\fP, \fB\-\-bits\fP=\fIBITS\fP +Set the number of bits to modify. Default is one bit.