feat(settings): label enum pickers from the schema (#7298)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-22 15:27:23 +00:00
1 parent 8c22e3b2ae
commit 720c7a0850
20 files changed
+353 -226

No files matched your search

@@ -0,0 +1,116 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.schemastrings
/**
* The Kotlin half of the sync: a lookup from an enum value to the resource holding its schema label, so a picker shows
* the label every client shares instead of the constant's Kotlin name.
*
* The keys these build are [SchemaCatalog.keyFor]'s, so this file and `schema_strings.xml` cannot name a value
* differently. What the lookup cannot do is fail at compile time: a resource that is not there reads as null, which is
* why `SchemaEnumLabelsTest` walks every labelled constant.
*/
object EnumLabelsKt {
/** One enum the schema labels: its proto path, the prefix its values share, and whether any value is described. */
data class LabelledEnum(val path: String, val prefix: String, val hasDescriptions: Boolean)
/**
* The whole generated file. Spotless reformats it afterwards, so this is the input to the formatter rather than the
* committed bytes - which is why `RepositorySyncTest` compares the branches it declares, not its text. The notice
* sits below the imports because the licence-header step deletes anything between the header and `package`.
*/
fun render(header: String, pin: String, enums: List<LabelledEnum>): String = buildString {
append(header)
append("package $PACKAGE\n\n")
imports(enums).forEach { append("import ").append(it).append('\n') }
append('\n').append(notice(pin)).append('\n')
append(LABEL_DOC)
append("@Suppress(\"CyclomaticComplexMethod\")\n")
append("fun Enum<*>.schemaLabelRes(): StringResource? = when (this) {\n")
enums.forEach { append(branch(it, "schemaLabel")) }
append(" else -> null\n}\n")
append(DESCRIPTION_DOC)
append("@Suppress(\"CyclomaticComplexMethod\")\n")
append("fun Enum<*>.schemaDescriptionRes(): StringResource? = when (this) {\n")
enums.filter { it.hasDescriptions }.forEach { append(branch(it, "schemaDescription")) }
append(" else -> null\n}\n")
append(HELPER)
}
/**
* One `when` branch. A branch that would not fit on a line is an error rather than a wrap: a wrapped branch makes
* the formatter space out every other branch in the same `when`, and the fix belongs in the name, not here.
*/
private fun branch(enum: LabelledEnum, function: String): String {
val line = " is ${enum.path} -> $function(\"${enum.prefix}\")"
require(line.length <= MAX_LINE) { "branch for ${enum.path} is ${line.length} characters, over $MAX_LINE" }
return "$line\n"
}
/** `is Config.DeviceConfig.Role -> ...`, as the committed file spells it, for the drift check. */
val branchPattern: Regex =
Regex("""^\s*is ([\w.]+) -> schema(Label|Description)\("([^"]+)"\)""", RegexOption.MULTILINE)
/** `Config.DeviceConfig.Role` needs only `Config` imported; a top-level enum needs itself. */
private fun imports(enums: List<LabelledEnum>): List<String> =
(FIXED_IMPORTS + enums.map { "$PROTO_PACKAGE.${it.path.substringBefore('.')}" }).distinct().sorted()
fun notice(pin: String): String =
"// Generated by ./gradlew :schema-strings:sync from org.meshtastic:protobufs $pin.\n" +
"// Do not edit: change the schema, then run sync."
private const val MAX_LINE = 120
private const val PACKAGE = "org.meshtastic.core.model"
private const val PROTO_PACKAGE = "org.meshtastic.proto"
private val FIXED_IMPORTS =
listOf(
"org.jetbrains.compose.resources.StringResource",
"org.meshtastic.core.resources.Res",
"org.meshtastic.core.resources.allStringResources",
)
private val LABEL_DOC =
"""
|
|/**
| * The schema's label for this enum value, or null where the schema does not name it. A picker shows this in
| * place of the constant's Kotlin name, and falls back to that name when it is null, so a value the schema has
| * not reached still renders.
| */
|"""
.trimMargin()
private val DESCRIPTION_DOC =
"""
|
|/** The schema's one-sentence explanation of this enum value, or null where it has none. */
|"""
.trimMargin()
private val HELPER =
"""
|
|private fun Enum<*>.schemaLabel(prefix: String): StringResource? =
| Res.allStringResources[prefix + name.lowercase()]
|
|private fun Enum<*>.schemaDescription(prefix: String): StringResource? =
| Res.allStringResources[prefix + name.lowercase() + "_description"]
|"""
.trimMargin()
}
@@ -73,6 +73,22 @@ object SchemaCatalog {
return out
}
/** Every enum the schema labels, in proto-path order, for the Kotlin accessors that read those labels. */
fun labelledEnums(): List<EnumLabelsKt.LabelledEnum> = generatedTypePaths().mapNotNull { path ->
val type = Class.forName(GENERATED_PACKAGE + path.replace('.', '$'))
val constants = type.enumConstants ?: return@mapNotNull null
val metadata =
constants.filterIsInstance<WireEnum>().mapNotNull {
FieldMetadataRegistry.forEnumValue(PROTO_PACKAGE + path, it.value)
}
if (metadata.none { !it.label.isNullOrBlank() }) return@mapNotNull null
EnumLabelsKt.LabelledEnum(
path = path,
prefix = keyFor(path, ""),
hasDescriptions = metadata.any { !it.description.isNullOrBlank() },
)
}
/**
* The resource for a field or enum value. Each message segment is lowercased with a trailing `Config` dropped, and
* the `Config`/`ModuleConfig` container is dropped: `ModuleConfig.MQTTConfig.address` is `schema_mqtt_address`.
@@ -17,13 +17,15 @@
package org.meshtastic.schemastrings
import java.io.File
import java.time.Year
/** The repository view of the sync: where the files live, what the English file should contain, how to write it. */
class SchemaStringsSync(rootDir: File) {
class SchemaStringsSync(private val rootDir: File) {
private val valuesRoot = rootDir.resolve("core/resources/src/commonMain/composeResources")
private val catalog = rootDir.resolve("gradle/libs.versions.toml")
val englishStrings: File = valuesRoot.resolve("values/$HAND_WRITTEN")
val englishSchemaStrings: File = valuesRoot.resolve("values/$GENERATED")
val enumLabels: File = rootDir.resolve("core/model/src/commonMain/kotlin/$GENERATED_KT")
/** Every hand-written strings file: the English one and one per locale. */
val handWrittenFiles: List<File>
@@ -51,6 +53,25 @@ class SchemaStringsSync(rootDir: File) {
notice = notice(catalogPin),
)
/**
* The Kotlin accessors the registry implies. The licence header is the committed file's own where there is one, so
* a rerun does not restamp a year Spotless is happy with - everything before `package` is that header.
*/
fun expectedEnumLabels(): String = EnumLabelsKt.render(
header = enumLabels.takeIf { it.isFile }?.let { licenceHeader(it.readText()) } ?: freshLicenceHeader(),
pin = catalogPin,
enums = SchemaCatalog.labelledEnums(),
)
/** The pin the committed Kotlin file says it was built from, or null when it is absent or unmarked. */
val recordedEnumLabelsPin: String?
get() = enumLabels.takeIf { it.isFile }?.let { noticePin.find(it.readText())?.groupValues?.get(1) }
private fun licenceHeader(kotlin: String): String = kotlin.substringBefore("package ").substringBefore("// ")
private fun freshLicenceHeader(): String =
rootDir.resolve("config/spotless/copyright.kt").readText().replace("\$YEAR", YEAR) + "\n"
/** Schema-named keys that someone wrote into a hand-written file, by file. Those files must never carry one. */
fun strays(): Map<File, Set<String>> = handWrittenFiles
.associateWith { file -> schemaKeys(StringsXml.bodies(file.readText()).keys) }
@@ -59,7 +80,9 @@ class SchemaStringsSync(rootDir: File) {
fun apply(): String {
val text = expectedEnglish()
englishSchemaStrings.writeText(text)
return "wrote ${englishSchemaStrings.path} (${StringsXml.bodies(text).size} strings from protobufs $catalogPin)"
enumLabels.writeText(expectedEnumLabels())
return "wrote ${StringsXml.bodies(text).size} strings and " +
"${SchemaCatalog.labelledEnums().size} enum accessors from protobufs $catalogPin"
}
private fun schemaKeys(names: Set<String>): Set<String> = names.filterTo(HashSet()) { it.startsWith("schema_") }
@@ -67,6 +90,8 @@ class SchemaStringsSync(rootDir: File) {
companion object {
const val HAND_WRITTEN = "strings.xml"
const val GENERATED = "schema_strings.xml"
const val GENERATED_KT = "org/meshtastic/core/model/SchemaEnumLabels.kt"
private val YEAR = Year.now().toString()
private val catalogPinLine = Regex("""^meshtastic-protobufs = "([^"]+)"$""", RegexOption.MULTILINE)
private val noticePin = Regex("""from org\.meshtastic:protobufs (\S+)\.""")
@@ -51,6 +51,54 @@ class RepositorySyncTest {
)
}
@Test
fun `the generated Kotlin records the pin it was built from`() {
assertNotNull(
sync.recordedEnumLabelsPin,
"core/model SchemaEnumLabels.kt carries no pin: run ./gradlew :schema-strings:sync",
)
}
/**
* Compares the accessors the file declares rather than its bytes: Spotless reformats what the sync writes, so its
* text is the formatter's and only the set of enums and prefixes is the sync's to promise.
*/
@Test
fun `at a matching pin the generated Kotlin declares the accessors the registry implies`() {
if (sync.recordedEnumLabelsPin != sync.catalogPin) {
println("protobufs moved to ${sync.catalogPin}; scheduled-updates re-syncs SchemaEnumLabels.kt")
return
}
val committed =
EnumLabelsKt.branchPattern
.findAll(sync.enumLabels.readText())
.map { Triple(it.groupValues[1], it.groupValues[2], it.groupValues[3]) }
.toSet()
val expected =
SchemaCatalog.labelledEnums()
.flatMap { enum ->
listOfNotNull(
Triple(enum.path, "Label", enum.prefix),
Triple(enum.path, "Description", enum.prefix).takeIf { enum.hasDescriptions },
)
}
.toSet()
assertEquals(expected, committed, "core/model SchemaEnumLabels.kt is stale: run ./gradlew :schema-strings:sync")
}
@Test
fun `every labelled enum builds a prefix the generated strings carry`() {
val strings = StringsXml.bodies(sync.englishSchemaStrings.readText()).keys
val missing = SchemaCatalog.labelledEnums().filter { enum -> strings.none { it.startsWith(enum.prefix) } }
assertTrue(
missing.isEmpty(),
"these enums build a resource prefix nothing in schema_strings.xml matches: " +
missing.joinToString { "${it.path} (${it.prefix})" },
)
}
@Test
fun `no schema key is written by hand`() {
val strays = sync.strays()