Skip to content

Commit 54c0cb6

Browse files
committed
Render built-in docs for terminals
1 parent 20fa504 commit 54c0cb6

4 files changed

Lines changed: 264 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to `devloop` will be recorded in this file.
44

5+
## [Unreleased]
6+
7+
### Changed
8+
- Render `devloop docs <topic>` output as terminal-friendly text instead
9+
of printing literal Markdown.
10+
511
## [0.6.0] - 2026-03-26
612

713
### Added

Cargo.lock

Lines changed: 36 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ axum = { version = "0.8.6", features = ["json", "tokio", "http1"] }
99
clap = { version = "4.5.39", features = ["derive"] }
1010
globset = "0.4.16"
1111
notify = "8.0.0"
12+
pulldown-cmark = "0.13.0"
1213
rand = "0.9.2"
1314
regex = "1.11.1"
1415
reqwest = { version = "0.12.15", default-features = false, features = ["http2", "json", "rustls-tls"] }

src/main.rs

Lines changed: 221 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ use std::path::PathBuf;
1010

1111
use anyhow::{Result, anyhow};
1212
use clap::{Parser, Subcommand, ValueEnum};
13+
use pulldown_cmark::{
14+
CodeBlockKind, Event as MarkdownEvent, HeadingLevel, Parser as MarkdownParser, Tag, TagEnd,
15+
};
1316
use tracing::{Event, Subscriber};
1417
use tracing_subscriber::EnvFilter;
1518
use tracing_subscriber::fmt::FmtContext;
@@ -83,7 +86,7 @@ async fn main() -> Result<()> {
8386
Engine::new(config).run().await?;
8487
}
8588
Command::Docs { topic } => {
86-
print!("{}", docs_text(topic));
89+
print!("{}", render_docs_text(topic));
8790
}
8891
}
8992
Ok(())
@@ -151,9 +154,192 @@ fn docs_text(topic: DocsTopic) -> &'static str {
151154
}
152155
}
153156

