treemd 0.5.12

A markdown navigator with tree-based structural navigation and syntax highlighting
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
//! # treemd
//!
//! A markdown navigator with tree-based structural navigation and syntax highlighting.
//!
//! ## Features
//!
//! - Interactive TUI with dual-pane interface (outline + content)
//! - CLI mode for scripting and automation
//! - Syntax-highlighted code blocks (50+ languages)
//! - Tree-based navigation with expand/collapse
//! - Search and filter headings
//! - Multiple output formats (plain, JSON, tree)
//!
//! ## Usage
//!
//! Launch the interactive TUI:
//! ```sh
//! treemd README.md
//! ```
//!
//! List all headings:
//! ```sh
//! treemd -l README.md
//! ```
//!
//! Show heading tree:
//! ```sh
//! treemd --tree README.md
//! ```

mod cli;

use clap::Parser as ClapParser;
use cli::{Cli, OutputFormat};
use color_eyre::Result;
use std::collections::HashMap;
use std::process;
use treemd::{Document, parser};

fn main() -> Result<()> {
    color_eyre::install()?;

    // Handle dynamic shell completions
    #[cfg(feature = "unstable-dynamic")]
    clap_complete::CompleteEnv::with_factory(|| {
        use clap::CommandFactory;
        Cli::command()
    })
    .complete();

    let args = Cli::parse();

    // Handle completion setup
    #[cfg(feature = "unstable-dynamic")]
    if args.setup_completions {
        match cli::setup::setup_completions_interactive("treemd") {
            Ok(_) => return Ok(()),
            Err(e) => {
                eprintln!("Error setting up completions: {}", e);
                cli::setup::print_completion_instructions("treemd");
                process::exit(1);
            }
        }
    }

    // Handle --query-help (doesn't require input)
    if args.query_help {
        print_query_help();
        return Ok(());
    }

    // For TUI mode with piped stdin, we'll read stdin first, then open TUI
    // This allows elegant piping: tree | treemd
    //
    // How it works:
    // 1. Read all of stdin into memory (tree output, markdown, etc.)
    // 2. Process and parse the content
    // 3. ratatui/crossterm opens /dev/tty for keyboard input (not stdin)
    // 4. TUI displays the processed content with full interactivity
    //
    // This is the standard pattern used by: less, fzf, bat, etc.

    // Determine input source - check for file picker case first
    let (input_source, needs_file_picker, file_picker_dir) = match args.file.len() {
        0 => {
            // No file provided - check for .md files in cwd
            use std::fs;
            let cwd = std::env::current_dir().unwrap_or_default();
            let md_files: Vec<_> = fs::read_dir(&cwd)
                .ok()
                .into_iter()
                .flatten()
                .filter_map(|entry| entry.ok())
                .filter(|entry| {
                    let path = entry.path();
                    path.is_file()
                        && path
                            .extension()
                            .and_then(|ext| ext.to_str())
                            .map(|ext| ext == "md" || ext == "markdown")
                            .unwrap_or(false)
                })
                .collect();

            if md_files.is_empty() {
                eprintln!("No markdown files found in current directory.");
                eprintln!("\nUsage: treemd [OPTIONS] <FILE>");
                eprintln!("       treemd [OPTIONS] -");
                eprintln!("       treemd [OPTIONS] .           # Open file picker");
                eprintln!("       tree | treemd [OPTIONS]\n");
                eprintln!("Tip: Navigate to a directory with .md files, or specify a file path.");
                eprintln!("\nFor shell completion setup, use:");
                eprintln!("  treemd --setup-completions");
                std::process::exit(0);
            }

            // Create dummy document to show file picker
            (
                treemd::input::InputSource::Stdin(
                    "# Select a file\n\nPress Enter to select a markdown file.".to_string(),
                ),
                true,
                None,
            )
        }
        1 => {
            let file_path = &args.file[0];
            // Check if it's a directory
            if file_path.is_dir() {
                // Directory provided - open file picker in that directory
                (
                    treemd::input::InputSource::Stdin(
                        "# Select a file\n\nPress Enter to select a markdown file.".to_string(),
                    ),
                    true,
                    Some(file_path.clone()),
                )
            } else {
                // Single file path was provided - use existing logic
                match treemd::input::determine_input_source(Some(file_path.as_path())) {
                    Ok(source) => (source, false, None),
                    Err(treemd::input::InputError::NoTty) => {
                        eprintln!("Error: markdown file argument is required");
                        eprintln!("\nUsage: treemd [OPTIONS] <FILE>");
                        eprintln!("       treemd [OPTIONS] -");
                        eprintln!("       treemd [OPTIONS] .           # Open file picker");
                        eprintln!("       tree | treemd [OPTIONS]\n");
                        eprintln!(
                            "Use '-' to explicitly read from stdin, or pipe input with CLI flags."
                        );
                        eprintln!("\nFor shell completion setup, use:");
                        eprintln!("  treemd --setup-completions");
                        std::process::exit(1);
                    }
                    Err(e) => {
                        eprintln!("Error reading input: {}", e);
                        process::exit(1);
                    }
                }
            }
        }
        _ => {
            // Multiple files provided - open first file, others available in file picker
            // For now, just open the first file (file picker will show all files in same dir)
            let file_path = &args.file[0];
            if file_path.is_dir() {
                // If first arg is a directory, use it for file picker
                (
                    treemd::input::InputSource::Stdin(
                        "# Select a file\n\nPress Enter to select a markdown file.".to_string(),
                    ),
                    true,
                    Some(file_path.clone()),
                )
            } else {
                match treemd::input::determine_input_source(Some(file_path.as_path())) {
                    Ok(source) => (source, false, None),
                    Err(treemd::input::InputError::NoTty) => {
                        eprintln!("Error: markdown file argument is required");
                        std::process::exit(1);
                    }
                    Err(e) => {
                        eprintln!("Error reading input: {}", e);
                        process::exit(1);
                    }
                }
            }
        }
    };

    // Check if stdin was piped (before consuming input_source)
    let stdin_was_piped = matches!(input_source, treemd::input::InputSource::Stdin(_));

    // Process input (handles tree format conversion, markdown passthrough, etc.)
    let markdown_content = match treemd::input::process_input(input_source) {
        Ok(content) => content,
        Err(e) => {
            eprintln!("Error processing input: {}", e);
            process::exit(1);
        }
    };

    // Parse the markdown content
    let doc = parser::parse_markdown(&markdown_content);

    // Handle query mode
    if let Some(ref query_str) = args.query {
        return handle_query_mode(&doc, query_str, args.query_output.as_deref());
    }

    #[cfg(feature = "unstable-dynamic")]
    let setup_completions_requested = args.setup_completions;
    #[cfg(not(feature = "unstable-dynamic"))]
    let setup_completions_requested = false;

    // If no flags, launch TUI
    if !args.list
        && !args.tree
        && !args.count
        && args.section.is_none()
        && args.at_line.is_none()
        && !setup_completions_requested
    {
        // Load configuration
        let mut config = treemd::Config::load();

        // Apply theme override from CLI if provided
        if let Some(ref theme_name) = args.theme {
            config.ui.theme = theme_name.clone();
        }

        // Detect terminal capabilities and determine color mode
        // Priority: CLI args > config file > auto-detection
        let caps = treemd::tui::TerminalCapabilities::detect();
        let color_mode = if let Some(ref mode_arg) = args.color_mode {
            // CLI flag takes highest priority
            use cli::ColorModeArg;
            use treemd::tui::ColorMode;
            match mode_arg {
                ColorModeArg::Auto => caps.recommended_color_mode,
                ColorModeArg::Rgb => ColorMode::Rgb,
                ColorModeArg::Color256 => ColorMode::Indexed256,
            }
        } else {
            // Check config file setting before falling back to auto-detection
            use treemd::tui::ColorMode;
            match config.terminal.color_mode.as_str() {
                "rgb" => ColorMode::Rgb,
                "256" => ColorMode::Indexed256,
                // "auto" or any other value falls back to detection
                _ => caps.recommended_color_mode,
            }
        };

        // Show compatibility warning if needed (before TUI init)
        // Skip the warning prompt if stdin was piped (already consumed)
        if caps.should_warn && !config.terminal.warned_terminal_app {
            if let Some(warning) = caps.warning_message() {
                eprintln!("\n{}\n", warning);
                // Only wait for keypress if stdin is still available (not piped)
                if !stdin_was_piped {
                    use std::io::{Read, stdin};
                    let _ = stdin().read(&mut [0u8]);
                } else {
                    eprintln!("Press any key in the TUI to continue...");
                }
            }
            // Mark that we've warned the user
            let _ = config.set_warned_terminal_app();
        }

        // Initialize terminal with explicit error handling
        // When stdin is piped, we use /dev/tty for input (handled by tui::tty module)
        use crossterm::ExecutableCommand;
        use crossterm::event::EnableMouseCapture;
        use crossterm::terminal::EnterAlternateScreen;
        use std::io::stdout;

        // Install panic hook before entering raw mode so the terminal is
        // restored if anything below panics.
        treemd::tui::tty::install_panic_hook();

        // Manually initialize to get better error messages
        // Use our custom enable_raw_mode that handles piped stdin
        treemd::tui::tty::enable_raw_mode().inspect_err(|e| {
            eprintln!("Failed to enable raw mode: {}", e);
            eprintln!("Note: When piping input, ensure you have a controlling terminal.");
        })?;

        stdout().execute(EnterAlternateScreen).inspect_err(|_| {
            treemd::tui::tty::disable_raw_mode().ok();
        })?;

        // Mouse capture: best-effort. Some terminals don't support it; that's
        // fine — keyboard navigation still works.
        let _ = stdout().execute(EnableMouseCapture);

        let backend = ratatui::backend::CrosstermBackend::new(stdout());
        let mut terminal = ratatui::Terminal::new(backend).inspect_err(|_| {
            treemd::tui::tty::disable_raw_mode().ok();
        })?;

        // Get filename and path (use placeholders for stdin)
        let (filename, file_path) = if !args.file.is_empty() && !args.file[0].is_dir() {
            let file = &args.file[0];
            let name = file
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("stdin")
                .to_string();
            let path = file.canonicalize().unwrap_or_else(|_| file.clone());
            (name, path)
        } else {
            // Stdin input or directory
            ("stdin".to_string(), std::path::PathBuf::from("<stdin>"))
        };

        // Determine if images are enabled
        // Priority: CLI flags > config file > default (true)
        let images_enabled = if args.no_images {
            false
        } else if args.images {
            true
        } else {
            config.images.enabled
        };

        let mut app =
            treemd::App::new(doc, filename, file_path, config, color_mode, images_enabled);
        if needs_file_picker {
            app.startup_needs_file_picker = true;
        }
        if let Some(dir) = file_picker_dir {
            app.file_picker_dir = Some(dir.canonicalize().unwrap_or(dir));
        }
        let result = treemd::tui::run(&mut terminal, app);

        // Cleanup terminal state
        use crossterm::event::DisableMouseCapture;
        use crossterm::terminal::LeaveAlternateScreen;
        stdout().execute(DisableMouseCapture).ok();
        stdout().execute(LeaveAlternateScreen).ok();
        treemd::tui::tty::disable_raw_mode().ok();

        return result;
    }

    // Handle CLI commands
    handle_cli_mode(&args, &doc);
    Ok(())
}

