feat(quartz): add an allocation-free read path for NIP-50 indexable text

`indexableContent()` is a write-path API. A store calls it once per event on
insert, so joining a string there is free — and 86 of the 126 implementations do
exactly that, via `listOfNotNull(...).joinToString("\n")`: a list, a
StringBuilder and a joined String per call. The JSON-backed kinds are worse;
`MetadataEvent.contactMetaData()` has no cache, so kind 0 reparses its profile
every single call.

Matching a query against the whole cache inverts that cost — once per event per
keystroke — so this adds a read path beside it rather than changing it:

    fun interface IndexableFieldVisitor { fun visit(field: String?): Boolean }
    fun forEachIndexableField(visitor: IndexableFieldVisitor)

A `fun interface` rather than a lambda parameter, because an interface method
cannot be inline and a lambda written at the call site would allocate per event —
which is the entire thing being avoided. One visitor is built per scan and
carries the term, so the walk allocates nothing and stops at the first field that
matches: a hit on the title never builds the body.

`indexableContent()` is untouched and stays the store contract, so the
externally-mirrored kind table needs no reindex. The default visitor falls back to
it — already free for the ~28 kinds whose indexable content is `content` itself —
and the kinds local search actually scans override it: text notes, long-form,
wiki, highlights, classifieds, live activities and community definitions.

`EventSearchMatcher` matches an event against a NIP-50 search string in memory:
terms ANDed, each a case-insensitive substring of a tag value or an indexable
field, with unsupported extensions ignored per the spec so an extensions-only
search matches everything rather than nothing. Substring because that is what
Amethyst's local search has always done and tokens would silently stop matching
mid-word; AND because that is what a relay does with the same string.

