Skip to content

Commit beef9c5

Browse files
committed
Add markdown crate which hand rolls markdown formatting in rustfmt
1 parent 1adcbf1 commit beef9c5

File tree

17 files changed

+23868
-0
lines changed

17 files changed

+23868
-0
lines changed

markdown/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
target/

markdown/Cargo.lock

Lines changed: 158 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

markdown/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "markdown"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]
9+
itertools = "0.10"
10+
pulldown-cmark = { version = "0.9.3", default-features = false }
11+
unicode-width = "0.1"
12+
unicode-segmentation = "1.9"
13+
14+
[features]
15+
gen-tests = []
16+
17+
[build-dependencies]
18+
serde = { version = "1.0.160", features = ["derive"] }
19+
serde_json = "1.0"

markdown/build.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
fn main() {
2+
generate_tests_markdown_tests().unwrap()
3+
}
4+
5+
#[cfg(not(feature = "gen-tests"))]
6+
fn generate_tests_markdown_tests() -> std::io::Result<()> {
7+
Ok(())
8+
}
9+
10+
#[cfg(feature = "gen-tests")]
11+
fn generate_tests_markdown_tests() -> std::io::Result<()> {
12+
use std::fs::File;
13+
use std::io::BufWriter;
14+
use std::path::PathBuf;
15+
16+
let spec_folder = "./tests/spec/";
17+
let test_folder = "./tests/";
18+
19+
let spec_files = [
20+
(
21+
"",
22+
"commonmark_v0_30_spec.json",
23+
"https://spec.commonmark.org/0.30/",
24+
),
25+
("gfm_", "gfm_spec.json", "https://github.github.com/gfm/"),
26+
];
27+
28+
for (prefix, spec, url) in spec_files {
29+
let input_file = format!("{spec_folder}{spec}");
30+
let mut output_file = PathBuf::from(format!("{test_folder}{spec}"));
31+
output_file.set_extension("rs");
32+
33+
let test_cases: Vec<TestCase<'_>> = serde_json::from_reader(File::open(&input_file)?)?;
34+
let mut output = BufWriter::new(File::create(&output_file)?);
35+
36+
write_test_cases(&mut output, prefix, test_cases, url)
37+
.expect("generated test case successfully");
38+
}
39+
40+
Ok(())
41+
}
42+
43+
#[cfg(feature = "gen-tests")]
44+
#[derive(Debug, serde::Deserialize)]
45+
struct TestCase<'a> {
46+
#[serde(rename(deserialize = "markdown"))]
47+
input: std::borrow::Cow<'a, str>,
48+
#[serde(rename(deserialize = "formattedMarkdown"))]
49+
output: Option<std::borrow::Cow<'a, str>>,
50+
#[serde(rename(deserialize = "example"))]
51+
id: usize,
52+
section: std::borrow::Cow<'a, str>,
53+
#[serde(default)]
54+
skip: bool,
55+
#[serde(default = "default_test", rename(deserialize = "testMacro"))]
56+
test_macro: std::borrow::Cow<'a, str>,
57+
comment: Option<std::borrow::Cow<'a, str>>,
58+
}
59+
60+
#[cfg(feature = "gen-tests")]
61+
fn default_test() -> std::borrow::Cow<'static, str> {
62+
// Name of the test macro to use
63+
"test_identical_markdown_events".into()
64+
}
65+
66+
#[cfg(feature = "gen-tests")]
67+
fn write_test_cases<W>(
68+
writer: &mut W,
69+
prefix: &str,
70+
test_cases: Vec<TestCase<'_>>,
71+
url: &str,
72+
) -> std::io::Result<()>
73+
where
74+
W: std::io::Write,
75+
{
76+
write!(writer, "// @generated\n")?;
77+
write!(writer, "// generated running `cargo build -F gen-tests`\n")?;
78+
write!(
79+
writer,
80+
"// test macros are defined in tests/common/mod.rs\n"
81+
)?;
82+
write!(writer, "mod common;\n")?;
83+
84+
for test_case in test_cases.into_iter() {
85+
write_test_case(writer, prefix, test_case, url)?;
86+
}
87+
Ok(())
88+
}
89+
90+
#[cfg(feature = "gen-tests")]
91+
fn write_test_case<W: std::io::Write>(
92+
writer: &mut W,
93+
prefix: &str,
94+
test_case: TestCase<'_>,
95+
url: &str,
96+
) -> std::io::Result<()> {
97+
let url = if url.ends_with("/") {
98+
format!("{}#example-{}", url, test_case.id)
99+
} else {
100+
format!("{}/#example-1{}", url, test_case.id)
101+
};
102+
103+
let replace_tab_chars = test_case.input.replace('→', "\t");
104+
let input = replace_tab_chars.trim_end_matches('\n');
105+
106+
if let Some(comment) = test_case.comment {
107+
write!(writer, "\n// {comment}")?;
108+
}
109+
110+
if test_case.skip {
111+
write!(writer, "\n#[ignore]")?;
112+
}
113+
114+
write!(
115+
writer,
116+
r##"
117+
#[test]
118+
fn {}markdown_{}_{}() {{
119+
// {}
120+
{}!("##,
121+
prefix,
122+
test_case
123+
.section
124+
.to_lowercase()
125+
.replace(char::is_whitespace, "_")
126+
.replace("(", "")
127+
.replace(")", ""),
128+
test_case.id,
129+
url,
130+
test_case.test_macro,
131+
)?;
132+
133+
let has_trailing_whitespace = input.lines().any(|l| l.ends_with(char::is_whitespace));
134+
if has_trailing_whitespace {
135+
write!(writer, "{:?}", input)?;
136+
} else {
137+
write!(writer, "r##\"{}\"##", input)?;
138+
}
139+
if let Some(expected_output) = test_case.output {
140+
let has_trailing_whitespace = expected_output
141+
.lines()
142+
.any(|l| l.ends_with(char::is_whitespace));
143+
if has_trailing_whitespace {
144+
write!(writer, ",{:?}", expected_output)?;
145+
} else {
146+
write!(writer, ",r##\"{}\"##", expected_output)?;
147+
}
148+
}
149+
write!(writer, ");")?;
150+
write!(writer, "\n}}\n")?;
151+
Ok(())
152+
}

markdown/rustfmt.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
error_on_line_overflow = true
2+
error_on_unformatted = true
3+
format_generated_files = false
4+
version = "Two"

markdown/src/escape.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use super::MarkdownFormatter;
2+
3+
const ATX_HEADER_ESCAPES: [&'static str; 6] = ["# ", "## ", "### ", "#### ", "##### ", "###### "];
4+
5+
impl<'i, F> MarkdownFormatter<'i, F> {
6+
pub(super) fn needs_escape(&mut self, input: &str) -> bool {
7+
if !self.last_was_softbreak {
8+
// We _should_ only need to escape after a softbreak since the markdown formatter will
9+
// adjust the indentation. Depending on the context we'll either remove leading spaces
10+
// or add indentation (spaces or '>') depending on if we're in a list or blockquote.
11+
// See <https://spec.commonmark.org/0.30/#example-70> as an example where the semantics
12+
// would change without an escape after removing indentation.
13+
return false;
14+
}
15+
16+
self.last_was_softbreak = false;
17+
18+
if input.len() <= 2 {
19+
return false;
20+
}
21+
22+
let Some(first_char) = input.chars().next() else {
23+
return false;
24+
};
25+
26+
let is_setext_heading = |value: u8| input.trim_end().bytes().all(|b| b == value);
27+
let is_unordered_list_marker = |value: &str| input.starts_with(value);
28+
let is_thematic_break = |value: u8| input.bytes().all(|b| b == value || b == b' ');
29+
30+
match first_char {
31+
'#' => ATX_HEADER_ESCAPES
32+
.iter()
33+
.any(|header| input.starts_with(header)),
34+
'=' => is_setext_heading(b'='),
35+
'-' => {
36+
is_unordered_list_marker("- ") || is_setext_heading(b'-') || is_thematic_break(b'-')
37+
}
38+
'_' => is_thematic_break(b'_'),
39+
'*' => is_unordered_list_marker("* ") || is_thematic_break(b'*'),
40+
'+' => is_unordered_list_marker("+ "),
41+
'>' => true,
42+
_ => false,
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)