// The mbglot command: identifies the language of a text, of its segments,
// or of every line of a file.
///|
/// What the command line asks for.
priv struct Settings {
/// The text to identify: the positional arguments joined with spaces.
text : String
/// A file whose lines are identified one by one, instead of `text`.
file : String?
segments : Bool
json : Bool
/// Also show why the language was chosen.
explain : Bool
/// How many candidates to list.
top : Int32
options : @mbglot.Options
}
///|
/// A command line that cannot be run, with the message to show.
priv suberror Usage {
Usage(String)
}
///|
impl Show for Usage with fn output(self, logger) {
let Usage(message) = self
logger.write_string(message)
}
///|
fn command() -> @argparse.Command {
@argparse.Command(
"mbglot",
about="Identifies the language of a text: the most likely language, its script, the confidence (franc's score margin to the runner-up, plus the letters and common words that favour the language in enhanced mode), whether it is reliable, and the best candidates. With --file, identifies every line of a file and prints one result per line.",
flags=[
@argparse.FlagArg(
"segments",
about="split text that mixes languages into segments and identify each",
),
@argparse.FlagArg(
"explain",
about="also show why: the step that decided the language and the trigrams that weigh most for and against it",
),
@argparse.FlagArg(
"json",
about="print JSON: one object, or with --file one object per line",
),
],
options=[
@argparse.OptionArg(
"file",
short='f',
about="identify each line of this file; empty lines give an empty result, so output line N belongs to input line N",
),
@argparse.OptionArg(
"top",
about="how many candidates to list (default 5; with --file, default 0)",
),
@argparse.OptionArg(
"allow",
action=Append,
about="only consider these languages: BCP 47 tags or ISO 639 codes separated by commas, such as en,fr,de",
),
@argparse.OptionArg(
"deny",
action=Append,
about="never return these languages: BCP 47 tags or ISO 639 codes separated by commas",
),
@argparse.OptionArg(
"mode",
about="enhanced (default): markup removed and related languages settled by letters and words; franc: franc-min's ranking alone",
),
@argparse.OptionArg(
"min-length",
about="texts shorter than this many UTF-16 code units are not identified (default 10)",
),
],
positionals=[
@argparse.PositionArg(
"text",
about="the text to identify",
num_args=@argparse.ValueRange(lower=0),
),
],
)
}
///|
/// The last value given for option `name`, if any.
fn last_value(matches : @argparse.Matches, name : String) -> String? {
match matches.values.get(name) {
Some([.., last]) => Some(last)
_ => None
}
}
///|
/// The languages in the comma-separated lists given for option `name`.
fn languages(
matches : @argparse.Matches,
name : String,
) -> Array[@mbglot.Lang]? raise Usage {
guard matches.values.get(name) is Some(lists) else { return None }
let langs = []
for list in lists {
for code in list.split(",") {
let code = code.trim().to_owned()
guard @mbglot.Lang::from_bcp47(code) is Some(lang) else {
raise Usage(
"error: --\{name}: unknown language \"\{code}\"; use language codes such as en, pt-BR or cmn",
)
}
if !langs.contains(lang) {
langs.push(lang)
}
}
}
Some(langs)
}
///|
/// The value of option `name` as a non-negative integer.
fn count(
matches : @argparse.Matches,
name : String,
default : Int32,
) -> Int32 raise Usage {
guard last_value(matches, name) is Some(value) else { return default }
let n = @string.parse_int(value) catch {
_ => raise Usage("error: --\{name} takes a number; got \"\{value}\"")
}
if n < 0 {
raise Usage("error: --\{name} must not be negative; got \{n}")
}
n
}
///|
/// Without `argv`, the command line of this process; `--help` then prints
/// the help and exits.
fn parse_args(argv? : ArrayView[String]) -> Settings raise {
let matches = command().parse(argv?)
let text = matches.values.get_or_default("text", []).join(" ")
let file = last_value(matches, "file")
match (file, text) {
(Some(_), "") => ()
(Some(_), _) => raise Usage("error: give either a text or --file, not both")
(None, "") =>
raise Usage(
"error: nothing to identify; give a text or --file (see --help)",
)
(None, _) => ()
}
let mode : @mbglot.Mode = match last_value(matches, "mode") {
None | Some("enhanced") => Enhanced
Some("franc") => Franc
Some(other) =>
raise Usage("error: --mode takes enhanced or franc; got \"\{other}\"")
}
let allow = languages(matches, "allow")
let deny = languages(matches, "deny").unwrap_or([])
let min_length = count(matches, "min-length", 10)
let options = @mbglot.Options::new(allow?, deny~, min_length~, mode~) catch {
EmptyAllow => raise Usage("error: --allow lists no language")
AllowDenyOverlap(lang) =>
raise Usage("error: \{lang.code()} is both allowed and denied")
DenyAll => raise Usage("error: --deny lists every language")
NegativeMinLength(_) => raise Usage("error: --min-length is negative")
}
let explain = matches.flags.get_or_default("explain", false)
if explain && file is Some(_) && !matches.flags.get_or_default("json", false) {
raise Usage("error: --explain with --file needs --json")
}
{
text,
file,
segments: matches.flags.get_or_default("segments", false),
json: matches.flags.get_or_default("json", false),
explain,
top: count(matches, "top", if file is Some(_) { 0 } else { 5 }),
options,
}
}
///|
/// `x` with three decimals, computed with integers so that every backend
/// prints the same digits.
fn fixed3(x : Double) -> String {
let scaled = (x * 1000.0).round().to_int()
let sign = if scaled < 0 { "-" } else { "" }
let abs = scaled.abs()
let frac = (abs % 1000).to_string()
"\{sign}\{abs / 1000}.\{"0".repeat(3 - frac.length())}\{frac}"
}
///|
/// `x` rounded to three decimals, for JSON.
fn round3(x : Double) -> Json {
Json((x * 1000.0).round() / 1000.0)
}
///|
fn pad(s : String, width : Int32) -> String {
s + " ".repeat((width - s.length()).max(0))
}
///|
fn verdict(detection : @mbglot.Detection) -> String {
if detection.reliable {
"reliable"
} else {
"not reliable"
}
}
///|
/// The lines printed for `text`.
fn report(
text : String,
detector : @mbglot.Detector,
top : Int32,
) -> Array[String] {
guard detector.detect(text) is Some(detection) else {
return [
"No language detected: the text is shorter than \{detector.options().min_length()} characters or has no letters of a supported script.",
]
}
let lang = detection.lang
let lines = [
"language: \{lang.name()} (\{lang.code()})",
"script: \{detection.script.name()}",
"confidence: \{fixed3(detection.confidence)} (\{verdict(detection)})",
]
if detection.runner_up is Some(other) {
lines.push(
"runner-up: \{other.name()} (\{other.code()}), margin \{fixed3(detection.margin)}",
)
}
if top > 0 {
lines.push("top \{top}:")
for i, entry in detector.rank(text) {
if i == top {
break
}
let (candidate, score) = entry
lines.push(
" \{i + 1}. \{candidate.code()} \{pad(candidate.name(), 24)} \{fixed3(score)}",
)
}
}
lines
}
///|
/// `text` on one line, cut to `width` characters.
fn preview(text : String, width : Int32) -> String {
let chars = text
.trim()
.iter()
.map(c => if c == '\n' || c == '\r' || c == '\t' { ' ' } else { c })
.collect()
if chars.length() <= width {
String::from_array(chars)
} else {
String::from_array(chars[:width - 3]) + "..."
}
}
///|
/// The lines printed for `text` with `--segments`.
fn segments_report(
text : String,
detector : @mbglot.Detector,
top : Int32,
) -> Array[String] {
let parts = detector.segments(text)
if parts is [{ detection: None, .. }] || parts.is_empty() {
return report(text, detector, top)
}
parts.map(part => {
let (code, name, verdict) = match part.detection {
Some(d) => (d.lang.code(), d.lang.name(), verdict(d))
None => ("---", "unidentified", "")
}
let range = pad(
"[" + part.start.to_string() + ", " + part.end.to_string() + ")",
12,
)
"\{range} \{code} \{pad(name, 24)} \{pad(verdict, 12)} \{preview(part.text, 40)}"
})
}
///|
fn detection_json(detection : @mbglot.Detection?) -> Json {
guard detection is Some(d) else { return Json::null() }
{
"lang": Json(d.lang.code()),
"bcp47": Json(d.lang.bcp47()),
"name": Json(d.lang.name()),
"script": Json(d.script.name()),
"confidence": round3(d.confidence),
"margin": round3(d.margin),
"reliable": Json(d.reliable),
"runner_up": match d.runner_up {
Some(lang) => Json(lang.code())
None => Json::null()
},
}
}
///|
/// The result for `text` as JSON: the detection (or null), the `top` best
/// candidates and, with `segments`, the segments with UTF-16 offsets.
fn json_report(
text : String,
detector : @mbglot.Detector,
top : Int32,
segments : Bool,
explain~ : Bool = false,
) -> Json {
let candidates : Array[Json] = []
if top > 0 {
for i, entry in detector.rank(text) {
if i == top {
break
}
candidates.push({ "lang": Json(entry.0.code()), "score": round3(entry.1) })
}
}
let result : Map[String, Json] = {
"detection": detection_json(detector.detect(text)),
"top": Json::array(candidates),
}
if segments {
result["segments"] = Json::array(
detector
.segments(text)
.map(part => {
"start": Json(part.start),
"end": Json(part.end),
"detection": detection_json(part.detection),
}),
)
}
if explain {
result["explain"] = explain_json(text, detector)
}
Json::object(result)
}
///|
/// One tab-separated line for line `number` of a file: the number, the
/// language code, the confidence and whether it is reliable, or dashes
/// when nothing was detected.
fn batch_line(number : Int32, detection : @mbglot.Detection?) -> String {
match detection {
Some(d) =>
"\{number}\t\{d.lang.code()}\t\{fixed3(d.confidence)}\t\{verdict(d)}"
None => "\{number}\t-\t-\t-"
}
}
///|
/// The lines printed for the lines of a file.
fn batch_report(contents : String, settings : Settings) -> Array[String] {
let detector = @mbglot.Detector::new(options=settings.options)
let lines = contents
.split("\n")
.map(line => line.trim_end(chars="\r").to_owned())
.collect()
if lines.last() == Some("") {
lines.pop() |> ignore
}
let out = []
for i, line in lines {
let number = i + 1
if settings.json {
let result = json_report(
line,
detector,
settings.top,
settings.segments,
explain=settings.explain,
)
if result is Object(fields) {
fields["line"] = Json(number)
}
out.push(result.stringify())
} else if settings.segments {
for part in segments_report(line, detector, settings.top) {
out.push("\{number}\t\{part}")
}
} else {
out.push(batch_line(number, detector.detect(line)))
if settings.top > 0 {
for k, entry in detector.rank(line) {
if k == settings.top {
break
}
out.push("\{number}\t \{entry.0.code()}\t\{fixed3(entry.1)}")
}
}
}
}
out
}
///|
fn lang_label(lang : @mbglot.Lang) -> String {
"\{lang.name()} (\{lang.code()})"
}
///|
/// A step of the decision in words.
fn describe_step(step : @mbglot.Step) -> String {
match step {
Trigrams(lang) => "trigram statistics rank \{lang_label(lang)} first"
ScriptShare(lang) =>
"the shares of kana, Han and Hangul make it \{lang_label(lang)}"
CommonWords(lang, words) =>
"common words of \{lang_label(lang)} in a short text: \{words.join(", ")}"
RelatedLanguages(lang, clues) =>
"letters and words that set \{lang_label(lang)} apart from a related language: \{clues.join(", ")}"
NearTie(lang, other) =>
"near tie with \{lang_label(other)}, broken for \{lang.name()}, which has more speakers"
}
}
///|
/// Up to five trigrams of `scores` that favour (or with `against`, weigh
/// against) the detected language, as `"tri" +gain`.
fn strongest(
scores : Array[@mbglot.TrigramScore],
against~ : Bool,
) -> Array[@mbglot.TrigramScore] {
let picked = if against {
scores.rev().filter(s => s.gain < 0)
} else {
scores.filter(s => s.gain > 0)
}
if picked.length() > 5 {
picked.truncate(5)
}
picked
}
///|
fn trigram_list(scores : Array[@mbglot.TrigramScore]) -> String {
let parts = scores.map(s => {
let sign = if s.gain > 0 { "+" } else { "" }
"\"\{s.trigram}\" \{sign}\{s.gain}"
})
if parts.is_empty() {
"none"
} else {
parts.join(", ")
}
}
///|
/// The lines printed for `text` with `--explain`, after the report.
fn explain_lines(text : String, detector : @mbglot.Detector) -> Array[String] {
let explanation = detector.explain(text)
guard explanation.detection is Some(detection) else { return [] }
let lines = ["why:"]
for step in explanation.steps {
lines.push(" \{describe_step(step)}")
}
if detection.runner_up is Some(other) && !explanation.trigrams.is_empty() {
let lang = detection.lang.code()
lines.push(
"trigrams for \{lang} over \{other.code()}: \{trigram_list(strongest(explanation.trigrams, against=false))}",
)
lines.push(
"trigrams for \{other.code()} over \{lang}: \{trigram_list(strongest(explanation.trigrams, against=true))}",
)
}
lines
}
///|
fn step_json(step : @mbglot.Step) -> Json {
match step {
Trigrams(lang) => { "step": "trigrams", "lang": Json(lang.code()) }
ScriptShare(lang) => { "step": "script_share", "lang": Json(lang.code()) }
CommonWords(lang, words) =>
{
"step": "common_words",
"lang": Json(lang.code()),
"clues": Json::array(words.map(w => Json(w))),
}
RelatedLanguages(lang, clues) =>
{
"step": "related_languages",
"lang": Json(lang.code()),
"clues": Json::array(clues.map(w => Json(w))),
}
NearTie(lang, other) =>
{
"step": "near_tie",
"lang": Json(lang.code()),
"over": Json(other.code()),
}
}
}
///|
/// The explanation of `text` as JSON: the steps, and the trigrams that
/// weigh most for and against the detected language.
fn explain_json(text : String, detector : @mbglot.Detector) -> Json {
let explanation = detector.explain(text)
let trigrams = (scores : Array[@mbglot.TrigramScore]) => {
Json::array(
scores.map(s => { "trigram": Json(s.trigram), "gain": Json(s.gain) }),
)
}
{
"steps": Json::array(explanation.steps.map(step_json)),
"trigrams_for": trigrams(strongest(explanation.trigrams, against=false)),
"trigrams_against": trigrams(strongest(explanation.trigrams, against=true)),
}
}
///|
/// The lines printed for the text of the command line.
fn text_report(settings : Settings) -> Array[String] {
let detector = @mbglot.Detector::new(options=settings.options)
if settings.json {
[
json_report(
settings.text,
detector,
settings.top,
settings.segments,
explain=settings.explain,
).stringify(),
]
} else if settings.segments {
segments_report(settings.text, detector, settings.top)
} else if settings.explain {
report(settings.text, detector, settings.top) +
explain_lines(settings.text, detector)
} else {
report(settings.text, detector, settings.top)
}
}
///|
fn main {
let settings = parse_args() catch {
error => {
eprintln(error)
@sys.exit(2)
return
}
}
let lines = match settings.file {
None => text_report(settings)
Some(path) =>
try @fs.read_file_to_string(path) catch {
@fs.IOError(message) => {
eprintln("\{path}: error: \{message}")
@sys.exit(1)
return
}
} noraise {
contents => batch_report(contents, settings)
}
}
for line in lines {
println(line)
}
}