just/src/lexer.rs

2312 lines
46 KiB
Rust

use {super::*, CompileErrorKind::*, TokenKind::*};
/// Just language lexer
///
/// The lexer proceeds character-by-character, as opposed to using regular
/// expressions to lex tokens or semi-tokens at a time. As a result, it is
/// verbose and straightforward. Just used to have a regex-based lexer, which
/// was slower and generally godawful. However, this should not be taken as a
/// slight against regular expressions, the lexer was just idiosyncratically
/// bad.
pub(crate) struct Lexer<'src> {
/// Char iterator
chars: Chars<'src>,
/// Indentation stack
indentation: Vec<&'src str>,
/// Interpolation token start stack
interpolation_stack: Vec<Token<'src>>,
/// Next character to be lexed
next: Option<char>,
/// Current open delimiters
open_delimiters: Vec<(Delimiter, usize)>,
/// Path to source file
path: &'src Path,
/// Inside recipe body
recipe_body: bool,
/// Next indent will start a recipe body
recipe_body_pending: bool,
/// Source text
src: &'src str,
/// Tokens
tokens: Vec<Token<'src>>,
/// Current token end
token_end: Position,
/// Current token start
token_start: Position,
}
impl<'src> Lexer<'src> {
/// Lex `src`
pub(crate) fn lex(path: &'src Path, src: &'src str) -> CompileResult<'src, Vec<Token<'src>>> {
Self::new(path, src).tokenize()
}
#[cfg(test)]
pub(crate) fn test_lex(src: &'src str) -> CompileResult<'src, Vec<Token<'src>>> {
Self::new("justfile".as_ref(), src).tokenize()
}
/// Create a new Lexer to lex `src`
fn new(path: &'src Path, src: &'src str) -> Self {
let mut chars = src.chars();
let next = chars.next();
let start = Position {
offset: 0,
column: 0,
line: 0,
};
Self {
indentation: vec![""],
tokens: Vec::new(),
token_start: start,
token_end: start,
recipe_body_pending: false,
recipe_body: false,
interpolation_stack: Vec::new(),
open_delimiters: Vec::new(),
chars,
next,
src,
path,
}
}
/// Advance over the character in `self.next`, updating `self.token_end`
/// accordingly.
fn advance(&mut self) -> CompileResult<'src> {
match self.next {
Some(c) => {
let len_utf8 = c.len_utf8();
self.token_end.offset += len_utf8;
self.token_end.column += len_utf8;
if c == '\n' {
self.token_end.column = 0;
self.token_end.line += 1;
}
self.next = self.chars.next();
Ok(())
}
None => Err(self.internal_error("Lexer advanced past end of text")),
}
}
/// Advance over N characters.
fn skip(&mut self, n: usize) -> CompileResult<'src> {
for _ in 0..n {
self.advance()?;
}
Ok(())
}
/// Lexeme of in-progress token
fn lexeme(&self) -> &'src str {
&self.src[self.token_start.offset..self.token_end.offset]
}
/// Length of current token
fn current_token_length(&self) -> usize {
self.token_end.offset - self.token_start.offset
}
fn accepted(&mut self, c: char) -> CompileResult<'src, bool> {
if self.next_is(c) {
self.advance()?;
Ok(true)
} else {
Ok(false)
}
}
fn presume(&mut self, c: char) -> CompileResult<'src> {
if !self.next_is(c) {
return Err(self.internal_error(format!("Lexer presumed character `{c}`")));
}
self.advance()?;
Ok(())
}
fn presume_str(&mut self, s: &str) -> CompileResult<'src> {
for c in s.chars() {
self.presume(c)?;
}
Ok(())
}
/// Is next character c?
fn next_is(&self, c: char) -> bool {
self.next == Some(c)
}
/// Is next character ' ' or '\t'?
fn next_is_whitespace(&self) -> bool {
self.next_is(' ') || self.next_is('\t')
}
/// Un-lexed text
fn rest(&self) -> &'src str {
&self.src[self.token_end.offset..]
}
/// Check if unlexed text begins with prefix
fn rest_starts_with(&self, prefix: &str) -> bool {
self.rest().starts_with(prefix)
}
/// Does rest start with "\n" or "\r\n"?
fn at_eol(&self) -> bool {
self.next_is('\n') || self.rest_starts_with("\r\n")
}
/// Are we at end-of-file?
fn at_eof(&self) -> bool {
self.rest().is_empty()
}
/// Are we at end-of-line or end-of-file?
fn at_eol_or_eof(&self) -> bool {
self.at_eol() || self.at_eof()
}
/// Get current indentation
fn indentation(&self) -> &'src str {
self.indentation.last().unwrap()
}
/// Are we currently indented
fn indented(&self) -> bool {
!self.indentation().is_empty()
}
/// Create a new token with `kind` whose lexeme is between `self.token_start`
/// and `self.token_end`
fn token(&mut self, kind: TokenKind) {
self.tokens.push(Token {
offset: self.token_start.offset,
column: self.token_start.column,
line: self.token_start.line,
src: self.src,
length: self.token_end.offset - self.token_start.offset,
kind,
path: self.path,
});
// Set `token_start` to point after the lexed token
self.token_start = self.token_end;
}
/// Create an internal error with `message`
fn internal_error(&self, message: impl Into<String>) -> CompileError<'src> {
// Use `self.token_end` as the location of the error
let token = Token {
src: self.src,
offset: self.token_end.offset,
line: self.token_end.line,
column: self.token_end.column,
length: 0,
kind: Unspecified,
path: self.path,
};
CompileError::new(
token,
Internal {
message: message.into(),
},
)
}
/// Create a compilation error with `kind`
fn error(&self, kind: CompileErrorKind<'src>) -> CompileError<'src> {
// Use the in-progress token span as the location of the error.
// The width of the error site to highlight depends on the kind of error:
let length = match kind {
UnterminatedString | UnterminatedBacktick => {
let Some(kind) = StringKind::from_token_start(self.lexeme()) else {
return self.internal_error("Lexer::error: expected string or backtick token start");
};
kind.delimiter().len()
}
// highlight the full token
_ => self.lexeme().len(),
};
let token = Token {
kind: Unspecified,
src: self.src,
offset: self.token_start.offset,
line: self.token_start.line,
column: self.token_start.column,
length,
path: self.path,
};
CompileError::new(token, kind)
}
fn unterminated_interpolation_error(interpolation_start: Token<'src>) -> CompileError<'src> {
CompileError::new(interpolation_start, UnterminatedInterpolation)
}
/// True if `text` could be an identifier
pub(crate) fn is_identifier(text: &str) -> bool {
if !text.chars().next().map_or(false, Self::is_identifier_start) {
return false;
}
for c in text.chars().skip(1) {
if !Self::is_identifier_continue(c) {
return false;
}
}
true
}
/// True if `c` can be the first character of an identifier
pub(crate) fn is_identifier_start(c: char) -> bool {
matches!(c, 'a'..='z' | 'A'..='Z' | '_')
}
/// True if `c` can be a continuation character of an identifier
pub(crate) fn is_identifier_continue(c: char) -> bool {
Self::is_identifier_start(c) || matches!(c, '0'..='9' | '-')
}
/// Consume the text and produce a series of tokens
fn tokenize(mut self) -> CompileResult<'src, Vec<Token<'src>>> {
loop {
if self.token_start.column == 0 {
self.lex_line_start()?;
}
match self.next {
Some(first) => {
if let Some(&interpolation_start) = self.interpolation_stack.last() {
self.lex_interpolation(interpolation_start, first)?;
} else if self.recipe_body {
self.lex_body()?;
} else {
self.lex_normal(first)?;
};
}
None => break,
}
}
if let Some(&interpolation_start) = self.interpolation_stack.last() {
return Err(Self::unterminated_interpolation_error(interpolation_start));
}
while self.indented() {
self.lex_dedent();
}
self.token(Eof);
assert_eq!(self.token_start.offset, self.token_end.offset);
assert_eq!(self.token_start.offset, self.src.len());
assert_eq!(self.indentation.len(), 1);
Ok(self.tokens)
}
/// Handle blank lines and indentation
fn lex_line_start(&mut self) -> CompileResult<'src> {
enum Indentation<'src> {
// Line only contains whitespace
Blank,
// Indentation continues
Continue,
// Indentation decreases
Decrease,
// Indentation isn't consistent
Inconsistent,
// Indentation increases
Increase,
// Indentation mixes spaces and tabs
Mixed { whitespace: &'src str },
}
use Indentation::*;
let nonblank_index = self
.rest()
.char_indices()
.skip_while(|&(_, c)| c == ' ' || c == '\t')
.map(|(i, _)| i)
.next()
.unwrap_or_else(|| self.rest().len());
let rest = &self.rest()[nonblank_index..];
let whitespace = &self.rest()[..nonblank_index];
let body_whitespace = &whitespace[..whitespace
.char_indices()
.take(self.indentation().chars().count())
.map(|(i, _c)| i)
.next()
.unwrap_or(0)];
let spaces = whitespace.chars().any(|c| c == ' ');
let tabs = whitespace.chars().any(|c| c == '\t');
let body_spaces = body_whitespace.chars().any(|c| c == ' ');
let body_tabs = body_whitespace.chars().any(|c| c == '\t');
#[allow(clippy::if_same_then_else)]
let indentation = if rest.starts_with('\n') || rest.starts_with("\r\n") || rest.is_empty() {
Blank
} else if whitespace == self.indentation() {
Continue
} else if self.indentation.contains(&whitespace) {
Decrease
} else if self.recipe_body && whitespace.starts_with(self.indentation()) {
Continue
} else if self.recipe_body && body_spaces && body_tabs {
Mixed {
whitespace: body_whitespace,
}
} else if !self.recipe_body && spaces && tabs {
Mixed { whitespace }
} else if whitespace.len() < self.indentation().len() {
Inconsistent
} else if self.recipe_body
&& body_whitespace.len() >= self.indentation().len()
&& !body_whitespace.starts_with(self.indentation())
{
Inconsistent
} else if whitespace.len() >= self.indentation().len()
&& !whitespace.starts_with(self.indentation())
{
Inconsistent
} else {
Increase
};
match indentation {
Blank => {
if !whitespace.is_empty() {
while self.next_is_whitespace() {
self.advance()?;
}
self.token(Whitespace);
};
Ok(())
}
Continue => {
if !self.indentation().is_empty() {
for _ in self.indentation().chars() {
self.advance()?;
}
self.token(Whitespace);
}
Ok(())
}
Decrease => {
while self.indentation() != whitespace {
self.lex_dedent();
}
if !whitespace.is_empty() {
while self.next_is_whitespace() {
self.advance()?;
}
self.token(Whitespace);
}
Ok(())
}
Mixed { whitespace } => {
for _ in whitespace.chars() {
self.advance()?;
}
Err(self.error(MixedLeadingWhitespace { whitespace }))
}
Inconsistent => {
for _ in whitespace.chars() {
self.advance()?;
}
Err(self.error(InconsistentLeadingWhitespace {
expected: self.indentation(),
found: whitespace,
}))
}
Increase => {
while self.next_is_whitespace() {
self.advance()?;
}
if self.open_delimiters() {
self.token(Whitespace);
} else {
let indentation = self.lexeme();
self.indentation.push(indentation);
self.token(Indent);
if self.recipe_body_pending {
self.recipe_body = true;
}
}
Ok(())
}
}
}
/// Lex token beginning with `start` outside of a recipe body
fn lex_normal(&mut self, start: char) -> CompileResult<'src> {
match start {
' ' | '\t' => self.lex_whitespace(),
'!' if self.rest().starts_with("!include") => Err(self.error(Include)),
'!' => self.lex_digraph('!', '=', BangEquals),
'#' => self.lex_comment(),
'$' => self.lex_single(Dollar),
'&' => self.lex_digraph('&', '&', AmpersandAmpersand),
'(' => self.lex_delimiter(ParenL),
')' => self.lex_delimiter(ParenR),
'*' => self.lex_single(Asterisk),
'+' => self.lex_single(Plus),
',' => self.lex_single(Comma),
'/' => self.lex_single(Slash),
':' => self.lex_colon(),
'=' => self.lex_choices('=', &[('=', EqualsEquals), ('~', EqualsTilde)], Equals),
'?' => self.lex_single(QuestionMark),
'@' => self.lex_single(At),
'[' => self.lex_delimiter(BracketL),
'\\' => self.lex_escape(),
'\n' | '\r' => self.lex_eol(),
'\u{feff}' => self.lex_single(ByteOrderMark),
']' => self.lex_delimiter(BracketR),
'`' | '"' | '\'' => self.lex_string(),
'{' => self.lex_delimiter(BraceL),
'}' => self.lex_delimiter(BraceR),
_ if Self::is_identifier_start(start) => self.lex_identifier(),
_ => {
self.advance()?;
Err(self.error(UnknownStartOfToken))
}
}
}
/// Lex token beginning with `start` inside an interpolation
fn lex_interpolation(
&mut self,
interpolation_start: Token<'src>,
start: char,
) -> CompileResult<'src> {
if self.rest_starts_with("}}") {
// end current interpolation
if self.interpolation_stack.pop().is_none() {
self.advance()?;
self.advance()?;
return Err(self.internal_error(
"Lexer::lex_interpolation found `}}` but was called with empty interpolation stack.",
));
}
// Emit interpolation end token
self.lex_double(InterpolationEnd)
} else if self.at_eol_or_eof() {
// Return unterminated interpolation error that highlights the opening
// {{
Err(Self::unterminated_interpolation_error(interpolation_start))
} else {
// Otherwise lex as per normal
self.lex_normal(start)
}
}
/// Lex token while in recipe body
fn lex_body(&mut self) -> CompileResult<'src> {
enum Terminator {
Newline,
NewlineCarriageReturn,
Interpolation,
EndOfFile,
}
use Terminator::*;
let terminator = loop {
if self.rest_starts_with("{{{{") {
self.skip(4)?;
continue;
}
if self.rest_starts_with("\n") {
break Newline;
}
if self.rest_starts_with("\r\n") {
break NewlineCarriageReturn;
}
if self.rest_starts_with("{{") {
break Interpolation;
}
if self.at_eof() {
break EndOfFile;
}
self.advance()?;
};
// emit text token containing text so far
if self.current_token_length() > 0 {
self.token(Text);
}
match terminator {
Newline => self.lex_single(Eol),
NewlineCarriageReturn => self.lex_double(Eol),
Interpolation => {
self.lex_double(InterpolationStart)?;
self
.interpolation_stack
.push(self.tokens[self.tokens.len() - 1]);
Ok(())
}
EndOfFile => Ok(()),
}
}
fn lex_dedent(&mut self) {
assert_eq!(self.current_token_length(), 0);
self.token(Dedent);
self.indentation.pop();
self.recipe_body_pending = false;
self.recipe_body = false;
}
/// Lex a single-character token
fn lex_single(&mut self, kind: TokenKind) -> CompileResult<'src> {
self.advance()?;
self.token(kind);
Ok(())
}
/// Lex a double-character token
fn lex_double(&mut self, kind: TokenKind) -> CompileResult<'src> {
self.advance()?;
self.advance()?;
self.token(kind);
Ok(())
}
/// Lex a double-character token of kind `then` if the second character of
/// that token would be `second`, otherwise lex a single-character token of
/// kind `otherwise`
fn lex_choices(
&mut self,
first: char,
choices: &[(char, TokenKind)],
otherwise: TokenKind,
) -> CompileResult<'src> {
self.presume(first)?;
for (second, then) in choices {
if self.accepted(*second)? {
self.token(*then);
return Ok(());
}
}
self.token(otherwise);
Ok(())
}
/// Lex an opening or closing delimiter
fn lex_delimiter(&mut self, kind: TokenKind) -> CompileResult<'src> {
use Delimiter::*;
match kind {
BraceL => self.open_delimiter(Brace),
BraceR => self.close_delimiter(Brace)?,
BracketL => self.open_delimiter(Bracket),
BracketR => self.close_delimiter(Bracket)?,
ParenL => self.open_delimiter(Paren),
ParenR => self.close_delimiter(Paren)?,
_ => {
return Err(self.internal_error(format!(
"Lexer::lex_delimiter called with non-delimiter token: `{kind}`",
)))
}
}
// Emit the delimiter token
self.lex_single(kind)
}
/// Push a delimiter onto the open delimiter stack
fn open_delimiter(&mut self, delimiter: Delimiter) {
self
.open_delimiters
.push((delimiter, self.token_start.line));
}
/// Pop a delimiter from the open delimiter stack and error if incorrect type
fn close_delimiter(&mut self, close: Delimiter) -> CompileResult<'src> {
match self.open_delimiters.pop() {
Some((open, _)) if open == close => Ok(()),
Some((open, open_line)) => Err(self.error(MismatchedClosingDelimiter {
open,
close,
open_line,
})),
None => Err(self.error(UnexpectedClosingDelimiter { close })),
}
}
/// Return true if there are any unclosed delimiters
fn open_delimiters(&self) -> bool {
!self.open_delimiters.is_empty()
}
/// Lex a two-character digraph
fn lex_digraph(&mut self, left: char, right: char, token: TokenKind) -> CompileResult<'src> {
self.presume(left)?;
if self.accepted(right)? {
self.token(token);
Ok(())
} else {
// Emit an unspecified token to consume the current character,
self.token(Unspecified);
if self.at_eof() {
return Err(self.error(UnexpectedEndOfToken { expected: right }));
}
// …and advance past another character,
self.advance()?;
// …so that the error we produce highlights the unexpected character.
Err(self.error(UnexpectedCharacter { expected: right }))
}
}
/// Lex a token starting with ':'
fn lex_colon(&mut self) -> CompileResult<'src> {
self.presume(':')?;
if self.accepted('=')? {
self.token(ColonEquals);
} else {
self.token(Colon);
self.recipe_body_pending = true;
}
Ok(())
}
/// Lex an token starting with '\' escape
fn lex_escape(&mut self) -> CompileResult<'src> {
self.presume('\\')?;
// Treat newline escaped with \ as whitespace
if self.accepted('\n')? {
while self.next_is_whitespace() {
self.advance()?;
}
self.token(Whitespace);
} else if self.accepted('\r')? {
if !self.accepted('\n')? {
return Err(self.error(UnpairedCarriageReturn));
}
while self.next_is_whitespace() {
self.advance()?;
}
self.token(Whitespace);
} else if let Some(character) = self.next {
return Err(self.error(InvalidEscapeSequence { character }));
}
Ok(())
}
/// Lex a carriage return and line feed
fn lex_eol(&mut self) -> CompileResult<'src> {
if self.accepted('\r')? {
if !self.accepted('\n')? {
return Err(self.error(UnpairedCarriageReturn));
}
} else {
self.presume('\n')?;
}
// Emit an eol if there are no open delimiters, otherwise emit a whitespace
// token.
if self.open_delimiters() {
self.token(Whitespace);
} else {
self.token(Eol);
}
Ok(())
}
/// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
fn lex_identifier(&mut self) -> CompileResult<'src> {
self.advance()?;
while let Some(c) = self.next {
if !Self::is_identifier_continue(c) {
break;
}
self.advance()?;
}
self.token(Identifier);
Ok(())
}
/// Lex comment: #[^\r\n]
fn lex_comment(&mut self) -> CompileResult<'src> {
self.presume('#')?;
while !self.at_eol_or_eof() {
self.advance()?;
}
self.token(Comment);
Ok(())
}
/// Lex whitespace: [ \t]+
fn lex_whitespace(&mut self) -> CompileResult<'src> {
while self.next_is_whitespace() {
self.advance()?;
}
self.token(Whitespace);
Ok(())
}
/// Lex a backtick, cooked string, or raw string.
///
/// Backtick: ``[^`]*``
/// Cooked string: "[^"]*" # also processes escape sequences
/// Raw string: '[^']*'
fn lex_string(&mut self) -> CompileResult<'src> {
let Some(kind) = StringKind::from_token_start(self.rest()) else {
self.advance()?;
return Err(self.internal_error("Lexer::lex_string: invalid string start"));
};
self.presume_str(kind.delimiter())?;
let mut escape = false;
loop {
if self.next.is_none() {
return Err(self.error(kind.unterminated_error_kind()));
} else if kind.processes_escape_sequences() && self.next_is('\\') && !escape {
escape = true;
} else if self.rest_starts_with(kind.delimiter()) && !escape {
break;
} else {
escape = false;
}
self.advance()?;
}
self.presume_str(kind.delimiter())?;
self.token(kind.token_kind());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
macro_rules! test {
{
name: $name:ident,
text: $text:expr,
tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)?
} => {
#[test]
fn $name() {
let kinds: &[TokenKind] = &[$($kind,)* Eof];
let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""];
test($text, true, kinds, lexemes);
}
};
{
name: $name:ident,
text: $text:expr,
tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)?
unindent: $unindent:expr,
} => {
#[test]
fn $name() {
let kinds: &[TokenKind] = &[$($kind,)* Eof];
let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""];
test($text, $unindent, kinds, lexemes);
}
}
}
macro_rules! lexeme {
{
$kind:ident, $lexeme:literal
} => {
$lexeme
};
{
$kind:ident
} => {
default_lexeme($kind)
}
}
fn test(text: &str, unindent_text: bool, want_kinds: &[TokenKind], want_lexemes: &[&str]) {
let text = if unindent_text {
unindent(text)
} else {
text.to_owned()
};
let have = Lexer::test_lex(&text).unwrap();
let have_kinds = have
.iter()
.map(|token| token.kind)
.collect::<Vec<TokenKind>>();
let have_lexemes = have.iter().map(Token::lexeme).collect::<Vec<&str>>();
assert_eq!(have_kinds, want_kinds, "Token kind mismatch");
assert_eq!(have_lexemes, want_lexemes, "Token lexeme mismatch");
let mut roundtrip = String::new();
for lexeme in have_lexemes {
roundtrip.push_str(lexeme);
}
assert_eq!(roundtrip, text, "Roundtrip mismatch");
let mut offset = 0;
let mut line = 0;
let mut column = 0;
for token in have {
assert_eq!(token.offset, offset);
assert_eq!(token.line, line);
assert_eq!(token.lexeme().len(), token.length);
assert_eq!(token.column, column);
for c in token.lexeme().chars() {
if c == '\n' {
line += 1;
column = 0;
} else {
column += c.len_utf8();
}
}
offset += token.length;
}
}
fn default_lexeme(kind: TokenKind) -> &'static str {
match kind {
// Fixed lexemes
AmpersandAmpersand => "&&",
Asterisk => "*",
At => "@",
BangEquals => "!=",
BraceL => "{",
BraceR => "}",
BracketL => "[",
BracketR => "]",
ByteOrderMark => "\u{feff}",
Colon => ":",
ColonEquals => ":=",
Comma => ",",
Dollar => "$",
Eol => "\n",
Equals => "=",
EqualsEquals => "==",
EqualsTilde => "=~",
Indent => " ",
InterpolationEnd => "}}",
InterpolationStart => "{{",
ParenL => "(",
ParenR => ")",
Plus => "+",
QuestionMark => "?",
Slash => "/",
Whitespace => " ",
// Empty lexemes
Dedent | Eof => "",
// Variable lexemes
Text | StringToken | Backtick | Identifier | Comment | Unspecified => {
panic!("Token {kind:?} has no default lexeme")
}
}
}
macro_rules! error {
(
name: $name:ident,
input: $input:expr,
offset: $offset:expr,
line: $line:expr,
column: $column:expr,
width: $width:expr,
kind: $kind:expr,
) => {
#[test]
fn $name() {
error($input, $offset, $line, $column, $width, $kind);
}
};
}
fn error(
src: &str,
offset: usize,
line: usize,
column: usize,
length: usize,
kind: CompileErrorKind,
) {
match Lexer::test_lex(src) {
Ok(_) => panic!("Lexing succeeded but expected"),
Err(have) => {
let want = CompileError {
token: Token {
kind: have.token.kind,
src,
offset,
line,
column,
length,
path: "justfile".as_ref(),
},
kind: kind.into(),
};
assert_eq!(have, want);
}
}
}
test! {
name: name_new,
text: "foo",
tokens: (Identifier:"foo"),
}
test! {
name: comment,
text: "# hello",
tokens: (Comment:"# hello"),
}
test! {
name: backtick,
text: "`echo`",
tokens: (Backtick:"`echo`"),
}
test! {
name: backtick_multi_line,
text: "`echo\necho`",
tokens: (Backtick:"`echo\necho`"),
}
test! {
name: raw_string,
text: "'hello'",
tokens: (StringToken:"'hello'"),
}
test! {
name: raw_string_multi_line,
text: "'hello\ngoodbye'",
tokens: (StringToken:"'hello\ngoodbye'"),
}
test! {
name: cooked_string,
text: "\"hello\"",
tokens: (StringToken:"\"hello\""),
}
test! {
name: cooked_string_multi_line,
text: "\"hello\ngoodbye\"",
tokens: (StringToken:"\"hello\ngoodbye\""),
}
test! {
name: cooked_multiline_string,
text: "\"\"\"hello\ngoodbye\"\"\"",
tokens: (StringToken:"\"\"\"hello\ngoodbye\"\"\""),
}
test! {
name: ampersand_ampersand,
text: "&&",
tokens: (AmpersandAmpersand),
}
test! {
name: equals,
text: "=",
tokens: (Equals),
}
test! {
name: equals_equals,
text: "==",
tokens: (EqualsEquals),
}
test! {
name: bang_equals,
text: "!=",
tokens: (BangEquals),
}
test! {
name: brace_l,
text: "{",
tokens: (BraceL),
}
test! {
name: brace_r,
text: "{}",
tokens: (BraceL, BraceR),
}
test! {
name: brace_lll,
text: "{{{",
tokens: (BraceL, BraceL, BraceL),
}
test! {
name: brace_rrr,
text: "{{{}}}",
tokens: (BraceL, BraceL, BraceL, BraceR, BraceR, BraceR),
}
test! {
name: dollar,
text: "$",
tokens: (Dollar),
}
test! {
name: export_concatenation,
text: "export foo = 'foo' + 'bar'",
tokens: (
Identifier:"export",
Whitespace,
Identifier:"foo",
Whitespace,
Equals,
Whitespace,
StringToken:"'foo'",
Whitespace,
Plus,
Whitespace,
StringToken:"'bar'",
)
}
test! {
name: export_complex,
text: "export foo = ('foo' + 'bar') + `baz`",
tokens: (
Identifier:"export",
Whitespace,
Identifier:"foo",
Whitespace,
Equals,
Whitespace,
ParenL,
StringToken:"'foo'",
Whitespace,
Plus,
Whitespace,
StringToken:"'bar'",
ParenR,
Whitespace,
Plus,
Whitespace,
Backtick:"`baz`",
),
}
test! {
name: eol_linefeed,
text: "\n",
tokens: (Eol),
unindent: false,
}
test! {
name: eol_carriage_return_linefeed,
text: "\r\n",
tokens: (Eol:"\r\n"),
unindent: false,
}
test! {
name: indented_line,
text: "foo:\n a",
tokens: (Identifier:"foo", Colon, Eol, Indent:" ", Text:"a", Dedent),
}
test! {
name: indented_normal,
text: "
a
b
c
",
tokens: (
Identifier:"a",
Eol,
Indent:" ",
Identifier:"b",
Eol,
Whitespace:" ",
Identifier:"c",
Eol,
Dedent,
),
}
test! {
name: indented_normal_nonempty_blank,
text: "a\n b\n\t\t\n c\n",
tokens: (
Identifier:"a",
Eol,
Indent:" ",
Identifier:"b",
Eol,
Whitespace:"\t\t",
Eol,
Whitespace:" ",
Identifier:"c",
Eol,
Dedent,
),
unindent: false,
}
test! {
name: indented_normal_multiple,
text: "
a
b
c
",
tokens: (
Identifier:"a",
Eol,
Indent:" ",
Identifier:"b",
Eol,
Indent:" ",
Identifier:"c",
Eol,
Dedent,
Dedent,
),
}
test! {
name: indent_indent_dedent_indent,
text: "
a
b
c
d
e
",
tokens: (
Identifier:"a",
Eol,
Indent:" ",
Identifier:"b",
Eol,
Indent:" ",
Identifier:"c",
Eol,
Dedent,
Whitespace:" ",
Identifier:"d",
Eol,
Indent:" ",
Identifier:"e",
Eol,
Dedent,
Dedent,
),
}
test! {
name: indent_recipe_dedent_indent,
text: "
a
b:
c
d
e
",
tokens: (
Identifier:"a",
Eol,
Indent:" ",
Identifier:"b",
Colon,
Eol,
Indent:" ",
Text:"c",
Eol,
Dedent,
Whitespace:" ",
Identifier:"d",
Eol,
Indent:" ",
Identifier:"e",
Eol,
Dedent,
Dedent,
),
}
test! {
name: indented_block,
text: "
foo:
a
b
c
",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent,
Text:"a",
Eol,
Whitespace:" ",
Text:"b",
Eol,
Whitespace:" ",
Text:"c",
Eol,
Dedent,
)
}
test! {
name: brace_escape,
text: "
foo:
{{{{
",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent,
Text:"{{{{",
Eol,
Dedent,
)
}
test! {
name: indented_block_followed_by_item,
text: "
foo:
a
b:
",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent,
Text:"a",
Eol,
Dedent,
Identifier:"b",
Colon,
Eol,
)
}
test! {
name: indented_block_followed_by_blank,
text: "
foo:
a
b:
",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent:" ",
Text:"a",
Eol,
Eol,
Dedent,
Identifier:"b",
Colon,
Eol,
),
}
test! {
name: indented_line_containing_unpaired_carriage_return,
text: "foo:\n \r \n",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent:" ",
Text:"\r ",
Eol,
Dedent,
),
unindent: false,
}
test! {
name: indented_blocks,
text: "
b: a
@mv a b
a:
@touch F
@touch a
d: c
@rm c
c: b
@mv b c
",
tokens: (
Identifier:"b",
Colon,
Whitespace,
Identifier:"a",
Eol,
Indent,
Text:"@mv a b",
Eol,
Eol,
Dedent,
Identifier:"a",
Colon,
Eol,
Indent,
Text:"@touch F",
Eol,
Whitespace:" ",
Text:"@touch a",
Eol,
Eol,
Dedent,
Identifier:"d",
Colon,
Whitespace,
Identifier:"c",
Eol,
Indent,
Text:"@rm c",
Eol,
Eol,
Dedent,
Identifier:"c",
Colon,
Whitespace,
Identifier:"b",
Eol,
Indent,
Text:"@mv b c",
Eol,
Dedent
),
}
test! {
name: interpolation_empty,
text: "hello:\n echo {{}}",
tokens: (
Identifier:"hello",
Colon,
Eol,
Indent:" ",
Text:"echo ",
InterpolationStart,
InterpolationEnd,
Dedent,
),
}
test! {
name: interpolation_expression,
text: "hello:\n echo {{`echo hello` + `echo goodbye`}}",
tokens: (
Identifier:"hello",
Colon,
Eol,
Indent:" ",
Text:"echo ",
InterpolationStart,
Backtick:"`echo hello`",
Whitespace,
Plus,
Whitespace,
Backtick:"`echo goodbye`",
InterpolationEnd,
Dedent,
),
}
test! {
name: interpolation_raw_multiline_string,
text: "hello:\n echo {{'\n'}}",
tokens: (
Identifier:"hello",
Colon,
Eol,
Indent:" ",
Text:"echo ",
InterpolationStart,
StringToken:"'\n'",
InterpolationEnd,
Dedent,
),
}
test! {
name: tokenize_names,
text: "
foo
bar-bob
b-bob_asdfAAAA
test123
",
tokens: (
Identifier:"foo",
Eol,
Identifier:"bar-bob",
Eol,
Identifier:"b-bob_asdfAAAA",
Eol,
Identifier:"test123",
Eol,
),
}
test! {
name: tokenize_indented_line,
text: "foo:\n a",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent:" ",
Text:"a",
Dedent,
),
}
test! {
name: tokenize_indented_block,
text: "
foo:
a
b
c
",
tokens: (
Identifier:"foo",
Colon,
Eol,
Indent,
Text:"a",
Eol,
Whitespace:" ",
Text:"b",
Eol,
Whitespace:" ",
Text:"c",
Eol,
Dedent,
),
}
test! {
name: tokenize_strings,
text: r#"a = "'a'" + '"b"' + "'c'" + '"d"'#echo hello"#,
tokens: (
Identifier:"a",
Whitespace,
Equals,
Whitespace,
StringToken:"\"'a'\"",
Whitespace,
Plus,
Whitespace,
StringToken:"'\"b\"'",
Whitespace,
Plus,
Whitespace,
StringToken:"\"'c'\"",
Whitespace,
Plus,
Whitespace,
StringToken:"'\"d\"'",
Comment:"#echo hello",
)
}
test! {
name: tokenize_recipe_interpolation_eol,
text: "
foo: # some comment
{{hello}}
",
tokens: (
Identifier:"foo",
Colon,
Whitespace,
Comment:"# some comment",
Eol,
Indent:" ",
InterpolationStart,
Identifier:"hello",
InterpolationEnd,
Eol,
Dedent
),
}
test! {
name: tokenize_recipe_interpolation_eof,
text: "foo: # more comments
{{hello}}
# another comment
",
tokens: (
Identifier:"foo",
Colon,
Whitespace,
Comment:"# more comments",
Eol,
Indent:" ",
InterpolationStart,
Identifier:"hello",
InterpolationEnd,
Eol,
Dedent,
Comment:"# another comment",
Eol,
),
}
test! {
name: tokenize_recipe_complex_interpolation_expression,
text: "foo: #lol\n {{a + b + \"z\" + blarg}}",
tokens: (
Identifier:"foo",
Colon,
Whitespace:" ",
Comment:"#lol",
Eol,
Indent:" ",
InterpolationStart,
Identifier:"a",
Whitespace,
Plus,
Whitespace,
Identifier:"b",
Whitespace,
Plus,
Whitespace,
StringToken:"\"z\"",
Whitespace,
Plus,
Whitespace,
Identifier:"blarg",
InterpolationEnd,
Dedent,
),
}
test! {
name: tokenize_recipe_multiple_interpolations,
text: "foo:,#ok\n {{a}}0{{b}}1{{c}}",
tokens: (
Identifier:"foo",
Colon,
Comma,
Comment:"#ok",
Eol,
Indent:" ",
InterpolationStart,
Identifier:"a",
InterpolationEnd,
Text:"0",
InterpolationStart,
Identifier:"b",
InterpolationEnd,
Text:"1",
InterpolationStart,
Identifier:"c",
InterpolationEnd,
Dedent,
),
}
test! {
name: tokenize_junk,
text: "
bob
hello blah blah blah : a b c #whatever
",
tokens: (
Identifier:"bob",
Eol,
Eol,
Identifier:"hello",
Whitespace,
Identifier:"blah",
Whitespace,
Identifier:"blah",
Whitespace,
Identifier:"blah",
Whitespace,
Colon,
Whitespace,
Identifier:"a",
Whitespace,
Identifier:"b",
Whitespace,
Identifier:"c",
Whitespace,
Comment:"#whatever",
Eol,
)
}
test! {
name: tokenize_empty_lines,
text: "
# this does something
hello:
asdf
bsdf
csdf
dsdf # whatever
# yolo
",
tokens: (
Eol,
Comment:"# this does something",
Eol,
Identifier:"hello",
Colon,
Eol,
Indent,
Text:"asdf",
Eol,
Whitespace:" ",
Text:"bsdf",
Eol,
Eol,
Whitespace:" ",
Text:"csdf",
Eol,
Eol,
Whitespace:" ",
Text:"dsdf # whatever",
Eol,
Eol,
Dedent,
Comment:"# yolo",
Eol,
),
}
test! {
name: tokenize_comment_before_variable,
text: "
#
A='1'
echo:
echo {{A}}
",
tokens: (
Comment:"#",
Eol,
Identifier:"A",
Equals,
StringToken:"'1'",
Eol,
Identifier:"echo",
Colon,
Eol,
Indent,
Text:"echo ",
InterpolationStart,
Identifier:"A",
InterpolationEnd,
Eol,
Dedent,
),
}
test! {
name: tokenize_interpolation_backticks,
text: "hello:\n echo {{`echo hello` + `echo goodbye`}}",
tokens: (
Identifier:"hello",
Colon,
Eol,
Indent:" ",
Text:"echo ",
InterpolationStart,
Backtick:"`echo hello`",
Whitespace,
Plus,
Whitespace,
Backtick:"`echo goodbye`",
InterpolationEnd,
Dedent
),
}
test! {
name: tokenize_empty_interpolation,
text: "hello:\n echo {{}}",
tokens: (
Identifier:"hello",
Colon,
Eol,
Indent:" ",
Text:"echo ",
InterpolationStart,
InterpolationEnd,
Dedent,
),
}
test! {
name: tokenize_assignment_backticks,
text: "a = `echo hello` + `echo goodbye`",
tokens: (
Identifier:"a",
Whitespace,
Equals,
Whitespace,
Backtick:"`echo hello`",
Whitespace,
Plus,
Whitespace,
Backtick:"`echo goodbye`",
),
}
test! {
name: tokenize_multiple,
text: "
hello:
a
b
c
d
# hello
bob:
frank
\t
",
tokens: (
Eol,
Identifier:"hello",
Colon,
Eol,
Indent,
Text:"a",
Eol,
Whitespace:" ",
Text:"b",
Eol,
Eol,
Whitespace:" ",
Text:"c",
Eol,
Eol,
Whitespace:" ",
Text:"d",
Eol,
Eol,
Dedent,
Comment:"# hello",
Eol,
Identifier:"bob",
Colon,
Eol,
Indent:" ",
Text:"frank",
Eol,
Eol,
Dedent,
),
}
test! {
name: tokenize_comment,
text: "a:=#",
tokens: (
Identifier:"a",
ColonEquals,
Comment:"#",
),
}
test! {
name: tokenize_comment_with_bang,
text: "a:=#foo!",
tokens: (
Identifier:"a",
ColonEquals,
Comment:"#foo!",
),
}
test! {
name: tokenize_order,
text: "
b: a
@mv a b
a:
@touch F
@touch a
d: c
@rm c
c: b
@mv b c
",
tokens: (
Identifier:"b",
Colon,
Whitespace,
Identifier:"a",
Eol,
Indent,
Text:"@mv a b",
Eol,
Eol,
Dedent,
Identifier:"a",
Colon,
Eol,
Indent,
Text:"@touch F",
Eol,
Whitespace:" ",
Text:"@touch a",
Eol,
Eol,
Dedent,
Identifier:"d",
Colon,
Whitespace,
Identifier:"c",
Eol,
Indent,
Text:"@rm c",
Eol,
Eol,
Dedent,
Identifier:"c",
Colon,
Whitespace,
Identifier:"b",
Eol,
Indent,
Text:"@mv b c",
Eol,
Dedent,
),
}
test! {
name: tokenize_parens,
text: "((())) ()abc(+",
tokens: (
ParenL,
ParenL,
ParenL,
ParenR,
ParenR,
ParenR,
Whitespace,
ParenL,
ParenR,
Identifier:"abc",
ParenL,
Plus,
),
}
test! {
name: crlf_newline,
text: "#\r\n#asdf\r\n",
tokens: (
Comment:"#",
Eol:"\r\n",
Comment:"#asdf",
Eol:"\r\n",
),
}
test! {
name: multiple_recipes,
text: "a:\n foo\nb:",
tokens: (
Identifier:"a",
Colon,
Eol,
Indent:" ",
Text:"foo",
Eol,
Dedent,
Identifier:"b",
Colon,
),
}
test! {
name: brackets,
text: "[][]",
tokens: (BracketL, BracketR, BracketL, BracketR),
}
test! {
name: open_delimiter_eol,
text: "[\n](\n){\n}",
tokens: (
BracketL, Whitespace:"\n", BracketR,
ParenL, Whitespace:"\n", ParenR,
BraceL, Whitespace:"\n", BraceR
),
}
error! {
name: tokenize_space_then_tab,
input: "a:
0
1
\t2
",
offset: 9,
line: 3,
column: 0,
width: 1,
kind: InconsistentLeadingWhitespace{expected: " ", found: "\t"},
}
error! {
name: tokenize_tabs_then_tab_space,
input: "a:
\t\t0
\t\t 1
\t 2
",
offset: 12,
line: 3,
column: 0,
width: 3,
kind: InconsistentLeadingWhitespace{expected: "\t\t", found: "\t "},
}
error! {
name: tokenize_unknown,
input: "%",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnknownStartOfToken,
}
error! {
name: unterminated_string_with_escapes,
input: r#"a = "\n\t\r\"\\"#,
offset: 4,
line: 0,
column: 4,
width: 1,
kind: UnterminatedString,
}
error! {
name: unterminated_raw_string,
input: "r a='asdf",
offset: 4,
line: 0,
column: 4,
width: 1,
kind: UnterminatedString,
}
error! {
name: unterminated_interpolation,
input: "foo:\n echo {{
",
offset: 11,
line: 1,
column: 6,
width: 2,
kind: UnterminatedInterpolation,
}
error! {
name: unterminated_backtick,
input: "`echo",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnterminatedBacktick,
}
error! {
name: unpaired_carriage_return,
input: "foo\rbar",
offset: 3,
line: 0,
column: 3,
width: 1,
kind: UnpairedCarriageReturn,
}
error! {
name: invalid_name_start_dash,
input: "-foo",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnknownStartOfToken,
}
error! {
name: invalid_name_start_digit,
input: "0foo",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnknownStartOfToken,
}
error! {
name: unterminated_string,
input: r#"a = ""#,
offset: 4,
line: 0,
column: 4,
width: 1,
kind: UnterminatedString,
}
error! {
name: mixed_leading_whitespace_recipe,
input: "a:\n\t echo hello",
offset: 3,
line: 1,
column: 0,
width: 2,
kind: MixedLeadingWhitespace{whitespace: "\t "},
}
error! {
name: mixed_leading_whitespace_normal,
input: "a\n\t echo hello",
offset: 2,
line: 1,
column: 0,
width: 2,
kind: MixedLeadingWhitespace{whitespace: "\t "},
}
error! {
name: mixed_leading_whitespace_indent,
input: "a\n foo\n \tbar",
offset: 7,
line: 2,
column: 0,
width: 2,
kind: MixedLeadingWhitespace{whitespace: " \t"},
}
error! {
name: bad_dedent,
input: "a\n foo\n bar\n baz",
offset: 14,
line: 3,
column: 0,
width: 2,
kind: InconsistentLeadingWhitespace{expected: " ", found: " "},
}
error! {
name: unclosed_interpolation_delimiter,
input: "a:\n echo {{ foo",
offset: 9,
line: 1,
column: 6,
width: 2,
kind: UnterminatedInterpolation,
}
error! {
name: unexpected_character_after_at,
input: "@%",
offset: 1,
line: 0,
column: 1,
width: 1,
kind: UnknownStartOfToken,
}
error! {
name: mismatched_closing_brace,
input: "(]",
offset: 1,
line: 0,
column: 1,
width: 0,
kind: MismatchedClosingDelimiter {
open: Delimiter::Paren,
close: Delimiter::Bracket,
open_line: 0,
},
}
error! {
name: ampersand_eof,
input: "&",
offset: 1,
line: 0,
column: 1,
width: 0,
kind: UnexpectedEndOfToken {
expected: '&',
},
}
error! {
name: ampersand_unexpected,
input: "&%",
offset: 1,
line: 0,
column: 1,
width: 1,
kind: UnexpectedCharacter {
expected: '&',
},
}
#[test]
fn presume_error() {
let compile_error = Lexer::new("justfile".as_ref(), "!")
.presume('-')
.unwrap_err();
assert_matches!(
compile_error.token,
Token {
offset: 0,
line: 0,
column: 0,
length: 0,
src: "!",
kind: Unspecified,
path: _,
}
);
assert_matches!(&*compile_error.kind,
Internal { ref message }
if message == "Lexer presumed character `-`"
);
assert_eq!(
Error::Compile { compile_error }
.color_display(Color::never())
.to_string(),
"error: Internal error, this may indicate a bug in just: Lexer presumed character `-`
consider filing an issue: https://github.com/casey/just/issues/new
justfile:1:1
1 !
^"
);
}
}