(work-in-progress) Kotlin implementation of KDL parsing with kotlinx.serialization support. https://kdl.dev
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
James Fenn 723e364909 partial integer writing / roundtrip functionality 3 days ago
example-jvm Initial commit 1 month ago
example-kotlinbrowser Initial commit 1 month ago
gradle/wrapper Initial commit 1 month ago
kdlkt partial integer writing / roundtrip functionality 3 days ago
.gitignore Initial commit 1 month ago
README.md Merge branch 'main' of code.horrific.dev:james/kdl-kt 1 month ago
build.gradle Initial commit 1 month ago
gradle.properties Initial commit 1 month ago
gradlew Initial commit 1 month ago
gradlew.bat Initial commit 1 month ago
settings.gradle Initial commit 1 month ago

README.md

KDL-KT JitPack Discord

This is a multiplatform Kotlin implementation of KDL, an xml-like document language with similar semantics to CLI commands. It currently targets the JVM, Android, and JS platforms (with plans for Native support in the future), and includes a serializer interface for kotlinx.serialization.

Installation

The :gitrest module is published on JitPack, which you can add to your project by copying the following to your root build.gradle at the end of "repositories".

allprojects {
  repositories {
    ...
    maven { url 'https://jitpack.io' }
  }
}

To add the dependency to a module, copy this line into your app's build.gradle file.

implementation "dev.horrific.code.james.kdl-kt:kdlkt-jvm:$gitrest_version"

The Android dependency can be imported similarly, using the root kdlkt dependency instead of kdlkt-jvm; gradle should identify & download the gitrest-android variant.

implementation "dev.horrific.code.james.kdl-kt:kdlkt:$gitrest_version"

Usage

The KDL object can be created as follows:

val kdl = KDL {
  // ...config
}

(TODO: explain config values)

Parsing

The KDL.parse(String) or KDL.parse(Iterator<Char>) functions can be used to parse a KDL document. On the Java platform, this function also accepts a File or a BufferedReader.

This returns a ParserResult object containing warnings & other parsing metadata. To access the root nodes, this can be iterated over directly or converted into a List<Node> with KDL.parse(...).toList().

Each node has several properties containing its information:

  • identifier: String: the node id/key
  • arguments: List<Any?>: arguments are parsed as a Boolean, String, Int, Double, or (platform-specific) BigInt.
  • properties: Map<String, Any?>: property values (parsed the same as arguments)
  • children: Iterator<Node>: any node children (this is a delegated property, so it always provides a new iterator)

Node Iterator

If time/space usage is a concern, ParserResult can also be iterated over directly in order to interact with the nodes during parsing:

for (node in KDL.parse(...)) {
    // do something
}

In this form, the parser only keeps a reference to the last two nodes at a time* - and only reads through the file until it finds the next node. This allows implementations to perform efficient searches with an early return, or discard extraneous nodes in large files.

* in the case of nested nodes, the document is parsed recursively - so in reality, this should be multiplied by the depth of the document.

Discarding a Node

node.discard() can be called to remove a node from the tree during the first iteration. This only works using the iterator as shown below, and will not have any effect if used with .toList().

As an example, here is an implementation that only preserves root nodes with the identifier "keep":

val parser = KDL.parse(document)
for (node in parser) {
    if (node.identifier != "keep")
        node.discard() // can only be used during initial parsing/iteration
}

// the parser object only contains root `keep` nodes

When an extraneous node is discovered, the Node.discard() function will remove it from the Node tree. At this point, none of its child nodes have been parsed - they will be skipped, and iteration will resume at the next root node.

