GraphQuery.kt
src/jvmMain/kotlin/afield/data/query/graph/GraphQuery.kt · 1012 lines
package afield.data.query.graph
import afield.ui.visualization.Table
import afield.ui.visualization.ToHTML
import java.util.IdentityHashMap
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
import kotlin.reflect.KVisibility
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.isAccessible
A reflective, XPath-style query layer over arbitrary live object graphs.
The model is small: graphPaths walks any object and returns a PathSet of
GraphPathRefs. A ref is a live lens — a slash-joined address plus a getter and, when
the underlying slot is mutable, a setter. Nothing is snapshotted, so reading a ref twice
can legitimately give two answers.
val cutoffs = patch.graphPaths("**\/cutoff") // every cutoff at any depth
patch.graphPaths("osc/" + "*").writable() // one level down, settable only
patch.graphPaths("**" + "/" + "*Alpha") // a glob inside one segment
patch.graphPaths("gain | **" + "/cutoff") // either pattern, each slot once
patch.graphPaths("**").where { (it.get() as? Float ?: 0f) > 0.5f }
This package is domain-agnostic: it knows nothing about audio, and in
particular it does not touch the runloop. The synth-specific parameter head (ParamSet,
modulation, supplier binding) lives in afield.audio.synth and is layered on top via
PathSet.params().
Traversal rules
Edges come from LiveGraphInspectable.liveGraphChildren when an object opts in, merged
ahead of reflected edges (maps by key, lists/arrays by index, everything else by Kotlin
member property in primary-constructor order). Descent stops at scalars, strings, enums,
primitive arrays, functions, class objects, and anything in the JDK's own packages — see
GraphWalkOptions.descendInto to override that policy.
Cycles are broken by path-scoped detection: a node is skipped only when it already
appears on the current ancestor chain. A node shared by several edges is therefore
expanded under each of them (so an alias is fully queryable), while a -> b -> a still
terminates. GraphWalkOptions.maxRefs bounds the pathological dense-DAG case.
interface LiveGraphInspectable
Optional protocol for objects that want to expose meaningful live-graph children instead of whatever general reflection would discover. Custom edges win on name collisions.
fun liveGraphChildren(): Iterable<LiveGraphEdge> = emptyList()
data class LiveGraphEdge
One named, readable — and possibly writable — edge out of a node.
data class LiveGraphEdge(
val name: String,
val getter: () -> Any?,
val setter: ((Any?) -> Unit)? = null
) {
constructor(name: String, getter: () -> Any?) : this(name, getter, null)
data class GraphWalkOptions
Traversal policy.
data class GraphWalkOptions(
/** Reflect over private properties too, forcing accessibility on each one. */
val includePrivate: Boolean = false,
/** With this off, a slot whose current value is null is walked through but left out of the result. */
val includeNulls: Boolean = true,
/** How many edges deep to expand. */
val maxDepth: Int = 32,
/** Hard bound on collected refs; the walk stops cleanly once it is reached, rather than failing. */
val maxRefs: Int = 200_000,
/**
* Refines the "should I open this value up?" policy. It receives the value *and* the default
* decision, so `{ value, byDefault -> byDefault && value !is Osc }` tightens the default rather
* than replacing it. Taking the default as an argument is how a one-line prune avoids reflecting
* into `String` and reporting `name/length`.
*/
val descendInto: ((value: Any, byDefault: Boolean) -> Boolean)? = null
)
class GraphPathRef internal constructor
A live, addressable slot in an object graph.
class GraphPathRef internal constructor(
internal val pathSegments: List<String>,
val owner: Any?,
private val getter: () -> Any?,
private val setter: ((Any?) -> Unit)?
) {
val rawPath: String = pathSegments.joinToString("/")
val path: String = if (rawPath.isBlank()) "<root>" else rawPath
val name: String get() = pathSegments.lastOrNull().orEmpty()
The last segment of the address — the slot's own name.
val depth: Int get() = pathSegments.size
How many edges from the root this slot sits at.
val writable: Boolean get() = setter != null
fun get(): Any? = getter()
fun <T : Any> getAs(type: KClass<T>): T? = type.safeCast(get())
Typed read: the value when it is a T, else null.
inline fun <reified T : Any> getAs(): T? = getAs(T::class)
fun set(value: Any?)
fun set(value: Any?) {
val set = setter ?: error("Path '$path' is not writable")
set(value)
}
fun trySet(value: Any?): Boolean
Write only if the slot is writable; reports whether it happened.
fun trySet(value: Any?): Boolean {
val set = setter ?: return false
set(value)
return true
}
internal fun rebased(prefix: List<String>): GraphPathRef
The same live slot, re-addressed under prefix. Used when a query is continued from a
ref rather than from the original root, so rig / "carrier" / "freq" still reports
carrier/freq rather than the bare freq the second walk would have produced.
internal fun rebased(prefix: List<String>): GraphPathRef =
GraphPathRef(prefix + pathSegments, owner, getter, setter)
override fun toString(): String = path
class PathSet internal constructor
The result of a query: an ordered, immutable collection of live refs.
Combinators return new sets and never mutate the receiver, so a set is safe to hold and re-filter. Renders itself as an HTML table, which is what the docs captures show.
class PathSet internal constructor(
val root: Any?,
internal val refs: List<GraphPathRef>
) : Iterable<GraphPathRef>, ToHTML {
val size: Int get() = refs.size
val isEmpty: Boolean get() = refs.isEmpty()
val isNotEmpty: Boolean get() = refs.isNotEmpty()
fun paths(): List<String> = refs.map { it.path }
fun values(): List<Any?> = refs.map { it.get() }
fun writable(): PathSet = PathSet(root, refs.filter { it.writable })
fun dedup(): PathSet = PathSet(root, deduplicateRefs(refs))
fun deduplicated(): PathSet = dedup()
fun matching(pattern: String): PathSet
Glob-filter an existing set with the same * / ** / | rules as graphPaths.
fun matching(pattern: String): PathSet {
val branches = splitAlternatives(pattern)
return PathSet(root, refs.filter { globMatchesAny(it.pathSegments, branches) })
}
fun excluding(pattern: String): PathSet
Glob-filter out the matches — the complement of matching.
fun excluding(pattern: String): PathSet {
val branches = splitAlternatives(pattern)
return PathSet(root, refs.filterNot { globMatchesAny(it.pathSegments, branches) })
}
predicates: the XPath [...] half of the story
fun where(predicate: (GraphPathRef) -> Boolean): PathSet =
Keep refs satisfying an arbitrary predicate over the whole ref.
fun where(predicate: (GraphPathRef) -> Boolean): PathSet =
PathSet(root, refs.filter(predicate))
fun whereValue(predicate: (Any?) -> Boolean): PathSet =
Keep refs whose current value satisfies a predicate.
fun whereValue(predicate: (Any?) -> Boolean): PathSet =
PathSet(root, refs.filter { predicate(it.get()) })
fun <T : Any> whereValueIs(type: KClass<T>, predicate: (T) -> Boolean): PathSet =
Keep refs whose value is a T and satisfies a typed predicate — the common case, without the cast at every call site.
fun <T : Any> whereValueIs(type: KClass<T>, predicate: (T) -> Boolean): PathSet =
PathSet(root, refs.filter { ref -> type.safeCast(ref.get())?.let(predicate) == true })
inline fun <reified T : Any> whereValueIs(noinline predicate: (T) -> Boolean): PathSet =
inline fun <reified T : Any> whereValueIs(noinline predicate: (T) -> Boolean): PathSet =
whereValueIs(T::class, predicate)
fun ownedBy(owner: Any?): PathSet = PathSet(root, refs.filter { it.owner === owner })
Keep refs owned by a specific object, compared by identity.
fun nonNull(): PathSet = PathSet(root, refs.filter { it.get() != null })
Drop refs whose current value is null.
fun atDepth(range: IntRange): PathSet = PathSet(root, refs.filter { it.depth in range })
Restrict to a depth range, e.g. atDepth(1..2).
fun ofType(type: KClass<*>): PathSet = PathSet(root, refs.filter { type.isInstance(it.get()) })
inline fun <reified T : Any> ofType(): PathSet = ofType(T::class)
typed terminals
fun <T : Any> lenses(type: KClass<T>): LensSet<T>
Narrow to a typed LensSet over the refs whose value is a T. This is the general terminal: the float-only parameter head is one specialization of the same idea.
fun <T : Any> lenses(type: KClass<T>): LensSet<T> = LensSet(
type,
refs.filter { type.isInstance(it.get()) }.map { Lens(it, type) }
)
inline fun <reified T : Any> lenses(): LensSet<T> = lenses(T::class)
fun firstOrNull(): GraphPathRef? = refs.firstOrNull()
fun first(): GraphPathRef = refs.firstOrNull() ?: error("PathSet is empty")
Returns the first path reference in this set.
@throws IllegalStateException if the set is empty.
operator fun get(path: String): GraphPathRef?
Look up a single literal path — case-insensitive, like the patterns — or null.
operator fun get(path: String): GraphPathRef? {
val wanted = splitPath(path)
return refs.firstOrNull { ref ->
ref.pathSegments.size == wanted.size &&
ref.pathSegments.indices.all { ref.pathSegments[it].equals(wanted[it], ignoreCase = true) }
}
}
override fun iterator(): Iterator<GraphPathRef> = refs.iterator()
override fun toString(): String = "PathSet(${size})"
override fun toHTML(): String
override fun toHTML(): String = renderGraphTable(
title = "PathSet",
count = size,
columns = listOf("path" to PATH_COLOUR, "value" to VALUE_COLOUR),
rows = refs.map { ref -> listOf(ref.path, shortValue(ref.get())) }
)
}
class Lens<T : Any> internal constructor
A typed handle on one slot. Unlike a bare GraphPathRef the type is fixed up front, so
get() needs no cast and set() cannot install the wrong kind of value.
class Lens<T : Any> internal constructor(
val ref: GraphPathRef,
val type: KClass<T>
) {
val path: String get() = ref.path
val name: String get() = ref.name
val owner: Any? get() = ref.owner
val writable: Boolean get() = ref.writable
fun get(): T? = type.safeCast(ref.get())
The value when it is still a T; null if the slot has since changed shape.
fun getOrElse(fallback: T): T = get() ?: fallback
fun set(value: T) = ref.set(value)
fun trySet(value: T): Boolean = ref.trySet(value)
fun update(transform: (T) -> T): Boolean {
Read-modify-write through the lens; no-op when the slot no longer holds a T.
fun update(transform: (T) -> T): Boolean {
val current = get() ?: return false
return ref.trySet(transform(current))
}
override fun toString(): String = "$path: ${type.simpleName}"
class LensSet<T : Any> internal constructor
A set of typed lenses — the general counterpart to the synth ParamSet, for graphs whose
interesting leaves are strings, booleans, enums, colours, vectors, anything at all.
scene.graphPaths("**").lenses<String>().set("hello")
scene.graphPaths("**").lenses<Boolean>().update { !it }
class LensSet<T : Any> internal constructor(
val type: KClass<T>,
val lenses: List<Lens<T>>
) : Iterable<Lens<T>>, ToHTML {
val size: Int get() = lenses.size
val isEmpty: Boolean get() = lenses.isEmpty()
fun paths(): List<String> = lenses.map { it.path }
fun values(): List<T?> = lenses.map { it.get() }
fun presentValues(): List<T> = lenses.mapNotNull { it.get() }
Present values only, in order.
fun writable(): LensSet<T> = LensSet(type, lenses.filter { it.writable })
fun where(predicate: (Lens<T>) -> Boolean): LensSet<T> = LensSet(type, lenses.filter(predicate))
fun set(value: T): Int = lenses.count { it.trySet(value) }
Assign one value to every writable lens; returns the number written.
fun set(values: Iterable<T>): Int
Assign positionally, stopping when either side runs out.
fun set(values: Iterable<T>): Int =
lenses.zip(values.toList()).count { (lens, value) -> lens.trySet(value) }
fun update(transform: (T) -> T): Int = lenses.count { it.update(transform) }
Read-modify-write every lens; returns the number actually written.
fun updateIndexed(transform: (Int, T) -> T): Int =
Read-modify-write with the index available.
fun updateIndexed(transform: (Int, T) -> T): Int =
lenses.withIndex().count { (index, lens) -> lens.update { transform(index, it) } }
fun firstOrNull(): Lens<T>? = lenses.firstOrNull()
override fun iterator(): Iterator<Lens<T>> = lenses.iterator()
override fun toString(): String = "LensSet<${type.simpleName}>(${size})"
override fun toHTML(): String
override fun toHTML(): String = renderGraphTable(
title = "LensSet<${type.simpleName ?: "?"}>",
count = size,
columns = listOf("path" to PATH_COLOUR, "value" to VALUE_COLOUR, "state" to MUTED_COLOUR),
rows = lenses.map { lens ->
listOf(
lens.path,
shortValue(lens.get()),
if (lens.writable) "writable" else "read-only"
)
}
)
}
Reflection
internal object GraphReflectionCache
internal object GraphReflectionCache {
private val publicCache = ConcurrentHashMap<KClass<*>, List<KProperty1<Any, *>>>()
private val privateCache = ConcurrentHashMap<KClass<*>, List<KProperty1<Any, *>>>()
private val publicByName = ConcurrentHashMap<KClass<*>, Map<String, KProperty1<Any, *>>>()
private val privateByName = ConcurrentHashMap<KClass<*>, Map<String, KProperty1<Any, *>>>()
fun properties(type: KClass<*>, includePrivate: Boolean): List<KProperty1<Any, *>>
fun properties(type: KClass<*>, includePrivate: Boolean): List<KProperty1<Any, *>> {
val cache = if (includePrivate) privateCache else publicCache
return cache.computeIfAbsent(type) { buildProperties(it, includePrivate) }
}
fun propertyMap(type: KClass<*>, includePrivate: Boolean): Map<String, KProperty1<Any, *>>
Name-keyed view, so the fast path can resolve one segment without building every edge.
fun propertyMap(type: KClass<*>, includePrivate: Boolean): Map<String, KProperty1<Any, *>> {
val cache = if (includePrivate) privateByName else publicByName
return cache.computeIfAbsent(type) { properties(it, includePrivate).associateBy { p -> p.name } }
}
private fun buildProperties(type: KClass<*>, includePrivate: Boolean): List<KProperty1<Any, *>> =
// Kotlin reflection refuses some classes outright (synthetic lambdas, some Java and script
// classes throw KotlinReflectionInternalError or UnsupportedOperationException). Such a
// node has no reflected edges; it must not abort the whole walk.
try { buildPropertiesOrThrow(type, includePrivate) } catch (_: Throwable) { emptyList() }
@Suppress("UNCHECKED_CAST")
private fun buildPropertiesOrThrow(type: KClass<*>, includePrivate: Boolean): List<KProperty1<Any, *>> {
val constructorOrder = runCatching { type.primaryConstructor?.parameters?.mapNotNull { it.name } }
.getOrNull() ?: emptyList()
val discovered = linkedMapOf<String, KProperty1<Any, *>>()
val candidates = buildList {
addAll(type.memberProperties)
if (includePrivate) addAll(type.declaredMemberProperties)
}
val ordered = candidates
.filterIsInstance<KProperty1<Any, *>>()
.filter { includePrivate || it.visibility == KVisibility.PUBLIC }
.sortedWith(
compareBy<KProperty1<Any, *>>(
{ constructorOrder.indexOf(it.name).let { idx -> if (idx >= 0) idx else Int.MAX_VALUE } },
{ it.name }
)
)
ordered.forEach { discovered.putIfAbsent(it.name, it) }
return discovered.values.toList()
}
}
internal class StaticResolution
The outcome of resolving a run of literal path segments.
internal class StaticResolution(
val ref: GraphPathRef,
val value: Any?,
val ancestors: List<Any>,
/**
* True when every segment matched an edge name *exactly*. A segment that only matched
* ignoring case may have case-variant siblings, and this resolver can only ever report
* one of them — so the pattern layer uses this to decide whether the fast path is still
* allowed to speak for the full walk.
*/
val allExact: Boolean = true
)
internal object GraphWalker
internal object GraphWalker {
fun scan(root: Any?, options: GraphWalkOptions): PathSet
Full traversal, then glob-filter. The general path.
fun scan(root: Any?, options: GraphWalkOptions): PathSet {
if (root == null) return PathSet(root, emptyList())
val refs = mutableListOf<GraphPathRef>()
walk(root, emptyList(), 0, ArrayList(), refs, options)
return PathSet(root, refs)
}
Collect every ref under node, addressed relative to basePath.
Cycle detection is path-scoped: ancestors holds the chain from the root to node,
compared by identity. A shared node therefore expands under each alias that reaches
it, while a -> b -> a still stops.
private fun walk(
node: Any?,
basePath: List<String>,
depth: Int,
ancestors: MutableList<Any>,
into: MutableList<GraphPathRef>,
options: GraphWalkOptions
) {
if (node == null || depth > options.maxDepth || !shouldDescend(node, options)) return
if (ancestors.any { it === node }) return
if (into.size >= options.maxRefs) return
ancestors.add(node)
try {
edgesFor(node, options).forEach { edge ->
if (into.size >= options.maxRefs) return
val childPath = basePath + edge.name
val value = safeGet(edge.getter)
if (value != null || options.includeNulls) {
into += GraphPathRef(
pathSegments = childPath,
owner = node,
getter = edge.getter,
setter = edge.setter
)
}
walk(value, childPath, depth + 1, ancestors, into, options)
}
} finally {
ancestors.removeAt(ancestors.size - 1)
}
}
fun resolveStatic
Resolve a run of literal segments without touching the rest of the graph — O(depth) instead of O(graph).
StaticResolution.ancestors is the chain of nodes stepped through, which the caller must feed back into scanSubtree. Without it a subtree that loops back up through an ancestor would be expanded here but pruned by a full walk, and the two paths would disagree. The same chain doubles as cycle detection for the literal path itself.
fun resolveStatic(
root: Any?,
segments: List<String>,
options: GraphWalkOptions
): StaticResolution? {
if (root == null || segments.isEmpty()) return null
var current: Any? = root
var ref: GraphPathRef? = null
var allExact = true
val chain = mutableListOf<Any>()
segments.forEachIndexed { index, segment ->
val node = current ?: return null
if (!shouldDescend(node, options)) return null
if (index > options.maxDepth) return null
if (chain.any { it === node }) return null
chain.add(node)
val exact = edgeNamedExactly(node, segment, options)
val edge = exact ?: edgeNamedIgnoringCase(node, segment, options) ?: return null
if (exact == null || caseAmbiguous(node, segment, options)) allExact = false
val value = safeGet(edge.getter)
ref = GraphPathRef(
pathSegments = segments.subList(0, index + 1).toList(),
owner = node,
getter = edge.getter,
setter = edge.setter
)
current = value
}
val resolved = ref ?: return null
return StaticResolution(resolved, current, chain, allExact)
}
fun scanSubtree
Walk only the subtree rooted at node, addressing results relative to basePath.
seedAncestors carries the chain already traversed to get here.
fun scanSubtree(
node: Any?,
basePath: List<String>,
options: GraphWalkOptions,
seedAncestors: List<Any> = emptyList()
): List<GraphPathRef> {
val refs = mutableListOf<GraphPathRef>()
walk(node, basePath, basePath.size, ArrayList(seedAncestors), refs, options)
return refs
}
One named edge, resolved directly by its exact name. Mirrors reflectiveEdges case by
case, so this stays O(1)-ish rather than building every edge of the node.
source — 51 lines, from line 513
private fun edgeNamedExactly(node: Any, name: String, options: GraphWalkOptions): LiveGraphEdge? {
(node as? LiveGraphInspectable)?.liveGraphChildren()?.firstOrNull { it.name == name }
?.let { return it }
return when (node) {
is Map<*, *> -> {
val entryKey = node.keys.firstOrNull { (it?.toString() ?: return@firstOrNull false) == name }
if (entryKey == null && !node.containsKey(name)) return null
val rawKey = entryKey ?: name
@Suppress("UNCHECKED_CAST")
val mutable = node as? MutableMap<Any?, Any?>
LiveGraphEdge(
name = name,
getter = { node[rawKey] },
setter = mutable?.let { map -> { value: Any? -> map[rawKey] = value } }
)
}
is List<*> -> {
val index = name.toIntOrNull() ?: return null
if (index !in node.indices) return null
@Suppress("UNCHECKED_CAST")
val mutable = node as? MutableList<Any?>
LiveGraphEdge(
name = name,
getter = { node.getOrNull(index) },
setter = mutable?.let { list -> { value: Any? -> list[index] = value } }
)
}
is Array<*> -> {
val index = name.toIntOrNull() ?: return null
if (index !in node.indices) return null
@Suppress("UNCHECKED_CAST")
val mutable = node as Array<Any?>
LiveGraphEdge(
name = name,
getter = { node[index] },
setter = { value: Any? -> mutable[index] = value }
)
}
is Collection<*> -> {
val index = name.toIntOrNull() ?: return null
if (index !in 0 until node.size) return null
LiveGraphEdge(name = name, getter = { node.elementAtOrNull(index) })
}
else -> {
val prop = GraphReflectionCache.propertyMap(node::class, options.includePrivate)[name]
?: return null
propertyEdge(node, prop, options)
}
}
}
Could this segment name more than one edge once case is ignored?
Two properties differing only in case are two slots, and a pattern matches both — but resolveStatic can only ever report one of them, so it has to know when to stand down.
What counts is two different spellings, not two hits: a custom edge and a reflected
property that share a name are merged into one edge by edgesFor, so seeing leaf
twice is not ambiguity. Index names cannot collide on case at all.
Cheap on purpose — names only, out of the cached property map or the map's own keys, with no allocation, no edge built and no value read. That is what keeps a named path's cost proportional to its length.
private fun caseAmbiguous(node: Any, name: String, options: GraphWalkOptions): Boolean {
var spelling: String? = null
var ambiguous = false
fun consider(candidate: String) {
if (!candidate.equals(name, ignoreCase = true)) return
val already = spelling
if (already == null) spelling = candidate else if (already != candidate) ambiguous = true
}
(node as? LiveGraphInspectable)?.liveGraphChildren()?.forEach { consider(it.name) }
when (node) {
is Map<*, *> -> node.keys.forEach { key -> consider(key?.toString() ?: return@forEach) }
is List<*>, is Array<*>, is Collection<*> -> Unit
else -> GraphReflectionCache.propertyMap(node::class, options.includePrivate)
.keys.forEach { consider(it) }
}
return ambiguous
}
The same lookup ignoring case, consulted only when the exact name missed. It goes
through edgesFor deliberately: an edge found this way is then the very edge the full
walk would have reported, and the first match wins so the answer is the declaration
order one rather than an arbitrary one.
private fun edgeNamedIgnoringCase(node: Any, name: String, options: GraphWalkOptions): LiveGraphEdge? =
edgesFor(node, options).firstOrNull { it.name.equals(name, ignoreCase = true) }
internal fun edges(node: Any, options: GraphWalkOptions): List<LiveGraphEdge>
The edges out of node in walk order — custom LiveGraphInspectable edges first, then
reflected ones — or an empty list when the walk would not descend into it. The inspector
(tree(...) / column(...)) lists exactly these, so a row's address is a path this walker
resolves.
body — 82 lines, from line 612
internal fun edges(node: Any, options: GraphWalkOptions): List<LiveGraphEdge> =
if (shouldDescend(node, options)) edgesFor(node, options) else emptyList()
private fun edgesFor(node: Any, options: GraphWalkOptions): List<LiveGraphEdge> {
val custom = (node as? LiveGraphInspectable)?.liveGraphChildren()?.toList().orEmpty()
val reflected = reflectiveEdges(node, options)
if (custom.isEmpty()) return reflected
val merged = linkedMapOf<String, LiveGraphEdge>()
custom.forEach { merged[it.name] = it }
reflected.forEach { merged.putIfAbsent(it.name, it) }
return merged.values.toList()
}
private fun reflectiveEdges(node: Any, options: GraphWalkOptions): List<LiveGraphEdge> = when (node) {
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
val mutable = node as? MutableMap<Any?, Any?>
node.keys.mapNotNull { rawKey ->
val key = rawKey?.toString() ?: return@mapNotNull null
LiveGraphEdge(
name = key,
getter = { node[rawKey] },
setter = mutable?.let { map -> { value: Any? -> map[rawKey] = value } }
)
}
}
is List<*> -> {
@Suppress("UNCHECKED_CAST")
val mutable = node as? MutableList<Any?>
node.indices.map { index ->
LiveGraphEdge(
name = index.toString(),
getter = { node.getOrNull(index) },
setter = mutable?.let { list -> { value: Any? -> list[index] = value } }
)
}
}
// Arrays are mutable in Kotlin, so their elements get setters just like a
// MutableList's do. Storing an incompatible value throws ArrayStoreException,
// which is the correct loud failure rather than something to pre-empt here.
is Array<*> -> {
@Suppress("UNCHECKED_CAST")
val mutable = node as Array<Any?>
node.indices.map { index ->
LiveGraphEdge(
name = index.toString(),
getter = { node[index] },
setter = { value: Any? -> mutable[index] = value }
)
}
}
// A Set (or any other non-List collection) is addressed by position in iteration order,
// read-only: there is no slot to write back to. Before this, a Set was reflected as an
// object and showed only its `size`.
is Collection<*> -> node.indices.map { index ->
LiveGraphEdge(name = index.toString(), getter = { node.elementAtOrNull(index) })
}
else -> GraphReflectionCache.properties(node::class, options.includePrivate)
.map { propertyEdge(node, it, options) }
}
private fun propertyEdge(
node: Any,
prop: KProperty1<Any, *>,
options: GraphWalkOptions
): LiveGraphEdge {
// Accessibility is about *reaching* the member, not about which members we chose to
// expose — that decision was already made by the visibility filter in
// GraphReflectionCache. A public property of a non-public class (a private nested
// class, an internal class) still needs setAccessible(true) to invoke; without it
// prop.get throws IllegalAccessException, safeGet turns that into null, and the walk
// silently stops descending. So always try to open the accessor.
ensureAccessible(prop)
val getter = { prop.get(node) }
val setter = if (prop is KMutableProperty1<*, *>) {
@Suppress("UNCHECKED_CAST")
val mutable = prop as KMutableProperty1<Any, Any?>
{ value: Any? -> mutable.set(node, value) }
} else null
return LiveGraphEdge(prop.name, getter, setter)
}
Best-effort setAccessible(true). A module boundary can refuse, in which case the
getter simply fails later and the slot reads as null — the same outcome as before,
but now only for members that genuinely cannot be reached.
private fun ensureAccessible(prop: KProperty1<Any, *>) {
if (prop.isAccessible) return
runCatching { prop.isAccessible = true }
}
private fun safeGet(getter: () -> Any?): Any? = try {
getter()
} catch (_: Throwable) {
null
}
internal fun shouldDescend(value: Any, options: GraphWalkOptions): Boolean
internal fun shouldDescend(value: Any, options: GraphWalkOptions): Boolean {
val byDefault = defaultShouldDescend(value)
val refine = options.descendInto ?: return byDefault
return refine(value, byDefault)
}
private fun defaultShouldDescend(value: Any): Boolean = when (value) {
is Number, is Boolean, is Char, is String, is Enum<*> -> false
is FloatArray, is DoubleArray, is IntArray, is LongArray,
is ShortArray, is ByteArray, is BooleanArray, is CharArray -> false
is Function<*> -> false
is Class<*>, is KClass<*> -> false
is Map<*, *>, is Collection<*>, is Array<*> -> true
is LiveGraphInspectable -> true
else -> {
val pkg = value::class.java.packageName ?: ""
!(pkg.startsWith("java.") || pkg.startsWith("javax.") || pkg.startsWith("jdk.") || pkg.startsWith("sun."))
}
}
}
Patterns — parsed once, reusable, with a fast path for literal prefixes
class GraphPattern internal constructor(val pattern: String, internal val branches: List<List<String>>)
A parsed query pattern.
Compiling a pattern is worth doing when the same query runs repeatedly — the segments are split once, and the literal prefix is exploited to avoid walking the whole graph:
val cutoffs = GraphQuery.compile("osc/" + "*" + "/cutoff")
every(frame) { cutoffs.resolve(patch).values() }
osc is descended directly, and only that subtree is traversed. A fully literal pattern
resolves in O(depth) with no traversal and no wasted allocation at all. A segment holding a
within-segment glob ("osc*") is not part of the literal prefix — it has to be matched
against the names that are actually there, which means walking. A pattern with more than
one | branch walks too, and that is the deliberate trade: one walk filtered by every
branch at once, which is both simpler and cheaper than a query per branch, and it reports
each slot once even when two branches would have claimed it.
class GraphPattern internal constructor(val pattern: String, internal val branches: List<List<String>>) {
The single branch, when there is one. Every optimization below needs one path shape to descend, so alternation deliberately gives all of them up rather than guessing.
private val onlyBranch: List<String>? = branches.singleOrNull()
internal val staticPrefix: List<String>
Leading segments that are plain names — the part we can descend without searching.
internal val staticPrefix: List<String> =
onlyBranch?.takeWhile { !it.contains('*') } ?: emptyList()
val isStatic: Boolean get() = onlyBranch != null && staticPrefix.size == onlyBranch.size
True when the whole pattern is literal, so exactly one slot can match.
val alternatives: Int get() = branches.size
How many alternatives this pattern was written with.
fun matches(path: String): Boolean = globMatchesAny(splitPath(path), branches)
fun resolve(root: Any?, options: GraphWalkOptions = GraphWalkOptions()): PathSet
Run this pattern against a root.
fun resolve(root: Any?, options: GraphWalkOptions = GraphWalkOptions()): PathSet {
if (root == null || branches.isEmpty()) return PathSet(root, emptyList())
// Fully literal: resolve the chain directly, no traversal.
//
// `allExact` is what keeps the two strategies honest. A literal segment matches
// ignoring case, so `"freq"` can reach a slot actually called `Freq` — but if a node
// holds both, only the walk can report both, while this resolver returns one. So a
// literal pattern takes the fast path only when it named its slots exactly, and
// otherwise falls through to the walk below. Getting the case right is the common
// case, so the optimization is intact in practice.
if (isStatic) {
val resolved = GraphWalker.resolveStatic(root, onlyBranch!!, options)
?: return PathSet(root, emptyList())
if (resolved.allExact) {
if (resolved.value == null && !options.includeNulls) return PathSet(root, emptyList())
return PathSet(root, listOf(resolved.ref))
}
}
// Literal prefix then wildcards: descend the prefix, walk only that subtree.
// Only the *final* prefix ref can ever satisfy the pattern (a shorter path cannot
// match a longer literal prefix), so the intermediate refs are not collected.
if (staticPrefix.isNotEmpty()) {
val resolved = GraphWalker.resolveStatic(root, staticPrefix, options)
?: return PathSet(root, emptyList())
if (resolved.allExact) {
val refs = mutableListOf<GraphPathRef>()
if (resolved.value != null || options.includeNulls) refs += resolved.ref
refs += GraphWalker.scanSubtree(resolved.value, staticPrefix, options, resolved.ancestors)
return PathSet(root, refs.filter { globMatchesAny(it.pathSegments, branches) })
}
}
return GraphWalker.scan(root, options).matching(pattern)
}
override fun toString(): String = pattern
object GraphQuery
Entry point for compiled queries.
object GraphQuery {
private val cache = ConcurrentHashMap<String, GraphPattern>()
fun compile(pattern: String): GraphPattern
Parse (and cache) a pattern for repeated use.
fun compile(pattern: String): GraphPattern =
cache.computeIfAbsent(pattern) { GraphPattern(it, splitAlternatives(it)) }
}
Entry points
fun Any?.graphPaths
Walk this object graph and return every slot whose address matches pattern.
* matches one segment and ** matches any number of segments including zero. A *
inside a segment globs within that one name — "*Alpha" finds contourAlpha, "osc*"
finds osc1 and osc2 — and never crosses a /. Names are matched ignoring case, so
"cutoff" reaches cutOff. A | separates whole patterns and the matches are unioned:
"**" + "/" + "*alpha | **" + "/w" collects both, each slot once, in walk order.
A literal prefix is resolved directly rather than by traversing everything, so
"osc/filter/cutoff" costs three property reads. Alternation gives that up — with more
than one branch there is no single path to descend, so it walks.
fun Any?.graphPaths(
pattern: String = "**",
options: GraphWalkOptions = GraphWalkOptions()
): PathSet = GraphQuery.compile(pattern).resolve(this, options)
fun Any?.graphPath
The single slot at a literal path — matched ignoring case — or null.
fun Any?.graphPath(
path: String,
options: GraphWalkOptions = GraphWalkOptions()
): GraphPathRef? = GraphWalker.resolveStatic(this, splitPath(path), options)?.ref
inline fun <reified T : Any> Any?.graphLenses
Typed lenses over every matching slot holding a T.
inline fun <reified T : Any> Any?.graphLenses(
pattern: String = "**",
options: GraphWalkOptions = GraphWalkOptions()
): LensSet<T> = graphPaths(pattern, options).lenses(T::class)
Path / glob plumbing
internal fun splitPath(path: String): List<String>
internal fun splitPath(path: String): List<String> =
path.split("/").map { it.trim() }.filter { it.isNotEmpty() }
internal fun splitGlob(path: String): List<String>
internal fun splitGlob(path: String): List<String> =
path.split("/").map { it.trim() }.filter { it.isNotEmpty() }
internal fun splitAlternatives(pattern: String): List<List<String>>
Split a pattern into its alternatives on |, each already split into segments.
| separates whole patterns, so "** slash *alpha | ** slash w" — written with real
separators — is two branches, and a slot matches when it satisfies either. The trimming that splitGlob already does is what makes
the spaces round the bar optional.
The corollary is worth stating because it will catch someone: "**\/freq|gain" is
"**\/freq" or "gain", not two leaves under **. Repeat the prefix on both sides.
Empty alternatives are dropped, so a trailing bar is harmless and "|" matches nothing.
internal fun splitAlternatives(pattern: String): List<List<String>> =
pattern.split('|').map { splitGlob(it) }.filter { it.isNotEmpty() }
internal fun globMatchesAny(path: List<String>, branches: List<List<String>>): Boolean
True when path satisfies any one of branches — the whole of what | means.
internal fun globMatchesAny(path: List<String>, branches: List<List<String>>): Boolean =
branches.any { globMatches(path, it) }
internal fun globMatches
internal fun globMatches(
path: List<String>,
pattern: List<String>,
pathIndex: Int = 0,
patternIndex: Int = 0
): Boolean {
if (patternIndex >= pattern.size) return pathIndex >= path.size
val token = pattern[patternIndex]
return when (token) {
"**" -> {
if (patternIndex == pattern.lastIndex) true
else (pathIndex..path.size).any { next -> globMatches(path, pattern, next, patternIndex + 1) }
}
"*" -> pathIndex < path.size && globMatches(path, pattern, pathIndex + 1, patternIndex + 1)
else -> pathIndex < path.size && segmentMatches(path[pathIndex], token) &&
globMatches(path, pattern, pathIndex + 1, patternIndex + 1)
}
}
internal fun segmentMatches(segment: String, token: String): Boolean
Does one path segment satisfy one pattern token?
A token that contains a * is a glob within one segment: "*Alpha" finds
contourAlpha, "contour*" finds it too, and neither can cross a / — segment-crossing
stays the exclusive job of **. A token with no * is a plain name.
Both comparisons ignore case, on the grounds that a slot is usually a name you half remember rather than one you are quoting. The consequence worth knowing is that two properties differing only in case are both matched by one pattern; they are still two distinct slots, and deduplicateRefs keeps treating them as such.
internal fun segmentMatches(segment: String, token: String): Boolean =
if (token.contains('*')) segmentGlob(token).matches(segment)
else token.equals(segment, ignoreCase = true)
private val segmentGlobs = ConcurrentHashMap<String, Regex>()
Compile (and cache) one within-segment glob. Each run of * becomes "any run of
non-separator characters" and every literal chunk between them is quoted, so a property
name containing regex metacharacters cannot smuggle syntax into the pattern.
private fun segmentGlob(token: String): Regex = segmentGlobs.computeIfAbsent(token) { raw ->
Regex(
raw.split('*').joinToString("[^/]*") { chunk -> if (chunk.isEmpty()) "" else Regex.escape(chunk) },
RegexOption.IGNORE_CASE
)
}
internal fun groupRefsBySlot(refs: List<GraphPathRef>): List<List<GraphPathRef>>
Group refs by the slot they address — same owner (by identity), same final segment. Two addresses for one slot land in the same group.
internal fun groupRefsBySlot(refs: List<GraphPathRef>): List<List<GraphPathRef>> {
val nullOwner = Any()
val byOwner = IdentityHashMap<Any, LinkedHashMap<String, MutableList<GraphPathRef>>>()
val groups = mutableListOf<MutableList<GraphPathRef>>()
refs.forEach { ref ->
val owner = ref.owner ?: nullOwner
val segment = ref.pathSegments.lastOrNull().orEmpty()
val bySegment = byOwner.getOrPut(owner) { linkedMapOf() }
val group = bySegment.getOrPut(segment) {
mutableListOf<GraphPathRef>().also { groups += it }
}
group += ref
}
return groups
}
internal fun deduplicateRefs(refs: List<GraphPathRef>): List<GraphPathRef>
internal fun deduplicateRefs(refs: List<GraphPathRef>): List<GraphPathRef> =
groupRefsBySlot(refs).map { it.first() }
@Suppress("UNCHECKED_CAST")
private fun <T : Any> KClass<T>.safeCast(value: Any?): T? =
if (isInstance(value)) value as T else null
Shared HTML rendering
internal const val PATH_COLOUR = "#8cf"
internal const val VALUE_COLOUR = "#ccc"
internal const val MUTED_COLOUR = "#888"
private const val MAX_HTML_ROWS = 24
internal fun renderGraphTable
One table renderer for every result type, so the docs captures look consistent — now a thin adapter over Table, which is the repo-wide one.
columns is each column's name paired with the colour its cells take, and rows holds plain
text: Table escapes it, so callers must not pre-escape, and the <script> case in
GraphQuerySmokeTest covers that.
internal fun renderGraphTable(
title: String,
count: Int,
columns: List<Pair<String, String>>,
rows: List<List<String>>
): String {
val table = Table(columns.map { it.first }, title = "$title ($count)")
table.maxRows = MAX_HTML_ROWS
columns.forEach { (name, colour) -> table.color(name, colour) }
return table.rows(rows).toHTML()
}
internal fun shortValue(value: Any?): String
internal fun shortValue(value: Any?): String = when (value) {
null -> "null"
is Float -> formatGraphFloat(value)
is Double -> "%.3f".format(value)
is Number, is Boolean, is Char -> value.toString()
is String -> if (value.length > 80) value.take(77) + "..." else value
else -> value::class.simpleName ?: value.toString()
}
internal fun formatGraphFloat(value: Float): String
internal fun formatGraphFloat(value: Float): String {
val abs = kotlin.math.abs(value)
val decimals = when {
abs >= 100f -> 0
abs >= 10f -> 1
abs >= 1f -> 2
else -> 3
}
return String.format(java.util.Locale.US, "%.${decimals}f", value)
}