Skip to content

Commit

Permalink
Support reading and writing Zipline APIs as TOML (cashapp#1051)
Browse files Browse the repository at this point in the history
I didn't introduce a dependency on a 3rd-party TOML library.
I'm open to this, but it might make it difficult to write
comments. (I'm less concerned with reading comments.)

I'm also anxious about how any dependency will interact
with Gradle's classpath.
  • Loading branch information
swankjesse committed Jun 21, 2023
1 parent fd33c34 commit 24fbc30
Show file tree
Hide file tree
Showing 6 changed files with 533 additions and 0 deletions.
1 change: 1 addition & 0 deletions zipline-kotlin-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies {

kapt(libs.auto.service.compiler)
compileOnly(libs.auto.service.annotations)
api(libs.okio.core)

testImplementation(projects.zipline)
testImplementation(libs.assertk)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2023 Cash App
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.zipline.api.toml

data class TomlZiplineApi(
val services: List<TomlZiplineService>,
)

data class TomlZiplineService(
val name: String,
val functions: List<TomlZiplineFunction>,
)

data class TomlZiplineFunction(
val leadingComment: String,
val id: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Copyright (C) 2023 Cash App
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.zipline.api.toml

import okio.BufferedSource
import okio.ByteString.Companion.encodeUtf8
import okio.IOException
import okio.Options

fun BufferedSource.readZiplineApi(): TomlZiplineApi {
return TomlZiplineApiReader(this).readServices()
}

/**
* Super-limited reader for the tiny subset of TOML we use for Zipline API files.
*
* Among other things, this doesn't support:
*
* - keys or values not within a table
* - keys that aren't `functions`
* - values that aren't arrays of strings
* - escaped string contents
*
* But it does capture comments.
*/
internal class TomlZiplineApiReader(
private val source: BufferedSource,
) {
fun readServices(): TomlZiplineApi {
val services = mutableListOf<TomlZiplineService>()

while (true) {
readComment()

val token = source.select(readServicesToken)
when {
// [com.example.SampleService]
token == readServicesTokenOpenBrace -> {
val serviceName = readTableHeader()
services += readService(serviceName)
}
source.exhausted() -> break
else -> throw IOException("expected '['")
}
}

return TomlZiplineApi(services)
}

private fun readService(serviceName: String): TomlZiplineService {
val functions = mutableListOf<TomlZiplineFunction>()

while (true) {
readComment()

when (source.select(readServiceToken)) {
// functions = [ ... ]
readServicesTokenFunctions -> {
skipWhitespace()
if (source.select(equals) == -1) throw IOException("expected '='")
skipWhitespace()
functions += readFunctions()
}
else -> break
}
}

return TomlZiplineService(
name = serviceName,
functions = functions,
)
}

private fun readFunctions(): List<TomlZiplineFunction> {
val result = mutableListOf<TomlZiplineFunction>()

readComment()
if (source.select(openBrace) == -1) throw IOException("expected '['")

while (true) {
val comment = readComment()
when (source.select(readFunctionToken)) {
readFunctionQuote -> {
val functionId = readString()
skipWhitespace()
result += TomlZiplineFunction(comment ?: "", functionId)
when (source.select(afterFunctionToken)) {
afterFunctionComma -> Unit
afterFunctionCloseBrace -> break
else -> throw IOException("expected ',' or ']'")
}
}
readFunctionCloseBrace -> break
else -> throw IOException("expected '\"' or ']'")
}
}

return result
}

private fun readTableHeader(): String {
val closeBrace = source.indexOf(']'.code.toByte())
if (closeBrace == -1L) throw IOException("unterminated '['")
val result = source.readUtf8(closeBrace)
require(source.readByte() == ']'.code.toByte())
return result
}

private fun readString(): String {
val closeQuote = source.indexOf('"'.code.toByte())
if (closeQuote == -1L) throw IOException("unterminated '\"'")
val result = source.readUtf8(closeQuote)
require(source.readByte() == '"'.code.toByte())
return result
}

/** Read a potentially multi-line comment as a single string. */
private fun readComment(): String? {
var result: StringBuilder? = null

while (true) {
skipWhitespace()
if (source.select(comment) == -1) break

if (result == null) {
result = StringBuilder()
} else {
result.append("\n")
}

val line = source.readUtf8Line() ?: ""
result.append(line.trimEnd())
}

return result?.toString()
}

private fun skipWhitespace() {
while (source.select(whitespace) != -1) {
// Skip.
}
}

private companion object {
val readServicesToken = Options.of(
"[".encodeUtf8(),
)

const val readServicesTokenOpenBrace = 0

val readServiceToken = Options.of(
"functions".encodeUtf8(),
)

const val readServicesTokenFunctions = 0

val readFunctionToken = Options.of(
"\"".encodeUtf8(),
"]".encodeUtf8(),
)

const val readFunctionQuote = 0
const val readFunctionCloseBrace = 1

val afterFunctionToken = Options.of(
",".encodeUtf8(),
"]".encodeUtf8(),
)

const val afterFunctionComma = 0
const val afterFunctionCloseBrace = 1

val whitespace = Options.of(
" ".encodeUtf8(),
"\t".encodeUtf8(),
"\r".encodeUtf8(),
"\n".encodeUtf8(),
)

val comment = Options.of(
"# ".encodeUtf8(), // Prefer to skip a space after a comment.
"#".encodeUtf8(),
)

val equals = Options.of(
"=".encodeUtf8(),
)

val openBrace = Options.of(
"[".encodeUtf8(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2023 Cash App
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.zipline.api.toml

import okio.BufferedSink

fun BufferedSink.writeZiplineApi(api: TomlZiplineApi) {
TomlZiplineApiWriter(this).writeApi(api)
}

/**
* Super-limited writer for the tiny subset of TOML we use for Zipline API files.
*
* This doesn't support strings that require character escapes.
*/
internal class TomlZiplineApiWriter(
private val sink: BufferedSink,
) {
fun writeApi(api: TomlZiplineApi) {
var first = true
for (service in api.services) {
if (!first) sink.writeUtf8("\n")
first = false

writeService(service)
}
}

private fun writeService(service: TomlZiplineService) {
sink.writeUtf8("[").writeUtf8(service.name).writeUtf8("]\n")
sink.writeUtf8("\n")
sink.writeUtf8("functions = [\n")
var first = true
for (function in service.functions) {
if (!first) sink.writeUtf8("\n")
first = false

writeFunction(function)
}
sink.writeUtf8("]\n")
}

private fun writeFunction(function: TomlZiplineFunction) {
val comment = function.leadingComment
if (comment.isNotEmpty()) {
sink.writeUtf8(" # ").writeUtf8(comment.replace("\n", "\n # ")).writeUtf8("\n")
}

sink.writeUtf8(" \"").writeUtf8(function.id).writeUtf8("\",\n")
}
}
Loading

0 comments on commit 24fbc30

Please sign in to comment.