diff --git a/Cargo.lock b/Cargo.lock index e31923aaf..c9d7d7ea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,17 @@ dependencies = [ "unarray", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -1992,6 +2003,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2101,6 +2118,7 @@ dependencies = [ "miette", "num-traits", "pretty_assertions", + "pulldown-cmark", "regex", "roff", "semver", diff --git a/benches/go/mise/tables.go b/benches/go/mise/tables.go index aa9be0bed..6560064ad 100644 --- a/benches/go/mise/tables.go +++ b/benches/go/mise/tables.go @@ -5633,7 +5633,7 @@ var HelpText = argv.HelpTable{ {Key: FlagSetRemove, Hide: true, Repeatable: true, ValueName: "ENV_KEY", ValueDemanded: true, Short: "Remove the environment variable from config file", Long: "Remove the environment variable from config file\n\nCan be used multiple times."}, {Key: FlagSetStdin, Short: "Read the value from stdin (for multiline input)", Long: "Read the value from stdin (for multiline input)\n\nWhen using --stdin, provide a single key without a value. The value will be read from stdin until EOF."}, {Key: ArgSetEnvVar, Short: "Environment variable(s) to set\ne.g.: NODE_ENV=production", Long: "Environment variable(s) to set\ne.g.: NODE_ENV=production"}, - {Key: CmdSettings, Short: "Manage settings", AfterLongHelp: "\x1b[1m\x1b[4mExamples:\x1b[22m\x1b[24m\n # list all settings\n $ \x1b[1mmise settings\x1b[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \x1b[1mmise settings always_keep_download\x1b[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \x1b[1mmise settings always_keep_download=true\x1b[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \x1b[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\x1b[22m\n"}, + {Key: CmdSettings, Short: "Manage settings", AfterLongHelp: "\x1b[1m\x1b[4mExamples:\x1b[22m\x1b[24m\n\n # list all settings\n $ \x1b[1mmise settings\x1b[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \x1b[1mmise settings always_keep_download\x1b[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \x1b[1mmise settings always_keep_download=true\x1b[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \x1b[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\x1b[22m\n"}, {Key: FlagSettingsAll, Short: "List all settings", Long: "List all settings"}, {Key: FlagSettingsJson, Short: "Output in JSON format", Long: "Output in JSON format"}, {Key: FlagSettingsLocal, Short: "Use the local config file instead of the global one", Long: "Use the local config file instead of the global one"}, diff --git a/benches/mise.usage.kdl b/benches/mise.usage.kdl index 37682505e..988de1b72 100644 --- a/benches/mise.usage.kdl +++ b/benches/mise.usage.kdl @@ -2796,7 +2796,7 @@ e.g.: NODE_ENV=production """# } cmd settings help="Manage settings" effect=write { - after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n # list all settings\n $ \u{1b}[1mmise settings\u{1b}[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \u{1b}[1mmise settings always_keep_download\u{1b}[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \u{1b}[1mmise settings always_keep_download=true\u{1b}[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \u{1b}[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\u{1b}[22m\n" + after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n # list all settings\n $ \u{1b}[1mmise settings\u{1b}[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \u{1b}[1mmise settings always_keep_download\u{1b}[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \u{1b}[1mmise settings always_keep_download=true\u{1b}[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \u{1b}[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\u{1b}[22m\n" flag "-a --all" help="List all settings" flag "-J --json" help="Output in JSON format" flag "-l --local" help="Use the local config file instead of the global one" global=#true diff --git a/benches/shadows/mise/src/lib.rs b/benches/shadows/mise/src/lib.rs index 0fbc56b1c..296908d4d 100644 --- a/benches/shadows/mise/src/lib.rs +++ b/benches/shadows/mise/src/lib.rs @@ -4648,7 +4648,7 @@ pub struct SettingsUnsetArgs { /// Manage settings #[derive(Args)] #[usage( - after_long_help = "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n # list all settings\n $ \u{1b}[1mmise settings\u{1b}[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \u{1b}[1mmise settings always_keep_download\u{1b}[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \u{1b}[1mmise settings always_keep_download=true\u{1b}[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \u{1b}[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\u{1b}[22m\n", + after_long_help = "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n # list all settings\n $ \u{1b}[1mmise settings\u{1b}[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \u{1b}[1mmise settings always_keep_download\u{1b}[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \u{1b}[1mmise settings always_keep_download=true\u{1b}[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \u{1b}[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\u{1b}[22m\n", effect = "write", group("output") )] diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index 668a5f2cb..47a02a6fb 100644 --- a/docs/cli/reference/index.md +++ b/docs/cli/reference/index.md @@ -2,8 +2,6 @@ # `usage` -**Usage:** `usage [--completions ] [--usage-spec] ` - **Version:** 6.7.1 **Repository:** https://github.com/jdx/usage diff --git a/examples/docs/MISE_INLINE.md b/examples/docs/MISE_INLINE.md index 96e7249ed..444889f4d 100644 --- a/examples/docs/MISE_INLINE.md +++ b/examples/docs/MISE_INLINE.md @@ -277,29 +277,19 @@ Runs the bootstrap steps for the current config in order: 9. `mise bootstrap mise-shell-activate apply` — configure shell activation from `[bootstrap.mise_shell_activate]` 10. `mise bootstrap macos defaults apply` — write -``` -`[bootstrap.macos.defaults]` entries (macOS) -surrounded by `pre-defaults`/`post-defaults` hooks -``` + `[bootstrap.macos.defaults]` entries (macOS) + surrounded by `pre-defaults`/`post-defaults` hooks 11. `mise bootstrap macos launchd-agents apply` — install/load -``` -`[bootstrap.macos.launchd.agents]` -``` + `[bootstrap.macos.launchd.agents]` 12. `mise bootstrap linux systemd-units apply` — install/start -``` -`[bootstrap.linux.systemd.units]` -``` + `[bootstrap.linux.systemd.units]` 13. `mise bootstrap user apply` — set `[bootstrap.user].login_shell` -``` -(Unix) -surrounded by `pre-user`/`post-user` hooks -``` + (Unix) + surrounded by `pre-user`/`post-user` hooks 14. `mise install` — install missing tools from `[tools]` -``` -surrounded by `pre-tools`/`post-tools` hooks; package-plugin entries -from `[bootstrap.packages]` install afterward, followed by -`[bootstrap.hooks.post-packages]` -``` + surrounded by `pre-tools`/`post-tools` hooks; package-plugin entries + from `[bootstrap.packages]` install afterward, followed by + `[bootstrap.hooks.post-packages]` 15. `mise run bootstrap` — if a task named `bootstrap` is defined 16. `[bootstrap.hooks.final]` — optional final hook The declarative steps converge — anything already in its desired state is skipped, so re-running is safe. The `bootstrap` task runs on every invocation; keep it idempotent. Use it for any project-specific setup that doesn't fit the declarative sections (seeding databases, auth flows, etc.) — it runs with the installed tools on PATH. @@ -3250,6 +3240,7 @@ Manage settings - **`--json-extended`** — Output in JSON format with sources Examples: + ``` # list all settings $ mise settings @@ -4764,26 +4755,18 @@ For more advanced process management (daemon management, auto-restart, readiness - 'path | file_size' returns the size of the file at path, or null if it does not exist. - 'path | file_read(bytes)' returns a string with the first n bytes of the file at path. - ``` - If the file is smaller than n bytes, the whole file is returned. There is no filter to - read the whole file at once to encourage limiting the amount of data read and processed. - ``` + If the file is smaller than n bytes, the whole file is returned. There is no filter to + read the whole file at once to encourage limiting the amount of data read and processed. - 'string | hash', and 'path | file_hash' return the hash of the string or file at path. - ``` - No guarantee is made about the algorithm used: treat it as an opaque value. - ``` + No guarantee is made about the algorithm used: treat it as an opaque value. - 'any | kv_store(key)', 'kv_fetch(key)', and 'kv_clear' provide a simple key-value store. - ``` - Data is kept in memory only, there is no persistence. Consistency is not guaranteed. - ``` + Data is kept in memory only, there is no persistence. Consistency is not guaranteed. - 'any | printout', 'any | printerr', and 'any | log(level)' will print or log any given - ``` - value to stdout, stderr, or the log (levels = error, warn, info, debug, trace), and - pass the value through (so '[1] | log("debug") | .[]' will produce a '1' and log '[1]'). - ``` + value to stdout, stderr, or the log (levels = error, warn, info, debug, trace), and + pass the value through (so '[1] | log("debug") | .[]' will produce a '1' and log '[1]'). All filtering done with such programs, and especially those using kv or filesystem access, is much slower than the other filtering methods. If filtering is too slow, events will back up and stall watchexec. Take care when designing your filters. @@ -4913,27 +4896,25 @@ For more advanced process management (daemon management, auto-restart, readiness ```json { - ``` - "tags": [ - { - "kind": "path", - "absolute": "/home/user/your/new-folder", - "filetype": "dir" - }, - { - "kind": "fs", - "simple": "create", - "full": "Create(Folder)" - }, - { - "kind": "source", - "source": "filesystem", - } - ], - "metadata": { - "notify-backend": "inotify" - } - ``` + "tags": [ + { + "kind": "path", + "absolute": "/home/user/your/new-folder", + "filetype": "dir" + }, + { + "kind": "fs", + "simple": "create", + "full": "Create(Folder)" + }, + { + "kind": "source", + "source": "filesystem", + } + ], + "metadata": { + "notify-backend": "inotify" + } } ``` @@ -4941,25 +4922,23 @@ For more advanced process management (daemon management, auto-restart, readiness - `tags`, structured event data. - `tags[].kind`, which can be: - ``` - * 'path', along with: - + `absolute`, an absolute path. - + `filetype`, a file type if known ('dir', 'file', 'symlink', 'other'). - * 'fs': - + `simple`, the "simple" event type ('access', 'create', 'modify', 'remove', or 'other'). - + `full`, the "full" event type, which is too complex to fully describe here, but looks like 'General(Precise(Specific))'. - * 'source', along with: - + `source`, the source of the event ('filesystem', 'keyboard', 'mouse', 'os', 'time', 'internal'). - * 'keyboard', along with: - + `keycode`. Currently only the value 'eof' is supported. - * 'process', for events caused by processes: - + `pid`, the process ID. - * 'signal', for signals sent to Watchexec: - + `signal`, the normalised signal name ('hangup', 'interrupt', 'quit', 'terminate', 'user1', 'user2'). - * 'completion', for when a command ends: - + `disposition`, the exit disposition ('success', 'error', 'signal', 'stop', 'exception', 'continued'). - + `code`, the exit, signal, stop, or exception code. - ``` + * 'path', along with: + + `absolute`, an absolute path. + + `filetype`, a file type if known ('dir', 'file', 'symlink', 'other'). + * 'fs': + + `simple`, the "simple" event type ('access', 'create', 'modify', 'remove', or 'other'). + + `full`, the "full" event type, which is too complex to fully describe here, but looks like 'General(Precise(Specific))'. + * 'source', along with: + + `source`, the source of the event ('filesystem', 'keyboard', 'mouse', 'os', 'time', 'internal'). + * 'keyboard', along with: + + `keycode`. Currently only the value 'eof' is supported. + * 'process', for events caused by processes: + + `pid`, the process ID. + * 'signal', for signals sent to Watchexec: + + `signal`, the normalised signal name ('hangup', 'interrupt', 'quit', 'terminate', 'user1', 'user2'). + * 'completion', for when a command ends: + + `disposition`, the exit disposition ('success', 'error', 'signal', 'stop', 'exception', 'continued'). + + `code`, the exit, signal, stop, or exception code. - `metadata`, additional information about the event. The 'json-stdio' mode will emit JSON events to the standard input of the command, one per line, then close stdin. The 'json-file' mode will create a temporary file, write the events to it, and provide the path to the file with the $WATCHEXEC_EVENTS_FILE environment variable. diff --git a/examples/docs/MISE_MULTI.md b/examples/docs/MISE_MULTI.md index 96e7249ed..444889f4d 100644 --- a/examples/docs/MISE_MULTI.md +++ b/examples/docs/MISE_MULTI.md @@ -277,29 +277,19 @@ Runs the bootstrap steps for the current config in order: 9. `mise bootstrap mise-shell-activate apply` — configure shell activation from `[bootstrap.mise_shell_activate]` 10. `mise bootstrap macos defaults apply` — write -``` -`[bootstrap.macos.defaults]` entries (macOS) -surrounded by `pre-defaults`/`post-defaults` hooks -``` + `[bootstrap.macos.defaults]` entries (macOS) + surrounded by `pre-defaults`/`post-defaults` hooks 11. `mise bootstrap macos launchd-agents apply` — install/load -``` -`[bootstrap.macos.launchd.agents]` -``` + `[bootstrap.macos.launchd.agents]` 12. `mise bootstrap linux systemd-units apply` — install/start -``` -`[bootstrap.linux.systemd.units]` -``` + `[bootstrap.linux.systemd.units]` 13. `mise bootstrap user apply` — set `[bootstrap.user].login_shell` -``` -(Unix) -surrounded by `pre-user`/`post-user` hooks -``` + (Unix) + surrounded by `pre-user`/`post-user` hooks 14. `mise install` — install missing tools from `[tools]` -``` -surrounded by `pre-tools`/`post-tools` hooks; package-plugin entries -from `[bootstrap.packages]` install afterward, followed by -`[bootstrap.hooks.post-packages]` -``` + surrounded by `pre-tools`/`post-tools` hooks; package-plugin entries + from `[bootstrap.packages]` install afterward, followed by + `[bootstrap.hooks.post-packages]` 15. `mise run bootstrap` — if a task named `bootstrap` is defined 16. `[bootstrap.hooks.final]` — optional final hook The declarative steps converge — anything already in its desired state is skipped, so re-running is safe. The `bootstrap` task runs on every invocation; keep it idempotent. Use it for any project-specific setup that doesn't fit the declarative sections (seeding databases, auth flows, etc.) — it runs with the installed tools on PATH. @@ -3250,6 +3240,7 @@ Manage settings - **`--json-extended`** — Output in JSON format with sources Examples: + ``` # list all settings $ mise settings @@ -4764,26 +4755,18 @@ For more advanced process management (daemon management, auto-restart, readiness - 'path | file_size' returns the size of the file at path, or null if it does not exist. - 'path | file_read(bytes)' returns a string with the first n bytes of the file at path. - ``` - If the file is smaller than n bytes, the whole file is returned. There is no filter to - read the whole file at once to encourage limiting the amount of data read and processed. - ``` + If the file is smaller than n bytes, the whole file is returned. There is no filter to + read the whole file at once to encourage limiting the amount of data read and processed. - 'string | hash', and 'path | file_hash' return the hash of the string or file at path. - ``` - No guarantee is made about the algorithm used: treat it as an opaque value. - ``` + No guarantee is made about the algorithm used: treat it as an opaque value. - 'any | kv_store(key)', 'kv_fetch(key)', and 'kv_clear' provide a simple key-value store. - ``` - Data is kept in memory only, there is no persistence. Consistency is not guaranteed. - ``` + Data is kept in memory only, there is no persistence. Consistency is not guaranteed. - 'any | printout', 'any | printerr', and 'any | log(level)' will print or log any given - ``` - value to stdout, stderr, or the log (levels = error, warn, info, debug, trace), and - pass the value through (so '[1] | log("debug") | .[]' will produce a '1' and log '[1]'). - ``` + value to stdout, stderr, or the log (levels = error, warn, info, debug, trace), and + pass the value through (so '[1] | log("debug") | .[]' will produce a '1' and log '[1]'). All filtering done with such programs, and especially those using kv or filesystem access, is much slower than the other filtering methods. If filtering is too slow, events will back up and stall watchexec. Take care when designing your filters. @@ -4913,27 +4896,25 @@ For more advanced process management (daemon management, auto-restart, readiness ```json { - ``` - "tags": [ - { - "kind": "path", - "absolute": "/home/user/your/new-folder", - "filetype": "dir" - }, - { - "kind": "fs", - "simple": "create", - "full": "Create(Folder)" - }, - { - "kind": "source", - "source": "filesystem", - } - ], - "metadata": { - "notify-backend": "inotify" - } - ``` + "tags": [ + { + "kind": "path", + "absolute": "/home/user/your/new-folder", + "filetype": "dir" + }, + { + "kind": "fs", + "simple": "create", + "full": "Create(Folder)" + }, + { + "kind": "source", + "source": "filesystem", + } + ], + "metadata": { + "notify-backend": "inotify" + } } ``` @@ -4941,25 +4922,23 @@ For more advanced process management (daemon management, auto-restart, readiness - `tags`, structured event data. - `tags[].kind`, which can be: - ``` - * 'path', along with: - + `absolute`, an absolute path. - + `filetype`, a file type if known ('dir', 'file', 'symlink', 'other'). - * 'fs': - + `simple`, the "simple" event type ('access', 'create', 'modify', 'remove', or 'other'). - + `full`, the "full" event type, which is too complex to fully describe here, but looks like 'General(Precise(Specific))'. - * 'source', along with: - + `source`, the source of the event ('filesystem', 'keyboard', 'mouse', 'os', 'time', 'internal'). - * 'keyboard', along with: - + `keycode`. Currently only the value 'eof' is supported. - * 'process', for events caused by processes: - + `pid`, the process ID. - * 'signal', for signals sent to Watchexec: - + `signal`, the normalised signal name ('hangup', 'interrupt', 'quit', 'terminate', 'user1', 'user2'). - * 'completion', for when a command ends: - + `disposition`, the exit disposition ('success', 'error', 'signal', 'stop', 'exception', 'continued'). - + `code`, the exit, signal, stop, or exception code. - ``` + * 'path', along with: + + `absolute`, an absolute path. + + `filetype`, a file type if known ('dir', 'file', 'symlink', 'other'). + * 'fs': + + `simple`, the "simple" event type ('access', 'create', 'modify', 'remove', or 'other'). + + `full`, the "full" event type, which is too complex to fully describe here, but looks like 'General(Precise(Specific))'. + * 'source', along with: + + `source`, the source of the event ('filesystem', 'keyboard', 'mouse', 'os', 'time', 'internal'). + * 'keyboard', along with: + + `keycode`. Currently only the value 'eof' is supported. + * 'process', for events caused by processes: + + `pid`, the process ID. + * 'signal', for signals sent to Watchexec: + + `signal`, the normalised signal name ('hangup', 'interrupt', 'quit', 'terminate', 'user1', 'user2'). + * 'completion', for when a command ends: + + `disposition`, the exit disposition ('success', 'error', 'signal', 'stop', 'exception', 'continued'). + + `code`, the exit, signal, stop, or exception code. - `metadata`, additional information about the event. The 'json-stdio' mode will emit JSON events to the standard input of the command, one per line, then close stdin. The 'json-file' mode will create a temporary file, write the events to it, and provide the path to the file with the $WATCHEXEC_EVENTS_FILE environment variable. diff --git a/go/internal/shadow/mise/tables.go b/go/internal/shadow/mise/tables.go index aa9be0bed..6560064ad 100644 --- a/go/internal/shadow/mise/tables.go +++ b/go/internal/shadow/mise/tables.go @@ -5633,7 +5633,7 @@ var HelpText = argv.HelpTable{ {Key: FlagSetRemove, Hide: true, Repeatable: true, ValueName: "ENV_KEY", ValueDemanded: true, Short: "Remove the environment variable from config file", Long: "Remove the environment variable from config file\n\nCan be used multiple times."}, {Key: FlagSetStdin, Short: "Read the value from stdin (for multiline input)", Long: "Read the value from stdin (for multiline input)\n\nWhen using --stdin, provide a single key without a value. The value will be read from stdin until EOF."}, {Key: ArgSetEnvVar, Short: "Environment variable(s) to set\ne.g.: NODE_ENV=production", Long: "Environment variable(s) to set\ne.g.: NODE_ENV=production"}, - {Key: CmdSettings, Short: "Manage settings", AfterLongHelp: "\x1b[1m\x1b[4mExamples:\x1b[22m\x1b[24m\n # list all settings\n $ \x1b[1mmise settings\x1b[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \x1b[1mmise settings always_keep_download\x1b[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \x1b[1mmise settings always_keep_download=true\x1b[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \x1b[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\x1b[22m\n"}, + {Key: CmdSettings, Short: "Manage settings", AfterLongHelp: "\x1b[1m\x1b[4mExamples:\x1b[22m\x1b[24m\n\n # list all settings\n $ \x1b[1mmise settings\x1b[22m\n\n # get the value of the setting \"always_keep_download\"\n $ \x1b[1mmise settings always_keep_download\x1b[22m\n\n # set the value of the setting \"always_keep_download\" to \"true\"\n $ \x1b[1mmise settings always_keep_download=true\x1b[22m\n\n # set the value of the setting \"node.mirror_url\" to \"https://npmmirror.com/mirrors/node/\"\n $ \x1b[1mmise settings node.mirror_url https://npmmirror.com/mirrors/node/\x1b[22m\n"}, {Key: FlagSettingsAll, Short: "List all settings", Long: "List all settings"}, {Key: FlagSettingsJson, Short: "Output in JSON format", Long: "Output in JSON format"}, {Key: FlagSettingsLocal, Short: "Use the local config file instead of the global one", Long: "Use the local config file instead of the global one"}, diff --git a/lib/Cargo.toml b/lib/Cargo.toml index a115868a6..e7076e5f4 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -30,6 +30,7 @@ log = "0.4" miette = { version = "7", default-features = false, optional = true } num-traits = "0.2" regex = "1" +pulldown-cmark = { version = "0.13", default-features = false, optional = true } roff = { version = "1.0", optional = true } semver = "1" serde = { version = "1", features = ["derive"] } @@ -48,7 +49,7 @@ usage-validation = { workspace = true, optional = true } default = ["docs"] # CLI help is useful to lightweight consumers that do not generate Markdown or manpages. cli-help = ["tera"] -markdown = ["cli-help"] +markdown = ["cli-help", "dep:pulldown-cmark"] manpage = ["cli-help", "dep:roff"] docs = ["markdown", "manpage", "roff"] # Compatibility alias for callers that enabled the implicit optional-dependency feature. diff --git a/lib/src/docs/markdown/renderer.rs b/lib/src/docs/markdown/renderer.rs index 4bbd19a11..b903e68d8 100644 --- a/lib/src/docs/markdown/renderer.rs +++ b/lib/src/docs/markdown/renderer.rs @@ -54,7 +54,7 @@ impl MarkdownTemplate { static CODE_SPAN_OR_LT: LazyLock = LazyLock::new(|| Regex::new(r"(`[^`]*`)|(<)").unwrap()); fn escape_md_with_indent(value: &str, html_encode: bool, indent: bool) -> String { - let mut in_fenced_code_block = false; + let mut fence: Option<(char, usize)> = None; // Help text is allowed to contain terminal styling. clap-era applications commonly build // their examples with `color_print::cstr!`, which embeds SGR sequences even when color is // disabled at runtime. Terminal styling has no meaning in generated Markdown, and leaving @@ -68,22 +68,22 @@ fn escape_md_with_indent(value: &str, html_encode: bool, indent: bool) -> String let line = if !html_encode { line.to_string() } else { - // Indented code is handled before fence state. This is safe because - // `fence_indented_blocks` always emits closing fences at column zero. + // Preserve indented code, including code nested within list items. if line.starts_with(" ") { line.to_string() - } else if in_fenced_code_block { - if line.trim_end() == "```" { - in_fenced_code_block = false; + } else if let Some((marker, length)) = fence { + let trimmed = line.trim(); + if trimmed.len() >= length && trimmed.chars().all(|c| c == marker) { + fence = None; } line.to_string() - // Support the conventional fence shape emitted by `fence_indented_blocks` - // without attempting to parse the full Markdown specification. - } else if line - .strip_prefix("```") - .is_some_and(|suffix| !suffix.starts_with('`')) + // Converted blocks may require longer fences to contain literal backticks. + } else if line.trim_start().starts_with("```") + || line.trim_start().starts_with("~~~") { - in_fenced_code_block = true; + let trimmed = line.trim_start(); + let marker = trimmed.chars().next().unwrap(); + fence = Some((marker, trimmed.chars().take_while(|c| *c == marker).count())); line.to_string() } else { // replace '<' with '<' but not inside code blocks @@ -337,29 +337,63 @@ impl MarkdownRenderer { if !self.indented_blocks_to_code_fences { return md; } - // TODO: handle fences inside of
 or 
-        let mut in_code_block = false;
-        let mut new_md = String::new();
-        for line in md.lines() {
-            if let Some(line) = line.strip_prefix("    ") {
-                if in_code_block {
-                    new_md.push_str(&format!("{line}\n"));
-                } else {
-                    new_md.push_str(&format!("```\n{line}\n"));
-                    in_code_block = true;
+        use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
+        let mut edits = Vec::new();
+        let mut block = None;
+        for (event, range) in Parser::new(&md).into_offset_iter() {
+            match event {
+                Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
+                    block = Some((range, String::new()));
                 }
-            } else {
-                if in_code_block {
-                    new_md.push_str("```\n");
-                    in_code_block = false;
+                Event::Text(text) => {
+                    if let Some((_, content)) = &mut block {
+                        content.push_str(&text);
+                    }
                 }
-                new_md.push_str(&format!("{line}\n"));
+                Event::End(TagEnd::CodeBlock) => {
+                    if let Some((range, content)) = block.take() {
+                        let start = md[..range.start].rfind('\n').map_or(0, |i| i + 1);
+                        // A same-line list marker or blockquote prefix belongs to its
+                        // container. Keep these blocks indented rather than replacing
+                        // the container along with the code block.
+                        if md[start..range.start].chars().any(|c| !c.is_whitespace()) {
+                            continue;
+                        }
+                        let source_line = md[start..].lines().next().unwrap_or_default();
+                        let source_indent =
+                            source_line.len() - source_line.trim_start_matches(' ').len();
+                        let content_line = content
+                            .lines()
+                            .find(|line| !line.trim().is_empty())
+                            .unwrap_or_default();
+                        let content_indent =
+                            content_line.len() - content_line.trim_start_matches(' ').len();
+                        let prefix = " ".repeat(source_indent.saturating_sub(4 + content_indent));
+                        let fence = "`".repeat(
+                            content
+                                .split(|c| c != '`')
+                                .map(str::len)
+                                .max()
+                                .unwrap_or(0)
+                                .max(2)
+                                + 1,
+                        );
+                        let mut replacement = format!("{prefix}{fence}\n");
+                        for line in content.lines() {
+                            replacement.push_str(&format!("{prefix}{line}\n"));
+                        }
+                        replacement.push_str(&format!("{prefix}{fence}\n"));
+                        edits.push((start..range.end, replacement));
+                    }
+                }
+                _ => {}
             }
         }
-        if in_code_block {
-            new_md.push_str("```\n");
+        let mut result = md;
+        for (range, replacement) in edits.into_iter().rev() {
+            result.replace_range(range, &replacement);
         }
-        new_md.replace("```\n\n```\n", "\n")
+        result
     }
 }
 
@@ -368,6 +402,90 @@ mod tests {
     use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
     use pretty_assertions::assert_eq;
 
+    #[test]
+    fn conversion_preserves_markdown_structure_and_code_contents() {
+        use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag};
+        let renderer = MarkdownRenderer::new("bin ex".parse().unwrap())
+            .with_indented_blocks_to_code_fences(true);
+        for source in [
+            "- outer\n  - inner\n\n        first\n\n          indented\n\n    prose\n",
+            "```json\n{\n    \"key\": 1\n}\n```\n",
+            "    [tools]\n    node = \"20\"\n\n    ```literal```\n",
+            "    first\n\n      second\n",
+            ">     quoted code\n>\n>       indented\n",
+            "-     same-line list code\n",
+            "Examples:\n    paragraph continuation\n\n    actual code\n",
+        ] {
+            let normalized = |text: &str| {
+                pulldown_cmark::TextMergeStream::new(Parser::new(text))
+                    .map(|event| match event {
+                        Event::Start(Tag::CodeBlock(_)) => {
+                            Event::Start(Tag::CodeBlock(CodeBlockKind::Indented))
+                        }
+                        event => event,
+                    })
+                    .map(Event::into_static)
+                    .collect::>()
+            };
+            let output = renderer.fence_indented_blocks(source.into());
+            assert_eq!(normalized(source), normalized(&output), "{output}");
+            assert_eq!(renderer.fence_indented_blocks(output.clone()), output);
+        }
+    }
+
+    #[test]
+    fn legacy_examples_with_markdown_separation_form_one_code_block() {
+        let renderer = MarkdownRenderer::new("bin ex".parse().unwrap())
+            .with_indented_blocks_to_code_fences(true);
+        let source = "Examples:\n\n    # first\n    ex first\n\n    # second\n    ex second\n";
+        assert_eq!(
+            renderer.fence_indented_blocks(source.into()),
+            "Examples:\n\n```\n# first\nex first\n\n# second\nex second\n```\n"
+        );
+        let spec =
+            crate::Spec::parse_file(std::path::Path::new("../benches/mise.usage.kdl")).unwrap();
+        let page = MarkdownRenderer::new(spec.clone())
+            .with_indented_blocks_to_code_fences(true)
+            .render_cmd(&spec.cmd.subcommands["settings"])
+            .unwrap();
+        assert!(
+            page.contains("Examples:\n\n```\n# list all settings"),
+            "{page}"
+        );
+    }
+
+    #[test]
+    fn visible_children_get_a_heading_and_index_has_one_synopsis() {
+        let spec: crate::Spec = "bin ex\ncmd a hide=#true\ncmd b\n".parse().unwrap();
+        for theme in [
+            super::MarkdownTheme::Compact,
+            super::MarkdownTheme::Detailed,
+        ] {
+            let renderer = MarkdownRenderer::new(spec.clone())
+                .with_theme(theme)
+                .with_multi(true);
+            assert!(renderer
+                .render_cmd(&spec.cmd)
+                .unwrap()
+                .contains("## Subcommands"));
+            assert_eq!(
+                renderer
+                    .render_index()
+                    .unwrap()
+                    .matches("**Usage:**")
+                    .count(),
+                1
+            );
+            let hidden: crate::Spec = "bin ex\ncmd a hide=#true\n".parse().unwrap();
+            assert!(!MarkdownRenderer::new(hidden.clone())
+                .with_theme(theme)
+                .with_multi(true)
+                .render_cmd(&hidden.cmd)
+                .unwrap()
+                .contains("## Subcommands"));
+        }
+    }
+
     #[test]
     fn page_links_follow_extension_without_renaming_files() {
         let spec: crate::Spec = "bin ex\ncmd go\nconfig {\n prop jobs type=\"uint\"\n}\n"
@@ -413,9 +531,9 @@ mod tests {
     }
 
     #[test]
-    fn ignores_indented_and_longer_fences() {
+    fn handles_longer_fences_and_indented_code() {
         let input = "    ```\nindented <\n````\nlonger <";
-        let expected = "    ```\nindented <\n````\nlonger <";
+        let expected = "    ```\nindented <\n````\nlonger <";
 
         assert_eq!(escape_md(input, true), expected);
     }
diff --git a/lib/src/docs/markdown/templates/cmd_template.md.tera b/lib/src/docs/markdown/templates/cmd_template.md.tera
index d7f200818..581d06e18 100644
--- a/lib/src/docs/markdown/templates/cmd_template.md.tera
+++ b/lib/src/docs/markdown/templates/cmd_template.md.tera
@@ -161,7 +161,7 @@
 {%- endif %}
 
 {%- if multi %}
-{%- for cmd in cmd.subcommands | values | sort(attribute="usage") %}
+{%- for cmd in cmd.subcommands | values | filter(attribute="hide", value=false) | sort(attribute="usage") %}
 {%- if not cmd.hide or cmd.hide == false %}
 {%- if loop.first %}
 
diff --git a/lib/src/docs/markdown/templates/compact_cmd_template.md.tera b/lib/src/docs/markdown/templates/compact_cmd_template.md.tera
index 6706b7904..ca0185317 100644
--- a/lib/src/docs/markdown/templates/compact_cmd_template.md.tera
+++ b/lib/src/docs/markdown/templates/compact_cmd_template.md.tera
@@ -141,7 +141,7 @@
 {%- endif %}
 
 {%- if multi %}
-{%- for cmd in cmd.subcommands | values | sort(attribute="usage") %}
+{%- for cmd in cmd.subcommands | values | filter(attribute="hide", value=false) | sort(attribute="usage") %}
 {%- if not cmd.hide or cmd.hide == false %}
 {%- if loop.first %}
 
diff --git a/lib/src/docs/markdown/templates/index_template.md.tera b/lib/src/docs/markdown/templates/index_template.md.tera
index 2c120fc58..b1b06b58d 100644
--- a/lib/src/docs/markdown/templates/index_template.md.tera
+++ b/lib/src/docs/markdown/templates/index_template.md.tera
@@ -3,8 +3,6 @@
 {%- set full_cmd = cmd.full_cmd | join(sep=" ") %}
 {{- "#" | repeat(count=header_level) }} `{{ (spec.bin ~ " " ~ full_cmd) | trim }}`
 
-**Usage:** `{{ (spec.bin ~ " " ~ cmd.usage) | trim }}`
-
 {%- if spec.version %}
 
 **Version:** {{ spec.version }}