Skip to content

Commit 8a57953

Browse files
authored
Add error handling for unclosed code blocks in Markdown inputs (#1550)
1 parent 49dddff commit 8a57953

File tree

6 files changed

+124
-24
lines changed

6 files changed

+124
-24
lines changed

modules/build/src/main/scala/scala/build/internal/markdown/MarkdownCodeBlock.scala

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package scala.build.internal.markdown
22

33
import scala.annotation.tailrec
4+
import scala.build.Position
5+
import scala.build.errors.{BuildException, MarkdownUnclosedBackticksError}
46
import scala.collection.mutable
57
import scala.jdk.CollectionConverters.*
68

@@ -47,12 +49,20 @@ object MarkdownCodeBlock {
4749

4850
/** Finds all code snippets in given input
4951
*
52+
* @param subPath
53+
* the project [[os.SubPath]] to the Markdown file
5054
* @param md
5155
* Markdown file in a `String` format
56+
* @param maybeRecoverOnError
57+
* function potentially recovering on errors
5258
* @return
5359
* list of all found snippets
5460
*/
55-
def findCodeBlocks(md: String): Seq[MarkdownCodeBlock] = {
61+
def findCodeBlocks(
62+
subPath: os.SubPath,
63+
md: String,
64+
maybeRecoverOnError: BuildException => Option[BuildException]
65+
): Either[BuildException, Seq[MarkdownCodeBlock]] = {
5666
val allLines = md
5767
.lines()
5868
.toList
@@ -63,20 +73,24 @@ object MarkdownCodeBlock {
6373
closedFences: Seq[MarkdownCodeBlock] = Seq.empty,
6474
maybeOpenFence: Option[MarkdownOpenFence] = None,
6575
currentIndex: Int = 0
66-
): Seq[MarkdownCodeBlock] = if lines.isEmpty then closedFences
67-
else {
68-
val currentLine = lines.head
69-
val (newClosedFences, newOpenFence) = maybeOpenFence match {
70-
case None => closedFences -> MarkdownOpenFence.maybeFence(currentLine, currentIndex)
71-
case mof @ Some(openFence) =>
72-
val backticksStart = currentLine.indexOf(openFence.backticks)
73-
if backticksStart == openFence.indent &&
74-
currentLine.forall(c => c == '`' || c.isWhitespace)
75-
then (closedFences :+ openFence.closeFence(currentIndex, allLines.toArray)) -> None
76-
else closedFences -> mof
77-
}
78-
findCodeBlocksRec(lines.tail, newClosedFences, newOpenFence, currentIndex + 1)
76+
): Either[BuildException, Seq[MarkdownCodeBlock]] = lines -> maybeOpenFence match {
77+
case (Seq(currentLine, tail*), mof) =>
78+
val (newClosedFences, newOpenFence) = mof match {
79+
case None => closedFences -> MarkdownOpenFence.maybeFence(currentLine, currentIndex)
80+
case Some(openFence) =>
81+
val backticksStart = currentLine.indexOf(openFence.backticks)
82+
if backticksStart == openFence.indent &&
83+
currentLine.forall(c => c == '`' || c.isWhitespace)
84+
then (closedFences :+ openFence.closeFence(currentIndex, allLines.toArray)) -> None
85+
else closedFences -> Some(openFence)
86+
}
87+
findCodeBlocksRec(tail, newClosedFences, newOpenFence, currentIndex + 1)
88+
case (Nil, Some(openFence)) =>
89+
maybeRecoverOnError(openFence.toUnclosedBackticksError(os.pwd / subPath))
90+
.map(e => Left(e))
91+
.getOrElse(Right(closedFences))
92+
case _ => Right(closedFences)
7993
}
80-
findCodeBlocksRec(allLines.toSeq).filter(!_.shouldIgnore)
94+
findCodeBlocksRec(allLines.toSeq).map(_.filter(!_.shouldIgnore))
8195
}
8296
}

modules/build/src/main/scala/scala/build/internal/markdown/MarkdownCodeWrapper.scala

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package scala.build.internal.markdown
22

33
import scala.annotation.tailrec
4+
import scala.build.EitherCps.{either, value}
5+
import scala.build.errors.BuildException
46
import scala.build.internal.markdown.MarkdownCodeBlock
57
import scala.build.internal.{AmmUtil, Name}
68

@@ -21,12 +23,13 @@ object MarkdownCodeWrapper {
2123
*/
2224
def apply(
2325
subPath: os.SubPath,
24-
content: String
25-
): (Option[String], Option[String], Option[String]) = {
26+
content: String,
27+
maybeRecoverOnError: BuildException => Option[BuildException] = b => Some(b)
28+
): Either[BuildException, (Option[String], Option[String], Option[String])] = either {
2629
val (pkg, wrapper) = AmmUtil.pathToPackageWrapper(subPath)
2730
val maybePkgString =
2831
if pkg.isEmpty then None else Some(s"package ${AmmUtil.encodeScalaSourcePath(pkg)}")
29-
val allSnippets = MarkdownCodeBlock.findCodeBlocks(content)
32+
val allSnippets = value(MarkdownCodeBlock.findCodeBlocks(subPath, content, maybeRecoverOnError))
3033
val (rawSnippets, processedSnippets) = allSnippets.partition(_.isRaw)
3134
val (testSnippets, mainSnippets) = processedSnippets.partition(_.isTest)
3235
val wrapperName = s"${wrapper.raw}_md"

modules/build/src/main/scala/scala/build/internal/markdown/MarkdownOpenFence.scala

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package scala.build.internal.markdown
22

3+
import scala.build.Position
4+
import scala.build.errors.MarkdownUnclosedBackticksError
5+
36
/** Representation for an open code block in Markdown. (open meaning the closing backticks haven't
47
* yet been parsed or they aren't at all present)
58
*
@@ -41,6 +44,21 @@ case class MarkdownOpenFence(
4144
tickEndLine - 1 // ending backticks have to be placed below the snippet
4245
)
4346
}
47+
48+
/** Converts the [[MarkdownOpenFence]] into a [[MarkdownUnclosedBackticksError]]
49+
*
50+
* @param mdPath
51+
* path to the Markdown file
52+
* @return
53+
* a [[MarkdownUnclosedBackticksError]]
54+
*/
55+
def toUnclosedBackticksError(mdPath: os.Path): MarkdownUnclosedBackticksError = {
56+
val startCoordinates = tickStartLine -> indent
57+
val endCoordinates =
58+
tickStartLine -> (indent + backticks.length)
59+
val position = Position.File(Right(mdPath), startCoordinates, endCoordinates)
60+
MarkdownUnclosedBackticksError(backticks, Seq(position))
61+
}
4462
}
4563

4664
object MarkdownOpenFence {

modules/build/src/main/scala/scala/build/preprocessing/MarkdownPreprocessor.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ case object MarkdownPreprocessor extends Preprocessor {
8282
}
8383
}
8484

85-
val (mainScalaCode, rawScalaCode, testScalaCode) = MarkdownCodeWrapper(subPath, content)
85+
val (mainScalaCode, rawScalaCode, testScalaCode) =
86+
value(MarkdownCodeWrapper(subPath, content, maybeRecoverOnError))
8687

8788
val maybeMainFile = value(preprocessSnippets(mainScalaCode, ".scala"))
8889
val maybeRawFile = value(preprocessSnippets(rawScalaCode, ".raw.scala"))

modules/build/src/test/scala/scala/build/tests/markdown/MarkdownCodeWrapperTests.scala

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import scala.build.internal.markdown.MarkdownCodeWrapper
44
import com.eed3si9n.expecty.Expecty.expect
55
import os.RelPath
66

7+
import scala.build.Position
8+
import scala.build.errors.{BuildException, MarkdownUnclosedBackticksError}
79
import scala.build.internal.AmmUtil
810

911
class MarkdownCodeWrapperTests extends munit.FunSuite {
@@ -21,7 +23,7 @@ class MarkdownCodeWrapperTests extends munit.FunSuite {
2123
|Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
2224
|Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
2325
|""".stripMargin
24-
expect(MarkdownCodeWrapper(os.sub / "Example.md", markdown) == (None, None, None))
26+
expect(MarkdownCodeWrapper(os.sub / "Example.md", markdown) == Right((None, None, None)))
2527
}
2628

2729
test("a simple Scala snippet is correctly extracted from markdown") {
@@ -39,7 +41,7 @@ class MarkdownCodeWrapperTests extends munit.FunSuite {
3941
|println("Hello")
4042
|}}""".stripMargin
4143
val result = MarkdownCodeWrapper(os.sub / "Example.md", markdown)
42-
expect(result == (Some(expectedScala), None, None))
44+
expect(result == Right((Some(expectedScala), None, None)))
4345
}
4446

4547
test("a raw Scala snippet is correctly extracted from markdown") {
@@ -61,7 +63,7 @@ class MarkdownCodeWrapperTests extends munit.FunSuite {
6163
|}
6264
|""".stripMargin
6365
val result = MarkdownCodeWrapper(os.sub / "Example.md", markdown)
64-
expect(result == (None, Some(expectedScala), None))
66+
expect(result == Right((None, Some(expectedScala), None)))
6567
}
6668

6769
test("a test Scala snippet is correctly extracted from markdown") {
@@ -85,7 +87,7 @@ class MarkdownCodeWrapperTests extends munit.FunSuite {
8587
|}
8688
|""".stripMargin
8789
val result = MarkdownCodeWrapper(os.sub / "Example.md", markdown)
88-
expect(result == (None, None, Some(expectedScala)))
90+
expect(result == Right((None, None, Some(expectedScala))))
8991
}
9092

9193
test("a Scala snippet is skipped when it's marked as `ignore` in markdown") {
@@ -96,6 +98,56 @@ class MarkdownCodeWrapperTests extends munit.FunSuite {
9698
|println("Hello")
9799
|```
98100
|""".stripMargin
99-
expect(MarkdownCodeWrapper(os.sub / "Example.md", markdown) == (None, None, None))
101+
expect(MarkdownCodeWrapper(os.sub / "Example.md", markdown) == Right((None, None, None)))
102+
}
103+
104+
test("an unclosed snippet produces a build error") {
105+
val markdown =
106+
"""# Some snippet
107+
|
108+
|```scala
109+
|println("Hello")
110+
|""".stripMargin
111+
val subPath = os.sub / "Example.md"
112+
val expectedPosition = Position.File(Right(os.pwd / subPath), 2 -> 0, 2 -> 3)
113+
val expectedError = MarkdownUnclosedBackticksError("```", Seq(expectedPosition))
114+
val Left(result) = MarkdownCodeWrapper(subPath, markdown)
115+
expect(result.message == expectedError.message)
116+
expect(result.positions == expectedError.positions)
117+
}
118+
119+
test("recovery from an unclosed snippet error works correctly") {
120+
val markdown =
121+
"""# Some snippet
122+
|```scala
123+
|println("closed snippet")
124+
|```
125+
|
126+
|# Some other snippet
127+
|
128+
|````scala
129+
|println("unclosed snippet")
130+
|
131+
|```scala
132+
|println("whatever")
133+
|```
134+
|""".stripMargin
135+
val subPath = os.sub / "Example.md"
136+
var actualError: Option[BuildException] = None
137+
val recoveryFunction = (be: BuildException) => {
138+
actualError = Some(be)
139+
None
140+
}
141+
val Right(result) =
142+
MarkdownCodeWrapper(subPath, markdown, maybeRecoverOnError = recoveryFunction)
143+
val expectedScala =
144+
"""object Example_md { @annotation.nowarn("msg=pure expression does nothing") def main(args: Array[String]): Unit = { Scope; }
145+
|object Scope {
146+
|println("closed snippet")
147+
|}}""".stripMargin
148+
expect(result == (Some(expectedScala), None, None))
149+
val expectedPosition = Position.File(Right(os.pwd / subPath), 7 -> 0, 7 -> 4)
150+
val expectedError = MarkdownUnclosedBackticksError("````", Seq(expectedPosition))
151+
expect(actualError.get.positions == expectedError.positions)
100152
}
101153
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package scala.build.errors
2+
import scala.build.Position
3+
4+
class MarkdownUnclosedBackticksError(
5+
backticks: String,
6+
positions: Seq[Position]
7+
) extends BuildException(s"Unclosed $backticks code block in a Markdown input", positions)
8+
9+
object MarkdownUnclosedBackticksError {
10+
def apply(backticks: String, positions: Seq[Position]) =
11+
new MarkdownUnclosedBackticksError(backticks, positions)
12+
}

0 commit comments

Comments
 (0)