///|
/// Parse an include_file body as complete statements, never as a text splice.
pub fn parse_fragment(
  text : String,
  limits~ : Limits = Limits::default(),
) -> Array[Node] raise LibertyError {
  let parser : Parser = {
    tokens: tokenize(text, limits~),
    limits,
    index: 0,
    nodes: 0,
  }
  let nodes = []
  while parser.peek().kind != End {
    nodes.push(parser.node(1))
  }
  nodes
}

///|
fn include_name(
  arguments : Array[Array[Atom]],
  pos : Position,
) -> String raise LibertyError {
  guard arguments is [[atom]] else {
    raise failure("IncludeArguments", "include_file needs one file name", pos)
  }
  let name = atom_text(atom)
  if name.is_empty() ||
    name == "." ||
    name == ".." ||
    name.contains("/") ||
    name.contains("\\") ||
    name.contains(":") ||
    name.contains_char('\u0000') {
    raise failure("IncludeName", "include_file requires a leaf file name", pos)
  }
  name
}

///|
priv struct IncludeExpander {
  sources : Map[String, String]
  limits : Limits
  mut input_chars : Int32
  mut nodes : Int32
}

///|
fn IncludeExpander::group(
  self : IncludeExpander,
  group : Group,
  depth : Int32,
  from_include : Bool,
) -> Group raise LibertyError {
  if depth > self.limits.max_depth {
    raise failure(
      "DepthLimit",
      "expanded include nesting exceeds limit",
      group.position,
    )
  }
  let items = []
  for node in group.items {
    self.nodes += 1
    if self.nodes > self.limits.max_nodes {
      raise failure(
        "NodeLimit",
        "expanded include node budget exceeded",
        group.position,
      )
    }
    match node {
      Complex("include_file", arguments, pos) => {
        if from_include {
          raise failure(
            "NestedInclude", "included files cannot contain include_file", pos,
          )
        }
        let name = include_name(arguments, pos)
        let text = match self.sources.get(name) {
          Some(text) => text
          None =>
            raise failure(
              "MissingInclude",
              "no supplied source for " + name,
              pos,
            )
        }
        if text.length() > self.limits.max_input_chars - self.input_chars {
          raise failure(
            "InputLimit", "combined include input exceeds limit", pos,
          )
        }
        self.input_chars += text.length()
        let fragment = parse_fragment(text, limits=self.limits) catch {
          LibertyError(code, message, fragment_pos) =>
            raise LibertyError(code, name + ": " + message, fragment_pos)
        }
        if fragment.any(node => {
            match node {
              Section(g) => g.kind == "library"
              _ => false
            }
          }) {
          raise failure(
            "IncludeBoundary", "include body cannot introduce a library root", pos,
          )
        }
        let holder = { ..group, items: fragment, }
        let expanded = self.group(holder, depth, true)
        items.append(expanded.items)
      }
      Section(child) =>
        items.push(Section(self.group(child, depth + 1, from_include)))
      _ => items.push(node)
    }
  }
  { ..group, items, }
}

///|
/// Resolve caller-supplied include sources without reading the filesystem.
/// Nested includes and cross-group fragments are rejected (2017.06 include rules).
pub fn parse_liberty_sources(
  text : String,
  sources : Map[String, String],
  limits~ : Limits = Limits::default(),
) -> Group raise LibertyError {
  let root = parse_liberty(text, limits~)
  let state : IncludeExpander = {
    sources,
    limits,
    input_chars: text.length(),
    nodes: 1,
  }
  let expanded = state.group(root, 1, false)
  // Combined, rather than per-file, token/node budgets also apply to the result.
  ignore(write_liberty(expanded, limits~))
  expanded
}