Skip to content

Commit

Permalink
Add error handling for unclosed code blocks in Markdown inputs (#1550)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gedochao authored Nov 9, 2022
1 parent 49dddff commit 8a57953
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package scala.build.internal.markdown

import scala.annotation.tailrec
import scala.build.Position
import scala.build.errors.{BuildException, MarkdownUnclosedBackticksError}
import scala.collection.mutable
import scala.jdk.CollectionConverters.*

Expand Down Expand Up @@ -47,12 +49,20 @@ object MarkdownCodeBlock {

/** Finds all code snippets in given input
*
* @param subPath
* the project [[os.SubPath]] to the Markdown file
* @param md
* Markdown file in a `String` format
* @param maybeRecoverOnError
* function potentially recovering on errors
* @return
* list of all found snippets
*/
def findCodeBlocks(md: String): Seq[MarkdownCodeBlock] = {
def findCodeBlocks(
subPath: os.SubPath,
md: String,
maybeRecoverOnError: BuildException => Option[BuildException]
): Either[BuildException, Seq[MarkdownCodeBlock]] = {
val allLines = md
.lines()
.toList
Expand All @@ -63,20 +73,24 @@ object MarkdownCodeBlock {
closedFences: Seq[MarkdownCodeBlock] = Seq.empty,
maybeOpenFence: Option[MarkdownOpenFence] = None,
currentIndex: Int = 0
): Seq[MarkdownCodeBlock] = if lines.isEmpty then closedFences
else {
val currentLine = lines.head
val (newClosedFences, newOpenFence) = maybeOpenFence match {
case None => closedFences -> MarkdownOpenFence.maybeFence(currentLine, currentIndex)
case mof @ Some(openFence) =>
val backticksStart = currentLine.indexOf(openFence.backticks)
if backticksStart == openFence.indent &&
currentLine.forall(c => c == '`' || c.isWhitespace)
then (closedFences :+ openFence.closeFence(currentIndex, allLines.toArray)) -> None
else closedFences -> mof
}
findCodeBlocksRec(lines.tail, newClosedFences, newOpenFence, currentIndex + 1)
): Either[BuildException, Seq[MarkdownCodeBlock]] = lines -> maybeOpenFence match {
case (Seq(currentLine, tail*), mof) =>
val (newClosedFences, newOpenFence) = mof match {
case None => closedFences -> MarkdownOpenFence.maybeFence(currentLine, currentIndex)
case Some(openFence) =>
val backticksStart = currentLine.indexOf(openFence.backticks)
if backticksStart == openFence.indent &&
currentLine.forall(c => c == '`' || c.isWhitespace)
then (closedFences :+ openFence.closeFence(currentIndex, allLines.toArray)) -> None
else closedFences -> Some(openFence)
}
findCodeBlocksRec(tail, newClosedFences, newOpenFence, currentIndex + 1)
case (Nil, Some(openFence)) =>
maybeRecoverOnError(openFence.toUnclosedBackticksError(os.pwd / subPath))
.map(e => Left(e))
.getOrElse(Right(closedFences))
case _ => Right(closedFences)
}
findCodeBlocksRec(allLines.toSeq).filter(!_.shouldIgnore)
findCodeBlocksRec(allLines.toSeq).map(_.filter(!_.shouldIgnore))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package scala.build.internal.markdown

import scala.annotation.tailrec
import scala.build.EitherCps.{either, value}
import scala.build.errors.BuildException
import scala.build.internal.markdown.MarkdownCodeBlock
import scala.build.internal.{AmmUtil, Name}

Expand All @@ -21,12 +23,13 @@ object MarkdownCodeWrapper {
*/
def apply(
subPath: os.SubPath,
content: String
): (Option[String], Option[String], Option[String]) = {
content: String,
maybeRecoverOnError: BuildException => Option[BuildException] = b => Some(b)
): Either[BuildException, (Option[String], Option[String], Option[String])] = either {
val (pkg, wrapper) = AmmUtil.pathToPackageWrapper(subPath)
val maybePkgString =
if pkg.isEmpty then None else Some(s"package ${AmmUtil.encodeScalaSourcePath(pkg)}")
val allSnippets = MarkdownCodeBlock.findCodeBlocks(content)
val allSnippets = value(MarkdownCodeBlock.findCodeBlocks(subPath, content, maybeRecoverOnError))
val (rawSnippets, processedSnippets) = allSnippets.partition(_.isRaw)
val (testSnippets, mainSnippets) = processedSnippets.partition(_.isTest)
val wrapperName = s"${wrapper.raw}_md"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package scala.build.internal.markdown

import scala.build.Position
import scala.build.errors.MarkdownUnclosedBackticksError

/** Representation for an open code block in Markdown. (open meaning the closing backticks haven't
* yet been parsed or they aren't at all present)
*
Expand Down Expand Up @@ -41,6 +44,21 @@ case class MarkdownOpenFence(
tickEndLine - 1 // ending backticks have to be placed below the snippet
)
}

/** Converts the [[MarkdownOpenFence]] into a [[MarkdownUnclosedBackticksError]]
*
* @param mdPath
* path to the Markdown file
* @return
* a [[MarkdownUnclosedBackticksError]]
*/
def toUnclosedBackticksError(mdPath: os.Path): MarkdownUnclosedBackticksError = {
val startCoordinates = tickStartLine -> indent
val endCoordinates =
tickStartLine -> (indent + backticks.length)
val position = Position.File(Right(mdPath), startCoordinates, endCoordinates)
MarkdownUnclosedBackticksError(backticks, Seq(position))
}
}

object MarkdownOpenFence {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ case object MarkdownPreprocessor extends Preprocessor {
}
}

val (mainScalaCode, rawScalaCode, testScalaCode) = MarkdownCodeWrapper(subPath, content)
val (mainScalaCode, rawScalaCode, testScalaCode) =
value(MarkdownCodeWrapper(subPath, content, maybeRecoverOnError))

val maybeMainFile = value(preprocessSnippets(mainScalaCode, ".scala"))
val maybeRawFile = value(preprocessSnippets(rawScalaCode, ".raw.scala"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import scala.build.internal.markdown.MarkdownCodeWrapper
import com.eed3si9n.expecty.Expecty.expect
import os.RelPath

import scala.build.Position
import scala.build.errors.{BuildException, MarkdownUnclosedBackticksError}
import scala.build.internal.AmmUtil

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

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

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

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

test("a Scala snippet is skipped when it's marked as `ignore` in markdown") {
Expand All @@ -96,6 +98,56 @@ class MarkdownCodeWrapperTests extends munit.FunSuite {
|println("Hello")
|```
|""".stripMargin
expect(MarkdownCodeWrapper(os.sub / "Example.md", markdown) == (None, None, None))
expect(MarkdownCodeWrapper(os.sub / "Example.md", markdown) == Right((None, None, None)))
}

test("an unclosed snippet produces a build error") {
val markdown =
"""# Some snippet
|
|```scala
|println("Hello")
|""".stripMargin
val subPath = os.sub / "Example.md"
val expectedPosition = Position.File(Right(os.pwd / subPath), 2 -> 0, 2 -> 3)
val expectedError = MarkdownUnclosedBackticksError("```", Seq(expectedPosition))
val Left(result) = MarkdownCodeWrapper(subPath, markdown)
expect(result.message == expectedError.message)
expect(result.positions == expectedError.positions)
}

test("recovery from an unclosed snippet error works correctly") {
val markdown =
"""# Some snippet
|```scala
|println("closed snippet")
|```
|
|# Some other snippet
|
|````scala
|println("unclosed snippet")
|
|```scala
|println("whatever")
|```
|""".stripMargin
val subPath = os.sub / "Example.md"
var actualError: Option[BuildException] = None
val recoveryFunction = (be: BuildException) => {
actualError = Some(be)
None
}
val Right(result) =
MarkdownCodeWrapper(subPath, markdown, maybeRecoverOnError = recoveryFunction)
val expectedScala =
"""object Example_md { @annotation.nowarn("msg=pure expression does nothing") def main(args: Array[String]): Unit = { Scope; }
|object Scope {
|println("closed snippet")
|}}""".stripMargin
expect(result == (Some(expectedScala), None, None))
val expectedPosition = Position.File(Right(os.pwd / subPath), 7 -> 0, 7 -> 4)
val expectedError = MarkdownUnclosedBackticksError("````", Seq(expectedPosition))
expect(actualError.get.positions == expectedError.positions)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package scala.build.errors
import scala.build.Position

class MarkdownUnclosedBackticksError(
backticks: String,
positions: Seq[Position]
) extends BuildException(s"Unclosed $backticks code block in a Markdown input", positions)

object MarkdownUnclosedBackticksError {
def apply(backticks: String, positions: Seq[Position]) =
new MarkdownUnclosedBackticksError(backticks, positions)
}

0 comments on commit 8a57953

Please sign in to comment.