diff --git a/plugins/core.py b/plugins/core.py index df08981..80eb788 100644 --- a/plugins/core.py +++ b/plugins/core.py @@ -8,18 +8,20 @@ from derp import __version__ from derp.plugin import command -def _build_cmd_detail(handler, prefix: str) -> str: - """Extract and format a command's docstring into a detail block. +def _build_cmd_detail(handler, prefix: str, indent: int = 0) -> str: + """Format command header + docstring at the given indent level. - Command name anchors the left edge; docstring body is indented 4 spaces. + Command name sits at *indent*, docstring body at *indent + 4*. + Returns just the header line when no docstring exists. """ - doc = textwrap.dedent(handler.callback.__doc__ or "").strip() - if not doc: - return "" - header = f"{prefix}{handler.name}" + pad = " " * indent + header = f"{pad}{prefix}{handler.name}" if handler.help: header += f" -- {handler.help}" - indented = textwrap.indent(doc, " ") + doc = textwrap.dedent(handler.callback.__doc__ or "").strip() + if not doc: + return header + indented = textwrap.indent(doc, " " * (indent + 4)) return f"{header}\n{indented}" @@ -57,8 +59,8 @@ async def cmd_help(bot, message): if handler and bot._plugin_allowed(handler.plugin, channel): help_text = handler.help or "No help available." reply = f"{bot.prefix}{name} -- {help_text}" - detail = _build_cmd_detail(handler, bot.prefix) - if detail: + if (handler.callback.__doc__ or "").strip(): + detail = _build_cmd_detail(handler, bot.prefix) url = await _paste(bot, detail) if url: reply += f" | {url}" @@ -77,15 +79,20 @@ async def cmd_help(bot, message): if cmds: lines.append(f"Commands: {', '.join(bot.prefix + c for c in cmds)}") reply = " | ".join(lines) - # Build detail block for all plugin commands - blocks = [] + # Build detail: plugin header + indented commands + section_lines = [f"[{name}]"] + if desc: + section_lines.append(f" {desc}") + section_lines.append("") + has_detail = False for cmd_name in cmds: h = bot.registry.commands[cmd_name] - blk = _build_cmd_detail(h, bot.prefix) - if blk: - blocks.append(blk) - if blocks: - url = await _paste(bot, "\n\n".join(blocks)) + section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4)) + section_lines.append("") + if (h.callback.__doc__ or "").strip(): + has_detail = True + if has_detail: + url = await _paste(bot, "\n".join(section_lines).rstrip()) if url: reply += f" | {url}" await bot.reply(message, reply) @@ -106,27 +113,21 @@ async def cmd_help(bot, message): for cmd_name in names: h = bot.registry.commands[cmd_name] plugins.setdefault(h.plugin, []).append(cmd_name) - blocks = [] + sections = [] for plugin_name in sorted(plugins): mod = bot.registry._modules.get(plugin_name) desc = (getattr(mod, "__doc__", "") or "").split("\n")[0].strip() if mod else "" - header = f"[{plugin_name}]" + section_lines = [f"[{plugin_name}]"] if desc: - header += f"\n {desc}" - cmd_lines = [] + section_lines.append(f" {desc}") + section_lines.append("") for cmd_name in plugins[plugin_name]: h = bot.registry.commands[cmd_name] - detail = _build_cmd_detail(h, bot.prefix) - if detail: - cmd_lines.append(detail) - else: - line = f"{bot.prefix}{cmd_name}" - if h.help: - line += f" -- {h.help}" - cmd_lines.append(line) - blocks.append(header + "\n\n" + "\n\n".join(cmd_lines)) - if blocks: - url = await _paste(bot, "\n\n".join(blocks)) + section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4)) + section_lines.append("") + sections.append("\n".join(section_lines).rstrip()) + if sections: + url = await _paste(bot, "\n\n".join(sections)) if url: reply += f" | {url}" await bot.reply(message, reply) diff --git a/tests/test_core.py b/tests/test_core.py index 08108e5..aa20e77 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -237,8 +237,8 @@ class TestHelpCommand: assert "!widget -- Manage widgets" in bot.replied[0] assert "https://" not in bot.replied[0] - def test_help_paste_body_indented(self): - """Paste body has command name flush-left, docstring indented.""" + def test_help_cmd_paste_hierarchy(self): + """Single-command paste: header at 0, docstring at 4.""" bot = _FakeBot() pastes: list[str] = [] bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes) @@ -250,9 +250,39 @@ class TestHelpCommand: asyncio.run(_mod.cmd_help(bot, msg)) assert len(pastes) == 1 lines = pastes[0].split("\n") - # First line: command header, no indent + # Level 0: command header flush-left assert lines[0] == "!widget -- Manage widgets" - # Docstring lines are indented 4 spaces + # Level 1: docstring lines indented 4 spaces for line in lines[1:]: if line.strip(): assert line.startswith(" "), f"not indented: {line!r}" + + def test_help_list_paste_hierarchy(self): + """Full reference paste: plugin at 0, command at 4, doc at 8.""" + bot = _FakeBot() + pastes: list[str] = [] + bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes) + mod = types.ModuleType("core") + mod.__doc__ = "Core plugin." + bot.registry._modules["core"] = mod + bot.registry.commands["state"] = _FakeHandler( + name="state", callback=_cmd_with_doc, + help="Inspect state", plugin="core", + ) + msg = _Msg(text="!help") + asyncio.run(_mod.cmd_help(bot, msg)) + assert len(pastes) == 1 + text = pastes[0] + lines = text.split("\n") + # Level 0: plugin header + assert lines[0] == "[core]" + # Level 1: plugin description + assert lines[1] == " Core plugin." + # Blank separator + assert lines[2] == "" + # Level 1: command header at indent 4 + assert lines[3] == " !state -- Inspect state" + # Level 2: docstring at indent 8 + for line in lines[4:]: + if line.strip(): + assert line.startswith(" "), f"not at indent 8: {line!r}"