///|
/// WIT lexer. Tokenizes a WIT 1.0 source document into a token stream with
/// line/column positions. Keywords are returned as plain identifiers and
/// resolved by the parser.

///|
pub enum TokenKind {
  /// An identifier, or a keyword (resolved by the parser).
  /// WIT escaped identifiers retain their leading `%`.
  Ident(String)
  /// A semver version string following `@`, e.g. `0.1.0`.
  Version(String)
  LBrace
  RBrace
  LParen
  RParen
  Lt
  Gt
  Comma
  Semi
  Colon
  Arrow
  Eq
  Dot
  Slash
  At
  Star
  Eof
} derive(Eq)

///|
pub struct Token {
  kind : TokenKind
  line : Int
  col : Int
} derive(Eq)

///|
pub fn Token::to_string(self : Token) -> String {
  match self.kind {
    Ident(s) => "ident(\{s})"
    Version(s) => "version(\{s})"
    LBrace => "{"
    RBrace => "}"
    LParen => "("
    RParen => ")"
    Lt => "<"
    Gt => ">"
    Comma => ","
    Semi => ";"
    Colon => ":"
    Arrow => "->"
    Eq => "="
    Dot => "."
    Slash => "/"
    At => "@"
    Star => "*"
    Eof => "eof"
  }
}

///|
/// A single-pass lexer cursor over the source.
priv struct Lexer {
  src : String
  len : Int
  mut pos : Int
  mut line : Int
  mut col : Int
}

///|
fn Lexer::make(src : String) -> Lexer {
  { src, len: src.length(), pos: 0, line: 1, col: 1 }
}

///|
/// The UTF-16 code unit `offset` positions ahead of the cursor, if in bounds.
fn Lexer::code_at(self : Lexer, offset : Int) -> UInt16? {
  let i = self.pos + offset
  if i >= 0 && i < self.len {
    Some(self.src.unsafe_get(i))
  } else {
    None
  }
}

///|
/// The character `offset` positions ahead of the cursor, if in bounds.
fn Lexer::peek_char(self : Lexer, offset : Int) -> Char? {
  match self.code_at(offset) {
    Some(c) => Some(c.to_int().unsafe_to_char())
    None => None
  }
}

///|
/// Consume one code unit, tracking line/column.
fn Lexer::advance(self : Lexer) -> Unit {
  match self.code_at(0) {
    Some(c) => {
      self.pos += 1
      if c == 0x0A {
        self.line += 1
        self.col = 1
      } else {
        self.col += 1
      }
    }
    None => ()
  }
}

///|
fn is_ident_start(ch : Char) -> Bool {
  ch.is_ascii_alphabetic() || ch == '_'
}

///|
fn is_ident_continue(ch : Char) -> Bool {
  ch.is_ascii_alphabetic() || ch.is_ascii_digit() || ch == '_' || ch == '-'
}

///|
/// Skip whitespace and `//` / `/* */` comments (doc comments `///` included).
fn Lexer::skip_trivia(self : Lexer) -> Unit raise WitError {
  for ;; {
    match self.peek_char(0) {
      Some(ch) if ch.is_whitespace() => self.advance()
      Some('/') =>
        match self.peek_char(1) {
          Some('/') =>
            for ;; {
              match self.peek_char(0) {
                Some('\n') | None => break
                _ => self.advance()
              }
            }
          Some('*') => {
            self.advance()
            self.advance()
            for ;; {
              match self.peek_char(0) {
                None =>
                  raise WitError::make(
                    self.line,
                    self.col,
                    "unterminated block comment",
                  )
                Some('*') =>
                  match self.peek_char(1) {
                    Some('/') => {
                      self.advance()
                      self.advance()
                      break
                    }
                    _ => self.advance()
                  }
                _ => self.advance()
              }
            }
          }
          _ => break
        }
      _ => break
    }
  }
}

///|
fn Lexer::lex_ident(self : Lexer) -> String {
  let start = self.pos
  for ;; {
    match self.peek_char(0) {
      Some(ch) if is_ident_continue(ch) =>
        // A '-' continues an identifier only when followed by another
        // identifier character; otherwise it starts `->`.
        if ch == '-' {
          match self.peek_char(1) {
            Some(next) if is_ident_continue(next) => self.advance()
            _ => break
          }
        } else {
          self.advance()
        }
      _ => break
    }
  }
  self.src[start:self.pos].to_owned()
}