This can also be done recursively across the whole document (only eliminating individual nodes if they don't have any children):

fun kdlWithKeepOnly(source: Iterable<Node>) : Iterable<Node> {
    // make sure to iterate depth-first! (in order to eliminate child nodes while-parsing, then check for parent nodes afterwards)
    // - we don't know if a node is a "parent node" until its children have been parsed
    for (node in source) {
        kdlWithKeepOnly(node) // Node also extends Iterable<Node> for convenience, but `node.children` could be used here as well

        if (node.isEmpty() && node.identifier != "keep")
            node.discard()
    }

    return source
}

val rootNodes = kdlWithKeepOnly(KDL.parse(document))
// rootNodes (and children) now only contain `keep` nodes and parents

* disclaimer: I have yet to actually test the practical efficiency of this approach - but it should still be better for large files than the List<Node> alternative.

Writing

(TODO: writing has not been implemented yet)

Serialization

In frameworks such as Ktor, the KDL object can be registered as a content serializer:

install(ContentNegotiation) {
    serialization(ContentType("application", "kdl"), KDL)
}

Alternatively, KDL.encodeToString(Type) and KDL.decodeFromString<Type>(string) can be used to manually serialize an object. See the Kotlin Serialization Guide for more information.

Object Types

When decoding the root document, the decoder acts like it is parsing a node into that type with nothing but child nodes (being the collection of root nodes in the document).

Currently, nodes can be serialized as the following types:

  • class: serializes all node arguments/properties/children

    Note: conflicting field names might cause some ambiguity or unintentional side-effects - see Special Identifiers for a workaround.

  • List<Type>: attempts to serialize all arguments and child nodes as Type (omits special identifiers)

    If strictMode is true (default), this will throw an error if there are any properties are on the provided node.

  • Map<String, Type>: attempts to serialize all arguments, properties, and child nodes as Type (omits special identifiers)

    Arguments are indexed by [0], and other values are indexed by their raw identifier value. If multiple values are provided with the same name, the last value will overwrite any previous properties.

Special Identifiers

Because KDL does not directly map into Kotlin classes/objects, some "special identifiers" can be used to explicitly reference parts of the node (it is suggested to use the @SerialName annotation to reference these):

  • @ - the current node identifier
  • $@ - the set of node arguments
  • .@ - the map of node properties
  • {@} - the set of node children

Similarly, all serialization occurs in the priority of "arguments, then properties, then children." For example, an argument [0] would replace a property named "[0]" on the same node; and a property named .[0] would replace a child node with the identifier ".[0]". However, every part of the node can always be accessed by specifying the @SerialName in the following syntax:

  • $n - references the n-th argument (where n is the index of the argument, from 0)
  • .key - references a property named key
  • {$n} - references the n-th child (where n is the index of the child node)
  • {id} - references the first child node with the identifier id

Example

This is pretty complex, so I've created an example of how one might parse a kdl-like package metadata format:

package "me.jfenn.kdlkt" version="1.0"

dependencies {
  implementation "kotlinx.serialization" version="1.2.0"
  testImplementation "test"
  testImplementation "test-junit"
}

authors {
  "James Fenn" email="me@jfenn.me" website="https://jfenn.me"
  "Someone Else"
  "Another Person" email="notme@jfenn.me"
}

...and the corresponding Kotlin class:

@Serializable
class ApplicationInfo {

  // single PackageInfo class
  @SerialName("{package}")
  val pkg: PackageInfo? = null

  // plain list of dependencies
  @SerialName("{dependencies}")
  val dependencies: List<DependencyInfo> = listOf()

  // map of (name, AuthorInfo)
  @SerialName("{authors}")
  val authors: Map<String, AuthorInfo> = mapOf()

  @Serializable
  class PackageInfo {
    @SerialName("$0")
    val packageName: String = ""

    @SerialName(".version")
    val version: String = ""
  }

  @Serializable
  class DependencyInfo {
    @SerialName("@")
    val type: String = "implementation"

    @SerialName("$0")
    val name: String = ""

    @SerialName(".version")
    val version: String? = null
  }

  @Serializable
  class AuthorInfo {
    @SerialName("@")
    val name: String = ""

    @SerialName(".email")
    val email: String? = ""

    @SerialName(".website")
    val website: String? = ""
  }
}