fn handle_cli_mode(args: &Cli, doc: &Document) {
    // Apply filters
    let headings: Vec<_> = if let Some(level) = args.level {
        doc.headings_at_level(level)
    } else if let Some(ref filter) = args.filter {
        doc.filter_headings(filter)
    } else {
        doc.headings.iter().collect()
    };

    // --at-line takes precedence over the other flag-based modes.
    if let Some(line) = args.at_line {
        print_heading_at_line(doc, line);
        return;
    }

    // Handle different modes
    if args.count {
        print_heading_counts(doc);
    } else if args.tree {
        print_tree(doc, &args.output, &headings);
    } else if let Some(ref section_name) = args.section {
        extract_section(doc, section_name);
    } else if args.list {
        print_headings(&headings, &args.output, doc);
    }
}

/// Print the heading at or immediately before `target_line` (1-indexed).
///
/// Used by the `at-line` subcommand. Walks the heading list once, counting
/// newlines between consecutive offsets so the whole call is O(content_len)
/// rather than O(headings * content_len).
fn print_heading_at_line(doc: &Document, target_line: usize) {
    if target_line == 0 {
        eprintln!("Error: line number must be >= 1");
        process::exit(1);
    }

    let mut best: Option<&parser::Heading> = None;
    let mut prev_line = 1usize;
    let mut prev_offset = 0usize;

    for h in &doc.headings {
        let h_line = prev_line + doc.content[prev_offset..h.offset].matches('\n').count();
        if h_line <= target_line {
            best = Some(h);
        } else {
            break;
        }
        prev_line = h_line;
        prev_offset = h.offset;
    }

    match best {
        Some(h) => {
            let prefix = "#".repeat(h.level);
            println!("{} {}", prefix, h.text);
        }
        None => {
            eprintln!("No heading at or before line {}", target_line);
            process::exit(1);
        }
    }
}

