@@ -10,6 +10,9 @@ use std::path::PathBuf;
1010
1111use anyhow:: { Result , anyhow} ;
1212use clap:: { Parser , Subcommand , ValueEnum } ;
13+ use pulldown_cmark:: {
14+ CodeBlockKind , Event as MarkdownEvent , HeadingLevel , Parser as MarkdownParser , Tag , TagEnd ,
15+ } ;
1316use tracing:: { Event , Subscriber } ;
1417use tracing_subscriber:: EnvFilter ;
1518use 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) ]
155338mod 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\n cargo 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 \n Paragraph 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