diff --git a/build.sbt b/build.sbt index 73e1b427d..e02328484 100644 --- a/build.sbt +++ b/build.sbt @@ -66,6 +66,7 @@ lazy val mdoc = project "com.vladsch.flexmark" % "flexmark-all" % "0.26.4", "com.lihaoyi" %% "fansi" % "0.2.5", "io.methvin" % "directory-watcher" % "0.7.0", + "me.xdrop" % "fuzzywuzzy" % "1.1.9", // for link hygiene "did you mean?" "ch.epfl.scala" %% "scalafix-core" % V.scalafix ) ) diff --git a/docs/readme.md b/docs/readme.md index e6f3a2bf2..f895c7600 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -337,9 +337,13 @@ invalid. # My title Link to [my title](#my-title). +Link to [typo section](#mytitle). Link to [old section](#doesnotexist). ``` +Observe that mdoc suggests a fix if there exists a header that is similar to the +unknown link. + ### Script semantics Mdoc interprets code fences as normal Scala programs instead of as if they're diff --git a/mdoc/src/main/scala/mdoc/internal/markdown/LinkHygiene.scala b/mdoc/src/main/scala/mdoc/internal/markdown/LinkHygiene.scala index 59495c0c0..ff9952985 100644 --- a/mdoc/src/main/scala/mdoc/internal/markdown/LinkHygiene.scala +++ b/mdoc/src/main/scala/mdoc/internal/markdown/LinkHygiene.scala @@ -2,6 +2,7 @@ package mdoc.internal.markdown import java.net.URI import mdoc.Reporter +import me.xdrop.fuzzywuzzy.FuzzySearch object LinkHygiene { def lint(docs: List[DocumentLinks], reporter: Reporter, verbose: Boolean): Unit = { @@ -16,16 +17,41 @@ object LinkHygiene { } { val isAbsolutePath = uri.getPath.startsWith("/") val debug = - if (verbose) s". isValidHeading=$isValidHeading" - else "" + if (verbose) { + val query = uri.toString + val candidates = isValidHeading + .map { candidate => + val score = FuzzySearch.ratio(candidate.toString, query) + score -> f"$score%-3s $candidate" + } + .toSeq + .sortBy(-_._1) + .map(_._2) + .mkString("\n ") + s"\nisValidHeading:\n $candidates" + } else "" + val help = getSimilarHeading(isValidHeading, uri) match { + case None => "." + case Some(similar) => s", did you mean '$similar'?" + } val hint = if (isAbsolutePath) - s". To fix this problem, either make the link relative or turn it into complete URL such as http://example.com$uri." + s" To fix this problem, either make the link relative or turn it into complete URL such as http://example.com$uri." else "" - reporter.warning(reference.pos, s"Unknown link '$uri'$hint$debug") + reporter.warning(reference.pos, s"Unknown link '$uri'$help$hint$debug") } } + private def getSimilarHeading(candidates: Set[URI], query: URI): Option[URI] = { + val queryString = query.toString + val similar = for { + candidate <- candidates.iterator + score = FuzzySearch.ratio(queryString, candidate.toString) + if score > 90 // discard noisy candidates + } yield score -> candidate + if (similar.isEmpty) None + else Some(similar.maxBy(_._1)._2) + } private def resolve(baseUri: URI, reference: String): Option[URI] = { try { Some(baseUri.resolve(reference).normalize()) diff --git a/readme.md b/readme.md index 948edf60e..fa7430dac 100644 --- a/readme.md +++ b/readme.md @@ -503,18 +503,25 @@ Before: # My title Link to [my title](#my-title). +Link to [typo section](#mytitle). Link to [old section](#doesnotexist). ```` Error: ```` -warning: readme.md:4:9: warning: Unknown link 'readme.md#doesnotexist' +warning: readme.md:4:9: warning: Unknown link 'readme.md#mytitle', did you mean 'readme.md#my-title'? +Link to [typo section](#mytitle). + ^^^^^^^^^^^^^^^^^^^^^^^^ +warning: readme.md:5:9: warning: Unknown link 'readme.md#doesnotexist'. Link to [old section](#doesnotexist). ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ```` +Observe that mdoc suggests a fix if there exists a header that is similar to the +unknown link. + ### Script semantics Mdoc interprets code fences as normal Scala programs instead of as if they're diff --git a/tests/unit/src/test/scala/tests/markdown/LinkHygieneSuite.scala b/tests/unit/src/test/scala/tests/markdown/LinkHygieneSuite.scala index 485d5d6b1..b74913b2e 100644 --- a/tests/unit/src/test/scala/tests/markdown/LinkHygieneSuite.scala +++ b/tests/unit/src/test/scala/tests/markdown/LinkHygieneSuite.scala @@ -55,10 +55,10 @@ class LinkHygieneSuite extends FunSuite with DiffAssertions { |* [name](a.md#name) | """.stripMargin, - """|warning: a.md:3:7: warning: Unknown link 'a.md#does-not-exist' + """|warning: a.md:3:7: warning: Unknown link 'a.md#does-not-exist'. |Error [link](#does-not-exist) failed. | ^^^^^^^^^^^^^^^^^^^^^^^ - |warning: a.md:4:6: warning: Unknown link 'a.md#sectionn' + |warning: a.md:4:6: warning: Unknown link 'a.md#sectionn', did you mean 'a.md#section'? |Typo [section](#sectionn) failed. | ^^^^^^^^^^^^^^^^^^^^ """.stripMargin @@ -122,7 +122,12 @@ class LinkHygieneSuite extends FunSuite with DiffAssertions { |/b.md |# Header 2 """.stripMargin, - """|warning: a.md:2:1: warning: Unknown link 'b.md#header'. isValidHeading=Set(b.md, b.md#header-2, a.md, a.md#header-1) + """|warning: a.md:2:1: warning: Unknown link 'b.md#header', did you mean 'b.md#header-2'? + |isValidHeading: + | 92 b.md#header-2 + | 83 a.md#header-1 + | 53 b.md + | 40 a.md |[2](b.md#header) |^^^^^^^^^^^^^^^^ |""".stripMargin,