From 40daa1ee5f6d2e2fbad870fdcc05f5f4779441b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergej=20Ko=C5=A1=C4=8Dejev?= Date: Tue, 13 Oct 2026 22:25:24 +0200 Subject: [PATCH 1/5] Show subcommands before options and group global options in help --- .../com/specificlanguages/mops/cli/Main.kt | 19 ++++- .../mops/cli/check/ModuleCheckCommand.kt | 1 + .../mops/cli/code/CodeHelpCommand.kt | 6 +- .../mops/cli/code/CodeRunCommand.kt | 1 + .../cli/diagnose/DiagnoseModuleCommand.kt | 1 + .../cli/diagnose/DiagnoseModulesCommand.kt | 1 + .../mops/cli/examples/ExampleTopics.kt | 4 +- .../mops/cli/examples/ExamplesCommand.kt | 6 +- .../mops/cli/explain/ExplainCommand.kt | 1 + .../mops/cli/explain/ExplainTopics.kt | 41 ++++++++-- .../mops/cli/find/FindInstancesCommand.kt | 1 + .../mops/cli/find/FindNodeByIdCommand.kt | 1 + .../mops/cli/find/FindRootByNameCommand.kt | 1 + .../mops/cli/find/FindUsagesCommand.kt | 1 + .../mops/cli/help/HelpLayout.kt | 82 +++++++++++++++++++ .../mops/cli/help/RecursiveHelpCommand.kt | 7 +- .../mops/cli/homes/WrapperCommand.kt | 1 + .../mops/cli/make/MakeOperations.kt | 1 + .../mops/cli/skill/SkillPathCommand.kt | 6 +- .../mops/cli/CommandHelpTest.kt | 48 +++++++++++ .../mops/cli/ExamplesCommandTest.kt | 8 ++ .../mops/cli/ExplainCommandTest.kt | 11 +++ 22 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt index 3ac91e17..39307184 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt @@ -22,8 +22,10 @@ import com.specificlanguages.mops.cli.diagnose.DiagnoseModulesCommand import com.specificlanguages.mops.cli.diagnose.DiagnoseOperations import com.specificlanguages.mops.cli.edit.EditOperations import com.specificlanguages.mops.cli.edit.ModelEditCommand +import com.specificlanguages.mops.cli.examples.ExampleTopics import com.specificlanguages.mops.cli.examples.ExamplesCommand import com.specificlanguages.mops.cli.explain.ExplainCommand +import com.specificlanguages.mops.cli.explain.ExplainTopics import com.specificlanguages.mops.cli.find.FindSubconceptsCommand import com.specificlanguages.mops.cli.find.FindInstancesCommand import com.specificlanguages.mops.cli.find.FindNodeByIdCommand @@ -33,6 +35,7 @@ import com.specificlanguages.mops.cli.find.FindUsagesCommand import com.specificlanguages.mops.cli.get.GetModuleCommand import com.specificlanguages.mops.cli.get.GetOperations import com.specificlanguages.mops.cli.get.ModelGetNodeCommand +import com.specificlanguages.mops.cli.help.HelpLayout import com.specificlanguages.mops.cli.help.RecursiveHelpCommand import com.specificlanguages.mops.cli.homes.WrapperCommand import com.specificlanguages.mops.cli.homes.mopsExecutablePath @@ -114,9 +117,10 @@ fun newCommandLine( root.addGroup("skill", SkillOperations()) { addLeaf("path", SkillPathCommand()) } - root.addLeaf("explain", ExplainCommand()) - root.addLeaf("examples", ExamplesCommand()) + root.addLeaf("explain", ExplainCommand()).footer(ExplainTopics.topicList()) + root.addLeaf("examples", ExamplesCommand()).footer(ExampleTopics.topicList()) root.addLeaf("help", RecursiveHelpCommand()) + HelpLayout.apply(root) return root.setExecutionExceptionHandler(PrintErrorAndExit) } @@ -128,8 +132,15 @@ private fun CommandLine.addGroup(name: String, command: Any, configure: CommandL addSubcommand(name, group) } -private fun CommandLine.addLeaf(name: String, command: Any) { - addSubcommand(name, command) +private fun CommandLine.addLeaf(name: String, command: Any): CommandLine { + val leaf = CommandLine(command) + addSubcommand(name, leaf) + return leaf +} + +/** Appends [text] to the command's help, separated from the preceding section by a blank line. */ +private fun CommandLine.footer(text: String) { + commandSpec.usageMessage().footer("", *text.trimEnd().lines().toTypedArray()) } object PrintErrorAndExit : CommandLine.IExecutionExceptionHandler { diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModuleCheckCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModuleCheckCommand.kt index bfe52535..e961b42c 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModuleCheckCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModuleCheckCommand.kt @@ -9,6 +9,7 @@ import picocli.CommandLine.Parameters @Command( name = "module", + header = ["Run MPS's full check over one or more project modules."], description = [ "Run MPS's full check over one or more project modules and their models. Exits 1 when any finding is an error.", ], diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeHelpCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeHelpCommand.kt index febb44d6..fa981bd5 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeHelpCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeHelpCommand.kt @@ -6,7 +6,11 @@ import picocli.CommandLine.Command import picocli.CommandLine.Option import picocli.CommandLine.Parameters -@Command(name = "help", description = ["Show the Code Mode extension reference from the selected project daemon."]) +@Command( + name = "help", + header = ["Show the Code Mode extension reference."], + description = ["Show the Code Mode extension reference from the selected project daemon."], +) class CodeHelpCommand(private val environment: CommandEnvironment) : CliCommand() { @Parameters(index = "0", arity = "0..1", paramLabel = "PATH") var path: String? = null diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeRunCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeRunCommand.kt index 3be609ac..18ddaa25 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeRunCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/code/CodeRunCommand.kt @@ -11,6 +11,7 @@ import picocli.CommandLine.Parameters @Command( name = "run", + header = ["Run a trusted Groovy program from FILE or stdin."], description = ["Run a trusted Groovy program. FILE and - both mean stdin when omitted or set to -."], ) class CodeRunCommand(private val environment: CommandEnvironment) : CliCommand() { diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModuleCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModuleCommand.kt index 512c4eaa..2334a6c3 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModuleCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModuleCommand.kt @@ -13,6 +13,7 @@ import picocli.CommandLine.Parameters @Command( name = "module", + header = ["Diagnose the load state of one module."], description = [ "Diagnose the load state of one module, addressed by name or serialized module reference.", "Exits 1 when the module failed to load (a blocking problem, including an absent module), 0 when it is loaded.", diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModulesCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModulesCommand.kt index b77e904e..72da215a 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModulesCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/diagnose/DiagnoseModulesCommand.kt @@ -13,6 +13,7 @@ import picocli.CommandLine.Option @Command( name = "project", + header = ["Report which project languages and Java-bearing modules loaded."], description = ["Report which project languages and Java-bearing modules loaded, and why each unloaded one failed."], ) class DiagnoseModulesCommand(private val environment: CommandEnvironment) : CliCommand() { diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExampleTopics.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExampleTopics.kt index 2a994a58..59d4521b 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExampleTopics.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExampleTopics.kt @@ -6,7 +6,9 @@ object ExampleTopics { "editing.references", "editing.java", "editing.json", "runtime", "validation", ) - fun index(): String = pageResource("README") + "\nTopics: ${topics.joinToString(", ")}\n" + fun index(): String = pageResource("README") + "\n" + topicList() + + fun topicList(): String = "Topics: ${topics.joinToString(", ")}\n" fun page(topic: String): String { if (topic == "all") return index() + topics.joinToString("\n", transform = ::pageResource) diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExamplesCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExamplesCommand.kt index a3c55f31..c3f2e7d7 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExamplesCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/examples/ExamplesCommand.kt @@ -4,7 +4,11 @@ import com.specificlanguages.mops.cli.common.CliCommand import picocli.CommandLine.Command import picocli.CommandLine.Parameters -@Command(name = "examples", description = ["Read bundled task recipes without starting MPS. Omit the topic to find where to start."]) +@Command( + name = "examples", + header = ["Read bundled task recipes without starting MPS."], + description = ["Read bundled task recipes without starting MPS. Omit the topic to find where to start."], +) class ExamplesCommand : CliCommand() { @Parameters(index = "0", arity = "0..1", paramLabel = "TOPIC", description = ["Topic such as editing, editing.java, or all."]) var topic: String? = null diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainCommand.kt index 46211c51..27e8cb5d 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainCommand.kt @@ -13,6 +13,7 @@ import picocli.CommandLine.Parameters */ @Command( name = "explain", + header = ["Explain mops notations such as node references and edit batches."], description = ["Explain mops notations (the textual formats mops exchanges with agents)."], ) class ExplainCommand : CliCommand() { diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainTopics.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainTopics.kt index 691b7681..91868161 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainTopics.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/explain/ExplainTopics.kt @@ -11,6 +11,8 @@ import com.specificlanguages.mops.protocol.levenshteinDistance * format. */ object ExplainTopics { + private const val LINE_WIDTH = 80 + private val topLevelTopics = listOf("code", "edit", "inline-subtree", "target", "position", "node-ref", "name-pattern", "scope") @@ -18,19 +20,42 @@ object ExplainTopics { private val allTopics: List = topLevelTopics + operationTopics - /** Renders the topic index: each top-level topic with a one-line summary. */ - fun index(): String { - val width = topLevelTopics.maxOf { it.length } + /** Renders the topic index: the [topicList] framed by what the pages are and how to open one. */ + fun index(): String = buildString { + appendLine("mops explain — reference for mops notations (the textual formats mops exchanges with agents).") + appendLine() + append(topicList()) + appendLine() + appendLine("Run `mops explain ` for a page; `mops explain edit` lists the edit operations.") + } + + /** Renders a `Topics:` block with each top-level topic and its summary, wrapped to [LINE_WIDTH] columns. */ + fun topicList(): String { + val nameWidth = topLevelTopics.maxOf { it.length } + val summaryIndent = " ".repeat(2 + nameWidth + 2) return buildString { - appendLine("mops explain — reference for mops notations (the textual formats mops exchanges with agents).") - appendLine() appendLine("Topics:") for (topic in topLevelTopics) { - appendLine(" ${topic.padEnd(width)} ${summaryOf(topic)}") + val summaryLines = wrap(summaryOf(topic), LINE_WIDTH - summaryIndent.length) + appendLine(" ${topic.padEnd(nameWidth)} ${summaryLines.first()}") + for (line in summaryLines.drop(1)) appendLine(summaryIndent + line) + } + } + } + + private fun wrap(text: String, width: Int): List { + val lines = mutableListOf() + var line = "" + for (word in text.split(' ')) { + if (line.isNotEmpty() && line.length + 1 + word.length > width) { + lines += line + line = word + } else { + line = if (line.isEmpty()) word else "$line $word" } - appendLine() - appendLine("Run `mops explain ` for a page; `mops explain edit` lists the edit operations.") } + lines += line + return lines } /** Returns the verbatim page for [path], or throws [UnknownTopicException] when it does not resolve. */ diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindInstancesCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindInstancesCommand.kt index ca7421e1..658ab7af 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindInstancesCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindInstancesCommand.kt @@ -16,6 +16,7 @@ import picocli.CommandLine.Parameters @Command( name = "instances", + header = ["Find instances of one MPS concept."], description = [ "Find instances of one MPS concept, optionally narrowed by `--named` and `--role` (which AND together with " + "each other and the scope). Searches editable project sources by default; append `in ` to " + diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindNodeByIdCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindNodeByIdCommand.kt index 217f28ab..a874a90f 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindNodeByIdCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindNodeByIdCommand.kt @@ -13,6 +13,7 @@ import picocli.CommandLine.Parameters @Command( name = "node-by-id", + header = ["Find every node with a given node ID."], description = [ "Find every node with a given node ID. A node ID is unique only within its model, so this reports one match " + "per model that holds it, each with its full node reference — never guessing one over another. The ID " + diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindRootByNameCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindRootByNameCommand.kt index 53180d20..4622fb63 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindRootByNameCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindRootByNameCommand.kt @@ -13,6 +13,7 @@ import picocli.CommandLine.Parameters @Command( name = "root-by-name", + header = ["Find root nodes by name pattern."], description = [ "Find root nodes by name using MPS Go-to-Node pattern matching. Searches editable project sources by " + "default; append `in ` to search a module, model, or the whole repository (`in /`). Only " + diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindUsagesCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindUsagesCommand.kt index b4dc4ee1..ec191dab 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindUsagesCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindUsagesCommand.kt @@ -15,6 +15,7 @@ import picocli.CommandLine.Parameters @Command( name = "usages", + header = ["Find references to one MPS node."], description = [ "Find references to one MPS node. Searches editable project sources by default; append " + "`in ` to search a module, model, or subtree exhaustively, or `in /` for the whole " + diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt new file mode 100644 index 00000000..052f4a37 --- /dev/null +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt @@ -0,0 +1,82 @@ +package com.specificlanguages.mops.cli.help + +import picocli.CommandLine +import picocli.CommandLine.Help +import picocli.CommandLine.Help.Ansi +import picocli.CommandLine.Help.ColorScheme +import picocli.CommandLine.IHelpSectionRenderer +import picocli.CommandLine.Model.ArgSpec +import picocli.CommandLine.Model.CommandSpec +import picocli.CommandLine.Model.OptionSpec +import picocli.CommandLine.Model.UsageMessageSpec +import picocli.CommandLine.ScopeType + +/** + * Help layout shared by every mops command: subcommands are listed right after the description so they survive + * `--help | head`, and the options every command accepts (those declared with [ScopeType.INHERIT] on the root command) + * are listed once at the end, under `Global options:`, instead of among the command's own options and in the synopsis. + */ +object HelpLayout { + private const val SECTION_KEY_GLOBAL_OPTION_LIST_HEADING = "globalOptionListHeading" + private const val SECTION_KEY_GLOBAL_OPTION_LIST = "globalOptionList" + + private val sectionKeys = listOf( + UsageMessageSpec.SECTION_KEY_SYNOPSIS_HEADING, + UsageMessageSpec.SECTION_KEY_SYNOPSIS, + UsageMessageSpec.SECTION_KEY_DESCRIPTION_HEADING, + UsageMessageSpec.SECTION_KEY_DESCRIPTION, + UsageMessageSpec.SECTION_KEY_COMMAND_LIST_HEADING, + UsageMessageSpec.SECTION_KEY_COMMAND_LIST, + UsageMessageSpec.SECTION_KEY_PARAMETER_LIST_HEADING, + UsageMessageSpec.SECTION_KEY_PARAMETER_LIST, + UsageMessageSpec.SECTION_KEY_OPTION_LIST_HEADING, + UsageMessageSpec.SECTION_KEY_OPTION_LIST, + UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST_HEADING, + UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST, + SECTION_KEY_GLOBAL_OPTION_LIST_HEADING, + SECTION_KEY_GLOBAL_OPTION_LIST, + UsageMessageSpec.SECTION_KEY_FOOTER_HEADING, + UsageMessageSpec.SECTION_KEY_FOOTER, + ) + + /** Applies the layout to [root] and every subcommand registered so far. */ + fun apply(root: CommandLine) { + val sections = root.helpSectionMap.toMutableMap() + sections[UsageMessageSpec.SECTION_KEY_PARAMETER_LIST_HEADING] = heading("Parameters:%n") { help -> + help.commandSpec().positionalParameters().any { !it.hidden() } + } + sections[UsageMessageSpec.SECTION_KEY_OPTION_LIST_HEADING] = heading("Options:%n") { help -> + help.commandOptions().isNotEmpty() + } + sections[UsageMessageSpec.SECTION_KEY_OPTION_LIST] = + IHelpSectionRenderer { help -> help.optionListExcludingGroups(help.commandOptions()) } + sections[SECTION_KEY_GLOBAL_OPTION_LIST_HEADING] = heading("Global options:%n") { help -> + help.globalOptions().isNotEmpty() + } + sections[SECTION_KEY_GLOBAL_OPTION_LIST] = + IHelpSectionRenderer { help -> help.optionListExcludingGroups(help.globalOptions()) } + root.setHelpSectionMap(sections) + root.setHelpSectionKeys(sectionKeys) + root.setHelpFactory { spec, colorScheme -> SynopsisWithoutGlobalOptions(spec, colorScheme) } + } + + private class SynopsisWithoutGlobalOptions(spec: CommandSpec, colorScheme: ColorScheme) : Help(spec, colorScheme) { + override fun createDetailedSynopsisOptionsText( + done: Collection, + optionSort: Comparator?, + clusterBooleanOptions: Boolean, + ): Ansi.Text = createDetailedSynopsisOptionsText(done, commandOptions(), optionSort, clusterBooleanOptions) + } + + private fun heading(text: String, applies: (Help) -> Boolean) = + IHelpSectionRenderer { help -> if (applies(help)) help.createHeading(text) else "" } + + private fun Help.commandOptions(): List = visibleOptions().filter { !it.isGlobal() } + + private fun Help.globalOptions(): List = visibleOptions().filter { it.isGlobal() } + + private fun Help.visibleOptions(): List = commandSpec().options().filter { !it.hidden() } + + /** True on the root command's own declaration and on the copies picocli adds to each subcommand. */ + private fun OptionSpec.isGlobal(): Boolean = scopeType() == ScopeType.INHERIT +} diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/RecursiveHelpCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/RecursiveHelpCommand.kt index 6d2576b4..f42bd390 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/RecursiveHelpCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/RecursiveHelpCommand.kt @@ -16,12 +16,11 @@ import java.io.PrintWriter */ @Command( name = "help", - header = ["Display help information about the specified command."], - synopsisHeading = "%nUsage: ", helpCommand = true, description = [ - "%nWhen no COMMAND is given, the usage help for the main command is displayed.", - "If a COMMAND is specified, the help for that command is shown.%n", + "Display help information about the specified command.", + "When no COMMAND is given, the usage help for the main command is displayed.", + "If a COMMAND is specified, the help for that command is shown.", ], ) class RecursiveHelpCommand : IHelpCommandInitializable2, Runnable { diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/homes/WrapperCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/homes/WrapperCommand.kt index dd76604a..ca574512 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/homes/WrapperCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/homes/WrapperCommand.kt @@ -11,6 +11,7 @@ import java.util.concurrent.Callable @Command( name = "wrapper", + header = ["Write a project-local mops wrapper using Gradle-discovered paths."], mixinStandardHelpOptions = true, description = [ "Write a project-local mops wrapper using runtime paths discovered from Gradle.", diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/make/MakeOperations.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/make/MakeOperations.kt index 6aa1c551..6bc27ece 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/make/MakeOperations.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/make/MakeOperations.kt @@ -8,6 +8,7 @@ import picocli.CommandLine.Command */ @Command( name = "make", + header = ["Generate and compile modules or the whole project."], description = ["Run the MPS make (generation and compilation) on modules or the whole project."], ) class MakeOperations : CommandGroup() diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/skill/SkillPathCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/skill/SkillPathCommand.kt index 0036b015..62508f38 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/skill/SkillPathCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/skill/SkillPathCommand.kt @@ -4,7 +4,11 @@ import com.specificlanguages.mops.cli.common.CliCommand import picocli.CommandLine.Command import picocli.CommandLine.Parameters -@Command(name = "path", description = ["Print the absolute path to a bundled SKILL.md. Load it directly or symlink its directory into your agent's skills directory."]) +@Command( + name = "path", + header = ["Print the absolute path to a bundled SKILL.md."], + description = ["Print the absolute path to a bundled SKILL.md. Load it directly or symlink its directory into your agent's skills directory."], +) class SkillPathCommand : CliCommand() { @Parameters(index = "0", arity = "0..1", paramLabel = "NAME", description = ["Skill name: mops (default), mops-daemon (alias: daemon)."]) var name: String = "mops" diff --git a/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt b/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt index 0d370d71..c73c8f02 100644 --- a/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt +++ b/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt @@ -7,9 +7,12 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue @ResourceLock("system-streams") class CommandHelpTest { + private val groups = listOf("find", "get", "render", "edit", "check", "make", "create", "diagnose", "daemon", "code", "skill") + @Test fun `refs as URLs is a global option`() { val commandLine = newCommandLine() @@ -78,6 +81,51 @@ class CommandHelpTest { assertContains(output, "node-by-id") } + @Test + fun `group help lists commands before options`() { + for (group in groups) { + val output = runHelp(group, "--help") + + val commands = output.indexOf("Commands:") + val options = output.indexOf("Options:") + assertTrue(commands >= 0, "$group help should have a Commands section") + assertTrue(options >= 0, "$group help should have an Options section") + assertTrue(commands < options, "$group help should list commands before options") + } + } + + @Test + fun `every commands list entry fits on one line`() { + for (command in listOf(emptyArray()) + groups.map { arrayOf(it) }) { + val output = runHelp(*command, "--help") + val name = command.joinToString(" ").ifEmpty { "mops" } + + val entries = output.lines().dropWhile { it != "Commands:" }.drop(1).takeWhile { it.startsWith(" ") } + assertTrue(entries.isNotEmpty(), "$name help should list commands") + for (entry in entries) { + assertTrue(entry.matches(Regex(" \\S.*")), "$name help wraps a command entry: '$entry'") + } + } + } + + @Test + fun `global options form the last section of every command's help`() { + for (command in listOf(arrayOf(), arrayOf("find"), arrayOf("find", "instances"), arrayOf("get", "node"), arrayOf("explain"))) { + val output = runHelp(*command, "--help") + val name = command.joinToString(" ").ifEmpty { "mops" } + + val options = output.indexOf("Options:") + val globalOptions = output.indexOf("Global options:") + assertTrue(options in 0 until globalOptions, "$name help should list options before global options") + assertEquals(1, Regex("--mps-home").findAll(output).count(), "$name help should list --mps-home once") + assertTrue(output.indexOf("--mps-home") > globalOptions, "$name help should list --mps-home under global options") + for (option in listOf("--mps-home", "--project-root", "--daemon-home", "--java-home", "--refs-as-urls")) { + assertContains(output.substring(globalOptions), option) + } + assertFalse(output.substring(globalOptions).contains("--help"), "$name help should keep --help out of global options") + } + } + @Test fun `help verb prints root usage`() { val output = runHelp("help") diff --git a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt index 6157faca..a12fd0da 100644 --- a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt +++ b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt @@ -31,6 +31,14 @@ class ExamplesCommandTest { assertContains(error, "editing.java") } + @Test + fun `examples help lists the topics`() { + val help = tapSystemOut { assertEquals(0, newCommandLine().execute("examples", "--help")) } + + val topics = help.substringAfter("\nTopics:") + for (topic in ExampleTopics.topics) assertContains(topics, topic) + } + @Test fun `bundled reference links resolve`() { val index = ExampleTopics.index() diff --git a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt index b7d3ed35..bb94b550 100644 --- a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt +++ b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt @@ -39,6 +39,17 @@ class ExplainCommandTest { } } + @Test + fun `explain help lists the top-level topics with their summaries`() { + val help = runExplain("--help") + + val block = help.lines().dropWhile { it != "Topics:" }.drop(1).takeWhile { it.startsWith(" ") } + val entries = block.filter { it.matches(Regex(" \\S.*")) } + assertEquals(listOf("code") + topLevelTopics, entries.map { it.trim().substringBefore(' ') }) + assertContains(entries.single { it.trim().startsWith("edit ") }, "edit-batch notation") + for (line in block) assertTrue(line.length <= 80, "topic line exceeds 80 columns: '$line'") + } + @Test fun `explain edit prints the edit page and exits zero`() { val output = runExplain("edit") From 4bfa091bb1a783b3a95ee2bedf671cced23f69be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergej=20Ko=C5=A1=C4=8Dejev?= Date: Tue, 13 Oct 2026 22:27:21 +0200 Subject: [PATCH 2/5] Fix node command synopses, bare mops usage, and document the help layout --- .../com/specificlanguages/mops/cli/MopsCommand.kt | 6 +++++- .../specificlanguages/mops/cli/CommandHelpTest.kt | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/MopsCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/MopsCommand.kt index 3f77e2b1..3e485b04 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/MopsCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/MopsCommand.kt @@ -13,6 +13,7 @@ import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Option import picocli.CommandLine.ScopeType +import picocli.CommandLine.Spec @Command( name = "mops", @@ -71,8 +72,11 @@ class MopsCommand( nodeReferenceFormat = if (enabled) NodeReferenceFormat.URL else NodeReferenceFormat.SERIALIZED } + @Spec + lateinit var commandSpec: CommandLine.Model.CommandSpec + override fun run() { - CommandLine(this).usage(System.out) + commandSpec.commandLine().usage(System.out) } private var daemonPool: DefaultDaemonPool? = null diff --git a/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt b/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt index c73c8f02..695ba1c9 100644 --- a/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt +++ b/cli/src/test/kotlin/com/specificlanguages/mops/cli/CommandHelpTest.kt @@ -41,6 +41,11 @@ class CommandHelpTest { assertContains(output, "--refs-as-urls") } + @Test + fun `bare mops prints the same usage as --help`() { + assertEquals(runHelp("--help"), runHelp()) + } + @Test fun `edit help lists model subcommand`() { val output = runHelp("edit", "--help") @@ -126,6 +131,15 @@ class CommandHelpTest { } } + @Test + fun `find help head shows every find subcommand`() { + val head = runHelp("find", "--help").lines().take(12).joinToString("\n") + + for (subcommand in listOf("instances", "usages", "root-by-name", "node-by-id")) { + assertContains(head, " $subcommand ") + } + } + @Test fun `help verb prints root usage`() { val output = runHelp("help") From 8d0860679a249d0ada76922ffb9970f6acb0b8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergej=20Ko=C5=A1=C4=8Dejev?= Date: Tue, 13 Oct 2026 22:32:23 +0200 Subject: [PATCH 3/5] List topics before the options and share the node target parameters --- .../kotlin/com/specificlanguages/mops/cli/Main.kt | 11 ++++++----- .../com/specificlanguages/mops/cli/help/HelpLayout.kt | 4 ++-- .../specificlanguages/mops/cli/ExamplesCommandTest.kt | 3 ++- .../specificlanguages/mops/cli/ExplainCommandTest.kt | 1 + 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt index 39307184..354b6364 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt @@ -117,8 +117,8 @@ fun newCommandLine( root.addGroup("skill", SkillOperations()) { addLeaf("path", SkillPathCommand()) } - root.addLeaf("explain", ExplainCommand()).footer(ExplainTopics.topicList()) - root.addLeaf("examples", ExamplesCommand()).footer(ExampleTopics.topicList()) + root.addLeaf("explain", ExplainCommand()).appendDescription(ExplainTopics.topicList()) + root.addLeaf("examples", ExamplesCommand()).appendDescription(ExampleTopics.topicList()) root.addLeaf("help", RecursiveHelpCommand()) HelpLayout.apply(root) @@ -138,9 +138,10 @@ private fun CommandLine.addLeaf(name: String, command: Any): CommandLine { return leaf } -/** Appends [text] to the command's help, separated from the preceding section by a blank line. */ -private fun CommandLine.footer(text: String) { - commandSpec.usageMessage().footer("", *text.trimEnd().lines().toTypedArray()) +/** Appends [text] to the command's description, separated from it by a blank line, so it precedes the option sections. */ +private fun CommandLine.appendDescription(text: String) { + val usage = commandSpec.usageMessage() + usage.description(*usage.description(), "", *text.trimEnd().lines().toTypedArray()) } object PrintErrorAndExit : CommandLine.IExecutionExceptionHandler { diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt index 052f4a37..3847cd3d 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt @@ -68,8 +68,8 @@ object HelpLayout { ): Ansi.Text = createDetailedSynopsisOptionsText(done, commandOptions(), optionSort, clusterBooleanOptions) } - private fun heading(text: String, applies: (Help) -> Boolean) = - IHelpSectionRenderer { help -> if (applies(help)) help.createHeading(text) else "" } + private fun heading(text: String, hasContent: (Help) -> Boolean) = + IHelpSectionRenderer { help -> if (hasContent(help)) help.createHeading(text) else "" } private fun Help.commandOptions(): List = visibleOptions().filter { !it.isGlobal() } diff --git a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt index a12fd0da..a91f69e3 100644 --- a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt +++ b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExamplesCommandTest.kt @@ -35,7 +35,8 @@ class ExamplesCommandTest { fun `examples help lists the topics`() { val help = tapSystemOut { assertEquals(0, newCommandLine().execute("examples", "--help")) } - val topics = help.substringAfter("\nTopics:") + assertTrue(help.indexOf("Topics:") in 0 until help.indexOf("Options:"), "topics should precede the options") + val topics = help.substringAfter("Topics:").substringBefore("Options:") for (topic in ExampleTopics.topics) assertContains(topics, topic) } diff --git a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt index bb94b550..b1f5714a 100644 --- a/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt +++ b/cli/src/test/kotlin/com/specificlanguages/mops/cli/ExplainCommandTest.kt @@ -43,6 +43,7 @@ class ExplainCommandTest { fun `explain help lists the top-level topics with their summaries`() { val help = runExplain("--help") + assertTrue(help.indexOf("Topics:") in 0 until help.indexOf("Options:"), "topics should precede the options") val block = help.lines().dropWhile { it != "Topics:" }.drop(1).takeWhile { it.startsWith(" ") } val entries = block.filter { it.matches(Regex(" \\S.*")) } assertEquals(listOf("code") + topLevelTopics, entries.map { it.trim().substringBefore(' ') }) From b85d7021aad38aef233e2f1d1069bea362c1f408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergej=20Ko=C5=A1=C4=8Dejev?= Date: Thu, 22 Oct 2026 17:36:40 +0200 Subject: [PATCH 4/5] refactor: derive the help section order from picocli's defaults --- .../mops/cli/help/HelpLayout.kt | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt index 3847cd3d..f6791777 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/help/HelpLayout.kt @@ -20,25 +20,6 @@ object HelpLayout { private const val SECTION_KEY_GLOBAL_OPTION_LIST_HEADING = "globalOptionListHeading" private const val SECTION_KEY_GLOBAL_OPTION_LIST = "globalOptionList" - private val sectionKeys = listOf( - UsageMessageSpec.SECTION_KEY_SYNOPSIS_HEADING, - UsageMessageSpec.SECTION_KEY_SYNOPSIS, - UsageMessageSpec.SECTION_KEY_DESCRIPTION_HEADING, - UsageMessageSpec.SECTION_KEY_DESCRIPTION, - UsageMessageSpec.SECTION_KEY_COMMAND_LIST_HEADING, - UsageMessageSpec.SECTION_KEY_COMMAND_LIST, - UsageMessageSpec.SECTION_KEY_PARAMETER_LIST_HEADING, - UsageMessageSpec.SECTION_KEY_PARAMETER_LIST, - UsageMessageSpec.SECTION_KEY_OPTION_LIST_HEADING, - UsageMessageSpec.SECTION_KEY_OPTION_LIST, - UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST_HEADING, - UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST, - SECTION_KEY_GLOBAL_OPTION_LIST_HEADING, - SECTION_KEY_GLOBAL_OPTION_LIST, - UsageMessageSpec.SECTION_KEY_FOOTER_HEADING, - UsageMessageSpec.SECTION_KEY_FOOTER, - ) - /** Applies the layout to [root] and every subcommand registered so far. */ fun apply(root: CommandLine) { val sections = root.helpSectionMap.toMutableMap() @@ -56,10 +37,28 @@ object HelpLayout { sections[SECTION_KEY_GLOBAL_OPTION_LIST] = IHelpSectionRenderer { help -> help.optionListExcludingGroups(help.globalOptions()) } root.setHelpSectionMap(sections) - root.setHelpSectionKeys(sectionKeys) + root.setHelpSectionKeys(sectionKeys(root.helpSectionKeys)) root.setHelpFactory { spec, colorScheme -> SynopsisWithoutGlobalOptions(spec, colorScheme) } } + /** + * Picocli's default order with the command list moved up after the description, the global options added before + * the footer, and the header left out (it only serves the parent's command list). + */ + private fun sectionKeys(defaults: List): List { + val keys = defaults.toMutableList() + keys -= listOf(UsageMessageSpec.SECTION_KEY_HEADER_HEADING, UsageMessageSpec.SECTION_KEY_HEADER) + val commandList = + listOf(UsageMessageSpec.SECTION_KEY_COMMAND_LIST_HEADING, UsageMessageSpec.SECTION_KEY_COMMAND_LIST) + keys -= commandList + keys.addAll(keys.indexOf(UsageMessageSpec.SECTION_KEY_DESCRIPTION) + 1, commandList) + keys.addAll( + keys.indexOf(UsageMessageSpec.SECTION_KEY_FOOTER_HEADING), + listOf(SECTION_KEY_GLOBAL_OPTION_LIST_HEADING, SECTION_KEY_GLOBAL_OPTION_LIST), + ) + return keys + } + private class SynopsisWithoutGlobalOptions(spec: CommandSpec, colorScheme: ColorScheme) : Help(spec, colorScheme) { override fun createDetailedSynopsisOptionsText( done: Collection, From 154d25411544a727b9e2d8d3dc4b7d564976edf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergej=20Ko=C5=A1=C4=8Dejev?= Date: Thu, 22 Oct 2026 17:36:41 +0200 Subject: [PATCH 5/5] fix: give check model, check project, find subconcepts, and get module one-line headers --- .../com/specificlanguages/mops/cli/check/ModelCheckCommand.kt | 1 + .../com/specificlanguages/mops/cli/check/ProjectCheckCommand.kt | 1 + .../specificlanguages/mops/cli/find/FindSubconceptsCommand.kt | 1 + .../com/specificlanguages/mops/cli/get/GetModuleCommand.kt | 1 + 4 files changed, 4 insertions(+) diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModelCheckCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModelCheckCommand.kt index f7e570ec..c5fc6b9c 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModelCheckCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ModelCheckCommand.kt @@ -9,6 +9,7 @@ import picocli.CommandLine.Parameters @Command( name = "model", + header = ["Run MPS's full model check over one model."], description = [ "Run MPS's full model check over one model and report the findings. Exits 1 when any finding is an error.", ], diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ProjectCheckCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ProjectCheckCommand.kt index b4a45a76..b73dff09 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ProjectCheckCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/check/ProjectCheckCommand.kt @@ -8,6 +8,7 @@ import picocli.CommandLine.Command @Command( name = "project", + header = ["Run MPS's full check over every project module and its models."], description = [ "Run MPS's full check over every project module and its models. Exits 1 when any finding is an error.", ], diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindSubconceptsCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindSubconceptsCommand.kt index 2caf4f19..c39708c9 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindSubconceptsCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/find/FindSubconceptsCommand.kt @@ -11,6 +11,7 @@ import picocli.CommandLine.Parameters @Command( name = "subconcepts", + header = ["Find subconcepts of one MPS concept."], description = [ "Find extending concepts and interface implementors in all loaded language runtimes, including libraries. " + "Excludes the queried concept; prints qualified names in sorted order. Unloaded languages contribute no results.", diff --git a/cli/src/main/kotlin/com/specificlanguages/mops/cli/get/GetModuleCommand.kt b/cli/src/main/kotlin/com/specificlanguages/mops/cli/get/GetModuleCommand.kt index de3e8892..9180b8ed 100644 --- a/cli/src/main/kotlin/com/specificlanguages/mops/cli/get/GetModuleCommand.kt +++ b/cli/src/main/kotlin/com/specificlanguages/mops/cli/get/GetModuleCommand.kt @@ -12,6 +12,7 @@ import picocli.CommandLine.Parameters @Command( name = "module", + header = ["Report where MPS locates one module's files."], description = ["Report where MPS locates one module's descriptor, jars, class output, and model sources."], ) class GetModuleCommand(private val environment: CommandEnvironment) : CliCommand() {