├── .editorconfig ├── .github └── workflows │ ├── deploy-webapp.yml │ ├── gradle.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── build.gradle.kts ├── cli ├── README.md ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── com │ │ └── mattprecious │ │ └── protogram │ │ └── main.kt │ └── test │ └── kotlin │ └── com │ └── mattprecious │ └── protogram │ ├── ProtogramCliTest.kt │ └── files.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── protogram ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── mattprecious │ │ └── protogram │ │ ├── protogram.kt │ │ └── protos.kt │ └── commonTest │ └── kotlin │ └── com │ └── mattprecious │ └── protogram │ └── ProtogramTest.kt ├── settings.gradle.kts ├── test ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── mattprecious │ │ └── protogram │ │ └── test │ │ └── recursion.kt │ └── commonTest │ └── kotlin │ └── com │ └── mattprecious │ └── protogram │ └── test │ └── RecursionTest.kt ├── tinsel ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── mattprecious │ │ └── tinsel │ │ ├── Tinsel.kt │ │ ├── dsl.kt │ │ └── model.kt │ └── commonTest │ └── kotlin │ └── com │ └── mattprecious │ └── tinsel │ └── TinselTest.kt └── webapp ├── README.md ├── build.gradle.kts └── src └── main ├── kotlin └── com │ └── mattprecious │ └── protogram │ └── web │ └── main.kt └── resources ├── app.css └── index.html /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | indent_size=2 3 | ij_continuation_indent_size=4 4 | insert_final_newline=true 5 | ij_kotlin_name_count_to_use_star_import = 9999 6 | -------------------------------------------------------------------------------- /.github/workflows/deploy-webapp.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Webapp 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | 15 | - name: Set up JDK 1.8 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 1.8 19 | 20 | - name: Build 21 | run: ./gradlew --no-daemon :webapp:installDist 22 | 23 | - name: Deploy 24 | uses: JamesIves/github-pages-deploy-action@releases/v3 25 | with: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | BASE_BRANCH: master 28 | BRANCH: gh-pages 29 | FOLDER: webapp/build/install/webapp 30 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Java CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up JDK 1.8 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 1.8 19 | - name: Build with Gradle 20 | run: ./gradlew --no-daemon build 21 | - name: Archive protogram test results 22 | if: always() 23 | uses: actions/upload-artifact@v1 24 | with: 25 | name: protogram-report 26 | path: protogram/build/reports/tests/allTests 27 | - name: Archive tinsel test results 28 | if: always() 29 | uses: actions/upload-artifact@v1 30 | with: 31 | name: tinsel-report 32 | path: tinsel/build/reports/tests/allTests 33 | - name: Archive test test results 34 | if: always() 35 | uses: actions/upload-artifact@v1 36 | with: 37 | name: test-report 38 | path: test/build/reports/tests/allTests 39 | - name: Archive cli test results 40 | if: always() 41 | uses: actions/upload-artifact@v1 42 | with: 43 | name: cli-report 44 | path: cli/build/reports/tests/test 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Set up JDK 1.8 15 | uses: actions/setup-java@v1 16 | with: 17 | java-version: 1.8 18 | 19 | - name: Build with Gradle 20 | run: ./gradlew --no-daemon build 21 | 22 | - name: Get version 23 | id: get_version 24 | run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\//} 25 | 26 | - name: Set SHA 27 | id: shasum 28 | run: | 29 | echo ::set-output name=sha::"$(shasum -a 256 cli/build/bin/protogram | awk '{printf $1}')" 30 | 31 | - name: Extract release notes 32 | id: release_notes 33 | uses: ffurrer2/extract-release-notes@v1 34 | 35 | - name: Create Release 36 | uses: softprops/action-gh-release@v1 37 | with: 38 | body: ${{ steps.release_notes.outputs.release_notes }} 39 | files: cli/build/bin/protogram 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Bump Brew 44 | env: 45 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.BREW_TOKEN }} 46 | run: | 47 | # Can this be done automatically? 48 | git config --global user.email "protogram@runner" 49 | git config --global user.name "protogram" 50 | 51 | brew tap mattprecious/repo 52 | brew bump-formula-pr -f --version=${{ steps.get_version.outputs.version }} --no-browse --no-audit \ 53 | --sha256=${{ steps.shasum.outputs.sha }} \ 54 | --url="https://github.com/mattprecious/protogram/releases/download/${{ steps.get_version.outputs.version }}/protogram" \ 55 | mattprecious/repo/protogram 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEA 2 | .idea 3 | *.iml 4 | 5 | # Gradle 6 | .gradle 7 | gradlew.bat 8 | build/ 9 | local.properties -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.2.0] - 2020-05-26 6 | ### Added 7 | - Allow piping a binary file to stdin 8 | - Print float representation of `FIXED64` values 9 | - Support packed repeated fields 10 | - Parse proto files to display human-readable names 11 | 12 | ## [0.1.0] - 2019-12-18 13 | ### Added 14 | - Initial release 15 | 16 | [Unreleased]: https://github.com/mattprecious/protogram/compare/0.2.0...HEAD 17 | [0.2.0]: https://github.com/mattprecious/protogram/releases/tag/0.2.0 18 | [0.1.0]: https://github.com/mattprecious/protogram/releases/tag/0.1.0 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Protogram 2 | 3 | Protogram is a tool to quickly deserialize and print an encoded proto without the need for any `.proto` files. 4 | 5 | ## Command-Line Usage 6 | 7 | Pass a hex-encoded proto message to `protogram`: 8 | 9 | ``` 10 | $ protogram 0a0344616e120911000000000000e03f70107a021001 11 | ┌─ 1: "Dan" 12 | ├─ 2 ┐ 13 | │ ╰- 2: 4602678819172646912 (0.5) 14 | ├─ 14: 16 15 | ╰- 15 ┐ 16 | ╰- 2: 1 17 | ``` 18 | 19 | You can also pipe a binary file into `protogram`: 20 | 21 | ``` 22 | $ cat dan.pb | protogram 23 | ┌─ 1: "Dan" 24 | ├─ 2 ┐ 25 | │ ╰- 2: 4602678819172646912 (0.5) 26 | ├─ 14: 16 27 | ╰- 15 ┐ 28 | ╰- 2: 1 29 | ``` 30 | 31 | If you have the proto files available, `protogram` can parse the names more accurately: 32 | 33 | ``` 34 | protogram --source protos/ --type example.User 0a0344616e120911000000000000e03f70107a021001 35 | ┌─ name: "Dan" 36 | ├─ prefs: ┐ 37 | │ ╰- ratio: 4602678819172646912 (0.5) 38 | ├─ age: 16 39 | ╰- count: ┐ 40 | ╰- download: 1 41 | ``` 42 | 43 | ## Browser Usage 44 | 45 | The same code that powers the command-line tool is compiled to JS and lives at 46 | https://mattprecious.github.io/protogram/. Enter hex-encoded protos or open binary proto 47 | files from your computer. 48 | 49 | ## Download 50 | 51 | Available in [Releases](https://github.com/mattprecious/protogram/releases) or via Homebrew: 52 | 53 | ```bash 54 | brew install mattprecious/repo/protogram 55 | ``` 56 | 57 | ## License 58 | 59 | ``` 60 | Copyright 2019 Matthew Precious 61 | 62 | Licensed under the Apache License, Version 2.0 (the "License"); 63 | you may not use this file except in compliance with the License. 64 | You may obtain a copy of the License at 65 | 66 | http://www.apache.org/licenses/LICENSE-2.0 67 | 68 | Unless required by applicable law or agreed to in writing, software 69 | distributed under the License is distributed on an "AS IS" BASIS, 70 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 71 | See the License for the specific language governing permissions and 72 | limitations under the License. 73 | ``` 74 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | base 3 | kotlin("multiplatform") version "1.6.21" apply false 4 | kotlin("jvm") version "1.6.21" apply false 5 | kotlin("js") version "1.6.21" apply false 6 | id("org.jlleitschuh.gradle.ktlint") version "9.1.1" apply false 7 | } 8 | 9 | subprojects { 10 | group = "com.mattprecious" 11 | version = "0.3.0-SNAPSHOT" 12 | 13 | repositories { 14 | mavenCentral() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | Protogram CLI 2 | ============= 3 | 4 | Run `./gradlew assemble` to create the executable binary at `cli/build/bin/`. 5 | -------------------------------------------------------------------------------- /cli/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | kotlin("jvm") 5 | id("org.jlleitschuh.gradle.ktlint") 6 | } 7 | 8 | dependencies { 9 | implementation(kotlin("stdlib-jdk8")) 10 | implementation(project(":protogram")) 11 | implementation("com.github.ajalt", "clikt", "2.3.0") 12 | 13 | testImplementation("junit", "junit", "4.13") 14 | testImplementation("com.google.jimfs", "jimfs", "1.1") 15 | } 16 | 17 | tasks.withType { 18 | kotlinOptions.jvmTarget = "1.8" 19 | } 20 | 21 | val fatJar = task("fatJar", type = Jar::class) { 22 | setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) 23 | from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) 24 | with(tasks.jar.get() as CopySpec) 25 | 26 | archiveClassifier.set("fat") 27 | 28 | manifest { 29 | attributes["Main-Class"] = "com.mattprecious.protogram.Main" 30 | } 31 | } 32 | 33 | val binaryJar = task("binaryJar") { 34 | val binaryDir = File(buildDir, "bin") 35 | val binaryFile = File(binaryDir, "protogram") 36 | 37 | doLast { 38 | val fatJarFile = fatJar.archiveFile.get().asFile 39 | 40 | binaryFile.parentFile.mkdirs() 41 | binaryFile.writeText("#!/bin/sh\n\nexec java -jar \$0 \"\$@\"\n\n") 42 | binaryFile.appendBytes(fatJarFile.readBytes()) 43 | 44 | binaryFile.setExecutable(true) 45 | } 46 | } 47 | 48 | tasks { 49 | "assemble" { 50 | dependsOn(binaryJar) 51 | } 52 | 53 | binaryJar.dependsOn(fatJar) 54 | } 55 | -------------------------------------------------------------------------------- /cli/src/main/kotlin/com/mattprecious/protogram/main.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("Main") 2 | 3 | package com.mattprecious.protogram 4 | 5 | import com.github.ajalt.clikt.core.CliktCommand 6 | import com.github.ajalt.clikt.core.PrintHelpMessage 7 | import com.github.ajalt.clikt.core.UsageError 8 | import com.github.ajalt.clikt.parameters.arguments.argument 9 | import com.github.ajalt.clikt.parameters.arguments.convert 10 | import com.github.ajalt.clikt.parameters.arguments.optional 11 | import com.github.ajalt.clikt.parameters.groups.OptionGroup 12 | import com.github.ajalt.clikt.parameters.groups.cooccurring 13 | import com.github.ajalt.clikt.parameters.options.multiple 14 | import com.github.ajalt.clikt.parameters.options.option 15 | import com.github.ajalt.clikt.parameters.options.required 16 | import com.github.ajalt.clikt.parameters.types.path 17 | import com.squareup.wire.schema.ProtoType 18 | import com.squareup.wire.schema.Schema 19 | import com.squareup.wire.schema.SchemaLoader 20 | import java.io.InputStream 21 | import java.io.PrintStream 22 | import java.nio.file.FileSystem 23 | import java.nio.file.FileSystems 24 | import okio.ByteString.Companion.decodeHex 25 | import okio.buffer 26 | import okio.source 27 | 28 | fun main(vararg args: String) { 29 | ProtogramCli(FileSystems.getDefault(), System.`in`, System.out, System.err).main(args.toList()) 30 | } 31 | 32 | internal class ProtogramCli( 33 | fs: FileSystem, 34 | private val stdin: InputStream, 35 | private val stdout: PrintStream, 36 | private val stderr: PrintStream 37 | ) : CliktCommand(name = "protogram") { 38 | 39 | private class ProtoOptions(fs: FileSystem) : OptionGroup("Protos") { 40 | val dirs by option("--source", help = "Directory of proto files") 41 | .path(exists = true, fileOkay = false, readable = true, fileSystem = fs) 42 | .multiple() 43 | 44 | val type by option("--type", help = "Message or enum qualified type").required() 45 | } 46 | 47 | private val protoOptions by ProtoOptions(fs).cooccurring() 48 | 49 | private val bytes by argument("hex", "Encoded proto bytes as hex") 50 | .convert { it.decodeHex() } 51 | .optional() 52 | 53 | override fun run() { 54 | if (bytes == null && stdin.available() == 0) { 55 | stderr.println("Error: Attempted to read bytes from stdin but was empty.\n") 56 | throw PrintHelpMessage(this) 57 | } 58 | val decodeBytes = bytes ?: stdin.source().buffer().readByteString() 59 | 60 | var schema: Schema? = null 61 | var type: ProtoType? = null 62 | protoOptions?.let { protoOptions -> 63 | if (protoOptions.dirs.isEmpty()) { 64 | throw UsageError("At least one --source is required with --type") 65 | } 66 | val schemaLoader = SchemaLoader() 67 | protoOptions.dirs.forEach { 68 | schemaLoader.addSource(it) 69 | } 70 | schema = schemaLoader.load() 71 | type = schema!!.getType(protoOptions.type)?.type 72 | } 73 | 74 | stdout.print(printProto(decodeBytes, schema, type)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /cli/src/test/kotlin/com/mattprecious/protogram/ProtogramCliTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram 2 | 3 | import com.github.ajalt.clikt.core.PrintHelpMessage 4 | import com.github.ajalt.clikt.core.UsageError 5 | import com.google.common.jimfs.Configuration 6 | import com.google.common.jimfs.Jimfs 7 | import java.io.PrintStream 8 | import java.nio.file.Files 9 | import okio.Buffer 10 | import okio.ByteString.Companion.decodeHex 11 | import org.junit.Assert.assertEquals 12 | import org.junit.Assert.assertNotEquals 13 | import org.junit.Assert.fail 14 | import org.junit.Test 15 | 16 | class ProtogramCliTest { 17 | private val fs = Jimfs.newFileSystem(Configuration.unix()) 18 | private val fsRoot = fs.rootDirectories.first() 19 | private val stdin = Buffer() 20 | private val stdout = Buffer() 21 | private val stderr = Buffer() 22 | 23 | private val command = ProtogramCli( 24 | fs, 25 | stdin.inputStream(), 26 | PrintStream(stdout.outputStream()), 27 | PrintStream(stderr.outputStream()) 28 | ) 29 | 30 | @Test fun hex() { 31 | command.parse(listOf("0804120610e80718c806")) 32 | 33 | val expected = """ 34 | |┌─ 1: 4 35 | |╰- 2 ┐ 36 | | ├─ 2: 1000 37 | | ╰- 3: 840 38 | |""".trimMargin() 39 | assertEquals(expected, stdout.readUtf8()) 40 | } 41 | 42 | @Test fun hexWithProtos() { 43 | fsRoot.resolve("test.proto").writeText(""" 44 | message Foo { 45 | required int32 id = 1; 46 | required Bar bar = 2; 47 | } 48 | message Bar { 49 | required int32 id = 2; 50 | optional int32 age = 3; 51 | } 52 | """) 53 | 54 | command.parse(listOf( 55 | "--source", fsRoot.toString(), 56 | "--type", "Foo", 57 | "0804120610e80718c806")) 58 | 59 | val expected = """ 60 | |┌─ id: 4 61 | |╰- bar ┐ 62 | | ├─ id: 1000 63 | | ╰- age: 840 64 | |""".trimMargin() 65 | assertEquals(expected, stdout.readUtf8()) 66 | } 67 | 68 | @Test fun hexWithProtosTwoSources() { 69 | val fooDir = fsRoot.resolve("foo") 70 | Files.createDirectory(fooDir) 71 | fooDir.resolve("foo.proto").writeText(""" 72 | import bar.proto; 73 | 74 | message Foo { 75 | required int32 id = 1; 76 | required Bar bar = 2; 77 | } 78 | """) 79 | 80 | val barDir = fsRoot.resolve("bar") 81 | Files.createDirectory(barDir) 82 | barDir.resolve("bar.proto").writeText(""" 83 | message Bar { 84 | required int32 id = 2; 85 | optional int32 age = 3; 86 | } 87 | """) 88 | 89 | command.parse(listOf( 90 | "--source", fooDir.toString(), 91 | "--source", barDir.toString(), 92 | "--type", "Foo", 93 | "0804120610e80718c806")) 94 | 95 | val expected = """ 96 | |┌─ id: 4 97 | |╰- bar ┐ 98 | | ├─ id: 1000 99 | | ╰- age: 840 100 | |""".trimMargin() 101 | assertEquals(expected, stdout.readUtf8()) 102 | } 103 | 104 | @Test fun stdinBytes() { 105 | stdin.write("0804120610e80718c806".decodeHex()) 106 | command.parse(listOf()) 107 | 108 | val expected = """ 109 | |┌─ 1: 4 110 | |╰- 2 ┐ 111 | | ├─ 2: 1000 112 | | ╰- 3: 840 113 | |""".trimMargin() 114 | assertEquals(expected, stdout.readUtf8()) 115 | } 116 | 117 | @Test fun stdinEmpty() { 118 | try { 119 | command.parse(listOf()) 120 | fail() 121 | } catch (_: PrintHelpMessage) { 122 | } 123 | 124 | assertEquals("Error: Attempted to read bytes from stdin but was empty.", stderr.readUtf8Line()) 125 | } 126 | 127 | @Test fun typeNoSources() { 128 | try { 129 | command.parse(listOf("--type", "Foo", "0804120610e80718c806")) 130 | fail() 131 | } catch (e: UsageError) { 132 | assertEquals("At least one --source is required with --type", e.message) 133 | assertNotEquals(0, e.statusCode) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cli/src/test/kotlin/com/mattprecious/protogram/files.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram 2 | 3 | import java.nio.charset.Charset 4 | import java.nio.charset.StandardCharsets.UTF_8 5 | import java.nio.file.Files 6 | import java.nio.file.OpenOption 7 | import java.nio.file.Path 8 | 9 | fun Path.writeText(text: String, charset: Charset = UTF_8, vararg options: OpenOption) { 10 | Files.write(this, text.toByteArray(charset), *options) 11 | } 12 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/protogram/661469283f51b6740e446a42100d68380748c59e/gradle.properties -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/protogram/661469283f51b6740e446a42100d68380748c59e/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /protogram/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | id("org.jlleitschuh.gradle.ktlint") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | js { 9 | browser() 10 | } 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | api(kotlin("stdlib-common")) 16 | implementation(project(":tinsel")) 17 | api("com.squareup.okio:okio-multiplatform:2.4.2") 18 | api("com.squareup.wire:wire-schema-multiplatform:3.1.0") 19 | } 20 | } 21 | commonTest { 22 | dependencies { 23 | implementation(kotlin("test-common")) 24 | implementation(kotlin("test-annotations-common")) 25 | implementation(project(":test")) 26 | } 27 | } 28 | 29 | jvm().compilations["main"].defaultSourceSet { 30 | dependencies { 31 | api(kotlin("stdlib-jdk8")) 32 | } 33 | } 34 | jvm().compilations["test"].defaultSourceSet { 35 | dependencies { 36 | implementation(kotlin("test-junit")) 37 | } 38 | } 39 | js().compilations["main"].defaultSourceSet { 40 | dependencies { 41 | api(kotlin("stdlib-js")) 42 | } 43 | } 44 | js().compilations["test"].defaultSourceSet { 45 | dependencies { 46 | implementation(kotlin("test-js")) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /protogram/src/commonMain/kotlin/com/mattprecious/protogram/protogram.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("Protogram") 2 | 3 | package com.mattprecious.protogram 4 | 5 | import com.mattprecious.tinsel.Node.Branch 6 | import com.mattprecious.tinsel.Node.Leaf 7 | import com.mattprecious.tinsel.Tree 8 | import com.mattprecious.tinsel.render 9 | import com.squareup.wire.FieldEncoding.FIXED32 10 | import com.squareup.wire.FieldEncoding.FIXED64 11 | import com.squareup.wire.FieldEncoding.LENGTH_DELIMITED 12 | import com.squareup.wire.FieldEncoding.VARINT 13 | import com.squareup.wire.ProtoReader 14 | import com.squareup.wire.schema.EnumType 15 | import com.squareup.wire.schema.MessageType 16 | import com.squareup.wire.schema.ProtoType 17 | import com.squareup.wire.schema.Schema 18 | import kotlin.jvm.JvmName 19 | import okio.Buffer 20 | import okio.ByteString 21 | 22 | fun printProto(bytes: ByteString, schema: Schema? = null, type: ProtoType? = null): String { 23 | return bytes.readProtoTree(schema, type).render() 24 | } 25 | 26 | internal fun ByteString.readProtoTree(schema: Schema? = null, type: ProtoType? = null): Tree { 27 | return ProtoReader(Buffer().write(this)).readTree(schema, type) 28 | } 29 | 30 | private fun ProtoReader.readTree(schema: Schema?, type: ProtoType?): Tree { 31 | return generateFieldSequence() 32 | .flatMap { (tag, encoding) -> 33 | var fieldType: ProtoType? = null 34 | var fieldName = tag.toString() 35 | if (schema != null && type != null) { 36 | when (val schemaType = schema.getType(type)) { 37 | is MessageType -> { 38 | val field = schemaType.field(tag) 39 | if (field != null) { 40 | fieldType = field.type 41 | fieldName = field.name 42 | } 43 | } 44 | is EnumType -> { 45 | val constant = schemaType.constant(tag) 46 | if (constant != null) { 47 | fieldName = constant.name 48 | } 49 | } 50 | } 51 | } 52 | 53 | // TODO use fieldType (if present) to aid in parsing and display of value. 54 | val nodeValues = when (encoding) { 55 | VARINT -> listOf(readVarint64().toString()) 56 | FIXED64 -> { 57 | val long = readFixed64() 58 | val double = Double.fromBits(long) 59 | 60 | listOf("$long ($double)") 61 | } 62 | FIXED32 -> listOf(readFixed32().toString()) 63 | LENGTH_DELIMITED -> { 64 | val bytes = readBytes() 65 | 66 | if (bytes.size == 0) { 67 | listOf("(empty)") 68 | } else { 69 | try { 70 | val nestedTree = bytes.readProtoTree(schema, fieldType) 71 | return@flatMap listOf(Branch(fieldName, nestedTree.nodes)).asSequence() 72 | } catch (_: Exception) { 73 | if (bytes.isProbablyUtf8()) { 74 | listOf("\"${bytes.utf8().escape()}\"") 75 | } else { 76 | bytes.readPackedVarint64().map { it.toString() } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | return@flatMap nodeValues.map { Leaf(fieldName, it) }.asSequence() 84 | } 85 | .toList() 86 | .let { Tree(it) } 87 | } 88 | 89 | private fun ByteString.readPackedVarint64(): List { 90 | val values = mutableListOf() 91 | 92 | var bytePos = 0 93 | 94 | // Adapted from ProtoReader#readVarint64(). 95 | infix fun Byte.and(other: Int): Int = toInt() and other 96 | fun readNextPackedValue(): Long? { 97 | var shift = 0 98 | var result: Long = 0 99 | while (shift < 64 && bytePos < size) { 100 | val b = this[bytePos++] 101 | result = result or ((b and 0x7F).toLong() shl shift) 102 | if (b and 0x80 == 0) { 103 | return result 104 | } 105 | shift += 7 106 | } 107 | 108 | return null 109 | } 110 | 111 | while (true) { 112 | values += readNextPackedValue() ?: break 113 | } 114 | 115 | return values 116 | } 117 | 118 | private fun ByteString.isProbablyUtf8(): Boolean { 119 | val byteBuffer = Buffer().write(this) 120 | while (!byteBuffer.exhausted()) { 121 | val codePoint = byteBuffer.readUtf8CodePoint() 122 | if (codePoint.isLikelyBinary()) { 123 | return false 124 | } 125 | } 126 | 127 | return true 128 | } 129 | 130 | private fun Int.isLikelyBinary(): Boolean { 131 | return when (this) { 132 | 0xFFFD -> true // Replacement code point. 133 | ' '.code, '\t'.code, '\r'.code, '\n'.code -> false 134 | else -> (this in 0x00..0x1F) or (this in 0x7F..0x9F) 135 | } 136 | } 137 | 138 | private fun String.escape(): String { 139 | return replace("\n", "\\n") 140 | } 141 | -------------------------------------------------------------------------------- /protogram/src/commonMain/kotlin/com/mattprecious/protogram/protos.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram 2 | 3 | import com.squareup.wire.FieldEncoding 4 | import com.squareup.wire.ProtoReader 5 | 6 | internal data class ProtoField( 7 | val tag: Int, 8 | val encoding: FieldEncoding 9 | ) 10 | 11 | internal fun ProtoReader.generateFieldSequence(): Sequence { 12 | beginMessage() 13 | return generateSequence { 14 | val tag = nextTag() 15 | return@generateSequence if (tag == -1) { 16 | null 17 | } else { 18 | ProtoField(tag, peekFieldEncoding()!!) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /protogram/src/commonTest/kotlin/com/mattprecious/protogram/ProtogramTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram 2 | 3 | import com.mattprecious.protogram.test.buildRecursiveTree 4 | import com.mattprecious.tinsel.tree 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import okio.ByteString.Companion.decodeHex 8 | 9 | class ProtogramTest { 10 | @Test fun empty() { 11 | val actual = "".decodeHex().readProtoTree() 12 | val expected = tree {} 13 | assertEquals(expected, actual) 14 | } 15 | 16 | @Test fun simple() { 17 | val actual = "0a0344616e120911000000000000e03f70107a021001".decodeHex() 18 | .readProtoTree() 19 | val expected = tree { 20 | "1" to "\"Dan\"" 21 | "2" to tree { 22 | "2" to "4602678819172646912 (0.5)" 23 | } 24 | "14" to "16" 25 | "15" to tree { 26 | "2" to "1" 27 | } 28 | } 29 | 30 | assertEquals(expected, actual) 31 | } 32 | 33 | @Test fun crazyNesting() { 34 | val actual = "127e127c127a12781276127412721270126e126c126a12681266126412621260125e125c125a12581256125412521250124e124c124a12481246124412421240123e123c123a12381236123412321230122e122c122a12281226122412221220121e121c121a12181216121412121210120e120c120a1208120612041202120008c803".decodeHex().readProtoTree() 35 | 36 | val recursiveTree = buildRecursiveTree(label = "2", leafValue = "(empty)", depth = 63) 37 | val expected = tree { 38 | "2" to recursiveTree 39 | "1" to "456" 40 | } 41 | 42 | assertEquals(expected, actual) 43 | } 44 | 45 | @Test fun packedRepeatedInt32() { 46 | val actual = "d20504d904bd05".decodeHex().readProtoTree() 47 | val expected = tree { 48 | "90" to "601" 49 | "90" to "701" 50 | } 51 | assertEquals(expected, actual) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "protogram-root" 2 | 3 | include("protogram", "tinsel", "test", "webapp", "cli") 4 | -------------------------------------------------------------------------------- /test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | id("org.jlleitschuh.gradle.ktlint") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | js { 9 | browser() 10 | } 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | implementation(kotlin("stdlib-common")) 16 | implementation(project(":tinsel")) 17 | } 18 | } 19 | commonTest { 20 | dependencies { 21 | implementation(kotlin("test-common")) 22 | implementation(kotlin("test-annotations-common")) 23 | } 24 | } 25 | jvm().compilations["main"].defaultSourceSet { 26 | dependencies { 27 | implementation(kotlin("stdlib-jdk8")) 28 | } 29 | } 30 | jvm().compilations["test"].defaultSourceSet { 31 | dependencies { 32 | implementation(kotlin("test-junit")) 33 | } 34 | } 35 | js().compilations["main"].defaultSourceSet { 36 | dependencies { 37 | implementation(kotlin("stdlib-js")) 38 | } 39 | } 40 | js().compilations["test"].defaultSourceSet { 41 | dependencies { 42 | implementation(kotlin("test-js")) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/src/commonMain/kotlin/com/mattprecious/protogram/test/recursion.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram.test 2 | 3 | import com.mattprecious.tinsel.Node 4 | import com.mattprecious.tinsel.Node.Branch 5 | import com.mattprecious.tinsel.Node.Leaf 6 | import com.mattprecious.tinsel.Tree 7 | 8 | fun buildRecursiveTree( 9 | label: String, 10 | leafValue: String, 11 | depth: Int 12 | ): Tree { 13 | return Tree(listOf(buildRecursiveNodes(label, leafValue, depth))) 14 | } 15 | 16 | private fun buildRecursiveNodes( 17 | label: String, 18 | leafValue: String, 19 | depth: Int 20 | ): Node { 21 | return if (depth == 1) { 22 | Leaf(label, leafValue) 23 | } else { 24 | Branch(label, listOf(buildRecursiveNodes(label, leafValue, depth - 1))) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/src/commonTest/kotlin/com/mattprecious/protogram/test/RecursionTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram.test 2 | 3 | import com.mattprecious.tinsel.tree 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | 7 | class RecursionTest { 8 | @Test fun recursiveTreeBuilder() { 9 | var actual = buildRecursiveTree(label = "2", leafValue = "v", depth = 1) 10 | var expected = tree { "2" to "v" } 11 | assertEquals(expected, actual) 12 | 13 | actual = buildRecursiveTree(label = "2", leafValue = "v", depth = 2) 14 | expected = tree { "2" to tree { "2" to "v" } } 15 | assertEquals(expected, actual) 16 | 17 | actual = buildRecursiveTree(label = "2", leafValue = "v", depth = 5) 18 | expected = tree { "2" to tree { "2" to tree { "2" to tree { "2" to tree { "2" to "v" } } } } } 19 | assertEquals(expected, actual) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tinsel/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | id("org.jlleitschuh.gradle.ktlint") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | js { 9 | browser() 10 | } 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | implementation(kotlin("stdlib-common")) 16 | } 17 | } 18 | commonTest { 19 | dependencies { 20 | implementation(kotlin("test-common")) 21 | implementation(kotlin("test-annotations-common")) 22 | implementation(project(":test")) 23 | } 24 | } 25 | jvm().compilations["main"].defaultSourceSet { 26 | dependencies { 27 | implementation(kotlin("stdlib-jdk8")) 28 | } 29 | } 30 | jvm().compilations["test"].defaultSourceSet { 31 | dependencies { 32 | implementation(kotlin("test-junit")) 33 | } 34 | } 35 | js().compilations["main"].defaultSourceSet { 36 | dependencies { 37 | implementation(kotlin("stdlib-js")) 38 | } 39 | } 40 | js().compilations["test"].defaultSourceSet { 41 | dependencies { 42 | implementation(kotlin("test-js")) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tinsel/src/commonMain/kotlin/com/mattprecious/tinsel/Tinsel.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.tinsel 2 | 3 | fun Tree.render(): String { 4 | if (nodes.isEmpty()) return "" 5 | return buildString { appendNodes(nodes) } 6 | } 7 | 8 | private fun StringBuilder.appendNodes(nodes: List, indent: String = "") { 9 | val tagWidth = nodes.maxOf { it.label.length } 10 | 11 | nodes.forEachIndexed { index, node -> 12 | val indented = indent != "" 13 | val isFirst = index == 0 14 | val isLast = index == nodes.size - 1 15 | 16 | append(indent) 17 | append( 18 | when { 19 | isFirst && isLast && !indented -> "──" 20 | isFirst && !indented -> "┌─" 21 | isLast -> "╰-" 22 | else -> "├─" 23 | } 24 | ) 25 | 26 | val paddedLabel = node.label.padStart(tagWidth + 1) 27 | if (node is Node.Branch) { 28 | append("$paddedLabel ┐\n") 29 | 30 | val nextIndentPrefix = if (!isLast) '│' else ' ' 31 | val paddedNextIndent = nextIndentPrefix.toString().padEnd(4 + tagWidth) 32 | 33 | appendNodes(node.children, indent = "$indent$paddedNextIndent") 34 | } else if (node is Node.Leaf) { 35 | append("$paddedLabel: ${node.value}\n") 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tinsel/src/commonMain/kotlin/com/mattprecious/tinsel/dsl.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("-DslKt") 2 | 3 | package com.mattprecious.tinsel 4 | 5 | import com.mattprecious.tinsel.Node.Branch 6 | import com.mattprecious.tinsel.Node.Leaf 7 | import kotlin.jvm.JvmName 8 | 9 | @DslMarker 10 | private annotation class TinselDsl 11 | 12 | fun tree(content: TreeDsl.() -> Unit) = TreeDslImpl().apply(content).create() 13 | 14 | @TinselDsl 15 | interface TreeDsl : BranchDsl { 16 | fun branch( 17 | label: String, 18 | content: BranchDsl.() -> Unit 19 | ) 20 | 21 | infix fun String.to(tree: Tree) 22 | } 23 | 24 | @TinselDsl 25 | interface BranchDsl { 26 | fun leaf( 27 | label: String, 28 | value: String 29 | ) 30 | 31 | infix fun String.to(value: String) 32 | } 33 | 34 | private class TreeDslImpl : BranchDslImpl(), TreeDsl { 35 | override fun branch( 36 | label: String, 37 | content: BranchDsl.() -> Unit 38 | ) { 39 | nodes += Branch(label, BranchDslImpl().apply(content).nodes) 40 | } 41 | 42 | override fun String.to(tree: Tree) { 43 | nodes += Branch(this, tree.nodes) 44 | } 45 | 46 | fun create(): Tree { 47 | return Tree(nodes) 48 | } 49 | } 50 | 51 | private open class BranchDslImpl : BranchDsl { 52 | val nodes = mutableListOf() 53 | 54 | override fun leaf( 55 | label: String, 56 | value: String 57 | ) { 58 | nodes += Leaf(label, value) 59 | } 60 | 61 | override fun String.to(value: String) { 62 | leaf(this, value) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tinsel/src/commonMain/kotlin/com/mattprecious/tinsel/model.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.tinsel 2 | 3 | data class Tree(val nodes: List = emptyList()) 4 | 5 | sealed class Node { 6 | abstract val label: String 7 | 8 | data class Branch( 9 | override val label: String, 10 | val children: List 11 | ) : Node() 12 | 13 | data class Leaf( 14 | override val label: String, 15 | val value: String 16 | ) : Node() 17 | } 18 | -------------------------------------------------------------------------------- /tinsel/src/commonTest/kotlin/com/mattprecious/tinsel/TinselTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.tinsel 2 | 3 | import com.mattprecious.protogram.test.buildRecursiveTree 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | 7 | class TinselTest { 8 | @Test fun empty() { 9 | val actual = tree {}.render() 10 | val expected = "" 11 | assertEquals(expected, actual) 12 | } 13 | 14 | @Test fun crazyNesting() { 15 | val recursiveTree = buildRecursiveTree(label = "2", leafValue = "(empty)", depth = 63) 16 | val actual = tree { 17 | "2" to recursiveTree 18 | "1" to "456" 19 | }.render() 20 | val expected = """ 21 | |┌─ 2 ┐ 22 | |│ ╰- 2 ┐ 23 | |│ ╰- 2 ┐ 24 | |│ ╰- 2 ┐ 25 | |│ ╰- 2 ┐ 26 | |│ ╰- 2 ┐ 27 | |│ ╰- 2 ┐ 28 | |│ ╰- 2 ┐ 29 | |│ ╰- 2 ┐ 30 | |│ ╰- 2 ┐ 31 | |│ ╰- 2 ┐ 32 | |│ ╰- 2 ┐ 33 | |│ ╰- 2 ┐ 34 | |│ ╰- 2 ┐ 35 | |│ ╰- 2 ┐ 36 | |│ ╰- 2 ┐ 37 | |│ ╰- 2 ┐ 38 | |│ ╰- 2 ┐ 39 | |│ ╰- 2 ┐ 40 | |│ ╰- 2 ┐ 41 | |│ ╰- 2 ┐ 42 | |│ ╰- 2 ┐ 43 | |│ ╰- 2 ┐ 44 | |│ ╰- 2 ┐ 45 | |│ ╰- 2 ┐ 46 | |│ ╰- 2 ┐ 47 | |│ ╰- 2 ┐ 48 | |│ ╰- 2 ┐ 49 | |│ ╰- 2 ┐ 50 | |│ ╰- 2 ┐ 51 | |│ ╰- 2 ┐ 52 | |│ ╰- 2 ┐ 53 | |│ ╰- 2 ┐ 54 | |│ ╰- 2 ┐ 55 | |│ ╰- 2 ┐ 56 | |│ ╰- 2 ┐ 57 | |│ ╰- 2 ┐ 58 | |│ ╰- 2 ┐ 59 | |│ ╰- 2 ┐ 60 | |│ ╰- 2 ┐ 61 | |│ ╰- 2 ┐ 62 | |│ ╰- 2 ┐ 63 | |│ ╰- 2 ┐ 64 | |│ ╰- 2 ┐ 65 | |│ ╰- 2 ┐ 66 | |│ ╰- 2 ┐ 67 | |│ ╰- 2 ┐ 68 | |│ ╰- 2 ┐ 69 | |│ ╰- 2 ┐ 70 | |│ ╰- 2 ┐ 71 | |│ ╰- 2 ┐ 72 | |│ ╰- 2 ┐ 73 | |│ ╰- 2 ┐ 74 | |│ ╰- 2 ┐ 75 | |│ ╰- 2 ┐ 76 | |│ ╰- 2 ┐ 77 | |│ ╰- 2 ┐ 78 | |│ ╰- 2 ┐ 79 | |│ ╰- 2 ┐ 80 | |│ ╰- 2 ┐ 81 | |│ ╰- 2 ┐ 82 | |│ ╰- 2 ┐ 83 | |│ ╰- 2 ┐ 84 | |│ ╰- 2: (empty) 85 | |╰- 1: 456 86 | |""".trimMargin() 87 | assertEquals(expected, actual) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /webapp/README.md: -------------------------------------------------------------------------------- 1 | Protogram Webapp 2 | ================ 3 | 4 | Live at https://mattprecious.github.io/protogram/ 5 | 6 | Currently the website deploys automatically from the `master` branch. 7 | 8 | 9 | Development 10 | ----------- 11 | 12 | Run `./gradlew :webapp:run --continuous` and a local webserver and browser will be opened. 13 | Code changes will be automatically picked up and compiled. The browser will also refresh 14 | automatically. 15 | 16 | 17 | Release 18 | ------- 19 | 20 | Running `./gradlew :webapp:installDist` will create an unpacked version of the final site 21 | at `webapp/build/install/webapp/`. For a zip of the site, run `./gradlew :webapp:distZip` instead 22 | which will put a zip in `webapp/build/distributions/`. 23 | -------------------------------------------------------------------------------- /webapp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("js") 3 | id("org.jlleitschuh.gradle.ktlint") 4 | distribution 5 | } 6 | 7 | kotlin { 8 | js { 9 | browser() 10 | } 11 | 12 | sourceSets["main"].dependencies { 13 | implementation(project(":protogram")) 14 | implementation(kotlin("stdlib-js")) 15 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.3.3") 16 | } 17 | } 18 | 19 | distributions { 20 | main { 21 | contents { 22 | from("src/main/resources") 23 | from("$buildDir/distributions/webapp.js") 24 | into("/") 25 | } 26 | } 27 | } 28 | listOf("distZip", "installDist").forEach { 29 | tasks.named(it).configure { 30 | dependsOn(tasks.getByName("browserWebpack")) 31 | } 32 | } 33 | tasks.named("distTar").configure { 34 | enabled = false 35 | } 36 | -------------------------------------------------------------------------------- /webapp/src/main/kotlin/com/mattprecious/protogram/web/main.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.protogram.web 2 | 3 | import com.mattprecious.protogram.printProto 4 | import kotlin.js.Promise 5 | import kotlinx.browser.document 6 | import kotlinx.browser.window 7 | import kotlinx.coroutines.await 8 | import kotlinx.coroutines.channels.awaitClose 9 | import kotlinx.coroutines.coroutineScope 10 | import kotlinx.coroutines.flow.callbackFlow 11 | import kotlinx.coroutines.flow.collect 12 | import kotlinx.coroutines.flow.map 13 | import kotlinx.coroutines.launch 14 | import okio.Buffer 15 | import okio.ByteString 16 | import okio.ByteString.Companion.decodeHex 17 | import org.khronos.webgl.ArrayBuffer 18 | import org.khronos.webgl.Uint8Array 19 | import org.khronos.webgl.get 20 | import org.w3c.dom.HTMLInputElement 21 | import org.w3c.dom.HTMLTextAreaElement 22 | import org.w3c.dom.events.Event 23 | import org.w3c.dom.events.EventTarget 24 | import org.w3c.files.Blob 25 | import org.w3c.files.File 26 | import org.w3c.files.FileList 27 | 28 | suspend fun main() = coroutineScope { 29 | val input = document.getElementById("input") as HTMLTextAreaElement 30 | val file = document.getElementById("file") as HTMLInputElement 31 | val output = document.getElementById("output") as HTMLTextAreaElement 32 | 33 | fun setInput(value: String) { 34 | input.value = value 35 | // Setting the value programmatically does not fire the 'input' event. 36 | input.dispatchEvent(Event("input")) 37 | } 38 | 39 | fun renderHex(value: String) { 40 | val tree = try { 41 | val trimmedValue = value.trim() 42 | .replace(" ", "") 43 | .replace("\n", "") 44 | printProto(trimmedValue.decodeHex()) 45 | } catch (e: Exception) { 46 | console.error(e) 47 | "Unable to decode hex\n\nError: ${e::class.simpleName} ${e.message ?: ""}" 48 | } 49 | output.value = tree 50 | } 51 | 52 | launch { 53 | file.events("change") 54 | .map { 55 | val inputFiles = it.target.asDynamic().files as FileList 56 | inputFiles.firstOrNull() 57 | ?.arrayBuffer() 58 | ?.await() 59 | ?.toByteString() 60 | ?.hex() ?: "" 61 | } 62 | .collect { 63 | // Clear input file. We use dynamic because the Kotlin type is not nullable. 64 | file.asDynamic().value = null 65 | setInput(it) 66 | } 67 | } 68 | 69 | launch { 70 | input.events("input") 71 | .map { input.value.trim() } 72 | .collect { 73 | window.history.replaceState(it, "", "?$it") 74 | renderHex(it) 75 | } 76 | } 77 | 78 | val defaultHex = window.location.search.trimStart('?') 79 | // We directly set the input and render to avoid replacing the initial history state. 80 | input.value = defaultHex 81 | renderHex(defaultHex) 82 | } 83 | 84 | private fun EventTarget.events(event: String) = callbackFlow { 85 | val listener: (Event) -> Unit = { 86 | offer(it) 87 | } 88 | addEventListener(event, listener) 89 | awaitClose { 90 | removeEventListener(event, listener) 91 | } 92 | } 93 | 94 | private fun FileList.firstOrNull(): File? { 95 | return if (length > 0) item(0) else null 96 | } 97 | 98 | private fun Blob.arrayBuffer() = asDynamic().arrayBuffer() as Promise 99 | 100 | private fun ArrayBuffer.toByteString(): ByteString { 101 | val uint8Array = Uint8Array(this) 102 | val buffer = Buffer() 103 | for (i in 0 until uint8Array.length) { 104 | buffer.writeByte(uint8Array[i].toInt()) 105 | } 106 | return buffer.readByteString() 107 | } 108 | -------------------------------------------------------------------------------- /webapp/src/main/resources/app.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #191927; 3 | color: rgb(224, 235, 247); 4 | margin: 0; 5 | padding: 0; 6 | } 7 | body { 8 | padding: 0; 9 | margin: 15px; 10 | margin-left: auto; 11 | margin-right: auto; 12 | width: 1024px; 13 | font-family: Georgia, serif; 14 | } 15 | @media (max-width: 1023px) { 16 | body { 17 | width: inherit; 18 | margin-left: 15px; 19 | margin-right: 15px; 20 | } 21 | } 22 | a, a:link, a:active, a:visited { 23 | text-decoration: underline; 24 | color: rgba(224, 235, 247, 0.8); 25 | } 26 | a:hover { 27 | color: rgb(40, 217, 242); 28 | } 29 | h1 { 30 | font-weight: 200; 31 | } 32 | textarea { 33 | font-family: Menlo, Monaco, monospace; 34 | width: 100%; 35 | text-rendering: optimizeleibility; 36 | background-color: #242432; 37 | border: 1px solid #272735; 38 | border-radius: 3px; 39 | padding: 3px; 40 | color: rgba(224, 235, 247, 0.8); 41 | } 42 | -------------------------------------------------------------------------------- /webapp/src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Protogram 7 | 8 | 9 | 10 |

Protogram

11 |

12 | 13 | 14 |

15 |

16 | 17 |

18 | 19 |

© 2019 Matt Precious. Open source on GitHub.

20 | 21 | 22 | --------------------------------------------------------------------------------