├── .git-blame-ignore-revs ├── .github └── workflows │ ├── ci.yml │ └── cla.yml ├── .gitignore ├── .scalafix.conf ├── .scalafmt.conf ├── CODE_OF_CONDUCT.md ├── LICENSE ├── NOTICE ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── main ├── resources │ └── scalac-plugin.xml └── scala │ └── com │ └── lightbend │ └── tools │ └── sculpt │ ├── cmd │ └── package.scala │ ├── model │ ├── ClassMode.scala │ ├── Components.scala │ ├── Cycles.scala │ ├── FullDependenciesPrinter.scala │ ├── ModelJsonProtocol.scala │ ├── TreePrinter.scala │ ├── graph.scala │ └── model.scala │ ├── plugin │ ├── ExtractDependencies.scala │ └── SculptPlugin.scala │ └── util │ └── package.scala └── test └── scala └── com └── lightbend └── tools └── sculpt ├── CyclesTests.scala ├── GraphTests.scala ├── IntegrationTest.scala ├── Samples.scala ├── SerializationTests.scala └── TreeTests.scala /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.8.3 2 | 3675303ce05e98548cc6f36447ab11142692cb87 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | java: [8, 11, 17, 21] 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: coursier/cache-action@v6 17 | - uses: actions/setup-java@v4 18 | with: 19 | distribution: temurin 20 | java-version: ${{matrix.java}} 21 | - uses: sbt/setup-sbt@v1 22 | - name: Test 23 | run: sbt test 24 | -------------------------------------------------------------------------------- /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: "Check Scala CLA" 2 | on: 3 | pull_request: 4 | jobs: 5 | cla-check: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Verify CLA 9 | uses: scala/cla-checker@v1 10 | with: 11 | author: ${{ github.event.pull_request.user.login }} 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | /.idea/ 3 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [OrganizeImports] 2 | OrganizeImports.groupedImports = AggressiveMerge 3 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.9.7 2 | runner.dialect = scala213 3 | maxColumn = 100 4 | newlines.source = keep 5 | newlines.alwaysBeforeElseAfterCurlyIf = true 6 | danglingParentheses.defnSite = false 7 | danglingParentheses.callSite = false 8 | rewrite.trailingCommas.style = keep 9 | binPack.literalsExclude = [] 10 | binPack.literalsIncludeSimpleExpr = true 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | All repositories in these organizations: 2 | 3 | * [lightbend](https://github.com/lightbend) 4 | * [akka](https://github.com/akka) 5 | * [lagom](https://github.com/lagom) 6 | * [playframework](https://github.com/playframework) 7 | * [sbt](https://github.com/sbt) 8 | * [slick](https://github.com/slick) 9 | 10 | are covered by the Lightbend Community Code of Conduct: https://www.lightbend.com/conduct 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | scala-sculpt 2 | Copyright 2015-2025 Lightbend Inc. dba Akka 3 | https://akka.io 4 | License: http://www.apache.org/licenses/LICENSE-2.0 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sculpt: dependency graph extraction for Scala 2 2 | 3 | Sculpt is a compiler plugin for analyzing the dependency structure of 4 | Scala 2.13 source code. 5 | 6 | ## Project status 7 | 8 | This is **unfinished**, **unmaintained** software. We are releasing 9 | it as open source as a public service with the hopes the code will be 10 | useful to someone. 11 | 12 | Sculpt is NOT supported under the Akka subscription. 13 | 14 | ## What is it for? 15 | 16 | The data generated by the plugin should be useful for all sorts of 17 | refactoring efforts, including carving a monolithic codebase into 18 | independent subprojects. 19 | 20 | The plugin analyzes source code, not generated bytecode. The analysis 21 | code is based on code from the incremental compiler in sbt and zinc. 22 | Therefore, the plugin should be an accurate source of information for 23 | developers looking to reduce dependencies in order to reduce 24 | incremental compile times. 25 | 26 | ## Building the plugin from source 27 | 28 | `sbt assembly` will create `target/scala-2.13/scala-sculpt_2.13-0.1.4.jar`. 29 | (The JAR is a fat JAR that bundles its dependency on spray-json.) 30 | 31 | ## Using the plugin 32 | 33 | You can use the compiled plugin with the Scala compiler as follows. 34 | 35 | Supposing you have `scala-sculpt_2.13-0.1.4.jar` in your current working directory, 36 | 37 | Then you can do e.g.: 38 | 39 | scalac -Xplugin:scala-sculpt_2.13-0.1.4.jar \ 40 | -Xplugin-require:sculpt \ 41 | -P:sculpt:out=dep.json \ 42 | Dep.scala 43 | 44 | ## Sample input and output 45 | 46 | Assuming `Dep.scala` contains this source code: 47 | 48 | object Dep1 { val x = 42; val y = Dep2.z } 49 | object Dep2 { val z = Dep1.x } 50 | 51 | then the command line shown above will generate this `dep.json` file: 52 | 53 | [ 54 | {"sym": ["o:Dep1"], "extends": ["pkt:scala", "tp:AnyRef"]}, 55 | {"sym": ["o:Dep1", "def:"], "uses": ["o:Dep1"]}, 56 | {"sym": ["o:Dep1", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 57 | {"sym": ["o:Dep1", "def:x"], "uses": ["o:Dep1", "t:x"]}, 58 | {"sym": ["o:Dep1", "def:x"], "uses": ["pkt:scala", "cl:Int"]}, 59 | {"sym": ["o:Dep1", "def:y"], "uses": ["o:Dep1", "t:y"]}, 60 | {"sym": ["o:Dep1", "def:y"], "uses": ["pkt:scala", "cl:Int"]}, 61 | {"sym": ["o:Dep1", "t:x"], "uses": ["pkt:scala", "cl:Int"]}, 62 | {"sym": ["o:Dep1", "t:y"], "uses": ["o:Dep2", "def:z"]}, 63 | {"sym": ["o:Dep1", "t:y"], "uses": ["ov:Dep2"]}, 64 | {"sym": ["o:Dep1", "t:y"], "uses": ["pkt:scala", "cl:Int"]}, 65 | {"sym": ["o:Dep2"], "extends": ["pkt:scala", "tp:AnyRef"]}, 66 | {"sym": ["o:Dep2", "def:"], "uses": ["o:Dep2"]}, 67 | {"sym": ["o:Dep2", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 68 | {"sym": ["o:Dep2", "def:z"], "uses": ["o:Dep2", "t:z"]}, 69 | {"sym": ["o:Dep2", "def:z"], "uses": ["pkt:scala", "cl:Int"]}, 70 | {"sym": ["o:Dep2", "t:z"], "uses": ["o:Dep1", "def:x"]}, 71 | {"sym": ["o:Dep2", "t:z"], "uses": ["ov:Dep1"]}, 72 | {"sym": ["o:Dep2", "t:z"], "uses": ["pkt:scala", "cl:Int"]} 73 | ] 74 | 75 | Each line in the JSON file represents an edge between two symbols in a 76 | dependency graph. 77 | 78 | The edges are of two types, `extends` and `uses`. 79 | 80 | Each symbol is represented in the JSON as an array of strings, where 81 | each string represents a part of the symbol's fully qualified name. 82 | 83 | So for example, in the above source code, we see that `Dep1` extends 84 | `scala.AnyRef`: 85 | 86 | {"sym": ["o:Dep1"], "extends": ["pkt:scala", "tp:AnyRef"]}, 87 | 88 | And we see that `Dep1` uses `scala.Int` in three places: 89 | 90 | {"sym": ["o:Dep1", "def:x"], "uses": ["pkt:scala", "cl:Int"]}, 91 | {"sym": ["o:Dep1", "def:y"], "uses": ["pkt:scala", "cl:Int"]}, 92 | {"sym": ["o:Dep1", "t:x"], "uses": ["pkt:scala", "cl:Int"]}, 93 | 94 | from this we see that `scala.Int` is used as the return type of 95 | `Dep1.x` and `Dep1.y`, and as the inferred type of the body of 96 | `Dep1.y`. 97 | 98 | For brevity, the following abbreviations are used in the JSON output: 99 | 100 | ### Terms 101 | 102 | abbreviation | meaning 103 | -------------|-------- 104 | ov | object 105 | def | def 106 | var | var 107 | mac | macro 108 | pk | package 109 | t | other term 110 | 111 | ### Types 112 | 113 | abbreviation | meaning 114 | -------------|-------- 115 | tr | trait 116 | pkt | package 117 | o | object 118 | cl | class 119 | tp | other type 120 | 121 | ### Other 122 | 123 | The name of a constructor is always ``. 124 | 125 | ## Running in "class mode" 126 | 127 | The dependency information produced by the default mode is extremely 128 | fine-grained; it goes all the way down to the level of individual 129 | methods. 130 | 131 | If you prefer an aggregated higher-level summary, you can run Sculpt 132 | in "class mode" by adding `-P:sculpt:mode=class`. So e.g. a complete 133 | invocation would look like: 134 | 135 | scalac -Xplugin:scala-sculpt_2.13-0.1.4.jar \ 136 | -Xplugin-require:sculpt \ 137 | -P:sculpt:out=classes.json \ 138 | -P:sculpt:mode=class \ 139 | Dep.scala 140 | 141 | on the same source code used in the example above, this command line 142 | generates this `classes.json` file: 143 | 144 | [ 145 | {"sym": ["o:Dep1"], "uses": ["o:Dep2"]}, 146 | {"sym": ["o:Dep1"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 147 | {"sym": ["o:Dep1"], "uses": ["pkt:scala", "cl:Int"]}, 148 | {"sym": ["o:Dep1"], "uses": ["pkt:scala", "tp:AnyRef"]}, 149 | {"sym": ["o:Dep2"], "uses": ["o:Dep1"]}, 150 | {"sym": ["o:Dep2"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 151 | {"sym": ["o:Dep2"], "uses": ["pkt:scala", "cl:Int"]}, 152 | {"sym": ["o:Dep2"], "uses": ["pkt:scala", "tp:AnyRef"]} 153 | ] 154 | 155 | Note that all of the nodes are top-level classes, traits, objects, or 156 | type aliases, and all of the edges are of type "uses". 157 | 158 | `-P:sculpt:mode=class` is provided as a convenience, but it isn't 159 | strictly needed, in that if you have already run Sculpt in default 160 | mode, you can convert detailed dependencies to class-level 161 | dependencies in the course of an interactive session. This 162 | is demonstrated in the sample interactive session below. 163 | 164 | ## Graphs represented as case classes 165 | 166 | The same JAR that contains the plugin also contains a suite of case 167 | classes for representing the same information in the JSON files as 168 | Scala objects. 169 | 170 | We provide a `load` method for parsing a JSON file into instances 171 | of these case classes, and a `save` method for writing the instances 172 | back out to JSON. 173 | 174 | These classes provide a possible starting point for graph analysis and 175 | manipulation, e.g. in the REPL. 176 | 177 | ### Sample interactive session 178 | 179 | Now in a Scala REPL with the same JARs on the classpath: 180 | 181 | scala -classpath scala-sculpt_2.13-0.1.4.jar 182 | 183 | If we load `dep.json` as follows, we'll see the following graph: 184 | 185 | scala> import com.lightbend.tools.sculpt.cmd._ 186 | import com.lightbend.tools.sculpt.cmd._ 187 | 188 | scala> load("dep.json") 189 | res0: com.lightbend.tools.sculpt.model.Graph = Graph 'dep.json': 15 nodes, 19 edges 190 | 191 | scala> println(res0.fullString) 192 | Graph 'dep.json': 15 nodes, 19 edges 193 | Nodes: 194 | - o:Dep1 195 | - pkt:scala.tp:AnyRef 196 | ... 197 | Edges: 198 | - o:Dep1 -[Extends]-> pkt:scala.tp:AnyRef 199 | - o:Dep1.def: -[Uses]-> o:Dep1 200 | ... 201 | 202 | #### Converting to class-level dependencies 203 | 204 | If we're interested in class-level dependencies only, we can 205 | call `load` with `classMode = true` in order to aggregate the 206 | dependencies after loading: 207 | 208 | scala> load("dep.json", classMode = true) 209 | res2: com.lightbend.tools.sculpt.model.Graph = Graph 'dep.json': 7 nodes, 10 edges 210 | 211 | #### Cycles and layers reports 212 | 213 | When untangling dependencies, circular dependencies are always 214 | especially problematic. We can identify these, list their contents, 215 | sort them by the total number of classes in the cycle, or print 216 | them grouped into layers according to their dependency structure. 217 | 218 | The cycles and layers reports operate on class-level dependencies 219 | only, so you must either run the plugin in "class mode", or convert 220 | from default mode to class mode at load time: 221 | 222 | Continuing the running example, here's a cycles report: 223 | 224 | scala> import com.lightbend.tools.sculpt.model.Cycles 225 | 226 | scala> println(Cycles.cyclesString(res2.nodes)) 227 | [2] o:Dep1 o:Dep2 228 | 229 | The report shows that the codebase contains a single cycle of size 2, 230 | because `Dep1` and `Dep2` mutually reference each other. ("Cycles" of 231 | a single node are omitted.) 232 | 233 | And here's the layers report for the same code: 234 | 235 | scala> println(res2.layersString) 236 | layers = 237 | """|[1] o:Dep1 o:Dep2 238 | |[0] cl:java.lang.Object 239 | |[0] cl:scala.Int 240 | |[0] tp:scala.AnyRef 241 | 242 | The numbers are layer numbers, defined as follows: 243 | 244 | * layer 0: classes with no dependencies 245 | * layer 1: classes with only layer 0 dependencies 246 | * layer 2: classes with only layer 0 and 1 dependencies 247 | * ... 248 | 249 | Note that some concepts of layered architectures require that layer n 250 | accesses only layer n - 1 and not any lower layers; we are not making 251 | that assumption here. 252 | 253 | Here's an example portion of a cycle report for a larger sample codebase: 254 | 255 | [8] tr:api.Agent tr:api.AgentSet tr:api.Link tr:api.Observer tr:api.Patch tr:api.TrailDrawerInterface tr:api.Turtle tr:api.World 256 | [5] cl:workspace.AbstractWorkspace cl:workspace.DefaultFileManager cl:workspace.Evaluator o:workspace.AbstractWorkspaceTraits o:workspace.Benchmarker 257 | [4] cl:agent.HorizCylinder cl:agent.Torus cl:agent.VertCylinder o:agent.Topology 258 | [3] cl:agent.AgentSet cl:agent.ArrayAgentSet o:agent.AgentSet 259 | 260 | (The numbers are cycle sizes.) 261 | 262 | And here's part of the layer report for the same codebase: 263 | 264 | [14] o:org.nlogo.headless.Main 265 | [14] o:org.nlogo.headless.Shell 266 | [13] o:org.nlogo.compile.middle.FrontMiddleBridge 267 | [13] o:org.nlogo.headless.HeadlessWorkspace 268 | [13] o:org.nlogo.mirror.ModelRunIO 269 | [12] o:org.nlogo.compile.back.BackEnd 270 | [12] o:org.nlogo.compile.middle.MiddleEnd 271 | 272 | showing just the topmost layers of the application. 273 | 274 | #### Modifying the graph 275 | 276 | We can explore the effect of removing edges from the graph using `removePaths`: 277 | 278 | scala> res0.removePaths("Dep2", "java.lang") 279 | 280 | scala> println(res0.fullString) 281 | Graph 'dep.json': 9 nodes, 8 edges 282 | Nodes: 283 | - o:Dep1 284 | - pkt:scala.tp:AnyRef 285 | - o:Dep1.def: 286 | - o:Dep1.def:x 287 | - o:Dep1.t:x 288 | - pkt:scala.cl:Int 289 | - o:Dep1.def:y 290 | - o:Dep1.t:y 291 | - ov:Dep1 292 | Edges: 293 | - o:Dep1 -[Extends]-> pkt:scala.tp:AnyRef 294 | - o:Dep1.def: -[Uses]-> o:Dep1 295 | - o:Dep1.def:x -[Uses]-> o:Dep1.t:x 296 | - o:Dep1.def:x -[Uses]-> pkt:scala.cl:Int 297 | - o:Dep1.def:y -[Uses]-> o:Dep1.t:y 298 | - o:Dep1.def:y -[Uses]-> pkt:scala.cl:Int 299 | - o:Dep1.t:x -[Uses]-> pkt:scala.cl:Int 300 | - o:Dep1.t:y -[Uses]-> pkt:scala.cl:Int 301 | 302 | Saving the graph back to a JSON model and loading it again: 303 | 304 | scala> save(res0, "dep2.json") 305 | 306 | scala> load("dep2.json") 307 | res5: com.lightbend.tools.sculpt.model.Graph = Graph 'dep2.json': 8 nodes, 8 edges 308 | 309 | scala> println(res5.fullString) 310 | Graph 'dep2.json': 8 nodes, 8 edges 311 | Nodes: 312 | - o:Dep1 313 | - pkt:scala.tp:AnyRef 314 | - o:Dep1.def: 315 | - o:Dep1.def:x 316 | - o:Dep1.t:x 317 | - pkt:scala.cl:Int 318 | - o:Dep1.def:y 319 | - o:Dep1.t:y 320 | Edges: 321 | - o:Dep1 -[Extends]-> pkt:scala.tp:AnyRef 322 | - o:Dep1.def: -[Uses]-> o:Dep1 323 | - o:Dep1.def:x -[Uses]-> o:Dep1.t:x 324 | - o:Dep1.def:x -[Uses]-> pkt:scala.cl:Int 325 | - o:Dep1.def:y -[Uses]-> o:Dep1.t:y 326 | - o:Dep1.def:y -[Uses]-> pkt:scala.cl:Int 327 | - o:Dep1.t:x -[Uses]-> pkt:scala.cl:Int 328 | - o:Dep1.t:y -[Uses]-> pkt:scala.cl:Int 329 | 330 | ## Future work 331 | 332 | Possible future directions include: 333 | 334 | * aggregation of dependency data at higher "zoom levels" (per-package, per-source-file) 335 | * user interface (perhaps via IDE integration) 336 | * automatic identification of problematic dependencies 337 | * “what-if” analyses exploring the effect of proposed code changes 338 | * offer a means of declaring and enforcing desired architectural constraints (allowed and forbidden dependencies) 339 | 340 | There are tickets on some of these at https://github.com/lightbend-labs/scala-sculpt/issues . 341 | 342 | ## Similar/related work 343 | 344 | * https://github.com/matanster/extractor 345 | * https://github.com/lihaoyi/acyclic 346 | * https://www.jetbrains.com/help/idea/dsm-analysis.html 347 | * http://classycle.sourceforge.net 348 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | organization := "com.lightbend" 2 | name := "scala-sculpt" 3 | version := "0.1.4-SNAPSHOT" 4 | licenses := Seq( 5 | "Apache 2" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 6 | homepage := Some(url("http://github.com/lightbend/scala-sculpt")) 7 | 8 | scalaVersion := "2.13.16" 9 | 10 | libraryDependencies ++= Seq( 11 | "org.scala-lang" % "scala-compiler" % scalaVersion.value % "provided", 12 | "io.spray" %% "spray-json" % "1.3.6", 13 | "org.scalameta" %% "munit" % "1.1.0" % Test, 14 | ) 15 | testFrameworks += new TestFramework("munit.Framework") 16 | 17 | // so we can run the Scala compiler during integration testing without 18 | // weird problems 19 | Test / fork := true 20 | 21 | // so the output of `Test/runMain ...Samples` doesn't get tagged with [info] 22 | Test / outputStrategy := Some(StdoutOutput) 23 | 24 | scalacOptions ++= Seq( 25 | "-deprecation", 26 | "-unchecked", 27 | "-feature", 28 | "-Xlint", 29 | "-Xfatal-warnings", 30 | ) 31 | 32 | // generate same JAR name as `package` would: 33 | // - don't append "-assembly"; see issue #18 34 | // - and do include the "_2.1x", don't know why assembly removes that by default 35 | assembly / assemblyJarName := 36 | s"${name.value}_${scalaBinaryVersion.value}-${version.value}.jar" 37 | 38 | assembly / assemblyOption := 39 | (assembly / assemblyOption).value.withIncludeScala(false) 40 | 41 | Compile / unmanagedResources ++= 42 | Seq("README.md", "LICENSE") 43 | .map(baseDirectory.value / _) 44 | 45 | pomExtra := ( 46 | https://github.com/lightbend/scala-sculpt.git 47 | scm:https://github.com/lightbend/scala-sculpt.git) 48 | 49 | // configure sbt-header -- to update, run 50 | // `headerCreate` and `test:headerCreate` 51 | headerMappings := headerMappings.value + (HeaderFileType.scala -> HeaderCommentStyle.cppStyleLineComment) 52 | headerLicense := Some(HeaderLicense.Custom( 53 | "Copyright (C) Lightbend Inc. ")) 54 | 55 | // scalafix; run with `scalafixEnable` followed by `scalafixAll` 56 | ThisBuild / scalafixDependencies += "com.github.liancheng" %% "organize-imports" % "0.6.0" 57 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") 2 | addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.10.0") 3 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2") 4 | -------------------------------------------------------------------------------- /src/main/resources/scalac-plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | sculpt 3 | com.lightbend.tools.sculpt.plugin.SculptPlugin 4 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/cmd/package.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | 5 | import com.lightbend.tools.sculpt.model.ModelJsonProtocol._ 6 | import com.lightbend.tools.sculpt.model._ 7 | import spray.json._ 8 | 9 | import scala.io.Codec 10 | 11 | /** REPL commands and features */ 12 | package object cmd { 13 | 14 | /** Load a Sculpt model JSON file and return the model */ 15 | def loadModel(path: String): Seq[FullDependency] = { 16 | val s = 17 | new scala.reflect.io.File(new java.io.File(path))(Codec.UTF8).slurp() 18 | s.parseJson.convertTo[Seq[FullDependency]] 19 | } 20 | 21 | /** Load a Sculpt model JSON file and return the graph. If classMode is true, convert detailed 22 | * dependencies to aggregated class-level dependencies. 23 | */ 24 | def load(path: String, classMode: Boolean = false): Graph = { 25 | val m0 = loadModel(path) 26 | val m = 27 | if (classMode) 28 | ClassMode(m0) 29 | else 30 | m0 31 | Graph(path, m) 32 | } 33 | 34 | /** Save a Sculpt model JSON file */ 35 | def saveModel(m: Seq[FullDependency], path: String): Unit = 36 | new scala.reflect.io.File(new java.io.File(path))(Codec.UTF8).writeAll( 37 | FullDependenciesPrinter(m.toJson)) 38 | 39 | /** Save a graph to a Sculpt model JSON file */ 40 | def save(graph: Graph, path: String): Unit = 41 | saveModel(graph.toJsonModel, path) 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/ClassMode.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | object ClassMode { 6 | 7 | // promotes all of the dependencies to class level: 8 | // * discarding self-dependencies 9 | // * collapsing the uses/extends distinction 10 | // * collapsing the Module/ModuleClass distinction 11 | // * ignoring irrelevant pseudo-dependencies 12 | // * setting all counts to 1 13 | // (real counts handling is possible future work) 14 | // * eliminating duplicates 15 | // * sorting, for testability and human-readability 16 | 17 | def apply(deps: Seq[FullDependency]): Seq[FullDependency] = { 18 | val promoted = 19 | for { 20 | dep <- deps 21 | from <- promote(dep.from) 22 | to <- promote(dep.to) 23 | if from != to 24 | } yield FullDependency( 25 | from = from, 26 | to = to, 27 | kind = DependencyKind.Uses, 28 | count = 1) 29 | promoted.distinct.sortBy(_.toString) 30 | } 31 | 32 | // Throws away too-specific stuff at the end of a path, so we end up with 33 | // a class-level path (or None). The too-specific stuff might be a method, 34 | // inner class, etc. 35 | 36 | def promote(path: Path): Option[Path] = 37 | path.elems.span(isPackage) match { 38 | case (packages, next +: _) => 39 | next.kind match { 40 | case k if isClassKind(k) => 41 | Some(Path(packages :+ next)) 42 | // collapse Module/ModuleClass distinction 43 | case EntityKind.Module => 44 | Some(Path(packages :+ next.copy(kind = EntityKind.ModuleClass))) 45 | // ignore strange dependencies on bare terms; 46 | // see https://github.com/lightbend/scala-sculpt/issues/28 47 | case EntityKind.Term if packages.isEmpty => 48 | None 49 | case _ => 50 | throw new IllegalArgumentException( 51 | s"unexpected entity kind after packages in $path") 52 | } 53 | case _ => 54 | None 55 | } 56 | 57 | def isPackage(entity: Entity): Boolean = 58 | entity.kind == EntityKind.Package || entity.kind == EntityKind.PackageType 59 | 60 | // The inclusion of EntityKind.Type may seem questionable, but it's needed 61 | // in order to pull in things like `extends scala.AnyRef` since AnyRef is 62 | // just a type alias for `java.lang.Object` 63 | 64 | private val isClassKind: EntityKind => Boolean = 65 | Set[EntityKind]( 66 | EntityKind.Trait, 67 | EntityKind.Class, 68 | EntityKind.ModuleClass, 69 | EntityKind.Type) 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/Components.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | // implements Tarjan's strongly connected components algorithm; see 6 | // https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm 7 | 8 | // parameterized on the node type, so the whole thing can be as abstract as possible. 9 | // `<: AnyRef` is an efficiency hack: it lets use `ne` instead of `!=` 10 | 11 | class Components[T <: AnyRef] { 12 | 13 | def apply(nodes: Iterable[T])(successors: T => Iterable[T]): Vector[Set[T]] = { 14 | 15 | val components = Vector.newBuilder[Set[T]] 16 | val indexer = new Indexer 17 | val stack = new Stack 18 | val lowestReachable = new Minimizer 19 | 20 | def recurse(node: T): Unit = { 21 | lowestReachable.update(node, indexer.tag(node)) 22 | stack.push(node) 23 | for (node2 <- successors(node)) 24 | if (!indexer.contains(node2)) { 25 | recurse(node2) 26 | lowestReachable.update(node, lowestReachable(node2)) 27 | } 28 | else if (stack.contains(node2)) 29 | lowestReachable.update(node, indexer(node2)) 30 | if (indexer(node) == lowestReachable(node)) 31 | components += stack.popUntil(node) 32 | } 33 | 34 | for (node <- nodes) 35 | if (!indexer.contains(node)) 36 | recurse(node) 37 | 38 | components.result() 39 | 40 | } 41 | 42 | // remembers lowest number seen for each item 43 | private class Minimizer { 44 | private val mins = collection.mutable.Map.empty[T, Int] 45 | def update(x: T, n: Int): Unit = 46 | if (mins.contains(x)) 47 | mins(x) = mins(x) min n 48 | else 49 | mins(x) = n 50 | def apply(x: T): Int = 51 | mins(x) 52 | } 53 | 54 | // assigns successive numbers to items; queryable 55 | private class Indexer { 56 | private val n = Iterator.from(0) 57 | private val indices = collection.mutable.Map.empty[T, Int] 58 | def tag(x: T): Int = { 59 | val next = n.next() 60 | indices(x) = next 61 | next 62 | } 63 | def apply(x: T): Int = 64 | indices(x) 65 | def contains(x: T): Boolean = 66 | indices.contains(x) 67 | } 68 | 69 | // stack plus set, so we can check for membership cheaply 70 | private class Stack { 71 | private val stack = collection.mutable.Stack.empty[T] 72 | private val set = collection.mutable.Set.empty[T] 73 | def contains(x: T): Boolean = 74 | set(x) 75 | def push(x: T): Unit = { 76 | stack.push(x) 77 | set += x 78 | } 79 | def pop(): T = { 80 | val result = stack.pop() 81 | set -= result 82 | result 83 | } 84 | // pop stack til we hit x; 85 | // return popped values including x itself 86 | def popUntil(x: T): Set[T] = 87 | (Iterator.continually(pop()) 88 | .takeWhile(_ ne x) 89 | .toSet) + x 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/Cycles.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | object Cycles { 6 | 7 | // input type 8 | type Nodes = Iterable[Node] 9 | 10 | // output types 11 | type Cycle = Set[Node] 12 | type Layer = Vector[Set[Node]] 13 | 14 | /** Cycles in the graph, in (a not-uniquely-determined) reverse topological order. The ordering 15 | * property means that edges never exist from earlier-listed to later-listed cycles. Even 16 | * one-node "cycles" are included in the output. 17 | */ 18 | def cycles(nodes: Nodes): Vector[Cycle] = 19 | (new Components)(nodes)(_.edgesOut.map(_.to)) 20 | 21 | /** Human-readable report of cycles (size > 1) in the graph, in descending order by size. 22 | */ 23 | def cyclesString(nodes: Nodes): String = 24 | cycles(nodes) 25 | .sortBy(-_.size) 26 | .takeWhile(_.size > 1) 27 | .map(cycle => s"[${cycle.size}] ${cycleString(cycle)}") 28 | .mkString("\n") 29 | 30 | private def cycleString(cycle: Cycle): String = 31 | cycle.toSeq.map(_.path.simpleString).sortBy(_.toString).mkString(" ") 32 | 33 | /** Layers in the graph, in ascending order */ 34 | def layers(nodes: Nodes): Vector[Layer] = { 35 | def recurse(seen: Set[Node], cycles: Vector[Cycle]): Vector[Layer] = 36 | if (cycles.isEmpty) 37 | Vector() 38 | else { 39 | val (layer, others) = 40 | cycles.partition { cycle => 41 | cycle.forall { n1 => 42 | n1.edgesOut.map(_.to).forall { n2 => cycle(n2) || seen(n2) } 43 | } 44 | } 45 | layer +: recurse(seen ++ layer.flatten, others) 46 | } 47 | recurse(Set(), cycles(nodes)) 48 | } 49 | 50 | /** Human-readable report of layers in graph, in descending order. */ 51 | def layersString(nodes: Nodes): String = 52 | layers(nodes) 53 | .zipWithIndex 54 | .reverse 55 | .map { case (layer, n) => 56 | layer.map(cycleString) 57 | .filter(_.nonEmpty) // omit empty package 58 | .map(s => s"[$n] $s\n") 59 | .sorted 60 | .mkString 61 | } 62 | .mkString 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/FullDependenciesPrinter.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | import spray.json._ 6 | 7 | import java.lang.StringBuilder 8 | 9 | // we didn't like the appearance of the output with either of spray-json's formatters 10 | // (`compactPrint` and `prettyPrint`), so we rolled our own 11 | 12 | object FullDependenciesPrinter extends JsonPrinter { 13 | 14 | def print(js: JsValue): String = { 15 | val sb = new java.lang.StringBuilder 16 | print(js, sb) 17 | sb.toString 18 | } 19 | 20 | override def print(x: JsValue, sb: StringBuilder): Unit = 21 | printRootArray(x.asInstanceOf[JsArray].elements, sb) 22 | 23 | protected def printRootArray(elements: Seq[JsValue], sb: StringBuilder): Unit = { 24 | sb.append("[\n ") 25 | printSeq(elements, sb.append(",\n "))(printCompact(_, sb)) 26 | sb.append("\n]") 27 | } 28 | 29 | def printCompact(x: JsValue, sb: StringBuilder): Unit = { 30 | x match { 31 | case JsObject(x) => printCompactObject(x, sb) 32 | case JsArray(x) => printCompactArray(x, sb) 33 | case _ => printLeaf(x, sb) 34 | } 35 | } 36 | 37 | protected def printCompactObject(members: Map[String, JsValue], sb: StringBuilder): Unit = { 38 | sb.append('{') 39 | printSeq(members, sb.append(", ")) { m => 40 | printString(m._1, sb) 41 | sb.append(": ") 42 | printCompact(m._2, sb) 43 | } 44 | sb.append('}') 45 | } 46 | 47 | protected def printCompactArray(elements: Seq[JsValue], sb: StringBuilder): Unit = { 48 | sb.append('[') 49 | printSeq(elements, sb.append(", "))(printCompact(_, sb)) 50 | sb.append(']') 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/ModelJsonProtocol.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | import spray.json._ 6 | 7 | /** JSON serialization/deserialization for the Sculpt model types */ 8 | object ModelJsonProtocol extends DefaultJsonProtocol { 9 | 10 | implicit object entityFormat extends JsonFormat[Entity] { 11 | def write(e: Entity) = new JsString(e.kind.prefix + ":" + e.name) 12 | 13 | private object Kind { 14 | def unapply(str: String): Option[EntityKind] = str match { 15 | case "ov" => Some(EntityKind.Module) 16 | case "def" => Some(EntityKind.Method) 17 | case "var" => Some(EntityKind.Mutable) 18 | case "mac" => Some(EntityKind.Macro) 19 | case "pk" => Some(EntityKind.Package) 20 | case "t" => Some(EntityKind.Term) 21 | case "tr" => Some(EntityKind.Trait) 22 | case "pkt" => Some(EntityKind.PackageType) 23 | case "o" => Some(EntityKind.ModuleClass) 24 | case "cl" => Some(EntityKind.Class) 25 | case "tp" => Some(EntityKind.Type) 26 | case _ => None 27 | } 28 | } 29 | def read(value: JsValue) = { 30 | val valueString = value.convertTo[String] 31 | val idx = valueString.indexOf(':') 32 | valueString.splitAt(idx) match { 33 | case (Kind(kind), n) => 34 | Entity(n.tail, kind) // n includes ':' so take tail 35 | case _ => throw new DeserializationException("'EntityKind:Name' string expected") 36 | } 37 | } 38 | } 39 | 40 | implicit object pathFormat extends JsonFormat[Path] { 41 | def write(p: Path) = p.elems.toJson 42 | def read(value: JsValue) = Path(value.convertTo[Vector[Entity]]) 43 | } 44 | 45 | implicit object fullDependencyFormat extends JsonFormat[FullDependency] { 46 | def write(d: FullDependency) = { 47 | val data = Seq( 48 | "sym" -> d.from.toJson, 49 | (d.kind match { 50 | case DependencyKind.Extends => "extends" 51 | case DependencyKind.Uses => "uses" 52 | }) -> d.to.toJson 53 | ) 54 | JsObject((if (d.count == 1) data else data :+ ("count" -> JsNumber(d.count))): _*) 55 | } 56 | def read(value: JsValue) = { 57 | val m = value.asJsObject.fields 58 | val from = m("sym").convertTo[Path] 59 | val (kind, to) = 60 | if (m.contains("uses")) (DependencyKind.Uses, m("uses").convertTo[Path]) 61 | else (DependencyKind.Extends, m("extends").convertTo[Path]) 62 | val count = m.get("count").map(_.convertTo[Int]).getOrElse(1) 63 | FullDependency(from, to, kind, count) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/TreePrinter.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | object TreePrinter { 6 | 7 | def apply(graph: Graph): String = { 8 | val sb = new StringBuilder 9 | sb ++= s"${graph.name}:\n" 10 | def traverse(node: Node, prefix: String = ""): Unit = { 11 | sb ++= prefix 12 | sb ++= s"└── ${node.path.elems.map(_.name).mkString(".")}\n" 13 | for (edge <- node.edgesOut) 14 | traverse(edge.to, prefix + " ") 15 | } 16 | for { 17 | node <- graph.nodes 18 | kind <- node.path.elems.lastOption.map(_.kind) // could be empty package 19 | if kind.isInstanceOf[EntityKind.AnyType] 20 | } traverse(node) 21 | sb.toString 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/graph.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | import scala.collection.mutable 6 | 7 | // abstract Node and Edge traits are agnostic about whether the 8 | // underlying data structures are mutable or immutable 9 | 10 | trait Node { 11 | def path: Path 12 | def edgesIn: Iterable[Edge] 13 | def edgesOut: Iterable[Edge] 14 | def connectTo(to: Node, kind: DependencyKind, count: Int): Edge 15 | def remove(): Boolean 16 | } 17 | 18 | trait Edge { 19 | def from: Node 20 | def to: Node 21 | def kind: DependencyKind 22 | def remove(): Boolean 23 | def count: Int 24 | override def toString = s"$from -[$kind]-> $to" 25 | } 26 | 27 | // the rest of the code in this file is a particular implementation 28 | // of Node and Edge built on mutable data structures 29 | 30 | class Graph(val name: String) { graph => 31 | 32 | // use LinkedHash* for deterministic ordering, for ease of testing 33 | private val nodesMap = mutable.LinkedHashMap[Path, Node]() 34 | private val edgesSet = mutable.LinkedHashSet[Edge]() 35 | 36 | def nodes: Iterable[Node] = nodesMap.values 37 | def edges: Iterable[Edge] = edgesSet 38 | 39 | def addNode(path: Path): Node = nodesMap.getOrElseUpdate(path, new GraphNode(path)) 40 | 41 | private[this] class GraphNode(val path: Path) extends Node { self => 42 | val in = mutable.LinkedHashMap[(Node, DependencyKind), Edge]() 43 | val out = mutable.LinkedHashMap[(Node, DependencyKind), Edge]() 44 | var dead = false 45 | def ensureNotDead[T](v: => T): T = 46 | if (dead) throw new IllegalStateException(s"Node '$this' has been removed from the graph") 47 | else v 48 | 49 | def edgesIn: Iterable[Edge] = ensureNotDead(in.values) 50 | def edgesOut: Iterable[Edge] = ensureNotDead(out.values) 51 | 52 | def connectTo(to: Node, kind: DependencyKind, count: Int): Edge = { 53 | val _to = to.asInstanceOf[GraphNode] 54 | ensureNotDead(out.getOrElseUpdate( 55 | (_to, kind), { 56 | val e = new GraphEdge(this, _to, kind, count) 57 | out.put((to, kind), e) 58 | _to.in.put((self, kind), e) 59 | graph.edgesSet.add(e) 60 | e 61 | })) 62 | } 63 | 64 | def remove(): Boolean = { 65 | if (dead) false 66 | else { 67 | edgesIn.foreach(_.remove()) 68 | edgesOut.foreach(_.remove()) 69 | graph.nodesMap.remove(path) 70 | dead = true 71 | true 72 | } 73 | } 74 | 75 | override def toString = path.toString 76 | } 77 | 78 | /** Remove all nodes (and their connecting edges) whose path matches one of the specified simple 79 | * path names (i.e. kinds are ignored, names concatenated by '.', no quotations). Descendents of 80 | * the specified paths are also removed. 81 | */ 82 | def removePaths(simplePaths: String*): Unit = { 83 | val s = simplePaths.toSet 84 | // first remove matching nodes 85 | nodes.foreach { n => 86 | val name = n.path.nameString 87 | if ( 88 | s.exists { p => 89 | name == p || name.startsWith(p + ".") 90 | } 91 | ) n.remove() 92 | } 93 | // after the first round of removals, we may now have "orphan" nodes 94 | // with no incoming or outgoing edges. we'll remove those too 95 | nodes.foreach { n => 96 | if (n.edgesIn.isEmpty && n.edgesOut.isEmpty) 97 | n.remove() 98 | } 99 | } 100 | 101 | private[this] class GraphEdge( 102 | _from: GraphNode, 103 | _to: GraphNode, 104 | _kind: DependencyKind, 105 | _count: Int) extends Edge { 106 | var dead = false 107 | def ensureNotDead[T](v: T): T = 108 | if (dead) throw new IllegalStateException(s"Edge '$this' has been removed from the graph") 109 | else v 110 | def from: Node = ensureNotDead(_from) 111 | def to: Node = ensureNotDead(_to) 112 | def kind = ensureNotDead(_kind) 113 | def count = ensureNotDead(_count) 114 | def remove(): Boolean = 115 | if (dead) false 116 | else { 117 | _from.out.remove((_to, _kind)) 118 | _to.in.remove((_from, kind)) 119 | graph.edgesSet.remove(this) 120 | dead = true 121 | true 122 | } 123 | } 124 | 125 | override def toString: String = s"Graph '$name': ${nodes.size} nodes, ${edges.size} edges" 126 | 127 | /** Create a full dump of the graph */ 128 | def fullString: String = { 129 | val b = new StringBuilder() 130 | b.append(toString + "\nNodes:\n") 131 | for (n <- nodes) b.append(s" - $n\n") 132 | b.append("Edges:") 133 | for (e <- edges) b.append(s"\n - $e") 134 | b.result() 135 | } 136 | 137 | def toJsonModel: Seq[FullDependency] = 138 | edgesSet.map(e => FullDependency(e.from.path, e.to.path, e.kind, e.count)).toSeq.sortBy( 139 | _.toString) 140 | } 141 | 142 | object Graph { 143 | def apply(name: String, model: Seq[FullDependency]): Graph = { 144 | val g = new Graph(name) 145 | for (d <- model) 146 | g.addNode(d.from).connectTo(g.addNode(d.to), d.kind, d.count) 147 | g 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/model/model.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.model 4 | 5 | import scala.reflect.internal.Symbols 6 | 7 | sealed trait EntityKind { 8 | def prefix: String 9 | } 10 | object EntityKind { 11 | sealed abstract class AnyTerm(val prefix: String) extends EntityKind 12 | sealed abstract class AnyType(val prefix: String) extends EntityKind 13 | 14 | case object Module extends AnyTerm("ov") 15 | case object Method extends AnyTerm("def") 16 | case object Mutable extends AnyTerm("var") 17 | case object Macro extends AnyTerm("mac") 18 | case object Package extends AnyTerm("pk") 19 | case object Term extends AnyTerm("t") 20 | 21 | case object Trait extends AnyType("tr") 22 | case object PackageType extends AnyType("pkt") 23 | case object ModuleClass extends AnyType("o") 24 | case object Class extends AnyType("cl") 25 | case object Type extends AnyType("tp") 26 | } 27 | 28 | sealed trait DependencyKind 29 | object DependencyKind { 30 | case object Extends extends DependencyKind 31 | case object Uses extends DependencyKind 32 | } 33 | 34 | case class Entity(name: String, kind: EntityKind) { 35 | override def toString = kind.prefix + ":" + name 36 | } 37 | 38 | case class Path(elems: Seq[Entity]) { 39 | override def toString = elems.mkString(".") 40 | def kindString = elems.lastOption.map(_.kind.prefix).getOrElse("") 41 | def nameString = elems.map(_.name).mkString(".") 42 | def simpleString = s"$kindString:$nameString" 43 | } 44 | 45 | object Entity { 46 | def forSymbol(sym: Symbols#Symbol): Entity = { 47 | val kind = 48 | if (sym.isType) { 49 | if (sym.isTrait) EntityKind.Trait 50 | else if (sym.hasPackageFlag) EntityKind.PackageType 51 | else if (sym.isModuleClass) EntityKind.ModuleClass 52 | else if (sym.isClass) EntityKind.Class 53 | else EntityKind.Type 54 | } 55 | else { // Term 56 | if (sym.hasPackageFlag) EntityKind.Package 57 | else if (sym.isTermMacro) EntityKind.Macro 58 | else if (sym.isModule) EntityKind.Module 59 | else if (sym.isMethod) EntityKind.Method 60 | else if (sym.isVariable) EntityKind.Mutable 61 | else EntityKind.Term 62 | } 63 | Entity(sym.nameString, kind) 64 | } 65 | } 66 | 67 | case class FullDependency(from: Path, to: Path, kind: DependencyKind, count: Int) { 68 | override def toString = { 69 | val s = s"$from ${kind.toString.toLowerCase} $to" 70 | if (count == 1) s else s"$s [$count]" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/plugin/ExtractDependencies.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.plugin 4 | 5 | import com.lightbend.tools.sculpt.model.ModelJsonProtocol._ 6 | import com.lightbend.tools.sculpt.model._ 7 | import spray.json._ 8 | 9 | import java.io.File 10 | import scala.collection.mutable 11 | import scala.collection.mutable.HashSet 12 | import scala.io.Codec 13 | import scala.reflect.internal.Flags.PACKAGE 14 | import scala.tools.nsc 15 | 16 | import nsc.plugins._ 17 | 18 | // adapted from the incremental compiler 19 | abstract class ExtractDependencies extends PluginComponent { 20 | import global._ 21 | 22 | /** The output file to write to, or None for stdout */ 23 | def outputPath: Option[File] 24 | 25 | def classMode: Boolean 26 | 27 | override def description = "Extract Dependency Phase for Scala Sculpt" 28 | 29 | /** the following two members override abstract members in Transform */ 30 | val phaseName: String = "extract-deps" 31 | 32 | /** Create a new phase which applies transformer */ 33 | def newPhase(prev: nsc.Phase): StdPhase = new Phase(prev) 34 | 35 | type MultiMapIterator = Iterator[(Symbol, HashSet[Symbol])] 36 | 37 | /** The phase defined by this transform */ 38 | class Phase(prev: nsc.Phase) extends StdPhase(prev) { 39 | private var extractDependenciesTraverser: ExtractDependenciesTraverser = null 40 | 41 | override def run(): Unit = { 42 | extractDependenciesTraverser = new ExtractDependenciesTraverser 43 | super.run() 44 | val deps = extractDependenciesTraverser.dependencies 45 | val inheritDeps = extractDependenciesTraverser.inheritanceDependencies 46 | extractDependenciesTraverser = null 47 | val fullDependencies = 48 | (createFullDependencies(deps, DependencyKind.Uses) ++ createFullDependencies( 49 | inheritDeps, 50 | DependencyKind.Extends)) 51 | .filterNot(d => d.from == d.to) 52 | .groupBy(identity).map { case (d, l) => d.copy(count = l.size) }.toSeq.sortBy(_.toString) 53 | val json = 54 | if (classMode) 55 | ClassMode(fullDependencies).toJson 56 | else 57 | fullDependencies.toJson 58 | writeOutput(FullDependenciesPrinter(json)) 59 | } 60 | 61 | def apply(unit: CompilationUnit) = extractDependenciesTraverser.traverse(unit.body) 62 | 63 | def writeOutput(s: String): Unit = { 64 | outputPath match { 65 | case Some(f) => new scala.reflect.io.File(f)(Codec.UTF8).writeAll(s) 66 | case None => print(s) 67 | } 68 | } 69 | 70 | def createFullDependencies( 71 | syms: MultiMapIterator, 72 | kind: DependencyKind): Seq[FullDependency] = { 73 | def entitiesFor(s: Symbol) = 74 | Path(s.ownerChain.reverse.dropWhile(s => s.isEffectiveRoot || s.isEmptyPackage).map( 75 | Entity.forSymbol _)) 76 | (for { 77 | (from, tos) <- syms 78 | fromEntities = entitiesFor(from) 79 | to <- tos 80 | } yield FullDependency(fromEntities, entitiesFor(to), kind, 1)).toSeq 81 | } 82 | } 83 | 84 | private class ExtractDependenciesTraverser extends Traverser { 85 | import collection.mutable.{HashMap, HashSet} 86 | private def emptyMultiMap: mutable.Map[Symbol, HashSet[Symbol]] = 87 | HashMap.empty[Symbol, HashSet[Symbol]].withDefault(_ => HashSet.empty[Symbol]) 88 | 89 | private val deps = emptyMultiMap 90 | protected def addDependency(dep: Symbol): Unit = if (dep ne NoSymbol) deps(currentOwner) +== dep 91 | def dependencies: MultiMapIterator = deps.iterator 92 | 93 | private val inheritanceDeps = emptyMultiMap 94 | protected def addInheritanceDependency(dep: Symbol): Unit = 95 | if (dep ne NoSymbol) inheritanceDeps(currentOwner) +== dep 96 | def inheritanceDependencies: MultiMapIterator = inheritanceDeps.iterator 97 | 98 | /* 99 | * Some macros appear to contain themselves as original tree. 100 | * We must check that we don't inspect the same tree over and over. 101 | * See https://github.com/scala/bug/issues/8486 102 | * https://github.com/sbt/sbt/issues/1237 103 | * https://github.com/sbt/sbt/issues/1544 104 | */ 105 | private val inspectedOriginalTrees = collection.mutable.Set.empty[Tree] 106 | 107 | object MacroExpansionOf { 108 | def unapply(tree: Tree): Option[Tree] = 109 | tree.attachments.all.collect { 110 | case att: analyzer.MacroExpansionAttachment => att.expandee 111 | }.headOption 112 | } 113 | 114 | // skip packages 115 | private def symbolsInType(tp: Type) = tp.collect { 116 | case part if part != null && !part.typeSymbolDirect.hasFlag(PACKAGE) => 117 | part.typeSymbolDirect 118 | }.toSet 119 | private def flattenTypeToSymbols(tp: Type): List[Symbol] = 120 | if (tp eq null) Nil 121 | else tp match { 122 | case ct: CompoundType => ct.typeSymbolDirect :: ct.parents.flatMap(flattenTypeToSymbols) 123 | case _ => List(tp.typeSymbolDirect) 124 | } 125 | 126 | override def traverse(tree: Tree): Unit = 127 | tree match { 128 | /* 129 | * Idents are used in number of situations: 130 | * - to refer to local variable 131 | * - to refer to a top-level package (other packages are nested selections) 132 | * - to refer to a term defined in the same package as an enclosing class; 133 | * this looks fishy, see this thread: 134 | * https://groups.google.com/d/topic/scala-internals/Ms9WUAtokLo/discussion 135 | * 136 | * Select 137 | * 138 | * SelectFromTypeTree 139 | */ 140 | case id: Ident => addDependency(id.symbol) 141 | case sel @ Select(qual, _) => traverse(qual); addDependency(sel.symbol) 142 | case sel @ SelectFromTypeTree(qual, _) => traverse(qual); addDependency(sel.symbol) 143 | 144 | // In some cases (eg. macro annotations), `typeTree.tpe` may be null. 145 | // See sbt/sbt#1593 and sbt/sbt#1655. 146 | case typeTree: TypeTree if typeTree.tpe != null => 147 | symbolsInType(typeTree.tpe).foreach(addDependency) 148 | 149 | case Template(parents, self, body) => 150 | val inheritanceTypes = (self.tpt.tpe :: parents.map(_.tpe)).toSet 151 | val inheritanceSymbols = inheritanceTypes.flatMap(flattenTypeToSymbols) 152 | inheritanceSymbols.foreach(addInheritanceDependency) 153 | 154 | val allSymbols = inheritanceTypes.flatMap(symbolsInType) 155 | (allSymbols diff inheritanceSymbols).foreach(addDependency) 156 | traverseTrees(body) 157 | 158 | case MacroExpansionOf(original) if inspectedOriginalTrees.add(original) => 159 | traverse(original) 160 | 161 | // imports are a separate issue (remove unused ones, rewrite ones that were moved) 162 | // case Import(expr, selectors) => 163 | // selectors.foreach { 164 | // case ImportSelector(nme.WILDCARD, _, null, _) => 165 | // // in case of wildcard import we do not rely on any particular name being defined 166 | // // on `expr`; all symbols that are being used will get caught through selections 167 | // case ImportSelector(name: Name, _, _, _) => 168 | // def lookupImported(name: Name) = expr.symbol.info.member(name) 169 | // // importing a name means importing both a term and a type (if they exist) 170 | // addDependency(lookupImported(name.toTermName)) 171 | // addDependency(lookupImported(name.toTypeName)) 172 | // } 173 | 174 | case _ => super.traverse(tree) 175 | } 176 | 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/plugin/SculptPlugin.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt.plugin 4 | 5 | import com.lightbend.tools.sculpt.util.RegexInterpolator 6 | 7 | import java.io.File 8 | import scala.tools.nsc 9 | 10 | import nsc.Global 11 | import nsc.plugins.{Plugin, PluginComponent} 12 | 13 | class SculptPlugin(val global: Global) extends Plugin { 14 | val name = "sculpt" 15 | val description = "Aid in modularizing big codebases" 16 | 17 | object extractDependencies extends ExtractDependencies { 18 | val global = SculptPlugin.this.global 19 | val runsAfter = List("refchecks") 20 | var outputPath: Option[File] = None 21 | var classMode = false 22 | } 23 | 24 | val components = List[PluginComponent](extractDependencies) 25 | 26 | override val optionsHelp: Option[String] = Some( 27 | " -P:sculpt:out= Path to write dependency file to (default: stdout)\n" + 28 | " -P:sculpt:mode=class Run in 'class mode' instead of default fine-grained mode" 29 | ) 30 | 31 | override def init(options: List[String], error: String => Unit) = { 32 | options.foreach { 33 | case r"out=(.*)$out" => 34 | extractDependencies.outputPath = Some(new File(out)) 35 | case r"mode=class" => 36 | extractDependencies.classMode = true 37 | case arg => 38 | error(s"Bad argument: $arg") 39 | } 40 | true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/com/lightbend/tools/sculpt/util/package.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | 5 | package object util { 6 | implicit class RegexInterpolator(sc: StringContext) { 7 | def r = new scala.util.matching.Regex(sc.parts.mkString, sc.parts.tail.map(_ => "x"): _*) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/test/scala/com/lightbend/tools/sculpt/CyclesTests.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | import model._ 5 | 6 | object CyclesTests { 7 | // also used by Samples.main 8 | def toCyclesAndLayersStrings(name: String, json: String): (String, String) = { 9 | val graph: Graph = { 10 | import spray.json._ 11 | import ModelJsonProtocol._ 12 | val deps = json.parseJson.convertTo[Seq[FullDependency]] 13 | val classJson = FullDependenciesPrinter.print(ClassMode(deps).toJson) 14 | GraphTests.toGraph(name, classJson) 15 | } 16 | (Cycles.cyclesString(graph.nodes), Cycles.layersString(graph.nodes)) 17 | } 18 | } 19 | 20 | class CyclesTests extends munit.FunSuite { 21 | for (sample <- Samples.samples) 22 | test(sample.name) { 23 | val (cycles, layers) = CyclesTests.toCyclesAndLayersStrings(sample.name, sample.json) 24 | assert(sample.cycles == cycles) 25 | assert(sample.layers == layers) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/scala/com/lightbend/tools/sculpt/GraphTests.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | import model._ 5 | 6 | object GraphTests { 7 | // also used by Samples.main 8 | def toGraph(name: String, json: String): Graph = { 9 | import spray.json._ 10 | import ModelJsonProtocol._ 11 | val dependencies = json.parseJson.convertTo[Seq[FullDependency]] 12 | Graph.apply(name, dependencies) 13 | } 14 | } 15 | 16 | class GraphTests extends munit.FunSuite { 17 | 18 | // test reading JSON and generating a human-readable dump of 19 | // the resulting Graph object 20 | for { 21 | sample <- Samples.samples 22 | } test(sample.name) { 23 | assert(sample.graph == 24 | GraphTests.toGraph(sample.name, sample.json).fullString) 25 | } 26 | 27 | // test `removePaths` as demonstrated in the readme 28 | test("readme removePaths") { 29 | val graph = { 30 | val sample = Samples.samples.find(_.name == "readme").get 31 | GraphTests.toGraph(sample.name, sample.json) 32 | } 33 | assert(graph.fullString.contains("pkt:java.pkt:lang")) 34 | assert(graph.fullString.contains("Dep2")) 35 | assert((15, 19) == ((graph.nodes.size, graph.edges.size))) 36 | graph.removePaths("Dep2", "java.lang") 37 | val expected = 38 | """|Graph 'readme': 8 nodes, 8 edges 39 | |Nodes: 40 | | - o:Dep1 41 | | - pkt:scala.tp:AnyRef 42 | | - o:Dep1.def: 43 | | - o:Dep1.def:x 44 | | - o:Dep1.t:x 45 | | - pkt:scala.cl:Int 46 | | - o:Dep1.def:y 47 | | - o:Dep1.t:y 48 | |Edges: 49 | | - o:Dep1 -[Extends]-> pkt:scala.tp:AnyRef 50 | | - o:Dep1.def: -[Uses]-> o:Dep1 51 | | - o:Dep1.def:x -[Uses]-> o:Dep1.t:x 52 | | - o:Dep1.def:x -[Uses]-> pkt:scala.cl:Int 53 | | - o:Dep1.def:y -[Uses]-> o:Dep1.t:y 54 | | - o:Dep1.def:y -[Uses]-> pkt:scala.cl:Int 55 | | - o:Dep1.t:x -[Uses]-> pkt:scala.cl:Int 56 | | - o:Dep1.t:y -[Uses]-> pkt:scala.cl:Int""".stripMargin.replaceAll("\\r\\n", "\n") 57 | assert(expected == graph.fullString) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/test/scala/com/lightbend/tools/sculpt/IntegrationTest.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | 5 | import scala.reflect.internal.util.BatchSourceFile 6 | import scala.tools.nsc.io.VirtualDirectory 7 | import scala.tools.nsc.{Global, Settings} 8 | 9 | object Scaffold { 10 | 11 | val classes: String = 12 | new java.io.File("./target/scala-2.13/classes") 13 | .ensuring(_.exists) 14 | .getAbsolutePath 15 | 16 | def defaultSettings: Settings = { 17 | val settings = new Settings 18 | settings.processArgumentString( 19 | s"-usejavacp -Xplugin:$classes -Xplugin-require:sculpt") 20 | settings.outputDirs.setSingleOutput( 21 | new VirtualDirectory("(memory)", None)) 22 | settings 23 | } 24 | 25 | def analyze(code: String, classMode: Boolean = false): String = { 26 | val out = java.io.File.createTempFile("sculpt", "json", null) 27 | val modeSetting = 28 | if (classMode) 29 | " -P:sculpt:mode=class" 30 | else 31 | "" 32 | val settings = defaultSettings 33 | settings.processArgumentString(s"-P:sculpt:out=$out$modeSetting") 34 | val sources = List(new BatchSourceFile("", code)) 35 | val compiler = new Global(settings) 36 | (new compiler.Run).compileSources(sources) 37 | scala.io.Source.fromFile(out).mkString 38 | } 39 | 40 | } 41 | 42 | class IntegrationTest extends munit.FunSuite { 43 | def check(s: Sample): Unit = { 44 | assert(s.json == Scaffold.analyze(s.source)) 45 | assert(s.classJson == Scaffold.analyze(s.source, classMode = true)) 46 | } 47 | for (sample <- Samples.samples) 48 | test(sample.name) { 49 | check(sample) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/scala/com/lightbend/tools/sculpt/Samples.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | 5 | import model._ 6 | 7 | // to add a new sample (or update an existing one): 8 | // test:runMain com.lightbend.tools.sculpt.Samples name 9 | // and paste the results below 10 | 11 | class Sample( 12 | val name: String, 13 | val source: String, 14 | val json: String, 15 | val classJson: String, 16 | val graph: String, 17 | val tree: String, 18 | val cycles: String = "", 19 | val layers: String = "" 20 | ) { 21 | Samples.samples += this 22 | } 23 | 24 | object Sample { 25 | def apply( 26 | name: String, 27 | source: String, 28 | json: String, 29 | classJson: String, 30 | graph: String, 31 | tree: String, 32 | cycles: String = "", 33 | layers: String = "" 34 | ): Sample = 35 | new Sample( 36 | name, 37 | source.replaceAll("\\r\\n", "\n"), 38 | json.replaceAll("\\r\\n", "\n"), 39 | classJson.replaceAll("\\r\\n", "\n"), 40 | graph.replaceAll("\\r\\n", "\n"), 41 | tree.replaceAll("\\r\\n", "\n"), 42 | cycles.replaceAll("\\r\\n", "\n"), 43 | layers.replaceAll("\\r\\n", "\n") 44 | ) 45 | } 46 | 47 | object Samples { 48 | 49 | def main(args: Array[String]): Unit = { 50 | import spray.json._ 51 | import ModelJsonProtocol._ 52 | val (name, source) = (args.head, args.tail.mkString(" ")) 53 | val json = Scaffold.analyze(source) 54 | val classJson = { 55 | val deps = json.parseJson.convertTo[Seq[FullDependency]] 56 | FullDependenciesPrinter.print(ClassMode(deps).toJson) 57 | } 58 | val graph = GraphTests.toGraph(name, json).fullString 59 | val tree = TreeTests.toTreeString(name, json) + "\n" 60 | val (cycles, layers) = CyclesTests.toCyclesAndLayersStrings(name, classJson) 61 | def triple(s: String): String = 62 | s.linesIterator.mkString("\"\"\"|", "\n |", "\"\"\".stripMargin") 63 | println( 64 | s"""@ Sample( 65 | @ name = "$name", 66 | @ source = 67 | @ ${triple(source)}, 68 | @ json = 69 | @ ${triple(json)}, 70 | @ classJson = 71 | @ ${triple(classJson)}, 72 | @ graph = 73 | @ ${triple(graph)}, 74 | @ tree = 75 | @ ${triple(tree)}, 76 | @ cycles = 77 | @ ${triple(cycles)}, 78 | @ layers = 79 | @ ${triple(layers.trim + "\n\n")})""".stripMargin('@')) 80 | } 81 | 82 | val samples = collection.mutable.Buffer.empty[Sample] 83 | 84 | // test:runMain com.lightbend.tools.sculpt.Samples "lone object" "object O" 85 | Sample( 86 | name = "lone object", 87 | source = 88 | """|object O""".stripMargin, 89 | json = 90 | """|[ 91 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["o:O"]}, 92 | | {"sym": ["o:O", "def:"], "uses": ["o:O"]}, 93 | | {"sym": ["o:O", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]} 94 | |]""".stripMargin, 95 | classJson = 96 | """|[ 97 | | {"sym": ["o:O"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 98 | | {"sym": ["o:O"], "uses": ["pkt:scala", "tp:AnyRef"]} 99 | |]""".stripMargin, 100 | graph = 101 | """|Graph 'lone object': 4 nodes, 3 edges 102 | |Nodes: 103 | | - o:O 104 | | - pkt:scala.tp:AnyRef 105 | | - o:O.def: 106 | | - pkt:java.pkt:lang.cl:Object.def: 107 | |Edges: 108 | | - o:O -[Extends]-> pkt:scala.tp:AnyRef 109 | | - o:O.def: -[Uses]-> o:O 110 | | - o:O.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def:""".stripMargin, 111 | tree = 112 | """|lone object: 113 | |└── O 114 | | └── scala.AnyRef 115 | |└── scala.AnyRef 116 | |""".stripMargin, 117 | cycles = 118 | """|""".stripMargin, 119 | layers = 120 | """|[1] o:O 121 | |[0] cl:java.lang.Object 122 | |[0] tp:scala.AnyRef 123 | |""".stripMargin) 124 | 125 | // test:runMain com.lightbend.tools.sculpt.Samples "two subclasses" "trait T; class C1 extends T; class C2 extends T" 126 | Sample( 127 | name = "two subclasses", 128 | source = 129 | """|trait T; class C1 extends T; class C2 extends T""".stripMargin, 130 | json = 131 | """|[ 132 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["cl:C1"]}, 133 | | {"extends": ["tr:T"], "sym": ["cl:C1"]}, 134 | | {"sym": ["cl:C1", "def:"], "uses": ["cl:C1"]}, 135 | | {"sym": ["cl:C1", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 136 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["cl:C2"]}, 137 | | {"extends": ["tr:T"], "sym": ["cl:C2"]}, 138 | | {"sym": ["cl:C2", "def:"], "uses": ["cl:C2"]}, 139 | | {"sym": ["cl:C2", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 140 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T"]} 141 | |]""".stripMargin, 142 | classJson = 143 | """|[ 144 | | {"sym": ["cl:C1"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 145 | | {"sym": ["cl:C1"], "uses": ["pkt:scala", "tp:AnyRef"]}, 146 | | {"sym": ["cl:C1"], "uses": ["tr:T"]}, 147 | | {"sym": ["cl:C2"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 148 | | {"sym": ["cl:C2"], "uses": ["pkt:scala", "tp:AnyRef"]}, 149 | | {"sym": ["cl:C2"], "uses": ["tr:T"]}, 150 | | {"sym": ["tr:T"], "uses": ["pkt:scala", "tp:AnyRef"]} 151 | |]""".stripMargin, 152 | graph = 153 | """|Graph 'two subclasses': 7 nodes, 9 edges 154 | |Nodes: 155 | | - cl:C1 156 | | - pkt:scala.tp:AnyRef 157 | | - tr:T 158 | | - cl:C1.def: 159 | | - pkt:java.pkt:lang.cl:Object.def: 160 | | - cl:C2 161 | | - cl:C2.def: 162 | |Edges: 163 | | - cl:C1 -[Extends]-> pkt:scala.tp:AnyRef 164 | | - cl:C1 -[Extends]-> tr:T 165 | | - cl:C1.def: -[Uses]-> cl:C1 166 | | - cl:C1.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 167 | | - cl:C2 -[Extends]-> pkt:scala.tp:AnyRef 168 | | - cl:C2 -[Extends]-> tr:T 169 | | - cl:C2.def: -[Uses]-> cl:C2 170 | | - cl:C2.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 171 | | - tr:T -[Extends]-> pkt:scala.tp:AnyRef""".stripMargin, 172 | tree = 173 | """|two subclasses: 174 | |└── C1 175 | | └── scala.AnyRef 176 | | └── T 177 | | └── scala.AnyRef 178 | |└── scala.AnyRef 179 | |└── T 180 | | └── scala.AnyRef 181 | |└── C2 182 | | └── scala.AnyRef 183 | | └── T 184 | | └── scala.AnyRef 185 | |""".stripMargin, 186 | cycles = 187 | """|""".stripMargin, 188 | layers = 189 | """|[2] cl:C1 190 | |[2] cl:C2 191 | |[1] tr:T 192 | |[0] cl:java.lang.Object 193 | |[0] tp:scala.AnyRef 194 | |""".stripMargin) 195 | 196 | // test:runMain com.lightbend.tools.sculpt.Samples "circular dependency" "trait T1 { def x: T2 }; trait T2 { def x: T1 }" 197 | Sample( 198 | name = "circular dependency", 199 | source = 200 | """|trait T1 { def x: T2 }; trait T2 { def x: T1 }""".stripMargin, 201 | json = 202 | """|[ 203 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T1"]}, 204 | | {"sym": ["tr:T1", "def:x"], "uses": ["tr:T2"]}, 205 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T2"]}, 206 | | {"sym": ["tr:T2", "def:x"], "uses": ["tr:T1"]} 207 | |]""".stripMargin, 208 | classJson = 209 | """|[ 210 | | {"sym": ["tr:T1"], "uses": ["pkt:scala", "tp:AnyRef"]}, 211 | | {"sym": ["tr:T1"], "uses": ["tr:T2"]}, 212 | | {"sym": ["tr:T2"], "uses": ["pkt:scala", "tp:AnyRef"]}, 213 | | {"sym": ["tr:T2"], "uses": ["tr:T1"]} 214 | |]""".stripMargin, 215 | graph = 216 | """|Graph 'circular dependency': 5 nodes, 4 edges 217 | |Nodes: 218 | | - tr:T1 219 | | - pkt:scala.tp:AnyRef 220 | | - tr:T1.def:x 221 | | - tr:T2 222 | | - tr:T2.def:x 223 | |Edges: 224 | | - tr:T1 -[Extends]-> pkt:scala.tp:AnyRef 225 | | - tr:T1.def:x -[Uses]-> tr:T2 226 | | - tr:T2 -[Extends]-> pkt:scala.tp:AnyRef 227 | | - tr:T2.def:x -[Uses]-> tr:T1""".stripMargin, 228 | tree = 229 | """|circular dependency: 230 | |└── T1 231 | | └── scala.AnyRef 232 | |└── scala.AnyRef 233 | |└── T2 234 | | └── scala.AnyRef 235 | |""".stripMargin, 236 | cycles = 237 | """|[2] tr:T1 tr:T2""".stripMargin, 238 | layers = 239 | """|[1] tr:T1 tr:T2 240 | |[0] tp:scala.AnyRef 241 | |""".stripMargin) 242 | 243 | // test:runMain com.lightbend.tools.sculpt.Samples "3-cycle" "trait T1 { def t: T2 }; trait T2 { def t: T3 }; trait T3 { def t: T1 }" 244 | Sample( 245 | name = "3-cycle", 246 | source = 247 | """|trait T1 { def t: T2 }; trait T2 { def t: T3 }; trait T3 { def t: T1 }""".stripMargin, 248 | json = 249 | """|[ 250 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T1"]}, 251 | | {"sym": ["tr:T1", "def:t"], "uses": ["tr:T2"]}, 252 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T2"]}, 253 | | {"sym": ["tr:T2", "def:t"], "uses": ["tr:T3"]}, 254 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T3"]}, 255 | | {"sym": ["tr:T3", "def:t"], "uses": ["tr:T1"]} 256 | |]""".stripMargin, 257 | classJson = 258 | """|[ 259 | | {"sym": ["tr:T1"], "uses": ["pkt:scala", "tp:AnyRef"]}, 260 | | {"sym": ["tr:T1"], "uses": ["tr:T2"]}, 261 | | {"sym": ["tr:T2"], "uses": ["pkt:scala", "tp:AnyRef"]}, 262 | | {"sym": ["tr:T2"], "uses": ["tr:T3"]}, 263 | | {"sym": ["tr:T3"], "uses": ["pkt:scala", "tp:AnyRef"]}, 264 | | {"sym": ["tr:T3"], "uses": ["tr:T1"]} 265 | |]""".stripMargin, 266 | graph = 267 | """|Graph '3-cycle': 7 nodes, 6 edges 268 | |Nodes: 269 | | - tr:T1 270 | | - pkt:scala.tp:AnyRef 271 | | - tr:T1.def:t 272 | | - tr:T2 273 | | - tr:T2.def:t 274 | | - tr:T3 275 | | - tr:T3.def:t 276 | |Edges: 277 | | - tr:T1 -[Extends]-> pkt:scala.tp:AnyRef 278 | | - tr:T1.def:t -[Uses]-> tr:T2 279 | | - tr:T2 -[Extends]-> pkt:scala.tp:AnyRef 280 | | - tr:T2.def:t -[Uses]-> tr:T3 281 | | - tr:T3 -[Extends]-> pkt:scala.tp:AnyRef 282 | | - tr:T3.def:t -[Uses]-> tr:T1""".stripMargin, 283 | tree = 284 | """|3-cycle: 285 | |└── T1 286 | | └── scala.AnyRef 287 | |└── scala.AnyRef 288 | |└── T2 289 | | └── scala.AnyRef 290 | |└── T3 291 | | └── scala.AnyRef 292 | |""".stripMargin, 293 | cycles = 294 | """|[3] tr:T1 tr:T2 tr:T3""".stripMargin, 295 | layers = 296 | """|[1] tr:T1 tr:T2 tr:T3 297 | |[0] tp:scala.AnyRef 298 | |""".stripMargin) 299 | 300 | // test:runMain com.lightbend.tools.sculpt.Samples "package" "package a.b { class C1; class C2 }" 301 | Sample( 302 | name = "package", 303 | source = 304 | """|package a.b { class C1; class C2 }""".stripMargin, 305 | json = 306 | """|[ 307 | | {"sym": [], "uses": ["pk:a"]}, 308 | | {"sym": [], "uses": ["pkt:a", "pk:b"]}, 309 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["pkt:a", "pkt:b", "cl:C1"]}, 310 | | {"sym": ["pkt:a", "pkt:b", "cl:C1", "def:"], "uses": ["pkt:a", "pkt:b", "cl:C1"]}, 311 | | {"sym": ["pkt:a", "pkt:b", "cl:C1", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 312 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["pkt:a", "pkt:b", "cl:C2"]}, 313 | | {"sym": ["pkt:a", "pkt:b", "cl:C2", "def:"], "uses": ["pkt:a", "pkt:b", "cl:C2"]}, 314 | | {"sym": ["pkt:a", "pkt:b", "cl:C2", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]} 315 | |]""".stripMargin, 316 | classJson = 317 | """|[ 318 | | {"sym": ["pkt:a", "pkt:b", "cl:C1"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 319 | | {"sym": ["pkt:a", "pkt:b", "cl:C1"], "uses": ["pkt:scala", "tp:AnyRef"]}, 320 | | {"sym": ["pkt:a", "pkt:b", "cl:C2"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 321 | | {"sym": ["pkt:a", "pkt:b", "cl:C2"], "uses": ["pkt:scala", "tp:AnyRef"]} 322 | |]""".stripMargin, 323 | graph = 324 | """|Graph 'package': 9 nodes, 8 edges 325 | |Nodes: 326 | | - 327 | | - pk:a 328 | | - pkt:a.pk:b 329 | | - pkt:a.pkt:b.cl:C1 330 | | - pkt:scala.tp:AnyRef 331 | | - pkt:a.pkt:b.cl:C1.def: 332 | | - pkt:java.pkt:lang.cl:Object.def: 333 | | - pkt:a.pkt:b.cl:C2 334 | | - pkt:a.pkt:b.cl:C2.def: 335 | |Edges: 336 | | - -[Uses]-> pk:a 337 | | - -[Uses]-> pkt:a.pk:b 338 | | - pkt:a.pkt:b.cl:C1 -[Extends]-> pkt:scala.tp:AnyRef 339 | | - pkt:a.pkt:b.cl:C1.def: -[Uses]-> pkt:a.pkt:b.cl:C1 340 | | - pkt:a.pkt:b.cl:C1.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 341 | | - pkt:a.pkt:b.cl:C2 -[Extends]-> pkt:scala.tp:AnyRef 342 | | - pkt:a.pkt:b.cl:C2.def: -[Uses]-> pkt:a.pkt:b.cl:C2 343 | | - pkt:a.pkt:b.cl:C2.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def:""".stripMargin, 344 | tree = 345 | """|package: 346 | |└── a.b.C1 347 | | └── scala.AnyRef 348 | |└── scala.AnyRef 349 | |└── a.b.C2 350 | | └── scala.AnyRef 351 | |""".stripMargin, 352 | cycles = 353 | """|""".stripMargin, 354 | layers = 355 | """|[1] cl:a.b.C1 356 | |[1] cl:a.b.C2 357 | |[0] cl:java.lang.Object 358 | |[0] tp:scala.AnyRef 359 | |""".stripMargin) 360 | 361 | // test:runMain com.lightbend.tools.sculpt.Samples "nested class" "trait T; class C { class D extends T }" 362 | Sample( 363 | name = "nested class", 364 | source = 365 | """|trait T; class C { class D extends T }""".stripMargin, 366 | json = 367 | """|[ 368 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["cl:C"]}, 369 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["cl:C", "cl:D"]}, 370 | | {"extends": ["tr:T"], "sym": ["cl:C", "cl:D"]}, 371 | | {"sym": ["cl:C", "cl:D", "def:"], "uses": ["cl:C"]}, 372 | | {"sym": ["cl:C", "cl:D", "def:"], "uses": ["cl:C", "cl:D"]}, 373 | | {"sym": ["cl:C", "cl:D", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 374 | | {"sym": ["cl:C", "def:"], "uses": ["cl:C"]}, 375 | | {"sym": ["cl:C", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 376 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["tr:T"]} 377 | |]""".stripMargin, 378 | classJson = 379 | """|[ 380 | | {"sym": ["cl:C"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 381 | | {"sym": ["cl:C"], "uses": ["pkt:scala", "tp:AnyRef"]}, 382 | | {"sym": ["cl:C"], "uses": ["tr:T"]}, 383 | | {"sym": ["tr:T"], "uses": ["pkt:scala", "tp:AnyRef"]} 384 | |]""".stripMargin, 385 | graph = 386 | """|Graph 'nested class': 7 nodes, 9 edges 387 | |Nodes: 388 | | - cl:C 389 | | - pkt:scala.tp:AnyRef 390 | | - cl:C.cl:D 391 | | - tr:T 392 | | - cl:C.cl:D.def: 393 | | - pkt:java.pkt:lang.cl:Object.def: 394 | | - cl:C.def: 395 | |Edges: 396 | | - cl:C -[Extends]-> pkt:scala.tp:AnyRef 397 | | - cl:C.cl:D -[Extends]-> pkt:scala.tp:AnyRef 398 | | - cl:C.cl:D -[Extends]-> tr:T 399 | | - cl:C.cl:D.def: -[Uses]-> cl:C 400 | | - cl:C.cl:D.def: -[Uses]-> cl:C.cl:D 401 | | - cl:C.cl:D.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 402 | | - cl:C.def: -[Uses]-> cl:C 403 | | - cl:C.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 404 | | - tr:T -[Extends]-> pkt:scala.tp:AnyRef""".stripMargin, 405 | tree = 406 | """|nested class: 407 | |└── C 408 | | └── scala.AnyRef 409 | |└── scala.AnyRef 410 | |└── C.D 411 | | └── scala.AnyRef 412 | | └── T 413 | | └── scala.AnyRef 414 | |└── T 415 | | └── scala.AnyRef 416 | |""".stripMargin, 417 | cycles = 418 | """|""".stripMargin, 419 | layers = 420 | """|[2] cl:C 421 | |[1] tr:T 422 | |[0] cl:java.lang.Object 423 | |[0] tp:scala.AnyRef 424 | |""".stripMargin) 425 | 426 | // test:runMain com.lightbend.tools.sculpt.Samples "uses module" "object O { None }" 427 | Sample( 428 | name = "uses module", 429 | source = 430 | """|object O { None }""".stripMargin, 431 | json = 432 | """|[ 433 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["o:O"]}, 434 | | {"sym": ["o:O"], "uses": ["pk:scala"]}, 435 | | {"sym": ["o:O"], "uses": ["pkt:scala", "ov:None"]}, 436 | | {"sym": ["o:O", "def:"], "uses": ["o:O"]}, 437 | | {"sym": ["o:O", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]} 438 | |]""".stripMargin, 439 | classJson = 440 | """|[ 441 | | {"sym": ["o:O"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 442 | | {"sym": ["o:O"], "uses": ["pkt:scala", "o:None"]}, 443 | | {"sym": ["o:O"], "uses": ["pkt:scala", "tp:AnyRef"]} 444 | |]""".stripMargin, 445 | graph = 446 | """|Graph 'uses module': 6 nodes, 5 edges 447 | |Nodes: 448 | | - o:O 449 | | - pkt:scala.tp:AnyRef 450 | | - pk:scala 451 | | - pkt:scala.ov:None 452 | | - o:O.def: 453 | | - pkt:java.pkt:lang.cl:Object.def: 454 | |Edges: 455 | | - o:O -[Extends]-> pkt:scala.tp:AnyRef 456 | | - o:O -[Uses]-> pk:scala 457 | | - o:O -[Uses]-> pkt:scala.ov:None 458 | | - o:O.def: -[Uses]-> o:O 459 | | - o:O.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def:""".stripMargin, 460 | tree = 461 | """|uses module: 462 | |└── O 463 | | └── scala.AnyRef 464 | | └── scala 465 | | └── scala.None 466 | |└── scala.AnyRef 467 | |""".stripMargin, 468 | cycles = 469 | """|""".stripMargin, 470 | layers = 471 | """|[1] o:O 472 | |[0] cl:java.lang.Object 473 | |[0] o:scala.None 474 | |[0] tp:scala.AnyRef 475 | |""".stripMargin) 476 | 477 | Sample( 478 | name = "pattern match", 479 | source = 480 | """|object O { 0 match { case _ => () } }""".stripMargin, 481 | json = 482 | """|[ 483 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["o:O"]}, 484 | | {"sym": ["o:O", "def:"], "uses": ["o:O"]}, 485 | | {"sym": ["o:O", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]} 486 | |]""".stripMargin, 487 | classJson = 488 | """|[ 489 | | {"sym": ["o:O"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 490 | | {"sym": ["o:O"], "uses": ["pkt:scala", "tp:AnyRef"]} 491 | |]""".stripMargin, 492 | graph = 493 | """|Graph 'pattern match': 4 nodes, 3 edges 494 | |Nodes: 495 | | - o:O 496 | | - pkt:scala.tp:AnyRef 497 | | - o:O.def: 498 | | - pkt:java.pkt:lang.cl:Object.def: 499 | |Edges: 500 | | - o:O -[Extends]-> pkt:scala.tp:AnyRef 501 | | - o:O.def: -[Uses]-> o:O 502 | | - o:O.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def:""".stripMargin, 503 | tree = 504 | """|pattern match: 505 | |└── O 506 | | └── scala.AnyRef 507 | |└── scala.AnyRef 508 | |""".stripMargin, 509 | cycles = 510 | """|""".stripMargin, 511 | layers = 512 | """|[1] o:O 513 | |[0] cl:java.lang.Object 514 | |[0] tp:scala.AnyRef 515 | |""".stripMargin) 516 | 517 | // this is the sample in the readme 518 | // test:runMain com.lightbend.tools.sculpt.Samples "readme" "object Dep1 { val x = 42; val y = Dep2.z }; object Dep2 { val z = Dep1.x }" 519 | Sample( 520 | name = "readme", 521 | source = 522 | """|object Dep1 { val x = 42; val y = Dep2.z }; object Dep2 { val z = Dep1.x }""".stripMargin, 523 | json = 524 | """|[ 525 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["o:Dep1"]}, 526 | | {"sym": ["o:Dep1", "def:"], "uses": ["o:Dep1"]}, 527 | | {"sym": ["o:Dep1", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 528 | | {"sym": ["o:Dep1", "def:x"], "uses": ["o:Dep1", "t:x"]}, 529 | | {"sym": ["o:Dep1", "def:x"], "uses": ["pkt:scala", "cl:Int"]}, 530 | | {"sym": ["o:Dep1", "def:y"], "uses": ["o:Dep1", "t:y"]}, 531 | | {"sym": ["o:Dep1", "def:y"], "uses": ["pkt:scala", "cl:Int"]}, 532 | | {"sym": ["o:Dep1", "t:x"], "uses": ["pkt:scala", "cl:Int"]}, 533 | | {"sym": ["o:Dep1", "t:y"], "uses": ["o:Dep2", "def:z"]}, 534 | | {"sym": ["o:Dep1", "t:y"], "uses": ["ov:Dep2"]}, 535 | | {"sym": ["o:Dep1", "t:y"], "uses": ["pkt:scala", "cl:Int"]}, 536 | | {"extends": ["pkt:scala", "tp:AnyRef"], "sym": ["o:Dep2"]}, 537 | | {"sym": ["o:Dep2", "def:"], "uses": ["o:Dep2"]}, 538 | | {"sym": ["o:Dep2", "def:"], "uses": ["pkt:java", "pkt:lang", "cl:Object", "def:"]}, 539 | | {"sym": ["o:Dep2", "def:z"], "uses": ["o:Dep2", "t:z"]}, 540 | | {"sym": ["o:Dep2", "def:z"], "uses": ["pkt:scala", "cl:Int"]}, 541 | | {"sym": ["o:Dep2", "t:z"], "uses": ["o:Dep1", "def:x"]}, 542 | | {"sym": ["o:Dep2", "t:z"], "uses": ["ov:Dep1"]}, 543 | | {"sym": ["o:Dep2", "t:z"], "uses": ["pkt:scala", "cl:Int"]} 544 | |]""".stripMargin, 545 | classJson = 546 | """|[ 547 | | {"sym": ["o:Dep1"], "uses": ["o:Dep2"]}, 548 | | {"sym": ["o:Dep1"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 549 | | {"sym": ["o:Dep1"], "uses": ["pkt:scala", "cl:Int"]}, 550 | | {"sym": ["o:Dep1"], "uses": ["pkt:scala", "tp:AnyRef"]}, 551 | | {"sym": ["o:Dep2"], "uses": ["o:Dep1"]}, 552 | | {"sym": ["o:Dep2"], "uses": ["pkt:java", "pkt:lang", "cl:Object"]}, 553 | | {"sym": ["o:Dep2"], "uses": ["pkt:scala", "cl:Int"]}, 554 | | {"sym": ["o:Dep2"], "uses": ["pkt:scala", "tp:AnyRef"]} 555 | |]""".stripMargin, 556 | graph = 557 | """|Graph 'readme': 15 nodes, 19 edges 558 | |Nodes: 559 | | - o:Dep1 560 | | - pkt:scala.tp:AnyRef 561 | | - o:Dep1.def: 562 | | - pkt:java.pkt:lang.cl:Object.def: 563 | | - o:Dep1.def:x 564 | | - o:Dep1.t:x 565 | | - pkt:scala.cl:Int 566 | | - o:Dep1.def:y 567 | | - o:Dep1.t:y 568 | | - o:Dep2.def:z 569 | | - ov:Dep2 570 | | - o:Dep2 571 | | - o:Dep2.def: 572 | | - o:Dep2.t:z 573 | | - ov:Dep1 574 | |Edges: 575 | | - o:Dep1 -[Extends]-> pkt:scala.tp:AnyRef 576 | | - o:Dep1.def: -[Uses]-> o:Dep1 577 | | - o:Dep1.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 578 | | - o:Dep1.def:x -[Uses]-> o:Dep1.t:x 579 | | - o:Dep1.def:x -[Uses]-> pkt:scala.cl:Int 580 | | - o:Dep1.def:y -[Uses]-> o:Dep1.t:y 581 | | - o:Dep1.def:y -[Uses]-> pkt:scala.cl:Int 582 | | - o:Dep1.t:x -[Uses]-> pkt:scala.cl:Int 583 | | - o:Dep1.t:y -[Uses]-> o:Dep2.def:z 584 | | - o:Dep1.t:y -[Uses]-> ov:Dep2 585 | | - o:Dep1.t:y -[Uses]-> pkt:scala.cl:Int 586 | | - o:Dep2 -[Extends]-> pkt:scala.tp:AnyRef 587 | | - o:Dep2.def: -[Uses]-> o:Dep2 588 | | - o:Dep2.def: -[Uses]-> pkt:java.pkt:lang.cl:Object.def: 589 | | - o:Dep2.def:z -[Uses]-> o:Dep2.t:z 590 | | - o:Dep2.def:z -[Uses]-> pkt:scala.cl:Int 591 | | - o:Dep2.t:z -[Uses]-> o:Dep1.def:x 592 | | - o:Dep2.t:z -[Uses]-> ov:Dep1 593 | | - o:Dep2.t:z -[Uses]-> pkt:scala.cl:Int""".stripMargin, 594 | tree = 595 | """|readme: 596 | |└── Dep1 597 | | └── scala.AnyRef 598 | |└── scala.AnyRef 599 | |└── scala.Int 600 | |└── Dep2 601 | | └── scala.AnyRef 602 | |""".stripMargin, 603 | cycles = 604 | """|[2] o:Dep1 o:Dep2""".stripMargin, 605 | layers = 606 | """|[1] o:Dep1 o:Dep2 607 | |[0] cl:java.lang.Object 608 | |[0] cl:scala.Int 609 | |[0] tp:scala.AnyRef 610 | |""".stripMargin) 611 | 612 | } 613 | -------------------------------------------------------------------------------- /src/test/scala/com/lightbend/tools/sculpt/SerializationTests.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | 5 | import spray.json._ 6 | 7 | import model._ 8 | 9 | class SerializationTests extends munit.FunSuite { 10 | 11 | // JSON -> JSValue -> JSON 12 | def roundTripThroughJsonASTs(sample: Sample): Unit = { 13 | assert(sample.json == 14 | FullDependenciesPrinter.print(sample.json.parseJson)) 15 | } 16 | 17 | // JSON -> JSValue -> Seq[FullDependency] -> JSValue -> JSON 18 | def roundTripThroughModel(sample: Sample): Unit = { 19 | import ModelJsonProtocol._ 20 | val dependencies = 21 | sample.json.parseJson.convertTo[Seq[FullDependency]] 22 | assert(sample.json == 23 | FullDependenciesPrinter.print(dependencies.toJson)) 24 | } 25 | 26 | for (sample <- Samples.samples) { 27 | test(s"${sample.name}: through ASTs") { 28 | roundTripThroughJsonASTs(sample) 29 | } 30 | test(s"${sample.name}: through model") { 31 | roundTripThroughModel(sample) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/test/scala/com/lightbend/tools/sculpt/TreeTests.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) Lightbend Inc. 2 | 3 | package com.lightbend.tools.sculpt 4 | import model._ 5 | 6 | object TreeTests { 7 | // also used by Samples.main 8 | def toTreeString(name: String, json: String): String = { 9 | import spray.json._ 10 | import ModelJsonProtocol._ 11 | val dependencies = json.parseJson.convertTo[Seq[FullDependency]] 12 | val graph = Graph.apply(name, dependencies) 13 | TreePrinter(graph) 14 | } 15 | } 16 | 17 | class TreeTests extends munit.FunSuite { 18 | 19 | for (sample <- Samples.samples) 20 | test(sample.name) { 21 | assert(sample.tree == 22 | TreeTests.toTreeString(sample.name, sample.json)) 23 | } 24 | 25 | } 26 | --------------------------------------------------------------------------------