diff --git a/eipw-lint/src/lib.rs b/eipw-lint/src/lib.rs index f223bf2f..8f3024a4 100644 --- a/eipw-lint/src/lib.rs +++ b/eipw-lint/src/lib.rs @@ -419,6 +419,12 @@ pub fn default_lints_enum() -> impl Iterator impl Iterator { MarkdownHtmlComments(markdown::HtmlComments), MarkdownJsonSchema(markdown::JsonSchema), + MarkdownLinkEip { + pattern: markdown::LinkEip, + }, MarkdownLinkFirst { pattern: markdown::LinkFirst, }, MarkdownNoBackticks { pattern: markdown::NoBackticks, }, + MarkdownLinkOther { + pattern: markdown::LinkOther, + }, MarkdownLinkStatus(markdown::LinkStatus), MarkdownProposalRef(markdown::ProposalRef), MarkdownRegex(markdown::Regex), @@ -105,6 +111,8 @@ where Self::MarkdownHtmlComments(l) => Box::new(l), Self::MarkdownJsonSchema(l) => Box::new(l), + Self::MarkdownLinkEip { pattern } => Box::new(pattern), + Self::MarkdownLinkOther { pattern } => Box::new(pattern), Self::MarkdownLinkFirst { pattern } => Box::new(pattern), Self::MarkdownNoBackticks { pattern } => Box::new(pattern), Self::MarkdownLinkStatus(l) => Box::new(l), @@ -145,8 +153,10 @@ where Self::MarkdownHtmlComments(l) => l, Self::MarkdownJsonSchema(l) => l, + Self::MarkdownLinkEip { pattern } => pattern, Self::MarkdownLinkFirst { pattern } => pattern, Self::MarkdownNoBackticks { pattern } => pattern, + Self::MarkdownLinkOther { pattern } => pattern, Self::MarkdownLinkStatus(l) => l, Self::MarkdownProposalRef(l) => l, Self::MarkdownRegex(l) => l, @@ -262,6 +272,12 @@ where .map(|(a, b)| (a.as_ref(), b.as_ref())) .collect(), }), + Self::MarkdownLinkEip { pattern } => DefaultLint::MarkdownLinkEip { + pattern: markdown::LinkEip(pattern.0.as_ref()), + }, + Self::MarkdownLinkOther { pattern } => DefaultLint::MarkdownLinkOther { + pattern: markdown::LinkOther(pattern.0.as_ref()), + }, Self::MarkdownLinkFirst { pattern } => DefaultLint::MarkdownLinkFirst { pattern: markdown::LinkFirst(pattern.0.as_ref()), }, diff --git a/eipw-lint/src/lints/markdown.rs b/eipw-lint/src/lints/markdown.rs index 264329e1..1b3b1182 100644 --- a/eipw-lint/src/lints/markdown.rs +++ b/eipw-lint/src/lints/markdown.rs @@ -7,7 +7,9 @@ pub mod headings_space; pub mod html_comments; pub mod json_schema; +pub mod link_eip; pub mod link_first; +pub mod link_other; pub mod link_status; pub mod no_backticks; pub mod proposal_ref; @@ -19,7 +21,9 @@ 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_eip::LinkEip; pub use self::link_first::LinkFirst; +pub use self::link_other::LinkOther; pub use self::link_status::LinkStatus; pub use self::no_backticks::NoBackticks; pub use self::proposal_ref::ProposalRef; diff --git a/eipw-lint/src/lints/markdown/link_eip.rs b/eipw-lint/src/lints/markdown/link_eip.rs new file mode 100644 index 00000000..98f03e70 --- /dev/null +++ b/eipw-lint/src/lints/markdown/link_eip.rs @@ -0,0 +1,177 @@ +/* + * 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, AnnotationType, Slice, Snippet}; + +use comrak::nodes::{Ast, NodeLink}; + +use crate::lints::{Context, Error, Lint}; +use crate::tree::{self, Next, TraverseExt}; + +use regex::Regex; + +use serde::{Deserialize, Serialize}; + +use std::fmt::{Debug, Display}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LinkEip(pub S); + +impl Lint for LinkEip +where + S: Display + Debug + AsRef, +{ + fn lint<'a>(&self, slug: &'a str, ctx: &Context<'a, '_>) -> Result<(), Error> { + let pattern = self.0.as_ref(); + let re = Regex::new(pattern).map_err(Error::custom)?; + + let mut visitor = Visitor { + ctx, + re, + slug, + link_depth: 0, + text_depth: 0, + current_link: Link { + url: String::new(), + text: String::new(), + }, + }; + ctx.body().traverse().visit(&mut visitor)?; + + Ok(()) + } +} +#[derive(Debug)] +struct Link { + url: String, + text: String, +} + +#[derive(Debug)] +struct Visitor<'a, 'b, 'c> { + ctx: &'c Context<'a, 'b>, + re: Regex, + slug: &'c str, + link_depth: usize, + text_depth: usize, + current_link: Link, +} + +impl<'a, 'b, 'c> Visitor<'a, 'b, 'c> { + fn extract_capture(&self, text: &str, re: &Regex, index: usize) -> Result { + if let Some(captures) = re.captures(text) { + Ok(captures + .get(index) + .map(|m| m.as_str().to_string()) + .unwrap_or_default()) + } else { + Ok(String::new()) + } + } + + fn transform_section_description(description: &str) -> String { + let re = Regex::new(r"[-_]").unwrap(); + let mut description = re.replace_all(description, " ").to_string(); + if let Some(first_char) = description.get_mut(0..1) { + first_char.make_ascii_uppercase(); + } + description + } + + fn check(&self, ast: &Ast) -> Result { + let url_eip_text = self.extract_capture(&self.current_link.url, &self.re, 1)?; + let url_eip_number = self.extract_capture(&self.current_link.url, &self.re, 2)?; + let url_section = self.extract_capture(&self.current_link.url, &self.re, 4)?; + + let dynamic_pattern = if url_section != "" { + format!(r"^(EIP|ERC)-{}(\s*\S+)", regex::escape(&url_eip_number)) + } else { + format!(r"^(EIP|ERC)-{}$", regex::escape(&url_eip_number)) + }; + let text_re = Regex::new(&dynamic_pattern).map_err(Error::custom)?; + + if text_re.is_match(&self.current_link.text) && self.text_depth <= 1 { + return Ok(Next::TraverseChildren); + }; + + let expected = if url_section != "" { + let section_description = Visitor::transform_section_description(&url_section); + format!( + "[{}{}: {}]({})", + url_eip_text.to_uppercase(), + url_eip_number, + section_description, + &self.current_link.url + ) + } else { + format!( + "[{}{}]({})", + url_eip_text.to_uppercase(), + url_eip_number, + &self.current_link.url + ) + }; + + let footer_label = format!("use `{}` instead", expected); + + let source = self + .ctx + .source_for_text(ast.sourcepos.start.line, &self.current_link.text); + self.ctx.report(Snippet { + title: Some(Annotation { + annotation_type: self.ctx.annotation_type(), + id: Some(self.slug), + label: Some("link text does not match link destination"), + }), + slices: vec![Slice { + fold: false, + line_start: ast.sourcepos.start.line, + origin: self.ctx.origin(), + source: &source, + annotations: vec![], + }], + footer: vec![Annotation { + id: None, + annotation_type: AnnotationType::Help, + label: Some(&footer_label), + }], + opt: Default::default(), + })?; + + Ok(Next::TraverseChildren) + } +} + +impl<'a, 'b, 'c> tree::Visitor for Visitor<'a, 'b, 'c> { + type Error = Error; + + fn enter_link(&mut self, _: &Ast, link: &NodeLink) -> Result { + if self.re.is_match(&link.url) { + self.current_link = Link { + url: link.url.to_owned(), + text: String::new(), + }; + self.link_depth += 1; + } + Ok(Next::TraverseChildren) + } + + fn depart_link(&mut self, ast: &Ast, _: &NodeLink) -> Result<(), Self::Error> { + if self.link_depth > 0 { + self.check(ast)?; + self.link_depth -= 1; + } + Ok(()) + } + + fn enter_text(&mut self, ast: &Ast, txt: &str) -> Result { + if self.link_depth > 0 { + self.text_depth += 1; + self.current_link.text.push_str(txt); + } + Ok(Next::SkipChildren) + } +} diff --git a/eipw-lint/src/lints/markdown/link_other.rs b/eipw-lint/src/lints/markdown/link_other.rs new file mode 100644 index 00000000..56c78bf1 --- /dev/null +++ b/eipw-lint/src/lints/markdown/link_other.rs @@ -0,0 +1,149 @@ +/* + * 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, AnnotationType, Slice, Snippet}; + +use comrak::nodes::{Ast, NodeLink}; + +use crate::lints::{Context, Error, Lint}; +use crate::tree::{self, Next, TraverseExt}; + +use regex::Regex; + +use serde::{Deserialize, Serialize}; + +use std::fmt::{Debug, Display}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LinkOther(pub S); + +impl Lint for LinkOther +where + S: Display + Debug + AsRef, +{ + fn lint<'a>(&self, slug: &'a str, ctx: &Context<'a, '_>) -> Result<(), Error> { + let pattern = self.0.as_ref(); + let re = Regex::new(pattern).map_err(Error::custom)?; + + let mut visitor = Visitor { + ctx, + re, + slug, + link_depth: 0, + current_link: Link { + url: String::new(), + text: String::new(), + }, + }; + ctx.body().traverse().visit(&mut visitor)?; + + Ok(()) + } +} +#[derive(Debug)] +struct Link { + url: String, + text: String, +} + +#[derive(Debug)] +struct Visitor<'a, 'b, 'c> { + ctx: &'c Context<'a, 'b>, + re: Regex, + slug: &'c str, + link_depth: usize, + current_link: Link, +} + +impl<'a, 'b, 'c> Visitor<'a, 'b, 'c> { + fn extract_capture(&self, text: &str, re: &Regex, index: usize) -> Result { + if let Some(captures) = re.captures(text) { + Ok(captures + .get(index) + .map(|m| m.as_str().to_string()) + .unwrap_or_default()) + } else { + Ok(String::new()) + } + } + + fn check(&self, ast: &Ast) -> Result { + let text_eip_full = self.extract_capture(&self.current_link.text, &self.re, 1)?; + let text_eip_number = self.extract_capture(&self.current_link.text, &self.re, 2)?; + + let dynamic_pattern = format!(r"(?i)\beip-{}\b", regex::escape(&text_eip_number)); + let url_re = Regex::new(&dynamic_pattern).map_err(Error::custom)?; + + if url_re.is_match(&self.current_link.url) { + return Ok(Next::TraverseChildren); + } + + let expected = format!( + "[{}](./{}.md)", + text_eip_full.to_uppercase(), + text_eip_full.to_lowercase() + ); + let footer_label = format!( + "the link destination should target {}, for example `{}`", + text_eip_full.to_uppercase(), + expected + ); + + let source = self + .ctx + .source_for_text(ast.sourcepos.start.line, &self.current_link.text); + self.ctx.report(Snippet { + title: Some(Annotation { + annotation_type: self.ctx.annotation_type(), + id: Some(self.slug), + label: Some("link text does not match link destination"), + }), + slices: vec![Slice { + fold: false, + line_start: ast.sourcepos.start.line, + origin: self.ctx.origin(), + source: &source, + annotations: vec![], + }], + footer: vec![Annotation { + id: None, + annotation_type: AnnotationType::Help, + label: Some(&footer_label), + }], + opt: Default::default(), + })?; + + Ok(Next::TraverseChildren) + } +} + +impl<'a, 'b, 'c> tree::Visitor for Visitor<'a, 'b, 'c> { + type Error = Error; + + fn enter_link(&mut self, _: &Ast, link: &NodeLink) -> Result { + self.current_link = Link { + url: link.url.to_owned(), + text: String::new(), + }; + self.link_depth += 1; + Ok(Next::TraverseChildren) + } + + fn depart_link(&mut self, _: &Ast, _: &NodeLink) -> Result<(), Self::Error> { + if self.link_depth > 0 { + self.link_depth -= 1; + } + Ok(()) + } + + fn enter_text(&mut self, ast: &Ast, txt: &str) -> Result { + if self.link_depth > 0 && self.re.is_match(&txt) { + self.current_link.text = txt.to_owned(); + self.check(ast)?; + } + Ok(Next::TraverseChildren) + } +} diff --git a/eipw-lint/tests/eipv/markdown-link-too-unstable/expected.txt b/eipw-lint/tests/eipv/markdown-link-too-unstable/expected.txt index 3a8efd4f..1d9be445 100644 --- a/eipw-lint/tests/eipv/markdown-link-too-unstable/expected.txt +++ b/eipw-lint/tests/eipv/markdown-link-too-unstable/expected.txt @@ -7,6 +7,6 @@ error[markdown-link-status]: proposal `eip-20.md` is not stable enough for a `st error[markdown-link-status]: proposal `eip-2048.md` is not stable enough for a `status` of `Last Call` --> input.md | -21 | This is the specification for the EIP, [for some reason](./eip-2048.md). +21 | This is the specification for the EIP, [EIP-2048](./eip-2048.md). | = help: because of this link, this proposal's `status` must be one of: `Draft`, `Stagnant` diff --git a/eipw-lint/tests/eipv/markdown-link-too-unstable/input.md b/eipw-lint/tests/eipv/markdown-link-too-unstable/input.md index f4cd0ea3..05968262 100644 --- a/eipw-lint/tests/eipv/markdown-link-too-unstable/input.md +++ b/eipw-lint/tests/eipv/markdown-link-too-unstable/input.md @@ -18,7 +18,7 @@ This is the abstract for the EIP which needs [EIP-20](./eip-20.md). This is the motivation for the EIP, which links to [EIP-1337](./eip-1337.md). ## Specification -This is the specification for the EIP, [for some reason](./eip-2048.md). +This is the specification for the EIP, [EIP-2048](./eip-2048.md). ## Rationale This is the rationale for the EIP. diff --git a/eipw-lint/tests/eipv/markdown-unlinked-eip/input.md b/eipw-lint/tests/eipv/markdown-unlinked-eip/input.md index 54d972d6..91e46dc5 100644 --- a/eipw-lint/tests/eipv/markdown-unlinked-eip/input.md +++ b/eipw-lint/tests/eipv/markdown-unlinked-eip/input.md @@ -19,7 +19,7 @@ This is also the abstract for the EIP, which extends ERC-1236. ## Motivation This is the motivation for the EIP, which is separate from [EIP-1235](./eip-1235.md). -This is also the abstract for the EIP, which extends [ERC-1237](./eip-1236.md). +This is also the abstract for the EIP, which extends [ERC-1237](./eip-1237.md). ## Specification This is the specification for the EIP, mentioning EIP-1235 and ERC-1237 again. diff --git a/eipw-lint/tests/lint_markdown_link_eip.rs b/eipw-lint/tests/lint_markdown_link_eip.rs new file mode 100644 index 00000000..7cc05bc1 --- /dev/null +++ b/eipw-lint/tests/lint_markdown_link_eip.rs @@ -0,0 +1,389 @@ +/* + * 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::LinkEip; +use eipw_lint::reporters::Text; +use eipw_lint::Linter; + +#[tokio::test] +async fn eip_number_mismatch() { + let src = r#"--- +header: value1 +--- +[EIP-1](./eip-2.md) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1](./eip-2.md) + | + = help: use `[EIP-2](./eip-2.md)` instead +"# + ); +} + +#[tokio::test] +async fn link_eip_has_no_section() { + let src = r#"--- +header: value1 +--- +[EIP-1: Foo](./eip-1.md) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1: Foo](./eip-1.md) + | + = help: use `[EIP-1](./eip-1.md)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_missing_eip() { + let src = r#"--- +header: value1 +--- +[Another Proposal](./eip-1.md) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#.+)?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [Another Proposal](./eip-1.md) + | + = help: use `[EIP-1](./eip-1.md)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_missing_section_description() { + let src = r#"--- +header: value1 +--- +[EIP-1](./eip-1.md#motivation) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1](./eip-1.md#motivation) + | + = help: use `[EIP-1: Motivation](./eip-1.md#motivation)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_with_bold() { + let src = r#"--- +header: value1 +--- +[EIP-1**EIP-1**](./eip-1.md) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!(reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1**EIP-1**](./eip-1.md) + | + = help: use `[EIP-1](./eip-1.md)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_extended_section_description_with_bold() { + let src = r#"--- +header: value1 +--- +[EIP-1: eip motivation**EIP-1: eip motivation**](./eip-1.md#eip-motivation) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!(reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1: eip motivation**EIP-1: eip motivation**](./eip-1.md#eip-motivation) + | + = help: use `[EIP-1: Eip motivation](./eip-1.md#eip-motivation)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_missing_extended_section_description_with_hyphen() { + let src = r#"--- +header: value1 +--- +[EIP-1](./eip-1.md#eip-motivation) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1](./eip-1.md#eip-motivation) + | + = help: use `[EIP-1: Eip motivation](./eip-1.md#eip-motivation)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_missing_extended_section_description_with_underscore() { + let src = r#"--- +header: value1 +--- +[EIP-1](./eip-1.md#eip_motivation) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-1](./eip-1.md#eip_motivation) + | + = help: use `[EIP-1: Eip motivation](./eip-1.md#eip_motivation)` instead +"# + ); +} + +#[tokio::test] +async fn eip_number_mismatch_with_section() { + let src = r#"--- +header: value1 +--- +[EIP-2: Hello](./eip-1.md#abstract) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-2: Hello](./eip-1.md#abstract) + | + = help: use `[EIP-1: Abstract](./eip-1.md#abstract)` instead +"# + ); +} + +#[tokio::test] +async fn eip_number_mismatch_extended_section_description_with_hyphen() { + let src = r#"--- +header: value1 +--- +[EIP-2: Hello](./eip-1.md#hello-abstract) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-2: Hello](./eip-1.md#hello-abstract) + | + = help: use `[EIP-1: Hello abstract](./eip-1.md#hello-abstract)` instead +"# + ); +} + +#[tokio::test] +async fn eip_number_mismatch_extended_section_description_with_underscore() { + let src = r#"--- +header: value1 +--- +[EIP-2: Hello](./eip-1.md#hello_abstract) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [EIP-2: Hello](./eip-1.md#hello_abstract) + | + = help: use `[EIP-1: Hello abstract](./eip-1.md#hello_abstract)` instead +"# + ); +} + +#[tokio::test] +async fn link_text_missing_eip_with_section() { + let src = r#"--- +header: value1 +--- +[Another Proposal](./eip-1.md#rationale) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-eip]: link text does not match link destination + | +4 | [Another Proposal](./eip-1.md#rationale) + | + = help: use `[EIP-1: Rationale](./eip-1.md#rationale)` instead +"# + ); +} + +#[tokio::test] +async fn should_be_ignored() { + let src = r#"--- +header: value1 +--- +[EIP-721's Motivation](./eip-721.md#motivation) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-eip", + LinkEip(r"(eip-)([^.]*)\.md(#(.+))?$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!(reports, ""); +} diff --git a/eipw-lint/tests/lint_markdown_link_other.rs b/eipw-lint/tests/lint_markdown_link_other.rs new file mode 100644 index 00000000..b19d4226 --- /dev/null +++ b/eipw-lint/tests/lint_markdown_link_other.rs @@ -0,0 +1,121 @@ +/* + * 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::LinkOther; +use eipw_lint::reporters::Text; +use eipw_lint::Linter; + +#[tokio::test] +async fn link_destination_missing_eip() { + let src = r#"--- +header: value1 +--- +[EIP-2](../assets/foo.txt) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-other", + LinkOther(r"(?i)^((?:EIP|ERC)-(\d+)).*$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-other]: link text does not match link destination + | +4 | [EIP-2](../assets/foo.txt) + | + = help: the link destination should target EIP-2, for example `[EIP-2](./eip-2.md)` +"# + ); +} + +#[tokio::test] +async fn link_destination_missing_eip_advanced_text() { + let src = r#"--- +header: value1 +--- +[EIP-2: Foo](../assets/foo.txt) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-other", + LinkOther(r"(?i)^((?:EIP|ERC)-(\d+)).*$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-other]: link text does not match link destination + | +4 | [EIP-2: Foo](../assets/foo.txt) + | + = help: the link destination should target EIP-2, for example `[EIP-2](./eip-2.md)` +"# + ); +} + +#[tokio::test] +async fn link_eip_number_differs_from_text() { + let src = r#"--- +header: value1 +--- +[EIP-1](../assets/eip-2/foo.txt) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-other", + LinkOther(r"(?i)^((?:EIP|ERC)-(\d+)).*$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!( + reports, + r#"error[markdown-link-other]: link text does not match link destination + | +4 | [EIP-1](../assets/eip-2/foo.txt) + | + = help: the link destination should target EIP-1, for example `[EIP-1](./eip-1.md)` +"# + ); +} + +#[tokio::test] +async fn should_be_ignored() { + let src = r#"--- +header: value1 +--- +[EIP-2](../assets/eip-2/foo.txt) +"#; + + let reports = Linter::>::default() + .clear_lints() + .deny( + "markdown-link-other", + LinkOther(r"(?i)^((?:EIP|ERC)-(\d+)).*$".to_string()), + ) + .check_slice(None, src) + .run() + .await + .unwrap() + .into_inner(); + assert_eq!(reports, ""); +}