fmt_test.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. package md_test
  2. import (
  3. "fmt"
  4. "html"
  5. "regexp"
  6. "strings"
  7. "testing"
  8. "unicode/utf8"
  9. "github.com/google/go-cmp/cmp"
  10. . "src.elv.sh/pkg/md"
  11. "src.elv.sh/pkg/testutil"
  12. "src.elv.sh/pkg/wcwidth"
  13. )
  14. var supplementalFmtCases = []testCase{
  15. {
  16. Section: "Fenced code blocks",
  17. Name: "Tilde fence with info starting with tilde",
  18. Markdown: "~~~ ~`\n" + "~~~",
  19. },
  20. {
  21. Section: "Emphasis and strong emphasis",
  22. Name: "Space at start of content",
  23. Markdown: "* x*",
  24. },
  25. {
  26. Section: "Emphasis and strong emphasis",
  27. Name: "Space at end of content",
  28. Markdown: "*x *",
  29. },
  30. {
  31. Section: "Emphasis and strong emphasis",
  32. Name: "Emphasis opener after word before punctuation",
  33. Markdown: "A*!*",
  34. },
  35. {
  36. Section: "Emphasis and strong emphasis",
  37. Name: "Emphasis closer after punctuation before word",
  38. Markdown: "*!*A",
  39. },
  40. {
  41. Section: "Emphasis and strong emphasis",
  42. Name: "Space-only content",
  43. Markdown: "* *",
  44. },
  45. {
  46. Section: "Links",
  47. Name: "Exclamation mark before link",
  48. Markdown: `\![a](b)`,
  49. },
  50. {
  51. Section: "Links",
  52. Name: "Link title with both single and double quotes",
  53. Markdown: `[a](b ('"))`,
  54. },
  55. {
  56. Section: "Links",
  57. Name: "Link title with fewer double quotes than single quotes and parens",
  58. Markdown: `[a](b "\"''()")`,
  59. },
  60. {
  61. Section: "Links",
  62. Name: "Link title with fewer single quotes than double quotes and parens",
  63. Markdown: `[a](b '\'""()')`,
  64. },
  65. {
  66. Section: "Links",
  67. Name: "Link title with fewer parens than single and double quotes",
  68. Markdown: `[a](b (\(''""))`,
  69. },
  70. {
  71. Section: "Links",
  72. Name: "Newline in link destination",
  73. Markdown: `[a](<&NewLine;>)`,
  74. },
  75. {
  76. Section: "Soft line breaks",
  77. Name: "Space at start of line",
  78. Markdown: "&#32;foo",
  79. },
  80. {
  81. Section: "Soft line breaks",
  82. Name: "Space at end of line",
  83. Markdown: "foo&#32;",
  84. },
  85. }
  86. var fmtTestCases = concat(htmlTestCases, supplementalFmtCases)
  87. func TestFmtPreservesHTMLRender(t *testing.T) {
  88. testutil.Set(t, &UnescapeHTML, html.UnescapeString)
  89. for _, tc := range fmtTestCases {
  90. t.Run(tc.testName(), func(t *testing.T) {
  91. testFmtPreservesHTMLRender(t, tc.Markdown)
  92. })
  93. }
  94. }
  95. func FuzzFmtPreservesHTMLRender(f *testing.F) {
  96. for _, tc := range fmtTestCases {
  97. f.Add(tc.Markdown)
  98. }
  99. f.Fuzz(testFmtPreservesHTMLRender)
  100. }
  101. func testFmtPreservesHTMLRender(t *testing.T, original string) {
  102. testFmtPreservesHTMLRenderModulo(t, original, 0, nil)
  103. }
  104. func TestReflowFmtPreservesHTMLRenderModuleWhitespaces(t *testing.T) {
  105. testReflowFmt(t, testReflowFmtPreservesHTMLRenderModuloWhitespaces)
  106. }
  107. func FuzzReflowFmtPreservesHTMLRenderModuleWhitespaces(f *testing.F) {
  108. fuzzReflowFmt(f, testReflowFmtPreservesHTMLRenderModuloWhitespaces)
  109. }
  110. var (
  111. paragraph = regexp.MustCompile(`(?s)<p>.*?</p>`)
  112. whitespaceRun = regexp.MustCompile(`[ \t\n]+`)
  113. brWithWhitespaces = regexp.MustCompile(`[ \t\n]*<br />[ \t\n]*`)
  114. )
  115. func testReflowFmtPreservesHTMLRenderModuloWhitespaces(t *testing.T, original string, w int) {
  116. if strings.Contains(original, "<p>") {
  117. t.Skip("markdown contains <p>")
  118. }
  119. if strings.Contains(original, "</p>") {
  120. t.Skip("markdown contains </p>")
  121. }
  122. testFmtPreservesHTMLRenderModulo(t, original, w, func(html string) string {
  123. // Coalesce whitespaces in each paragraph.
  124. return paragraph.ReplaceAllStringFunc(html, func(p string) string {
  125. body := strings.Trim(p[3:len(p)-4], " \t\n")
  126. // Convert each whitespace run to a single space.
  127. body = whitespaceRun.ReplaceAllLiteralString(body, " ")
  128. // Remove whitespaces around <br />.
  129. body = brWithWhitespaces.ReplaceAllLiteralString(body, "<br />")
  130. return "<p>" + body + "</p>"
  131. })
  132. })
  133. }
  134. func TestReflowFmtResultIsUnchangedUnderFmt(t *testing.T) {
  135. testReflowFmt(t, testReflowFmtResultIsUnchangedUnderFmt)
  136. }
  137. func FuzzReflowFmtResultIsUnchangedUnderFmt(f *testing.F) {
  138. fuzzReflowFmt(f, testReflowFmtResultIsUnchangedUnderFmt)
  139. }
  140. func testReflowFmtResultIsUnchangedUnderFmt(t *testing.T, original string, w int) {
  141. reflowed := formatAndSkipIfUnsupported(t, original, w)
  142. formatted := RenderString(reflowed, &FmtCodec{})
  143. if reflowed != formatted {
  144. t.Errorf("original:\n%s\nreflowed:\n%s\nformatted:\n%s"+
  145. "markdown diff (-reflowed +formatted):\n%s",
  146. hr+"\n"+original+hr, hr+"\n"+reflowed+hr, hr+"\n"+formatted+hr,
  147. cmp.Diff(reflowed, formatted))
  148. }
  149. }
  150. func TestReflowFmtResultFitsInWidth(t *testing.T) {
  151. testReflowFmt(t, testReflowFmtResultFitsInWidth)
  152. }
  153. func FuzzReflowFmtResultFitsInWidth(f *testing.F) {
  154. fuzzReflowFmt(f, testReflowFmtResultFitsInWidth)
  155. }
  156. var (
  157. // Match all markers that can be written by FmtCodec.
  158. markersRegexp = regexp.MustCompile(`^ *(?:(?:[-*>]|[0-9]{1,9}[.)]) *)*`)
  159. linkRegexp = regexp.MustCompile(`\[.*\]\(.*\)`)
  160. codeSpanRegexp = regexp.MustCompile("`.*`")
  161. )
  162. func testReflowFmtResultFitsInWidth(t *testing.T, original string, w int) {
  163. if w <= 0 {
  164. t.Skip("width <= 0")
  165. }
  166. var trace TraceCodec
  167. Render(original, &trace)
  168. for _, op := range trace.Ops() {
  169. switch op.Type {
  170. case OpHeading, OpCodeBlock, OpHTMLBlock:
  171. t.Skipf("input contains unsupported block type %s", op.Type)
  172. }
  173. }
  174. reflowed := formatAndSkipIfUnsupported(t, original, w)
  175. for _, line := range strings.Split(reflowed, "\n") {
  176. lineWidth := wcwidth.Of(line)
  177. if lineWidth <= w {
  178. continue
  179. }
  180. // Strip all markers
  181. content := line[len(markersRegexp.FindString(line)):]
  182. // Analyze whether the content is allowed to exceed width
  183. switch {
  184. case !strings.Contains(content, " "):
  185. case strings.Contains(content, "<"):
  186. case linkRegexp.MatchString(content):
  187. case codeSpanRegexp.MatchString(content):
  188. default:
  189. t.Errorf("line length > %d: %q\nfull reflowed:\n%s",
  190. w, line, hr+"\n"+reflowed+hr)
  191. }
  192. }
  193. }
  194. var widths = []int{20, 51, 80}
  195. func testReflowFmt(t *testing.T, test func(*testing.T, string, int)) {
  196. for _, tc := range fmtTestCases {
  197. for _, w := range widths {
  198. t.Run(fmt.Sprintf("%s/Width %d", tc.testName(), w), func(t *testing.T) {
  199. test(t, tc.Markdown, w)
  200. })
  201. }
  202. }
  203. }
  204. func fuzzReflowFmt(f *testing.F, test func(*testing.T, string, int)) {
  205. for _, tc := range fmtTestCases {
  206. for _, w := range widths {
  207. f.Add(tc.Markdown, w)
  208. }
  209. }
  210. f.Fuzz(test)
  211. }
  212. func testFmtPreservesHTMLRenderModulo(t *testing.T, original string, w int, processHTML func(string) string) {
  213. formatted := formatAndSkipIfUnsupported(t, original, w)
  214. originalRender := RenderString(original, &HTMLCodec{})
  215. formattedRender := RenderString(formatted, &HTMLCodec{})
  216. if processHTML != nil {
  217. originalRender = processHTML(originalRender)
  218. formattedRender = processHTML(formattedRender)
  219. }
  220. if formattedRender != originalRender {
  221. t.Errorf("original:\n%s\nformatted:\n%s\n"+
  222. "markdown diff (-original +formatted):\n%s"+
  223. "HTML diff (-original +formatted):\n%s"+
  224. "ops diff (-original +formatted):\n%s",
  225. hr+"\n"+original+hr, hr+"\n"+formatted+hr,
  226. cmp.Diff(original, formatted),
  227. cmp.Diff(originalRender, formattedRender),
  228. cmp.Diff(RenderString(original, &TraceCodec{}), RenderString(formatted, &TraceCodec{})))
  229. }
  230. }
  231. func formatAndSkipIfUnsupported(t *testing.T, original string, w int) string {
  232. if !utf8.ValidString(original) {
  233. t.Skipf("input is not valid UTF-8")
  234. }
  235. if strings.Contains(original, "\t") {
  236. t.Skipf("input contains tab")
  237. }
  238. codec := &FmtCodec{Width: w}
  239. formatted := RenderString(original, codec)
  240. if u := codec.Unsupported(); u != nil {
  241. t.Skipf("input uses unsupported feature: %v", u)
  242. }
  243. return formatted
  244. }