FieldMLInterface.kt
src/jvmMain/kotlin/afield/lang/fieldml/FieldMLInterface.kt · 476 lines
package and imports — 41 lines, from line 1
package afield.lang.fieldml
import afield.document.graph.Box
import afield.runtime.execution.BoxExecution
import afield.document.graph.CachedLaunchReturn
import afield.app.protocol.CompletionRequest
import afield.app.protocol.Execute
import afield.runtime.execution.ExecutionTrace
import afield.app.protocol.ExecuteSymbol
import afield.ui.output.HasOutput
import afield.app.protocol.IsRunning
import afield.app.protocol.Message
import afield.app.protocol.Output
import afield.runtime.execution.ReturnValueRegistry
import afield.document.graph.RunningState
import afield.runtime.lifecycle.Runloop
import afield.runtime.lifecycle.RunsAt
import afield.app.protocol.SymbolContextRequest
import afield.app.protocol.SymbolContextResponse
import afield.runtime.execution.configureOutputProxies
import afield.runtime.execution.dispatchExecutionOutput
import afield.runtime.lifecycle.early
import afield.runtime.lifecycle.inMain
import afield.lang.fieldml.core.Evaluator
import afield.lang.fieldml.core.fieldMLValueToHost
import afield.lang.fieldml.core.renderFieldMLValue
import afield.app.protocol.payload
import afield.runtime.lifecycle.plugins
import afield.runtime.TopologyWatcher
import afield.runtime.MainLanguageRuntime
import afield.runtime.applyRuntimeToggle
import afield.runtime.isMainLanguage
import afield.runtime.registerMainLanguageRuntime
import afield.app.protocol.send
import afield.document.graph.captureLaunchEpoch
import afield.document.graph.isLaunchEpochCurrent
import afield.document.graph.stopTrackedJobs
import afield.document.properties.Delegation
import afield.app.server.thisSession
import io.ktor.websocket.WebSocketSession
import kotlinx.coroutines.CancellationException
FieldML as a box language: set a box's Main Language to FieldML and its children hold FieldML programs instead of Kotlin.
FieldMLExecution is the BoxExecution behind that, one per box. It hands the text to
FieldMLCompiler, evaluates the core tree that came back against FieldMLRuntimePrelude, and
writes the value and its inferred type into the box as one inline value : type line. A program
that printed something of its own gets no inline line, which is what the file's TrackingOutput
is watching for. display does not count as printing, so a box that draws still says what it
evaluated to.
Nothing survives between runs. Each run compiles its text from scratch and evaluates it against
a freshly built prelude, so a FieldML box is the script model rather than the accumulating one a
Datalog box uses. State that has to outlive a run goes in a box property, which is what the
prelude's prop and once are for.
Executing and launching differ in one thing, and it is the thing that makes animation work. Both
compile and evaluate. Launch also hands the result to ReturnValueRegistry, the same machinery a
Kotlin box's return value goes through, so the Sequence that animate returns is advanced once
per run loop frame for as long as the box is running, while a plain number is simply the answer.
The run generation counted around that is how a second launch supersedes the first rather than
running beside it.
Which boxes are FieldML is decided by fieldMLTopologyNeedsUpdating, and the answer is about
parents: a box runs FieldML because a parent carries isFieldMLRoot or names fieldml as its
main language, not because it carries the mark itself.
private const val FIELDML_PENDING_LAUNCH_MODE = "launching"
private const val FIELDML_ACTIVE_LAUNCH_MODE = "launch"
class FieldMLExecution(private val owner: Box) : BoxExecution
One FieldML box's runtime: compile, evaluate, and report back to the editor, holding nothing between runs and hopping to the main thread before each one.
class FieldMLExecution(private val owner: Box) : BoxExecution {
private inline fun <T> reactiveRun(box: Box, mode: String, evaluate: () -> T): T {
val previous = Box.context.get()
Box.context.set(box)
afield.runtime.lifecycle.ReactiveRuns.runStarted(box, mode)
var ok = false
try {
val result = evaluate()
ok = true
return result
} finally {
afield.runtime.lifecycle.ReactiveRuns.runFinished(box, ok, mode)
if (previous == null) Box.context.remove() else Box.context.set(previous)
}
}
override fun executeFragment(inside: Box, execute: Execute, reply: (Message) -> Unit) {
Run the selected text and print what it evaluated to.
The FieldML runtime draws no distinction between a fragment and the whole box: both compile and evaluate the text they were handed, and only the caller decides how much of the box that text is.
override fun executeFragment(inside: Box, execute: Execute, reply: (Message) -> Unit) {
executeOnce(inside, execute, reply, fragment = true, mode = "fragment")
}
override fun executeAll(inside: Box, execute: Execute, reply: (Message) -> Unit) {
Run the whole box and print what it evaluated to, without offering the result to the launch machinery.
override fun executeAll(inside: Box, execute: Execute, reply: (Message) -> Unit) {
executeOnce(inside, execute, reply, fragment = false, mode = "run")
}
override fun launchAll(inside: Box, execute: Execute, code: String, strand: String, reply: (Message) -> Unit) {
Run the whole box and keep whatever the result turns out to be alive.
Beyond what executeAll does, the value is converted to its host form and offered to
ReturnValueRegistry, so a Sequence from animate runs a frame at a time until the box is
stopped or launched again. The is-running messages sent around that are what the editor
draws as the box's running indicator, and they carry a generation per strand so a relaunch
cancels its predecessor instead of doubling it.
A result no handler claims is not an error; it just means there is nothing left to run, and the cached return value is dropped so a downstream box does not read a stale one.
body — 91 lines, from line 121
override fun launchAll(inside: Box, execute: Execute, code: String, strand: String, reply: (Message) -> Unit) {
val originSession = thisSession.get()
val launchEpoch = inside.captureLaunchEpoch()
val trace = ExecutionTrace.forEngine(inside, execute, "launch", reply)
inMain {
if (!inside.isLaunchEpochCurrent(launchEpoch)) {
trace.finish(false, "launch cancelled by a stop before it started")
return@inMain
}
configureOutputProxies(inside, trace.reply, originSession)
val compileResult = FieldMLCompiler.compile(code)
if (reportDiagnostics(inside, execute, compileResult, originSession, trace.reply)) {
trace.finish(false, "the box did not compile")
} else {
val generation = inside.runGeneration.getOrPut(strand) { -1 } + 1
inside.runGeneration[strand] = generation
inside.currentRunningStates[strand] = RunningState(generation, FIELDML_PENDING_LAUNCH_MODE)
send(Message("is-running").payload(IsRunning(execute.uid, true, generation, strand, FIELDML_PENDING_LAUNCH_MODE)))
val core = compileResult.core!!
try {
val output = TrackingOutput(inside.output)
val value = reactiveRun(inside, "launch") {
Evaluator(FieldMLRuntimePrelude.values(output, inside)).evaluate(core)
}
val typeText = afield.lang.fieldml.type.TypePrinter().print(compileResult.typed!!.type)
val valueText = renderFieldMLValue(value)
if (!output.printed) {
dispatchExecutionOutput(
inside,
trace.reply,
Output(
uid = inside.id,
line = fieldMLResultLine(execute.line, compileResult),
kind = "normal",
property = execute.property,
html = renderInlineResult(valueText, typeText),
frame = Runloop.runLoop.frame.toInt(),
),
originSession = originSession,
)
}
if (inside.currentRunningStates[strand]?.watermark == generation) {
inside.currentRunningStates.remove(strand)
}
send(Message("is-running").payload(IsRunning(execute.uid, false, generation, strand, FIELDML_PENDING_LAUNCH_MODE)))
val hostValue = fieldMLValueToHost(value)
if (hostValue == null) {
inside.cachedLaunchReturns.remove(strand)
} else {
inside.cachedLaunchReturns[strand] = CachedLaunchReturn(code, hostValue)
}
val consumed = ReturnValueRegistry.launchWithGeneration(
inside,
hostValue,
strand,
generation,
launchEpoch,
) { isRunning, notifiedStrand, watermark ->
if (isRunning) {
inside.currentRunningStates[notifiedStrand] = RunningState(watermark, FIELDML_ACTIVE_LAUNCH_MODE)
} else if (inside.currentRunningStates[notifiedStrand]?.watermark == watermark) {
inside.currentRunningStates.remove(notifiedStrand)
}
send(Message("is-running").payload(IsRunning(execute.uid, isRunning, watermark, notifiedStrand, FIELDML_ACTIVE_LAUNCH_MODE)))
}
if (!consumed) {
inside.cachedLaunchReturns.remove(strand)
}
// Evaluated, and its value either taken up by the registry or cached. What
// the registry drives from here is the run, not the setup.
trace.finish(inside.isLaunchEpochCurrent(launchEpoch))
} catch (_: CancellationException) {
if (inside.currentRunningStates[strand]?.watermark == generation) {
inside.currentRunningStates.remove(strand)
}
send(Message("is-running").payload(IsRunning(execute.uid, false, generation, strand, FIELDML_PENDING_LAUNCH_MODE)))
trace.finish(false, "launch cancelled")
} catch (t: Throwable) {
if (inside.currentRunningStates[strand]?.watermark == generation) {
inside.currentRunningStates.remove(strand)
}
send(Message("is-running").payload(IsRunning(execute.uid, false, generation, strand, FIELDML_PENDING_LAUNCH_MODE)))
inside.output.displayError(fieldMLErrorMessage(t), execute.property, execute.line)
trace.finish(false)
}
}
}
}
override fun executeSymbol(inside: Box, executeSymbol: ExecuteSymbol, reply: (Message) -> Unit) {
Run one symbol the editor asked about, as a single-line fragment, and print what it evaluated to.
override fun executeSymbol(inside: Box, executeSymbol: ExecuteSymbol, reply: (Message) -> Unit) {
executeFragment(
inside,
Execute(
uid = executeSymbol.uid,
type = "fragment",
line = executeSymbol.line,
code = executeSymbol.symbol,
singleLine = true,
property = "code",
),
reply,
)
}
override fun stop(inside: Box)
Stop whatever this box launched.
There is no FieldML-specific state to tear down, so this is the standard box stop: every strand's generation is bumped past what is running, which is the condition the launch handlers loop on, and the box's registered jobs and stop hooks are drained.
override fun stop(inside: Box) {
inside.stopTrackedJobs()
}
override fun complete(inside: Box, completionRequest: CompletionRequest, reply: (Message) -> Unit) {
Answer the editor's completion request from FieldMLCompletionEngine, which reads the whole box rather than this execution's state.
override fun complete(inside: Box, completionRequest: CompletionRequest, reply: (Message) -> Unit) {
reply(Message("complete").payload(FieldMLCompletionEngine.complete(completionRequest)))
}
override fun describeSymbolContext(inside: Box, request: SymbolContextRequest): SymbolContextResponse
The hover card for the symbol under the caret, from FieldMLCompletionEngine.
override fun describeSymbolContext(inside: Box, request: SymbolContextRequest): SymbolContextResponse =
FieldMLCompletionEngine.describeSymbolContext(request)
override val languageName: String get() = "fieldml"
fieldml: what the AI layer and the operation transcript are told a box's code property is written in.
source — 52 lines, from line 252
private fun executeOnce(
inside: Box, execute: Execute, reply: (Message) -> Unit, fragment: Boolean, mode: String
) {
val originSession = thisSession.get()
val trace = ExecutionTrace.forEngine(inside, execute, mode, reply)
inMain {
val watermark = ++inside.fragmentGeneration
configureOutputProxies(inside, trace.reply, originSession)
send(Message("is-running").payload(IsRunning(execute.uid, true, watermark, "__fragment__")))
var ok = false
try {
val result = FieldMLCompiler.compile(execute.code)
if (!reportDiagnostics(inside, execute, result, originSession, trace.reply)) {
val output = TrackingOutput(inside.output)
val bound = ArrayList<Pair<Int, afield.lang.fieldml.core.FieldMLValue>>()
val value = try {
reactiveRun(inside, mode) {
Evaluator(FieldMLRuntimePrelude.values(output, inside))
.also { it.onTopLevelLet = { index, _, v -> bound += index to v } }
.evaluate(result.core!!)
}
} finally {
recordFieldMLLineNotes(inside, execute, result.typed!!, bound)
}
val typeText = afield.lang.fieldml.type.TypePrinter().print(result.typed.type)
val valueText = renderFieldMLValue(value)
recordFieldMLLineNotes(inside, execute, result.typed, bound, valueText to typeText)
if (!output.printed) {
dispatchExecutionOutput(
inside,
trace.reply,
Output(
uid = inside.id,
line = fieldMLResultLine(execute.line, result),
kind = "normal",
property = execute.property,
html = renderInlineResult(valueText, typeText),
frame = Runloop.runLoop.frame.toInt(),
),
originSession = originSession,
)
}
ok = true
}
} catch (t: Throwable) {
inside.output.displayError(t.message ?: t.javaClass.simpleName, execute.property, execute.line)
} finally {
trace.finish(ok)
send(Message("is-running").payload(IsRunning(execute.uid, false, watermark, "__fragment__")))
}
}
}
Hand the box's line notes (afield.ui.output.LastRunNotes) what each top-level let bound on this
run — bound, by position in the program's spine of lets — and, when the run finished, what the
final expression came to (result, its value and type as text). The typed tree has the lines;
the lowered one the evaluator ran has the same spine in the same order.
source — 63 lines, from line 311
private fun recordFieldMLLineNotes(
inside: Box, execute: Execute, typed: afield.lang.fieldml.type.TExpr,
bound: List<Pair<Int, afield.lang.fieldml.core.FieldMLValue>>, result: Pair<String, String>? = null,
) {
runCatching {
val lets = generateSequence(typed as? afield.lang.fieldml.type.TExprLet) { it.body as? afield.lang.fieldml.type.TExprLet }.toList()
val printer = afield.lang.fieldml.type.TypePrinter()
val produced = bound.mapNotNull { (index, v) ->
val let = lets.getOrNull(index) ?: return@mapNotNull null
val shown = afield.ui.output.LastRunNotes.oneLine(renderFieldMLValue(v))
val type = " : " + printer.print(let.value.type)
afield.ui.output.LastRunNotes.Produced(let.range.start.line, let.value.range.end.line, "${let.name} = $shown$type",
value = listOf(afield.ui.output.LineNotes.plain("${let.name} = "), afield.ui.output.LineNotes.strong(shown), afield.ui.output.LineNotes.plain(type)))
}.toMutableList()
val tail = lets.lastOrNull()?.body ?: typed
if (result != null && tail !is afield.lang.fieldml.type.TExprLet) {
val shown = afield.ui.output.LastRunNotes.oneLine(result.first)
produced += afield.ui.output.LastRunNotes.Produced(tail.range.start.line, tail.range.end.line,
"evaluated to $shown : ${result.second}",
value = listOf(afield.ui.output.LineNotes.plain("evaluated to "), afield.ui.output.LineNotes.strong(shown), afield.ui.output.LineNotes.plain(" : ${result.second}")))
}
afield.ui.output.LastRunNotes.record(inside, "code", execute.code, execute.line, produced)
}.onFailure { afield.app.diagnostics.logging.lprintln("warning,study", "could not record FieldML line notes: $it") }
}
private fun fieldMLResultLine(executeLine: Int, result: CompilationResult): Int {
val sourceLine = result.typed?.range?.end?.line ?: result.surface?.range?.end?.line
return sourceLine?.let { executeLine + it } ?: executeLine
}
private fun reportDiagnostics(
inside: Box,
execute: Execute,
result: CompilationResult,
originSession: WebSocketSession?,
reply: (Message) -> Unit,
): Boolean {
val errors = result.diagnostics.filter { it.severity == afield.lang.fieldml.diag.Severity.Error }
if (errors.isEmpty()) {
dispatchExecutionOutput(
inside,
reply,
Output(
uid = inside.id,
line = -1,
kind = "err",
property = execute.property,
html = "",
frame = Runloop.runLoop.frame.toInt(),
),
originSession = originSession,
)
return false
}
errors.forEach { diagnostic ->
val line = diagnostic.range?.start?.line?.let { execute.line + it - 1 } ?: execute.line
inside.output.displayError(diagnostic.message, execute.property, line)
}
return true
}
}
var Box.isFieldMLRoot by Delegation<Boolean?>()
The mark that makes a box's children FieldML boxes.
MainLanguageRuntime calls this the legacy root, and it is the second of the two marks
fieldMLTopologyNeedsUpdating accepts: either this or a mainLanguage of fieldml will do. It
is persisted with the document, which is what installFieldML adds it to
Box.ioPersistentProperties for.
fun installFieldML()
Register FieldML with the Main Language menu, and make isFieldMLRoot survive a save.
Registration is what puts FieldML in the menu at all; without it the language exists but no box can be told to use it.
@RunsAt(early)
fun installFieldML() {
Box.ioPersistentProperties.add("isFieldMLRoot")
registerMainLanguageRuntime(
MainLanguageRuntime(
id = "fieldml",
label = "FieldML",
runtimeClass = FieldMLExecution::class,
setLegacyRoot = { box, enabled -> box.isFieldMLRoot = enabled },
applyTopology = ::fieldMLTopologyNeedsUpdating,
),
)
}
fun installFieldMLPlugin()
Start watching the box graph, so marking a box propagates to its children without a reload.
The watcher fires fieldMLTopologyNeedsUpdating whenever a session's topology generation
changes, which is how a box dragged under a FieldML root becomes a FieldML box as you drop it.
@RunsAt(plugins)
fun installFieldMLPlugin() {
TopologyWatcher("fieldml") { fieldMLTopologyNeedsUpdating(it) }.start()
}
fun fieldMLTopologyNeedsUpdating(values: Collection<Box>)
Attach and detach FieldMLExecution over a set of boxes, from their parents' marks.
A child of a marked box gets a FieldMLExecution; a child that has one and no marked parent left goes back to Kotlin. Having any marked parent is enough, so a box under both a FieldML root and something else stays FieldML and the order the boxes are visited in does not matter.
fun fieldMLTopologyNeedsUpdating(values: Collection<Box>) {
applyRuntimeToggle(
boxes = values,
isRoot = { it.isFieldMLRoot == true || it.isMainLanguage("fieldml") },
runtimeClass = FieldMLExecution::class,
newExecution = { child -> FieldMLExecution(child) },
language = "fieldml",
customization = "",
notifyCategory = "fieldml",
)
}
private class TrackingOutput(private val delegate: HasOutput) : HasOutput {
var printed: Boolean = false
private set
override fun print(message: Any?) {
printed = true
delegate.print(message)
}
override fun println(message: Any?) {
printed = true
delegate.println(message)
}
override fun display(message: Any?) {
delegate.display(message)
}
override fun display(channel: String, message: Any?) {
delegate.display(channel, message)
}
override fun displayError(message: Any?, property: String, line: Int) {
delegate.displayError(message, property, line)
}
}
internal fun renderInlineResult(value: String, type: String): String
internal fun renderInlineResult(value: String, type: String): String =
if (type.isBlank()) value else "$value : $type"
internal fun fieldMLErrorMessage(t: Throwable): String
FieldML evaluates by walking the tree, so a runaway or merely deep recursion surfaces as a StackOverflowError with no message at all. Name it, and point at the iteration that does not grow the stack.
internal fun fieldMLErrorMessage(t: Throwable): String =
when (t) {
is StackOverflowError ->
"Recursion went too deep. FieldML does not optimise tail calls — for long loops prefer range/tabulate with map or fold."
else -> t.message ?: t.javaClass.simpleName
}