Compare commits

...
Author SHA1 Message Date
Ben d845237f1f feat(docs): export registered Bukkit command reference automatically 2026-11-09 17:25:04 -06:00
4 changed files with 365 additions and 0 deletions

No files matched your search

@@ -2414,6 +2414,17 @@ public class VotingPluginMain extends AdvancedCorePlugin {
}
}
// Export documentation only after both handler lists and the player command label are ready.
// A filesystem problem must never stop registration or normal voting.
try {
if (com.bencodez.votingplugin.commands.CommandDocumentationExporter.write(getDataFolder().toPath(),
getVoteCommandPrefix(), getVoteCommand(), getAdminVoteCommand())) {
getLogger().info("Updated generated command documentation in GeneratedDocs/");
}
} catch (IOException | RuntimeException error) {
getLogger().warning("Unable to write generated command documentation: " + error.getMessage());
}
plugin.debug("Loaded Commands");
}
@@ -0,0 +1,207 @@
package com.bencodez.votingplugin.commands;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
/**
* Exports the actual Bukkit command-handler registrations as a deterministic
* machine-readable manifest and an administrator-friendly Markdown reference.
* It never executes a command or accesses user data.
*/
public final class CommandDocumentationExporter {
public static final String DIRECTORY = "GeneratedDocs";
public static final String JSON_NAME = "commands.json";
public static final String MARKDOWN_NAME = "commands.md";
private CommandDocumentationExporter() {
}
public static final class CommandRow {
private final String root;
private final String syntax;
private final String description;
private final String permissionExpression;
private final List<String> permissions;
private final List<String> allTargetPermissions;
private final boolean multiplePermissionChecks;
private final String sender;
private CommandRow(String root, String syntax, String description, String permissionExpression,
List<String> permissions, List<String> allTargetPermissions,
boolean multiplePermissionChecks, String sender) {
this.root = root;
this.syntax = syntax;
this.description = description;
this.permissionExpression = permissionExpression;
this.permissions = List.copyOf(permissions);
this.allTargetPermissions = List.copyOf(allTargetPermissions);
this.multiplePermissionChecks = multiplePermissionChecks;
this.sender = sender;
}
}
/** Capture only loaded handlers, including conditionally registered handlers. */
public static List<CommandRow> capture(String playerRoot, List<? extends CommandHandler> playerHandlers,
List<? extends CommandHandler> adminHandlers) {
List<CommandRow> result = new ArrayList<>();
result.addAll(captureGroup(playerRoot, playerHandlers));
result.addAll(captureGroup("/av", adminHandlers));
return List.copyOf(result);
}
private static List<CommandRow> captureGroup(String root, List<? extends CommandHandler> handlers) {
List<CommandRow> result = new ArrayList<>();
if (handlers == null) return result;
for (CommandHandler handler : handlers) {
if (handler == null) continue;
String syntax = safe(handler.getHelpLineCommand(root));
if (syntax.isEmpty()) continue;
String permission = safe(handler.getPerm());
List<String> allTarget = List.of();
if (handler instanceof PlayerCommandHandler) {
List<String> additional = ((PlayerCommandHandler) handler).getAdditionalPermissions();
if (additional != null) allTarget = new ArrayList<>(new LinkedHashSet<>(additional));
}
String sender = handler.isForceConsole() ? "console" :
handler.isAllowConsole() ? "player or console" : "player";
result.add(new CommandRow(root, syntax, safe(handler.getHelpMessage()), permission,
splitPermissions(permission), allTarget, handler.isAllowMultiplePermissions(), sender));
}
result.sort(Comparator.comparing((CommandRow row) -> row.syntax, String.CASE_INSENSITIVE_ORDER)
.thenComparing(row -> row.permissionExpression, String.CASE_INSENSITIVE_ORDER));
return result;
}
private static String safe(String text) {
return text == null ? "" : text.trim();
}
private static List<String> splitPermissions(String expression) {
Set<String> unique = new LinkedHashSet<>();
for (String part : expression.split("\\|")) {
String value = part.trim();
if (!value.isEmpty()) unique.add(value);
}
return new ArrayList<>(unique);
}
/** Stable JSON for future documentation bots; it contains no credentials or player data. */
public static String toJson(List<CommandRow> rows) {
JsonObject doc = new JsonObject();
doc.addProperty("schemaVersion", 1);
doc.addProperty("scope", "VotingPlugin Bukkit registered handlers");
JsonArray commands = new JsonArray();
for (CommandRow row : rows) {
JsonObject object = new JsonObject();
object.addProperty("root", row.root);
object.addProperty("syntax", row.syntax);
object.addProperty("description", row.description);
object.addProperty("permissionExpression", row.permissionExpression);
object.add("permissions", asArray(row.permissions));
object.add("allTargetPermissions", asArray(row.allTargetPermissions));
object.addProperty("multiplePermissionChecks", row.multiplePermissionChecks);
object.addProperty("sender", row.sender);
commands.add(object);
}
doc.add("commands", commands);
return new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create().toJson(doc) + "\n";
}
private static JsonArray asArray(List<String> values) {
JsonArray array = new JsonArray();
for (String value : values) array.add(value);
return array;
}
/** Reader-friendly generated snapshot for reviewing changes without running code. */
public static String toMarkdown(List<CommandRow> rows) {
StringBuilder markdown = new StringBuilder();
markdown.append("# VotingPlugin Bukkit commands (generated)\n\n");
markdown.append("This page is generated from the command handlers loaded by the running server. ");
markdown.append("It is not a canonical release reference: configured command labels and ");
markdown.append("conditionally loaded commands may differ between servers.\n\n");
markdown.append("Permission expressions separated by | may provide alternatives when ");
markdown.append("multiple-permission checks are enabled. Extra all-target permissions ");
markdown.append("apply only to supported bulk player commands.\n\n");
markdown.append("This export does not cover separate Velocity/BungeeCord proxy commands ");
markdown.append("or standalone plugin.yml convenience aliases.\n\n");
Map<String, List<CommandRow>> groups = new LinkedHashMap<>();
for (CommandRow row : rows) groups.computeIfAbsent(row.root, key -> new ArrayList<>()).add(row);
for (Map.Entry<String, List<CommandRow>> group : groups.entrySet()) {
markdown.append("## ").append(escapeCell(group.getKey())).append("\n\n");
markdown.append("| Command | Description | Permission expression | All-target permissions | Sender |\n");
markdown.append("|---|---|---|---|---|\n");
for (CommandRow row : group.getValue()) {
markdown.append("| ").append(code(row.syntax));
markdown.append(" | ").append(escapeCell(row.description));
markdown.append(" | ").append(code(row.permissionExpression.isEmpty() ? "(none)" : row.permissionExpression));
markdown.append(" | ").append(row.allTargetPermissions.isEmpty() ? "—" :
code(String.join(", ", row.allTargetPermissions)));
markdown.append(" | ").append(escapeCell(row.sender)).append(" |\n");
}
markdown.append("\n");
}
return markdown.toString();
}
private static String code(String value) {
return String.valueOf((char) 96) + escapeCell(value) + (char) 96;
}
private static String escapeCell(String value) {
return safe(value).replace("\r", " ").replace("\n", " ").replace("|", "\\|")
.replace(String.valueOf((char) 96), "&#96;");
}
/**
* Write only when content changes, using temporary files and atomic moves
* when supported. No writes occur outside the plugin data folder.
*/
public static boolean write(Path pluginDataFolder, String playerRoot,
List<? extends CommandHandler> playerHandlers, List<? extends CommandHandler> adminHandlers)
throws IOException {
List<CommandRow> rows = capture(playerRoot, playerHandlers, adminHandlers);
Path directory = pluginDataFolder.resolve(DIRECTORY);
Files.createDirectories(directory);
boolean jsonChanged = writeIfChanged(directory.resolve(JSON_NAME), toJson(rows));
boolean markdownChanged = writeIfChanged(directory.resolve(MARKDOWN_NAME), toMarkdown(rows));
return jsonChanged || markdownChanged;
}
private static boolean writeIfChanged(Path target, String content) throws IOException {
if (Files.isSymbolicLink(target)) throw new IOException("Refusing to overwrite symbolic link: " + target);
if (Files.isRegularFile(target) && content.equals(Files.readString(target, StandardCharsets.UTF_8))) {
return false;
}
Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".tmp");
try {
Files.writeString(temporary, content, StandardCharsets.UTF_8);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
return true;
}
}
@@ -0,0 +1,103 @@
package com.bencodez.votingplugin.tests.commands;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
import com.bencodez.votingplugin.commands.CommandDocumentationExporter;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
class CommandDocumentationExporterTest {
@TempDir
Path temp;
@Test
void capturesNewCommandsWithoutAHandMaintainedList() {
CommandHandler shop = handler("/vote", "/vote Shop", "VotingPlugin.Commands.Vote.Shop|VotingPlugin.Player",
"Open shop");
CommandHandler points = handler("/vote", "/vote Points", "VotingPlugin.Commands.Vote.Points|VotingPlugin.Player",
"View points");
CommandHandler admin = handler("/av", "/av Reload", "VotingPlugin.Commands.AdminVote.Reload|VotingPlugin.Admin",
"Reload plugin");
var rows = CommandDocumentationExporter.capture("/vote", List.of(shop, points), List.of(admin));
JsonObject json = JsonParser.parseString(CommandDocumentationExporter.toJson(rows)).getAsJsonObject();
assertEquals(1, json.get("schemaVersion").getAsInt());
JsonArray commands = json.getAsJsonArray("commands");
assertEquals(3, commands.size());
assertEquals("/vote Points", commands.get(0).getAsJsonObject().get("syntax").getAsString());
assertEquals("/vote Shop", commands.get(1).getAsJsonObject().get("syntax").getAsString());
assertEquals("/av Reload", commands.get(2).getAsJsonObject().get("syntax").getAsString());
assertEquals(2, commands.get(0).getAsJsonObject().getAsJsonArray("permissions").size());
}
@Test
void capturesBulkPermissionsAndSenderRestrictions() {
PlayerCommandHandler bulk = mock(PlayerCommandHandler.class);
when(bulk.getHelpLineCommand("/av")).thenReturn("/av User (player) AddPoints (number)");
when(bulk.getPerm()).thenReturn("VotingPlugin.Commands.AdminVote.AddPoints|VotingPlugin.Admin");
when(bulk.getHelpMessage()).thenReturn("Add points");
when(bulk.getAdditionalPermissions()).thenReturn(List.of("VotingPlugin.Commands.AdminVote.AddPoints.All"));
when(bulk.isAllowMultiplePermissions()).thenReturn(true);
when(bulk.isForceConsole()).thenReturn(true);
var rows = CommandDocumentationExporter.capture("/vote", List.of(), List.of(bulk));
JsonObject entry = JsonParser.parseString(CommandDocumentationExporter.toJson(rows))
.getAsJsonObject().getAsJsonArray("commands").get(0).getAsJsonObject();
assertEquals("console", entry.get("sender").getAsString());
assertEquals("VotingPlugin.Commands.AdminVote.AddPoints.All",
entry.getAsJsonArray("allTargetPermissions").get(0).getAsString());
assertTrue(entry.get("multiplePermissionChecks").getAsBoolean());
}
@Test
void rendersMarkdownSafelyAndRespectsConfiguredPlayerRoot() {
CommandHandler handle = handler("/customvote", "/customvote Help/?",
"VotingPlugin.Player|VotingPlugin.Commands.Vote.Help", "Help | usage\nwith newline");
String markdown = CommandDocumentationExporter.toMarkdown(
CommandDocumentationExporter.capture("/customvote", List.of(handle), List.of()));
assertTrue(markdown.contains("## /customvote"));
assertTrue(markdown.contains("Help \\| usage with newline"));
assertTrue(markdown.contains("VotingPlugin.Player\\|VotingPlugin.Commands.Vote.Help"));
assertFalse(markdown.contains("usage\nwith newline"));
}
@Test
void writesOnlyWhenOutputChanges() throws Exception {
CommandHandler original = handler("/vote", "/vote Shop", "VotingPlugin.Player", "Shop");
assertTrue(CommandDocumentationExporter.write(temp, "/vote", List.of(original), List.of()));
Path folder = temp.resolve(CommandDocumentationExporter.DIRECTORY);
Path json = folder.resolve(CommandDocumentationExporter.JSON_NAME);
Path markdown = folder.resolve(CommandDocumentationExporter.MARKDOWN_NAME);
assertTrue(Files.readString(json, StandardCharsets.UTF_8).contains("/vote Shop"));
assertTrue(Files.readString(markdown, StandardCharsets.UTF_8).contains("/vote Shop"));
assertFalse(CommandDocumentationExporter.write(temp, "/vote", List.of(original), List.of()));
CommandHandler added = handler("/vote", "/vote Next", "VotingPlugin.Player", "Next vote");
assertTrue(CommandDocumentationExporter.write(temp, "/vote", List.of(original, added), List.of()));
assertEquals(2, JsonParser.parseString(Files.readString(json))
.getAsJsonObject().getAsJsonArray("commands").size());
}
private static CommandHandler handler(String root, String syntax, String permission, String description) {
CommandHandler handle = mock(CommandHandler.class);
when(handle.getHelpLineCommand(root)).thenReturn(syntax);
when(handle.getPerm()).thenReturn(permission);
when(handle.getHelpMessage()).thenReturn(description);
when(handle.isAllowConsole()).thenReturn(true);
when(handle.isAllowMultiplePermissions()).thenReturn(true);
return handle;
}
}
+44
View File
@@ -0,0 +1,44 @@
# Automatic command documentation export
VotingPlugin writes a fresh reference for its **registered Bukkit command handlers** whenever
the plugin finishes registering commands on startup. There is no new runtime command or
configuration switch. The export is diagnostic/documentation output only: failure to write
it must not prevent voting.
Files written under the plugin data folder:
- `GeneratedDocs/commands.json`: schema version 1, a stable manifest with syntax, help text,
permission expression, parsed permission names, bulk/all-target permission metadata,
console/player eligibility, and the command root.
- `GeneratedDocs/commands.md`: a human-readable table of the same information.
The exporter reads the registered `/vote` (including configured alternate name) and
`/av` `CommandHandler` lists. Newly registered handlers automatically appear after the
next successful startup. Output is stable and files are not rewritten when unchanged.
It never runs the commands or reads player data, config secrets, tokens, or databases.
## Scope and cautions
- This is a **runtime snapshot**, not a canonical release manifest. Enabled/configured features
and debug-only commands can differ between servers.
- This initial implementation intentionally does **not** enumerate the separate
BungeeCord/Velocity proxy dispatcher, plugin.yml convenience aliases, or commands from
other plugins.
- A registered command's `permissionExpression` may include multiple permission nodes
separated by `|`. Its interpretation can depend on multiple-permission-check settings.
- `allTargetPermissions` are extra permission nodes required for supported bulk
player selectors; do not confuse them with ordinary command alternatives.
- The generated folder is for read-only inspection or export. Do not edit its contents.
## Updating the published wiki
Copy the JSON output from a controlled **development/test server** using the intended
configuration. A future documentation sync step can transform the JSON into the
`BenCodez/Wiki` command-reference page, compare it with the last published snapshot,
and open a focused PR when commands change.
**This PR does not yet connect a running server or CI job to the wiki.** The local export
is automatic; publishing to GitHub still requires an artifact transfer/sync workflow.
For a release-quality update, review command additions or removals, test runtime
availability, and confirm proxy commands separately before publishing.