diff --git a/cli/src/main/scala/mdoc/internal/cli/Settings.scala b/cli/src/main/scala/mdoc/internal/cli/Settings.scala index c35fb494b..0b904d014 100644 --- a/cli/src/main/scala/mdoc/internal/cli/Settings.scala +++ b/cli/src/main/scala/mdoc/internal/cli/Settings.scala @@ -125,6 +125,8 @@ case class Settings( charset: Charset = StandardCharsets.UTF_8, @Description("The working directory to use for making relative paths absolute.") cwd: AbsolutePath, + @Description("Allow indented code fence blocks") + allowCodeFenceIndented: Boolean = false, @Hidden() stringModifiers: List[StringModifier] = StringModifier.default(), @Hidden() diff --git a/mdoc/src/main/scala/mdoc/internal/markdown/Markdown.scala b/mdoc/src/main/scala/mdoc/internal/markdown/Markdown.scala index 71175416b..1e085676e 100644 --- a/mdoc/src/main/scala/mdoc/internal/markdown/Markdown.scala +++ b/mdoc/src/main/scala/mdoc/internal/markdown/Markdown.scala @@ -127,7 +127,12 @@ object Markdown { reporter, settings ) - val file = MarkdownFile.parse(textWithVariables, inputFile, reporter) + val file = MarkdownFile.parse( + textWithVariables, + inputFile, + reporter, + settings + ) val processor = new Processor()(context) processor.processDocument(file) file.renderToString diff --git a/mdoc/src/main/scala/mdoc/internal/markdown/MarkdownFile.scala b/mdoc/src/main/scala/mdoc/internal/markdown/MarkdownFile.scala index 95225549b..262cd1cac 100644 --- a/mdoc/src/main/scala/mdoc/internal/markdown/MarkdownFile.scala +++ b/mdoc/src/main/scala/mdoc/internal/markdown/MarkdownFile.scala @@ -3,9 +3,10 @@ package mdoc.internal.markdown import scala.meta.inputs.Position import scala.meta.inputs.Input import mdoc.Reporter + import scala.collection.mutable import scala.meta.io.RelativePath -import mdoc.internal.cli.InputFile +import mdoc.internal.cli.{InputFile, Settings} final case class MarkdownFile(input: Input, file: InputFile, parts: List[MarkdownPart]) { private val appends = mutable.ListBuffer.empty[String] @@ -20,12 +21,26 @@ final case class MarkdownFile(input: Input, file: InputFile, parts: List[Markdow } } object MarkdownFile { + object syntax { + private[markdown] implicit class StringOps(private val x: String) extends AnyVal { + def isNL: Boolean = x.forall { c => c == '\n' || c == '\r' } + } + + private[markdown] implicit class StringBuilderOps(private val x: StringBuilder) extends AnyVal { + def appendLinesPrefixed(prefix: String, text: String): Unit = { + text.linesWithSeparators foreach { line => + if (line.nonEmpty && !line.isNL && !line.startsWith(prefix)) x.append(prefix) + x.append(line) + } + } + } + } sealed abstract class State object State { - case class CodeFence(start: Int, backticks: String, info: String) extends State + case class CodeFence(start: Int, backticks: String, info: String, indent: Int) extends State case object Text extends State } - class Parser(input: Input, reporter: Reporter) { + class Parser(input: Input, reporter: Reporter, settings: Settings) { private val text = input.text private def newPos(start: Int, end: Int): Position = { Position.Range(input, start, end) @@ -41,12 +56,15 @@ object MarkdownFile { backtickStart: Int, backtickEnd: Int ): CodeFence = { - val open = newText(state.start, state.start + state.backticks.length()) + // tag is characters preceding the code fence in this line + val tag = newText(state.start, state.start + state.indent) + val open = newText(state.start, state.start + state.indent + state.backticks.length()) + .dropLinePrefix(state.indent) val info = newText(open.pos.end, open.pos.end + state.info.length()) val adaptedBacktickStart = math.max(0, backtickStart - 1) - val body = newText(info.pos.end, adaptedBacktickStart) - val close = newText(adaptedBacktickStart, backtickEnd) - val part = CodeFence(open, info, body, close) + val body = newText(info.pos.end, adaptedBacktickStart).dropLinePrefix(state.indent) + val close = newText(adaptedBacktickStart, backtickEnd).dropLinePrefix(state.indent) + val part = CodeFence(open, info, body, close, tag) part.pos = newPos(state.start, backtickEnd) part } @@ -58,16 +76,19 @@ object MarkdownFile { val end = curr + line.length() state match { case State.Text => - if (line.startsWith("```")) { - val backticks = line.takeWhile(_ == '`') - val info = line.substring(backticks.length()) - state = State.CodeFence(curr, backticks, info) + val start = line.indexOf("```") + if (start == 0 || (start > 0 && settings.allowCodeFenceIndented)) { + val fence = line.substring(start) + val backticks = fence.takeWhile(_ == '`') + val info = fence.substring(backticks.length()) + state = State.CodeFence(curr, backticks, info, indent = start) } else { parts += newText(curr, end) } case s: State.CodeFence => + val start = line.indexOf(s.backticks) if ( - line.startsWith(s.backticks) && + start == s.indent && line.forall(ch => ch == '`' || ch.isWhitespace) ) { parts += newCodeFence(s, curr, end) @@ -84,24 +105,33 @@ object MarkdownFile { parts.toList } } - def parse(input: Input, file: InputFile, reporter: Reporter): MarkdownFile = { - val parser = new Parser(input, reporter) + def parse( + input: Input, + file: InputFile, + reporter: Reporter, + settings: Settings + ): MarkdownFile = { + val parser = new Parser(input, reporter, settings) val parts = parser.acceptParts() MarkdownFile(input, file, parts) } } sealed abstract class MarkdownPart { + import MarkdownFile.syntax._ + var pos: Position = Position.None final def renderToString(out: StringBuilder): Unit = this match { case Text(value) => out.append(value) case fence: CodeFence => + val indentation = if (fence.hasBlankTag) fence.tag.value else " " * fence.indent fence.newPart match { case Some(newPart) => - out.append(newPart) + out.appendLinesPrefixed(indentation, newPart) case None => + fence.tag.renderToString(out) fence.openBackticks.renderToString(out) fence.newInfo match { case None => @@ -114,18 +144,38 @@ sealed abstract class MarkdownPart { } fence.newBody match { case None => - fence.body.renderToString(out) + out.appendLinesPrefixed(indentation, fence.body.value) case Some(newBody) => - out.append(newBody) + out.appendLinesPrefixed(indentation, newBody) } - fence.closeBackticks.renderToString(out) + out.appendLinesPrefixed(indentation, fence.closeBackticks.value) } } } -final case class Text(value: String) extends MarkdownPart -final case class CodeFence(openBackticks: Text, info: Text, body: Text, closeBackticks: Text) - extends MarkdownPart { +final case class Text(value: String) extends MarkdownPart { + import MarkdownFile.syntax._ + + def dropLinePrefix(indent: Int): Text = { + if (indent > 0) { + val updatedValue = value.linesWithSeparators.map { line => + if (!line.isNL && line.length >= indent) line.substring(indent) else line + }.mkString + val updatedText = Text(updatedValue) + updatedText.pos = this.pos + updatedText + } else this + } +} +final case class CodeFence( + openBackticks: Text, + info: Text, + body: Text, + closeBackticks: Text, + tag: Text = Text("") +) extends MarkdownPart { var newPart = Option.empty[String] var newInfo = Option.empty[String] var newBody = Option.empty[String] + def indent: Int = tag.value.length + def hasBlankTag: Boolean = tag.value.forall(_.isWhitespace) } diff --git a/tests/unit/src/test/scala/tests/markdown/IndentedMarkdownSuite.scala b/tests/unit/src/test/scala/tests/markdown/IndentedMarkdownSuite.scala new file mode 100644 index 000000000..7316df65f --- /dev/null +++ b/tests/unit/src/test/scala/tests/markdown/IndentedMarkdownSuite.scala @@ -0,0 +1,91 @@ +package tests.markdown + +class IndentedMarkdownSuite extends BaseMarkdownSuite { + + check( + "2 whitespace", + """ + | ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + """ + | ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + settings = baseSettings.copy(allowCodeFenceIndented = true) + ) + + check( + "4 whitespace", + """ + | ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + """ + | ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + settings = baseSettings.copy(allowCodeFenceIndented = true) + ) + + check( + "tab", + """ + | ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + """ + | ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + settings = baseSettings.copy(allowCodeFenceIndented = true) + ) + + check( + "4 whitespace, tagged", + """ + |: ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + """ + |: ```scala + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + settings = baseSettings.copy(allowCodeFenceIndented = true) + ) + + check( + "4 whitespace, tagged, mdoc", + """ + |: ```scala mdoc + | val msg = "Hello!" + | println(msg) + | ``` + """.stripMargin, + """ + |: ```scala + | val msg = "Hello!" + | // msg: String = "Hello!" + | println(msg) + | // Hello! + | ``` + """.stripMargin, + settings = baseSettings.copy(allowCodeFenceIndented = true) + ) +} diff --git a/tests/unit/src/test/scala/tests/markdown/MarkdownFileSuite.scala b/tests/unit/src/test/scala/tests/markdown/MarkdownFileSuite.scala index f443043ea..2a1000085 100644 --- a/tests/unit/src/test/scala/tests/markdown/MarkdownFileSuite.scala +++ b/tests/unit/src/test/scala/tests/markdown/MarkdownFileSuite.scala @@ -1,28 +1,37 @@ package tests.markdown -import munit.FunSuite +import munit.{FunSuite, Location} import mdoc.internal.markdown.MarkdownFile + import scala.meta.inputs.Input import mdoc.internal.io.ConsoleReporter import mdoc.internal.markdown.Text import mdoc.internal.markdown.MarkdownPart import mdoc.internal.markdown.CodeFence + import scala.meta.io.RelativePath import mdoc.internal.cli.InputFile + import scala.meta.io.AbsolutePath import java.nio.file.Files import mdoc.internal.cli.Settings +import mdoc.internal.markdown.MarkdownFile.Parser + import scala.meta.internal.io.PathIO -class MarkdownFileSuite extends FunSuite { +class MarkdownFileSuite extends BaseMarkdownSuite { val reporter = new ConsoleReporter(System.out) - def check(name: String, original: String, expected: MarkdownPart*): Unit = { + def checkParse(name: String, original: String, expected: MarkdownPart*)(implicit + loc: Location + ): Unit = { test(name) { reporter.reset() val input = Input.VirtualFile(name, original) val file = InputFile.fromRelativeFilename(name, Settings.default(PathIO.workingDirectory)) - val obtained = MarkdownFile.parse(input, file, reporter).parts + val obtained = MarkdownFile + .parse(input, file, reporter, baseSettings.copy(allowCodeFenceIndented = true)) + .parts require(!reporter.hasErrors) val expectedParts = expected.toList assertNoDiff( @@ -32,8 +41,21 @@ class MarkdownFileSuite extends FunSuite { } } - check( - "basic", + def checkRenderToString(name: String, original: MarkdownPart, expected: String)(implicit + loc: Location + ): Unit = { + test(name) { + val sb = new StringBuilder + original.renderToString(sb) + assertNoDiff( + sb.toString, + expected + ) + } + } + + checkParse( + "parse. basic", """# Hello |World |```scala mdoc @@ -52,8 +74,8 @@ class MarkdownFileSuite extends FunSuite { Text("End.\n") ) - check( - "four-backtick", + checkParse( + "parse. four-backtick", """# Hello |World |````scala mdoc @@ -74,8 +96,8 @@ class MarkdownFileSuite extends FunSuite { Text("End.\n") ) - check( - "two-backtick", + checkParse( + "parse. two-backtick", """# Hello |World |``scala mdoc @@ -91,8 +113,8 @@ class MarkdownFileSuite extends FunSuite { Text("End.\n") ) - check( - "backtick-mismatch", + checkParse( + "parse. backtick-mismatch", """|````scala mdoc |````` |```` @@ -111,4 +133,104 @@ class MarkdownFileSuite extends FunSuite { Text("\n") ) ) + + checkParse( + "parse. indented with whitespaces", + """prefix + | ```scala mdoc + | println("foo") + | println("bar") + | ``` + |suffix + |""".stripMargin, + Text("prefix\n"), + CodeFence( + Text("```"), + Text("scala mdoc\n"), + Text("println(\"foo\")\nprintln(\"bar\")"), + Text("\n```\n"), + Text(" ") + ), + Text("suffix\n") + ) + + checkParse( + "parse. indented with tag", + """prefix + |: ```scala mdoc + | println(42) + | ``` + |suffix + |""".stripMargin, + Text("prefix\n"), + CodeFence( + Text("```"), + Text("scala mdoc\n"), + Text("println(42)"), + Text("\n```\n"), + Text(": ") + ), + Text("suffix\n") + ) + + checkRenderToString( + "render. basic", + CodeFence( + Text("```"), + Text("scala mdoc\n"), + Text("println(42)"), + Text("\n```\n") + ), + """```scala mdoc + |println(42) + |``` + |""".stripMargin + ) + + checkRenderToString( + "render. indented, one-liner body", + CodeFence( + Text("```"), + Text("scala mdoc\n"), + Text("println(42)"), + Text("\n```\n"), + Text(" ") + ), + """ ```scala mdoc + | println(42) + | ``` + |""".stripMargin + ) + + checkRenderToString( + "render. indented, multi-line body", + CodeFence( + Text("```"), + Text("scala mdoc\n"), + Text("println(42)\nprintln(52)"), + Text("\n```\n"), + Text(" ") + ), + """ ```scala mdoc + | println(42) + | println(52) + | ``` + |""".stripMargin + ) + + checkRenderToString( + "render. indented with tag, multi-line body", + CodeFence( + Text("```"), + Text("scala mdoc\n"), + Text("println(42)\nprintln(52)"), + Text("\n```\n"), + Text(": ") + ), + """: ```scala mdoc + | println(42) + | println(52) + | ``` + |""".stripMargin + ) }