Compare commits

...
2 Commits
Author SHA1 Message Date
Hare 58ad0e67c9 feat: 構造ノードの定義を可能に 2025-12-04 05:13:56 +09:00
Hare a4f6e8e236 feat: namespaceの扱いの改善 2025-12-04 04:41:37 +09:00
9 changed files with 212 additions and 82 deletions
+37 -30
View File
@@ -8,37 +8,40 @@ instances and keep `PermissionAttachment`s in sync.
## Usage
```kotlin
import net.hareworks.permits_lib.domain.NodeRegistration
class ExamplePlugin : JavaPlugin() {
private val permits = PermitsLib.session(this)
override fun onEnable() {
val tree = permissionTree("example") {
node("command") {
node("command", NodeRegistration.STRUCTURAL) {
description = "Access to all example commands"
defaultValue = PermissionDefault.OP
node("reload") {
node("reload", NodeRegistration.PERMISSION) {
description = "Allows /example reload (permission example.command.reload)"
wildcard = true // include reload in example.command.*
}
// Link to a helper node defined elsewhere under the command branch:
child("helper")
// Link to a permission outside the current branch by using the absolute helper:
childAbsolute("tools.repair")
// Link to a permission outside the current branch (must be fully-qualified):
childAbsolute("example.tools.repair")
node("cooldown") {
node("cooldown", NodeRegistration.PERMISSION) {
description = "Allows /example cooldown tweaks"
wildcard = false // opt-out if you do not want example.command.* to include it
wildcard = true // opt in so example.command.* grants cooldown
}
}
node("command.helper") {
node("command.helper", NodeRegistration.PERMISSION) {
description = "Allows /example helper (referenced via child(\"helper\"))"
}
node("tools.repair") {
description = "Allows /example tools repair (linked with childAbsolute)"
node("tools.repair", NodeRegistration.PERMISSION) {
description = "Allows /example tools repair (linked with childAbsolute(\"example.tools.repair\"))"
}
}
@@ -47,7 +50,7 @@ class ExamplePlugin : JavaPlugin() {
// The tree above materializes as permissions such as:
// example.command, example.command.reload, example.command.helper, example.command.cooldown,
// example.tools.repair,
// plus the auto-generated example.command.* wildcard that references every child (visible if you
// plus the auto-generated example.command.* wildcard (because reload/cooldown set wildcard = true)
// export to plugin.yml or inspect Bukkit's /permissions output).
configureRuntimePermissions()
@@ -61,15 +64,15 @@ class ExamplePlugin : JavaPlugin() {
// Later in runtime you can mutate the previously applied structure without rebuilding it:
permits.edit("example") {
// Update an existing node and link it to new children
node("command") {
node("command", NodeRegistration.STRUCTURAL) {
description = "Admins for every command path"
node("debug") {
node("debug", NodeRegistration.PERMISSION) {
description = "Allows /example debug"
defaultValue = PermissionDefault.OP
}
}
// Remove deprecated permissions entirely
remove("command.cooldown")
removeNode("command.cooldown")
}
}
}
@@ -82,45 +85,49 @@ mutate it procedurally, and then apply the result:
```kotlin
val baseTree = permissionTree("example") {
node("command") { node("reload") }
node("command", NodeRegistration.STRUCTURAL) {
node("reload", NodeRegistration.PERMISSION)
}
}
val mutable = MutablePermissionTree.from(baseTree)
mutable.node("command") {
node("debug") {
mutable.node("command", NodeRegistration.STRUCTURAL) {
node("debug", NodeRegistration.PERMISSION) {
description = "Allows /example debug"
defaultValue = PermissionDefault.OP
}
child("helper", value = false) // unlink helper if present
}
mutable.remove("command.legacy")
mutable.removeNode("command.legacy")
permits.applyTree(mutable.build())
```
The mutable API mirrors the DSL (`node`, `child`, `childAbsolute`, `remove`, `removeChild`, etc.) so you can
The mutable API mirrors the DSL (`node`, `child`, `childAbsolute`, `removeNode`, `renameNode`, etc.) so you can
stage edits procedurally before ever touching `MutationSession`.
### Concepts
- **Permission tree** immutable graph of `PermissionNode`s. Nodes specify description, default value,
boolean children map, optional tags, and the `wildcard` flag (enabled by default) that makes the library
create/update `namespace.path.*` aggregate permissions automatically.
- **DSL** `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles).
- **Nested nodes** `node("command") { node("reload") { ... } }` automatically produces
boolean children map, optional tags, and the `wildcard` flag (disabled by default) that, when enabled,
makes the library create/update `namespace.path.*` aggregate permissions automatically.
- **DSL** `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles). Every `node("command", NodeRegistration.PERMISSION)` (or `.STRUCTURAL`) is relative to that namespace, so you never include the namespace manually at the root.
- **Nested nodes** `node("command", NodeRegistration.STRUCTURAL) { node("reload", NodeRegistration.PERMISSION) { ... } }` automatically produces
`namespace.command` and `namespace.command.reload` plus wires the parent/child relationship so you don't
have to repeat the full id.
- **Flexible references** `child("reload")`, `node("command") { node("reload") { ... } }`, or
even `node("command.reload")` inside `edit` all resolve to the same node; children are auto-created on
- **Flexible references** `child("reload")`, `node("command", NodeRegistration.STRUCTURAL) { node("reload", NodeRegistration.PERMISSION) { ... } }`, or
even `node("command.reload", NodeRegistration.PERMISSION)` inside `edit` all resolve to the same node; children are auto-created on
first reference but you can demand explicit nodes by adding a `node` block later, and you can unlink
specific children via `node("command") { removeChild("cooldown") }` without deleting the underlying node.
Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` lets you
point at any fully-qualified permission ID within the namespace.
specific children via `node("command", NodeRegistration.STRUCTURAL) { removeNode("cooldown") }` and the entire subtree disappears.
- **Node registration** `NodeRegistration.PERMISSION` materializes the node as a Bukkit permission, while `NodeRegistration.STRUCTURAL` keeps it purely for grouping (still participates in wildcard aggregation) so you can avoid ambiguous intermediate permissions like `hoge.command`.
Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` now
expects a fully-qualified permission ID (e.g., `example.tools.repair`) so you can also point at nodes in
other namespaces.
- **PermissionRegistry** calculates a diff between snapshots and performs the minimum additions,
removals, or updates via Bukkit's `PluginManager`.
- **Wildcards** with `wildcard = true`, the generated `namespace.command.*` child always exists and stays
in sync so granting `example.command.*` automatically grants every nested node; set it to `false` to opt
out for specific permissions.
- **Wildcards** opt-in via `wildcard = true` to have the generated `namespace.command.*` child kept in sync,
so granting `example.command.*` automatically grants every nested node you marked; leave it `false` (default)
to keep nodes out of the wildcard.
- **Mutable edits** `permits.edit { ... }` clones the currently registered tree, lets you mutate nodes
imperatively, re-validates, and only pushes the structural diff to Bukkit.
- **AttachmentSynchronizer** manages identity-based `PermissionAttachment`s and exposes high-level
@@ -10,38 +10,38 @@ class MutablePermissionTree internal constructor(
private val namespace: String,
private val drafts: MutableMap<PermissionId, PermissionNodeDraft>
) {
fun node(id: String, block: MutableNode.() -> Unit = {}): MutableNode {
val permissionId = PermissionId.of(qualify(id))
fun node(id: String, registration: NodeRegistration, block: MutableNode.() -> Unit = {}): MutableNode {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("$namespace.${id.lowercase()}")
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
draft.registration = registration
return MutableNode(permissionId, draft).apply(block)
}
fun remove(id: String) {
val permissionId = PermissionId.of(qualify(id))
drafts.remove(permissionId)
drafts.values.forEach { it.children.remove(permissionId) }
fun removeNode(id: String) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("$namespace.${id.lowercase()}")
removeSubtree(permissionId)
}
fun contains(id: String): Boolean = drafts.containsKey(PermissionId.of(qualify(id)))
fun renameNode(oldId: String, newId: String) {
require(oldId.isNotBlank()) { "Old node id must not be blank." }
require(newId.isNotBlank()) { "New node id must not be blank." }
val oldPermissionId = PermissionId.of("$namespace.${oldId.lowercase()}")
val newPermissionId = PermissionId.of("$namespace.${newId.lowercase()}")
renameSubtree(oldPermissionId, newPermissionId)
}
fun contains(id: String): Boolean {
require(id.isNotBlank()) { "Node id must not be blank." }
return drafts.containsKey(PermissionId.of("$namespace.${id.lowercase()}"))
}
fun build(): PermissionTree {
val nodes = drafts.mapValues { it.value.toNode() }
return PermissionTree.from(namespace, nodes)
}
private fun qualify(id: String): String =
if (id.startsWith(namespace)) id else "$namespace.$id"
private fun qualifyRelative(parent: PermissionId, childSegment: String): String {
val normalized = childSegment.trim().lowercase().trimStart('.')
require(normalized.isNotEmpty()) { "Child id must not be blank." }
return if (normalized.startsWith(namespace)) {
normalized
} else {
"${parent.value}.$normalized"
}
}
inner class MutableNode internal constructor(
val id: PermissionId,
private val draft: PermissionNodeDraft
@@ -64,6 +64,12 @@ class MutablePermissionTree internal constructor(
draft.wildcard = value
}
var registration: NodeRegistration
get() = draft.registration
set(value) {
draft.registration = value
}
val tags: MutableSet<String>
get() = draft.tags
@@ -74,24 +80,103 @@ class MutablePermissionTree internal constructor(
}
fun child(id: String, value: Boolean = true) {
val permissionId = PermissionId.of(qualifyRelative(this.id, id))
require(id.isNotBlank()) { "Child id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
draft.children[permissionId] = value
}
fun childAbsolute(id: String, value: Boolean = true) {
val permissionId = PermissionId.of(qualify(id))
val permissionId = PermissionId.of(id.lowercase())
draft.children[permissionId] = value
}
fun node(id: String, block: MutableNode.() -> Unit = {}) {
val permissionId = PermissionId.of(qualifyRelative(this.id, id))
fun node(id: String, registration: NodeRegistration, block: MutableNode.() -> Unit = {}) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
draft.children[permissionId] = true
val childDraft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
childDraft.registration = registration
MutableNode(permissionId, childDraft).apply(block)
}
fun removeChild(id: String) {
draft.children.remove(PermissionId.of(qualify(id)))
fun removeNode(id: String) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}")
removeSubtree(permissionId)
}
fun renameNode(oldId: String, newId: String) {
require(oldId.isNotBlank()) { "Old node id must not be blank." }
require(newId.isNotBlank()) { "New node id must not be blank." }
val oldPermissionId = PermissionId.of("${this.id.value}.${oldId.lowercase()}")
val newPermissionId = PermissionId.of("${this.id.value}.${newId.lowercase()}")
renameSubtree(oldPermissionId, newPermissionId)
}
}
private fun removeSubtree(rootId: PermissionId) {
val prefix = "${rootId.value}."
val targets = drafts.keys.filter { key ->
key.value == rootId.value || key.value.startsWith(prefix)
}.toSet()
if (targets.isEmpty()) return
targets.forEach { drafts.remove(it) }
drafts.values.forEach { draft ->
val iterator = draft.children.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.key in targets) {
iterator.remove()
}
}
}
}
private fun renameSubtree(oldRoot: PermissionId, newRoot: PermissionId) {
if (oldRoot == newRoot) return
val prefix = "${oldRoot.value}."
val affected = drafts.keys.filter { key ->
key.value == oldRoot.value || key.value.startsWith(prefix)
}
if (affected.isEmpty()) return
val affectedSet = affected.toSet()
val mapping = linkedMapOf<PermissionId, PermissionId>()
affected.forEach { oldId ->
val suffix = oldId.value.removePrefix(oldRoot.value)
val newValue = newRoot.value + suffix
val newId = PermissionId.of(newValue)
if (!affectedSet.contains(newId) && drafts.containsKey(newId)) {
error("Cannot rename '${oldRoot.value}' to '${newRoot.value}' because '$newValue' already exists.")
}
mapping[oldId] = newId
}
mapping.forEach { (oldId, newId) ->
val draft = drafts.remove(oldId) ?: return@forEach
val newDraft = PermissionNodeDraft(
id = newId,
description = draft.description,
defaultValue = draft.defaultValue,
children = draft.children.toMutableMap(),
tags = draft.tags.toMutableSet(),
wildcard = draft.wildcard,
registration = draft.registration
)
drafts[newId] = newDraft
}
drafts.values.forEach { draft ->
val pending = mutableListOf<Pair<PermissionId, Boolean>>()
val iterator = draft.children.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
val replacement = mapping[entry.key]
if (replacement != null) {
iterator.remove()
pending += replacement to entry.value
}
}
pending.forEach { (id, value) -> draft.children[id] = value }
}
}
@@ -0,0 +1,10 @@
package net.hareworks.permits_lib.domain
/**
* Declares whether a DSL node should materialize as an actual Bukkit permission or behave as a
* purely structural placeholder (still participates in relationships/wildcards).
*/
enum class NodeRegistration(val registersPermission: Boolean) {
PERMISSION(true),
STRUCTURAL(false)
}
@@ -14,7 +14,8 @@ data class PermissionNode(
val defaultValue: PermissionDefault = PermissionDefault.FALSE,
val children: Map<PermissionId, Boolean> = emptyMap(),
val tags: Set<String> = emptySet(),
val wildcard: Boolean = true
val wildcard: Boolean = false,
val registration: NodeRegistration = NodeRegistration.PERMISSION
) {
init {
require(children.keys.none { it == id }) { "Permission node cannot be a child of itself." }
@@ -8,7 +8,8 @@ internal data class PermissionNodeDraft(
var defaultValue: PermissionDefault = PermissionDefault.FALSE,
val children: MutableMap<PermissionId, Boolean> = linkedMapOf(),
val tags: MutableSet<String> = linkedSetOf(),
var wildcard: Boolean = true
var wildcard: Boolean = false,
var registration: NodeRegistration = NodeRegistration.PERMISSION
) {
fun toNode(): PermissionNode =
PermissionNode(
@@ -17,7 +18,8 @@ internal data class PermissionNodeDraft(
defaultValue = defaultValue,
children = children.toMap(),
tags = tags.toSet(),
wildcard = wildcard
wildcard = wildcard,
registration = registration
)
companion object {
@@ -28,7 +30,8 @@ internal data class PermissionNodeDraft(
defaultValue = node.defaultValue,
children = node.children.toMutableMap(),
tags = node.tags.toMutableSet(),
wildcard = node.wildcard
wildcard = node.wildcard,
registration = node.registration
)
}
}
@@ -15,7 +15,8 @@ class PermissionTree internal constructor(
operator fun get(id: PermissionId): PermissionNode? = nodes[id]
fun toSnapshot(): TreeSnapshot = TreeSnapshot(nodes)
fun toSnapshot(): TreeSnapshot =
TreeSnapshot(nodes.filterValues { it.registration.registersPermission })
companion object {
fun empty(namespace: String): PermissionTree = PermissionTree(namespace, emptyMap())
@@ -29,6 +29,7 @@ class TreeSnapshot internal constructor(
digest.update(tag.toByteArray())
}
digest.update(if (node.wildcard) 1 else 0)
digest.update(node.registration.name.toByteArray())
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
@@ -1,5 +1,6 @@
package net.hareworks.permits_lib.dsl
import net.hareworks.permits_lib.domain.NodeRegistration
import net.hareworks.permits_lib.domain.PermissionId
import net.hareworks.permits_lib.domain.PermissionNodeDraft
import org.bukkit.permissions.PermissionDefault
@@ -27,6 +28,12 @@ class PermissionNodeBuilder internal constructor(
draft.wildcard = value
}
var registration: NodeRegistration
get() = draft.registration
set(value) {
draft.registration = value
}
fun tag(value: String) {
if (value.isNotBlank()) {
draft.tags += value.trim()
@@ -41,6 +48,9 @@ class PermissionNodeBuilder internal constructor(
treeBuilder.childAbsolute(draft, id.value, value)
}
/**
* Links to a fully-qualified permission id. The provided [id] must already include its namespace.
*/
fun childAbsolute(id: String, value: Boolean = true) {
treeBuilder.childAbsolute(draft, id, value)
}
@@ -49,12 +59,16 @@ class PermissionNodeBuilder internal constructor(
* Declares a nested node whose id is derived from the current node:
*
* ```
* node("command") {
* node("reload") { ... } // -> namespace.command.reload
* node("command", NodeRegistration.STRUCTURAL) {
* node("reload", NodeRegistration.PERMISSION) { ... } // -> namespace.command.reload
* }
* ```
*/
fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) {
treeBuilder.nestedNode(draft, id, block)
fun node(
id: String,
registration: NodeRegistration,
block: PermissionNodeBuilder.() -> Unit = {}
) {
treeBuilder.nestedNode(draft, id, registration, block)
}
}
@@ -1,5 +1,6 @@
package net.hareworks.permits_lib.dsl
import net.hareworks.permits_lib.domain.NodeRegistration
import net.hareworks.permits_lib.domain.PermissionId
import net.hareworks.permits_lib.domain.PermissionNodeDraft
import net.hareworks.permits_lib.domain.PermissionTree
@@ -10,22 +11,31 @@ class PermissionTreeBuilder internal constructor(
) {
private val drafts = linkedMapOf<PermissionId, PermissionNodeDraft>()
fun node(id: String, block: PermissionNodeBuilder.() -> Unit = {}) {
val permissionId = PermissionId.of(qualify(id))
fun node(
id: String,
registration: NodeRegistration,
block: PermissionNodeBuilder.() -> Unit = {}
) {
require(id.isNotBlank()) { "Node id must not be blank." }
val permissionId = PermissionId.of("$namespace.${id.lowercase()}")
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
draft.registration = registration
PermissionNodeBuilder(this, draft).apply(block)
}
internal fun qualify(id: String): String =
if (id.startsWith(namespace)) id else "$namespace.$id"
internal fun child(
parent: PermissionNodeDraft,
id: String,
value: Boolean,
relative: Boolean
) {
val permissionId = PermissionId.of(if (relative) qualifyRelative(parent.id, id) else qualify(id))
val target = if (relative) {
require(id.isNotBlank()) { "Child id must not be blank." }
"${parent.id.value}.${id.lowercase()}"
} else {
normalizeAbsolute(id)
}
val permissionId = PermissionId.of(target)
parent.children[permissionId] = value
}
@@ -44,25 +54,23 @@ class PermissionTreeBuilder internal constructor(
internal fun nestedNode(
parent: PermissionNodeDraft,
id: String,
registration: NodeRegistration,
block: PermissionNodeBuilder.() -> Unit
) {
val permissionId = PermissionId.of(qualifyRelative(parent.id, id))
parent.children[permissionId] = true
val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) }
require(id.isNotBlank()) { "Nested node id must not be blank." }
val composedId = PermissionId.of("${parent.id.value}.${id.lowercase()}")
parent.children[composedId] = true
val draft = drafts.getOrPut(composedId) { PermissionNodeDraft(composedId) }
draft.registration = registration
PermissionNodeBuilder(this, draft).apply(block)
}
fun build(): PermissionTree =
PermissionTree.from(namespace, drafts.mapValues { it.value.toNode() })
private fun qualifyRelative(parent: PermissionId, childSegment: String): String {
val normalized = childSegment.trim().lowercase().trimStart('.')
require(normalized.isNotEmpty()) { "Child id must not be blank." }
return if (normalized.startsWith(namespace)) {
normalized
} else {
"${parent.value}.$normalized"
}
private fun normalizeAbsolute(id: String): String {
require(id.isNotBlank()) { "Absolute permission id must not be blank." }
return id.lowercase()
}
}