///|
priv struct TokenizedLine {
  tokens : Array[String]
  unclosed_quote : Bool
  trailing_escape : Bool
}

///|
fn decode_escaped_char(char : Char) -> Char {
  match char {
    'n' => '\n'
    'r' => '\r'
    't' => '\t'
    'b' => '\b'
    _ => char
  }
}

///|
fn tokenize_line(line : String) -> TokenizedLine {
  let tokens : Array[String] = []
  let current = StringBuilder()
  let mut quoted = false
  let mut escaping = false
  let mut started = false
  for char in line {
    if escaping {
      current.write_char(decode_escaped_char(char))
      escaping = false
      started = true
    } else if char == '\\' {
      escaping = true
      started = true
    } else if char == '"' {
      quoted = !quoted
      started = true
    } else if !quoted && char.is_ascii_whitespace() {
      if started {
        tokens.push(current.to_string())
        current.reset()
        started = false
      }
    } else {
      current.write_char(char)
      started = true
    }
  }
  if escaping {
    current.write_char('\\')
  }
  if started {
    tokens.push(current.to_string())
  }
  { tokens, unclosed_quote: quoted, trailing_escape: escaping, }
}

///|
fn diagnostic(
  code : String,
  severity : Severity,
  message : String,
  source : String,
  line : Int,
  column? : Int = 1,
) -> Diagnostic {
  { code, severity, message, source, line, column, }
}

///|
fn normalized_line(line : StringView) -> String {
  let text = line.to_owned()
  if text.has_suffix("\r") {
    text.unsafe_substring(start=0, end=text.length() - 1)
  } else {
    text
  }
}

///|
fn parse_assignment_for_line(
  token : String,
  source : String,
  line : Int,
  diagnostics : Array[Diagnostic],
) -> Assignment? {
  try parse_assignment(token) catch {
    InvalidAssignment(_) => {
      diagnostics.push(
        diagnostic(
          "MGA004",
          Error,
          "invalid attribute assignment: \{token}",
          source,
          line,
        ),
      )
      None
    }
    InvalidAttributeName(name) => {
      diagnostics.push(
        diagnostic(
          "MGA003",
          Error,
          "invalid attribute name: \{name}",
          source,
          line,
        ),
      )
      None
    }
  } noraise {
    assignment => Some(assignment)
  }
}

///|
fn parse_line(
  text : String,
  source : String,
  base_dir : String,
  line : Int,
  rules : Array[Rule],
  macros : Array[MacroDefinition],
  diagnostics : Array[Diagnostic],
) -> Unit {
  let trimmed = text.trim_start()
  if trimmed.length() == 0 || trimmed.has_prefix("#") {
    return
  }
  let tokenized = tokenize_line(text)
  if tokenized.unclosed_quote {
    diagnostics.push(
      diagnostic("MGA001", Error, "unclosed quoted token", source, line),
    )
  }
  if tokenized.trailing_escape {
    diagnostics.push(
      diagnostic(
        "MGA002",
        Warning,
        "trailing backslash is literal",
        source,
        line,
      ),
    )
  }
  guard tokenized.tokens is [pattern, .. rest] else { return }
  let escaped_leading_bang = trimmed.has_prefix("\\!")
  if pattern.has_prefix("!") && !escaped_leading_bang {
    diagnostics.push(
      diagnostic(
        "MGA005",
        Error,
        "negative patterns are not allowed in .gitattributes",
        source,
        line,
      ),
    )
    return
  }
  let assignments : Array[Assignment] = []
  for token in rest {
    if parse_assignment_for_line(token, source, line, diagnostics)
      is Some(value) {
      assignments.push(value)
    }
  }
  if assignments.length() == 0 {
    diagnostics.push(
      diagnostic(
        "MGA006",
        Warning,
        "rule has no valid attribute assignments",
        source,
        line,
      ),
    )
  }
  if pattern.has_prefix("[attr]") {
    let name = pattern.unsafe_substring(start=6, end=pattern.length())
    if base_dir.length() > 0 {
      diagnostics.push(
        diagnostic(
          "MGA007",
          Error,
          "attribute macros may only be declared at repository root",
          source,
          line,
        ),
      )
    } else if is_valid_attribute_name(name) {
      macros.push({ name, assignments, source, line, })
    } else {
      diagnostics.push(
        diagnostic("MGA003", Error, "invalid macro name: \{name}", source, line),
      )
    }
  } else {
    if pattern.has_suffix("/") {
      diagnostics.push(
        diagnostic(
          "MGA008",
          Warning,
          "a trailing slash does not recursively match directory contents; use /**",
          source,
          line,
        ),
      )
    }
    rules.push({ pattern, assignments, source, line, base_dir, })
  }
}

///|
/// Parses one `.gitattributes` document without accessing the file system.
pub fn parse(
  content : String,
  source? : String = ".gitattributes",
  base_dir? : String = "",
) -> RuleSet {
  let rules : Array[Rule] = []
  let macros : Array[MacroDefinition] = []
  let diagnostics : Array[Diagnostic] = []
  for index, line in content.split("\n") {
    parse_line(
      normalized_line(line),
      source,
      base_dir,
      index + 1,
      rules,
      macros,
      diagnostics,
    )
  }
  { rules, macros, diagnostics, }
}