diff --git a/build.sbt b/build.sbt index 96975bf0fc..d484f5383b 100644 --- a/build.sbt +++ b/build.sbt @@ -153,6 +153,7 @@ lazy val core = crossProject(JVMPlatform) scalacOptions ++= scalacJvmOptions.value, libraryDependencies ++= Seq( scalameta.value, + "org.scalameta" %% "mdoc-parser" % mdocV, // scala-reflect is an undeclared dependency of fansi, see #1252. // Scalafmt itself does not require scala-reflect. "org.scala-lang" % "scala-reflect" % scalaVersion.value diff --git a/project/Dependencies.scala b/project/Dependencies.scala index f986624edb..2fa452ac92 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -11,6 +11,7 @@ object Dependencies { val scalacheckV = "1.17.0" val coursier = "2.1.2" val munitV = "0.7.29" + val mdocV = "2.5.0" val scalapb = Def.setting { ExclusionRule( diff --git a/scalafmt-core/shared/src/main/scala/org/scalafmt/Scalafmt.scala b/scalafmt-core/shared/src/main/scala/org/scalafmt/Scalafmt.scala index e772f8361f..cf594d5756 100644 --- a/scalafmt-core/shared/src/main/scala/org/scalafmt/Scalafmt.scala +++ b/scalafmt-core/shared/src/main/scala/org/scalafmt/Scalafmt.scala @@ -19,7 +19,7 @@ import org.scalafmt.internal.FormatOps import org.scalafmt.internal.FormatWriter import org.scalafmt.rewrite.Rewrite import org.scalafmt.sysops.FileOps -import org.scalafmt.util.{MarkdownFile, MarkdownPart} +import org.scalafmt.util.MarkdownParser /** WARNING. This API is discouraged when integrating with Scalafmt from a build * tool or editor plugin. It is recommended to use the `scalafmt-dynamic` @@ -118,25 +118,9 @@ object Scalafmt { file: String, range: Set[Range] ): Try[String] = - if (FileOps.isMarkdown(file)) { - val markdown = MarkdownFile.parse(Input.VirtualFile(file, code)) - - val resultIterator: Iterator[Try[String]] = - markdown.parts.iterator.collect { - case fence: MarkdownPart.CodeFence - if fence.info.startsWith("scala mdoc") => - val res = doFormatOne(fence.body, style, file) - res.foreach { formatted => - fence.newBody = Some(formatted.trim) - } - res - } - if (resultIterator.isEmpty) Success(code) - else - resultIterator - .find(_.isFailure) - .getOrElse(Success(markdown.renderToString)) - } else + if (FileOps.isMarkdown(file)) + MarkdownParser.transformMdoc(code)(doFormatOne(_, style, file, range)) + else doFormatOne(code, style, file, range) private[scalafmt] def toInput(code: String, file: String): Input = { @@ -148,7 +132,7 @@ object Scalafmt { code: String, style: ScalafmtConfig, file: String, - range: Set[Range] = Set.empty + range: Set[Range] ): Try[String] = if (code.matches("\\s*")) Success("") else { diff --git a/scalafmt-core/shared/src/main/scala/org/scalafmt/util/MarkdownFile.scala b/scalafmt-core/shared/src/main/scala/org/scalafmt/util/MarkdownFile.scala deleted file mode 100644 index 88ba0cf572..0000000000 --- a/scalafmt-core/shared/src/main/scala/org/scalafmt/util/MarkdownFile.scala +++ /dev/null @@ -1,119 +0,0 @@ -package org.scalafmt.util - -import scala.meta.inputs.Input -import scala.collection.mutable - -final case class MarkdownFile(parts: List[MarkdownPart]) { - def renderToString: String = { - val out = new StringBuilder() - parts.foreach(_.renderToString(out)) - out.result() - } -} -object MarkdownFile { - sealed abstract class State - object State { - case class CodeFence(start: Int, backticks: String, info: String) - extends State - case object Text extends State - } - class Parser(input: Input) { - private val text = input.text - - private def substringWithAdaptedEnd(start: Int, end: Int): String = { - val adaptedEnd = math.max(start, end) - text.substring(start, adaptedEnd) - } - - private def newCodeFence( - state: State.CodeFence, - backtickStart: Int, - backtickEnd: Int - ): MarkdownPart.CodeFence = { - val open = substringWithAdaptedEnd( - state.start, - state.start + state.backticks.length() - ) - val lastIndexOfOpeningBackTicks = state.start + state.backticks.length() - val info = substringWithAdaptedEnd( - lastIndexOfOpeningBackTicks, - lastIndexOfOpeningBackTicks + state.info.length() - ) - val adaptedBacktickStart = math.max(0, backtickStart - 1) - val body = substringWithAdaptedEnd( - lastIndexOfOpeningBackTicks + info.length(), - adaptedBacktickStart - ) - val close = substringWithAdaptedEnd(adaptedBacktickStart, backtickEnd) - MarkdownPart.CodeFence(open, info, body, close) - } - def acceptParts(): List[MarkdownPart] = { - var state: State = State.Text - val parts = mutable.ListBuffer.empty[MarkdownPart] - var curr = 0 - text.linesWithSeparators - .foreach { line => - 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) - } else { - parts += MarkdownPart.Text(substringWithAdaptedEnd(curr, end)) - } - case s: State.CodeFence => - if ( - line.startsWith(s.backticks) && - line.forall(ch => ch == '`' || ch.isWhitespace) - ) { - parts += newCodeFence(s, curr, end) - state = State.Text - } - } - curr = end - } - state match { - case s: State.CodeFence => - parts += newCodeFence(s, text.length(), text.length()) - case _ => - } - parts.toList - } - } - - def parse(input: Input): MarkdownFile = - MarkdownFile(new Parser(input).acceptParts()) - -} - -sealed abstract class MarkdownPart { - final def renderToString(out: StringBuilder): Unit = - this match { - case MarkdownPart.Text(value) => - out.append(value) - case fence: MarkdownPart.CodeFence => - out.append(fence.openBackticks) - out.append(fence.info) - fence.newBody match { - case None => - out.append(fence.body) - case Some(newBody) => - out.append(newBody) - } - out.append(fence.closeBackticks) - } -} -object MarkdownPart { - final case class Text(value: String) extends MarkdownPart - final case class CodeFence( - openBackticks: String, - info: String, - body: String, - closeBackticks: String - ) extends MarkdownPart { - var newBody = Option.empty[String] - } - -} diff --git a/scalafmt-core/shared/src/main/scala/org/scalafmt/util/MarkdownParser.scala b/scalafmt-core/shared/src/main/scala/org/scalafmt/util/MarkdownParser.scala new file mode 100644 index 0000000000..49051db8d0 --- /dev/null +++ b/scalafmt-core/shared/src/main/scala/org/scalafmt/util/MarkdownParser.scala @@ -0,0 +1,36 @@ +package org.scalafmt.util + +import scala.util.Success +import scala.util.Try + +import mdoc.parser._ + +private[scalafmt] object MarkdownParser { + + private val settings: ParserSettings = new ParserSettings { + override val allowCodeFenceIndented: Boolean = true + } + + def transformMdoc(code: String)(fmt: String => Try[String]): Try[String] = { + var hadFencedParts = false + val parts = MarkdownPart.parse(code, settings) + parts.foreach { + case p: CodeFence if p.getMdocMode.isDefined => + fmt(p.body.value) match { + case Success(b) => hadFencedParts = true; p.newBody = Some(b.trim) + case failure => return failure // RETURNING! + } + case _ => + } + + Success { + if (hadFencedParts) { + val out = new StringBuilder() + parts.foreach(_.renderToString(out)) + if (out.last != '\n') out.append('\n') + out.toString() + } else code + } + } + +} diff --git a/scalafmt-tests/src/test/resources/unit/Markdown.source b/scalafmt-tests/src/test/resources/unit/Markdown.source index 4f3d732476..fcf52d03a4 100644 --- a/scalafmt-tests/src/test/resources/unit/Markdown.source +++ b/scalafmt-tests/src/test/resources/unit/Markdown.source @@ -27,5 +27,5 @@ case class Foo(a: String) //> using dep io.scalaland::chimney::{{ git.tag or local.tag }} import io.scalaland.chimney.Transformer - case class Foo (a : String) + case class Foo(a: String) ``` diff --git a/scalafmt-tests/src/test/scala/org/scalafmt/cli/CliTest.scala b/scalafmt-tests/src/test/scala/org/scalafmt/cli/CliTest.scala index 44d4e33f20..aebd34f6b7 100644 --- a/scalafmt-tests/src/test/scala/org/scalafmt/cli/CliTest.scala +++ b/scalafmt-tests/src/test/scala/org/scalafmt/cli/CliTest.scala @@ -1168,7 +1168,7 @@ class CliTest extends AbstractCliTest with CliTestBehavior { } // This test might need to change based on maintainer feedback/requirements - test(s"does not apply to .md files with indented fenced content ") { + test(s"does apply to .md files with indented fenced content ") { val input = string2dir( s"""|/foobar2.md | Intro text: @@ -1181,7 +1181,7 @@ class CliTest extends AbstractCliTest with CliTestBehavior { s"""|/foobar2.md | Intro text: | ```scala mdoc - | object A { } + | object A {} | ``` |""".stripMargin runArgs(