Kotlin kit
Setting up a Kotlin plugin, and every function and type in the kit.
The Kotlin kit is a Kotlin Multiplatform library in the package hammer. You write your plugin's logic as common code. Its exports are a few lines in wasmWasiMain, and its tests run on the JVM against a fake Hammer. The com.darkrockstudios.hammer.plugin Gradle plugin sets all of this up for you and builds the .hammerplugin package.
This page covers kit version 0.8.0, which targets host API version 1.
A complete plugin
greeter/build.gradle.kts, with settings.gradle.kts as in Setup:
plugins {
id("com.darkrockstudios.hammer.plugin") version "0.8.0"
}
greeter/manifest.toml:
id = "greeter"
name = "Greeter"
version = "0.1.0"
api = 1
[permissions]
operations = ["scene.tree"]
[[actions]]
name = "outline"
label = "Outline"
greeter/src/commonMain/kotlin/Greeter.kt:
import hammer.ActionReply
import hammer.ActionRequest
import hammer.ops.Hammer
fun outline(request: ActionRequest): ActionReply {
val tree = Hammer.scene.tree(request.project)
return ActionReply.document(tree.nodes.joinToString("\n") { "- ${it.name}" }, title = request.project)
}
greeter/src/wasmWasiMain/kotlin/Exports.kt:
import hammer.action
import kotlin.wasm.WasmExport
@WasmExport
fun action(): Int = action { request ->
when (request.action) {
"outline" -> outline(request)
else -> null
}
}
greeter/src/jvmTest/kotlin/GreeterTest.kt:
import hammer.ActionDriver
import hammer.FakeHammerHost
import hammer.host
import hammer.ops.SceneNode
import hammer.ops.SceneTree
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
class GreeterTest {
@Test
fun `the outline lists the top-level nodes`() {
host = FakeHammerHost(mapOf("scene.tree" to { _ ->
Json.encodeToJsonElement(SceneTree(nodes = listOf(SceneNode(id = 1, name = "Storm"))))
}))
val driver = ActionDriver("outline", ::outline, project = "Rain").open()
assertEquals("- Storm", driver.page?.markdown)
}
}
Run gradle jvmTest to run the test, and gradle hammerPlugin to build build/greeter.hammerplugin.
Setup
The quickest start is the Kotlin template: a working plugin with tests and a release workflow, set up as below. Choose Use this template on its GitHub page to make your own repository from it.
The kit and its Gradle plugin are on Maven Central:
| Artifact | Coordinates |
|---|---|
| Gradle plugin | id("com.darkrockstudios.hammer.plugin"), artifact com.darkrockstudios.hammer:hammer-gradle-plugin |
| Kit | com.darkrockstudios.hammer:hammer-plugin-kit |
To set up a project yourself, add Maven Central to both sets of repositories in settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
rootProject.name = "greeter"
Then apply the Gradle plugin in build.gradle.kts. It adds the kit, com.darkrockstudios.hammer:hammer-plugin-kit, at its own version, so this is all your build needs:
plugins {
id("com.darkrockstudios.hammer.plugin") version "0.8.0"
}
You need a JDK; the Gradle wrapper, or any Gradle 9, does the rest.
What the Gradle plugin configures
When you apply com.darkrockstudios.hammer.plugin, it does the following:
- It applies
org.jetbrains.kotlin.multiplatformandorg.jetbrains.kotlin.plugin.serialization. The Gradle plugin pins these at Kotlin 2.4.10. - It adds a
jvmtarget, so you can test your common code. - It adds a
wasmWasitarget with an executable binary. The module is named after the Gradle project. Node is set as the target's environment only because Kotlin requires one; Hammer is what actually runs the module. - It generates an empty
fun main() {}intowasmWasiMain(thegenerateHammerMaintask), because a Kotlin/Wasm executable needs amain. Hammer never calls it. - It adds the kit to
commonMainandkotlin-testtojvmTest. The kit exposeskotlinx-serialization-json1.11.0 as an API dependency, so your plugin can use it without declaring it. - It registers the
hammerPlugintask.
Project layout
greeter/
build.gradle.kts
manifest.toml required
settings.toml optional: the plugin's settings
locales/<tag>.toml optional: translations
resources/ optional: files read at run time
src/
commonMain/kotlin/ the plugin's logic
wasmWasiMain/kotlin/ the @WasmExport functions
jvmTest/kotlin/ tests, run on the JVM
commonMaincontains your plugin's logic: functions that take a request and return a reply. Code here runs both in the Wasm module and in JVM tests.wasmWasiMaincontains only the exports, usually in a singleExports.kt.@WasmExportexists only on the Wasm target, so the exports can't live in common code.jvmTestcontains your tests. They call the common functions directly.FakeHammerHostresponds to operation calls, andActionDriverruns actions the way Hammer does.
See the manifest for manifest.toml, settings.toml, and locales/.
Building
hammerPlugin
A Zip task in the build group. It depends on compileProductionExecutableKotlinWasmWasiOptimize, then writes build/<project>.hammerplugin with these entries:
| Entry | From |
|---|---|
manifest.toml |
manifest.toml |
settings.toml |
settings.toml, when there is one |
locales/*.toml |
the .toml files in locales/ |
resources/... |
everything under resources/ |
plugin.wasm |
the optimized module |
Before packing, it checks that manifest.toml exists and that its api equals API_VERSION. If either check fails, the build stops with one of these errors:
A Hammer plugin needs a manifest.toml next to its build file
manifest.toml says api = 2, but this kit targets api = 1
The archive has no file timestamps and a fixed file order, so the same inputs always produce the same package.
Other tasks
| Task | What it does |
|---|---|
gradle :<plugin>:hammerPlugin |
Builds the package. |
gradle :<plugin>:jvmTest |
Runs the plugin's JVM tests. |
gradle :<plugin>:compileProductionExecutableKotlinWasmWasiOptimize |
Builds the optimized module alone, at build/compileSync/wasmWasi/main/productionExecutable/optimized/<project>.wasm. |
generateHammerMain |
Generates the empty main. It runs as part of compiling. |
The SDK's kotlin/build.sh builds every plugin next to it. If gradle isn't on your path, set GRADLE to a Gradle launcher.
Exports
Your plugin can have at most one export of each kind: export, action, diagnose, and command. Each export is a top-level function in wasmWasiMain that:
- is annotated with
@WasmExportfromkotlin.wasm, - is named after the export,
- takes no arguments and returns
Int.
Its body is a call to one of the kit functions below. That function reads the request, calls your code, and writes the output. If your plugin has two actions, its single action export tells them apart by request.action.
@WasmExport
fun export(): Int = exporter { helloExport(it).encodeToByteArray() }
@WasmExport
fun action(): Int = action { request ->
when (request.action) {
"greet" -> greet(request)
else -> null
}
}
@WasmExport
fun diagnose(): Int = diagnose(::findVery)
@WasmExport
fun command(): Int = command(::helloCommand)
If your code lets an exception escape, the call fails and the writer sees the exception's message. See the pages on export formats, actions, text checks and commands for what Hammer sends each export and what it expects back.
These export functions live in wasmWasiMain, so JVM tests can't call them. Test the functions they wrap instead.
exporter
fun exporter(render: (ExportRequest) -> ByteArray): Int
Use this as the body of your export export. render returns the bytes of the exported file. For a format whose manifest entry sets output = "archive", it returns an ExportArchive's toJson().encodeToByteArray() instead.
action
fun action(handle: (ActionRequest) -> ActionReply?): Int
Use this as the body of your action export. handle returns the reply, or null to end the action without showing anything.
diagnose
fun diagnose(check: (DiagnoseRequest) -> List<Diagnostic>): Int
Use this as the body of your diagnose export. check returns the issues it finds in the request's paragraphs, and the kit writes them out with diagnoseReply.
command
fun command(answer: (CommandRequest) -> String?): Int
Use this as the body of your command export. answer returns the line to write to standard output, or null to write nothing.
run
fun run(body: () -> String): Int
Runs an export's body. On success it writes the body's output and returns 0. On failure it writes the exception's message, which is what the writer sees, and returns 1. It catches any Throwable, so even TODO() reports its message.
The four functions above are built on run. Call it directly only for an export that reads its own input.
runBytes
fun runBytes(body: () -> ByteArray): Int
Like run, but for output that isn't text. It also sets host to the Extism host, so a Wasm build always talks to Hammer, whatever host was set to before.
input
fun input(): ByteArray
Returns the call's raw input. exporter, action, diagnose, and command read their request from it.
inputText
fun inputText(): String
Returns the call's raw input as UTF-8 text. Malformed UTF-8 is replaced rather than rejected.
Requests
Each export receives a request, which the kit decodes from Hammer's JSON, ignoring unknown keys. All four request types include settings and locale. See the pages on export formats, actions, text checks and commands for the JSON itself.
WithSettings
interface WithSettings {
val settings: JsonObject
}
A request that includes your plugin's settings, with the current value of each setting you declared. Every request type implements it.
ExportRequest
@Serializable
class ExportRequest(
val format: String,
val projectName: String,
val language: String = "en",
val topLevelAsChapters: Boolean = true,
val chapters: List<Chapter> = emptyList(),
override val settings: JsonObject = JsonObject(emptyMap()),
val locale: String? = null,
val images: List<Image> = emptyList(),
val coverImageId: Int? = null,
) : WithSettings {
fun image(id: Int): Image?
val cover: Image?
}
The request your export export receives. It names one of the export formats from your manifest and contains the story.
| Property | Meaning |
|---|---|
format |
The manifest's format being exported. |
projectName |
The project's name. |
language |
The project's language tag. |
topLevelAsChapters |
Whether the top-level groups of the scene tree are the chapters. |
chapters |
The story, chapter by chapter. |
settings |
The plugin's settings. |
locale |
The UI's BCP 47 tag, such as fr-CA, so you can reply in the writer's language; null when unknown. |
images |
For a format whose manifest entry sets images = true, each project image the story refers to whose file Hammer could read, sorted by id. With cover = true as well, it includes the cover. Empty otherwise. |
coverImageId |
The cover's id among images, with cover = true; null when the project has no cover or its file couldn't be read. |
image(id) |
The image id among images, or null when it isn't there. |
cover |
The cover among images, or null when there is none. |
topLevelAsChapters reflects the Export dialog's option to treat top-level scenes as chapters (treatTopLevelAsChapters on project.export). Hammer always sends language.
ExportRequest.Chapter
@Serializable
class Chapter(
val name: String,
val scenes: List<String> = emptyList(),
val prose: List<List<JsonElement>> = emptyList(),
)
One chapter and its scenes. Normally the scenes arrive as markdown in scenes. If the format's manifest entry sets input = "prose", they arrive in prose instead, as Hammer's parsed blocks: one list of blocks per scene.
Markdown shows an image as , which findImageIds and parseImageReference read. In prose, with images = true, a line holding only an image is a block {"type": "image", "id": 5, "name": "Map"}, which proseImageId reads; without it, the image is a paragraph of its name. An image inside a line, or one whose file Hammer couldn't read, is its name as text, never a block.
ExportRequest.Image
@Serializable
class Image(
val id: Int,
val name: String,
val extension: String,
val mimeType: String,
@SerialName("content")
val base64: String,
) {
val content: ByteArray
}
A project image and its file: what image.get returns, with mimeType added.
| Property | Meaning |
|---|---|
id, name, extension |
The image's id, name, and file extension. |
mimeType |
The file's media type; application/octet-stream when Hammer doesn't know extension. |
base64 |
The file as Hammer sent it, in standard padded base64. Serialized as content. |
content |
The file's bytes, decoded from base64 when first read. Throws IllegalArgumentException if the text isn't base64. |
request.chapters.flatMap { it.prose }.flatten().forEach { block ->
val image = proseImageId(block)?.let(request::image)
if (image != null) addFile("images/${image.id}.${image.extension}", image.content)
}
request.cover?.let { addFile("cover.${it.extension}", it.content) }
Here addFile stands for the format's own writer. See export formats for the manifest keys and the size limits on images.
ActionRequest
@Serializable
class ActionRequest(
val action: String,
val project: String,
override val settings: JsonObject = JsonObject(emptyMap()),
val input: JsonObject = JsonObject(emptyMap()),
val context: Context = Context(),
val button: String? = null,
val changed: String? = null,
val state: JsonElement? = null,
val resumed: Boolean = false,
val locale: String? = null,
) : WithSettings
The request your action export receives when the writer runs one of the [[actions]] from your manifest on one of a project's screens. See actions for how an action proceeds from call to call.
| Property | Meaning |
|---|---|
action |
The name of the action in your manifest. |
project |
The project's name. Operations take this as their project input. |
settings |
Your plugin's settings. |
input |
A value for each field on the page the writer was on. On the first call, these are the action's [[actions.field]]s. Read it with input<T>(). |
context |
Where the action was run from. |
button |
The id of the button the writer pressed. It's null on the first call, which either opens the action or runs it with the manifest's fields. |
changed |
Reserved for fields that call the action when they change. It's always null. |
state |
The previous page's state, exactly as you wrote it; null on the first call. Read it with state<T>(). |
resumed |
Set on the first call after Hammer restores a run the writer had left (for an action with keep = true). |
locale |
The UI's BCP 47 tag, such as fr-CA, so you can reply in the writer's language; null when unknown. |
ActionRequest.Context
@Serializable
class Context(val place: String = "project", val id: Int? = null)
The screen the action was run from (project, scene, note, entry, or event), and the id of the item shown on that screen. id is null for project.
DiagnoseRequest
@Serializable
class DiagnoseRequest(
val diagnostics: String,
val paragraphs: List<String>,
val language: String? = null,
override val settings: JsonObject = JsonObject(emptyMap()),
val locale: String? = null,
val project: String = "",
) : WithSettings
The request your diagnose export receives: paragraphs of plain text to check, for one of the [[diagnostics]] entries in your manifest.
| Property | Meaning |
|---|---|
diagnostics |
The name of the diagnostics entry in your manifest. |
paragraphs |
The paragraphs to check, as plain text. |
language |
The project's BCP 47 tag, or null when it has none. |
settings |
Your plugin's settings. |
locale |
The UI's BCP 47 tag, such as fr-CA; null when unknown. |
project |
The name of the project the text belongs to, for operations such as entry.list. |
CommandRequest
@Serializable
class CommandRequest(
val command: String,
val args: List<String> = emptyList(),
val line: String,
override val settings: JsonObject = JsonObject(emptyMap()),
val locale: String? = null,
) : WithSettings
The request your command export receives: one line of standard input, sent to one of the [[commands]] in your manifest.
| Property | Meaning |
|---|---|
command |
The name of the command in your manifest. |
args |
The command line's arguments. |
line |
One line of standard input. |
settings |
Your plugin's settings. |
locale |
The UI's BCP 47 tag, such as fr-CA; null when unknown. |
For hammer <name> [args], args are the arguments that follow the command's name. Hammer reads the settings again for each line.
Reading settings, input, and state
The kit decodes these with kotlinx.serialization and ignores unknown keys. Each T below is a @Serializable class. A property with a default value may be missing from the JSON.
WithSettings.settings
inline fun <reified T> WithSettings.settings(): T
Reads the settings into a T, a @Serializable class with one property per setting.
@Serializable
class HelloSettings(val greeting: String)
val greeting = request.settings<HelloSettings>().greeting
ActionRequest.input
inline fun <reified T> ActionRequest.input(): T
Reads the action's input into a T, a @Serializable class with one property per field on the page. Use these property types:
| Field | Property type |
|---|---|
| text or choice | String |
| bool | Boolean |
| int | Int |
| scenes | List<Int> (or Int? with multiple = false) |
| entity | EntityRef? (or List<EntityRef> with multiple = true) |
ActionRequest.state
inline fun <reified T> ActionRequest.state(): T?
Reads the request's state into a T, as stateOf wrote it. Returns null when there's no state, or when it isn't a T.
stateOf
inline fun <reified T> stateOf(value: T): JsonElement
Converts value to JSON for use as a page's state. ActionRequest.state reads it back. Your plugin keeps nothing in memory between calls, so put everything the next call needs into the page's state.
EntityRef
@Serializable
data class EntityRef(val kind: String, val id: Int)
An item in the project, as an entity field's value. kind is scene, note, entry, or event, and id is the item's id. To go to the item, reply with ActionReply.Open(ref.kind, ref.id).
Replies
ActionReply
@Serializable
class ActionReply(
val page: Page? = null,
val message: String? = null,
val errors: Map<String, String> = emptyMap(),
val open: Open? = null,
)
What your action export returns. Every part is optional. A reply with nothing set ends the action without showing anything.
pageis shown in the action's dialog. When the writer presses one of its buttons, Hammer calls the action again with the button's id, the page's values asinput, and the page's state.messageis shown briefly, whatever else the reply does.errorsmaps field keys to messages, each shown under its field. A message under the key"", or under a key that doesn't match any field, is shown for the whole page. Errors keep the writer on the page they were on, with what they entered and the page's state.openends the action and goes to an item.
A reply with none of page, errors, or open ends the action. See actions.
ActionReply.message
fun message(message: String): ActionReply
A function on ActionReply's companion. Returns a reply that ends the action and briefly shows message.
ActionReply.document
fun document(markdown: String, title: String? = null): ActionReply
A function on ActionReply's companion. Returns a reply that shows markdown on its own, as a report the writer can copy or save as a note.
ActionReply.toJson
fun toJson(): String
Encodes this reply as the JSON your action export returns, leaving out any parts that aren't set. action calls it for you.
ActionReply.Page
@Serializable
class Page(
val title: String? = null,
val markdown: String? = null,
val fields: List<Field> = emptyList(),
val buttons: List<Button> = emptyList(),
val state: JsonElement? = null,
)
What the dialog shows: the title, the markdown, the fields in order, and the buttons.
state is usually built with stateOf. Hammer never looks inside it; it passes it back unchanged as the next call's ActionRequest.state. If the state is larger than 1 MiB as JSON, the call fails.
If a page has markdown and no fields, Hammer offers to copy it or save it as a note.
You'll usually import Page, Field, and Button by those short names: import hammer.ActionReply.Page.
ActionReply.Field
@Serializable
class Field private constructor(...) {
val key: String
val type: String
val label: String
val hint: String?
val required: Boolean
val default: JsonElement?
val min: Int?
val max: Int?
val options: List<Option>
val format: TextFormat
val multiline: Boolean
val kinds: List<String>
val entryTypes: List<String>
val tags: List<String>
val multiple: Boolean
}
A field on a page. Its constructor is private, so create one with text, bool, int, choice, scenes, or entity. The field's value comes back in the next call's input under key.
| Property | Meaning |
|---|---|
key |
The input key the field's value comes back under. |
type |
text, bool, int, choice, scenes, or entity. |
label, hint |
The text shown with the field. |
required |
A text field must not be blank, a scenes field must not be empty, and an entity field must have a pick. |
default |
The initial value. When null, the field starts at its type's own default: false, min or 0, the first option, "", or nothing picked. |
min, max |
An int field's bounds. |
options |
A choice field's options. |
format, multiline |
How a text field is edited. |
kinds |
The kinds of item an entity field offers: scene, note, entry, or event. When empty, it offers all four. |
entryTypes |
The types of entry an entity field offers: person, place, thing, event, or idea. When empty, it offers any type. Serialized as entry_types. |
tags |
An entity field's tags. An item must have one of them; when empty, any item qualifies. |
multiple |
Whether the value is a list. A scenes field is a list by default; an entity field isn't. |
Before calling your plugin, Hammer checks required, an int's bounds, a choice's options, and that an entity field's picks exist and match its filters. It skips these checks if the button pressed says to.
Field.text
fun text(
key: String,
label: String,
hint: String? = null,
required: Boolean = false,
default: String? = null,
format: TextFormat = TextFormat.Plain,
multiline: Boolean = false,
): Field
A text field, edited in the way format specifies. multiline applies to plain text. The value is a string, which is markdown for the rich formats.
Field.bool
fun bool(key: String, label: String, hint: String? = null, default: Boolean? = null): Field
A checkbox. The value is true or false.
Field.int
fun int(key: String, label: String, hint: String? = null, default: Int? = null, min: Int? = null, max: Int? = null): Field
A whole number. If you give min or max, the value must stay within them.
Field.choice
fun choice(key: String, label: String, options: List<Option>, hint: String? = null, default: String? = null): Field
A choice between options, of which there must be at least one. default is the value of one of the options. The field's value is the value of the option the writer picks.
Field.scenes
fun scenes(
key: String,
label: String,
hint: String? = null,
required: Boolean = false,
multiple: Boolean = true,
default: List<Int>? = null,
): Field
Scenes picked from the project's tree. The value is a list of scene ids or, when multiple is false, a single id or null. default lists the ids picked initially; when multiple is false, it can contain at most one.
Field.entity
fun entity(
key: String,
label: String,
hint: String? = null,
required: Boolean = false,
kinds: List<String> = emptyList(),
entryTypes: List<String> = emptyList(),
tags: List<String> = emptyList(),
multiple: Boolean = false,
default: EntityRef? = null,
): Field
fun entity(
key: String,
label: String,
hint: String? = null,
required: Boolean = false,
kinds: List<String> = emptyList(),
entryTypes: List<String> = emptyList(),
tags: List<String> = emptyList(),
multiple: Boolean = true,
default: List<EntityRef>,
): Field
An item in the project, picked by name. The value is one EntityRef or null or, with multiple, a list. You can narrow what the picker offers:
kindslimits the kinds of item:scene,note,entry, orevent. When empty, all four are offered.entryTypeslimits which entries are offered:person,place,thing,event, oridea. When empty, any type is offered. It only has an effect whenentryis among the kinds.tags, when given, limits the picker to items tagged with at least one of them.
default is the item picked initially. If the default no longer exists or doesn't match the filters, Hammer shows the field as empty.
The second overload takes a list as default and starts with those items picked. It's a list field unless multiple is false. In that case default can contain at most one item; otherwise it throws IllegalArgumentException.
Field.entity("who", "Character", kinds = listOf("entry"), entryTypes = listOf("person"), default = here)
Field.Option
@Serializable
class Option(val value: String, val label: String)
One option in a choice field. value is what comes back in the input, and label is what the writer sees.
TextFormat
@Serializable
enum class TextFormat {
Plain,
Rich,
RichExtended,
}
The editor a text field uses.
Plain(plain) is an ordinary text field.Rich(rich) is the scene editor with its format bar.RichExtended(rich-extended) is the scene editor with the extended format bar, which adds highlight, task lists, and tables.
The value of a rich field is markdown.
ActionReply.Button
@Serializable
class Button(
val id: String,
val label: String,
val primary: Boolean = false,
val inline: Boolean = false,
val validate: Boolean = true,
)
A button on a page. Pressing it calls the action again with id as ActionRequest.button.
primarymarks the button as the main one to press.- A button sits in the dialog's footer. With
inline, it sits under the markdown instead, for acting on something in it. - With
validatefalse, Hammer calls the action without checking the fields. Use this for buttons like Back or Cancel.
ActionReply.Open
@Serializable
class Open(val place: String, val id: Int? = null)
An item for Hammer to go to. place is project, scene, note, entry, or event, and id is the item's id (null for project). If the id doesn't exist, the reply fails.
ActionReply(message = "Added $name", open = ActionReply.Open("entry", entry.id))
Diagnostic
class Diagnostic(
val paragraph: Int,
val start: Int,
val end: Int,
val message: String,
val fixes: List<Fix> = emptyList(),
val severity: Severity = Severity.Error,
)
An issue in the paragraph at index paragraph, running from start to end. start and end are indexes into that paragraph's String; the kit converts them to the UTF-8 offsets Hammer expects. A Word's start and end, and a LanguageTool Match's offset and end, are indexes of the same kind, so you can pass them on as they are.
Severity
enum class Severity {
Error,
Suggestion,
}
How Hammer underlines a Diagnostic.
Erroris a mistake, shown with a wavy underline.Suggestionis something the writer may want to change, shown with a quieter, dotted underline.
Fix
class Fix(val replacement: String, val label: String? = null)
Text to replace a Diagnostic's range with. Give it a label when the replacement text alone wouldn't make clear what the fix does; the writer sees the label instead. A fix with an empty replacement must have a label.
diagnoseReply
fun diagnoseReply(paragraphs: List<String>, diagnostics: List<Diagnostic>): String
Builds your diagnose export's reply for diagnostics in paragraphs, using UTF-8 offsets. Any diagnostic whose paragraph isn't a valid index into paragraphs is left out. A Suggestion is written with "severity": "suggestion"; an Error leaves severity out, since it's Hammer's default. diagnose calls it for you. Call it yourself in a test to check the offsets Hammer will receive.
ExportArchive
class ExportArchive {
fun text(path: String, text: String): ExportArchive
fun bytes(path: String, bytes: ByteArray): ExportArchive
fun base64(path: String, encoded: String): ExportArchive
fun image(path: String, id: Int): ExportArchive
fun image(path: String, image: ExportRequest.Image): ExportArchive
fun toJson(): String
}
What your export export returns for a format whose manifest entry sets output = "archive": the files Hammer zips, in the order you add them, into the one export file. The manifest still gives that file's extension and mime (zip and application/zip). Return toJson().encodeToByteArray() from exporter.
| Member | Meaning |
|---|---|
text |
Adds text as the file at path, written as UTF-8. |
bytes |
Adds bytes as the file at path. |
base64 |
Adds the file at path whose bytes encoded holds as standard padded base64, passed on without decoding, for base64 your plugin kept itself. Hammer fails the export if it isn't base64. |
image |
Adds one of the request's images, by its id, as the file at path. Hammer writes the image's bytes itself, so the reply carries none. An id Hammer didn't send fails the export. |
toJson |
The export export's reply. Throws IllegalArgumentException if no file was added. |
Each adding function returns the archive, so calls can be chained. A path is relative, with / between its segments. It must not be empty, start with /, contain \, have an empty segment (so no // or trailing /), have a . or .. segment, or be a path already added. Adding a file with any other path throws IllegalArgumentException naming the path, since Hammer would refuse the reply.
fun helloArchive(request: ExportRequest): ExportArchive = ExportArchive().apply {
text("hello.txt", helloExport(request))
request.chapters.forEachIndexed { i, chapter ->
val name = chapter.name.replace('/', '-').replace('\\', '-')
text("chapters/${i + 1}-$name.md", "# ${chapter.name}\n\n" + chapter.scenes.joinToString("\n\n"))
}
request.images.forEach { image("images/${it.id}.${it.extension}", it) }
}
@WasmExport
fun export(): Int = exporter { request ->
when (request.format) {
"hello.zip" -> helloArchive(request).toJson()
else -> helloExport(request)
}.encodeToByteArray()
}
Words
String.words
fun String.words(): List<Word>
The words in this text, in order. A word is a run of letters, with any combining marks on them, so accents, Indic vowel signs, and Arabic vowel marks stay in their word. Digits, hyphens, and everything else end a word.
- An apostrophe (
'or’), the Catalan middle dot, Hebrew's geresh and gershayim, ZWNJ, ZWJ, or a soft hyphen between two letters stays inside the word, so "don't", "l'arbre", and "col·lecció" are one word each. - Chinese and Japanese put no spaces between words, so each Han, kana, or Bopomofo character is a word of its own.
- Thai, Lao, Khmer, and Burmese don't use spaces either, but there each run of letters is one word.
Letters come from a table of Unicode 17, the same one the Rust kit's words and the C kit's hammer_words use, so all three kits split text the same way.
Kotlin/Wasm's Regex is slow in Hammer's interpreter, so search text with words() and String's own functions rather than a Regex:
fun findVery(request: DiagnoseRequest): List<Diagnostic> = request.paragraphs.flatMapIndexed { paragraph, text ->
text.words().filter { it.text.equals("very", ignoreCase = true) }.map { word ->
val end = if (text.getOrNull(word.end) == ' ') word.end + 1 else word.end
Diagnostic(paragraph, word.start, end, "“Very” rarely helps", listOf(Fix("", "Remove “very”")))
}
}
Word
data class Word(val text: String, val start: Int, val end: Int)
A word in a text: text, from start up to end. start and end are indexes into the text's String, as a Diagnostic takes them.
Calling Hammer
Your plugin calls Hammer's operations through the typed functions on Hammer, or by name with a JSON input. Your manifest's permissions.operations must grant each operation you call. See the host API for how these calls reach Hammer.
Hammer
object Hammer
Every operation, as a typed function. The functions are grouped the way the operations are named, so scene.read is Hammer.scene.read and scene.meta.write is Hammer.scene.meta.write. Each one takes the operation's inputs as parameters and returns its output as a data class from hammer.ops:
import hammer.ops.EntryKind
import hammer.ops.Hammer
val scene = Hammer.scene.read(request.project, id = 12)
val words = scene.wordCount
val people = Hammer.entry.list(request.project, type = EntryKind.Person).entries
The groups are account, backup, draft, entry, export, idea, image, note, ops, plugin, project, scene, stats, sync, tag, and timeline, with nested groups such as entry.images, scene.meta, timeline.def, and timeline.settings.
Optional inputs are parameters that default to null, so you pass only the ones you need, by name. Each function's documentation says which permission your manifest needs for it. If the operation fails, the function throws OperationException.
The functions and types are generated from Hammer's own list of operations, so they always match the operations reference. Two details make them forgiving:
- Every output field has a default. Hammer always sends every field, so this only matters in tests: a fake can return just the fields your code reads, or build its reply from the generated type, as in
Json.encodeToJsonElement(SceneText(markdown = "Rain fell.")). - Every enum has an
Unknownvalue. A value your kit doesn't know, such as an entry kind added in a later Hammer, reads asUnknowninstead of failing. You can't sendUnknownto Hammer.
call
inline fun <reified T> call(operation: String, input: JsonElement): T
Runs a Hammer operation by name and reads its output into a T, a @Serializable class. Unknown keys in the output are ignored, so your class only needs the properties you use. Prefer Hammer; use call when you want your own type for an output.
@Serializable
class SceneText(val markdown: String)
val text = call<SceneText>("scene.read", buildJsonObject {
put("project", request.project)
put("id", scene.id)
})
See scene.read.
callJson
fun callJson(operation: String, input: JsonElement): JsonElement
Runs a Hammer operation that your manifest has permission for and returns its output. If the operation fails, it throws OperationException. If Hammer leaves out the output, it returns JsonNull.
callJsonText
fun callJsonText(operation: String, input: JsonElement): String
Like callJson, but returns the output as unparsed JSON text. Use it to pass a large output along as it is, since parsing and printing it again would be slow. It fails the same way callJson does.
OperationException
class OperationException(val kind: String, message: String) : Exception(message)
Thrown when an operation fails. kind is NotFound, InvalidInput, PermissionDenied, or Failed. If your plugin lets it escape, the writer sees its message. An action can catch it and reply with errors instead, which keeps the writer on the page:
try {
// ...
} catch (e: OperationException) {
ActionReply(errors = mapOf("" to e.message.orEmpty()))
}
In tests, a FakeHammerHost handler throws it to make an operation fail.
Images
Markdown shows a project image as , sometimes with the destination in angle brackets, <hammer://image/<id>>. The alt text means nothing: Hammer shows the image's own name. These functions read and write those references. For an export's images, see ExportRequest.Image.
IMAGE_REFERENCE_PREFIX
const val IMAGE_REFERENCE_PREFIX = "hammer://image/"
What every image reference starts with.
imageReference
fun imageReference(imageId: Int): String
The reference to the image imageId, hammer://image/<id>, for Markdown such as .
parseImageReference
fun parseImageReference(reference: String): Int?
The image id a reference names: hammer://image/3, or <hammer://image/3>, is 3. Returns null for anything else, such as another scheme, or an id that is empty, isn't all ASCII digits, or is too large for an Int.
findImageIds
fun findImageIds(markdown: String): Set<Int>
Every image id markdown refers to, each once, in the order they first appear. It finds them in images, angle-bracket destinations, reference definitions ([map]: hammer://image/3), and code alike, as Hammer counts them. A reference is hammer://image/ and its digits, where no letter, digit, _, +, ., or - comes just before it and no letter, digit, or _ just after, so hammer://image/12x names none. An id too large for an Int names none either.
An id can name an image that has since been deleted, so Hammer.image.get may fail with NotFound:
val ids = findImageIds(Hammer.scene.read(request.project, scene).markdown)
ids.forEach { id ->
val image = try {
Hammer.image.get(request.project, id)
} catch (e: OperationException) {
if (e.kind != "NotFound") throw e
null
}
// ...
}
getImage
fun getImage(project: String, reference: String): ImageContent
The image a reference names, as parseImageReference reads it, with its bytes, from image.get, which your manifest must grant. Throws OperationException of kind InvalidInput for a reference that names no image, and fails as image.get does for an image that isn't in project.
proseImageId
fun proseImageId(block: JsonElement): Int?
The id of a prose image block, {"type": "image", "id": 5, "name": "Map"}; null for any other block. See ExportRequest.Chapter.
Sync server services
A writer's own Hammer sync server may offer services, such as languagetool for grammar checks. Hammer sends your plugin's requests to them as the signed-in writer, so your plugin never holds their credentials. It sends them only to the services your manifest names under permissions.hammerServer:
[permissions]
hammerServer = ["languagetool"]
For LanguageTool, use the typed functions in hammer.languagetool instead of serverRequest.
serverRequest
fun serverRequest(
service: String,
method: String,
path: String,
headers: Map<String, String> = emptyMap(),
body: ByteArray? = null,
): ServerResponse
Sends a request to service, at path under it (such as /v2/check), and waits for its response. method is GET or POST. Only the Content-Type and Accept headers are sent; Hammer drops any others. An empty body is sent as no body.
It returns a response of any status; ServerResponse.error says what a status other than success means. When there's no response, it throws ServerException.
ServerResponse
class ServerResponse(val status: Int, val body: String) {
val isSuccess: Boolean
fun error(): ServerException?
}
A service's response: its HTTP status and its body as text. isSuccess is true for a status from 200 to 299.
ServerResponse.error
fun error(): ServerException?
What a status other than success means, as a ServerException to throw; null on success. Its kind is RateLimited for 429, Rejected for any other 4xx, and ServiceFailed for anything else. Its message is the displayMessage the sync server gave for the writer, when the body is JSON with one; otherwise it's The service replied <status>, followed by the start of the body.
val response = serverRequest("languagetool", "GET", "/v2/languages", mapOf("Accept" to "application/json"))
response.error()?.let { throw it }
ServerException
class ServerException(val kind: ServerErrorKind, message: String) : Exception(message)
Thrown when a request to a service fails. If your plugin lets it escape, the writer sees its message. In tests, a FakeHammerHost service handler throws it to make a request fail.
ServerErrorKind
enum class ServerErrorKind {
NotSignedIn,
Unavailable,
Network,
NotPermitted,
Invalid,
RateLimited,
Rejected,
ServiceFailed,
}
Why a request failed. The first five come from Hammer, before there's any response; the last three are what ServerResponse.error makes of a status.
| Kind | Meaning |
|---|---|
NotSignedIn |
There's no sync account in this workspace, or its session has ended. |
Unavailable |
The sync server doesn't offer the service. |
Network |
The sync server couldn't be reached. |
NotPermitted |
The manifest doesn't name the service. |
Invalid |
A refused method or path, or a reply that isn't text or isn't what the service should send. |
RateLimited |
Too many requests: 429. The sync server allows more after a minute. |
Rejected |
The service refused the request, such as one that's too large or in a language it doesn't check: 4xx. |
ServiceFailed |
The service failed, or the sync server couldn't reach it: 5xx. |
LanguageTool checks
The package hammer.languagetool reaches LanguageTool on the writer's sync server: the languagetool service, which forwards to the LanguageTool server its admin set up. Your manifest must name it under permissions.hammerServer, as in Sync server services. Any failure throws ServerException.
import hammer.languagetool.CheckRequest
import hammer.languagetool.LanguageTool
fun grammar(request: DiagnoseRequest): List<Diagnostic> = request.paragraphs.flatMapIndexed { paragraph, text ->
LanguageTool.check(CheckRequest(text, language = request.language ?: "auto")).matches.map {
Diagnostic(paragraph, it.offset, it.end, it.message, it.replacements.map { fix -> Fix(fix.value) })
}
}
A check per paragraph keeps the example short. A real check sends paragraphs together and caches what comes back.
LanguageTool
object LanguageTool {
const val SERVICE = "languagetool"
const val CHECK_PATH = "/v2/check"
const val LANGUAGES_PATH = "/v2/languages"
const val DEFAULT_MAX_REQUEST_BYTES = 100_000
fun check(request: CheckRequest): CheckResponse
fun languages(): List<Language>
}
SERVICE is the service's name, and CHECK_PATH and LANGUAGES_PATH are the paths the two functions use. DEFAULT_MAX_REQUEST_BYTES is the largest a check's encoded form may be on a sync server left at its defaults; an admin may allow more. Encoding can triple text that isn't ASCII, so keep a check's text to about 30,000 bytes.
LanguageTool.check
fun check(request: CheckRequest): CheckResponse
Checks request's text on the sync server's LanguageTool, as a form POST to CHECK_PATH. A response other than success throws its error(), and a body that can't be read as a CheckResponse throws ServerException of kind Invalid.
LanguageTool.languages
fun languages(): List<Language>
The languages the sync server's LanguageTool checks. It fails the same way check does.
CheckRequest
data class CheckRequest(
val text: String,
val language: String = "auto",
val motherTongue: String? = null,
val preferredVariants: List<String> = emptyList(),
val enabledRules: List<String> = emptyList(),
val disabledRules: List<String> = emptyList(),
val enabledCategories: List<String> = emptyList(),
val disabledCategories: List<String> = emptyList(),
val enabledOnly: Boolean = false,
val level: Level = Level.Default,
) {
fun form(): String
}
A check of text. Rules and categories are LanguageTool's ids, such as EN_A_VS_AN or TYPOS.
| Property | Meaning |
|---|---|
text |
The text to check. |
language |
A language code such as en-US, or auto for LanguageTool to detect it. A blank code is sent as auto. |
motherTongue |
The writer's first language, for rules about false friends. |
preferredVariants |
With auto, the variants to prefer, such as en-GB and de-AT. |
enabledRules, disabledRules |
Rules to turn on or off. |
enabledCategories, disabledCategories |
Categories to turn on or off. |
enabledOnly |
Run only the enabled rules and categories, none of the rest. |
level |
How picky the check is. |
form() returns the request as the URL-encoded form LanguageTool reads. It leaves out empty lists, a null motherTongue, and enabledOnly and level at their defaults.
Level
enum class Level {
Default,
Picky,
}
How picky a check is. Picky adds more checks of style and typography.
CheckResponse
@Serializable
data class CheckResponse(
val language: CheckedLanguage? = null,
val matches: List<Match> = emptyList(),
val warnings: Warnings = Warnings(),
)
@Serializable
data class Warnings(val incompleteResults: Boolean = false)
@Serializable
data class CheckedLanguage(
val name: String = "",
val code: String = "",
val detectedLanguage: DetectedLanguage? = null,
)
@Serializable
data class DetectedLanguage(
val name: String = "",
val code: String = "",
val confidence: Double? = null,
)
What a check found. language is the language the text was checked in, with the one LanguageTool detected in it. matches are the problems, in order. warnings.incompleteResults is true when LanguageTool stopped before the end of the text, as when a check takes too long.
Match
@Serializable
data class Match(
val message: String = "",
val shortMessage: String = "",
val offset: Int,
val length: Int,
val replacements: List<Replacement> = emptyList(),
val context: Context = Context(),
val sentence: String = "",
val rule: Rule = Rule(),
) {
val end: Int
fun textIn(text: String): String?
}
@Serializable
data class Replacement(val value: String, val shortDescription: String? = null)
@Serializable
data class Context(val text: String = "", val offset: Int = 0, val length: Int = 0)
A problem LanguageTool found. offset and length count UTF-16 units of the text that was checked, as a Kotlin String does, so they index that String directly, and end is offset + length. Pass offset and end to a Diagnostic as they are.
| Member | Meaning |
|---|---|
message, shortMessage |
What's wrong, at length and in brief. |
replacements |
Suggested replacements for the matched text, each a value with an optional shortDescription. |
context |
Some of the text around the match, with the match's offset and length in it. |
sentence |
The sentence the match is in. |
rule |
The rule that matched. |
textIn(text) |
The matched part of text, the text that was checked; null when the match runs past its end. |
Rule
@Serializable
data class Rule(
val id: String = "",
val subId: String? = null,
val description: String = "",
val issueType: String = "",
val category: Category = Category(),
val urls: List<RuleUrl> = emptyList(),
)
@Serializable
data class Category(val id: String = "", val name: String = "")
@Serializable
data class RuleUrl(val value: String)
The LanguageTool rule behind a Match. issueType is such as misspelling, grammar, style, typographical, or register. category.id is such as TYPOS, GRAMMAR, STYLE, or REDUNDANCY. Each of urls is a page explaining the rule.
Language
@Serializable
data class Language(val name: String, val code: String, val longCode: String)
A language the sync server's LanguageTool checks. longCode is the code with its variant, such as en-US, which a CheckRequest takes as its language.
Cache
Hammer gives your plugin its own cache, which persists between calls and runs. Hammer may drop any value at any time, and it clears the whole cache when your plugin is reinstalled or uninstalled. A call still running on the old version during an install may write to the cache after it's cleared. If the format of a value might change between versions, include a version in its key.
cacheGet
fun cacheGet(key: String): ByteArray?
Returns the value cached under key, or null if there isn't one. A key longer than CACHE_MAX_KEY bytes always returns null.
cacheSet
fun cacheSet(key: String, value: ByteArray): Boolean
Caches value under key. An empty value removes the key. If the key or the value is over its limit, it stores nothing and returns false.
CACHE_MAX_KEY
const val CACHE_MAX_KEY = 1024
The longest allowed key, in UTF-8 bytes.
CACHE_MAX_VALUE
const val CACHE_MAX_VALUE = 1024 * 1024
The largest allowed value, in bytes: 1 MiB.
Resources
Files in your plugin's resources/ directory go into the package unchanged. Your plugin reads them by their path inside that directory.
resource
fun resource(name: String): ByteArray?
Reads a file from the package's resources/ by its path inside that directory, such as words/en.txt. Returns null if the package has no file by that name, or if the file is empty. Hammer reads the file from the package on every call, so hold on to one you use often instead of reading it repeatedly.
resourceText
fun resourceText(name: String): String?
Like resource, but returns the file as UTF-8 text.
val tips = resourceText("tips.txt").orEmpty().lines().filter { it.isNotBlank() }
Progress and logging
progress
fun progress(fraction: Double? = null, message: String? = null)
Reports how far an action has got. fraction runs from 0 to 1, or is null when you don't know. message is a line saying what the action is doing. Hammer shows the progress while the action runs, along with a Stop button. A fraction outside 0 to 1 is clamped. If the writer has stopped the run, the call doesn't return, and the action ends there.
scenes.forEachIndexed { i, scene ->
progress(i.toDouble() / scenes.size, "Counting ${scene.name}")
// ...
}
Log
object Log {
fun info(message: String)
fun warn(message: String)
fun error(message: String)
}
Writes message to Hammer's log at the function's level. Each line appears in Hammer's own log as Plugin '<id>': <message>. Trace and debug are both logged at debug level.
LogLevel
enum class LogLevel { Info, Warn, Error }
The level of a log line, as HammerHost.log receives it.
Constants
API_VERSION
const val API_VERSION = 1
The host API version this kit targets. Your manifest's api must match it. It's generated at build time from apiVersion in the SDK's kotlin/versions.properties. If your manifest's api is different, hammerPlugin stops the build.
The host
Everything above talks to Hammer through a single object, host. Inside Hammer, the exports set it to the Extism host. In JVM tests, you set it yourself.
HammerHost
interface HammerHost {
fun dispatch(request: String): String
fun cacheGet(key: ByteArray): ByteArray?
fun cacheSet(key: ByteArray, value: ByteArray)
fun log(level: LogLevel, message: String)
fun progress(report: String) {}
fun resource(name: String): ByteArray? = null
fun serverRequest(request: String, body: ByteArray?): String? = null
}
The interface the kit uses to talk to Hammer. Inside Hammer, it's the Extism host. In a JVM test, set host to a FakeHammerHost or to your own implementation.
| Member | Meaning |
|---|---|
dispatch |
Sends request, {"operation": ..., "input": ...}, and returns Hammer's reply JSON: {"output": ...} or {"error": {"kind": ..., "message": ...}}. |
cacheGet |
Returns the cached value for key, or null. |
cacheSet |
Stores value under key. An empty value removes it. |
log |
Writes a log line. |
progress |
Reports an action's progress as {"fraction": ..., "message": ...}. Does nothing by default. |
resource |
Returns a file from the package's resources/, or null if there's no file by that name or it's empty. Returns null by default. |
serverRequest |
Sends a request to a service on the sync server, {"service": ..., "method": ..., "path": ..., "headers": ...}, with body or no body, and returns Hammer's reply JSON: {"status": ..., "body": ...} or {"error": ..., "message": ...}, or null for no reply. Returns null by default. |
Your own host is useful when a test needs something FakeHammerHost doesn't do, such as replying with raw JSON:
host = object : HammerHost {
override fun dispatch(request: String) = """{"output":{"markdown":"Rain."}}"""
override fun cacheGet(key: ByteArray): ByteArray? = null
override fun cacheSet(key: ByteArray, value: ByteArray) = Unit
override fun log(level: LogLevel, message: String) = Unit
}
host
var host: HammerHost
The host the kit uses. The export functions set it inside Hammer; your tests set it themselves. Until it's set, any call through it fails with No Hammer host: in a test, set hammer.host to a FakeHammerHost.
Testing
FakeHammerHost and ActionDriver are part of the kit's jvm variant, so your plugin's jvmTest can use them without any extra dependency. Gradle runs tests in the plugin's project directory, which is how both of them find your plugin's resources/ and manifest.toml. Run the tests with gradle :<plugin>:jvmTest.
FakeHammerHost
class FakeHammerHost(
private val operations: Map<String, (JsonObject) -> JsonElement> = emptyMap(),
private val resourceDirectory: File = File("resources"),
private val services: Map<String, (ServiceCall) -> ServerResponse> = emptyMap(),
) : HammerHost {
val cache: MutableMap<String, ByteArray>
val logged: MutableList<Pair<LogLevel, String>>
val progress: MutableList<JsonObject>
val serviceCalls: MutableList<ServiceCall>
}
A stand-in for Hammer in JVM tests. operations maps operation names to handlers. Each handler takes the operation's input and returns its output. Calling an operation with no handler fails with NotFound. To make an operation fail, have its handler throw OperationException.
services maps the names of sync server services to handlers, which answer serverRequest and the LanguageTool functions. Each handler takes the request as a ServiceCall and returns a ServerResponse. A request to a service with no handler fails with NotPermitted. To make a request fail, have its handler throw ServerException. Like Hammer, the fake refuses a method other than GET or POST, and a path that isn't /-separated segments of letters, digits, ., _, and - or that has a . or .. segment, failing with Invalid. It drops any header other than Content-Type and Accept.
| Member | Meaning |
|---|---|
cache |
Your plugin's cache, by key. Read it to see what was cached, or fill it before a call. |
logged |
Every log line, with its level, in order. |
progress |
Every progress report, as {"fraction": ..., "message": ...}, in order. |
serviceCalls |
Every request to a service, in order, including those that failed. |
Resources are read from resourceDirectory, which defaults to your plugin's own resources/. Like Hammer, it only returns files inside that directory, and returns null for an empty file.
val fake = FakeHammerHost(
mapOf(
"scene.read" to { input ->
buildJsonObject { put("markdown", if (input["id"]!!.jsonPrimitive.int == 2) "Rain fell hard." else "Quiet.") }
},
),
).also { host = it }
// ...
assertEquals(listOf("Counting Storm", "Counting Calm"), fake.progress.map { it["message"]!!.jsonPrimitive.content })
val fake = FakeHammerHost(services = mapOf("languagetool" to { _ -> ServerResponse(200, reply) }))
host = fake
val checked = LanguageTool.check(CheckRequest(text, language = "en-US"))
assertEquals("/v2/check", fake.serviceCalls.single().path)
ServiceCall
class ServiceCall(
val service: String,
val method: String,
val path: String,
val headers: Map<String, String>,
val body: ByteArray?,
) {
val bodyText: String?
}
A request your plugin sent to a service on the sync server, as FakeHammerHost received it. method is upper case, and headers holds only the headers Hammer would send. bodyText is body decoded as UTF-8, or null when there's no body.
ActionDriver
class ActionDriver(
private val action: String,
private val handle: (ActionRequest) -> ActionReply?,
private val project: String = "Project",
private val settings: JsonObject = JsonObject(emptyMap()),
private val context: ActionRequest.Context = ActionRequest.Context(),
private val locale: String? = null,
private val fields: List<ActionReply.Field> = manifestFields(action),
)
Runs an action's handler in a JVM test the way Hammer runs it, over as many calls as the test needs. handle is the function your action export calls, such as ::wave. fields are the action's [[actions.field]]s. Unless you pass them in, they're read from your plugin's manifest.toml.
Before each call, the driver runs Hammer's own checks (required, an int's bounds, a choice's options, an entity's kinds), unless the button has validate = false. Any problems they find go into errors, and the handler isn't called. Because there's no real project to look in, the driver can't check that an entity field's picks exist, or check their entry types and tags. Your test should only fill in items the writer could actually pick.
Each call carries the page's values and its state. The driver passes the reply through JSON and back, then applies it the way Hammer would:
- Errors keep the current page, its values, and its state.
- A page replaces the one showing.
open, or a reply with none of these, ends the action.
Errors from a call made while no page is showing also end the action, and their messages are added to messages.
If Hammer would reject a reply, the driver throws IllegalStateException. That happens for:
- a field of an unknown type,
- a choice with no options,
- an entity field with an unknown kind or entry type,
- a page whose state is larger than 1 MiB,
- an
opento an unknown place.
val wave = ActionDriver("wave", ::wave, project = "Storm").open()
wave.fill("name", "Mara").press("next")
assertEquals("Mara", wave.state<Wave>()?.name)
wave.fill("times", 2).press("wave")
assertEquals(listOf("Mara waved 2 times"), wave.messages)
ActionDriver.open
fun open(): ActionDriver
Opens the action as if from its menu item, starting a new run regardless of what came before. It shows the manifest's fields or, if there are none, calls the handler right away. It throws if Hammer would reject the manifest's fields.
ActionDriver.fill
fun fill(key: String, value: String): ActionDriver
fun fill(key: String, value: Boolean): ActionDriver
fun fill(key: String, value: Int): ActionDriver
fun fill(key: String, value: List<Int>): ActionDriver
fun fill(key: String, value: EntityRef?): ActionDriver
@JvmName("fillEntities")
fun fill(key: String, value: List<EntityRef>): ActionDriver
fun fill(key: String, value: JsonElement): ActionDriver
Sets a field on the page that's showing. A List<Int> sets a scenes field to those scene ids. An EntityRef? sets an entity field to that pick, or to nothing. A List<EntityRef> sets an entity field that takes several items. It throws if the page has no field called key, or if no page is showing.
ActionDriver.run
fun run(): ActionDriver
Presses Run on the manifest's page, which calls the action with no button. It throws on any other page; use press with one of that page's buttons instead.
ActionDriver.press
fun press(id: String): ActionDriver
Presses the page's button id. Hammer's checks run first, unless the button has validate = false. It throws if the page has no such button.
ActionDriver.resume
fun resume(): ActionDriver
Closes the dialog and opens the action again from the same place, the way Hammer does for an action that keeps its runs. The page and its values come back, and the next call has resumed set.
ActionDriver.state
inline fun <reified T> state(): T?
Reads the page's state into a T, the same way ActionRequest.state does.
ActionDriver properties
var page: ActionReply.Page?
val values: Map<String, JsonElement>
var errors: Map<String, String>
val messages: List<String>
var opened: ActionReply.Open?
var ended: Boolean
val requests: List<ActionRequest>
The vars have private setters, so your test can read them but not change them.
| Property | Meaning |
|---|---|
page |
The page that's showing. It's null before open and after the action has ended. |
values |
The page's values by field key, as the next call's input will carry them. On a new page, each field starts at its default, or at its type's empty value. |
errors |
Messages shown under the page's fields, by key, or for the whole page under "". They come from Hammer's checks or from your plugin. Errors your plugin returns for keys that don't match any field are joined under "", the way Hammer shows them. |
messages |
Every reply's message, in order. |
opened |
What the action ended by opening, if it did. |
ended |
Whether the action has ended. |
requests |
Every request the handler was called with, in order. |
ActionDriver.manifestFields
fun manifestFields(action: String, manifest: File = File("manifest.toml")): List<ActionReply.Field>
A function on ActionDriver's companion. Returns the [[actions.field]]s of action in manifest, which defaults to your plugin's own. Returns an empty list if the file doesn't exist, and throws if the file has no action by that name. Use it to check the manifest's fields in a test:
assertTrue(ActionDriver.manifestFields("meet").isEmpty())