Skip to content

Writing a Custom Adapter

The kpointer-adapter module decouples pointer navigation and mutation from any concrete tree representation. The built-in JSON, YAML, and XML/HTML modules are all thin adapters over this API. If you have a different tree type — another JSON library, a config model, an in-memory DOM — you can teach kPointer about it by implementing the same interfaces.

Most consumers never depend on kpointer-adapter directly. Reach for it only when you are implementing an adapter for a new tree type, or writing generic code that must work across adapter families.

The interfaces

Every addressable value is a KpaElement, a sealed interface with exactly three implementors:

Interface Role
KpaStruct Object-shaped node addressed by string keys.
KpaList Array-shaped node addressed by integer indices.
KpaPrimitive Leaf value: string, number, boolean, or null.
KpaElementFactory Per-family factory that wraps primitives and rebuilds structs/lists.

Reading works on the read-only interfaces above. Mutation requires two more:

Interface Adds
RebuildableKpaStruct : KpaStruct a factory: KpaElementFactory property
RebuildableKpaList : KpaList a factory: KpaElementFactory property

The mutate { … } DSL extensions are defined only on the Rebuildable* interfaces. That is a deliberate design choice: a read-only adapter (say, one over an immutable DOM you cannot rebuild) implements plain KpaStruct / KpaList, and calling .mutate {} on it is a compile error rather than a runtime surprise.

graph TD
  element["KpaElement (sealed)"]
  struct["KpaStruct"]
  list["KpaList"]
  primitive["KpaPrimitive"]
  rstruct["RebuildableKpaStruct"]
  rlist["RebuildableKpaList"]

  element --> struct
  element --> list
  element --> primitive
  struct --> rstruct
  list --> rlist

Deciding: read-only or mutable?

  • If your tree can be rebuilt from a snapshot (a new map/list produces a new node), implement the Rebuildable* interfaces so consumers get mutate.
  • If your tree is read-only or cannot be reconstructed, implement plain KpaStruct / KpaList and skip the factory. Consumers can still read by pointer.

A read-only adapter

The minimum for reads is KpaStruct, KpaList, and KpaPrimitive:

class MyStruct(private val backing: MyObject) : KpaStruct {
    override fun get(key: String): KpaElement? = backing[key]?.toKpaElement()
    override fun contains(key: String): Boolean = key in backing
    override val keys: Set<String> get() = backing.keys()
    override fun toMap(): Map<String, KpaElement> =
        backing.entries().associate { it.key to it.value.toKpaElement() }
}

class MyList(private val backing: MyArray) : KpaList {
    override fun get(index: Int): KpaElement = backing[index].toKpaElement()
    override val size: Int get() = backing.size
    override fun toList(): List<KpaElement> = backing.map { it.toKpaElement() }
}

toKpaElement() here is your own conversion extension that wraps a native node in the right KpaElement. See Wrapping the backing value for a shortcut.

get semantics matter for conformance

KpaStruct.get must return null for an absent key, and KpaList.get is allowed to throw for an out-of-range index (the pointer resolver treats that as absent). Returning a non-null "empty" placeholder, or throwing for a missing struct key, breaks the absent-vs-error rule that the conformance suite enforces.

A mutable adapter

Add the factory and implement RebuildableKpaStruct / RebuildableKpaList, plus a KpaElementFactory that can wrap primitives and rebuild containers:

object MyFactory : KpaElementFactory {
    override fun String.toPrimitive(): KpaPrimitive = MyPrimitive(MyString(this))
    override fun Boolean.toPrimitive(): KpaPrimitive = MyPrimitive(MyBool(this))
    override fun Int.toPrimitive(): KpaPrimitive = MyPrimitive(MyNum(this))
    override fun Long.toPrimitive(): KpaPrimitive = MyPrimitive(MyNum(this))
    override fun Float.toPrimitive(): KpaPrimitive = MyPrimitive(MyNum(this))
    override fun Double.toPrimitive(): KpaPrimitive = MyPrimitive(MyNum(this))
    override val nullPrimitive: KpaPrimitive = MyPrimitive(MyNull)

    override fun withMap(map: Map<String, KpaElement>): RebuildableKpaStruct =
        MyStruct(rebuildObjectFrom(map))
    override fun withList(list: List<KpaElement>): RebuildableKpaList =
        MyList(rebuildArrayFrom(list))
}

class MyStruct(val backing: MyObject) : RebuildableKpaStruct {
    // ...the four read members from above...
    override val factory: KpaElementFactory get() = MyFactory
}

The mutate machinery threads factory from the root and calls withMap / withList to reconstruct each changed container, so nested elements themselves need not be rebuildable. The result of a mutation is a new tree of your adapter type; the original is never touched.

Reducing boilerplate

kpointer-adapter ships shared abstractions so you write only what is genuinely per-family.

Wrapping the backing value

Implement BackedKpaElement<B> to expose your native node through a single backing property. The companion requireBacking<B>(libraryName) unwraps any KpaElement back to its native value (or throws a descriptive error), so your toXxxElement() conversion can be a one-liner:

fun KpaElement.toMyElement(): MyNode = requireBacking("mylib")

AbstractKpaList and AbstractKpaStructMap

These base classes delegate the read members to a backing List<E> / Map<String, E>, leaving you to supply only wrapElement (and factory, if mutable):

class MyList(backing: List<MyNode>) : AbstractKpaList<MyNode>(backing) {
    override fun wrapElement(element: MyNode): KpaElement = element.toKpaElement()
}

AbstractKpaStructMap is for native maps that already use String keys. If your map uses a richer key type (as YamlKt does), implement KpaStruct directly and project to string keys yourself.

StringKpaPrimitive

For leaf types that are always strings (an XML attribute value, a text node), extend StringKpaPrimitive and override the single content property — it fixes every type flag to the string case for you:

class MyAttribute(override val content: String) : StringKpaPrimitive()

Verifying your adapter

Because the adapter contract is exactly what the conformance suite exercises, the surest test of a new adapter is to run the portable algorithm/resolve.json and algorithm/mutate.json fixtures through it. The reference JSON adapter does precisely this — a green run is evidence that your get, mutate, and absent-vs-error behavior all match the spec.