diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d747113..313b530 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,5 @@ jobs: env: SPEC_DENY_WARNINGS: 1 run: mdbook build + - name: Run style check + run: (cd style-check && cargo run -- ../spec) diff --git a/docs/authoring.md b/docs/authoring.md index 942ceee..6d7e892 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -2,7 +2,21 @@ ## Markdown formatting -* Use ATX-style heading with sentence case. +* Use [ATX-style headings][atx] (not Setext) with [sentence case]. +* Do not use tabs, only spaces. +* Files must end with a newline. +* Lines must not end with spaces. Double spaces have semantic meaning, but can be invisible. Use a trailing backslash if you need a hard line break. +* If possible, avoid double blank lines. +* Do not use indented code blocks, use 3+ backticks code blocks instead. +* Code blocks should have an explicit language tag. +* Do not wrap long lines. This helps with reviewing diffs of the source. +* Use [smart punctuation] instead of Unicode characters. For example, use `---` for em-dash instead of the Unicode character. Characters like em-dash can be difficult to see in a fixed-width editor, and some editors may not have easy methods to enter such characters. + +There are automated checks for some of these rules. Run `cargo run --manifest-path style-check/Cargo.toml -- spec` to run them locally. + +[atx]: https://spec.commonmark.org/0.31.2/#atx-headings +[sentence case]: https://apastyle.apa.org/style-grammar-guidelines/capitalization/sentence-case +[smart punctuation]: https://rust-lang.github.io/mdBook/format/markdown.html#smart-punctuation ## Special markdown constructs diff --git a/style-check/.gitignore b/style-check/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/style-check/.gitignore @@ -0,0 +1 @@ +target diff --git a/style-check/Cargo.lock b/style-check/Cargo.lock new file mode 100644 index 0000000..11511a8 --- /dev/null +++ b/style-check/Cargo.lock @@ -0,0 +1,71 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "pulldown-cmark" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce76ce678ffc8e5675b22aa1405de0b7037e2fdf8913fea40d1926c6fe1e6e7" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d8f9aa0e3cbcfaf8bf00300004ee3b72f74770f9cbac93f6928771f613276b" + +[[package]] +name = "style-check" +version = "0.1.0" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" diff --git a/style-check/Cargo.toml b/style-check/Cargo.toml new file mode 100644 index 0000000..083aeca --- /dev/null +++ b/style-check/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "style-check" +version = "0.1.0" +edition = "2021" + +[dependencies] +pulldown-cmark = "0.10.0" diff --git a/style-check/src/main.rs b/style-check/src/main.rs new file mode 100644 index 0000000..26f55c7 --- /dev/null +++ b/style-check/src/main.rs @@ -0,0 +1,137 @@ +use std::env; +use std::error::Error; +use std::fs; +use std::path::Path; + +macro_rules! style_error { + ($bad:expr, $path:expr, $($arg:tt)*) => { + *$bad = true; + eprint!("error in {}: ", $path.display()); + eprintln!("{}", format_args!($($arg)*)); + }; +} + +fn main() { + let arg = env::args().nth(1).unwrap_or_else(|| { + eprintln!("Please pass a src directory as the first argument"); + std::process::exit(1); + }); + + let mut bad = false; + if let Err(e) = check_directory(&Path::new(&arg), &mut bad) { + eprintln!("error: {}", e); + std::process::exit(1); + } + if bad { + eprintln!("some style checks failed"); + std::process::exit(1); + } + eprintln!("passed!"); +} + +fn check_directory(dir: &Path, bad: &mut bool) -> Result<(), Box> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + check_directory(&path, bad)?; + continue; + } + + if !matches!( + path.extension().and_then(|p| p.to_str()), + Some("md") | Some("html") + ) { + // This may be extended in the future if other file types are needed. + style_error!(bad, path, "expected only md or html in src"); + } + + let contents = fs::read_to_string(&path)?; + if contents.contains("#![feature") { + style_error!(bad, path, "#![feature] attributes are not allowed"); + } + if !cfg!(windows) && contents.contains('\r') { + style_error!( + bad, + path, + "CR characters not allowed, must use LF line endings" + ); + } + if contents.contains('\t') { + style_error!(bad, path, "tab characters not allowed, use spaces"); + } + if contents.contains('\u{2013}') { + style_error!(bad, path, "en-dash not allowed, use two dashes like --"); + } + if contents.contains('\u{2014}') { + style_error!(bad, path, "em-dash not allowed, use three dashes like ---"); + } + if !contents.ends_with('\n') { + style_error!(bad, path, "file must end with a newline"); + } + for line in contents.lines() { + if line.ends_with(' ') { + style_error!(bad, path, "lines must not end with spaces"); + } + } + cmark_check(&path, bad, &contents)?; + } + Ok(()) +} + +fn cmark_check(path: &Path, bad: &mut bool, contents: &str) -> Result<(), Box> { + use pulldown_cmark::{BrokenLink, CodeBlockKind, Event, Options, Parser, Tag}; + + macro_rules! cmark_error { + ($bad:expr, $path:expr, $range:expr, $($arg:tt)*) => { + *$bad = true; + let lineno = contents[..$range.start].chars().filter(|&ch| ch == '\n').count() + 1; + eprint!("error in {} (line {}): ", $path.display(), lineno); + eprintln!("{}", format_args!($($arg)*)); + } + } + + let options = Options::all(); + // Can't use `bad` because it would get captured in closure. + let mut link_err = false; + let mut cb = |link: BrokenLink<'_>| { + cmark_error!( + &mut link_err, + path, + link.span, + "broken {:?} link (reference `{}`)", + link.link_type, + link.reference + ); + None + }; + let parser = Parser::new_with_broken_link_callback(contents, options, Some(&mut cb)); + + for (event, range) in parser.into_offset_iter() { + match event { + Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => { + cmark_error!( + bad, + path, + range, + "indented code blocks should use triple backtick-style \ + with a language identifier" + ); + } + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(languages))) => { + if languages.is_empty() { + cmark_error!( + bad, + path, + range, + "code block should include an explicit language", + ); + } + } + _ => {} + } + } + *bad |= link_err; + Ok(()) +}