From 109cf81121e421b426abaffce8976a105cfd5cb2 Mon Sep 17 00:00:00 2001 From: SavioCodes Date: Sun, 12 Apr 2026 16:58:52 -0300 Subject: [PATCH] fix(frontend): allow trailing-dot float literals --- frontend/src/lexer/mod.rs | 113 +++++++++++++++++++++++++++++++++---- frontend/tests/parser.rs | 21 +++++++ slynx/trailing_float.slynx | 4 ++ tests/trailing_float.rs | 16 ++++++ 4 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 slynx/trailing_float.slynx create mode 100644 tests/trailing_float.rs diff --git a/frontend/src/lexer/mod.rs b/frontend/src/lexer/mod.rs index e50231e3..6c2b0ce5 100644 --- a/frontend/src/lexer/mod.rs +++ b/frontend/src/lexer/mod.rs @@ -196,22 +196,66 @@ impl Lexer { let mut last_is_underscore = false; let mut last_is_dot = false; let mut should_err = false; - while let Some(c) = chars.get(idx) - && (c.is_ascii_digit() || *c == '.' || *c == '_') - { - if *c == '.' { - float_value = true; + let previous_char = if start == 0 { + None + } else { + chars.get(start - 1).copied() + }; + while let Some(c) = chars.get(idx) { + if c.is_ascii_digit() { + last_is_dot = false; + last_is_underscore = false; + buffer.push(*c); + idx += 1; + continue; } - if (*c == '.' && last_is_dot) || (*c == '_' && last_is_underscore) { - should_err = true; + + if *c == '_' { + if last_is_underscore || last_is_dot { + should_err = true; + } + last_is_dot = false; + last_is_underscore = true; + buffer.push(*c); + idx += 1; + continue; } - last_is_dot = *c == '.'; - last_is_underscore = *c == '_'; - buffer.push(*c); - idx += 1; + if *c == '.' { + let next = chars.get(idx + 1); + if matches!(next, Some('.') | Some('_')) { + should_err = true; + buffer.push(*c); + idx += 1; + break; + } + + if !float_value && matches!(next, Some(next) if next.is_ascii_digit()) { + // Consume a standard fractional part like `1.5`. + float_value = true; + last_is_dot = true; + last_is_underscore = false; + buffer.push(*c); + idx += 1; + continue; + } + + if !float_value && previous_char != Some('.') { + // Accept trailing-dot floats like `0.` but avoid stealing + // the dot from postfix syntax such as `pair.0.age`. + float_value = true; + if last_is_underscore { + should_err = true; + } + buffer.push(*c); + idx += 1; + } + break; + } + + break; } - if should_err || buffer.ends_with("_") { + if should_err || buffer.ends_with('_') { return Err(LexerError::MalformedNumber { number: buffer, init: start, @@ -329,3 +373,48 @@ impl Lexer { }) } } + +#[cfg(test)] +mod tests { + use super::{Lexer, error::LexerError, tokens::TokenKind}; + + #[test] + fn lexes_trailing_dot_float_literals() { + let tokens = Lexer::tokenize("0.").expect("trailing dot floats should tokenize"); + let kinds = tokens + .stream + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert_eq!(kinds.len(), 1); + assert!(matches!(kinds[0], TokenKind::Float(value) if value == 0.0)); + } + + #[test] + fn keeps_postfix_style_dots_separate_from_number_tokens() { + let tokens = Lexer::tokenize("pair.0.age") + .expect("postfix access should keep the tuple index token separate"); + let kinds = tokens + .stream + .into_iter() + .map(|token| token.kind) + .collect::>(); + + assert!(matches!(kinds[0], TokenKind::Identifier(ref name) if name == "pair")); + assert!(matches!(kinds[1], TokenKind::Dot)); + assert!(matches!(kinds[2], TokenKind::Int(0))); + assert!(matches!(kinds[3], TokenKind::Dot)); + assert!(matches!(kinds[4], TokenKind::Identifier(ref name) if name == "age")); + } + + #[test] + fn rejects_double_dot_number_patterns() { + let err = Lexer::tokenize("4..0").expect_err("double-dot numerics should stay malformed"); + + assert!( + matches!(err, LexerError::MalformedNumber { .. }), + "expected malformed number, got {err:?}" + ); + } +} diff --git a/frontend/tests/parser.rs b/frontend/tests/parser.rs index d2c8c698..71b36dd9 100644 --- a/frontend/tests/parser.rs +++ b/frontend/tests/parser.rs @@ -243,3 +243,24 @@ func main(): int { assert!(error.contains("Unexpected token")); assert!(error.contains("'let'")); } + +#[test] +fn parses_trailing_dot_float_literal() { + let declarations = parse_source( + r#" +func main(): float { + let value = 0.; + value +} +"#, + ); + + let body = function_body(&declarations, "main"); + assert_eq!(body.len(), 2); + + let ASTStatementKind::Var { rhs, .. } = &body[0].kind else { + panic!("expected let statement"); + }; + + assert!(matches!(rhs.kind, ASTExpressionKind::FloatLiteral(value) if value == 0.0)); +} diff --git a/slynx/trailing_float.slynx b/slynx/trailing_float.slynx new file mode 100644 index 00000000..549aca8f --- /dev/null +++ b/slynx/trailing_float.slynx @@ -0,0 +1,4 @@ +func main(): float { + let value = 0.; + value +} diff --git a/tests/trailing_float.rs b/tests/trailing_float.rs new file mode 100644 index 00000000..94152745 --- /dev/null +++ b/tests/trailing_float.rs @@ -0,0 +1,16 @@ +use std::{path::PathBuf, sync::Arc}; + +#[test] +fn test_trailing_dot_float() { + let context = + slynx::SlynxContext::new(Arc::new(PathBuf::from("slynx/trailing_float.slynx"))).unwrap(); + let output = context.compile().unwrap(); + + assert_eq!( + output + .output_path() + .extension() + .and_then(|ext| ext.to_str()), + Some("sir") + ); +}