///|
/// One entry of an `index.md` section: `* [Title](link) - description`.
pub(all) struct IndexEntry {
text : String
destination : String
description : String?
/// The file line, counted from 1.
line : Int32
} derive(Eq, Debug)
///|
/// The heading of an index section.
pub(all) enum IndexHeading {
/// The lines before the first heading.
Preamble
/// A heading line with its text and level.
Titled(String, @mdc.HeadingLevel)
} derive(Eq, Debug)
///|
/// One section of an `index.md`: a heading with its entries and other lines.
pub struct IndexSection {
heading : IndexHeading
/// The file line of the heading, or of the body start for the preamble.
line : Int32
entries : ReadOnlyArray[IndexEntry]
/// Lines that are not entries, such as prose.
text : ReadOnlyArray[String]
} derive(Eq, Debug)
///|
/// The heading text, or `None` for the preamble.
pub fn IndexSection::title(self : IndexSection) -> String? {
match self.heading {
Preamble => None
Titled(text, _) => Some(text)
}
}
///|
/// The parsed body of an `index.md` (OKF v0.2, section 8).
pub struct IndexModel {
sections : ReadOnlyArray[IndexSection]
} derive(Eq, Debug)
///|
/// Every entry, in document order.
pub fn IndexModel::entries(self : IndexModel) -> Array[IndexEntry] {
let out = []
for section in self.sections {
for entry in section.entries {
out.push(entry)
}
}
out
}
///|
/// The section with this heading.
pub fn IndexModel::section(
self : IndexModel,
heading : String,
) -> IndexSection? {
self.sections.iter().find_first(s => s.title() == Some(heading))
}
///|
/// Parses an ATX heading line, `## Text`.
fn parse_heading_line(line : String) -> (@mdc.HeadingLevel, String)? {
let trimmed = line.trim_start(chars=" ").to_owned()
if line.length() - trimmed.length() > 3 {
return None
}
let mut level = 0
while level < trimmed.length() && trimmed[level] == '#' {
level += 1
}
guard @mdc.HeadingLevel::of_int(level) is Some(heading_level) else {
return None
}
if level < trimmed.length() && trimmed[level] != ' ' && trimmed[level] != '\t' {
return None
}
let text = trimmed[level:].trim(chars=" \t").to_owned()
// A closing sequence of `#` characters is not part of the text.
let text = text.trim_end(chars="#").trim_end(chars=" \t").to_owned()
Some((heading_level, text))
}
///|
/// Parses an index entry line: a list item that starts with a link, then an
/// optional ` - `, ` — ` or `: ` and a description.
pub fn parse_index_entry(
line : String,
line_number~ : Int32 = 0,
) -> IndexEntry? {
let t = line.trim_start(chars=" \t").to_owned()
guard t.length() >= 2 &&
(t[0] == '*' || t[0] == '-' || t[0] == '+') &&
t[1] == ' ' else {
return None
}
let t = t[2:].trim_start(chars=" \t").to_owned()
guard t.has_prefix("[") else { return None }
// Find the `](` that closes the link text, allowing nested brackets.
let mut depth = 0
let mut close = -1
for i in 0.. 0 && close + 1 < t.length() && t[close + 1] == '(' else {
return None
}
let text = t[1:close].to_owned()
let rest = t[close + 2:].to_owned()
guard rest.find(")") is Some(end) else { return None }
let destination = rest[:end].trim(chars=" \t").to_owned()
// Strip an optional title: `(dest "title")`.
let destination = match destination.find(" ") {
Some(i) => destination[:i].to_owned()
None => destination
}
let tail = rest[end + 1:].trim(chars=" \t").to_owned()
let description = if tail.length() == 0 {
None
} else {
let mut d = tail
for sep in ["- ", "— ", "– ", ": "] {
if d.has_prefix(sep) {
d = d[sep.length():].to_owned()
break
}
}
if d == "-" || d == "—" {
d = ""
}
let d = d.trim(chars=" \t").to_owned()
if d.length() == 0 {
None
} else {
Some(d)
}
}
Some({ text, destination, description, line: line_number, })
}
///|
/// The body lines of a document with their file line numbers, without the
/// lines inside fenced code blocks.
///|
/// The body lines of a document with their file line numbers, without the
/// lines inside fenced code blocks.
pub fn Document::prose_lines(self : Document) -> Array[(Int32, String)] {
body_lines(self)
}
///|
fn body_lines(doc : Document) -> Array[(Int32, String)] {
let out = []
let mut fence : String? = None
for i, line in split_lines(doc.body()) {
let number = doc.body_line() + i
let t = line.text.trim_start(chars=" ").to_owned()
match fence {
Some(marker) => {
if t.has_prefix(marker) {
fence = None
}
continue
}
None =>
if t.has_prefix("```") || t.has_prefix("~~~") {
fence = Some(t[:3].to_owned())
continue
}
}
out.push((number, line.text))
}
out
}
///|
/// Parses the body of an index document.
pub fn Document::index_model(self : Document) -> IndexModel {
let sections : Array[IndexSection] = []
let mut heading = Preamble
let mut start = self.body_line()
let entries : Array[IndexEntry] = []
let text : Array[String] = []
let flush = () => {
if heading is Titled(_, _) || !entries.is_empty() || !text.is_empty() {
sections.push({
heading,
line: start,
entries: ReadOnlyArray::from_array(entries.copy()),
text: ReadOnlyArray::from_array(text.copy()),
})
}
entries.clear()
text.clear()
}
for pair in body_lines(self) {
let (number, line) = pair
match parse_heading_line(line) {
Some((level, title)) => {
flush()
heading = Titled(title, level)
start = number
}
None =>
match parse_index_entry(line, line_number=number) {
Some(entry) => entries.push(entry)
None => if line.trim(chars=" \t").length() > 0 { text.push(line) }
}
}
}
flush()
{ sections: ReadOnlyArray::from_array(sections), }
}
///|
/// Normalizes a description for drift checks: lower case, single spaces, and
/// no trailing full stop.
pub fn normalize_description(text : String) -> String {
let words = text
.to_lower()
.split(" ")
.flat_map(w => w.split("\t"))
.map(w => w.to_owned())
.filter(w => w.length() > 0)
.collect()
let joined = words.join(" ")
if joined.has_suffix(".") {
joined[:joined.length() - 1].to_owned()
} else {
joined
}
}
///|
/// The index entries of a bundle that link to `target`, with their index documents.
pub fn Bundle::index_entries_for(
self : Bundle,
target : BundlePath,
) -> Array[(Document, IndexEntry)] {
let out = []
for index in self.indexes() {
for entry in index.index_model().entries() {
let link = body_link(entry.text, entry.destination, entry.line)
match resolve_link(self, index, link) {
StoreTarget(path~, escapes=false, ..) =>
if self.bundle_path_of(path) == Some(target) {
out.push((index, entry))
}
_ => ()
}
}
}
out
}
///|
/// A coverage gap: a concept that no index of its bundle links to.
pub fn Bundle::unindexed_concepts(self : Bundle) -> Array[Document] {
let covered : Map[BundlePath, Bool] = Map([])
for index in self.indexes() {
for entry in index.index_model().entries() {
let link = body_link(entry.text, entry.destination, entry.line)
match resolve_link(self, index, link) {
StoreTarget(path~, escapes=false, ..) =>
match self.bundle_path_of(path) {
Some(rel) => covered[rel] = true
None => ()
}
_ => ()
}
}
}
self.concepts().iter().filter(c => !covered.contains(c.path())).collect()
}
///|
/// One description drift: an index entry whose description differs from the
/// `description` of its concept.
pub(all) struct Drift {
index : Document
entry : IndexEntry
concept : Document
expected : String
}
///|
/// The index entries whose description differs from their concept.
pub fn Bundle::description_drift(self : Bundle) -> Array[Drift] {
let out = []
for index in self.indexes() {
for entry in index.index_model().entries() {
let link = body_link(entry.text, entry.destination, entry.line)
match resolve_link(self, index, link) {
StoreTarget(path~, escapes=false, ..) =>
match self.bundle_path_of(path) {
Some(rel) =>
match self.document(rel) {
Some(concept) if concept.is_concept() =>
match (concept.metadata().description(), entry.description) {
(Some(expected), Some(actual)) =>
if normalize_description(expected) !=
normalize_description(actual) {
out.push({ index, entry, concept, expected, })
}
(Some(expected), None) =>
out.push({ index, entry, concept, expected, })
_ => ()
}
_ => ()
}
None => ()
}
_ => ()
}
}
}
out
}
///|
/// Generates the content of the `index.md` for the bundle directory `dir`:
/// its concepts grouped by `type` (sorted by type, then by title ignoring
/// case), and a `Subdirectories` section. `describe` gives the description of
/// a subdirectory. The root index keeps the `okf_version` of the bundle.
pub fn generate_index(
bundle : Bundle,
dir~ : StoreDir = StoreDir::root(),
describe~ : (StoreDir) -> String? = _ => None,
) -> String {
let base = dir
let dir = dir.segments()
let groups : Array[(IndexGroup, Array[(String, String, String?)])] = []
let add = (group : IndexGroup, entry : (String, String, String?)) => {
// Concept types group by their exact text, so `Decision` and `decision`
// stay separate sections.
let same = (g : IndexGroup) => {
g.heading() == group.heading() && g.rank() == group.rank()
}
match groups.search_by(g => same(g.0)) {
Some(i) => groups[i].1.push(entry)
None => groups.push((group, [entry]))
}
}
let subdirs : Array[EntryName] = []
for doc in bundle.documents() {
let segments = doc.path().segments()
let n = segments.length()
guard n > dir.length() else { continue }
let mut inside = true
for i, s in dir {
if segments[i] != s {
inside = false
break
}
}
guard inside else { continue }
if n == dir.length() + 1 {
guard !doc.kind().is_index() && !(doc.kind() is Log) else { continue }
let group = match doc.concept_type() {
Some(t) => Typed(t)
None => Untyped
}
add(
group,
(doc.display_title(), doc.path().name(), doc.metadata().description()),
)
} else {
// A segment of a bundle path is always a valid entry name.
let child = EntryName(segments[dir.length()]) catch { _ => continue }
if !subdirs.contains(child) {
subdirs.push(child)
}
}
}
subdirs.sort_by((a, b) => lexical_compare(a.to_string(), b.to_string()))
for child in subdirs {
let name = child.to_string()
add(Subdirectories, (name, name + "/index.md", describe(base.child(child))))
}
// Sections sort by heading text, as the reference generator does; a concept
// type and a group with the same heading stay separate sections.
groups.sort_by((a, b) => {
let c = lexical_compare(a.0.heading(), b.0.heading())
if c != 0 {
c
} else {
a.0.rank().compare(b.0.rank())
}
})
let sections = groups.map(group => {
let entries = group.1
entries.sort_by((a, b) => lexical_compare(a.0.to_lower(), b.0.to_lower()))
let lines = ["# \{group.0.heading()}", ""]
for e in entries {
let suffix = match e.2 {
Some(d) => " - " + d
None => ""
}
lines.push("* [\{e.0}](\{e.1})\{suffix}")
}
lines.join("\n")
})
let body = sections.join("\n\n") + "\n"
if dir.is_empty() {
match bundle.okf_version() {
Some(v) => "---\nokf_version: \"\{v.to_string()}\"\n---\n\n" + body
None => body
}
} else {
body
}
}
///|
pub extend IndexEntry with Eq::{not_equal, equal}
///|
pub extend IndexEntry with @debug.Debug::{to_repr}
///|
pub extend IndexSection with Eq::{not_equal, equal}
///|
pub extend IndexSection with @debug.Debug::{to_repr}
///|
pub extend IndexModel with Eq::{not_equal, equal}
///|
pub extend IndexModel with @debug.Debug::{to_repr}
///|
/// A section of a generated index.
pub(all) enum IndexGroup {
/// The concepts of one type.
Typed(ConceptType)
/// The concepts without a type, under `Other`.
Untyped
/// The subdirectories.
Subdirectories
} derive(Eq, Debug)
///|
/// The heading text of the section.
pub fn IndexGroup::heading(self : IndexGroup) -> String {
match self {
Typed(t) => t.to_string()
Untyped => "Other"
Subdirectories => "Subdirectories"
}
}
///|
fn IndexGroup::rank(self : IndexGroup) -> Int32 {
match self {
Typed(_) => 0
Untyped => 1
Subdirectories => 2
}
}
///|
pub extend IndexGroup with Eq::{not_equal, equal}
///|
pub extend IndexGroup with @debug.Debug::{to_repr}
///|
pub extend IndexHeading with Eq::{not_equal, equal}
///|
pub extend IndexHeading with @debug.Debug::{to_repr}