diff --git a/README.md b/README.md index 4c8a144b..89143606 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ error[preamble-order]: preamble header `description` must come after `title` | `markdown-refs` | ERCs are referenced using ERC-X, while other proposals use EIP-X. | | `markdown-rel-links` | All URLs in the page are relative. | | `markdown-req-section` | Required sections are present in the body of the proposal. | +| `markdown-headings-space` | Headers have a space after the leading '#' characters | | `preamble-author` | The author header is correctly formatted, and there is at least one GitHub user listed. | | `preamble-date-created` | The `created` header is a date. | | `preamble-date-last-call-deadline` | The `last-call-deadline` header is a date. | diff --git a/docs/markdown-headings-space/index.html b/docs/markdown-headings-space/index.html new file mode 100644 index 00000000..78377616 --- /dev/null +++ b/docs/markdown-headings-space/index.html @@ -0,0 +1,43 @@ + + + + + markdown-headings-space + + + + +
+

markdown-headings-space

+

+ Markdown headers must have a space after the hash characters. +

+ +
+

Examples

+ +
error[markdown-headings-space]: Space missing in header
+  |
+5 | ##Banana
+  |  ^ space required here
+  |
+6 | ####Mango
+  |    ^ space required here
+  |
+			
+
+
+

Explanation

+ +

+ markdown-headings-space ensures that all headers + have a space after the hash characters so that they are valid. +

+ +

+ The lack of a space makes headers invalid, and they will not be rendered as such. +

+
+
+ + diff --git a/eipw-lint/src/lib.rs b/eipw-lint/src/lib.rs index 8b24d368..6ec5fc59 100644 --- a/eipw-lint/src/lib.rs +++ b/eipw-lint/src/lib.rs @@ -457,6 +457,10 @@ pub fn default_lints_enum() -> impl Iterator { MarkdownSectionRequired { sections: markdown::SectionRequired, }, + MarkdownHeadingsSpace(markdown::HeadingsSpace), } impl DefaultLint @@ -110,6 +111,7 @@ where Self::MarkdownRelativeLinks(l) => Box::new(l), Self::MarkdownSectionOrder { sections } => Box::new(sections), Self::MarkdownSectionRequired { sections } => Box::new(sections), + Self::MarkdownHeadingsSpace(l) => Box::new(l), } } } @@ -148,6 +150,7 @@ where Self::MarkdownRelativeLinks(l) => l, Self::MarkdownSectionOrder { sections } => sections, Self::MarkdownSectionRequired { sections } => sections, + Self::MarkdownHeadingsSpace(l) => l, } } } @@ -278,6 +281,7 @@ where Self::MarkdownSectionRequired { sections } => DefaultLint::MarkdownSectionRequired { sections: markdown::SectionRequired(sections.0.iter().map(AsRef::as_ref).collect()), }, + Self::MarkdownHeadingsSpace(l) => DefaultLint::MarkdownHeadingsSpace(l.clone()), } } } diff --git a/eipw-lint/src/lints/markdown.rs b/eipw-lint/src/lints/markdown.rs index d9a91117..7f1ca018 100644 --- a/eipw-lint/src/lints/markdown.rs +++ b/eipw-lint/src/lints/markdown.rs @@ -4,6 +4,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +pub mod headings_space; pub mod html_comments; pub mod json_schema; pub mod link_first; @@ -14,6 +15,7 @@ pub mod relative_links; pub mod section_order; pub mod section_required; +pub use self::headings_space::HeadingsSpace; pub use self::html_comments::HtmlComments; pub use self::json_schema::JsonSchema; pub use self::link_first::LinkFirst; diff --git a/eipw-lint/src/lints/markdown/headings_space.rs b/eipw-lint/src/lints/markdown/headings_space.rs new file mode 100644 index 00000000..b72bc12b --- /dev/null +++ b/eipw-lint/src/lints/markdown/headings_space.rs @@ -0,0 +1,86 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use annotate_snippets::snippet::{Annotation, Slice, Snippet}; + +use annotate_snippets::snippet::SourceAnnotation; +use comrak::nodes::{Ast, LineColumn, NodeValue, Sourcepos}; +use regex::Regex; + +use crate::lints::{Context, Error, Lint}; + +use serde::{Deserialize, Serialize}; + +use std::fmt::Debug; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HeadingsSpace; + +impl Lint for HeadingsSpace { + fn lint<'a>(&self, slug: &'a str, ctx: &Context<'a, '_>) -> Result<(), Error> { + // Match for text nodes starting with leading '#' chars (upto 6) + // Markdown does not recognise these nodes as valid Headings without the space + let heading_pattern = Regex::new("^#{1,6}").unwrap(); + let invalid_headings: Vec<_> = ctx + .body() + .descendants() + .filter_map(|node| match &*node.data.borrow() { + // Collect all matching Text nodes + Ast { + value: NodeValue::Text(text), + sourcepos: + Sourcepos { + start: LineColumn { column: 1, .. }, // Only match text nodes at the start of the line + .. + }, + .. + } => { + if let Some(matched_text) = heading_pattern.find(text) { + let heading_level = matched_text.end(); + Some(( + text.clone(), + node.data.borrow().sourcepos.start.line, + heading_level, + )) + } else { + None + } + } + _ => None, + }) + .collect(); + + let slices: Vec<_> = invalid_headings + .iter() + .map(|(text, line_start, heading_level)| Slice { + line_start: *line_start, + origin: ctx.origin(), + source: text, + fold: false, + annotations: vec![SourceAnnotation { + annotation_type: ctx.annotation_type(), + label: "space required here", + range: (*heading_level - 1, *heading_level), + }], + }) + .collect(); + + if !slices.is_empty() { + ctx.report(Snippet { + title: Some(Annotation { + id: Some(slug), + annotation_type: ctx.annotation_type(), + label: Some("Space missing in header"), + }), + footer: vec![], + slices, + opt: Default::default(), + })?; + } + + Ok(()) + } +} diff --git a/eipw-lint/tests/lint_markdown_headings_space.rs b/eipw-lint/tests/lint_markdown_headings_space.rs new file mode 100644 index 00000000..37e7618e --- /dev/null +++ b/eipw-lint/tests/lint_markdown_headings_space.rs @@ -0,0 +1,93 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use eipw_lint::lints::markdown::HeadingsSpace; +use eipw_lint::reporters::Text; +use eipw_lint::Linter; + +#[tokio::test] +async fn normal_headings() { + let src = r#"--- +header: value1 +--- + +##Banana +####Mango +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("markdown-headings-space", HeadingsSpace {}) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + + assert_eq!( + reports, + r#"error[markdown-headings-space]: Space missing in header + | +5 | ##Banana + | ^ space required here + | +6 | ####Mango + | ^ space required here + | +"# + ); +} + +#[tokio::test] +async fn abnormal_heading() { + let src = r#"--- +header: value1 +--- + +##B#an#ana +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("markdown-headings-space", HeadingsSpace {}) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + + assert_eq!( + reports, + r#"error[markdown-headings-space]: Space missing in header + | +5 | ##B#an#ana + | ^ space required here + | +"# + ); +} + +#[tokio::test] +async fn not_headings() { + let src_str = r#"--- +header: value1 +--- + +*Hello*#world +`#world` +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny("markdown-headings-space", HeadingsSpace {}) + .check_slice(None, src_str) + .run() + .await + .unwrap() + .into_inner(); + + assert_eq!(reports, ""); +}