///|
/// Lex a version string following `@`, e.g. `0.1.0` or `0.1.0-alpha`.
fn Lexer::lex_version(self : Lexer) -> String {
  let start = self.pos
  for ;; {
    match self.peek_char(0) {
      Some('.') =>
        match self.peek_char(1) {
          Some(next) if next.is_ascii_digit() => self.advance()
          _ => break
        }
      Some(ch) if ch.is_ascii_digit() || ch == '-' || ch.is_ascii_lowercase() =>
        self.advance()
      _ => break
    }
  }
  self.src[start:self.pos].to_owned()
}

///|
/// Skip a WIT attribute after its leading `@`, including a balanced argument
/// list. Attributes are accepted metadata but are not retained in the P0 AST.
fn Lexer::skip_attribute(self : Lexer) -> Unit raise WitError {
  ignore(self.lex_ident())
  while self.peek_char(0) is Some(ch) && ch.is_whitespace() {
    self.advance()
  }
  if self.peek_char(0) != Some('(') {
    return
  }
  let mut depth = 0
  for ;; {
    match self.peek_char(0) {
      None =>
        raise WitError::make(
          self.line,
          self.col,
          "unterminated attribute arguments",
        )
      Some('(') => {
        depth += 1
        self.advance()
      }
      Some(')') => {
        depth -= 1
        self.advance()
        if depth == 0 {
          return
        }
      }
      _ => self.advance()
    }
  }
}

///|
fn push_punct(
  tokens : Array[Token],
  kind : TokenKind,
  line : Int,
  col : Int,
) -> Unit {
  tokens.push({ kind, line, col })
}

///|
/// Tokenize a full WIT document. Raises `WitError` on an unexpected character
/// or an unterminated block comment.
pub fn tokenize(src : String) -> Array[Token] raise WitError {
  let lx = Lexer::make(src)
  let tokens : Array[Token] = Array::new()
  for ;; {
    lx.skip_trivia()
    if lx.pos >= lx.len {
      push_punct(tokens, Eof, lx.line, lx.col)
      break
    }
    let start_line = lx.line
    let start_col = lx.col
    match lx.peek_char(0) {
      Some('{') => {
        lx.advance()
        push_punct(tokens, LBrace, start_line, start_col)
      }
      Some('}') => {
        lx.advance()
        push_punct(tokens, RBrace, start_line, start_col)
      }
      Some('(') => {
        lx.advance()
        push_punct(tokens, LParen, start_line, start_col)
      }
      Some(')') => {
        lx.advance()
        push_punct(tokens, RParen, start_line, start_col)
      }
      Some('<') => {
        lx.advance()
        push_punct(tokens, Lt, start_line, start_col)
      }
      Some('>') => {
        lx.advance()
        push_punct(tokens, Gt, start_line, start_col)
      }
      Some(',') => {
        lx.advance()
        push_punct(tokens, Comma, start_line, start_col)
      }
      Some(';') => {
        lx.advance()
        push_punct(tokens, Semi, start_line, start_col)
      }
      Some(':') => {
        lx.advance()
        push_punct(tokens, Colon, start_line, start_col)
      }
      Some('.') => {
        lx.advance()
        push_punct(tokens, Dot, start_line, start_col)
      }
      Some('/') => {
        lx.advance()
        push_punct(tokens, Slash, start_line, start_col)
      }
      Some('@') => {
        lx.advance()
        match lx.peek_char(0) {
          Some(ch) if is_ident_start(ch) => lx.skip_attribute()
          _ => {
            push_punct(tokens, At, start_line, start_col)
            let v = lx.lex_version()
            push_punct(tokens, Version(v), start_line, start_col)
          }
        }
      }
      Some('*') => {
        lx.advance()
        push_punct(tokens, Star, start_line, start_col)
      }
      Some('=') => {
        lx.advance()
        push_punct(tokens, Eq, start_line, start_col)
      }
      Some('-') => {
        // `->`
        lx.advance()
        match lx.peek_char(0) {
          Some('>') => {
            lx.advance()
            push_punct(tokens, Arrow, start_line, start_col)
          }
          _ => raise WitError::make(lx.line, lx.col, "unexpected character '-'")
        }
      }
      Some('%') => {
        lx.advance()
        match lx.peek_char(0) {
          Some(ch) if is_ident_start(ch) => {
            let text = lx.lex_ident()
            tokens.push({
              kind: Ident("%" + text),
              line: start_line,
              col: start_col,
            })
          }
          _ =>
            raise WitError::make(
              start_line, start_col, "expected identifier after '%'",
            )
        }
      }
      Some(ch) if is_ident_start(ch) => {
        let text = lx.lex_ident()
        tokens.push({ kind: Ident(text), line: start_line, col: start_col })
      }
      Some(ch) =>
        raise WitError::make(
          lx.line,
          lx.col,
          "unexpected character '\{ch.to_string()}'",
        )
      None => break
    }
  }
  tokens
}