A test pins the two paths together: for every overriding kind, the visitor's
fields rejoined must equal `indexableContent()` byte-for-byte, including the
subsets where a null field is what makes a join drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
This commit is contained in:
Claude
2026-09-08 00:07:59 +00:00
parent 5c77643e9d
commit cfd57cde47
11 changed files with 455 additions and 0 deletions
@@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.nip19Bech32.eventHints
import com.vitorpamplona.quartz.nip19Bech32.eventIds
import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints
import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -66,6 +67,13 @@ class TextNoteEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(subject(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(subject())) return
visitor.visit(content)
}
override fun eventHints(): List<EventIdHint> {
val eHints = tags.mapNotNull(MarkedETag::parseAsHint)
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
@@ -72,6 +73,14 @@ class LongTextNoteEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return
if (!visitor.visit(summary())) return
visitor.visit(content)
}
override fun eventHints(): List<EventIdHint> {
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
val nip19Hints = citedNIP19().eventHints()
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip50Search
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.fastAny
/**
* Matches an event against a NIP-50 `search` string, in memory, without an index.
*
* Built once per query and reused across every event of a scan: the terms are split and the
* extensions stripped here, not per event, and `match` allocates nothing at all.
*
* ## What it matches
*
* Terms are ANDed and each is a case-insensitive substring — of a tag value, or of the event's
* own indexable fields. Substring rather than token because that is what Amethyst's local search
* has always done (`content.contains(text, true)`), and switching to tokens would silently stop
* matching mid-word queries; AND rather than one literal phrase because that is what a relay does
* with the same string, and the point of routing local search through a `Filter` is that the two
* stop disagreeing. For a single-word query — the overwhelmingly common case — the two rules are
* identical anyway.
*
* Unsupported NIP-50 extensions are ignored rather than treated as terms, per the spec, so a
* search that is nothing but extensions matches everything rather than nothing.
*
* ## What it does not do
*
* No relevance score. A relay ranks bm25-first; this answers only yes or no, and any ordering is
* the caller's to apply afterwards.
*
* ## Thread confinement
*
* An instance reuses one visitor across every event it is asked about, which is what keeps the
* scan allocation-free — a lambda written inline would capture the term and allocate once per
* event, defeating the whole point. That reuse makes an instance **single-threaded**: build one
* per scan, do not share it between coroutines.
*/
class EventSearchMatcher(
search: String?,
/**
* Tag names whose values are not worth searching — `p`/`e`/`a` hold hex ids that only ever
* match by accident, `client` and `alt` hold text the author did not write.
*/
private val exceptTagNames: Set<String> = DEFAULT_EXCLUDED_TAGS,
) {
private val terms: Array<String> =
SearchQuery
.stripExtensions(search)
?.split(' ', '\t', '\n', '\r')
?.filter { it.isNotBlank() }
?.toTypedArray()
?: emptyArray()
/** True when this matcher constrains nothing, so callers can skip the walk entirely. */
val isEmpty: Boolean get() = terms.isEmpty()
fun match(event: Event): Boolean {
if (terms.isEmpty()) return true
for (i in terms.indices) {
if (!matchesTerm(event, terms[i])) return false
}
return true
}
/** Reused for every field of every event; see the thread-confinement note on the class. */
private val visitor = TermVisitor()
private fun matchesTerm(
event: Event,
term: String,
): Boolean {
if (event.tags.fastAny { it.size > 1 && it[0] !in exceptTagNames && it[1].contains(term, true) }) {
return true
}
if (event !is SearchableEvent) return event.content.contains(term, true)
return visitor.matches(event, term)
}
/**
* Carries the term into the walk on a field rather than in a closure, so no lambda is
* allocated per event, and stops the walk at the first field that holds it.
*/
private class TermVisitor : IndexableFieldVisitor {
private var term: String = ""
private var found = false
fun matches(
event: SearchableEvent,
term: String,
): Boolean {
this.term = term
found = false
event.forEachIndexableField(this)
return found
}
override fun visit(field: String?): Boolean {
if (field != null && field.contains(term, true)) {
found = true
return false
}
return true
}
}
companion object {
val DEFAULT_EXCLUDED_TAGS = setOf("client", "p", "e", "a", "alt")
}
}
@@ -20,6 +20,48 @@
*/
package com.vitorpamplona.quartz.nip50Search
/**
* Receives one indexable field at a time. Returning false stops the walk.
*
* This is a `fun interface` rather than a lambda parameter on purpose: an interface method cannot
* be `inline`, so a lambda written at the call site would allocate on every event. One instance
* of this is built per scan and reused for every event in it, carrying whatever the walk needs —
* the query terms, a running hit flag — on itself. That is what makes a full-cache scan
* allocation-free.
*/
fun interface IndexableFieldVisitor {
/**
* @param field one field's text, or null where the event does not carry it — implementors
* pass their optional fields straight through rather than filtering them first.
* @return false to stop the walk. A hit on the title need not build the body.
*/
fun visit(field: String?): Boolean
}
/**
* An event whose human-authored text is worth indexing for NIP-50 search.
*
* There are two ways to read that text, and they exist for opposite cost profiles:
*
* - [indexableContent] is the **write path**. The stores call it once per event on insert, so it
* is free to build a joined string; the SQLite store hands the result to FTS5 and the
* filesystem store tokenizes it. Its exact output per kind is mirrored by external search
* engines — see `references/searchable-kinds.md` in the `searchable-events` skill — so changing
* what it returns is a breaking change that needs a reindex.
*
* - [forEachIndexableField] is the **read path**. Matching a query against the whole cache calls
* it once per event per keystroke, where building a string per event is not affordable. It
* hands over the fields the event already holds, allocating nothing, and stops early on a hit.
*
* The default [forEachIndexableField] falls back to [indexableContent], which is correct for
* every kind and free for the ~28 whose indexable content is `content` itself. Kinds that join
* several fields — the majority — should override it so the read path stops paying for a join
* the reader may not even need.
*/
interface SearchableEvent {
fun indexableContent(): String
fun forEachIndexableField(visitor: IndexableFieldVisitor) {
visitor.visit(indexableContent())
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip53LiveActivities.LiveStreamLike
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
@@ -65,6 +66,14 @@ class LiveActivitiesEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return
if (!visitor.visit(summary())) return
visitor.visit(content)
}
override fun eventHints(): List<EventIdHint> {
val pinnedEvents = pinned()
if (pinnedEvents.isEmpty()) return emptyList()
@@ -55,6 +55,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
@@ -79,6 +80,14 @@ class WikiNoteEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return
if (!visitor.visit(summary())) return
visitor.visit(content)
}
override fun eventHints(): List<EventIdHint> {
val eHints = tags.mapNotNull(MarkedETag::parseAsHint)
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag
@@ -59,6 +60,15 @@ class CommunityDefinitionEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(name(), description(), rules(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(name())) return
if (!visitor.visit(description())) return
if (!visitor.visit(rules())) return
visitor.visit(content)
}
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(QTag::parseEventId)
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip19Bech32.eventIds
import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints
import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag
import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag
@@ -71,6 +72,14 @@ class HighlightEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(comment(), context(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(comment())) return
if (!visitor.visit(context())) return
visitor.visit(content)
}
override fun eventHints(): List<EventIdHint> {
val eHints = tags.mapNotNull(ETag::parseAsHint)
val qHints = tags.mapNotNull(QTag::parseEventAsHint)
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag
@@ -65,6 +66,14 @@ class ClassifiedsEvent(
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n")
// The read path: hands over the same fields indexableContent() joins, without
// building the joined string a scan would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return
if (!visitor.visit(summary())) return
visitor.visit(content)
}
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip50Search
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class EventSearchMatcherTest {
private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
private val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
private fun note(
content: String,
tags: Array<Array<String>> = emptyArray(),
kind: Int = 1,
): Event = EventFactory.create(id, pubkey, 1683596206, kind, tags, content, "")
private fun matches(
search: String?,
event: Event,
) = EventSearchMatcher(search).match(event)
@Test
fun anEmptySearchConstrainsNothing() {
assertTrue(EventSearchMatcher(null).isEmpty)
assertTrue(EventSearchMatcher(" ").isEmpty)
assertTrue(matches(null, note("anything")))
assertTrue(matches("", note("anything")))
}
@Test
fun aTermIsACaseInsensitiveSubstring() {
// Substring, not token: Amethyst's local search has always matched mid-word, and
// switching to tokens would silently stop finding these.
assertTrue(matches("itcoi", note("Bitcoin is money")))
assertTrue(matches("BITCOIN", note("bitcoin is money")))
assertFalse(matches("ethereum", note("bitcoin is money")))
}
@Test
fun termsAreAnded() {
// What a relay does with the same string; a single-word query is unaffected either way.
assertTrue(matches("bitcoin money", note("bitcoin is money")))
assertFalse(matches("bitcoin gold", note("bitcoin is money")))
}
@Test
fun tagValuesAreSearchedExceptTheOnesNobodyMeansToSearch() {
assertTrue(matches("subj", note("body", arrayOf(arrayOf("subject", "a subject")))))
assertTrue(matches("bitcoin", note("body", arrayOf(arrayOf("t", "bitcoin")))))
// `p`/`e`/`a` hold hex ids, `client` and `alt` hold text the author did not write.
assertFalse(matches("amethyst", note("body", arrayOf(arrayOf("client", "amethyst")))))
assertFalse(matches("deadbeef", note("body", arrayOf(arrayOf("p", "deadbeef".repeat(8))))))
}
@Test
fun indexableFieldsBeyondContentAreSearched() {
// A long-form title lives in a tag, but its summary reaches the matcher through the
// visitor as well — both paths must find it.
val article = note("the body", arrayOf(arrayOf("title", "Lightning"), arrayOf("summary", "a summary")), kind = 30023)
assertTrue(matches("Lightning", article))
assertTrue(matches("summary", article))
assertTrue(matches("body", article))
}
@Test
fun unsupportedExtensionsAreIgnoredRatherThanTreatedAsTerms() {
// NIP-50: a relay ignores extensions it does not implement. An extensions-only search
// must therefore match everything, never nothing.
assertTrue(EventSearchMatcher("domain:nostr.com").isEmpty)
assertTrue(matches("domain:nostr.com", note("anything")))
assertTrue(matches("bitcoin language:en", note("bitcoin is money")))
assertFalse(matches("gold language:en", note("bitcoin is money")))
}
@Test
fun oneMatcherAnswersManyEventsConsistently() {
// The visitor is reused across events; a stale term or hit flag would show up here.
val matcher = EventSearchMatcher("lightning")
val hit = note("about lightning", kind = 30023)
val miss = note("about bitcoin", kind = 30023)
repeat(5) {
assertTrue(matcher.match(hit))
assertFalse(matcher.match(miss))
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip50Search
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The read path ([SearchableEvent.forEachIndexableField]) and the write path
* ([SearchableEvent.indexableContent]) must never disagree about what an event says.
*
* They cannot simply share code — one joins a string because a store wants one, the other refuses
* to build a string at all — so the agreement is pinned here instead. A kind whose visitor drifts
* from its joined content would make local search find things the relay does not, or miss things
* it does, with nothing else to catch it.
*/
class IndexableFieldVisitorTest {
private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
private val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
private fun event(
kind: Int,
tags: Array<Array<String>>,
content: String,
): Event = EventFactory.create(id, pubkey, 1683596206, kind, tags, content, "")
/** Every field the visitor hands over, in order, nulls dropped exactly as `listOfNotNull` does. */
private fun visited(event: SearchableEvent): List<String> {
val out = mutableListOf<String>()
event.forEachIndexableField { field ->
if (field != null) out.add(field)
true
}
return out
}
private fun assertAgrees(
kind: Int,
tags: Array<Array<String>>,
content: String,
) {
val e = event(kind, tags, content)
assertTrue(e is SearchableEvent, "kind $kind did not resolve to a SearchableEvent (is it in EventFactory?)")
assertEquals(
e.indexableContent(),
visited(e).joinToString("\n"),
"kind $kind: the visitor's fields do not rejoin to what the stores index",
)
}
@Test
fun theKindsThatOverrideTheVisitorRejoinToTheirIndexedContent() {
// A note with a subject, and one without — the null field is where a join drifts.
assertAgrees(1, arrayOf(arrayOf("subject", "A subject")), "the body")
assertAgrees(1, emptyArray(), "the body")
// title + summary + content, and each subset of it.
listOf(30023, 30818, 30402, 30311).forEach { kind ->
assertAgrees(kind, arrayOf(arrayOf("title", "T"), arrayOf("summary", "S")), "body")
assertAgrees(kind, arrayOf(arrayOf("title", "T")), "body")
assertAgrees(kind, emptyArray(), "body")
}
// Highlight: comment + context + content.
assertAgrees(9802, arrayOf(arrayOf("comment", "C"), arrayOf("context", "X")), "quoted")
assertAgrees(9802, emptyArray(), "quoted")
// Community definition: name + description + rules + content.
assertAgrees(34550, arrayOf(arrayOf("name", "N"), arrayOf("description", "D"), arrayOf("rules", "R")), "c")
assertAgrees(34550, emptyArray(), "c")
}
@Test
fun theDefaultVisitorHandsOverTheJoinedContentUntouched() {
// A kind that does not override still answers the read path correctly, via the default.
val e = event(1111, arrayOf(arrayOf("t", "bitcoin")), "a comment")
assertTrue(e is SearchableEvent)
assertEquals(e.indexableContent(), visited(e).joinToString("\n"))
}
@Test
fun theWalkStopsAtTheFirstFieldThatSatisfiesIt() {
val e = event(30023, arrayOf(arrayOf("title", "T"), arrayOf("summary", "S")), "body")
assertTrue(e is SearchableEvent)
var seen = 0
e.forEachIndexableField {
seen++
// Stop immediately: a hit on the title must not go on to build the body.
false
}
assertEquals(1, seen, "the visitor kept walking after being told to stop")
}
}