///|
/// moonsec-headers audits HTTP response security headers from plain text.
///
/// The library intentionally accepts raw header blocks instead of performing
/// network requests. That keeps it deterministic for CI, Wasm, and offline
/// resource pipelines.
pub(all) enum Severity {
  Critical
  High
  Medium
  Low
  Info
} derive(Eq, Debug)

///|
pub(all) enum ParseIssueKind {
  EmptyName
  MissingColon
  ControlCharacter
  EmptyDirective
  DuplicateDirective
  InvalidToken
} derive(Eq, Debug)

///|
pub(all) struct ParseIssue {
  kind : ParseIssueKind
  line : Int
  name : String
  message : String
} derive(Eq, Debug)

///|
pub(all) struct Header {
  name : String
  value : String
  line : Int
} derive(Eq, Debug)

///|
pub(all) struct HeaderSet {
  headers : Array[Header]
  index : Map[String, Array[String]]
  issues : Array[ParseIssue]
} derive(Eq, Debug)

///|
pub(all) struct Directive {
  name : String
  values : Array[String]
} derive(Eq, Debug)

///|
pub(all) struct CspPolicy {
  raw : String
  directives : Array[Directive]
  duplicates : Array[String]
  issues : Array[ParseIssue]
} derive(Eq, Debug)

///|
pub(all) struct Finding {
  id : String
  severity : Severity
  header : String
  message : String
  evidence : String
  remediation : String
} derive(Eq, Debug)

///|
pub(all) struct AuditReport {
  score : Int
  grade : String
  findings : Array[Finding]
  headers : HeaderSet
} derive(Eq, Debug)

///|
pub fn Severity::weight(self : Severity) -> Int {
  match self {
    Critical => 30
    High => 20
    Medium => 10
    Low => 4
    Info => 0
  }
}

///|
pub fn Severity::label(self : Severity) -> String {
  match self {
    Critical => "critical"
    High => "high"
    Medium => "medium"
    Low => "low"
    Info => "info"
  }
}

///|
pub fn ParseIssueKind::label(self : ParseIssueKind) -> String {
  match self {
    EmptyName => "empty-name"
    MissingColon => "missing-colon"
    ControlCharacter => "control-character"
    EmptyDirective => "empty-directive"
    DuplicateDirective => "duplicate-directive"
    InvalidToken => "invalid-token"
  }
}

///|
pub fn normalize_header_name(name : StringView) -> String {
  name.trim().to_lower().to_owned()
}

///|
pub fn is_known_security_header(name : StringView) -> Bool {
  let n = normalize_header_name(name)
  n == "content-security-policy" ||
  n == "content-security-policy-report-only" ||
  n == "strict-transport-security" ||
  n == "x-content-type-options" ||
  n == "x-frame-options" ||
  n == "referrer-policy" ||
  n == "permissions-policy" ||
  n == "cross-origin-opener-policy" ||
  n == "cross-origin-resource-policy" ||
  n == "cross-origin-embedder-policy" ||
  n == "access-control-allow-origin" ||
  n == "access-control-allow-credentials"
}

///|
pub fn parse_header_line(
  line : StringView,
  line_number : Int,
) -> Result[Header, ParseIssue] {
  match line.split_once(":") {
    Some((name_view, value_view)) => {
      let name = normalize_header_name(name_view)
      let value = value_view.trim().to_owned()
      if name.is_empty() {
        Err({
          kind: EmptyName,
          line: line_number,
          name: "",
          message: "header name is empty",
        })
      } else if has_control_character(name) || has_control_character(value) {
        Err({
          kind: ControlCharacter,
          line: line_number,
          name,
          message: "header contains a control character",
        })
      } else {
        Ok({ name, value, line: line_number })
      }
    }
    None =>
      Err({
        kind: MissingColon,
        line: line_number,
        name: "",
        message: "header line must contain ':'",
      })
  }
}

///|
pub fn parse_headers(raw : StringView) -> HeaderSet {
  let headers : Array[Header] = []
  let issues : Array[ParseIssue] = []
  let index : Map[String, Array[String]] = Map([])
  for line_number, line in raw.split("\n").iter2() {
    let trimmed = line.trim()
    if !trimmed.is_empty() {
      match parse_header_line(trimmed, line_number + 1) {
        Ok(header) => {
          let values = index.get_or_init(header.name, () => [])
          values.push(header.value)
          headers.push(header)
        }
        Err(issue) => issues.push(issue)
      }
    }
  }
  { headers, index, issues }
}