fn print_headings(headings: &[&parser::Heading], format: &OutputFormat, doc: &Document) {
    match format {
        OutputFormat::Plain => {
            for heading in headings {
                let prefix = "#".repeat(heading.level);
                println!("{} {}", prefix, heading.text);
            }
        }
        OutputFormat::Json => {
            // Use new nested JSON output with markdown intelligence
            let json_output = parser::build_json_output(doc, None);
            let json = serde_json::to_string_pretty(&json_output)
                .expect("JSON serialization of document output should not fail");
            println!("{}", json);
        }
        OutputFormat::Tree => {
            eprintln!("Use --tree for tree output");
            process::exit(1);
        }
    }
}

fn print_tree(doc: &Document, format: &OutputFormat, headings: &[&parser::Heading]) {
    // Build the tree from the (possibly filtered) heading subset so that
    // --tree --filter / --tree --level honor the docstring. When no filter
    // is in play, `headings` is the full list and we get the same tree as
    // doc.build_tree().
    let tree = if headings.len() == doc.headings.len() {
        doc.build_tree()
    } else {
        let owned: Vec<parser::Heading> = headings.iter().map(|&h| h.clone()).collect();
        // build_tree only inspects self.headings, so empty content is fine.
        Document::new(String::new(), owned).build_tree()
    };

    let config = treemd::Config::load();
    let compact = config.is_compact_tree();

    match format {
        OutputFormat::Tree | OutputFormat::Plain => {
            for (i, node) in tree.iter().enumerate() {
                let is_last = i == tree.len() - 1;
                print!("{}", node.render_box_tree_styled("", is_last, compact));
            }
        }
        OutputFormat::Json => {
            // For JSON, serialize the (filtered) flat headings list.
            // Tree serialization would need custom implementation.
            let owned: Vec<parser::Heading> = headings.iter().map(|&h| h.clone()).collect();
            let json = serde_json::to_string_pretty(&owned)
                .expect("JSON serialization of headings should not fail");
            println!("{}", json);
        }
    }
}

fn print_heading_counts(doc: &Document) {
    let mut counts: HashMap<usize, usize> = HashMap::new();

    for heading in &doc.headings {
        *counts.entry(heading.level).or_insert(0) += 1;
    }

    println!("Heading counts:");
    for level in 1..=6 {
        if let Some(count) = counts.get(&level) {
            let prefix = "#".repeat(level);
            println!("  {}: {}", prefix, count);
        }
    }
    println!("\nTotal: {}", doc.headings.len());
}

fn extract_section(doc: &Document, section_name: &str) {
    let heading = match doc.find_heading(section_name) {
        Some(h) => h,
        None => {
            eprintln!("Section '{}' not found", section_name);
            process::exit(1);
        }
    };

    // Use stored byte offsets instead of string-searching the rendered heading.
    // String search is wrong when the heading source has inline markdown
    // (e.g. `## **Bold** Section`) since heading.text is the stripped form.
    let start = heading.offset;
    let level = heading.level;
    let end = doc
        .headings
        .iter()
        .find(|h| h.offset > start && h.level <= level)
        .map(|h| h.offset)
        .unwrap_or(doc.content.len());

    println!("{}", doc.content[start..end].trim());
}

fn handle_query_mode(doc: &Document, query_str: &str, output_format: Option<&str>) -> Result<()> {
    use treemd::query::{self, OutputFormat};

    // Parse output format
    let format = output_format
        .map(|s| s.parse::<OutputFormat>())
        .transpose()
        .map_err(|e| {
            eprintln!("Error: {}", e);
            process::exit(1);
        })?
        .unwrap_or(OutputFormat::Plain);

    // Execute query
    match query::execute(doc, query_str) {
        Ok(results) => {
            if results.is_empty() {
                // No results - exit silently like jq
                return Ok(());
            }
            let output = query::format_output(&results, format);
            println!("{}", output);
            Ok(())
        }
        Err(e) => {
            eprintln!("{}", e);
            process::exit(1);
        }
    }
}

fn print_query_help() {
    let help = r#"
treemd Query Language (tql)

A jq-like query language for navigating and extracting markdown structure.

ELEMENT SELECTORS
    .h, .heading    All headings (any level)
    .h1 - .h6       Headings by level
    .code           All code blocks
    .code[rust]     Code blocks by language
    .link, .a       All links
    .link[external] External links only
    .img            All images
    .table          All tables
    .list           All lists
    .blockquote     All blockquotes

FILTERS & INDEXING
    .h2[Features]       Heading containing "Features" (fuzzy)
    .h2["Installation"] Heading with exact text
    .h2[0]              First h2
    .h2[-1]             Last h2
    .h2[1:3]            h2s at index 1 and 2
    .h2[:3]             First 3 h2s

HIERARCHY
    .h1 > .h2           Direct child h2s under h1s
    .h1 >> .code        Code blocks anywhere under h1s

PIPES
    .h2 | text          Get heading text (strips ##)
    [.h2] | count       Count all h2s
    .code | lang        Get code block languages
    .link | url         Get link URLs

COLLECTION FUNCTIONS
    count, length       Count elements (alias: len, size)
    first, last         First/last element (alias: head)
    limit(n), take(n)   First n elements
    skip(n), drop(n)    Skip first n elements
    nth(n)              Get element at index
    reverse             Reverse order
    sort                Sort alphabetically
    sort_by(key)        Sort by property
    unique              Remove duplicates
    flatten             Flatten nested arrays
    group_by(key)       Group elements by key
    min, max            Min/max numeric value
    add                 Sum numbers or concat strings

STRING FUNCTIONS
    text                Get text representation
    upper, lower        Case conversion
    trim                Strip whitespace
    split(sep)          Split by separator
    join(sep)           Join with separator
    replace(a, b)       Replace substring
    slugify             URL-friendly slug
    lines, words, chars Count lines/words/chars

FILTER FUNCTIONS
    select(cond)        Keep if condition true (alias: where, filter)
    contains(s)         Contains substring (alias: includes)
    startswith(s)       Starts with prefix
    endswith(s)         Ends with suffix
    matches(regex)      Matches regex pattern
    any, all            Check if any/all truthy
    not                 Negate boolean

CONTENT FUNCTIONS
    content             Section content (for headings)
    md                  Raw markdown
    url, href, src      Get URL/link/image source
    lang                Code block language

AGGREGATION FUNCTIONS
    stats               Document statistics
    levels              Heading count by level
    langs               Code block count by language
    types               Link types count

EXAMPLES
    # List all h2 headings
    treemd -q '.h2' doc.md

    # Get heading text only
    treemd -q '.h2 | text' doc.md

    # Count headings
    treemd -q '[.h2] | count' doc.md

    # First 5 headings
    treemd -q '[.h] | limit(5)' doc.md

    # Filter headings (three equivalent ways)
    treemd -q '.h | select(contains("API"))' doc.md
    treemd -q '.h | where(contains("API"))' doc.md
    treemd -q '.h[API]' doc.md

    # All Rust code blocks
    treemd -q '.code[rust]' doc.md

    # External link URLs
    treemd -q '.link[external] | url' doc.md

    # h2s under "Features" section
    treemd -q '.h1[Features] > .h2' doc.md

    # Group headings by level
    treemd -q '[.h] | group_by("level")' doc.md

    # Document statistics
    treemd -q '. | stats' doc.md

    # JSON output
    treemd -q '.h2' --query-output json doc.md

OUTPUT FORMATS (--query-output)
    plain       Human-readable text (default)
    json        Compact JSON
    json-pretty Pretty-printed JSON (alias: jsonp)
    jsonl       Line-delimited JSON (one per line)
    md          Raw markdown
    tree        Tree structure

For more details, see: https://github.com/epistates/treemd
"#;
    println!("{}", help.trim());
}