///|
pub(all) struct Heading {
level : Int
title : String
line : Int
end_line : Int
path : Array[String]
} derive(Debug, Eq, ToJson)
///|
pub(all) struct Section {
heading : Heading
content : String
} derive(Debug, Eq, ToJson)
///|
pub(all) enum ToolOutput {
Stdout(String)
Stderr(String)
} derive(Debug, Eq, ToJson)
///|
pub(all) enum CliCommand {
Toc(String)
Section(String, String)
ParseOutput(ToolOutput)
} derive(Debug, Eq, ToJson)
///|
priv enum Selector {
Text(String)
Line(Int)
Range(Int, Int)
}
///|
pub fn read_toc(markdown : String) -> Array[Heading] {
let lines = split_lines(markdown)
let headings = cmark_headings(markdown)
for i in 0.. Array[Heading] {
let doc = @cmark.Doc::from_string(markdown, locs=true)
let headings : Array[Heading] = []
let stack : Array[String] = []
collect_cmark_headings(doc.block, headings, stack)
headings
}
///|
fn collect_cmark_headings(
block : @cmark.Block,
headings : Array[Heading],
stack : Array[String],
) -> Unit {
match block {
Heading(node) => {
let level = node.v.level
let title = inline_plain_text(node.v.inline)
while stack.length() >= level {
ignore(stack.pop())
}
stack.push(title)
headings.push({
level,
title,
line: first_line_number(node.meta.loc),
end_line: first_line_number(node.meta.loc),
path: stack.copy(),
})
}
Blocks(node) =>
for child in node.v.iter() {
collect_cmark_headings(child, headings, stack)
}
BlockQuote(node) => collect_cmark_headings(node.v.block, headings, stack)
List(node) =>
for item in node.v.items.iter() {
collect_cmark_headings(item.v.block, headings, stack)
}
ExtFootnoteDefinition(node) =>
collect_cmark_headings(node.v.block, headings, stack)
_ => ()
}
}
///|
fn inline_plain_text(inline : @cmark.Inline) -> String {
let out = StringBuilder::new()
let mut first_line = true
for line in inline.to_plain_text(break_on_soft=true).iter() {
if first_line {
first_line = false
} else {
out.write_string(" ")
}
for part in line.iter() {
out.write_string(part)
}
}
out.to_string()
}
///|
fn first_line_number(loc : @cmark_base.TextLoc) -> Int {
match loc.first_line {
LinePos(line, _) => line
}
}
///|
pub fn render_toc(markdown : String, file_path? : String) -> String {
let out = StringBuilder::new()
for heading in read_toc(markdown) {
for _ in 1.. Section? {
get_section_with_selector(markdown, parse_selector(query))
}
///|
pub fn render_section(
markdown : String,
file_path : String,
query : String,
) -> String? {
match get_section(markdown, query) {
Some(section) => {
let out = StringBuilder::new()
out.write_string(
render_reference(
Some(file_path),
section.heading.line,
section.heading.end_line,
),
)
out.write_string(" ")
out.write_string(section.heading.path.join("/"))
out.write_string("\n\n")
out.write_string(section.content)
Some(out.to_string())
}
None => None
}
}
///|
pub fn parse_command(argv : Array[String]) -> CliCommand {
let parser = cli_parser()
try parser.parse(argv=argv[1:], env={}) catch {
err => {
let message = err.to_string()
if message.has_prefix("Usage:") {
ParseOutput(Stdout(message))
} else {
ParseOutput(Stderr(message))
}
}
} noraise {
parsed => command_from_matches(parsed)
}
}
///|
pub fn toc_tool(file_path : String, markdown : String) -> ToolOutput {
Stdout(render_toc(markdown, file_path~))
}
///|
pub fn section_tool(
file_path : String,
markdown : String,
query : String,
) -> ToolOutput {
match render_section(markdown, file_path, query) {
Some(section) => Stdout(section)
None => Stderr("section not found: \{query}\n")
}
}
///|
fn cli_parser() -> @argparse.Command {
@argparse.Command(
"md-outline",
about="Read Markdown outlines and sections.",
subcommand_required=true,
arg_required_else_help=true,
subcommands=[
@argparse.Command(
"toc",
about="Print outline headings with source line ranges.",
positionals=[
@argparse.PositionArg(
"file",
about="Markdown file to inspect.",
num_args=@argparse.ValueRange::single(),
),
],
),
@argparse.Command(
"section",
about="Print a section selected by outline path or line reference.",
positionals=[
@argparse.PositionArg(
"file",
about="Markdown file to inspect.",
num_args=@argparse.ValueRange::single(),
),
@argparse.PositionArg(
"query",
about="Outline path, heading title, start line, or line range.",
num_args=@argparse.ValueRange(lower=1),
allow_hyphen_values=true,
),
],
),
],
)
}
///|
fn command_from_matches(matches : @argparse.Matches) -> CliCommand {
match matches.subcommand {
Some(("toc", sub)) =>
match single_value(sub, "file") {
Some(file) => Toc(file)
None => ParseOutput(Stderr(cli_parser().render_help()))
}
Some(("section", sub)) =>
match (single_value(sub, "file"), values(sub, "query")) {
(Some(file), Some(query)) => Section(file, query.join(" "))
_ => ParseOutput(Stderr(cli_parser().render_help()))
}
_ => ParseOutput(Stderr(cli_parser().render_help()))
}
}
///|
fn single_value(matches : @argparse.Matches, name : StringView) -> String? {
match matches.values.get_from_string(name) {
Some(values) =>
match values.get(0) {
Some(value) => Some(value)
None => None
}
None => None
}
}
///|
fn values(matches : @argparse.Matches, name : StringView) -> Array[String]? {
matches.values.get_from_string(name)
}
///|
fn get_section_with_selector(
markdown : String,
selector : Selector,
) -> Section? {
let headings = read_toc(markdown)
let mut index = -1
for i in 0.. Bool {
match selector {
Text(wanted) => heading.title == wanted || heading.path.join("/") == wanted
Line(line) => heading.line <= line && line <= heading.end_line
Range(start_line, end_line) =>
heading.line == start_line && heading.end_line == end_line
}
}
///|
fn parse_selector(query : String) -> Selector {
let raw = query.trim().to_owned()
let text = strip_reference(raw)
match text.split_once("-") {
Some((start, end)) =>
match
(
parse_positive_int(start.to_owned()),
parse_positive_int(end.to_owned()),
) {
(Some(start_line), Some(end_line)) if start_line <= end_line =>
Range(start_line, end_line)
_ => Text(text)
}
None =>
match parse_positive_int(text) {
Some(line) => Line(line)
None => Text(text)
}
}
}
///|
fn strip_reference(raw : String) -> String {
let mut text = raw
if text.has_prefix("[") && text.has_suffix("]") && text.length() >= 2 {
text = text.unsafe_substring(start=1, end=text.length() - 1)
}
match text.rev_split_once(":") {
Some((_, suffix)) => suffix.to_owned()
None => text
}
}
///|
fn parse_positive_int(text : String) -> Int? {
if text.is_empty() {
None
} else {
let mut value = 0
for ch in text {
if ch < '0' || ch > '9' {
return None
}
value = value * 10 + (ch.to_int() - '0'.to_int())
}
Some(value)
}
}
///|
fn render_reference(
file_path : String?,
start_line : Int,
end_line : Int,
) -> String {
match file_path {
Some(path) => "[\{path}:\{start_line}-\{end_line}]"
None => "[line:\{start_line}-\{end_line}]"
}
}
///|
fn split_lines(text : String) -> Array[String] {
let lines : Array[String] = []
let mut start = 0
for offset, ch in text.iter2() {
if ch == '\n' {
lines.push(text.unsafe_substring(start~, end=offset + 1))
start = offset + 1
}
}
if start < text.length() {
lines.push(text.unsafe_substring(start~, end=text.length()))
}
lines
}