Skip to content

Commit

Permalink
feat: plugins system, CLI support of plugins
Browse files Browse the repository at this point in the history
  • Loading branch information
y9vad9 committed Nov 19, 2024
1 parent 1114c2c commit 3d12870
Show file tree
Hide file tree
Showing 26 changed files with 895 additions and 103 deletions.
7 changes: 7 additions & 0 deletions generator/core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id(libs.plugins.conventions.multiplatform.library.get().pluginId)
alias(libs.plugins.kotlinx.serialization)
}

kotlin {
Expand All @@ -13,6 +14,12 @@ dependencies {
// -- Project --
commonMainApi(projects.common.schema)

// -- Serialization --
commonMainImplementation(libs.kotlinx.serialization.proto)

// -- Coroutines --
commonMainImplementation(libs.kotlinx.coroutines)

// -- SquareUp --
commonMainImplementation(libs.squareup.wire.schema)
commonMainImplementation(libs.squareup.kotlinpoet)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.timemates.rrpc.codegen.configuration.GenerationOption
import org.timemates.rrpc.codegen.configuration.GenerationOptions
import org.timemates.rrpc.codegen.configuration.isPackageCyclesPermitted
import org.timemates.rrpc.codegen.configuration.protoInputs
import org.timemates.rrpc.common.schema.RSResolver

public class CodeGenerator(private val fileSystem: FileSystem = FileSystem.SYSTEM) {
public companion object {
Expand All @@ -20,7 +21,7 @@ public class CodeGenerator(private val fileSystem: FileSystem = FileSystem.SYSTE
public fun generate(
options: GenerationOptions,
adapters: List<SchemaAdapter>,
) {
): RSResolver {
val schemaLoader = SchemaLoader(fileSystem)
schemaLoader.permitPackageCycles = options.isPackageCyclesPermitted

Expand All @@ -31,8 +32,10 @@ public class CodeGenerator(private val fileSystem: FileSystem = FileSystem.SYSTE
val schema = schemaLoader.loadSchema()
val resolver = schema.asRSResolver()

return adapters.forEach { adapter ->
adapters.forEach { adapter ->
adapter.process(options, resolver)
}

return resolver
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ public interface SchemaAdapter {
public fun process(
options: GenerationOptions,
resolver: RSResolver,
): RSResolver
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.timemates.rrpc.codegen.configuration

import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import okio.Path
import okio.Path.Companion.toPath

Expand All @@ -16,7 +18,7 @@ import okio.Path.Companion.toPath
* @see GenerationOption
*/
@JvmInline
public value class GenerationOptions private constructor(private val map: Map<String, Any>) {
public value class GenerationOptions(private val map: Map<String, Any>) {
/**
* A collection of predefined options for code generation.
*/
Expand Down Expand Up @@ -47,7 +49,9 @@ public value class GenerationOptions private constructor(private val map: Map<St
* @return The value of the option, or `null` if not found.
*/
@Suppress("UNCHECKED_CAST")
public operator fun <T> get(option: SingleGenerationOption<T>): T? = map[option.name] as? T
public operator fun <T> get(option: SingleGenerationOption<T>): T? {
return map[option.name]?.let { option.valueFactory(it.toString()) }
}

/**
* Retrieves the values for a given [option], casting it to the expected type.
Expand All @@ -56,30 +60,42 @@ public value class GenerationOptions private constructor(private val map: Map<St
* @return The value of the option, or `null` if not found.
*/
@Suppress("UNCHECKED_CAST")
public operator fun <T> get(option: RepeatableGenerationOption<T>): List<T>? = map[option.name] as? List<T>
public operator fun <T> get(option: RepeatableGenerationOption<T>): List<T>? {
val list = map[option.name] as? List<String> ?: return null
return list.map { option.valueFactory(it) }
}

public val raw: Map<String, Any> get() = map.toMap()

public class Builder {
private val map: MutableMap<String, Any> = mutableMapOf()

public operator fun <T : Any> set(option: SingleGenerationOption<T>, value: String) {
@Suppress("NAME_SHADOWING")
val value = option.valueFactory(value)

map[option.name] = value
}

public fun <T : Any> append(option: RepeatableGenerationOption<T>, value: String) {
@Suppress("NAME_SHADOWING")
val value = option.valueFactory(value)

if (map.containsKey(option.name)) {
@Suppress("UNCHECKED_CAST")
map[option.name] = (map[option.name] as? List<T>)?.plus(value) ?: listOf(value)
map[option.name] = (map[option.name] as? List<String>)?.plus(value) ?: listOf(value)
} else {
map[option.name] = listOf(value)
}
}

public fun rawSet(name: String, value: String) {
map[name] = value
}

public fun rawAppend(name: String, value: String) {
if (map.containsKey(name)) {
@Suppress("UNCHECKED_CAST")
map[name] = (map[name] as? List<String>)?.plus(value) ?: listOf(value)
} else {
map[name] = listOf(value)
}
}

public fun build(): GenerationOptions {
return GenerationOptions(map.toMap())
}
Expand Down Expand Up @@ -123,17 +139,27 @@ public sealed interface GenerationOption {
}
}

@Serializable
public sealed interface OptionTypeKind {
@Serializable
public data object Text : OptionTypeKind
@Serializable
public data object Boolean : OptionTypeKind
@Serializable
public sealed interface Number : OptionTypeKind {
@Serializable
public data object Int : Number
@Serializable
public data object Long : Number
@Serializable
public data object Float : Number
@Serializable
public data object Double : Number
}
@Serializable
public data object Path : OptionTypeKind
public data class Choice(public val variants: List<String>) : OptionTypeKind
@Serializable
public data class Choice(@ProtoNumber(1) public val variants: List<String>) : OptionTypeKind
}

@Suppress("unused")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.timemates.rrpc.codegen.plugin

/**
* Exception thrown when communication with the generator fails or produces an invalid response.
*/
public class CommunicationException(message: String) : Exception(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package org.timemates.rrpc.codegen.plugin

import kotlinx.serialization.KSerializer
import kotlinx.serialization.protobuf.ProtoBuf
import okio.BufferedSink
import okio.BufferedSource
import okio.EOFException
import org.timemates.rrpc.codegen.plugin.data.*

public typealias PluginCommunication = GPCommunication<GeneratorMessage, PluginMessage>
public typealias GeneratorCommunication = GPCommunication<PluginMessage, GeneratorMessage>

/**
* Defines an API for plugin-to-generator communication, providing mechanisms to send signals,
* handle responses, and process incoming signals asynchronously.
*
* The implementation is not thread-safe.
*/
public sealed interface GPCommunication<TInput : GPMessage<*>, TOutput : GPMessage<*>> : AutoCloseable {

/**
* Sends a signal to the generator and suspends until a response of the expected type is received.
*
* @param message The outgoing message to send.
* @return The response signal matching the expected type.
* @throws CommunicationException If communication fails or the response type is mismatched.
*/
public suspend fun send(message: TOutput)

/**
* Provides an iterator for processing an incoming message asynchronously.
*
* The iterator can be used in a suspending loop to process each signal sequentially.
*/
public val incoming: GPMessageIterator<TInput>
}

/**
* High-level handler for processing and responding to incoming generator signals.
*
* @param block A handler function that takes a `GeneratorSignal` and returns a list of `PluginSignal` to respond with.
* @throws CommunicationException If communication errors occur during processing.
*/
@Suppress("UNCHECKED_CAST")
public suspend inline fun <TInput : GPSignal, TOutput : GPSignal, TInputMessage : GPMessage<TInput>, TOutputMessage : GPMessage<TOutput>> GPCommunication<TInputMessage, TOutputMessage>.receive(
block: (TInput) -> List<TOutput>,
) {
while (incoming.hasNext()) {
val message = incoming.next()
val signal = message.signal

val isPluginSide = signal is PluginSignal

val responses = block(signal)

for (response in responses) {
send(
if (isPluginSide) {
PluginMessage.create {
id = message.id
this.signal = response as PluginSignal?
}
} else {
GeneratorMessage.create {
id = message.id
this.signal = response as GeneratorSignal?
}
} as TOutputMessage
)
}
}
}

public fun PluginCommunication(
input: BufferedSource,
output: BufferedSink,
): PluginCommunication =
GPCommunicationImpl(input, output, GeneratorMessage.serializer(), PluginMessage.serializer())

public fun GeneratorCommunication(
input: BufferedSource,
output: BufferedSink,
): GeneratorCommunication =
GPCommunicationImpl(input, output, PluginMessage.serializer(), GeneratorMessage.serializer())

/**
* Concrete implementation of [GPCommunication], providing mechanisms for
* sending messages to the generator and processing incoming messages asynchronously.
*
* @property input The source for reading incoming generator messages.
* @property output The sink for writing outgoing plugin messages.
* @param coroutineContext The coroutine context used for internal operations.
*/
/**
* Concrete implementation of [GPCommunication], providing mechanisms for
* sending messages to the generator and processing incoming messages asynchronously.
*
* @property input The source for reading incoming generator messages.
* @property output The sink for writing outgoing plugin messages.
* @param coroutineContext The coroutine context used for internal operations.
*/
private class GPCommunicationImpl<TInput : GPMessage<*>, TOutput : GPMessage<*>>(
private val input: BufferedSource,
private val output: BufferedSink,
private val inputSerializer: KSerializer<TInput>,
private val outputSerializer: KSerializer<TOutput>,
) : GPCommunication<TInput, TOutput> {

override val incoming: GPMessageIterator<TInput> = object : GPMessageIterator<TInput> {
private var nextMessage: TInput? = null
private var isClosed = false

override suspend fun hasNext(): Boolean {
if (!input.isOpen) return false
// Keep waiting for the next message until the source is closed.
if (isClosed) return false

nextMessage = try {
readMessage()
} catch (e: Exception) {
if (e is EOFException) {
isClosed = true
return false // End of stream
}
null
}

return nextMessage != null
}

override suspend fun next(): TInput {
return nextMessage ?: throw NoSuchElementException("No message available")
}
}

override suspend fun send(message: TOutput) {
val bytes = ProtoBuf.encodeToByteArray(outputSerializer, message)
output.writeInt(bytes.size)
output.write(bytes)
output.flush()
}

private fun readMessage(): TInput {
val size = input.readInt()
val bytes = input.readByteArray(size.toLong())
return ProtoBuf.decodeFromByteArray(inputSerializer, bytes)
}

private fun BufferedSink.writeInt(value: Int) {
writeByte((value shr 24) and 0xFF)
writeByte((value shr 16) and 0xFF)
writeByte((value shr 8) and 0xFF)
writeByte(value and 0xFF)
}

private fun BufferedSource.readInt(): Int {
return (readByte().toInt() shl 24) or
(readByte().toInt() shl 16) or
(readByte().toInt() shl 8) or
readByte().toInt()
}

override fun close() {
input.close()
output.close()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.timemates.rrpc.codegen.plugin

import org.timemates.rrpc.codegen.plugin.data.GPMessage

/**
* An asynchronous iterator for signals, allowing suspending iteration.
*/
public interface GPMessageIterator<T : GPMessage<*>> {
/**
* Checks if there are more signals available.
*/
public suspend operator fun hasNext(): Boolean

/**
* Retrieves the next signal. Should only be called after [hasNext] returns `true`.
*/
public suspend operator fun next(): T
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.timemates.rrpc.codegen.plugin.data

public sealed interface GPMessage<TSignal : GPSignal> {
public val id: SignalId
public val signal: TSignal
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.timemates.rrpc.codegen.plugin.data

public sealed interface GPSignal
Loading

0 comments on commit 3d12870

Please sign in to comment.