// `doccheck` — use `spell.mbt` to spell-check a MoonBit project's own docs.
//
// This is the library's dogfooding example: it reads the Markdown and the
// `///` / `//` comments of a source tree, turns them into candidate English
// words, and asks the library whether each word is correct. Every spelling
// decision is made by `@spell.check`, so the example also demonstrates the
// public API described in the README.
//
// Extraction rules, and why:
//
// * Files: `*.md` (including `*.mbt.md`) and `*.mbt`. `*_test.mbt` and
// `*_wbtest.mbt` are skipped unless `--include-tests` is given, because
// test comments deliberately contain misspelled fixture words.
// * Markdown: fenced code blocks (``` / ~~~), inline code spans (`...`),
// HTML comments, link targets, and whitespace-separated tokens that
// contain `/` (paths and URLs) are removed before tokenising.
// * MoonBit: only lines whose first non-space characters are `///` or `//`
// are read; code fences inside a `///` block are skipped.
// * A candidate is a run of letters (or `'`) with no non-ASCII character.
// A run that touches a digit, `.` or `_` is dropped (code identifiers,
// file names, versions, hex), and so is an ALL-CAPS run. A possessive
// (`word's`) is reduced to its base; any other word with an apostrophe is
// skipped. Words shorter than three letters are skipped (`e.g.`, `is`).
//
// Known false positives, by construction:
//
// * a general English dictionary (en_US) rejects project vocabulary
// (`Hunspell`, `mooncakes`, `PFX`, ...), British spellings and proper
// nouns, so an allowlist is required for real prose;
// * an unclosed backtick or code fence can hide or leak text;
// * a `//` line inside a multi-line string literal would be read as a
// comment;
// * hyphenated words are checked as two separate words, and link text is
// checked while the link target is not.
///|
/// The parsed command line.
struct Options {
aff : String?
dic : String?
allowlist : String?
include_tests : Bool
quiet : Bool
/// Whether misspellings make the process exit 1 (`--no-fail` clears it).
fail_on_hits : Bool
dir : String
}
///|
/// What one run found. `mut` is used because these are direct assignments,
/// not array pushes (see docs/MOONBIT_GOTCHAS.md #3).
struct Report {
mut files : Int
mut words : Int
mut hits : Int
quiet : Bool
/// Distinct misspelled words as written, with how often each was seen.
distinct : Map[String, Int]
/// Content hash -> content of every file already scanned. `README.md` is a
/// symlink to `README.mbt.md` in this repository, and scanning the same
/// bytes twice would double every number.
scanned : Map[Int, String]
}
///|
fn main {
let status = run(command_args())
if status != 0 {
@sys.exit(status)
}
}
///|
/// The command line with the program name first, as `run` expects.
///
/// Node's `process.argv` (the js backend) starts with the interpreter path
/// instead, so that entry is dropped and the script path takes the
/// program-name slot.
#cfg(target="js")
fn command_args() -> Array[String] {
let args = @env.args()
let tail : Array[String] = []
let mut index = 1
while index < args.length() {
tail.push(args[index])
index = index + 1
}
tail
}
///|
#cfg(not(target="js"))
fn command_args() -> Array[String] {
@env.args()
}
///|
/// The process exit status for a finished scan.
///
/// `0` means "nothing to report" and `1` means "misspellings were found". `1` is
/// the point of the tool: it lets a CI step fail on a typo without parsing the
/// output. `--no-fail` turns that off for callers that only want the numbers,
/// such as this repository's own measurement runs.
fn exit_status(hits : Int, fail_on_hits : Bool) -> Int {
if hits > 0 && fail_on_hits {
1
} else {
0
}
}
///|
/// Parse, scan, and report; returns the process exit status.
fn run(args : Array[String]) -> Int {
if wants_help(args) {
print_usage()
return 0
}
let options = match parse_options(args) {
Ok(options) => options
Err(message) => {
eprintln("doccheck: \{message}")
print_usage()
return 2
}
}
let aff_path = match options.aff {
Some(path) => path
None => {
eprintln("doccheck: --aff is required")
print_usage()
return 2
}
}
let dic_path = match options.dic {
Some(path) => path
None => {
eprintln("doccheck: --dic is required")
print_usage()
return 2
}
}
let aff_text = match read_text(aff_path) {
Ok(text) => text
Err(message) => {
eprintln("doccheck: \{message}")
return 2
}
}
let dic_text = match read_text(dic_path) {
Ok(text) => text
Err(message) => {
eprintln("doccheck: \{message}")
return 2
}
}
let allowlist = match options.allowlist {
None => []
Some(path) =>
match read_allowlist(path) {
Ok(words) => words
Err(message) => {
eprintln("doccheck: \{message}")
return 2
}
}
}
if !allowlist.is_empty() {
println(
"doccheck: \{allowlist.length()} allowlist entries added to the dictionary",
)
}
let dictionary = match
load_dictionary(aff_text, with_allowlist(dic_text, allowlist)) {
Ok(dictionary) => dictionary
Err(message) => {
eprintln("doccheck: \{message}")
return 2
}
}
let report : Report = {
files: 0,
words: 0,
hits: 0,
quiet: options.quiet,
distinct: Map([]),
scanned: Map([]),
}
let root = normalize_dir(options.dir)
if !is_directory(root) {
eprintln("doccheck: not a directory: \{root}")
return 2
}
scan_directory(root, root, dictionary, report, options.include_tests)
print_summary(report)
exit_status(report.hits, options.fail_on_hits)
}
///|
/// How the program is meant to be called.
fn print_usage() -> Unit {
let message =
#|usage: doccheck --aff --dic [options] []
#|
#|Spell-check the Markdown and MoonBit comments under (default ".").
#|
#|options:
#| --aff Hunspell .aff file (required)
#| --dic Hunspell .dic file (required)
#| --allowlist extra correct words, one per line (# comments allowed)
#| --include-tests also scan *_test.mbt and *_wbtest.mbt
#| --quiet print only the summary
#| --no-fail exit 0 even when misspellings are found
#| -h, --help show this message
#|
#|exit status:
#| 0 no misspelling left after the allowlist
#| 1 misspellings found (so the step can gate a CI run)
#| 2 usage or I/O error
#|
#|Run it with the wrapper, which fetches the dictionary at run time:
#| bash examples/doccheck/run.sh [--include-tests] []
eprintln(message)
}
///|
/// Was `-h` or `--help` asked for?
fn wants_help(args : Array[String]) -> Bool {
for arg in args {
if arg == "-h" || arg == "--help" {
return true
}
}
false
}
///|
/// Parse `args` (with the program name first).
fn parse_options(args : Array[String]) -> Result[Options, String] {
let mut aff : String? = None
let mut dic : String? = None
let mut allowlist : String? = None
let mut include_tests = false
let mut quiet = false
let mut fail_on_hits = true
let mut dir : String? = None
let mut index = 1
while index < args.length() {
let arg = args[index]
if arg == "--aff" {
match next_arg(args, index, arg) {
Ok(value) => {
aff = Some(value)
index = index + 2
}
Err(message) => return Err(message)
}
} else if arg == "--dic" {
match next_arg(args, index, arg) {
Ok(value) => {
dic = Some(value)
index = index + 2
}
Err(message) => return Err(message)
}
} else if arg == "--allowlist" {
match next_arg(args, index, arg) {
Ok(value) => {
allowlist = Some(value)
index = index + 2
}
Err(message) => return Err(message)
}
} else if arg == "--include-tests" {
include_tests = true
index = index + 1
} else if arg == "--quiet" {
quiet = true
index = index + 1
} else if arg == "--no-fail" {
fail_on_hits = false
index = index + 1
} else if arg.has_prefix("-") {
return Err("unknown option \"\{arg}\"")
} else {
match dir {
Some(_) => return Err("more than one directory given: \"\{arg}\"")
None => {
dir = Some(arg)
index = index + 1
}
}
}
}
Ok({
aff,
dic,
allowlist,
include_tests,
quiet,
fail_on_hits,
dir: match dir {
Some(path) => path
None => "."
},
})
}
///|
/// The value that must follow `name` at `index + 1`.
fn next_arg(
args : Array[String],
index : Int,
name : String,
) -> Result[String, String] {
if index + 1 >= args.length() {
Err("\{name} needs a value")
} else {
Ok(args[index + 1])
}
}
///|
/// Read a UTF-8 text file, turning an IO failure into a message.
fn read_text(path : String) -> Result[String, String] {
let text = @fs.read_file_to_string(path) catch {
@fs.IOError(message) => return Err("cannot read \{path}: \{message}")
}
Ok(text)
}
///|
/// Read the allowlist: one word per line, `#` comments and blank lines ignored.
fn read_allowlist(path : String) -> Result[Array[String], String] {
let text = match read_text(path) {
Ok(text) => text
Err(message) => return Err(message)
}
let words : Array[String] = []
for raw in text.split("\n") {
let line = raw.trim()
if line.is_empty() || line.has_prefix("#") {
continue
}
words.push(line.to_owned())
}
Ok(words)
}
///|
/// Load the dictionary, turning a parse failure into a message.
fn load_dictionary(
aff_text : String,
dic_text : String,
) -> Result[@spell.Dictionary, String] {
let dictionary = @spell.load(aff_text, dic_text) catch {
error => return Err("cannot load the dictionary: \{error.to_string()}")
}
Ok(dictionary)
}
///|
/// Strip a trailing `/` so that path joining stays simple.
fn normalize_dir(dir : String) -> String {
let trimmed = dir.trim_end(chars="/").to_owned()
if trimmed.is_empty() {
"/"
} else {
trimmed
}
}
///|
/// Is `path` an existing directory? Never raises.
fn is_directory(path : String) -> Bool {
@fs.is_dir(path) catch {
@fs.IOError(_) => false
}
}
///|
/// Walk `dir`, scanning every candidate file below it.
fn scan_directory(
dir : String,
root : String,
dictionary : @spell.Dictionary,
report : Report,
include_tests : Bool,
) -> Unit {
let entries = @fs.read_dir(dir) catch {
@fs.IOError(message) => {
eprintln("doccheck: cannot list \{dir}: \{message}")
return
}
}
for name in entries {
let path = dir + "/" + name
if is_directory(path) {
if should_descend(name) {
scan_directory(path, root, dictionary, report, include_tests)
}
} else if is_candidate(name, include_tests) {
scan_file(
path,
display_path(path, root),
name.has_suffix(".md"),
dictionary,
report,
)
}
}
}
///|
/// Should the walker enter a directory with this name?
///
/// Hidden directories (`.git`, `.mooncakes`, ...) and build output are never
/// part of the prose a contributor edits.
fn should_descend(name : String) -> Bool {
!name.has_prefix(".") &&
name != "_build" &&
name != "node_modules" &&
name != "target"
}
///|
/// Is this file worth scanning?
fn is_candidate(name : String, include_tests : Bool) -> Bool {
let is_test = name.has_suffix("_test.mbt") || name.has_suffix("_wbtest.mbt")
if is_test && !include_tests {
return false
}
name.has_suffix(".md") || name.has_suffix(".mbt")
}
///|
/// The path as reported to the user: relative to the scanned root when it is
/// below it, absolute otherwise.
fn display_path(path : String, root : String) -> String {
if path == root {
return path
}
let prefix = root + "/"
if path.has_prefix(prefix) {
path[prefix.length():].to_owned()
} else {
path
}
}
///|
/// Scan one file. IO failures are reported and skipped.
fn scan_file(
path : String,
display : String,
is_markdown : Bool,
dictionary : @spell.Dictionary,
report : Report,
) -> Unit {
let text = match read_text(path) {
Ok(text) => text
Err(message) => {
eprintln("doccheck: \{message}")
return
}
}
let key = hash(text)
match report.scanned.get(key) {
Some(previous) =>
if previous == text {
// The same bytes were already scanned (a symlink or an identical copy).
return
}
None => ()
}
report.scanned[key] = text
report.files = report.files + 1
let mut in_fence = false
let mut number = 1
for raw in text.split("\n") {
let line = raw.to_owned()
if is_markdown {
if is_fence_line(line) {
in_fence = !in_fence
} else if !in_fence {
check_line(line, display, number, dictionary, report)
}
} else {
match comment_body(line) {
Some(body) =>
if is_fence_line(body) {
in_fence = !in_fence
} else if !in_fence {
check_line(body, display, number, dictionary, report)
}
None => ()
}
}
number = number + 1
}
}
///|
/// The text after a leading `///` or `//`, or `None` when the line is not a
/// comment.
fn comment_body(line : String) -> String? {
let trimmed = line.trim_start().to_owned()
if trimmed.has_prefix("///") {
Some(trimmed[3:].to_owned())
} else if trimmed.has_prefix("//") {
Some(trimmed[2:].to_owned())
} else {
None
}
}
///|
/// Is this a Markdown code-fence line (``` or ~~~)?
fn is_fence_line(line : String) -> Bool {
let trimmed = line.trim_start()
trimmed.has_prefix("```") || trimmed.has_prefix("~~~")
}
///|
/// Check every candidate word on one prose line.
fn check_line(
line : String,
file : String,
number : Int,
dictionary : @spell.Dictionary,
report : Report,
) -> Unit {
for word in collect_words(clean_line(line)) {
report.words = report.words + 1
if !@spell.check(dictionary, word) {
report.hits = report.hits + 1
match report.distinct.get(word) {
Some(count) => report.distinct[word] = count + 1
None => report.distinct[word] = 1
}
if !report.quiet {
println("\{file}:\{number}: \{word}")
}
}
}
}
///|
/// Remove the parts of a prose line that are code, links or paths.
fn clean_line(line : String) -> String {
drop_slash_tokens(
strip_link_targets(strip_html_comments(strip_code_spans(line))),
)
}
///|
/// Replace every `` `...` `` span with a space. An unmatched trailing backtick
/// is dropped on its own instead of swallowing the rest of the line.
fn strip_code_spans(line : String) -> String {
let chars = line.to_array()
let out = StringBuilder()
let mut index = 0
while index < chars.length() {
if chars[index] == '`' {
let mut close = index + 1
while close < chars.length() && chars[close] != '`' {
close = close + 1
}
if close < chars.length() {
out.write_char(' ')
index = close + 1
} else {
index = index + 1
}
} else {
out.write_char(chars[index])
index = index + 1
}
}
out.to_string()
}
///|
/// Drop `` comments that start and end on this line.
fn strip_html_comments(line : String) -> String {
let chars = line.to_array()
let out = StringBuilder()
let mut index = 0
while index < chars.length() {
if starts_with_at(chars, index, "") {
close = close + 1
}
if close + 2 < chars.length() {
index = close + 3
} else {
index = chars.length()
}
out.write_char(' ')
} else {
out.write_char(chars[index])
index = index + 1
}
}
out.to_string()
}
///|
/// Drop Markdown link targets (`](...)`), keeping the link text.
fn strip_link_targets(line : String) -> String {
let chars = line.to_array()
let out = StringBuilder()
let mut index = 0
while index < chars.length() {
if chars[index] == ']' &&
index + 1 < chars.length() &&
chars[index + 1] == '(' {
let mut close = index + 2
while close < chars.length() && chars[close] != ')' {
close = close + 1
}
index = if close < chars.length() { close + 1 } else { chars.length() }
out.write_char(' ')
} else {
out.write_char(chars[index])
index = index + 1
}
}
out.to_string()
}
///|
/// Drop whitespace-separated tokens that contain `/`, i.e. paths and URLs.
fn drop_slash_tokens(line : String) -> String {
let out = StringBuilder()
let mut token = StringBuilder()
let mut has_slash = false
for c in line {
if c == ' ' || c == '\t' {
if !has_slash {
out.write_string(token.to_string())
}
out.write_char(c)
token = StringBuilder()
has_slash = false
} else {
if c == '/' {
has_slash = true
}
token.write_char(c)
}
}
if !has_slash {
out.write_string(token.to_string())
}
out.to_string()
}
///|
/// Does `chars` start with `prefix` at `index`?
fn starts_with_at(chars : Array[Char], index : Int, prefix : String) -> Bool {
let wanted = prefix.to_array()
if index + wanted.length() > chars.length() {
return false
}
let mut offset = 0
while offset < wanted.length() {
if chars[index + offset] != wanted[offset] {
return false
}
offset = offset + 1
}
true
}
///|
/// Extract the candidate words of one cleaned prose line.
fn collect_words(line : String) -> Array[String] {
let chars = line.to_array()
let words : Array[String] = []
let mut index = 0
while index < chars.length() {
if is_word_char(chars[index]) {
let start = index
let mut non_ascii = false
while index < chars.length() && is_word_char(chars[index]) {
if !chars[index].is_ascii() {
non_ascii = true
}
index = index + 1
}
if !non_ascii && !touches_code(chars, start, index) {
match accept_word(chars_to_string(chars, start, index)) {
Some(word) => words.push(word)
None => ()
}
}
} else {
index = index + 1
}
}
words
}
///|
/// A letter (any script) or an apostrophe.
fn is_word_char(c : Char) -> Bool {
c.is_ascii_alphabetic() ||
c == '\'' ||
@unicode.non_ascii_id_start.contains(c) ||
@unicode.non_ascii_id_continue.contains(c)
}
///|
/// Does the run `chars[start..end)` touch a digit, `.` or `_`? Such a run is
/// part of an identifier, a file name or a number, not of prose.
///
/// A `.` *after* the run only counts when another letter or digit follows it
/// (`moon.pkg`), so a sentence-ending period (`the end.`) still leaves the
/// last word visible.
fn touches_code(chars : Array[Char], start : Int, end : Int) -> Bool {
let before = if start > 0 { Some(chars[start - 1]) } else { None }
let after = if end < chars.length() { Some(chars[end]) } else { None }
let bad_before = match before {
Some(c) => c.is_ascii_digit() || c == '.' || c == '_'
None => false
}
let bad_after = match after {
Some(c) =>
c.is_ascii_digit() ||
c == '_' ||
(
c == '.' &&
end + 1 < chars.length() &&
(
chars[end + 1].is_ascii_alphabetic() ||
chars[end + 1].is_ascii_digit() ||
chars[end + 1] == '_'
)
)
None => false
}
bad_before || bad_after
}
///|
/// Decide whether a raw run is a word worth checking. Returns `None` for
/// everything the extractor skips.
fn accept_word(raw : String) -> String? {
let base = strip_possessive(raw)
if base.contains("'") {
// a contraction or an internal quote, not prose we can judge
return None
}
if base.length() < 3 {
return None
}
if is_all_caps(base) {
return None
}
Some(base)
}
///|
/// Reduce a possessive to its base: `manual's` -> `manual`, `parts'` -> `parts`.
/// Surrounding apostrophes are removed so that `'quoted'` survives as a word.
fn strip_possessive(raw : String) -> String {
let trimmed = raw.trim(chars="'").to_owned()
if trimmed.has_suffix("'s") || trimmed.has_suffix("'S") {
trimmed[:trimmed.length() - 2].to_owned()
} else if trimmed.has_suffix("'") {
trimmed[:trimmed.length() - 1].to_owned()
} else {
trimmed
}
}
///|
/// Is every ASCII letter in `word` upper case? `UTF`, `PFX` and the like are
/// identifiers, not prose.
fn is_all_caps(word : String) -> Bool {
let mut has_upper = false
for c in word {
if c.is_ascii_lowercase() {
return false
}
if c.is_ascii_uppercase() {
has_upper = true
}
}
has_upper
}
///|
fn chars_to_string(chars : Array[Char], start : Int, end : Int) -> String {
let out = StringBuilder()
let mut index = start
while index < end {
out.write_char(chars[index])
index = index + 1
}
out.to_string()
}
///|
/// Lower-case every character of `word`.
fn lowercase_word(word : String) -> String {
let out = StringBuilder()
for c in word {
out.write_char(@unicode.to_lowercase(c))
}
out.to_string()
}
///|
/// Insert the allowlist into a `.dic` text: each entry is added verbatim and
/// in lower case, so the library accepts it in any capitalisation it knows.
/// The declared entry count is updated, although the parser tolerates a stale
/// one.
fn with_allowlist(dic_text : String, allowlist : Array[String]) -> String {
if allowlist.is_empty() {
return dic_text
}
let entries : Array[String] = []
for word in allowlist {
entries.push(word)
let lower = lowercase_word(word)
if lower != word {
entries.push(lower)
}
}
let (header, body) = match dic_text.find("\n") {
Some(index) =>
(dic_text[:index].to_owned(), dic_text[index + 1:].to_owned())
None => (dic_text, "")
}
let declared : Int = @string.parse_int(header) catch { _ => 0 }
let out = StringBuilder()
out.write_string((declared + entries.length()).to_string())
out.write_char('\n')
if !body.is_empty() {
out.write_string(body)
if !body.has_suffix("\n") {
out.write_char('\n')
}
}
for entry in entries {
out.write_string(entry)
out.write_char('\n')
}
out.to_string()
}
///|
/// Print the run's totals and the distinct words it rejected.
fn print_summary(report : Report) -> Unit {
println("")
println("files scanned : \{report.files}")
println("words checked : \{report.words}")
println("misspelled tokens : \{report.hits}")
println("distinct words : \{report.distinct.length()}")
if report.distinct.length() > 0 {
println("")
println("distinct misspelled words (count):")
let rows = report.distinct.to_array()
rows.sort_by(fn(a, b) { b.1 - a.1 })
for row in rows {
println(" \{pad(row.1.to_string(), 6)}\{row.0}")
}
}
}
///|
/// Left-align `word` in a field of `width` characters.
fn pad(word : String, width : Int) -> String {
let buffer = StringBuilder()
buffer.write_string(word)
let mut missing = width - word.char_length()
while missing > 0 {
buffer.write_char(' ')
missing = missing - 1
}
buffer.to_string()
}