From 08b261542715794fc598b67e302248947a7f600b Mon Sep 17 00:00:00 2001 From: Jason Pickens Date: Tue, 9 Mar 2021 09:32:07 +1300 Subject: [PATCH] Add complete example --- README.md | 61 ++++++++++ build.sbt | 30 +++-- .../bottech/scala2plantuml/ConfigParser.scala | 7 +- .../ClassDiagramGenerator.scala | 4 +- .../scala2plantuml/ClassDiagramRenderer.scala | 2 +- .../scala2plantuml/DefinitionIndex.scala | 2 +- .../scala2plantuml/LazySymbolTable.scala | 2 +- .../scala2plantuml/SemanticDbLoader.scala | 37 ++++-- .../SemanticDBLoaderTests.scala | 107 ++++++++++++++++++ docs/README.md | 44 +++++++ docs/example/example.md | 3 + example/example.md | 20 ++++ .../bottech/scala2plantuml/example/Main.scala | 4 +- 13 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 core/src/test/scala/nz/co/bottech/scala2plantuml/SemanticDBLoaderTests.scala create mode 100644 docs/example/example.md create mode 100644 example/example.md diff --git a/README.md b/README.md index c95e383..329b022 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,40 @@ It comes as a standalone library, a CLI tool and an sbt plugin. Scala2PlantUML consumes [SemanticDB] files so you will need to know how to create those or simply follow the sbt setup instructions below. +## Example + +```shell +scala2plantuml \ + --url 'https://repo1.maven.org/maven2/nz/co/bottech/scala2plantuml-example_2.13/0.2.0/scala2plantuml-example_2.13-0.2.0.jar'\ + --project example \ + "nz/co/bottech/scala2plantuml/example/Main." +``` + +```text +@startuml +class A extends B { + + {method} + + {method} b +} +A o-- C +interface B { + + {abstract} {method} b +} +B o-- C +class C { + + {method} + + {field} value +} +C o-- A +class Main { + + {static} {field} a +} +Main o-- A +@enduml +``` + +![Example Class Diagram](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.github.com/BotTech/scala2plantuml/main/example/example.md) + ## sbt ### Enable SemanticDB @@ -40,6 +74,21 @@ Create `~/.sbt/1.0/plugins/scala2PlantUML.sbt` containing: addSbtPlugin("nz.co.bottech" % "sbt-scala2plantuml" % "0.2.0") ``` +### Generate the Diagram + +Run the `scala2PlantUML` task from sbt: + +```sbt +scala2PlantUML "com/example/Foo#" +``` + +This accepts the following arguments: +- `--include` +- `--exclude` +- `--output` + +Refer to the [CLI Usage](#usage) for the definition of these arguments. + ## CLI ### Install @@ -179,6 +228,18 @@ Each of these can be provided multiple times. The result will be all combination > 🚧 TODO: Document Library. +## Limitations + +- Only class diagrams are supported. +- Only inheritance or aggregations are supported, compositions are shown as aggregations. +- Aggregations are shown between types not between fields. There is a [bug][namespaced field links] in PlantUML which + prevents us from being able to do this reliably. +- There is no reliable way to determine the path to a SemanticDB file from any symbol. + If Scala2PlantUML is unable to find your symbols then the following may help: + - Only have a single top level type in each file. + - Ensure that the file name matches the type name. + - Nest any subclasses of a sealed class within the companion object of the sealed class. + [coursier]: https://get-coursier.io/docs/cli-install [plantuml]: https://plantuml.com/ [semanticdb]: https://scalameta.org/docs/semanticdb/guide.html diff --git a/build.sbt b/build.sbt index ef9f674..115794d 100644 --- a/build.sbt +++ b/build.sbt @@ -82,16 +82,15 @@ inThisBuild( // This needs to be set otherwise the GitHub workflow plugin gets confused about which // version to use for the publish job. scalaVersion := scala212, - versionPolicyIntention := Compatibility.BinaryAndSourceCompatible, + // TODO: Revert this after releasing 0.3.0. + //versionPolicyIntention := Compatibility.BinaryAndSourceCompatible, + versionPolicyIntention := Compatibility.None, versionScheme := Some("early-semver") ) ) val commonProjectSettings = List( isScala213 := isScala213Setting.value, - // Who cares about these. Forwards binary compatibility is used as an approximation for source - // backwards compatibility and missing classes isn't a problem. - mimaForwardIssueFilters += "0.2.0" -> List(ProblemFilters.exclude[MissingClassProblem]("nz.co.bottech.scala2plantuml.*")), name := s"${(LocalRootProject / name).value}-${name.value}", scalastyleFailOnError := true, scalastyleFailOnWarning := true, @@ -110,14 +109,14 @@ val commonProjectSettings = List( val metaProjectSettings = List( mimaFailOnNoPrevious := false, mimaPreviousArtifacts := Set.empty, - publish / skip := true + publish / skip := true, + versionPolicyCheck := Def.unit(()) ) lazy val root = (project in file(".")) .aggregate(cli, core, docs, example, integrationTests, sbtProject) .settings(metaProjectSettings) .settings( - crossScalaVersions := supportedScalaVersions, name := "scala2plantuml", // Workaround for https://github.com/olafurpg/sbt-ci-release/issues/181 // These have to go on the root project. @@ -158,6 +157,10 @@ lazy val cli = project "ch.qos.logback" % "logback-core" % logbackVersion, "com.github.scopt" %% "scopt" % scoptVersion, "org.slf4j" % "slf4j-api" % slf4jVersion + ), + mimaForwardIssueFilters += "0.2.0" -> List( + // This is private so no harm done. + ProblemFilters.exclude[MissingClassProblem]("nz.co.bottech.scala2plantuml.ConfigParser$Terminated$") ) ) @@ -211,7 +214,7 @@ lazy val sbtProject = (project in file("sbt")) lazy val docs = (project in (file("meta") / "docs")) // Include build info here so that we can override the version. .enablePlugins(BuildInfoPlugin, MdocPlugin) - .dependsOn(cli) + .dependsOn(cli, example) .settings(metaProjectSettings) .settings( // We use a different version setting so that it may depend on versionPolicyPreviousVersions @@ -226,7 +229,8 @@ lazy val docs = (project in (file("meta") / "docs")) }, mdocOut := (ThisBuild / baseDirectory).value, mdocVariables := Map( - "VERSION" -> (mdoc / version).value + "VERSION" -> (mdoc / version).value, + "SCALA_VERSION" -> scalaMajorMinorVersion.value ), unusedCompileDependenciesFilter -= moduleFilter("org.scalameta", "mdoc*"), mdoc / version := versionPolicyPreviousVersions.value.lastOption.getOrElse(version.value) @@ -237,9 +241,17 @@ lazy val example = project .settings( semanticdbEnabled := true, semanticdbIncludeInJar := true, - semanticdbVersion := sdbVersion + semanticdbVersion := sdbVersion, + versionPolicyCheck := Def.unit(()) ) +def scalaMajorMinorVersion: Def.Initialize[String] = Def.setting { + CrossVersion.partialVersion(scala213) match { + case Some((major, minor)) => s"$major.$minor" + case _ => throw new IllegalArgumentException("scalaVersion is malformed.") + } +} + def isScala213Setting: Def.Initialize[Boolean] = Def.setting { CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, n)) if n == 13 => true diff --git a/cli/src/main/scala/nz/co/bottech/scala2plantuml/ConfigParser.scala b/cli/src/main/scala/nz/co/bottech/scala2plantuml/ConfigParser.scala index 8d9f9bb..c5ca015 100644 --- a/cli/src/main/scala/nz/co/bottech/scala2plantuml/ConfigParser.scala +++ b/cli/src/main/scala/nz/co/bottech/scala2plantuml/ConfigParser.scala @@ -3,6 +3,7 @@ package nz.co.bottech.scala2plantuml import scopt._ import java.io.File +import java.net.URI object ConfigParser { @@ -104,7 +105,7 @@ object ConfigParser { opt[File]('j', "jar") .valueName("") .unbounded() - .action((jar, config) => config.addDirectory(jar)) + .action((jar, config) => config.addFile(jar)) .text( """JAR containing META-INF/semanticdb/**/*.semanticdb files. | @@ -113,10 +114,10 @@ object ConfigParser { |""".stripMargin ), note(""), - opt[File]('u', "url") + opt[URI]('u', "url") .valueName("") .unbounded() - .action((url, config) => config.addDirectory(url)) + .action((url, config) => config.addURL(url.toURL)) .text( """A URL to a JAR containing META-INF/semanticdb/**/*.semanticdb files. | diff --git a/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramGenerator.scala b/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramGenerator.scala index a3f5918..11f4a79 100644 --- a/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramGenerator.scala +++ b/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramGenerator.scala @@ -13,7 +13,7 @@ object ClassDiagramGenerator { classloader: ClassLoader, maxLevel: Option[Int] = None ): Seq[ClassDiagramElement] = { - val loader = new SemanticdbLoader(prefixes, classloader) + val loader = new SemanticDBLoader(prefixes, classloader) val symbolTable = aggregateSymbolTable(loader) val symbolIndex = new SymbolIndex(ignore, symbolTable) val typeIndex = new TypeIndex(symbolIndex) @@ -21,7 +21,7 @@ object ClassDiagramGenerator { SymbolProcessor.processSymbol(symbol, maxLevel, symbolIndex, typeIndex, definitionIndex) } - private def aggregateSymbolTable(loader: SemanticdbLoader) = + private def aggregateSymbolTable(loader: SemanticDBLoader) = AggregateSymbolTable( List( new LazySymbolTable(loader), diff --git a/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramRenderer.scala b/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramRenderer.scala index 617287c..fea2301 100644 --- a/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramRenderer.scala +++ b/core/src/main/scala/nz/co/bottech/scala2plantuml/ClassDiagramRenderer.scala @@ -96,7 +96,7 @@ object ClassDiagramRenderer { def render(elements: Seq[ClassDiagramElement], options: Options, writer: Writer): Unit = { writer.write("@startuml\n") renderSnippet(elements, options, writer) - writer.write("@enduml\n") + writer.write("@enduml") } def renderSnippetString(elements: Seq[ClassDiagramElement], options: Options): String = diff --git a/core/src/main/scala/nz/co/bottech/scala2plantuml/DefinitionIndex.scala b/core/src/main/scala/nz/co/bottech/scala2plantuml/DefinitionIndex.scala index f7684c9..2da2fd4 100644 --- a/core/src/main/scala/nz/co/bottech/scala2plantuml/DefinitionIndex.scala +++ b/core/src/main/scala/nz/co/bottech/scala2plantuml/DefinitionIndex.scala @@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory import scala.collection.concurrent.TrieMap import scala.meta.internal.semanticdb.SymbolOccurrence -private[scala2plantuml] class DefinitionIndex(loader: SemanticdbLoader) { +private[scala2plantuml] class DefinitionIndex(loader: SemanticDBLoader) { private val logger = LoggerFactory.getLogger(classOf[DefinitionIndex]) private val cache = TrieMap.empty[String, Option[SymbolOccurrence]] diff --git a/core/src/main/scala/nz/co/bottech/scala2plantuml/LazySymbolTable.scala b/core/src/main/scala/nz/co/bottech/scala2plantuml/LazySymbolTable.scala index 11bc380..9c24b04 100644 --- a/core/src/main/scala/nz/co/bottech/scala2plantuml/LazySymbolTable.scala +++ b/core/src/main/scala/nz/co/bottech/scala2plantuml/LazySymbolTable.scala @@ -6,7 +6,7 @@ import scala.collection.concurrent.TrieMap import scala.meta.internal.semanticdb.SymbolInformation import scala.meta.internal.symtab.SymbolTable -private[scala2plantuml] class LazySymbolTable(loader: SemanticdbLoader) extends SymbolTable { +private[scala2plantuml] class LazySymbolTable(loader: SemanticDBLoader) extends SymbolTable { private val logger = LoggerFactory.getLogger(classOf[LazySymbolTable]) private val cache = TrieMap.empty[String, SymbolInformation] diff --git a/core/src/main/scala/nz/co/bottech/scala2plantuml/SemanticDbLoader.scala b/core/src/main/scala/nz/co/bottech/scala2plantuml/SemanticDbLoader.scala index c444999..cab9098 100644 --- a/core/src/main/scala/nz/co/bottech/scala2plantuml/SemanticDbLoader.scala +++ b/core/src/main/scala/nz/co/bottech/scala2plantuml/SemanticDbLoader.scala @@ -1,5 +1,7 @@ package nz.co.bottech.scala2plantuml +import nz.co.bottech.scala2plantuml.SemanticDBLoader._ + import java.io.{File, FileInputStream, FilenameFilter, InputStream} import java.net.URL import java.nio.file.Paths @@ -9,10 +11,7 @@ import scala.meta.internal.semanticdb.Scala._ import scala.meta.internal.semanticdb.{TextDocument, TextDocuments} import scala.util.Using -private[scala2plantuml] class SemanticdbLoader(prefixes: Seq[String], classLoader: ClassLoader) { - - type Errors = Vector[String] - type Result = Either[Errors, Seq[TextDocument]] +private[scala2plantuml] class SemanticDBLoader(prefixes: Seq[String], classLoader: ClassLoader) { private val cache = TrieMap.empty[String, Result] @@ -78,10 +77,23 @@ private[scala2plantuml] class SemanticdbLoader(prefixes: Seq[String], classLoade TextDocuments.parseFrom(semanticdb).documents }.toEither.left.map(error => Vector(error.getLocalizedMessage)) - private def semanticdbPath(symbol: String): Either[Errors, String] = - if (symbol.isGlobal) - Right(s"${symbol.dropRight(1).takeWhile(_ != '#')}.scala.semanticdb") - else +} + +private[scala2plantuml] object SemanticDBLoader { + + private type Errors = Vector[String] + private type Result = Either[Errors, Seq[TextDocument]] + + private[scala2plantuml] def semanticdbPath(symbol: String): Either[Errors, String] = + if (symbol.isGlobal) { + val a = symbol.lastIndexOf('/') + val (start, end) = symbol.splitAt(a + 1) + val prefix = if (start == "_empty" || start == "_root_") "" else start + val name = end.takeWhile(c => c != '#' && c != '.') + if (name == "package") { + Right(s"${prefix.init}.scala.semanticdb") + } else Right(s"$prefix$name.scala.semanticdb") + } else Left(Vector(s"Symbol is not global: $symbol")) private def packagePath(path: String): Option[String] = { @@ -91,9 +103,10 @@ private[scala2plantuml] class SemanticdbLoader(prefixes: Seq[String], classLoade } private def findSemanticdbs(directory: File): Array[File] = - directory.listFiles(new FilenameFilter { + directory + .listFiles(new FilenameFilter { - override def accept(dir: File, name: String): Boolean = - name.endsWith(".semanticdb") - }) + override def accept(dir: File, name: String): Boolean = + name.endsWith(".semanticdb") + }) } diff --git a/core/src/test/scala/nz/co/bottech/scala2plantuml/SemanticDBLoaderTests.scala b/core/src/test/scala/nz/co/bottech/scala2plantuml/SemanticDBLoaderTests.scala new file mode 100644 index 0000000..dcd649f --- /dev/null +++ b/core/src/test/scala/nz/co/bottech/scala2plantuml/SemanticDBLoaderTests.scala @@ -0,0 +1,107 @@ +package nz.co.bottech.scala2plantuml + +import utest._ + +object SemanticDBLoaderTests extends TestSuite { + + val tests: Tests = Tests { + test("class") { + test("field") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#d.") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#d().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method default") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#d$default$1().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method overload") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#d(+1).") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method setter") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#d_=().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("constructor") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#``().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("type member") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#D#") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("inner object") { + val path = SemanticDBLoader.semanticdbPath("a/b/C#D.") + assert(Right("a/b/C.scala.semanticdb") == path) + } + } + test("object") { + test("field") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.d.") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.d().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method default") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.d$default$1().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method overload") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.d(+1).") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("method setter") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.d_=().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("constructor") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.``().") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("type member") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.D#") + assert(Right("a/b/C.scala.semanticdb") == path) + } + test("inner object") { + val path = SemanticDBLoader.semanticdbPath("a/b/C.D.") + assert(Right("a/b/C.scala.semanticdb") == path) + } + } + test("package object") { + test("method") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.d().") + assert(Right("a/b/c.scala.semanticdb") == path) + } + test("method default") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.d$default$1().") + assert(Right("a/b/c.scala.semanticdb") == path) + } + test("method overload") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.d(+1).") + assert(Right("a/b/c.scala.semanticdb") == path) + } + test("method setter") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.d_=().") + assert(Right("a/b/c.scala.semanticdb") == path) + } + test("constructor") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.``().") + assert(Right("a/b/c.scala.semanticdb") == path) + } + test("type member") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.D#") + assert(Right("a/b/c.scala.semanticdb") == path) + } + test("inner object") { + val path = SemanticDBLoader.semanticdbPath("a/b/c/package.D.") + assert(Right("a/b/c.scala.semanticdb") == path) + } + } + } +} diff --git a/docs/README.md b/docs/README.md index a344260..ee2621e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,23 @@ It comes as a standalone library, a CLI tool and an sbt plugin. Scala2PlantUML consumes [SemanticDB] files so you will need to know how to create those or simply follow the sbt setup instructions below. +## Example + +```shell +scala2plantuml \ + --url 'https://repo1.maven.org/maven2/nz/co/bottech/scala2plantuml-example_@SCALA_VERSION@/@VERSION@/scala2plantuml-example_@SCALA_VERSION@-@VERSION@.jar'\ + --project example \ + "nz/co/bottech/scala2plantuml/example/Main." +``` + +```scala mdoc:passthrough +println("```text") +nz.co.bottech.scala2plantuml.Scala2PlantUML.main(Array("--project", "example", "nz/co/bottech/scala2plantuml/example/Main.")) +println("```") +``` + +![Example Class Diagram](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.github.com/BotTech/scala2plantuml/main/example/example.md) + ## sbt ### Enable SemanticDB @@ -40,6 +57,21 @@ Create `~/.sbt/1.0/plugins/scala2PlantUML.sbt` containing: addSbtPlugin("nz.co.bottech" % "sbt-scala2plantuml" % "@VERSION@") ``` +### Generate the Diagram + +Run the `scala2PlantUML` task from sbt: + +```sbt +scala2PlantUML "com/example/Foo#" +``` + +This accepts the following arguments: +- `--include` +- `--exclude` +- `--output` + +Refer to the [CLI Usage](#usage) for the definition of these arguments. + ## CLI ### Install @@ -66,6 +98,18 @@ println("```") > 🚧 TODO: Document Library. +## Limitations + +- Only class diagrams are supported. +- Only inheritance or aggregations are supported, compositions are shown as aggregations. +- Aggregations are shown between types not between fields. There is a [bug][namespaced field links] in PlantUML which + prevents us from being able to do this reliably. +- There is no reliable way to determine the path to a SemanticDB file from any symbol. + If Scala2PlantUML is unable to find your symbols then the following may help: + - Only have a single top level type in each file. + - Ensure that the file name matches the type name. + - Nest any subclasses of a sealed class within the companion object of the sealed class. + [coursier]: https://get-coursier.io/docs/cli-install [plantuml]: https://plantuml.com/ [semanticdb]: https://scalameta.org/docs/semanticdb/guide.html diff --git a/docs/example/example.md b/docs/example/example.md new file mode 100644 index 0000000..f019b47 --- /dev/null +++ b/docs/example/example.md @@ -0,0 +1,3 @@ +```scala mdoc:passthrough +nz.co.bottech.scala2plantuml.Scala2PlantUML.main(Array("--project", "example", "nz/co/bottech/scala2plantuml/example/Main.")) +``` diff --git a/example/example.md b/example/example.md new file mode 100644 index 0000000..e82eba5 --- /dev/null +++ b/example/example.md @@ -0,0 +1,20 @@ +@startuml +class A extends B { + + {method} + + {method} b +} +A o-- C +interface B { + + {abstract} {method} b +} +B o-- C +class C { + + {method} + + {field} value +} +C o-- A +class Main { + + {static} {field} a +} +Main o-- A +@enduml diff --git a/example/src/main/scala/nz/co/bottech/scala2plantuml/example/Main.scala b/example/src/main/scala/nz/co/bottech/scala2plantuml/example/Main.scala index 49fbc47..615d467 100644 --- a/example/src/main/scala/nz/co/bottech/scala2plantuml/example/Main.scala +++ b/example/src/main/scala/nz/co/bottech/scala2plantuml/example/Main.scala @@ -2,7 +2,5 @@ package nz.co.bottech.scala2plantuml.example object Main extends App { - { - val _ = new A - } + val a = new A }