///|
pub fn HeaderSet::has(self : HeaderSet, name : StringView) -> Bool {
  self.index.contains(normalize_header_name(name))
}

///|
pub fn HeaderSet::get(self : HeaderSet, name : StringView) -> String? {
  match self.index.get(normalize_header_name(name)) {
    Some(values) =>
      match values.get(0) {
        Some(value) => Some(value)
        None => None
      }
    None => None
  }
}

///|
pub fn HeaderSet::get_all(self : HeaderSet, name : StringView) -> Array[String] {
  match self.index.get(normalize_header_name(name)) {
    Some(values) => values.copy()
    None => []
  }
}

///|
pub fn HeaderSet::security_headers(self : HeaderSet) -> Array[Header] {
  self.headers.filter(header => is_known_security_header(header.name))
}

///|
pub fn parse_csp(value : StringView) -> CspPolicy {
  let directives : Array[Directive] = []
  let duplicates : Array[String] = []
  let issues : Array[ParseIssue] = []
  let seen : Map[String, Bool] = Map([])
  for offset, raw_part in value.split(";").iter2() {
    let part = raw_part.trim()
    if !part.is_empty() {
      let tokens = split_ascii_words(part)
      if tokens.is_empty() {
        issues.push({
          kind: EmptyDirective,
          line: offset + 1,
          name: "",
          message: "CSP directive is empty",
        })
      } else {
        let name = tokens[0].to_lower()
        if seen.contains(name) {
          duplicates.push(name)
          issues.push({
            kind: DuplicateDirective,
            line: offset + 1,
            name,
            message: "CSP directive appears more than once; later values are ignored by browsers",
          })
        } else {
          seen.set(name, true)
        }
        let values : Array[String] = []
        for i in 1.. Bool {
  self.directive(name) is Some(_)
}

///|
pub fn CspPolicy::directive(self : CspPolicy, name : StringView) -> Directive? {
  let expected = name.trim().to_lower().to_owned()
  self.directives.iter().find_first(d => d.name == expected)
}

///|
pub fn CspPolicy::values_for(
  self : CspPolicy,
  name : StringView,
) -> Array[String] {
  match self.directive(name) {
    Some(d) => d.values.copy()
    None => []
  }
}

///|
pub fn CspPolicy::effective_values(
  self : CspPolicy,
  name : StringView,
) -> Array[String] {
  match self.directive(name) {
    Some(d) => d.values.copy()
    None =>
      match self.directive("default-src") {
        Some(d) => d.values.copy()
        None => []
      }
  }
}

///|
pub fn audit_headers(raw : StringView) -> AuditReport {
  audit_header_set(parse_headers(raw))
}

///|
pub fn audit_header_set(headers : HeaderSet) -> AuditReport {
  let findings : Array[Finding] = []
  for issue in headers.issues {
    findings.push({
      id: "parse." + issue.kind.label(),
      severity: Medium,
      header: "headers",
      message: issue.message,
      evidence: "line " + issue.line.to_string(),
      remediation: "Keep every input line in the form 'Header-Name: value'.",
    })
  }
  audit_csp(headers, findings)
  audit_hsts(headers, findings)
  audit_x_content_type_options(headers, findings)
  audit_frame_controls(headers, findings)
  audit_referrer_policy(headers, findings)
  audit_permissions_policy(headers, findings)
  audit_cross_origin_isolation(headers, findings)
  audit_cors(headers, findings)
  let score = score_findings(findings)
  { score, grade: grade_score(score), findings, headers }
}

///|
fn audit_csp(headers : HeaderSet, findings : Array[Finding]) -> Unit {
  match headers.get("content-security-policy") {
    Some(value) => {
      let policy = parse_csp(value)
      for issue in policy.issues {
        findings.push({
          id: "csp." + issue.kind.label(),
          severity: Low,
          header: "content-security-policy",
          message: issue.message,
          evidence: issue.name,
          remediation: "Declare each CSP directive at most once.",
        })
      }
      let script_values = policy.effective_values("script-src")
      let default_values = policy.values_for("default-src")
      if default_values.is_empty() && script_values.is_empty() {
        findings.push({
          id: "csp.missing-default-src",
          severity: Medium,
          header: "content-security-policy",
          message: "CSP does not provide default-src or script-src fallback.",
          evidence: value,
          remediation: "Add at least default-src 'self' or a stricter directive set.",
        })
      }
      if contains_token(script_values, "'unsafe-inline'") {
        findings.push({
          id: "csp.unsafe-inline-script",
          severity: High,
          header: "content-security-policy",
          message: "script-src allows 'unsafe-inline'.",
          evidence: join_tokens(script_values),
          remediation: "Prefer nonces, hashes, or external scripts without unsafe inline execution.",
        })
      }
      if contains_token(script_values, "'unsafe-eval'") {
        findings.push({
          id: "csp.unsafe-eval",
          severity: High,
          header: "content-security-policy",
          message: "script-src allows 'unsafe-eval'.",
          evidence: join_tokens(script_values),
          remediation: "Remove 'unsafe-eval' and replace dynamic code evaluation.",
        })
      }
      if contains_token(script_values, "*") {
        findings.push({
          id: "csp.wildcard-script",
          severity: High,
          header: "content-security-policy",
          message: "script-src allows every origin.",
          evidence: join_tokens(script_values),
          remediation: "List trusted script origins explicitly.",
        })
      }
      if contains_http_scheme(script_values) {
        findings.push({
          id: "csp.http-script-source",
          severity: Medium,
          header: "content-security-policy",
          message: "script-src contains an insecure http: source.",
          evidence: join_tokens(script_values),
          remediation: "Use https: sources or hostnames that are always served over TLS.",
        })
      }
      if !directive_is_none(policy, "object-src") {
        findings.push({
          id: "csp.object-src-not-none",
          severity: Medium,
          header: "content-security-policy",
          message: "object-src is absent or not restricted to 'none'.",
          evidence: policy.values_for("object-src").join(" "),
          remediation: "Add object-src 'none' to block legacy plugin surfaces.",
        })
      }
      if !directive_mentions_self_or_none(policy, "base-uri") {
        findings.push({
          id: "csp.base-uri-missing",
          severity: Medium,
          header: "content-security-policy",
          message: "base-uri is absent or too broad.",
          evidence: policy.values_for("base-uri").join(" "),
          remediation: "Add base-uri 'self' or base-uri 'none'.",
        })
      }
      if !policy.has_directive("frame-ancestors") &&
        !headers.has("x-frame-options") {
        findings.push({
          id: "csp.frame-ancestors-missing",
          severity: Medium,
          header: "content-security-policy",
          message: "No clickjacking protection was found.",
          evidence: "frame-ancestors and x-frame-options are both absent",
          remediation: "Add frame-ancestors 'none' or 'self'.",
        })
      }
    }
    None =>
      findings.push({
        id: "csp.missing",
        severity: High,
        header: "content-security-policy",
        message: "Content-Security-Policy is missing.",
        evidence: "",
        remediation: "Add a site-specific CSP starting with default-src 'self'.",
      })
  }
}

///|
fn audit_hsts(headers : HeaderSet, findings : Array[Finding]) -> Unit {
  match headers.get("strict-transport-security") {
    Some(value) => {
      let lowered = value.to_lower()
      let max_age = parse_hsts_max_age(lowered)
      match max_age {
        Some(seconds) =>
          if seconds < 15552000 {
            findings.push({
              id: "hsts.max-age-short",
              severity: Medium,
              header: "strict-transport-security",
              message: "HSTS max-age is shorter than 180 days.",
              evidence: seconds.to_string(),
              remediation: "Use max-age=31536000 once HTTPS is stable for the whole site.",
            })
          }
        None =>
          findings.push({
            id: "hsts.max-age-missing",
            severity: High,
            header: "strict-transport-security",
            message: "HSTS is present but max-age is missing or invalid.",
            evidence: value,
            remediation: "Set Strict-Transport-Security: max-age=31536000; includeSubDomains.",
          })
      }
      if !lowered.contains("includesubdomains") {
        findings.push({
          id: "hsts.include-subdomains-missing",
          severity: Low,
          header: "strict-transport-security",
          message: "HSTS does not include subdomains.",
          evidence: value,
          remediation: "Add includeSubDomains when all subdomains are HTTPS ready.",
        })
      }
    }
    None =>
      findings.push({
        id: "hsts.missing",
        severity: Medium,
        header: "strict-transport-security",
        message: "Strict-Transport-Security is missing.",
        evidence: "",
        remediation: "Add an HSTS header for HTTPS responses.",
      })
  }
}

///|
fn audit_x_content_type_options(
  headers : HeaderSet,
  findings : Array[Finding],
) -> Unit {
  match headers.get("x-content-type-options") {
    Some(value) =>
      if value.to_lower().trim().to_owned() != "nosniff" {
        findings.push({
          id: "xcto.invalid",
          severity: Medium,
          header: "x-content-type-options",
          message: "X-Content-Type-Options is not set to nosniff.",
          evidence: value,
          remediation: "Use X-Content-Type-Options: nosniff.",
        })
      }
    None =>
      findings.push({
        id: "xcto.missing",
        severity: Low,
        header: "x-content-type-options",
        message: "X-Content-Type-Options is missing.",
        evidence: "",
        remediation: "Add X-Content-Type-Options: nosniff.",
      })
  }
}

///|
fn audit_frame_controls(headers : HeaderSet, findings : Array[Finding]) -> Unit {
  let has_frame_ancestors = match headers.get("content-security-policy") {
    Some(value) => parse_csp(value).has_directive("frame-ancestors")
    None => false
  }
  match headers.get("x-frame-options") {
    Some(value) => {
      let v = value.to_lower().trim().to_owned()
      if v != "deny" && v != "sameorigin" {
        findings.push({
          id: "xfo.invalid",
          severity: Medium,
          header: "x-frame-options",
          message: "X-Frame-Options is neither DENY nor SAMEORIGIN.",
          evidence: value,
          remediation: "Use DENY, SAMEORIGIN, or prefer CSP frame-ancestors.",
        })
      }
    }
    None =>
      if !has_frame_ancestors {
        findings.push({
          id: "xfo.missing",
          severity: Low,
          header: "x-frame-options",
          message: "X-Frame-Options is missing and CSP frame-ancestors is absent.",
          evidence: "",
          remediation: "Add CSP frame-ancestors or X-Frame-Options.",
        })
      }
  }
}

///|
fn audit_referrer_policy(
  headers : HeaderSet,
  findings : Array[Finding],
) -> Unit {
  match headers.get("referrer-policy") {
    Some(value) => {
      let v = value.to_lower().trim().to_owned()
      if v == "unsafe-url" {
        findings.push({
          id: "referrer.unsafe-url",
          severity: High,
          header: "referrer-policy",
          message: "Referrer-Policy unsafe-url can leak full URLs to third-party origins.",
          evidence: value,
          remediation: "Use strict-origin-when-cross-origin, same-origin, or no-referrer.",
        })
      } else if v == "no-referrer-when-downgrade" {
        findings.push({
          id: "referrer.legacy-default",
          severity: Medium,
          header: "referrer-policy",
          message: "Referrer-Policy uses the legacy browser default.",
          evidence: value,
          remediation: "Use strict-origin-when-cross-origin or stricter.",
        })
      }
    }
    None =>
      findings.push({
        id: "referrer.missing",
        severity: Low,
        header: "referrer-policy",
        message: "Referrer-Policy is missing.",
        evidence: "",
        remediation: "Add Referrer-Policy: strict-origin-when-cross-origin or stricter.",
      })
  }
}

///|
fn audit_permissions_policy(
  headers : HeaderSet,
  findings : Array[Finding],
) -> Unit {
  match headers.get("permissions-policy") {
    Some(value) => {
      let lower = value.to_lower()
      for feature in ["camera", "microphone", "geolocation"] {
        if !permissions_policy_disables(lower, feature) {
          findings.push({
            id: "permissions." + feature + "-not-disabled",
            severity: Low,
            header: "permissions-policy",
            message: "Permissions-Policy does not explicitly disable " +
            feature +
            ".",
            evidence: value,
            remediation: "Use " +
            feature +
            "=() unless the feature is required.",
          })
        }
      }
    }
    None =>
      findings.push({
        id: "permissions.missing",
        severity: Info,
        header: "permissions-policy",
        message: "Permissions-Policy is missing.",
        evidence: "",
        remediation: "Add a Permissions-Policy for high-risk browser features.",
      })
  }
}

///|
fn audit_cross_origin_isolation(
  headers : HeaderSet,
  findings : Array[Finding],
) -> Unit {
  match headers.get("cross-origin-opener-policy") {
    Some(value) => {
      let v = value.to_lower().trim().to_owned()
      if v != "same-origin" && v != "same-origin-allow-popups" {
        findings.push({
          id: "coop.weak",
          severity: Low,
          header: "cross-origin-opener-policy",
          message: "Cross-Origin-Opener-Policy is present but weak.",
          evidence: value,
          remediation: "Use same-origin for isolated applications.",
        })
      }
    }
    None =>
      findings.push({
        id: "coop.missing",
        severity: Info,
        header: "cross-origin-opener-policy",
        message: "Cross-Origin-Opener-Policy is missing.",
        evidence: "",
        remediation: "Add COOP when the application needs cross-origin isolation.",
      })
  }
  match headers.get("cross-origin-resource-policy") {
    Some(value) => {
      let v = value.to_lower().trim().to_owned()
      if v != "same-origin" && v != "same-site" && v != "cross-origin" {
        findings.push({
          id: "corp.invalid",
          severity: Low,
          header: "cross-origin-resource-policy",
          message: "Cross-Origin-Resource-Policy has an unrecognized value.",
          evidence: value,
          remediation: "Use same-origin, same-site, or cross-origin.",
        })
      }
    }
    None =>
      findings.push({
        id: "corp.missing",
        severity: Info,
        header: "cross-origin-resource-policy",
        message: "Cross-Origin-Resource-Policy is missing.",
        evidence: "",
        remediation: "Add CORP for resources that should not be embedded cross-origin.",
      })
  }
}

///|
fn audit_cors(headers : HeaderSet, findings : Array[Finding]) -> Unit {
  let origin = headers.get("access-control-allow-origin")
  let credentials = headers.get("access-control-allow-credentials")
  match (origin, credentials) {
    (Some(o), Some(c)) =>
      if o.trim().to_owned() == "*" && c.to_lower().trim().to_owned() == "true" {
        findings.push({
          id: "cors.wildcard-with-credentials",
          severity: High,
          header: "access-control-allow-origin",
          message: "CORS allows every origin while credentials are enabled.",
          evidence: "Access-Control-Allow-Origin: *; Access-Control-Allow-Credentials: true",
          remediation: "Echo only allowlisted origins when credentials are required.",
        })
      }
    _ => ()
  }
}

///|
pub fn score_findings(findings : Array[Finding]) -> Int {
  let mut score = 100
  for finding in findings {
    score = score - finding.severity.weight()
  }
  if score < 0 {
    0
  } else {
    score
  }
}

///|
pub fn grade_score(score : Int) -> String {
  if score >= 90 {
    "A"
  } else if score >= 75 {
    "B"
  } else if score >= 60 {
    "C"
  } else if score >= 40 {
    "D"
  } else {
    "F"
  }
}

///|
pub fn AuditReport::count_by_severity(
  self : AuditReport,
  severity : Severity,
) -> Int {
  self.findings.count_if(f => f.severity == severity)
}

///|
/// Returns true when the report reaches the caller's score threshold.
///
/// The threshold is supplied by the caller because a release gate may be
/// stricter than a local development check.
pub fn AuditReport::passes(self : AuditReport, minimum_score : Int) -> Bool {
  self.score >= minimum_score
}

///|
/// Returns true when the report contains no Critical or High finding.
pub fn AuditReport::has_blocking_findings(self : AuditReport) -> Bool {
  self.findings.any(f => f.severity == Critical || f.severity == High)
}

///|
pub fn AuditReport::is_clean(self : AuditReport) -> Bool {
  self.findings.is_empty()
}

///|
pub fn AuditReport::has_finding(self : AuditReport, id : StringView) -> Bool {
  let expected = id.to_owned()
  self.findings.any(f => f.id == expected)
}

///|
pub fn AuditReport::to_markdown(self : AuditReport) -> String {
  let lines : Array[String] = []
  lines.push("# Security Header Audit")
  lines.push("")
  lines.push("- Score: " + self.score.to_string())
  lines.push("- Grade: " + self.grade)
  lines.push("- Findings: " + self.findings.length().to_string())
  lines.push("")
  lines.push("| Severity | Header | Finding |")
  lines.push("| --- | --- | --- |")
  for finding in self.findings {
    lines.push(
      "| " +
      finding.severity.label() +
      " | " +
      finding.header +
      " | " +
      finding.message.replace_all(old="|", new="\\|") +
      " |",
    )
  }
  lines.join("\n")
}

///|
pub fn AuditReport::to_json_string(self : AuditReport) -> String {
  let finding_values : Array[Json] = []
  for finding in self.findings {
    finding_values.push(finding_to_json(finding))
  }
  let header_values : Array[Json] = []
  for header in self.headers.headers {
    header_values.push(
      Json::object(
        Map([
          ("name", Json::string(header.name)),
          ("value", Json::string(header.value)),
          (
            "line",
            Json::number(header.line.to_double(), repr=header.line.to_string()),
          ),
        ]),
      ),
    )
  }
  Json::object(
    Map([
      (
        "score",
        Json::number(self.score.to_double(), repr=self.score.to_string()),
      ),
      ("grade", Json::string(self.grade)),
      ("findings", Json::array(finding_values)),
      ("headers", Json::array(header_values)),
    ]),
  ).stringify(indent=2)
}

///|
fn finding_to_json(finding : Finding) -> Json {
  Json::object(
    Map([
      ("id", Json::string(finding.id)),
      ("severity", Json::string(finding.severity.label())),
      ("header", Json::string(finding.header)),
      ("message", Json::string(finding.message)),
      ("evidence", Json::string(finding.evidence)),
      ("remediation", Json::string(finding.remediation)),
    ]),
  )
}

///|
pub fn recommended_baseline() -> String {
  [
    "Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
    "Strict-Transport-Security: max-age=31536000; includeSubDomains", "X-Content-Type-Options: nosniff",
    "Referrer-Policy: strict-origin-when-cross-origin", "Permissions-Policy: camera=(), microphone=(), geolocation=()",
    "Cross-Origin-Opener-Policy: same-origin", "Cross-Origin-Resource-Policy: same-origin",
  ].join("\n")
}

///|
pub fn sample_insecure_headers() -> String {
  [
    "Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval' http:",
    "Strict-Transport-Security: max-age=300", "X-Content-Type-Options: sniff", "X-Frame-Options: ALLOW-FROM https://example.test",
    "Referrer-Policy: unsafe-url", "Access-Control-Allow-Origin: *", "Access-Control-Allow-Credentials: true",
  ].join("\n")
}

///|
fn contains_token(values : Array[String], token : String) -> Bool {
  values.any(v => v == token)
}

///|
fn contains_http_scheme(values : Array[String]) -> Bool {
  values.any(v => v == "http:" || v.has_prefix("http://"))
}

///|
fn directive_is_none(policy : CspPolicy, name : StringView) -> Bool {
  let values = policy.values_for(name)
  values.length() == 1 && values[0] == "'none'"
}

///|
fn directive_mentions_self_or_none(
  policy : CspPolicy,
  name : StringView,
) -> Bool {
  let values = policy.values_for(name)
  contains_token(values, "'self'") || contains_token(values, "'none'")
}

///|
fn join_tokens(tokens : Array[String]) -> String {
  if tokens.is_empty() {
    ""
  } else {
    tokens.join(" ")
  }
}

///|
fn parse_hsts_max_age(value : String) -> Int? {
  let mut parsed : Int? = None
  for part in value.split(";") {
    let p = part.trim().to_owned()
    if p.has_prefix("max-age=") {
      let number = p.strip_prefix("max-age=").unwrap().to_owned()
      if is_ascii_digits(number) {
        parsed = try @string.parse_int(number) |> Some catch {
          _ => None
        }
      }
    }
  }
  parsed
}

///|
fn permissions_policy_disables(value : String, feature : String) -> Bool {
  value.contains(feature + "=()") || value.contains(feature + "=(self)")
}

///|
fn has_control_character(value : String) -> Bool {
  value.any(ch => {
    let code = ch.to_int()
    code < 32 && ch != '\t'
  })
}

///|
fn split_ascii_words(input : StringView) -> Array[String] {
  let words : Array[String] = []
  for part in input.split(" ") {
    let token = part.trim()
    if !token.is_empty() {
      words.push(token.to_owned())
    }
  }
  words
}

///|
fn is_ascii_digits(value : String) -> Bool {
  !value.is_empty() && value.all(ch => ch >= '0' && ch <= '9')
}