///|
priv struct PathCounts {
mut files : Int
mut directories : Int
}
///|
/// One canonical prefix stores distinct spellings, not pairs of entries.
priv struct Prefix {
spellings : Map[String, Bool]
mut file : Bool
mut directory : Bool
}
///|
fn[V] sorted_keys(map : Map[String, V]) -> Array[String] {
let keys = map.keys().collect()
keys.sort_by(ordinal_compare)
keys
}
///|
/// MoonBit String.Compare is length-first. Reports use UTF-16 ordinal order.
fn ordinal_compare(a : String, b : String) -> Int {
for i in 0.. String {
String::from_iter(
text
.iter()
.map(c => {
if c >= 'A' && c <= 'Z' {
(c.to_int() + 32).unsafe_to_char()
} else {
c
}
}),
)
}
///|
fn diagnostic(
code : String,
paths : Array[String],
occurrences : Int,
) -> Diagnostic {
let message = match code {
"PATH_EMPTY" => "A relative path cannot be empty."
"PATH_ABSOLUTE" => "Use a root-relative path, not an absolute or UNC path."
"PATH_DRIVE" => "Drive-qualified paths are not root-relative."
"PATH_EMPTY_COMPONENT" =>
"Path components cannot be empty; omit trailing slashes."
"PATH_DOT_COMPONENT" =>
"Dot and parent components are not normalized or accepted."
"NAME_INVALID_CHAR" =>
"A component contains a Windows-reserved character or U+0000..U+001F."
"NAME_RESERVED" =>
"A component uses a Windows device name, including its extension form."
"NAME_TRAILING_DOT_SPACE" =>
"A component ends in an ASCII space or a period."
"PATH_DUPLICATE" =>
"The same exact path occurs more than once in the input."
"PATH_CASE_COLLISION" =>
"Distinct prefix spellings collide under ASCII case folding."
"PATH_KIND_CONFLICT" => "A folded path denotes both a file and a directory."
_ => "Path portability issue."
}
{ code, severity: "error", paths, occurrences, message, }
}
///|
fn sort_diagnostics(items : Array[Diagnostic]) -> Unit {
items.sort_by((a, b) => {
let code_order = ordinal_compare(a.code, b.code)
if code_order != 0 {
return code_order
}
let n = a.paths.length().min(b.paths.length())
for i in 0.. Bool {
let folded = ascii_lower(component)
let stem = match folded.split_once(".") {
Some((head, _)) => head.to_owned()
None => folded
}
match stem {
"con" | "prn" | "aux" | "nul" => true
_ => {
let chars = stem.iter().collect()
chars.length() == 4 &&
(stem.has_prefix("com") || stem.has_prefix("lpt")) &&
(
(chars[3] >= '1' && chars[3] <= '9') ||
chars[3] == '¹' ||
chars[3] == '²' ||
chars[3] == '³'
)
}
}
}
///|
/// Validate spelling separately from hierarchy construction.
fn check_path(
path : String,
count : Int,
out : Array[Diagnostic],
) -> Array[String]? {
let found : Map[String, Bool] = Map([])
let parts = path.split("/").map(p => p.to_owned()).collect()
let mut structural = false
if path == "" {
found["PATH_EMPTY"] = true
structural = true
}
if path.has_prefix("/") || path.has_prefix("\\") {
found["PATH_ABSOLUTE"] = true
structural = true
}
let leading = path.iter().take(2).collect()
if leading.length() == 2 &&
leading[1] == ':' &&
(
(leading[0] >= 'a' && leading[0] <= 'z') ||
(leading[0] >= 'A' && leading[0] <= 'Z')
) {
found["PATH_DRIVE"] = true
structural = true
}
for part in parts {
if part == "" && path != "" {
found["PATH_EMPTY_COMPONENT"] = true
structural = true
}
if part == "." || part == ".." {
found["PATH_DOT_COMPONENT"] = true
structural = true
} else if part.has_suffix(".") || part.has_suffix(" ") {
found["NAME_TRAILING_DOT_SPACE"] = true
}
for c in part.iter() {
if c.to_int() < 32 ||
(match c {
'<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*' => true
_ => false
}) {
found["NAME_INVALID_CHAR"] = true
}
}
if reserved(part) {
found["NAME_RESERVED"] = true
}
}
for code in sorted_keys(found) {
out.push(diagnostic(code, [path], count))
}
if structural {
None
} else {
Some(parts)
}
}
///| Audit supplied entries. Completeness means the supplied list was checked,
///|
/// not that it describes every file in a directory or every filesystem rule.
pub fn audit(entries : Array[PathEntry]) -> Report {
let counts : Map[String, PathCounts] = Map([])
let prefixes : Map[String, Prefix] = Map([])
let diagnostics : Array[Diagnostic] = []
let mut files = 0
let mut directories = 0
for entry in entries {
let count = match counts.get(entry.path) {
Some(c) => c
None => {
let c = { files: 0, directories: 0, }
counts[entry.path] = c
c
}
}
match entry.kind {
File => {
files += 1
count.files += 1
}
Directory => {
directories += 1
count.directories += 1
}
}
}
for path in sorted_keys(counts) {
let count = counts[path]
let total = count.files + count.directories
if total > 1 {
diagnostics.push(diagnostic("PATH_DUPLICATE", [path], total))
}
guard check_path(path, total, diagnostics) is Some(parts) else { continue }
let mut prefix = ""
for i, part in parts {
prefix = if i == 0 { part } else { prefix + "/" + part }
let folded = ascii_lower(prefix)
let node = match prefixes.get(folded) {
Some(p) => p
None => {
let p = { spellings: Map([]), file: false, directory: false, }
prefixes[folded] = p
p
}
}
node.spellings[prefix] = true
if i < parts.length() - 1 || count.directories > 0 {
node.directory = true
}
if i == parts.length() - 1 && count.files > 0 {
node.file = true
}
}
}
for node in prefixes.values() {
let paths = sorted_keys(node.spellings)
if paths.length() > 1 {
diagnostics.push(diagnostic("PATH_CASE_COLLISION", paths, paths.length()))
}
if node.file && node.directory {
diagnostics.push(diagnostic("PATH_KIND_CONFLICT", paths, paths.length()))
}
}
sort_diagnostics(diagnostics)
{
schema_version: 1,
profile: "portable-windows-v1",
source: "manifest",
complete: true,
summary: {
entries: entries.length(),
files,
directories,
diagnostic_groups: diagnostics.length(),
scan_issues: 0,
},
diagnostics,
limitations: [
"Only ASCII case folding is checked; Unicode case equivalence and normalization are not checked.",
"Path length limits, filesystem-specific rules, permissions and destination state are not checked.",
"A manifest report covers only supplied entries; a directory scan is not an atomic snapshot.",
],
}
}