// `Scripts` topical primitive — string-level script analysis.
//
// Sits alongside `Words`, `Sentences`, `Lines` in shape: public
// methods take `String box` and act on whole strings; `Text` delegates
// to these. The closed-union `Script` type itself and the byte
// codecs live in the auto-generated `script.pony` / `_ucd_script.pony`
// (the private `_ScriptCodec` primitive).
//
// Two layers of "what scripts are in this text" are exposed:
//
// `Scripts.of(s)` — plain set of `script(cp)` values seen in `s`.
// Common/Inherited show up here; call `.resolved()` on the result
// to drop them.
//
// `Scripts.resolved_script_set(s)` — UAX #39 §5.1 intersection over
// `Script_Extensions(cp)`. Constraining: a string only stays
// single-script if every codepoint's extensions overlap a common
// script. This is what `is_single_script` is built on.
primitive Scripts
fun from_iso(name: String box): (Script | None) =>
"""
Look up a `Script` by its long UCD name (`"Latin"`, `"Cyrillic"`,
`"Han"`, …). Returns `None` for unknown names. Case- and
underscore-sensitive (`"Anatolian_Hieroglyphs"`, not
`"Anatolian Hieroglyphs"`).
"""
_ScriptCodec.from_iso(name)
fun of(s: String box): ScriptSet val =>
"""
The set of `script(cp)` values for every codepoint in `s`. ASCII
digits, punctuation, etc. contribute `ScriptCommon`; combining
marks contribute `ScriptInherited`. Use `.resolved()` on the
result to drop those.
"""
let collected = recover trn Array[Script] end
for cp in s.runes() do
collected.push(Codepoints.script(cp))
end
ScriptSet.create(consume collected)
fun dominant(s: String box): Script =>
"""
The script that occurs most often in `s`, ignoring
`ScriptCommon` and `ScriptInherited` codepoints (digits,
punctuation, combining marks). Ties are broken by first-seen.
Returns `ScriptCommon` if `s` is empty or contains only
Common/Inherited codepoints.
"""
let counts = Array[USize].init(0, 256)
let first_seen = Array[USize].init(USize.max_value(), 256)
var n_cps: USize = 0
let common = _ScriptCodec.to_byte(ScriptCommon)
let inherited = _ScriptCodec.to_byte(ScriptInherited)
for cp in s.runes() do
let b = _ScriptCodec.to_byte(Codepoints.script(cp))
if (b == common) or (b == inherited) then continue end
let idx = USize.from[U8](b)
try
counts(idx)? = counts(idx)? + 1
if first_seen(idx)? == USize.max_value() then
first_seen(idx)? = n_cps
end
end
n_cps = n_cps + 1
end
var best_byte: U8 = common
var best_count: USize = 0
var best_seen: USize = USize.max_value()
var i: USize = 0
while i < 256 do
try
let c = counts(i)?
if c > 0 then
let seen = first_seen(i)?
if (c > best_count)
or ((c == best_count) and (seen < best_seen))
then
best_byte = U8.from[USize](i)
best_count = c
best_seen = seen
end
end
end
i = i + 1
end
_ScriptCodec.from_byte(best_byte)
fun restrict_to(s: String box, allowed: ScriptSet val): Bool =>
"""
True iff every codepoint in `s` either has a Common/Inherited
script (which fits anywhere) or has at least one entry in its
`Script_Extensions` that is also in `allowed`. This is the
"identifier-allowlist" predicate from UAX #39 §5: pass it the
scripts your application accepts (`ScriptSet.create([ScriptLatin
])` for a Latin-only identifier policy) and it tells you whether
`s` stays inside that allowlist.
"""
let common = _ScriptCodec.to_byte(ScriptCommon)
let inherited = _ScriptCodec.to_byte(ScriptInherited)
let allowed_bytes = allowed.bytes()
for cp in s.runes() do
let sc_byte = _ScriptCodec.to_byte(Codepoints.script(cp))
if (sc_byte == common) or (sc_byte == inherited) then
continue
end
let exts = Codepoints.script_extensions(cp)
var fits: Bool = false
for ext in exts.values() do
let eb = _ScriptCodec.to_byte(ext)
if _byte_in(allowed_bytes, eb) then fits = true; break end
end
if not fits then return false end
end
true
fun is_single_script(s: String box): Bool =>
"""
True iff there exists at least one script that every codepoint
in `s` belongs to via `Script_Extensions` (UAX #39 §5.1). Empty
strings and all-Common/all-Inherited strings are single-script.
Stronger than checking `Scripts.of(s).resolved().size() == 1` —
this honours Script_Extensions, so e.g. U+0640 ARABIC TATWEEL
(extended to many Arabic-family scripts) doesn't force
mixed-script just because its bare script is Arabic.
"""
var narrowed: (Array[Script] val | None) = None
for cp in s.runes() do
let exts = Codepoints.script_extensions(cp)
if _contains_common_or_inherited(exts) then continue end
match narrowed
| None => narrowed = exts
| let cur: Array[Script] val =>
let isect = _intersect(cur, exts)
if isect.size() == 0 then return false end
narrowed = isect
end
end
true
fun resolved_script_set(s: String box): ScriptSet val =>
"""
UAX #39 §5.1 running intersection of `Script_Extensions` across
all constraining codepoints (Common/Inherited are skipped).
Empty result means either mixed-script or all-Common/Inherited;
use `is_single_script` to disambiguate.
"""
var narrowed: (Array[Script] val | None) = None
for cp in s.runes() do
let exts = Codepoints.script_extensions(cp)
if _contains_common_or_inherited(exts) then continue end
match narrowed
| None => narrowed = exts
| let cur: Array[Script] val =>
let isect = _intersect(cur, exts)
narrowed = isect
if isect.size() == 0 then break end
end
end
match narrowed
| let a: Array[Script] val => ScriptSet.create(a)
| None => ScriptSet.empty()
end
fun _contains_common_or_inherited(scripts: Array[Script] val): Bool =>
for sc in scripts.values() do
match sc
| ScriptCommon => return true
| ScriptInherited => return true
end
end
false
fun _intersect(
a: Array[Script] val,
b: Array[Script] val): Array[Script] val
=>
let out = recover trn Array[Script] end
for x in a.values() do
if _contains_script(b, x) then out.push(x) end
end
consume out
fun _contains_script(scripts: Array[Script] val, s: Script): Bool =>
let target = _ScriptCodec.to_byte(s)
for x in scripts.values() do
if _ScriptCodec.to_byte(x) == target then return true end
end
false
fun _byte_in(bytes: Array[U8] val, target: U8): Bool =>
for b in bytes.values() do
if b == target then return true end
end
false