///|
fn comma_names(text : String) -> Array[String] raise DesignError {
  if text.trim().is_empty() {
    return []
  }
  let out = []
  for token in text.split(",") {
    let name = token.trim().to_owned()
    if !safe_label(name) {
      raise Invalid("invalid identifier in dependency text")
    }
    if out.contains(name) {
      raise Invalid("duplicate identifier in dependency text")
    }
    out.push(name)
  }
  out
}

///|
pub fn parse_schema_text(text : String) -> Schema raise DesignError {
  if text.length() > 65536 {
    raise Budget("schema text allowance")
  }
  let lines = text
    .split("\n")
    .map(line => line.trim().to_owned())
    .filter(line => !line.is_empty())
    .to_array()
  if lines.is_empty() {
    raise Invalid("missing attributes header")
  }
  let parts = lines[0].split(":").to_array()
  if parts.length() != 2 || parts[0].trim().to_owned() != "attributes" {
    raise Invalid("first line must be attributes: A,B,...")
  }
  let labels = comma_names(parts[1].to_owned())
  let empty = schema(labels, [])
  let dependencies = []
  for i in 1..").to_array()
    if halves.length() != 2 {
      raise Invalid("dependency line must contain exactly one arrow")
    }
    let left = empty.resolve(comma_names(halves[0].to_owned()))
    let right = empty.resolve(comma_names(halves[1].to_owned()))
    if right.is_empty() {
      raise Invalid("text FD conclusion must not be empty")
    }
    dependencies.push({ left, right, })
  }
  schema(labels, dependencies)
}

///|
pub fn Schema::to_text(self : Schema) -> String {
  let out = StringBuilder()
  out.write_string("attributes: " + self.labels.join(",") + "\n")
  for fd in self.dependencies() {
    if fd.right.is_empty() {
      continue
    }
    let a = fd.left.map(i => self.labels[i]).join(",")
    let b = fd.right.map(i => self.labels[i]).join(",")
    out.write_string(a + " -> " + b + "\n")
  }
  out.to_string()
}