use std::cmp::Ordering;
use crate::error::SyntaxError;
use crate::kind::SyntaxKind;
use crate::text::TextRange;
const TAB_WIDTH: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Token {
pub kind: SyntaxKind,
pub range: TextRange,
}
impl Token {
#[must_use]
pub fn text(self, source: &str) -> &str {
self.range.slice(source)
}
}
#[derive(Debug, Clone)]
pub struct LexResult {
pub tokens: Vec<Token>,
pub errors: Vec<SyntaxError>,
}
#[derive(Debug, Clone, Copy)]
struct LambdaContext {
bracket_depth: u32,
body_column: Option<u32>,
indent_len: usize,
inline: bool,
}
#[must_use]
pub fn tokenize(source: &str) -> LexResult {
Lexer::new(source).run()
}
struct Lexer<'a> {
source: &'a str,
bytes: &'a [u8],
pos: u32,
tokens: Vec<Token>,
errors: Vec<SyntaxError>,
indents: Vec<u32>,
bracket_depth: u32,
lambda_stack: Vec<LambdaContext>,
pending_lambda: Option<u32>,
at_line_start: bool,
}
impl<'a> Lexer<'a> {
fn new(source: &'a str) -> Self {
Self {
source,
bytes: source.as_bytes(),
pos: 0,
tokens: Vec::with_capacity(source.len() / 4),
errors: Vec::new(),
indents: vec![0],
bracket_depth: 0,
lambda_stack: Vec::new(),
pending_lambda: None,
at_line_start: true,
}
}
fn run(mut self) -> LexResult {
while !self.at_eof() {
if self.at_line_start && self.indent_significant() {
self.lex_line_start();
} else {
self.lex_token();
}
}
let end = self.pos;
while !self.lambda_stack.is_empty() {
self.close_top_lambda(end);
}
while self.indents.len() > 1 {
self.indents.pop();
self.push(SyntaxKind::Dedent, TextRange::empty(end));
}
self.push(SyntaxKind::Eof, TextRange::empty(end));
LexResult {
tokens: self.tokens,
errors: self.errors,
}
}
fn lex_line_start(&mut self) {
let start = self.pos;
let mut column = 0;
let mut cursor = self.pos;
while let Some(byte) = self.byte_at(cursor) {
match byte {
b' ' => column += 1,
b'\t' => column = (column / TAB_WIDTH + 1) * TAB_WIDTH,
_ => break,
}
cursor += 1;
}
let indent_range = TextRange::new(start, cursor);
let blank_line = matches!(
self.byte_at(cursor),
None | Some(b'\n' | b'\r' | b'#' | b'\\')
);
if blank_line {
if !indent_range.is_empty() {
self.pos = cursor;
self.push(SyntaxKind::Whitespace, indent_range);
}
self.at_line_start = false;
self.lex_token();
return;
}
while let Some(context) = self.lambda_stack.last() {
match context.body_column {
None if context.inline => self.close_top_lambda(start),
None => {
self.lambda_stack
.last_mut()
.expect("checked immediately above")
.body_column = Some(column);
break;
}
Some(body_column) if column < body_column => self.close_top_lambda(start),
Some(_) => break,
}
}
if !self.indent_significant() {
if !indent_range.is_empty() {
self.pos = cursor;
self.push(SyntaxKind::Whitespace, indent_range);
}
self.at_line_start = false;
return;
}
let current = *self.indents.last().expect("indent stack is never empty");
match column.cmp(¤t) {
Ordering::Greater => {
self.pos = cursor;
self.emit_indent_whitespace(indent_range);
self.indents.push(column);
self.push(SyntaxKind::Indent, TextRange::empty(cursor));
}
Ordering::Less => {
while *self.indents.last().expect("indent stack is never empty") > column {
self.indents.pop();
self.push(SyntaxKind::Dedent, TextRange::empty(start));
}
if *self.indents.last().expect("indent stack is never empty") != column {
self.errors.push(SyntaxError::new(
indent_range,
"unindent does not match any outer indentation level",
));
self.indents.push(column);
}
self.pos = cursor;
self.emit_indent_whitespace(indent_range);
}
Ordering::Equal => {
self.pos = cursor;
self.emit_indent_whitespace(indent_range);
}
}
self.at_line_start = false;
}
fn emit_indent_whitespace(&mut self, indent_range: TextRange) {
if !indent_range.is_empty() {
self.push(SyntaxKind::Whitespace, indent_range);
}
}
fn lex_token(&mut self) {
let start = self.pos;
let Some(byte) = self.byte_at(self.pos) else {
return;
};
let kind = match byte {
b' ' | b'\t' => {
self.bump_while(|b| b == b' ' || b == b'\t');
SyntaxKind::Whitespace
}
b'\n' => {
self.pos += 1;
self.finish_line();
SyntaxKind::Newline
}
b'\r' => {
self.pos += 1;
if self.byte_at(self.pos) == Some(b'\n') {
self.pos += 1;
}
self.finish_line();
SyntaxKind::Newline
}
b'\\' => self.lex_backslash(),
b'#' => self.lex_comment(),
b'0'..=b'9' => self.lex_number(),
b'.' if matches!(self.byte_at(self.pos + 1), Some(b'0'..=b'9')) => self.lex_number(),
b'"' | b'\'' => self.lex_string(),
b'$' => self.lex_node_path(SyntaxKind::GetNode),
b'&' | b'^' if self.starts_annotated_string() => self.lex_annotated_string(byte),
b'%' if self.starts_unique_node() => self.lex_node_path(SyntaxKind::UniqueNode),
b if is_ident_start(b) => self.lex_ident_or_keyword(),
_ => self.lex_operator(),
};
if self.pos == start && kind == SyntaxKind::Unknown {
self.pos += 1;
}
if kind == SyntaxKind::FuncKw && self.bracket_depth > 0 {
self.pending_lambda = Some(self.bracket_depth);
} else if kind == SyntaxKind::Colon
&& self.bracket_depth > 0
&& self.pending_lambda == Some(self.bracket_depth)
{
self.pending_lambda = None;
self.lambda_stack.push(LambdaContext {
bracket_depth: self.bracket_depth,
body_column: None,
indent_len: self.indents.len(),
inline: false,
});
} else if kind != SyntaxKind::Newline
&& let Some(context) = self.lambda_stack.last_mut()
&& context.body_column.is_none()
{
context.inline = true;
}
if kind != SyntaxKind::Newline {
self.at_line_start = false;
}
self.push(kind, TextRange::new(start, self.pos));
}
fn indent_significant(&self) -> bool {
match self.lambda_stack.last() {
Some(context) => self.bracket_depth == context.bracket_depth,
None => self.bracket_depth == 0,
}
}
fn close_top_lambda(&mut self, offset: u32) {
let Some(context) = self.lambda_stack.pop() else {
return;
};
while self.indents.len() > context.indent_len {
self.indents.pop();
self.push(SyntaxKind::Dedent, TextRange::empty(offset));
}
}
fn finish_line(&mut self) {
self.at_line_start = true;
}
fn lex_backslash(&mut self) -> SyntaxKind {
let start = self.pos;
let mut cursor = self.pos + 1;
while matches!(self.byte_at(cursor), Some(b' ' | b'\t')) {
cursor += 1;
}
match self.byte_at(cursor) {
Some(b'\n') => {
self.pos = cursor + 1;
SyntaxKind::LineContinuation
}
Some(b'\r') => {
cursor += 1;
if self.byte_at(cursor) == Some(b'\n') {
cursor += 1;
}
self.pos = cursor;
SyntaxKind::LineContinuation
}
_ => {
self.pos = start + 1;
self.errors.push(SyntaxError::new(
TextRange::new(start, self.pos),
"stray `\\` outside a line continuation",
));
SyntaxKind::Unknown
}
}
}
fn lex_comment(&mut self) -> SyntaxKind {
let doc = self.byte_at(self.pos + 1) == Some(b'#');
self.bump_while(|b| b != b'\n' && b != b'\r');
if doc {
SyntaxKind::DocComment
} else {
SyntaxKind::Comment
}
}
fn lex_number(&mut self) -> SyntaxKind {
let mut is_float = false;
if self.byte_at(self.pos) == Some(b'0')
&& matches!(self.byte_at(self.pos + 1), Some(b'x' | b'X' | b'b' | b'B'))
{
let radix_marker = self.byte_at(self.pos + 1).expect("checked above");
self.pos += 2;
if radix_marker == b'x' || radix_marker == b'X' {
self.bump_while(|b| b.is_ascii_hexdigit() || b == b'_');
} else {
self.bump_while(|b| matches!(b, b'0' | b'1' | b'_'));
}
return SyntaxKind::Int;
}
self.bump_while(|b| b.is_ascii_digit() || b == b'_');
if self.byte_at(self.pos) == Some(b'.')
&& matches!(self.byte_at(self.pos + 1), Some(b'0'..=b'9'))
{
is_float = true;
self.pos += 1;
self.bump_while(|b| b.is_ascii_digit() || b == b'_');
} else if self.byte_at(self.pos) == Some(b'.')
&& !matches!(self.byte_at(self.pos + 1), Some(b'.'))
&& !matches!(self.byte_at(self.pos + 1), Some(b) if is_ident_start(b))
{
is_float = true;
self.pos += 1;
}
if matches!(self.byte_at(self.pos), Some(b'e' | b'E')) {
let mut cursor = self.pos + 1;
if matches!(self.byte_at(cursor), Some(b'+' | b'-')) {
cursor += 1;
}
if matches!(self.byte_at(cursor), Some(b'0'..=b'9')) {
is_float = true;
self.pos = cursor;
self.bump_while(|b| b.is_ascii_digit() || b == b'_');
}
}
if is_float {
SyntaxKind::Float
} else {
SyntaxKind::Int
}
}
fn starts_annotated_string(&self) -> bool {
matches!(self.byte_at(self.pos + 1), Some(b'"' | b'\'')) && !self.prev_can_end_expr()
}
fn lex_annotated_string(&mut self, sigil: u8) -> SyntaxKind {
self.pos += 1;
self.lex_string();
if sigil == b'&' {
SyntaxKind::StringName
} else {
SyntaxKind::NodePath
}
}
fn starts_unique_node(&self) -> bool {
if self.prev_can_end_expr() {
return false;
}
matches!(self.byte_at(self.pos + 1), Some(b'"' | b'\''))
|| matches!(self.byte_at(self.pos + 1), Some(b) if is_ident_start(b))
}
fn lex_node_path(&mut self, kind: SyntaxKind) -> SyntaxKind {
let sigil_start = self.pos;
self.pos += 1;
if matches!(self.byte_at(self.pos), Some(b'"' | b'\'')) {
self.lex_string();
return kind;
}
let mut matched_any = false;
if self.byte_at(self.pos) == Some(b'/') {
self.pos += 1;
matched_any = true;
}
loop {
if self.byte_at(self.pos) == Some(b'.') && self.byte_at(self.pos + 1) == Some(b'.') {
self.pos += 2;
matched_any = true;
} else {
if self.byte_at(self.pos) == Some(b'%') {
self.pos += 1;
matched_any = true;
}
if matches!(self.byte_at(self.pos), Some(b) if is_ident_start(b)) {
self.bump_while(is_ident_continue);
matched_any = true;
} else {
break;
}
}
if self.byte_at(self.pos) == Some(b'/') {
self.pos += 1;
} else {
break;
}
}
if !matched_any {
self.errors.push(SyntaxError::new(
TextRange::new(sigil_start, self.pos),
"expected a node path after the sigil",
));
}
kind
}
fn lex_string(&mut self) -> SyntaxKind {
let start = self.pos;
let quote = self.byte_at(self.pos).expect("caller checked for a quote");
let triple =
self.byte_at(self.pos + 1) == Some(quote) && self.byte_at(self.pos + 2) == Some(quote);
let delim_len = if triple { 3 } else { 1 };
self.pos += delim_len;
loop {
let Some(byte) = self.byte_at(self.pos) else {
self.errors.push(SyntaxError::new(
TextRange::new(start, self.pos),
"unterminated string literal",
));
break;
};
if byte == b'\\' {
self.pos += 1;
if self.pos < self.len() {
self.pos += 1;
}
continue;
}
if !triple && matches!(byte, b'\n' | b'\r') {
self.errors.push(SyntaxError::new(
TextRange::new(start, self.pos),
"unterminated string literal",
));
break;
}
if byte == quote {
if triple {
if self.byte_at(self.pos + 1) == Some(quote)
&& self.byte_at(self.pos + 2) == Some(quote)
{
self.pos += 3;
break;
}
self.pos += 1;
continue;
}
self.pos += 1;
break;
}
self.pos += 1;
}
SyntaxKind::Str
}
fn lex_ident_or_keyword(&mut self) -> SyntaxKind {
let start = self.pos;
self.bump_while(is_ident_continue);
let text = TextRange::new(start, self.pos).slice(self.source);
if text == "r" && matches!(self.byte_at(self.pos), Some(b'"' | b'\'')) {
self.lex_string();
return SyntaxKind::Str;
}
SyntaxKind::from_keyword(text).unwrap_or(SyntaxKind::Ident)
}
#[allow(clippy::too_many_lines)]
fn lex_operator(&mut self) -> SyntaxKind {
let byte = self.byte_at(self.pos).expect("caller checked for a byte");
let next = self.byte_at(self.pos + 1);
let after = self.byte_at(self.pos + 2);
match byte {
b')' | b']' | b'}' => {
while self
.lambda_stack
.last()
.is_some_and(|context| context.bracket_depth >= self.bracket_depth)
{
self.close_top_lambda(self.pos);
}
}
b',' => {
while self
.lambda_stack
.last()
.is_some_and(|context| context.bracket_depth == self.bracket_depth)
{
self.close_top_lambda(self.pos);
}
}
_ => {}
}
self.pos += 1;
macro_rules! two {
($kind:expr) => {{
self.pos += 1;
$kind
}};
}
macro_rules! three {
($kind:expr) => {{
self.pos += 2;
$kind
}};
}
match byte {
b'+' if next == Some(b'=') => two!(SyntaxKind::PlusEq),
b'+' => SyntaxKind::Plus,
b'-' if next == Some(b'=') => two!(SyntaxKind::MinusEq),
b'-' if next == Some(b'>') => two!(SyntaxKind::Arrow),
b'-' => SyntaxKind::Minus,
b'*' if next == Some(b'*') && after == Some(b'=') => three!(SyntaxKind::StarStarEq),
b'*' if next == Some(b'*') => two!(SyntaxKind::StarStar),
b'*' if next == Some(b'=') => two!(SyntaxKind::StarEq),
b'*' => SyntaxKind::Star,
b'/' if next == Some(b'=') => two!(SyntaxKind::SlashEq),
b'/' => SyntaxKind::Slash,
b'%' if next == Some(b'=') => two!(SyntaxKind::PercentEq),
b'%' => SyntaxKind::Percent,
b'=' if next == Some(b'=') => two!(SyntaxKind::EqEq),
b'=' => SyntaxKind::Eq,
b'!' if next == Some(b'=') => two!(SyntaxKind::BangEq),
b'!' => SyntaxKind::Bang,
b'<' if next == Some(b'<') && after == Some(b'=') => three!(SyntaxKind::ShlEq),
b'<' if next == Some(b'<') => two!(SyntaxKind::Shl),
b'<' if next == Some(b'=') => two!(SyntaxKind::LtEq),
b'<' => SyntaxKind::Lt,
b'>' if next == Some(b'>') && after == Some(b'=') => three!(SyntaxKind::ShrEq),
b'>' if next == Some(b'>') => two!(SyntaxKind::Shr),
b'>' if next == Some(b'=') => two!(SyntaxKind::GtEq),
b'>' => SyntaxKind::Gt,
b'&' if next == Some(b'&') => two!(SyntaxKind::AmpAmp),
b'&' if next == Some(b'=') => two!(SyntaxKind::AmpEq),
b'&' => SyntaxKind::Amp,
b'|' if next == Some(b'|') => two!(SyntaxKind::PipePipe),
b'|' if next == Some(b'=') => two!(SyntaxKind::PipeEq),
b'|' => SyntaxKind::Pipe,
b'^' if next == Some(b'=') => two!(SyntaxKind::CaretEq),
b'^' => SyntaxKind::Caret,
b'~' => SyntaxKind::Tilde,
b':' if next == Some(b'=') => two!(SyntaxKind::ColonEq),
b':' => SyntaxKind::Colon,
b';' => SyntaxKind::Semicolon,
b',' => SyntaxKind::Comma,
b'.' if next == Some(b'.') && after == Some(b'.') => three!(SyntaxKind::Ellipsis),
b'.' if next == Some(b'.') => two!(SyntaxKind::DotDot),
b'.' => SyntaxKind::Dot,
b'@' => SyntaxKind::At,
b'$' => SyntaxKind::Dollar,
b'(' => {
self.bracket_depth += 1;
SyntaxKind::LParen
}
b')' => {
self.bracket_depth = self.bracket_depth.saturating_sub(1);
SyntaxKind::RParen
}
b'[' => {
self.bracket_depth += 1;
SyntaxKind::LBracket
}
b']' => {
self.bracket_depth = self.bracket_depth.saturating_sub(1);
SyntaxKind::RBracket
}
b'{' => {
self.bracket_depth += 1;
SyntaxKind::LBrace
}
b'}' => {
self.bracket_depth = self.bracket_depth.saturating_sub(1);
SyntaxKind::RBrace
}
_ => {
while self.pos < self.len() && !self.source.is_char_boundary(self.pos as usize) {
self.pos += 1;
}
self.errors.push(SyntaxError::new(
TextRange::new(self.pos - 1, self.pos),
"unexpected character",
));
SyntaxKind::Unknown
}
}
}
fn prev_can_end_expr(&self) -> bool {
for token in self.tokens.iter().rev() {
if token.kind == SyntaxKind::Newline {
return false;
}
if token.kind.is_trivia() {
continue;
}
return matches!(
token.kind,
SyntaxKind::Ident
| SyntaxKind::Int
| SyntaxKind::Float
| SyntaxKind::Str
| SyntaxKind::StringName
| SyntaxKind::NodePath
| SyntaxKind::GetNode
| SyntaxKind::UniqueNode
| SyntaxKind::RParen
| SyntaxKind::RBracket
| SyntaxKind::RBrace
| SyntaxKind::SelfKw
| SyntaxKind::SuperKw
| SyntaxKind::TrueKw
| SyntaxKind::FalseKw
| SyntaxKind::NullKw
);
}
false
}
fn push(&mut self, kind: SyntaxKind, range: TextRange) {
self.tokens.push(Token { kind, range });
}
fn bump_while(&mut self, predicate: impl Fn(u8) -> bool) {
while let Some(byte) = self.byte_at(self.pos) {
if !predicate(byte) {
break;
}
self.pos += 1;
}
}
fn byte_at(&self, pos: u32) -> Option<u8> {
self.bytes.get(pos as usize).copied()
}
fn len(&self) -> u32 {
self.bytes.len() as u32
}
fn at_eof(&self) -> bool {
self.pos >= self.len()
}
}
fn is_ident_start(byte: u8) -> bool {
byte.is_ascii_alphabetic() || byte == b'_' || byte >= 0x80
}
fn is_ident_continue(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_lossless(source: &str) {
let lexed = tokenize(source);
let rebuilt: String = lexed
.tokens
.iter()
.map(|token| token.text(source))
.collect();
assert_eq!(rebuilt, source, "token spans must cover the source exactly");
}
fn kinds(source: &str) -> Vec<SyntaxKind> {
tokenize(source)
.tokens
.into_iter()
.map(|token| token.kind)
.filter(|kind| !kind.is_trivia() && *kind != SyntaxKind::Eof)
.collect()
}
#[test]
fn round_trips_a_realistic_script() {
assert_lossless(
"@tool\nclass_name Player\nextends CharacterBody2D\n\n## Docs.\nsignal died\n\nconst MAX := 100\n\n\nfunc _ready() -> void:\n\tvar x := [1, 2, {\"a\": 1}] # trailing\n\tif x and true:\n\t\tprint($Sprite2D/Label)\n",
);
}
#[test]
fn round_trips_edge_cases() {
for source in [
"",
"\n",
"\n\n\n",
"pass",
"\tpass\n",
"a\r\nb\r\n",
"var s = \"unterminated\n",
"if a \\\n\tand b:\n\tpass\n",
"x = 1 § 2\n",
"func f():\n\t\t\tpass\n\treturn\n",
] {
assert_lossless(source);
}
}
#[test]
fn emits_indent_and_dedent_around_blocks() {
let kinds = kinds("func f():\n\tpass\nvar x = 1\n");
assert_eq!(
kinds,
vec![
SyntaxKind::FuncKw,
SyntaxKind::Ident,
SyntaxKind::LParen,
SyntaxKind::RParen,
SyntaxKind::Colon,
SyntaxKind::Indent,
SyntaxKind::PassKw,
SyntaxKind::Dedent,
SyntaxKind::VarKw,
SyntaxKind::Ident,
SyntaxKind::Eq,
SyntaxKind::Int,
]
);
}
#[test]
fn blank_and_comment_lines_do_not_shift_indentation() {
let kinds = kinds("func f():\n\tvar a = 1\n\n# note\n\tvar b = 2\n");
assert_eq!(
kinds.iter().filter(|k| **k == SyntaxKind::Indent).count(),
1
);
assert_eq!(
kinds.iter().filter(|k| **k == SyntaxKind::Dedent).count(),
1
);
}
#[test]
fn newlines_inside_brackets_are_not_line_breaks() {
let kinds = kinds("var a = [\n\t1,\n\t2,\n]\n");
assert!(!kinds.contains(&SyntaxKind::Indent));
assert!(!kinds.contains(&SyntaxKind::Dedent));
}
#[test]
fn distinguishes_modulo_from_unique_node() {
assert_eq!(
kinds("var a = b % c\n"),
vec![
SyntaxKind::VarKw,
SyntaxKind::Ident,
SyntaxKind::Eq,
SyntaxKind::Ident,
SyntaxKind::Percent,
SyntaxKind::Ident,
]
);
assert_eq!(
kinds("var a = %HealthBar\n"),
vec![
SyntaxKind::VarKw,
SyntaxKind::Ident,
SyntaxKind::Eq,
SyntaxKind::UniqueNode,
]
);
assert_eq!(
kinds("print(%Bar, a % 2)\n"),
vec![
SyntaxKind::Ident,
SyntaxKind::LParen,
SyntaxKind::UniqueNode,
SyntaxKind::Comma,
SyntaxKind::Ident,
SyntaxKind::Percent,
SyntaxKind::Int,
SyntaxKind::RParen,
]
);
}
#[test]
fn lexes_node_paths_as_single_tokens() {
assert_eq!(kinds("$Sprite2D\n"), vec![SyntaxKind::GetNode]);
assert_eq!(kinds("$../Sibling/%Unique\n"), vec![SyntaxKind::GetNode]);
assert_eq!(kinds("$\"quoted/path\"\n"), vec![SyntaxKind::GetNode]);
assert_eq!(
kinds("$Sprite2D.position\n"),
vec![SyntaxKind::GetNode, SyntaxKind::Dot, SyntaxKind::Ident]
);
}
#[test]
fn lexes_string_name_and_node_path_literals() {
assert_eq!(kinds("emit(&\"died\")\n")[2], SyntaxKind::StringName);
assert_eq!(kinds("var p = ^\"a/b\"\n")[3], SyntaxKind::NodePath);
assert_eq!(kinds("var x = a & b\n")[4], SyntaxKind::Amp);
}
#[test]
fn lexes_number_forms() {
assert_eq!(kinds("1_000_000"), vec![SyntaxKind::Int]);
assert_eq!(kinds("0xfb8c0b"), vec![SyntaxKind::Int]);
assert_eq!(kinds("0b1010_1010"), vec![SyntaxKind::Int]);
assert_eq!(kinds("0.234"), vec![SyntaxKind::Float]);
assert_eq!(kinds("1e-5"), vec![SyntaxKind::Float]);
assert_eq!(kinds("1.5e10"), vec![SyntaxKind::Float]);
assert_eq!(
kinds("1..2"),
vec![SyntaxKind::Int, SyntaxKind::DotDot, SyntaxKind::Int]
);
assert_eq!(
kinds("1.max(2)"),
vec![
SyntaxKind::Int,
SyntaxKind::Dot,
SyntaxKind::Ident,
SyntaxKind::LParen,
SyntaxKind::Int,
SyntaxKind::RParen
]
);
}
#[test]
fn lexes_string_forms() {
assert_eq!(kinds("\"double\""), vec![SyntaxKind::Str]);
assert_eq!(kinds("'single'"), vec![SyntaxKind::Str]);
assert_eq!(kinds("\"\"\"triple\nspanning\"\"\""), vec![SyntaxKind::Str]);
assert_eq!(kinds("r\"raw\\n\""), vec![SyntaxKind::Str]);
assert_eq!(kinds("\"esc\\\"aped\""), vec![SyntaxKind::Str]);
}
#[test]
fn separates_doc_comments_from_plain_comments() {
let lexed = tokenize("## doc\n# plain\n");
let comments: Vec<_> = lexed
.tokens
.iter()
.filter(|t| t.kind.is_comment())
.map(|t| t.kind)
.collect();
assert_eq!(comments, vec![SyntaxKind::DocComment, SyntaxKind::Comment]);
}
#[test]
fn line_continuation_joins_lines() {
let kinds = kinds("var a = 1 + \\\n\t2\n");
assert!(!kinds.contains(&SyntaxKind::Indent));
assert_eq!(kinds.last(), Some(&SyntaxKind::Int));
}
#[test]
fn reports_inconsistent_dedent() {
let lexed = tokenize("func f():\n\t\tpass\n\treturn\n");
assert!(
lexed
.errors
.iter()
.any(|e| e.message().contains("unindent")),
"expected an unindent diagnostic, got {:?}",
lexed.errors
);
}
#[test]
fn reports_unterminated_string() {
let lexed = tokenize("var s = \"oops\n");
assert!(
lexed
.errors
.iter()
.any(|e| e.message().contains("unterminated"))
);
}
#[test]
fn closes_open_blocks_at_eof() {
let lexed = tokenize("func f():\n\tif a:\n\t\tpass");
let dedents = lexed
.tokens
.iter()
.filter(|t| t.kind == SyntaxKind::Dedent)
.count();
assert_eq!(dedents, 2);
assert_eq!(lexed.tokens.last().map(|t| t.kind), Some(SyntaxKind::Eof));
}
}