157+
fn render_docs_text(topic: DocsTopic) -> String {
158+
render_markdown_for_terminal(docs_text(topic))
159+
}
160+
161+
fn render_markdown_for_terminal(markdown: &str) -> String {
162+
#[derive(Default)]
163+
struct RenderState {
164+
output: String,
165+
heading_level: Option<HeadingLevel>,
166+
in_code_block: bool,
167+
in_link: bool,
168+
pending_link_destination: Option<String>,
169+
list_depth: usize,
170+
in_item: bool,
171+
needs_blank_line: bool,
172+
at_line_start: bool,
173+
}
174+
175+
impl RenderState {
176+
fn ensure_blank_line(&mut self) {
177+
if self.output.is_empty() {
178+
return;
179+
}
180+
if !self.output.ends_with("\n\n") {
181+
if !self.output.ends_with('\n') {
182+
self.output.push('\n');
183+
}
184+
self.output.push('\n');
185+
}
186+
self.at_line_start = true;
187+
}
188+
189+
fn ensure_line_start(&mut self) {
190+
if !self.at_line_start {
191+
self.output.push('\n');
192+
self.at_line_start = true;
193+
}
194+
}
195+
196+
fn push_text(&mut self, text: &str) {
197+
if text.is_empty() {
198+
return;
199+
}
200+
if self.at_line_start && self.in_item {
201+
let indent = " ".repeat(self.list_depth.saturating_sub(1));
202+
self.output.push_str(&indent);
203+
self.output.push_str("- ");
204+
self.at_line_start = false;
205+
}
206+
self.output.push_str(text);
207+
self.at_line_start = false;
208+
}
209+
}
210+
211+
let mut state = RenderState::default();
212+
213+
for event in MarkdownParser::new(markdown) {
214+
match event {
215+
MarkdownEvent::Start(Tag::Heading { level, .. }) => {
216+
state.ensure_blank_line();
217+
state.heading_level = Some(level);
218+
}
219+
MarkdownEvent::End(TagEnd::Heading(_)) => {
220+
state.output.push('\n');
221+
state.output.push('\n');
222+
state.at_line_start = true;
223+
state.heading_level = None;
224+
}
225+
MarkdownEvent::Start(Tag::Paragraph) => {
226+
if state.needs_blank_line {
227+
state.ensure_blank_line();
228+
state.needs_blank_line = false;
229+
}
230+
}
231+
MarkdownEvent::End(TagEnd::Paragraph) => {
232+
state.output.push('\n');
233+
state.output.push('\n');
234+
state.at_line_start = true;
235+
}
236+
MarkdownEvent::Start(Tag::List(_)) => {
237+
state.ensure_blank_line();
238+
state.list_depth += 1;
239+
}
240+
MarkdownEvent::End(TagEnd::List(_)) => {
241+
state.list_depth = state.list_depth.saturating_sub(1);
242+
state.output.push('\n');
243+
state.at_line_start = true;
244+
}
245+
MarkdownEvent::Start(Tag::Item) => {
246+
state.in_item = true;
247+
}
248+
MarkdownEvent::End(TagEnd::Item) => {
249+
state.output.push('\n');
250+
state.at_line_start = true;
251+
state.in_item = false;
252+
}
253+
MarkdownEvent::Start(Tag::CodeBlock(kind)) => {
254+
state.ensure_blank_line();
255+
if let CodeBlockKind::Fenced(info) = kind
256+
&& !info.is_empty()
257+
{
258+
state.push_text(&format!("[{info}]"));
259+
state.output.push('\n');
260+
state.at_line_start = true;
261+
}
262+
state.in_code_block = true;
263+
}
264+
MarkdownEvent::End(TagEnd::CodeBlock) => {
265+
state.output.push('\n');
266+
state.at_line_start = true;
267+
state.in_code_block = false;
268+
state.needs_blank_line = true;
269+
}
270+
MarkdownEvent::Start(Tag::Link { dest_url, .. }) => {
271+
state.in_link = true;
272+
state.pending_link_destination = Some(dest_url.to_string());
273+
}
274+
MarkdownEvent::End(TagEnd::Link) => {
275+
if let Some(dest) = state.pending_link_destination.take() {
276+
state.push_text(&format!(" ({dest})"));
277+
}
278+
state.in_link = false;
279+
}
280+
MarkdownEvent::Text(text) => {
281+
let rendered = if state.in_code_block {
282+
text.lines()
283+
.map(|line| format!(" {line}"))
284+
.collect::<Vec<_>>()
285+
.join("\n")
286+
} else if let Some(level) = state.heading_level {
287+
format_heading(&text, level)
288+
} else {
289+
text.to_string()
290+
};
291+
state.push_text(&rendered);
292+
}
293+
MarkdownEvent::Code(text) => {
294+
state.push_text(&format!("`{text}`"));
295+
}
296+
MarkdownEvent::SoftBreak => {
297+
if state.in_code_block {
298+
state.output.push('\n');
299+
state.at_line_start = true;
300+
} else {
301+
state.push_text(" ");
302+
}
303+
}
304+
MarkdownEvent::HardBreak => {
305+
state.ensure_line_start();
306+
}
307+
MarkdownEvent::Rule => {
308+
state.ensure_blank_line();
309+
state.push_text("----------------------------------------");
310+
state.output.push('\n');
311+
state.output.push('\n');
312+
state.at_line_start = true;
313+
}
314+
MarkdownEvent::Html(_)
315+
| MarkdownEvent::InlineHtml(_)
316+
| MarkdownEvent::InlineMath(_)
317+
| MarkdownEvent::DisplayMath(_)
318+
| MarkdownEvent::FootnoteReference(_)
319+
| MarkdownEvent::TaskListMarker(_)
320+
| MarkdownEvent::Start(_)
321+
| MarkdownEvent::End(_) => {}
322+
}
323+
}
324+
325+
state.output.trim_end().to_owned() + "\n"
326+
}
327+
328+
fn format_heading(text: &str, level: HeadingLevel) -> String {
329+
match level {
330+
HeadingLevel::H1 => text.to_uppercase(),
331+
HeadingLevel::H2 => format!("{text}\n{}", "=".repeat(text.len())),
332+
HeadingLevel::H3 => format!("{text}\n{}", "-".repeat(text.len())),
333+
HeadingLevel::H4 | HeadingLevel::H5 | HeadingLevel::H6 => text.to_string(),
334+
}
335+
}
336+
154337
#[cfg(test)]
155338
mod tests {
156-
use super::{Cli, DocsTopic, default_rust_log, docs_text, format_tracing_prefix};
339+
use super::{
340+
Cli, DocsTopic, default_rust_log, docs_text, format_tracing_prefix, render_docs_text,
341+
render_markdown_for_terminal,
342+
};
157343
use crate::output::{
158344
format_output_prefix, normalize_internal_log_label, normalize_source_label,
159345
};
@@ -231,6 +417,39 @@ mod tests {
231417
assert!(rendered.contains("startup_workflows"));
232418
}
233419

420+
#[test]
421+
fn rendered_docs_drop_markdown_heading_markers() {
422+
let rendered = render_docs_text(DocsTopic::Config);
423+
424+
assert!(rendered.starts_with("CONFIGURATION REFERENCE"));
425+
assert!(!rendered.contains("# Configuration Reference"));
426+
}
427+
428+
#[test]
429+
fn markdown_renderer_formats_lists_and_code_blocks() {
430+
let rendered =
431+
render_markdown_for_terminal("# Title\n\n- one\n- two\n\n```bash\ncargo test\n```\n");
432+
433+
assert!(rendered.contains("TITLE"));
434+
assert!(rendered.contains("- one"));
435+
assert!(rendered.contains("[bash]"));
436+
assert!(rendered.contains(" cargo test"));
437+
}
438+
439+
#[test]
440+
fn markdown_renderer_formats_links_inline_code_rules_and_nested_lists() {
441+
let rendered = render_markdown_for_terminal(
442+
"## Section\n\nParagraph with [link](https://example.com) and `inline`.\n\n- parent\n - child\n\n---\n",
443+
);
444+
445+
assert!(rendered.contains("Section\n======="));
446+
assert!(rendered.contains("link (https://example.com)"));
447+
assert!(rendered.contains("`inline`"));
448+
assert!(rendered.contains("- parent"));
449+
assert!(rendered.contains(" - child"));
450+
assert!(rendered.contains("----------------------------------------"));
451+
}
452+
234453
#[test]
235454
fn cli_parses_docs_subcommand() {
236455
let cli = Cli::try_parse_from(["devloop", "docs", "security"]).expect("parse cli");

0 commit comments

Comments
 (0)