FunctionRewriteDsl.kt
src/jvmMain/kotlin/afield/math/functions/FunctionRewriteDsl.kt · 1075 lines
package and imports — 46 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Function")
package afield.math.functions
import afield.app.commands.Action
import afield.data.collections.get
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.expanded
import afield.geometry.mesh.summary
import afield.graphics.render.rangeTo
import afield.math.autodiff.clamp
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.f
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.runtime.execution.end
import afield.runtime.lifecycle.once
import afield.app.commands.action
import afield.app.commands.actions
import afield.audio.synth.*
import afield.data.collections.invoke
import afield.data.graph.rules
import afield.document.properties.clear
import afield.document.workspace.path
import afield.graphics.render.arrow
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.media.image.split
import afield.runtime.execution.start
import afield.study.ui.numbers
The selector => action string rewrite DSL, compiled and applied end to end.
compileFnRewrite parses a spec into an FnRewriteProgram carrying line/column
FnRewriteDiagnostics; Functions.Fn.rewrite, Functions.Fn.previewRewrite (which also
reports match paths and which rules did nothing) and Functions.Fn.rewriteChecked (which
refuses an invalid program) run it over the graph copier. Selector matching lives here — op,
role, name, tag and depth tokens, parent=, [key>=n] predicates and slash-separated path
selectors with * and ** wildcards — and so does action application: op=, name=,
domain=, drop, and the numeric =, *=, /=, +=, -= forms. op= targets are checked
against OpRegistry; an unknown op is reported as a warning, not an error.
The file does not depend on the live-code compiler. A Kotlin right-hand side is
parsed out of its @{...} (or dollar-brace) wrapper and then handed to
FnRewriteOptions.kotlinNumberContext; when no caller supplied one, the action is simply not
applied. FunctionRewriteKotlin.kt is what plugs a box's Kotlin engine into that hook, and
FunctionRewritePattern.kt is the separate structural pattern-to-template surface.
data class FnRewriteNumberContext
Everything one matched node can tell a right-hand side about itself.
Handed to a FnRewriteNumberExpression — an @{...} snippet or a fnRewriteNumber block — so the new
value can be computed from the old one and from where it sits: the current value, the node's op and
parameter name, its depth, and its occurrence path. Immutable, one instance per visited node.
data class FnRewriteNumberContext(
/**
* The Kotlin source of the right-hand side, already unwrapped from its `@{...}` or dollar-brace
* delimiters, and trimmed. An expression that carried no delimiters at all arrives verbatim.
*/
val expression: String,
/** The numeric value the action is about to change: the matched node's `value` metadata entry. */
val current: Double,
/** The matched occurrence itself — op, role, path, ancestry, domain and the node's scalar maps. */
val graph: FnGraphCopyContext
) {
val value: Double get() = current
current under the name a snippet actually writes, as in *= @{value * 1.1}.
val it: Double get() = current
current again, so a snippet can read like a lambda: = @{it * 2}. A plain property, not a
real lambda parameter — nothing is shadowing it, and it inside a nested lambda wins as usual.
val op: String get() = graph.op
The op name of the matched node, from OpRegistry's vocabulary.
val role: String? get() = graph.role
The role of the edge this node was reached by ("frequency", "amplitude", …), or null at the root.
val parameter: String? get() = graph.role
Alias for role, for rules that read better as "which parameter am I".
val parentOp: String? get() = graph.parentOp
The immediate parent's op, or null at the root — "am I inside an fbm?" in one property.
val depth: Int get() = graph.depth
How many edges below the root this occurrence sits; the root itself is 0.
val path: List<Int> get() = graph.path
Child indices from the root down to this node — the same path a preview reports.
val ancestors: List<FnGraphPathElement> get() = graph.ancestors
The path elements from the root down to this node's parent, root first. Empty at the root.
val occurrence: List<FnGraphPathElement> get() = graph.occurrence
ancestors plus this node — the whole root-to-here path, which is what a path selector matches against. Rebuilt on every read, so bind it to a local rather than indexing it repeatedly.
val name: String? get() = graph.name
The matched function's own name, if it was given one. Null for the great majority of nodes.
val domain: Functions.Domain get() = graph.domain
The matched function's own domain, which can be narrower than the root's.
val numbers: Map<String, Double> get() = graph.node.numbers
This node's numeric metadata — its own parameters, keyed by name. A constant carries value.
val strings: Map<String, String> get() = graph.node.strings
This node's string metadata, for the ops that carry names or modes rather than numbers.
val booleans: Map<String, Boolean> get() = graph.node.booleans
This node's boolean metadata — flags such as a wrap or clamp mode.
val longs: Map<String, Long> get() = graph.node.longs
This node's long metadata, typically seeds and counts.
fun number(key: String): Double? = numbers[key]
One numeric metadata entry, or null if this node has no such key.
fun string(key: String): String? = strings[key]
One string metadata entry, or null if this node has no such key.
fun boolean(key: String): Boolean? = booleans[key]
One boolean metadata entry, or null if this node has no such key.
fun long(key: String): Long? = longs[key]
One long metadata entry, or null if this node has no such key.
data class FnRewriteOptions
How a rewrite should be run: who evaluates a Kotlin right-hand side, and whether shared subgraphs stay shared.
The default is the compiler-free configuration, and that is a trap worth knowing: with no evaluator
installed, a rule whose action is a Kotlin expression is not applied, and says nothing — the rule still
matches, and a preview records it as matched/no-op. Pass an evaluator (or use rewriteKotlin,
which wires the live box's one in for you) when those actions are meant to run.
data class FnRewriteOptions(
/**
* The older, flatter evaluator hook, taking the raw expression text. Consulted only when
* [kotlinNumberContext] is absent *or* returns null, so it also works as a fallback under it.
*/
val kotlinNumber: ((expression: String, current: Double, context: FnGraphCopyContext) -> Double?)? = null,
/**
* Whether a subgraph reached by two paths is copied once and shared, or expanded so each
* occurrence can be edited on its own. False — expand — because that is what makes
* path-sensitive selectors behave the way you would guess; see [Functions.Fn.deepCopy].
*/
val preserveSharing: Boolean = false,
/**
* The preferred hook: given the matched node wrapped as an [FnRewriteNumberContext], return the
* new number, or null to leave the value alone. Returning null falls through to [kotlinNumber].
*/
val kotlinNumberContext: ((FnRewriteNumberContext) -> Double?)? = null
)
enum class FnRewriteSeverity { ERROR, WARNING }
Whether a diagnostic kills a rule or merely grumbles about it.
ERROR means the rule was dropped at parse time and Functions.Fn.rewriteChecked will refuse the
whole spec; the surviving rules still run under plain Functions.Fn.rewrite. WARNING means the
rule was kept and will be applied — an op= target the OpRegistry does not know is the usual
case, and it ends up as a silent no-op at every node it matches.
data class FnRewriteDiagnostic
One complaint from the rewrite parser, addressed to a line and column of the spec so an editor or an agent can point at it.
Only the action side is checked this thoroughly. A selector is validated for balanced brackets and nothing else, so a mistyped selector key produces no diagnostic at all and simply matches nothing — FnRewritePreview.unmatchedRules is where that shows up instead.
data class FnRewriteDiagnostic(
/** Fatal for [FnRewriteProgram.valid], or advisory. See [FnRewriteSeverity]. */
val severity: FnRewriteSeverity,
/** One-based line within the spec. Rules separated by `;` on one line all report that line. */
val line: Int,
/**
* One-based column. Mostly `1`, because most checks are about the statement as a whole; an empty
* action is reported just past the arrow.
*/
val column: Int,
/** What is wrong, as a sentence. */
val message: String,
/** An optional nudge toward the fix — accepted arrow forms, valid domain syntax, viable ops. */
val hint: String? = null
) {
override fun toString(): String
severity at line:column: message (hint) — the one-line form that goes into a report.
override fun toString(): String = buildString {
append(severity.name.lowercase()).append(" at ").append(line).append(':').append(column).append(": ").append(message)
hint?.let { append(" (").append(it).append(')') }
}
}
data class FnRewriteRuleSpec
One parsed rule: the line it came from, the selector text on the left of the arrow, and the actions on the right.
Both sides are kept as text and interpreted per node at rewrite time; nothing here is a compiled
matcher. The actions are already split on top-level commas and pipes (both of which mean "then") and
are applied left to right to the same copied node, so *= 2.0, += 1.0 adds one to the doubled value.
data class FnRewriteRuleSpec(
/** One-based line of the spec this rule was written on. */
val line: Int,
/** The raw selector text, trimmed — everything before the arrow. */
val selector: String,
/** The actions, in application order, each already trimmed and non-empty. */
val actions: List<String>
)
data class FnRewriteProgram
A compiled rewrite spec: the source you handed in, the rules that survived parsing, and every diagnostic raised along the way.
Produced by compileFnRewrite, which needs no function graph at all — this is a pure check of the
language. Parsing is partial by design: a statement that raises an error is dropped and the rest
are kept, so rules.size can be smaller than the number of rules you wrote and a plain
Functions.Fn.rewrite will happily run the remainder. Functions.Fn.rewriteChecked is the entry
point that refuses to.
data class FnRewriteProgram(
/** The spec text exactly as it was passed in. */
val source: String,
/** The rules that parsed, in source order. This is the order they are tried at each node. */
val rules: List<FnRewriteRuleSpec>,
/** Every error and warning, in source order. */
val diagnostics: List<FnRewriteDiagnostic>
) {
val valid: Boolean get() = diagnostics.none { it.severity == FnRewriteSeverity.ERROR }
True when nothing fatal was found. Warnings — an unknown op= target — do not make a program invalid.
fun diagnosticText(): String
The diagnostics as printable lines, or a one-line "no diagnostics" note with the rule count.
fun diagnosticText(): String = if (diagnostics.isEmpty()) "FnRewrite: ${rules.size} rule(s), no diagnostics"
else diagnostics.joinToString("\n")
}
data class FnRewriteApplication
One rule firing at one occurrence: which rule, where it landed, and whether it actually changed anything.
Recorded per match, so a rule that matches five nodes contributes five of these. Every rule is
tried at every node and each matching one is applied in turn to the same copy, so beforeOp is the
op as it stood after any earlier rule already ran here — not necessarily the op in the original graph.
data class FnRewriteApplication(
/** Zero-based index into [FnRewriteProgram.rules]. A summary prints it one-based, as `#1`. */
val rule: Int,
/** The one-based spec line of the rule, for reporting back at the source. */
val line: Int,
/** The rule's selector text, copied here so an application reads on its own. */
val selector: String,
/** The rule's actions, in the order they were applied to this node. */
val actions: List<String>,
/** Child indices from the graph root to the matched node; empty means the root itself. */
val path: List<Int>,
/** The role of the edge the matched node hangs from, or null at the root. */
val role: String?,
/** The op before this rule's actions ran here. */
val beforeOp: String,
/** The op afterwards. Usually the same: a naming edit, a numeric edit or a refused transmutation all leave it. */
val afterOp: String,
/**
* Whether the node's structural signature actually moved. False is the correct report for a
* transmutation the `OpRegistry` refused, a numeric action on a non-numeric node, a Kotlin
* right-hand side with no evaluator installed, and `*= 1.0`.
*/
val changed: Boolean
)
data class FnRewritePreview
The result of a rewrite with its receipts: the rewritten function, the compiled program, and one FnRewriteApplication per match.
Not a dry run. By the time you hold one of these the rewrite has been done and function is the
result — Functions.Fn.rewrite is literally this call with the report thrown away. What "preview"
buys you is evidence before you commit: matched paths, no-ops, and above all
unmatchedRules, since a selector that matches nothing is otherwise completely silent.
data class FnRewritePreview(
/** The rewritten function. Always a fresh graph; the receiver of the rewrite is untouched. */
val function: Functions.Fn,
/** The compiled spec, including the diagnostics that did not stop it. */
val program: FnRewriteProgram,
/** Every rule/occurrence pair that fired, in traversal order (bottom-up, so children first). */
val applications: List<FnRewriteApplication>
) {
val matchedRules: Int get() = applications.map { it.rule }.distinct().size
How many distinct rules found at least one node. Compare with program.rules.size.
val matchCount: Int get() = applications.size
Total matches across all rules — one per rule per occurrence, so easily larger than the rule count.
val changeCount: Int get() = applications.count { it.changed }
How many of those matches changed the graph. The gap between this and matchCount is the no-ops.
val unmatchedRules: List<FnRewriteRuleSpec> get()
The rules that never matched anything — the one place a mistyped selector becomes visible, since the parser does not check selectors. Treat these as hypotheses to reconsider, not as success.
val unmatchedRules: List<FnRewriteRuleSpec> get() {
val matched = applications.mapTo(mutableSetOf()) { it.rule }
return program.rules.filterIndexed { index, _ -> index !in matched }
}
fun summary(): String
Compact deterministic report suitable for logs, chat, and agent transcripts.
fun summary(): String = buildString {
append("FnRewrite: ").append(program.rules.size).append(" rule(s), ")
.append(matchCount).append(" match(es), ").append(changeCount).append(" change(s)")
if (program.diagnostics.isNotEmpty()) {
append(", ").append(program.diagnostics.count { it.severity == FnRewriteSeverity.ERROR }).append(" error(s), ")
.append(program.diagnostics.count { it.severity == FnRewriteSeverity.WARNING }).append(" warning(s)")
}
unmatchedRules.takeIf { it.isNotEmpty() }?.let { rules ->
append("\nunmatched: ").append(rules.joinToString { "line ${it.line} `${it.selector}`" })
}
program.diagnostics.forEach { append('\n').append(it) }
applications.forEach { application ->
append("\n#").append(application.rule + 1)
.append(" path=").append(if (application.path.isEmpty()) "/" else application.path.joinToString("/", prefix = "/"))
.append(" op=").append(application.beforeOp)
application.role?.let { append(" role=").append(it) }
append(if (application.changed) " changed" else " matched/no-op")
if (application.afterOp != application.beforeOp) append(" -> ").append(application.afterOp)
}
}
}
fun compileFnRewrite(spec: String): FnRewriteProgram = parseFnDslRewriteProgram(spec)
Parses a rewrite spec and reports on it, without needing a function graph or touching one.
The language check on its own: statements split on newlines and semicolons, # and // comments
stripped, => / -> / → all accepted as the arrow, actions split on top-level commas and pipes.
Every action is validated (an unknown one is an error, an unknown op= target a warning); selectors
are only checked for balanced square brackets. Bad statements are dropped and the rest are kept, so read
FnRewriteProgram.valid before assuming the whole spec survived.
fun Functions.Fn.rewrite
Compiles spec and applies it, returning a new function and discarding everything the attempt
learned — the forgiving one-liner for sketching and performance.
Quiet: rules that failed to parse are skipped, rules that matched nothing say nothing,
an impossible transmutation leaves the node alone, and a Kotlin right-hand side with no evaluator in
options is not applied. Always returns a fresh graph, even for a spec with no usable rules at all
(that case is a plain deep copy). When you need to know what actually happened, use
previewRewrite; when a malformed spec should be an error, rewriteChecked.
fun Functions.Fn.rewrite(
spec: String,
options: FnRewriteOptions = FnRewriteOptions()
): Functions.Fn = previewRewrite(spec, options).function
fun Functions.Fn.previewRewrite
Compiles and applies spec, retaining diagnostics, match paths, and effective-change information.
The rewrite really is performed — FnRewritePreview.function is the result, not an estimate of it —
so this costs exactly what rewrite costs and there is no second pass to commit. At each node every
rule is tried in source order and each matching rule is applied, cumulatively, so later rules see
what earlier ones did to that node; the traversal is bottom-up, so a rule can never revisit children
that have already been copied.
fun Functions.Fn.previewRewrite(
spec: String,
options: FnRewriteOptions = FnRewriteOptions()
): FnRewritePreview {
val program = compileFnRewrite(spec)
if (program.rules.isEmpty()) {
return FnRewritePreview(deepCopy(preserveSharing = options.preserveSharing), program, emptyList())
}
val applications = mutableListOf<FnRewriteApplication>()
val rewritten = rewriteGraph(preserveSharing = options.preserveSharing) { context, copy ->
var current = copy
for ((ruleIndex, rule) in program.rules.withIndex()) {
if (!rule.matches(context)) continue
val before = current
val beforeSignature = before.graphSignature()
for (action in rule.actions) current = applyRewriteAction(current, context, action, options)
applications += FnRewriteApplication(
rule = ruleIndex,
line = rule.line,
selector = rule.selector,
actions = rule.actions,
path = context.path,
role = context.role,
beforeOp = before.graphNode().op,
afterOp = current.graphNode().op,
changed = current.graphSignature() != beforeSignature
)
}
current
}
return FnRewritePreview(rewritten, program, applications)
}
fun Functions.Fn.rewriteChecked
Functions.Fn.rewrite, but a malformed spec throws instead of applying whatever rules parsed.
Note the order: the rewrite is run first and its result discarded if the program had an error, because
the diagnostics come out of the same pass. So a @{...} right-hand side may well have been compiled and
evaluated before the throw — that is fine for pure expressions and worth knowing for anything else.
Throws IllegalArgumentException carrying FnRewriteProgram.diagnosticText when the program has any
error. Only errors: a warning — the usual one being an op= target the OpRegistry does not know —
passes through here and then does nothing at the nodes it matches, so check a preview too if
the transmutation matters.
fun Functions.Fn.rewriteChecked(
spec: String,
options: FnRewriteOptions = FnRewriteOptions()
): Functions.Fn {
val preview = previewRewrite(spec, options)
require(preview.program.valid) {
"Invalid FnRewrite program:\n${preview.program.diagnosticText()}"
}
return preview.function
}
fun Functions.Fn.rewrite
rewrite when the only thing you want to vary is sharing — no evaluator, no other options.
With preserveSharing = true a subgraph reached by several paths is copied once and the single
replacement is reused at every one of them, which is usually not what a path-sensitive selector
wants. The overload exists so f.rewrite(spec, true) does not need an options object.
body — 142 lines, from line 450
fun Functions.Fn.rewrite(
spec: String,
preserveSharing: Boolean
): Functions.Fn = rewrite(spec, FnRewriteOptions(preserveSharing = preserveSharing))
private fun parseFnDslRewriteProgram(spec: String): FnRewriteProgram {
val rules = mutableListOf<FnRewriteRuleSpec>()
val diagnostics = mutableListOf<FnRewriteDiagnostic>()
var searchFrom = 0
for (rawStatement in splitRewriteText(spec, setOf('\n', ';'))) {
val offset = spec.indexOf(rawStatement, searchFrom).let { if (it < 0) searchFrom else it }
searchFrom = (offset + rawStatement.length).coerceAtMost(spec.length)
val line = spec.take(offset).count { it == '\n' } + 1
val statement = stripFnRewriteComment(rawStatement).trim()
if (statement.isEmpty() || statement.equals("#fnrewrite", ignoreCase = true)) continue
val arrow = findRewriteArrow(statement)
if (arrow == null) {
diagnostics += FnRewriteDiagnostic(
FnRewriteSeverity.ERROR, line, 1,
"Expected `selector => action`.",
"`->` and `→` are also accepted"
)
continue
}
val selector = statement.substring(0, arrow.first).trim()
val rhs = statement.substring(arrow.first + arrow.second).trim()
if (selector.isEmpty()) {
diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, 1, "Selector is empty.", "Use `any` to match every node")
continue
}
if (rhs.isEmpty()) {
diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, arrow.first + arrow.second + 1, "Action is empty.")
continue
}
if (!balancedSelectorBrackets(selector)) {
diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, 1, "Unbalanced `[...]` selector predicate.")
continue
}
val actions = splitRewriteText(rhs, setOf('|', ','))
.map { it.trim() }
.filter { it.isNotEmpty() }
if (actions.isEmpty()) {
diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, arrow.first + arrow.second + 1, "Action is empty.")
continue
}
actions.forEach { action -> validateRewriteAction(action, line, diagnostics) }
rules += FnRewriteRuleSpec(line, selector, actions)
}
return FnRewriteProgram(spec, rules, diagnostics)
}
private fun findRewriteArrow(statement: String): Pair<Int, Int>? {
val arrows = listOf("=>", "->", "→")
var best: Pair<Int, Int>? = null
for (arrow in arrows) {
val index = statement.indexOf(arrow)
if (index >= 0 && (best == null || index < best.first)) best = index to arrow.length
}
return best
}
private fun stripFnRewriteComment(text: String): String {
var bracketDepth = 0
var braceDepth = 0
var inSingle = false
var inDouble = false
var escaped = false
var i = 0
while (i < text.length) {
val c = text[i]
if (inSingle || inDouble) {
if (escaped) escaped = false
else if (c == '\\') escaped = true
else if (inSingle && c == '\'') inSingle = false
else if (inDouble && c == '"') inDouble = false
i++
continue
}
when (c) {
'\'' -> inSingle = true
'"' -> inDouble = true
'[' -> bracketDepth++
']' -> if (bracketDepth > 0) bracketDepth--
'{' -> braceDepth++
'}' -> if (braceDepth > 0) braceDepth--
'#' -> if (bracketDepth == 0 && braceDepth == 0) return text.substring(0, i)
'/' -> if (text.getOrNull(i + 1) == '/' && bracketDepth == 0 && braceDepth == 0) return text.substring(0, i)
}
i++
}
return text
}
private fun balancedSelectorBrackets(selector: String): Boolean {
var depth = 0
for (c in selector) {
if (c == '[') depth++
if (c == ']' && --depth < 0) return false
}
return depth == 0
}
private fun validateRewriteAction(
action: String,
line: Int,
diagnostics: MutableList<FnRewriteDiagnostic>
) {
val lower = action.lowercase()
if (lower in setOf("drop", "prune", "zero")) return
if (lower.startsWith("op=") || lower.startsWith("replace=")) {
val target = action.substringAfter('=').trim().trim('"', '\'')
if (target.isEmpty()) diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, 1, "Replacement operation is empty.")
else if (!OpRegistry.knows(target)) {
diagnostics += FnRewriteDiagnostic(
FnRewriteSeverity.WARNING, line, 1,
"`op=$target` is not a known operation.",
OpRegistry.transmutableHint()
)
}
return
}
if (lower.startsWith("name=")) {
if (action.substringAfter('=').trim().isEmpty()) diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, 1, "Name is empty.")
return
}
if (lower.startsWith("domain=")) {
val value = action.substringAfter('=').trim()
if (!value.equals("all", true) && !value.equals("default", true) && parseDomain(value) == null) {
diagnostics += FnRewriteDiagnostic(FnRewriteSeverity.ERROR, line, 1, "Invalid domain `$value`.", "Use `all`, `default`, or `a..b`")
}
return
}
if (Regex("""^\s*(?:value\s*)?(\*=|/=|\+=|-=|=)\s*.+$""").matches(action)) return
diagnostics += FnRewriteDiagnostic(
FnRewriteSeverity.ERROR, line, 1,
"Unknown action `$action`.",
"Try `op=triangle`, `*= 0.5`, `name=lead`, `domain=all`, or `drop`"
)
}
Split rewrite text on top-level separators only.
Kotlin expressions are allowed on the RHS (@{...}), and those snippets
routinely contain commas, pipes, braces, strings, or semicolons. This small
scanner is not a Kotlin parser; it simply keeps obvious nested/comment/string
regions intact so the forgiving DSL does not sabotage the expressive part.
source — 474 lines, from line 601
private fun splitRewriteText(text: String, separators: Set<Char>): List<String> {
val parts = mutableListOf<String>()
val current = StringBuilder()
var parenDepth = 0
var bracketDepth = 0
var braceDepth = 0
var inLineComment = false
var inSingleQuote = false
var inDoubleQuote = false
var inTripleQuote = false
var escaped = false
fun emit() {
parts += current.toString()
current.clear()
}
var i = 0
while (i < text.length) {
val c = text[i]
val next = text.getOrNull(i + 1)
val next2 = text.getOrNull(i + 2)
if (inLineComment) {
if (c == '\n') {
inLineComment = false
if (c in separators && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) emit()
else current.append(c)
} else {
current.append(c)
}
i++
continue
}
if (inTripleQuote) {
current.append(c)
if (c == '"' && next == '"' && next2 == '"') {
current.append("\"\"")
inTripleQuote = false
i += 3
} else {
i++
}
continue
}
if (inSingleQuote || inDoubleQuote) {
current.append(c)
if (escaped) {
escaped = false
} else if (c == '\\') {
escaped = true
} else if (inSingleQuote && c == '\'') {
inSingleQuote = false
} else if (inDoubleQuote && c == '"') {
inDoubleQuote = false
}
i++
continue
}
if (c == '/' && next == '/') {
inLineComment = true
current.append("//")
i += 2
continue
}
if (c == '"' && next == '"' && next2 == '"') {
inTripleQuote = true
current.append("\"\"\"")
i += 3
continue
}
when (c) {
'\'' -> inSingleQuote = true
'"' -> inDoubleQuote = true
'(' -> parenDepth++
')' -> if (parenDepth > 0) parenDepth--
'[' -> bracketDepth++
']' -> if (bracketDepth > 0) bracketDepth--
'{' -> braceDepth++
'}' -> if (braceDepth > 0) braceDepth--
}
if (
c in separators &&
parenDepth == 0 &&
bracketDepth == 0 &&
braceDepth == 0
) {
emit()
} else {
current.append(c)
}
i++
}
emit()
return parts
}
private fun FnRewriteRuleSpec.matches(context: FnGraphCopyContext): Boolean {
val selector = selector.trim()
if (selector == "*" || selector.equals("any", ignoreCase = true)) return true
return splitSelectorTerms(selector)
.filter { it.isNotEmpty() }
.all { token ->
if (looksLikePathSelector(token)) pathSelectorMatches(token, context)
else tokenMatches(token, context)
}
}
private fun tokenMatches(token: String, context: FnGraphCopyContext): Boolean {
if ("[" in token && pathSegmentMatches(token, context.occurrence.last(), context.depth)) return true
val lower = token.lowercase()
if ("." in lower) {
val parts = lower.split(".").filter { it.isNotEmpty() }
if (parts.size == 2) {
val left = parts[0]
val right = parts[1]
val parentMatches = context.parentOp?.equals(left, ignoreCase = true) == true ||
context.op.equals(left, ignoreCase = true) ||
context.node.tags.any { it.equals(left, ignoreCase = true) }
val roleMatches = context.role?.equals(right, ignoreCase = true) == true ||
context.op.equals(right, ignoreCase = true)
return parentMatches && roleMatches
}
}
if (lower == context.op.lowercase()) return true
if (context.role?.equals(lower, ignoreCase = true) == true) return true
if (lower == "number") return "number" in context.node.tags
if (lower == "const") return context.op.equals("const", ignoreCase = true)
if (lower == "param") return context.op.equals("param", ignoreCase = true)
if (lower == "opaque") return "opaque" in context.node.tags
val parts = token.split("=", limit = 2)
if (parts.size != 2) return false
val key = parts[0].trim().lowercase()
val value = parts[1].trim().trim('"', '\'')
return when (key) {
"op" -> context.op.equals(value, ignoreCase = true)
"name" -> context.name?.equals(value, ignoreCase = true) == true
"role", "edge", "parameter" -> context.role?.equals(value, ignoreCase = true) == true
"param" -> throw IllegalArgumentException(
"rewrite selector 'param=$value': write role=$value for the incoming parameter (bare param is the parameter node class)"
)
"parent" -> context.parentOp?.equals(value, ignoreCase = true) == true
// Any ancestor, at any depth; `parent=` is the immediate one.
"under" -> context.ancestors.any { it.node.op.equals(value, ignoreCase = true) }
"tag" -> context.node.tags.any { it.equals(value, ignoreCase = true) }
"depth" -> context.depth == value.toIntOrNull()
else -> false
}
}
private fun splitSelectorTerms(selector: String): List<String> {
val terms = mutableListOf<String>()
val current = StringBuilder()
var bracketDepth = 0
var parenDepth = 0
var braceDepth = 0
var inSingleQuote = false
var inDoubleQuote = false
var escaped = false
fun emit() {
val text = current.toString().trim()
if (text.isNotEmpty()) terms += text
current.clear()
}
for (c in selector) {
if (inSingleQuote || inDoubleQuote) {
current.append(c)
if (escaped) escaped = false
else if (c == '\\') escaped = true
else if (inSingleQuote && c == '\'') inSingleQuote = false
else if (inDoubleQuote && c == '"') inDoubleQuote = false
continue
}
when (c) {
'\'' -> inSingleQuote = true
'"' -> inDoubleQuote = true
'[' -> bracketDepth++
']' -> if (bracketDepth > 0) bracketDepth--
'(' -> parenDepth++
')' -> if (parenDepth > 0) parenDepth--
'{' -> braceDepth++
'}' -> if (braceDepth > 0) braceDepth--
}
if ((c == '&' || c.isWhitespace()) && bracketDepth == 0 && parenDepth == 0 && braceDepth == 0) {
emit()
} else {
current.append(c)
}
}
emit()
return terms
}
private fun looksLikePathSelector(selector: String): Boolean =
'/' in selector || selector == "**"
private fun pathSelectorMatches(selector: String, context: FnGraphCopyContext): Boolean {
val trimmed = selector.trim()
val anchored = trimmed.startsWith("/")
val body = trimmed.trim('/')
val segments = splitPathSegments(body).filter { it.isNotBlank() }
if (segments.isEmpty()) return false
val occurrence = context.occurrence
fun match(pathIndex: Int, segmentIndex: Int): Boolean {
if (segmentIndex == segments.size) return pathIndex == occurrence.size
val segment = segments[segmentIndex]
if (segment == "**") {
if (segmentIndex == segments.lastIndex) return true
for (nextIndex in pathIndex..occurrence.size) {
if (match(nextIndex, segmentIndex + 1)) return true
}
return false
}
if (pathIndex >= occurrence.size) return false
return pathSegmentMatches(segment, occurrence[pathIndex], pathIndex) &&
match(pathIndex + 1, segmentIndex + 1)
}
if (anchored) return match(0, 0)
return occurrence.indices.any { start -> match(start, 0) }
}
private fun splitPathSegments(selector: String): List<String> {
val segments = mutableListOf<String>()
val current = StringBuilder()
var bracketDepth = 0
var inSingleQuote = false
var inDoubleQuote = false
var escaped = false
fun emit() {
segments += current.toString().trim()
current.clear()
}
for (c in selector) {
if (inSingleQuote || inDoubleQuote) {
current.append(c)
if (escaped) escaped = false
else if (c == '\\') escaped = true
else if (inSingleQuote && c == '\'') inSingleQuote = false
else if (inDoubleQuote && c == '"') inDoubleQuote = false
continue
}
when (c) {
'\'' -> inSingleQuote = true
'"' -> inDoubleQuote = true
'[' -> bracketDepth++
']' -> if (bracketDepth > 0) bracketDepth--
}
if (c == '/' && bracketDepth == 0) emit()
else current.append(c)
}
emit()
return segments
}
private fun pathSegmentMatches(segment: String, element: FnGraphPathElement, depth: Int): Boolean {
val trimmed = segment.trim()
if (trimmed == "*" || trimmed.equals("any", ignoreCase = true)) return true
if (trimmed == "**") return true
val predicateRanges = Regex("""\[[^\]]*]""").findAll(trimmed).toList()
val base = trimmed.substringBefore("[").trim()
val baseMatches = base.isEmpty() ||
base == "*" ||
elementTokenMatches(base, element, depth)
if (!baseMatches) return false
return predicateRanges.all { match ->
val predicate = match.value.removePrefix("[").removeSuffix("]").trim()
predicate.isNotEmpty() && elementPredicateMatches(predicate, element, depth)
}
}
private fun elementTokenMatches(token: String, element: FnGraphPathElement, depth: Int): Boolean {
val lower = token.lowercase()
if (lower == element.op.lowercase()) return true
if (element.role?.equals(lower, ignoreCase = true) == true) return true
if (lower == "number") return "number" in element.node.tags
if (lower == "const") return element.op.equals("const", ignoreCase = true)
if (lower == "param") return element.op.equals("param", ignoreCase = true)
if (lower == "opaque") return "opaque" in element.node.tags
if (element.node.tags.any { it.equals(lower, ignoreCase = true) }) return true
val parts = token.split("=", limit = 2)
if (parts.size == 2) return elementPredicateMatches(token, element, depth)
return false
}
private fun elementPredicateMatches(predicate: String, element: FnGraphPathElement, depth: Int): Boolean {
val op = listOf(">=", "<=", "!=", "=", ">", "<").firstOrNull { it in predicate } ?: return elementTokenMatches(predicate, element, depth)
val parts = predicate.split(op, limit = 2)
if (parts.size != 2) return false
val key = parts[0].trim().lowercase()
val expected = parts[1].trim().trim('"', '\'')
if (key == "tag") return element.node.tags.any { it.equals(expected, ignoreCase = true) }
val actual = elementValue(key, element, depth) ?: return false
val expectedNumber = expected.toDoubleOrNull()
val actualNumber = when (actual) {
is Number -> actual.toDouble()
is Boolean -> if (actual) 1.0 else 0.0
is String -> actual.toDoubleOrNull()
else -> null
}
return if (actualNumber != null && expectedNumber != null) {
when (op) {
">=" -> actualNumber >= expectedNumber
"<=" -> actualNumber <= expectedNumber
"!=" -> actualNumber != expectedNumber
"=" -> actualNumber == expectedNumber
">" -> actualNumber > expectedNumber
"<" -> actualNumber < expectedNumber
else -> false
}
} else {
val actualText = actual.toString()
when (op) {
"!=" -> !actualText.equals(expected, ignoreCase = true)
"=" -> actualText.equals(expected, ignoreCase = true)
else -> false
}
}
}
private fun elementValue(key: String, element: FnGraphPathElement, depth: Int): Any? =
when (key) {
"op" -> element.op
"role", "edge", "parameter" -> element.role
"param" -> throw IllegalArgumentException("rewrite selector '[param=…]': write [role=…] for the incoming parameter")
"name" -> element.name
"depth" -> depth
"index" -> element.index
"value" -> element.node.numbers["value"]
else -> element.node.numbers[key]
?: element.node.strings[key]
?: element.node.booleans[key]
?: element.node.longs[key]
}
private fun applyRewriteAction(
copy: Functions.Fn,
context: FnGraphCopyContext,
action: String,
options: FnRewriteOptions
): Functions.Fn {
val trimmed = action.trim()
val lower = trimmed.lowercase()
if (lower == "drop" || lower == "prune" || lower == "zero") {
return Functions.zero(copy.domain)
}
if (lower.startsWith("op=") || lower.startsWith("replace=")) {
val target = trimmed.substringAfter('=').trim().trim('"', '\'')
return copy.rebuildAsOp(target) ?: copy
}
if (lower.startsWith("name=")) {
return copy.withName(trimmed.substringAfter('=').trim().trim('"', '\''))
}
if (lower.startsWith("domain=")) {
val value = trimmed.substringAfter('=').trim()
parseDomain(value)?.let { return copy.withDomain(it) }
if (value.equals("all", ignoreCase = true)) return copy.withDomain(Functions.Domain.ALL)
if (value.equals("default", ignoreCase = true)) return copy.withDomain(Functions.Domain.DEFAULT)
return copy
}
val currentValue = rewriteNumericValue(copy) ?: context.node.numbers["value"]
if (currentValue != null) {
val changed = applyNumberAction(trimmed, currentValue, context, options)
if (changed != null && changed != currentValue) return rewriteNumericFn(changed, copy, context)
}
return copy
}
private fun rewriteNumericFn(value: Double, copy: Functions.Fn, context: FnGraphCopyContext): Functions.Fn {
if ("parameter" in context.node.tags || context.node.op == "param") return parameterLiteral(value)
return Functions.c(value, copy.domain).withName(copy.name)
}
private fun parameterLiteral(value: Number): Functions.Fn {
val v = value.toDouble()
return Functions.Fn(
Functions.Domain.ALL,
"param",
FnGraphNode(
op = "param",
numbers = mapOf("value" to v),
tags = setOf("number", "parameter"),
rebuild = { parameterLiteral(v) }
)
) { v }
}
private fun rewriteNumericValue(fn: Functions.Fn): Double? {
val node = fn.graphNode()
node.numbers["value"]?.let { return it }
if (node.op != "name" && node.op != "over") return null
return node.child("source")?.let(::rewriteNumericValue)
}
private fun applyNumberAction(
action: String,
current: Double,
context: FnGraphCopyContext,
options: FnRewriteOptions
): Double? {
val match = Regex("""^\s*(?:value\s*)?(\*=|/=|\+=|-=|=)\s*(.+?)\s*$""").matchEntire(action)
?: return null
val op = match.groupValues[1]
val rhs = match.groupValues[2]
val value = parseNumberExpression(rhs, current, context, options) ?: return null
return when (op) {
"*=" -> current * value
"/=" -> if (value == 0.0) current else current / value
"+=" -> current + value
"-=" -> current - value
"=" -> value
else -> null
}
}
private fun parseNumberExpression(
expression: String,
current: Double,
context: FnGraphCopyContext,
options: FnRewriteOptions
): Double? {
val trimmed = expression.trim()
if (trimmed.equals("it", ignoreCase = true) || trimmed.equals("value", ignoreCase = true)) return current
trimmed.toDoubleOrNull()?.let { return it }
val kotlinExpr = when {
trimmed.startsWith("@{") && trimmed.endsWith("}") -> trimmed.removeSurrounding("@{", "}").trim()
trimmed.startsWith("\${") && trimmed.endsWith("}") -> trimmed.removeSurrounding("\${", "}").trim()
else -> trimmed
}
val numberContext = FnRewriteNumberContext(kotlinExpr, current, context)
return options.kotlinNumberContext?.invoke(numberContext)
?: options.kotlinNumber?.invoke(kotlinExpr, current, context)
}
private fun parseDomain(value: String): Functions.Domain? {
val trimmed = value.trim().removePrefix("(").removeSuffix(")")
val parts = trimmed.split("..", limit = 2)
if (parts.size != 2) return null
val a = parts[0].trim().toDoubleOrNull() ?: return null
val b = parts[1].trim().toDoubleOrNull() ?: return null
return Functions.domain(a..b)
}