///|
/// Heading discovered from a Markdown document.
pub(all) struct MarkdownHeading {
level : Int
text : String
line : Int
} derive(Debug, Eq)
///|
/// Fenced code block discovered from a Markdown document.
pub(all) struct MarkdownCodeBlock {
language : String
content : String
start_line : Int
end_line : Int
} derive(Debug, Eq)
///|
/// Static facts extracted from a repository Markdown document.
pub(all) struct MarkdownDocument {
path : String
headings : Array[MarkdownHeading]
code_blocks : Array[MarkdownCodeBlock]
links : Array[String]
image_links : Array[String]
command_examples : Array[String]
line_count : Int
} derive(Debug, Eq)
///|
pub fn MarkdownDocument::has_heading(
self : MarkdownDocument,
expected : String,
) -> Bool {
self.headings.any(heading => heading.text == expected)
}
///|
pub fn MarkdownDocument::has_heading_casefold(
self : MarkdownDocument,
expected : String,
) -> Bool {
self.headings.any(heading => {
heading.text == expected ||
heading.text.contains(expected) ||
expected.contains(heading.text)
})
}
///|
pub fn MarkdownDocument::heading_location(
self : MarkdownDocument,
expected : String,
) -> SourceLocation? {
for heading in self.headings {
if heading.text == expected {
return Some(SourceLocation::new(self.path, heading.line, 1))
}
}
None
}
///|
pub fn MarkdownDocument::has_command(
self : MarkdownDocument,
command : String,
) -> Bool {
self.command_examples.any(example => example.contains(command))
}
///|
pub fn MarkdownDocument::summary(self : MarkdownDocument) -> String {
"\{self.headings.length()} headings, \{self.code_blocks.length()} code blocks, \{self.links.length()} links"
}
///|
fn heading_level(line : String) -> Int {
let mut level = 0
for character in line {
if character == '#' {
level += 1
} else {
break
}
}
level
}
///|
fn heading_text(line : String, level : Int) -> String {
if level == 0 || line.length() <= level {
""
} else {
line[level:].trim().to_owned()
}
}
///|
fn fence_language(line : String) -> String? {
let trimmed = line.trim().to_owned()
if trimmed.has_prefix("```") {
Some(trimmed[3:].trim().to_owned())
} else {
None
}
}
///|
fn bracket_links(line : String) -> Array[String] {
let links : Array[String] = []
let pieces = line.split("](").to_array()
let mut index = 1
while index < pieces.length() {
let piece = pieces[index]
match piece.split(")").to_array() {
[target, ..] => links.push(target.to_owned())
[] => ()
}
index += 1
}
links
}
///|
fn looks_like_command(line : String) -> Bool {
let trimmed = line.trim().to_owned()
trimmed.has_prefix("moon ") ||
trimmed.has_prefix("git ") ||
trimmed.has_prefix("curl ") ||
trimmed.has_prefix("powershell ")
}
///|
/// Parse common Markdown structures without depending on a browser or network.
pub fn parse_markdown(path : String, content : String) -> MarkdownDocument {
let headings : Array[MarkdownHeading] = []
let code_blocks : Array[MarkdownCodeBlock] = []
let links : Array[String] = []
let image_links : Array[String] = []
let command_examples : Array[String] = []
let mut line_number = 1
let mut active_language : String? = None
let mut active_start = 0
let active_lines : Array[String] = []
for raw in content.split("\n") {
let line = raw.to_owned()
match active_language {
Some(language) =>
if fence_language(line) is Some(_) {
let block_content = active_lines.join("\n")
code_blocks.push({
language,
content: block_content,
start_line: active_start,
end_line: line_number,
})
active_language = None
active_start = 0
active_lines.clear()
} else {
active_lines.push(line)
if looks_like_command(line) {
command_examples.push(line.trim().to_owned())
}
}
None => {
let level = heading_level(line)
if level > 0 && line.get_char(level) is Some(' ') {
headings.push({
level,
text: heading_text(line, level),
line: line_number,
})
}
match fence_language(line) {
Some(language) => {
active_language = Some(language)
active_start = line_number
}
None => ()
}
let found_links = bracket_links(line)
for link in found_links {
if line.contains(" || line.contains("![]") {
image_links.push(link)
} else {
links.push(link)
}
}
}
}
line_number += 1
}
MarkdownDocument::{
path,
headings,
code_blocks,
links,
image_links,
command_examples,
line_count: line_number - 1,
}
}
///|
pub fn parse_inventory_markdown(
inventory : ProjectInventory,
) -> Array[MarkdownDocument] {
let documents : Array[MarkdownDocument] = []
for file in inventory.files_with_suffix(".md") {
documents.push(parse_markdown(file.path, file.content))
}
documents
}
///|
pub fn readme_document(inventory : ProjectInventory) -> MarkdownDocument? {
match inventory.find_file("README.md") {
Some(file) => Some(parse_markdown(file.path, file.content))
None =>
match inventory.find_file("README.mbt.md") {
Some(file) => Some(parse_markdown(file.path, file.content))
None => None
}
}
}
///|
pub fn documentation_health_report(inventory : ProjectInventory) -> String {
let builder = StringBuilder()
for document in parse_inventory_markdown(inventory) {
builder.write_string(document.path + ": " + document.summary() + "\n")
}
builder.to_string()
}