Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions cli/src/main/kotlin/com/specificlanguages/mops/cli/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()).appendDescription(ExplainTopics.topicList())
root.addLeaf("examples", ExamplesCommand()).appendDescription(ExampleTopics.topicList())
root.addLeaf("help", RecursiveHelpCommand())
HelpLayout.apply(root)

return root.setExecutionExceptionHandler(PrintErrorAndExit)
}
Expand All @@ -128,8 +132,16 @@ 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 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,51 @@ 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")

private val operationTopics: List<String> = EditNotation.operationNames.map { "edit.$it" }

private val allTopics: List<String> = 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 <topic>` 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<String> {
val lines = mutableListOf<String>()
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 <topic>` 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scope-segments>` to " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scope-segments>` to search a module, model, or the whole repository (`in /`). Only " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scope-segments>` to search a module, model, or subtree exhaustively, or `in /` for the whole " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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"

/** 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.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<String>): List<String> {
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<ArgSpec>,
optionSort: Comparator<OptionSpec>?,
clusterBooleanOptions: Boolean,
): Ansi.Text = createDetailedSynopsisOptionsText(done, commandOptions(), optionSort, clusterBooleanOptions)
}

private fun heading(text: String, hasContent: (Help) -> Boolean) =
IHelpSectionRenderer { help -> if (hasContent(help)) help.createHeading(text) else "" }

private fun Help.commandOptions(): List<OptionSpec> = visibleOptions().filter { !it.isGlobal() }

private fun Help.globalOptions(): List<OptionSpec> = visibleOptions().filter { it.isGlobal() }

private fun Help.visibleOptions(): List<OptionSpec> = 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading