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
use clap::{Parser, ValueEnum};
use std::path::PathBuf;
#[cfg(feature = "unstable-dynamic")]
use clap_complete::engine::{ArgValueCompleter, CompletionCandidate, ValueCompleter};
#[derive(Parser, Debug)]
#[command(name = "treemd")]
#[command(version)]
#[command(about = "A markdown navigator with tree-based structural navigation")]
#[command(
long_about = "treemd - A modern markdown viewer combining tree-based navigation with interactive TUI.\n\n\
Launch without flags for interactive mode with dual-pane interface, vim-style navigation,\n\
syntax highlighting, and real-time search. Use flags for CLI mode to extract, filter,\n\
and analyze markdown structure.\n\n\
Examples:\n \
treemd README.md # Interactive TUI mode\n \
treemd -l README.md # List all headings\n \
treemd --tree README.md # Show heading tree\n \
treemd -s Installation doc.md # Extract section\n \
treemd --setup-completions # Set up shell completions"
)]
pub struct Cli {
/// Markdown file(s) to view (.md or .markdown), directory, or '-' for stdin
///
/// Path to the markdown file(s) to open. Use '-' to read from stdin.
/// If a directory is specified, opens file picker in that directory.
/// Multiple files can be specified for the file picker.
/// If no file is specified and stdin is piped, input is read from stdin.
///
/// Examples:
/// treemd README.md # Open file
/// treemd . # Open file picker in current directory
/// treemd docs/ # Open file picker in docs directory
/// treemd *.md # Open file picker with matched files
/// treemd - # Read from stdin
/// cat doc.md | treemd -l # Pipe markdown
#[arg(add = markdown_file_completer())]
pub file: Vec<PathBuf>,
#[command(subcommand)]
pub command: Option<Command>,
/// List all headings in the document (non-interactive)
///
/// Displays all headings with their level indicators (# for h1, ## for h2, etc.).
/// Combine with --filter or --level to narrow results.
#[arg(short = 'l', long = "list")]
pub list: bool,
/// Show heading tree structure with box-drawing characters (non-interactive)
///
/// Renders the document structure as a visual tree using Unicode box-drawing.
/// Shows parent-child relationships between headings hierarchically.
#[arg(long = "tree")]
pub tree: bool,
/// Filter headings by text pattern (case-insensitive)
///
/// Only shows headings containing the specified text.
/// Works with --list or --tree modes.
///
/// Example: --filter "install" matches "Installation" and "Installing"
#[arg(long = "filter", value_name = "PATTERN")]
pub filter: Option<String>,
/// Show only headings at specific level (1-6)
///
/// Filters headings by their level:
/// 1 = # (h1), 2 = ## (h2), 3 = ### (h3), etc.
///
/// Example: -L 2 shows only ## headings
#[arg(short = 'L', long = "level", value_name = "LEVEL")]
pub level: Option<usize>,
/// Output format for --list and --tree modes
///
/// Controls how headings are displayed:
/// plain - Human-readable text (default)
/// json - JSON array for scripting/parsing
/// tree - Box-drawing tree structure
#[arg(short = 'o', long = "output", default_value = "plain")]
pub output: OutputFormat,
/// Extract specific section by heading name
///
/// Extracts content from a heading until the next heading of same or higher level.
/// Useful for pulling specific sections from large documents.
///
/// Example: -s "Usage" extracts the Usage section
#[arg(short = 's', long = "section", value_name = "HEADING")]
pub section: Option<String>,
/// Count headings by level (shows statistics)
///
/// Displays a summary showing how many headings exist at each level (h1-h6)
/// and the total count.
#[arg(long = "count")]
pub count: bool,
/// Set up shell completions interactively
///
/// Interactive helper to configure tab completion for your shell (bash/zsh/fish).
/// Detects your shell, finds the config file, and offers to add completion setup.
/// Completions intelligently filter to show only .md/.markdown files.
#[arg(long = "setup-completions")]
pub setup_completions: bool,
/// Set theme for TUI mode
///
/// Override the saved theme preference. Available themes:
/// OceanDark, Nord, Dracula, Solarized, Monokai, Gruvbox, TokyoNight, CatppuccinMocha
///
/// Example: --theme Nord
#[arg(long = "theme", value_name = "THEME")]
pub theme: Option<String>,
/// Force color mode (auto, rgb, 256)
///
/// Override automatic terminal detection:
/// auto - Detect terminal capabilities (default)
/// rgb - Force true color (16M colors)
/// 256 - Force 256-color palette
///
/// Example: --color-mode 256
#[arg(long = "color-mode", value_name = "MODE")]
pub color_mode: Option<ColorModeArg>,
/// Disable image rendering in TUI mode
///
/// Skip all image loading and display. Useful for terminals that don't
/// support graphics protocols or if you prefer text-only rendering.
/// This overrides the config file setting.
#[arg(long = "no-images")]
pub no_images: bool,
/// Enable image rendering in TUI mode (override config)
///
/// Force image rendering even if disabled in config.toml.
/// Images will be displayed using the best available terminal graphics
/// protocol (Kitty, iTerm2, Sixel) with halfblock Unicode fallback.
#[arg(long = "images", conflicts_with = "no_images")]
pub images: bool,
/// Query expression for selecting/filtering document elements
///
/// Uses a jq-like syntax for navigating and extracting markdown structure.
/// Supports element selectors (.h2, .code, .link), filters, pipes, and more.
///
/// Examples:
/// -q '.h2' # All h2 headings
/// -q '.code[rust]' # Rust code blocks
/// -q '.h1[Features] > .h2' # h2s under "Features"
/// -q '.link | url' # All link URLs
/// -q '.h | select(contains("API"))' # Headings with "API"
///
/// See --query-help for full documentation.
#[arg(short = 'q', long = "query", value_name = "EXPR")]
pub query: Option<String>,
/// Show query language documentation and examples
///
/// Displays comprehensive help for the query language including:
/// - Element selectors (.h1-6, .code, .link, etc.)
/// - Filters and indexing
/// - Built-in functions
/// - Pipe composition
/// - Output formats
#[arg(long = "query-help")]
pub query_help: bool,
/// Output format for query results
///
/// Controls how query results are displayed:
/// plain - Human-readable text (default)
/// json - Compact JSON
/// jsonp - Pretty-printed JSON
/// jsonl - Line-delimited JSON
/// md - Raw markdown
/// tree - Tree structure
///
/// Example: -q '.h2' --query-output json
#[arg(long = "query-output", value_name = "FORMAT")]
pub query_output: Option<String>,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum ColorModeArg {
/// Automatically detect terminal capabilities
Auto,
/// Force RGB/true color mode
Rgb,
/// Force 256-color mode
#[value(name = "256")]
Color256,
}
#[derive(Debug, clap::Subcommand)]
pub enum Command {
/// Show heading at specific line number
///
/// Finds and displays the heading that appears at or before the given line number.
/// Useful for jumping to a specific location in the document structure.
AtLine {
/// Line number in the markdown file
///
/// The line number to search for. Returns the heading at or immediately
/// before this line.
line: usize,
},
}
#[derive(Debug, Clone, ValueEnum)]
pub enum OutputFormat {
/// Plain text output
Plain,
/// JSON output
Json,
/// Tree format with box-drawing
Tree,
}
#[cfg(feature = "unstable-dynamic")]
fn markdown_file_completer() -> ArgValueCompleter {
use std::ffi::OsStr;
use std::path::Path;
struct MarkdownCompleter;
impl ValueCompleter for MarkdownCompleter {
fn complete(&self, current: &OsStr) -> Vec<CompletionCandidate> {
// Parse the input to extract the directory being completed
// e.g., "../docs/README" -> directory="../docs", prefix="README"
let input_str = current.to_string_lossy();
let input_path = Path::new(input_str.as_ref());
// Determine which directory to search
let search_dir: &Path;
let prefix: String;
if input_str.is_empty() {
// No input yet, show current directory
search_dir = Path::new(".");
prefix = String::new();
} else if input_str.ends_with('/') || input_str.ends_with('\\') {
// Ends with separator, show contents of that directory
search_dir = input_path;
prefix = String::new();
} else {
// Partial path, show completions in parent directory
// NOTE: parent() returns Some("") for simple filenames like "R"
// We need to normalize empty paths to "." for correct completion
let parent = input_path.parent().unwrap_or(Path::new("."));
search_dir = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
prefix = input_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
};
// Read the target directory
let entries = match std::fs::read_dir(search_dir) {
Ok(entries) => entries,
Err(_) => return vec![],
};
entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
let is_dir = path.is_dir();
let file_name = path.file_name()?.to_string_lossy().to_string();
// Filter by prefix if provided
if !prefix.is_empty()
&& !file_name.to_lowercase().starts_with(&prefix.to_lowercase())
{
return None;
}
// Build the completion value relative to the original input
let completion_value = if search_dir == Path::new(".") {
file_name.clone()
} else {
search_dir.join(&file_name).to_string_lossy().to_string()
};
// Include directories and .md/.markdown files
if is_dir {
// Append trailing slash to directories for easier navigation
let mut dir_completion = completion_value;
if !dir_completion.ends_with('/') {
dir_completion.push('/');
}
Some(
CompletionCandidate::new(dir_completion).help(Some("directory".into())),
)
} else if let Some(ext) = path.extension() {
let ext_lower = ext.to_string_lossy().to_lowercase();
if ext_lower == "md" || ext_lower == "markdown" {
Some(CompletionCandidate::new(completion_value))
} else {
None
}
} else {
None
}
})
.collect::<Vec<_>>()
}
}
ArgValueCompleter::new(MarkdownCompleter)
}
#[cfg(not(feature = "unstable-dynamic"))]
fn markdown_file_completer() -> clap::builder::ValueHint {
clap::ValueHint::FilePath
}