Repository: boa-dev/boa (rust)

Issue:
Slashes after assignments at the end of files are allowed
<!--
Thank you for reporting a bug in Boa! This will make us improve the engine. But first, fill the following template so that we better understand what's happening. Feel free to add or remove sections as you feel appropriate.
-->

**Describe the bug**
If code in the form `<identifier> <assignment operator> <identifier> "/" <eof>` is passed to Boa, the code executes as if the "/" were not present; it should raise a SyntaxError.

This bug does not work if there are characters after the "/", including newlines or spaces. The "/" must be the last character in the input.

<!-- E.g.:
The variable statement is not working as expected, it always adds 10 when assigning a number to a variable"
-->

**To Reproduce**
In your shell, assuming GNU coreutils: `echo -n 'let a, b; a = b /' > test.js; boa test.js`
Note that `echo -n` is required to avoid a trailing newline.

<!-- E.g.:
This JavaScript code reproduces the issue:
```javascript
var a = 10;
a;
```
-->

**Expected behavior**
This code should throw a `SyntaxError`. The `/` character is unexpected and is not valid syntax.

<!-- E.g.:
Running this code, `a` should be set to `10` and printed, but `a` is instead set to `20`. The expected behaviour can be found in the [ECMAScript specification][spec].

[spec]: https://tc39.es/ecma262/#sec-variable-statement-runtime-semantics-evaluation
-->

**Build environment (please complete the following information):**

- OS: Artix Linux
- Version: rolling
- Target triple: `x86_64-unknown-linux-gnu`
- Rustc version: `rustc 1.72.1 (d5c2e9c34 2023-09-13)`

**Additional context**
This bug works with most if not all of the assignment operators. For example, neither `a /= b /` nor `a += b /` raise a `SyntaxError` despite being invalid JavaScript.

<!-- E.g.:
You can find more information in [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var).
-->


Pull request:
bug fix: ops that stay strictly at the EOF after assigns ops are ignored
This Pull Request closes #3330 

The reason of the bug was that lexer decides next Token sequence {`...`, `OP`, `EOF`} as invalid and return an error. Yes, it's invalid, but in the same time lexer cannot know that {`...`, `OP`, `SPACE`, `EOF`} is also invalid and therefore there was two different logic to catch first case.

So, for me it seems more logically correct to delegate the catching of invalid ops at the end to the parser

-----

Should I:
* remove all `Ok(...)` from `core/parser/src/lexer/operator.rs` (now there exist only pseudo `Err(..)` that needs only for type inference)?
* use strum::EnumIter to remove enumeration of almost all `Punctuator`  in `core/parser/src/lexer/tests.rs`?

-----

Hmm... now I understand that I don't know the place where errors from lexer don't propagate upwards before, seems like somewhere not used `?`
Do I need to investigate where the place?

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index c3feb302..66229d03 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -417,7 +417,7 @@ dependencies = [
  "test-case",
  "textwrap",
  "thin-vec",
- "thiserror 2.0.3",
+ "thiserror",
  "time",
  "tinystr",
  "web-time",
@@ -562,8 +562,10 @@ name = "boa_string"
 version = "0.20.0"
 dependencies = [
  "fast-float2",
+ "itoa",
  "paste",
  "rustc-hash 2.1.0",
+ "ryu-js",
  "sptr",
  "static_assertions",
 ]
@@ -681,16 +683,16 @@ dependencies = [
 
 [[package]]
 name = "cargo_metadata"
-version = "0.19.0"
+version = "0.19.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afc309ed89476c8957c50fb818f56fe894db857866c3e163335faa91dc34eb85"
+checksum = "8769706aad5d996120af43197bf46ef6ad0fda35216b4505f926a365a232d924"
 dependencies = [
  "camino",
  "cargo-platform",
  "semver",
  "serde",
  "serde_json",
- "thiserror 1.0.69",
+ "thiserror",
 ]
 
 [[package]]
@@ -755,9 +757,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.21"
+version = "4.5.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f"
+checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -765,9 +767,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.21"
+version = "4.5.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec"
+checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838"
 dependencies = [
  "anstream",
  "anstyle",
@@ -789,9 +791,9 @@ dependencies = [
 
 [[package]]
 name = "clap_lex"
-version = "0.7.3"
+version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7"
+checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
 
 [[package]]
 name = "clipboard-win"
@@ -3403,38 +3405,18 @@ checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b"
 
 [[package]]
 name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.3"
+version = "2.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
+checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47"
 dependencies = [
- "thiserror-impl 2.0.3",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.3"
+version = "2.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
+checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/Cargo.toml b/Cargo.toml
index 9f8af784..c180c3cf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -48,12 +48,13 @@ boa_string = { version = "~0.20.0", path = "core/string" }
 # Shared deps
 arbitrary = "1"
 bitflags = "2.5.0"
-clap = "4.5.21"
+clap = "4.5.23"
 colored = "2.1.0"
 fast-float2 = "0.2.3"
 hashbrown = "0.15.2"
 indexmap = { version = "2.7.0", default-features = false }
 indoc = "2.0.5"
+itoa = "1.0.13"
 jemallocator = "0.5.4"
 num-bigint = "0.4.6"
 num-traits = "0.2.19"
@@ -72,7 +73,7 @@ time = { version = "0.3.37", default-features = false, features = ["local-offset
 tinystr = "0.7.5"
 log = "0.4.22"
 simple_logger = "5.0.0"
-cargo_metadata = "0.19.0"
+cargo_metadata = "0.19.1"
 trybuild = "1.0.101"
 rayon = "1.10.0"
 toml = "0.8.19"
@@ -100,7 +101,7 @@ rand = "0.8.5"
 num-integer = "0.1.46"
 ryu-js = "1.0.1"
 tap = "1.0.1"
-thiserror = { version = "2.0.3", default-features = false }
+thiserror = { version = "2.0.6", default-features = false }
 dashmap = "6.1.0"
 num_enum = "0.7.3"
 itertools = { version = "0.13.0", default-features = false }
diff --git a/core/engine/src/builtins/json/mod.rs b/core/engine/src/builtins/json/mod.rs
index 8dd04fcf..78e9710f 100644
--- a/core/engine/src/builtins/json/mod.rs
+++ b/core/engine/src/builtins/json/mod.rs
@@ -193,12 +193,8 @@ impl Json {
                 for i in 0..len {
                     // 1. Let prop be ! ToString(𝔽(I)).
                     // 2. Let newElement be ? InternalizeJSONProperty(val, prop, reviver).
-                    let new_element = Self::internalize_json_property(
-                        obj,
-                        i.to_string().into(),
-                        reviver,
-                        context,
-                    )?;
+                    let new_element =
+                        Self::internalize_json_property(obj, i.into(), reviver, context)?;
 
                     // 3. If newElement is undefined, then
                     if new_element.is_undefined() {
@@ -750,8 +746,7 @@ impl Json {
         // 8. Repeat, while index < len,
         while index < len {
             // a. Let strP be ? SerializeJSONProperty(state, ! ToString(𝔽(index)), value).
-            let str_p =
-                Self::serialize_json_property(state, index.to_string().into(), value, context)?;
+            let str_p = Self::serialize_json_property(state, index.into(), value, context)?;
 
             // b. If strP is undefined, then
             if let Some(str_p) = str_p {
diff --git a/core/engine/src/builtins/number/mod.rs b/core/engine/src/builtins/number/mod.rs
index d1b09d4e..99147f90 100644
--- a/core/engine/src/builtins/number/mod.rs
+++ b/core/engine/src/builtins/number/mod.rs
@@ -230,7 +230,7 @@ impl Number {
         };
         // 4. If x is not finite, return ! Number::toString(x).
         if !this_num.is_finite() {
-            return Ok(JsValue::new(Self::to_js_string(this_num)));
+            return Ok(JsValue::new(JsString::from(this_num)));
         }
         // Get rid of the '-' sign for -0.0
         let this_num = if this_num == 0. { 0. } else { this_num };
@@ -308,8 +308,7 @@ impl Number {
         _: &mut Context,
     ) -> JsResult<JsValue> {
         let this_num = Self::this_number_value(this)?;
-        let this_str_num = this_num.to_string();
-        Ok(JsValue::new(js_string!(this_str_num)))
+        Ok(JsValue::new(js_string!(this_num)))
     }
 
     /// `flt_str_to_exp` - used in `to_precision`
@@ -644,12 +643,6 @@ impl Number {
         ))
     }
 
-    #[allow(clippy::wrong_self_convention)]
-    pub(crate) fn to_js_string(x: f64) -> JsString {
-        let mut buffer = ryu_js::Buffer::new();
-        js_string!(buffer.format(x))
-    }
-
     /// `Number.prototype.toString( [radix] )`
     ///
     /// The `toString()` method returns a string representing the specified Number object.
@@ -688,7 +681,7 @@ impl Number {
 
         // 5. If radixNumber = 10, return ! ToString(x).
         if radix_number == 10 {
-            return Ok(JsValue::new(Self::to_js_string(x)));
+            return Ok(JsValue::new(JsString::from(x)));
         }
 
         if x == -0. {
diff --git a/core/engine/src/builtins/object/for_in_iterator.rs b/core/engine/src/builtins/object/for_in_iterator.rs
index cbaf5adf..e811685a 100644
--- a/core/engine/src/builtins/object/for_in_iterator.rs
+++ b/core/engine/src/builtins/object/for_in_iterator.rs
@@ -113,9 +113,7 @@ impl ForInIterator {
                             iterator.remaining_keys.push_back(k.clone());
                         }
                         PropertyKey::Index(i) => {
-                            iterator
-                                .remaining_keys
-                                .push_back(i.get().to_string().into());
+                            iterator.remaining_keys.push_back(i.get().into());
                         }
                         PropertyKey::Symbol(_) => {}
                     }
diff --git a/core/engine/src/builtins/object/mod.rs b/core/engine/src/builtins/object/mod.rs
index 30a02287..7d1b18c4 100644
--- a/core/engine/src/builtins/object/mod.rs
+++ b/core/engine/src/builtins/object/mod.rs
@@ -1533,7 +1533,7 @@ fn get_own_property_keys(
             (PropertyKeyType::String, PropertyKey::String(_))
             | (PropertyKeyType::Symbol, PropertyKey::Symbol(_)) => Some(next_key.into()),
             (PropertyKeyType::String, PropertyKey::Index(index)) => {
-                Some(js_string!(index.get().to_string()).into())
+                Some(js_string!(index.get()).into())
             }
             _ => None,
         }
diff --git a/core/engine/src/builtins/typed_array/object.rs b/core/engine/src/builtins/typed_array/object.rs
index 0389956c..1e6bf247 100644
--- a/core/engine/src/builtins/typed_array/object.rs
+++ b/core/engine/src/builtins/typed_array/object.rs
@@ -3,7 +3,7 @@
 use std::sync::atomic::Ordering;
 
 use crate::{
-    builtins::{array_buffer::BufferObject, Number},
+    builtins::array_buffer::BufferObject,
     object::{
         internal_methods::{
             ordinary_define_own_property, ordinary_delete, ordinary_get, ordinary_get_own_property,
@@ -272,7 +272,7 @@ fn canonical_numeric_index_string(argument: &JsString) -> Option<f64> {
     let n = argument.to_number();
 
     // 3. If ! ToString(n) is argument, return n.
-    if &Number::to_js_string(n) == argument {
+    if &JsString::from(n) == argument {
         return Some(n);
     }
 
diff --git a/core/engine/src/property/mod.rs b/core/engine/src/property/mod.rs
index 2ef676ab..f8c293da 100644
--- a/core/engine/src/property/mod.rs
+++ b/core/engine/src/property/mod.rs
@@ -717,7 +717,7 @@ impl From<PropertyKey> for JsValue {
         match property_key {
             PropertyKey::String(ref string) => string.clone().into(),
             PropertyKey::Symbol(ref symbol) => symbol.clone().into(),
-            PropertyKey::Index(index) => js_string!(index.get().to_string()).into(),
+            PropertyKey::Index(index) => js_string!(index.get()).into(),
         }
     }
 }
@@ -738,8 +738,7 @@ impl From<u16> for PropertyKey {
 
 impl From<u32> for PropertyKey {
     fn from(value: u32) -> Self {
-        NonMaxU32::new(value)
-            .map_or_else(|| Self::String(js_string!(value.to_string())), Self::Index)
+        NonMaxU32::new(value).map_or_else(|| Self::String(value.into()), Self::Index)
     }
 }
 
@@ -748,7 +747,7 @@ impl From<usize> for PropertyKey {
         u32::try_from(value)
             .ok()
             .and_then(NonMaxU32::new)
-            .map_or_else(|| Self::String(js_string!(value.to_string())), Self::Index)
+            .map_or_else(|| Self::String(value.into()), Self::Index)
     }
 }
 
@@ -757,7 +756,7 @@ impl From<i64> for PropertyKey {
         u32::try_from(value)
             .ok()
             .and_then(NonMaxU32::new)
-            .map_or_else(|| Self::String(js_string!(value.to_string())), Self::Index)
+            .map_or_else(|| Self::String(value.into()), Self::Index)
     }
 }
 
@@ -766,7 +765,7 @@ impl From<u64> for PropertyKey {
         u32::try_from(value)
             .ok()
             .and_then(NonMaxU32::new)
-            .map_or_else(|| Self::String(js_string!(value.to_string())), Self::Index)
+            .map_or_else(|| Self::String(value.into()), Self::Index)
     }
 }
 
@@ -775,7 +774,7 @@ impl From<isize> for PropertyKey {
         u32::try_from(value)
             .ok()
             .and_then(NonMaxU32::new)
-            .map_or_else(|| Self::String(js_string!(value.to_string())), Self::Index)
+            .map_or_else(|| Self::String(value.into()), Self::Index)
     }
 }
 
@@ -785,7 +784,7 @@ impl From<i32> for PropertyKey {
             // Safety: A positive i32 value fits in 31 bits, so it can never be u32::MAX.
             return Self::Index(unsafe { NonMaxU32::new_unchecked(value as u32) });
         }
-        Self::String(js_string!(value.to_string()))
+        Self::String(value.into())
     }
 }
 
@@ -793,10 +792,9 @@ impl From<f64> for PropertyKey {
     fn from(value: f64) -> Self {
         use num_traits::cast::FromPrimitive;
 
-        u32::from_f64(value).and_then(NonMaxU32::new).map_or_else(
-            || Self::String(ryu_js::Buffer::new().format(value).into()),
-            Self::Index,
-        )
+        u32::from_f64(value)
+            .and_then(NonMaxU32::new)
+            .map_or_else(|| Self::String(value.into()), Self::Index)
     }
 }
 
diff --git a/core/engine/src/value/mod.rs b/core/engine/src/value/mod.rs
index 6d195920..d44b12ee 100644
--- a/core/engine/src/value/mod.rs
+++ b/core/engine/src/value/mod.rs
@@ -540,8 +540,8 @@ impl JsValue {
             } else {
                 js_string!("false")
             }),
-            Self::Rational(rational) => Ok(Number::to_js_string(*rational)),
-            Self::Integer(integer) => Ok(integer.to_string().into()),
+            Self::Rational(rational) => Ok(JsString::from(*rational)),
+            Self::Integer(integer) => Ok(JsString::from(*integer)),
             Self::String(string) => Ok(string.clone()),
             Self::Symbol(_) => Err(JsNativeError::typ()
                 .with_message("can't convert symbol to string")
diff --git a/core/parser/src/lexer/mod.rs b/core/parser/src/lexer/mod.rs
index 0de8c14d..8f5d4583 100644
--- a/core/parser/src/lexer/mod.rs
+++ b/core/parser/src/lexer/mod.rs
@@ -176,9 +176,9 @@ impl<R> Lexer<R> {
                 }
             }
         } else {
-            Err(Error::syntax(
-                "Abrupt end: Expecting Token /,*,= or regex",
-                start,
+            Ok(Token::new(
+                Punctuator::Div.into(),
+                Span::new(start, self.cursor.pos()),
             ))
         }
     }
diff --git a/core/parser/src/lexer/operator.rs b/core/parser/src/lexer/operator.rs
index 8f99548d..130a216b 100644
--- a/core/parser/src/lexer/operator.rs
+++ b/core/parser/src/lexer/operator.rs
@@ -6,14 +6,16 @@ use boa_ast::{Position, Punctuator, Span};
 use boa_interner::Interner;
 use boa_profiler::Profiler;
 
+const CHAR_ASSIGN: u32 = '=' as u32;
+
 /// `vop` tests the next token to see if we're on an assign operation of just a plain binary operation.
 ///
 /// If the next value is not an assignment operation it will pattern match  the provided values and return the corresponding token.
 macro_rules! vop {
     ($cursor:ident, $assign_op:expr, $op:expr) => ({
         match $cursor.peek_char()? {
-            None => Err(Error::syntax("abrupt end - could not preview next value as part of the operator", $cursor.pos())),
-            Some(0x3D /* = */) => {
+            None => $op,
+            Some(CHAR_ASSIGN) => {
                 $cursor.next_char()?.expect("= token vanished");
                 $assign_op
             }
@@ -22,8 +24,8 @@ macro_rules! vop {
     });
     ($cursor:ident, $assign_op:expr, $op:expr, {$($case:pat => $block:expr), +}) => ({
         match $cursor.peek_char()? {
-            None => Err(Error::syntax("abrupt end - could not preview next value as part of the operator", $cursor.pos())),
-            Some(0x3D /* = */) => {
+            None => $op,
+            Some(CHAR_ASSIGN) => {
                 $cursor.next_char()?.expect("= token vanished");
                 $assign_op
             },
@@ -31,7 +33,7 @@ macro_rules! vop {
                 $cursor.next_char()?.expect("Token vanished");
                 $block
             })+,
-            _ => $op,
+            Some(_) => $op,
         }
     });
 }
@@ -39,17 +41,17 @@ macro_rules! vop {
 /// The `op` macro handles binary operations or assignment operations and converts them into tokens.
 macro_rules! op {
     ($cursor:ident, $start_pos:expr, $assign_op:expr, $op:expr) => ({
-        Ok(Token::new(
-            vop!($cursor, $assign_op, $op)?.into(),
+        Token::new(
+            vop!($cursor, $assign_op, $op).into(),
             Span::new($start_pos, $cursor.pos()),
-        ))
+        )
     });
     ($cursor:ident, $start_pos:expr, $assign_op:expr, $op:expr, {$($case:pat => $block:expr),+}) => ({
-        let punc: Punctuator = vop!($cursor, $assign_op, $op, {$($case => $block),+})?;
-        Ok(Token::new(
+        let punc: Punctuator = vop!($cursor, $assign_op, $op, {$($case => $block),+});
+        Token::new(
             punc.into(),
             Span::new($start_pos, $cursor.pos()),
-        ))
+        )
     });
 }
 
@@ -87,29 +89,22 @@ impl<R> Tokenizer<R> for Operator {
     {
         let _timer = Profiler::global().start_event("Operator", "Lexing");
 
-        match self.init {
-            b'*' => op!(cursor, start_pos, Ok(Punctuator::AssignMul), Ok(Punctuator::Mul), {
-                Some(0x2A /* * */) => vop!(cursor, Ok(Punctuator::AssignPow), Ok(Punctuator::Exp))
+        Ok(match self.init {
+            b'*' => op!(cursor, start_pos, Punctuator::AssignMul, Punctuator::Mul, {
+                Some(0x2A /* * */) => vop!(cursor, Punctuator::AssignPow, Punctuator::Exp)
             }),
-            b'+' => op!(cursor, start_pos, Ok(Punctuator::AssignAdd), Ok(Punctuator::Add), {
-                Some(0x2B /* + */) => Ok(Punctuator::Inc)
+            b'+' => op!(cursor, start_pos, Punctuator::AssignAdd, Punctuator::Add, {
+                Some(0x2B /* + */) => Punctuator::Inc
             }),
-            b'-' => op!(cursor, start_pos, Ok(Punctuator::AssignSub), Ok(Punctuator::Sub), {
-                Some(0x2D /* - */) => {
-                    Ok(Punctuator::Dec)
-                }
+            b'-' => op!(cursor, start_pos, Punctuator::AssignSub, Punctuator::Sub, {
+                Some(0x2D /* - */) => Punctuator::Dec
             }),
-            b'%' => op!(
-                cursor,
-                start_pos,
-                Ok(Punctuator::AssignMod),
-                Ok(Punctuator::Mod)
-            ),
-            b'|' => op!(cursor, start_pos, Ok(Punctuator::AssignOr), Ok(Punctuator::Or), {
-                Some(0x7C /* | */) => vop!(cursor, Ok(Punctuator::AssignBoolOr), Ok(Punctuator::BoolOr))
+            b'%' => op!(cursor, start_pos, Punctuator::AssignMod, Punctuator::Mod),
+            b'|' => op!(cursor, start_pos, Punctuator::AssignOr, Punctuator::Or, {
+                Some(0x7C /* | */) => vop!(cursor, Punctuator::AssignBoolOr, Punctuator::BoolOr)
             }),
-            b'&' => op!(cursor, start_pos, Ok(Punctuator::AssignAnd), Ok(Punctuator::And), {
-                Some(0x26 /* & */) => vop!(cursor, Ok(Punctuator::AssignBoolAnd), Ok(Punctuator::BoolAnd))
+            b'&' => op!(cursor, start_pos, Punctuator::AssignAnd, Punctuator::And, {
+                Some(0x26 /* & */) => vop!(cursor, Punctuator::AssignBoolAnd, Punctuator::BoolAnd)
             }),
             b'?' => {
                 let (first, second) = (cursor.peek_char()?, cursor.peek_n(2)?[1]);
@@ -119,62 +114,54 @@ impl<R> Tokenizer<R> for Operator {
                         op!(
                             cursor,
                             start_pos,
-                            Ok(Punctuator::AssignCoalesce),
-                            Ok(Punctuator::Coalesce)
+                            Punctuator::AssignCoalesce,
+                            Punctuator::Coalesce
                         )
                     }
                     Some(0x2E /* . */) if !matches!(second, Some(second) if (0x30..=0x39 /* 0..=9 */).contains(&second)) =>
                     {
                         cursor.next_char()?.expect(". vanished");
-                        Ok(Token::new(
+                        Token::new(
                             TokenKind::Punctuator(Punctuator::Optional),
                             Span::new(start_pos, cursor.pos()),
-                        ))
+                        )
                     }
-                    _ => Ok(Token::new(
+                    _ => Token::new(
                         TokenKind::Punctuator(Punctuator::Question),
                         Span::new(start_pos, cursor.pos()),
-                    )),
+                    ),
                 }
             }
-            b'^' => op!(
-                cursor,
-                start_pos,
-                Ok(Punctuator::AssignXor),
-                Ok(Punctuator::Xor)
-            ),
+            b'^' => op!(cursor, start_pos, Punctuator::AssignXor, Punctuator::Xor),
             b'=' => op!(cursor, start_pos, if cursor.next_if(0x3D /* = */)? {
-                Ok(Punctuator::StrictEq)
+                Punctuator::StrictEq
             } else {
-                Ok(Punctuator::Eq)
-            }, Ok(Punctuator::Assign), {
+                Punctuator::Eq
+            }, Punctuator::Assign, {
                 Some(0x3E /* > */) => {
-                    Ok(Punctuator::Arrow)
+                    Punctuator::Arrow
                 }
             }),
             b'<' => {
-                op!(cursor, start_pos, Ok(Punctuator::LessThanOrEq), Ok(Punctuator::LessThan), {
-                    Some(0x3C /* < */) => vop!(cursor, Ok(Punctuator::AssignLeftSh), Ok(Punctuator::LeftSh))
+                op!(cursor, start_pos, Punctuator::LessThanOrEq, Punctuator::LessThan, {
+                    Some(0x3C /* < */) => vop!(cursor, Punctuator::AssignLeftSh, Punctuator::LeftSh)
                 })
             }
             b'>' => {
-                op!(cursor, start_pos, Ok(Punctuator::GreaterThanOrEq), Ok(Punctuator::GreaterThan), {
-                    Some(0x3E /* > */) => vop!(cursor, Ok(Punctuator::AssignRightSh), Ok(Punctuator::RightSh), {
-                        Some(0x3E /* > */) => vop!(cursor, Ok(Punctuator::AssignURightSh), Ok(Punctuator::URightSh))
+                op!(cursor, start_pos, Punctuator::GreaterThanOrEq, Punctuator::GreaterThan, {
+                    Some(0x3E /* > */) => vop!(cursor, Punctuator::AssignRightSh, Punctuator::RightSh, {
+                        Some(0x3E /* > */) => vop!(cursor, Punctuator::AssignURightSh, Punctuator::URightSh)
                     })
                 })
             }
             b'!' => op!(
                 cursor,
                 start_pos,
-                vop!(cursor, Ok(Punctuator::StrictNotEq), Ok(Punctuator::NotEq)),
-                Ok(Punctuator::Not)
+                vop!(cursor, Punctuator::StrictNotEq, Punctuator::NotEq),
+                Punctuator::Not
             ),
-            b'~' => Ok(Token::new(
-                Punctuator::Neg.into(),
-                Span::new(start_pos, cursor.pos()),
-            )),
+            b'~' => Token::new(Punctuator::Neg.into(), Span::new(start_pos, cursor.pos())),
             op => unimplemented!("operator {}", op),
-        }
+        })
     }
 }
diff --git a/core/parser/src/parser/cursor/mod.rs b/core/parser/src/parser/cursor/mod.rs
index 77749cad..c6d0160b 100644
--- a/core/parser/src/parser/cursor/mod.rs
+++ b/core/parser/src/parser/cursor/mod.rs
@@ -104,6 +104,7 @@ where
     }
 
     /// Peeks a future token, without consuming it or advancing the cursor.
+    /// This peeking **skips** line terminators.
     ///
     /// You can skip some tokens with the `skip_n` option.
     pub(super) fn peek(
@@ -114,6 +115,18 @@ where
         self.buffered_lexer.peek(skip_n, true, interner)
     }
 
+    /// Peeks a future token, without consuming it or advancing the cursor.
+    /// This peeking **does not skips** line terminators.
+    ///
+    /// You can skip some tokens with the `skip_n` option.
+    pub(super) fn peek_no_skip_line_term(
+        &mut self,
+        skip_n: usize,
+        interner: &mut Interner,
+    ) -> ParseResult<Option<&Token>> {
+        self.buffered_lexer.peek(skip_n, false, interner)
+    }
+
     /// Gets the current strict mode for the cursor.
     pub(super) const fn strict(&self) -> bool {
         self.buffered_lexer.strict()
@@ -195,14 +208,12 @@ where
         &mut self,
         interner: &mut Interner,
     ) -> ParseResult<SemicolonResult<'_>> {
-        self.buffered_lexer.peek(0, false, interner)?.map_or(
-            Ok(SemicolonResult::Found(None)),
-            |tk| match tk.kind() {
+        self.peek_no_skip_line_term(0, interner)?
+            .map_or(Ok(SemicolonResult::Found(None)), |tk| match tk.kind() {
                 TokenKind::Punctuator(Punctuator::Semicolon | Punctuator::CloseBlock)
                 | TokenKind::LineTerminator => Ok(SemicolonResult::Found(Some(tk))),
                 _ => Ok(SemicolonResult::NotFound(tk)),
-            },
-        )
+            })
     }
 
     /// Consumes the next token if it is a semicolon, or returns a `Errpr` if it's not.
@@ -245,10 +256,7 @@ where
         context: &'static str,
         interner: &mut Interner,
     ) -> ParseResult<&Token> {
-        let tok = self
-            .buffered_lexer
-            .peek(skip_n, false, interner)
-            .or_abrupt()?;
+        let tok = self.peek_no_skip_line_term(skip_n, interner).or_abrupt()?;
 
         if tok.kind() == &TokenKind::LineTerminator {
             Err(Error::unexpected(
@@ -267,8 +275,7 @@ where
         skip_n: usize,
         interner: &mut Interner,
     ) -> ParseResult<Option<bool>> {
-        self.buffered_lexer
-            .peek(skip_n, false, interner)?
+        self.peek_no_skip_line_term(skip_n, interner)?
             .map_or(Ok(None), |t| {
                 Ok(Some(t.kind() == &TokenKind::LineTerminator))
             })
diff --git a/core/parser/src/parser/expression/assignment/mod.rs b/core/parser/src/parser/expression/assignment/mod.rs
index 4ee2786b..564baaa0 100644
--- a/core/parser/src/parser/expression/assignment/mod.rs
+++ b/core/parser/src/parser/expression/assignment/mod.rs
@@ -107,9 +107,7 @@ where
                 } else {
                     1
                 };
-                if let Ok(tok) =
-                    cursor.peek_expect_no_lineterminator(skip_n, "assignment expression", interner)
-                {
+                if let Some(tok) = cursor.peek_no_skip_line_term(skip_n, interner)? {
                     if tok.kind() == &TokenKind::Punctuator(Punctuator::Arrow) {
                         return ArrowFunction::new(
                             self.allow_in,
diff --git a/core/string/Cargo.toml b/core/string/Cargo.toml
index 60268b4c..4203ab51 100644
--- a/core/string/Cargo.toml
+++ b/core/string/Cargo.toml
@@ -12,7 +12,9 @@ repository.workspace = true
 rust-version.workspace = true
 
 [dependencies]
+itoa.workspace = true
 rustc-hash = { workspace = true, features = ["std"] }
+ryu-js.workspace = true
 sptr.workspace = true
 static_assertions.workspace = true
 paste.workspace = true
diff --git a/core/string/src/lib.rs b/core/string/src/lib.rs
index f3928828..b64004a0 100644
--- a/core/string/src/lib.rs
+++ b/core/string/src/lib.rs
@@ -1092,6 +1092,28 @@ impl std::fmt::Debug for JsString {
 
 impl Eq for JsString {}
 
+macro_rules! impl_from_number_for_js_string {
+    ($($module: ident => $($ty:ty),+)+) => {
+        $(
+            $(
+                impl From<$ty> for JsString {
+                    #[inline]
+                    fn from(value: $ty) -> Self {
+                        JsString::from_slice_skip_interning(JsStr::latin1(
+                            $module::Buffer::new().format(value).as_bytes(),
+                        ))
+                    }
+                }
+            )+
+        )+
+    };
+}
+
+impl_from_number_for_js_string!(
+    itoa => i8, i16, i32, i64, i128, u8, u16, u32, u64, u128, isize, usize
+    ryu_js => f32, f64
+);
+
 impl From<&[u16]> for JsString {
     #[inline]
     fn from(s: &[u16]) -> Self {


Test patch:
diff --git a/core/engine/src/tests/operators.rs b/core/engine/src/tests/operators.rs
index 85dba2b8..e150e76b 100644
--- a/core/engine/src/tests/operators.rs
+++ b/core/engine/src/tests/operators.rs
@@ -600,6 +600,44 @@ fn delete_in_strict_function_returned() {
     )]);
 }
 
+#[test]
+fn ops_at_the_end() {
+    let msg = "abrupt end";
+
+    let mut actions = vec![TestAction::assert_eq("var a, b=3; a = b ++", 3)];
+
+    let abrupt_op_sources = [
+        // there was a bug with different behavior with and without space at the end;
+        // so few lines are almost the same except for ending space
+        "var a, b=3; a = b **",
+        "var a, b=3; a = b ** ",
+        "var a, b=3; a = b *",
+        "var a, b=3; a = b * ",
+        "var a, b=3; a /= b *",
+        "var a, b=3; a /= b * ",
+        "var a, b=3; a = b /",
+        "var a, b=3; a = b / ",
+        "var a, b=3; a = b +",
+        "var a, b=3; a = b -",
+        "var a, b=3; a = b ||",
+        "var a, b=3; a = b || ",
+        "var a, b=3; a = b ==",
+        "var a, b=3; a = b ===",
+    ];
+
+    for source in abrupt_op_sources {
+        actions.push(TestAction::assert_native_error(
+            source,
+            JsNativeErrorKind::Syntax,
+            msg,
+        ));
+    }
+
+    actions.push(TestAction::assert_eq("var a, b=3; a = b --", 3));
+
+    run_test_actions(actions);
+}
+
 #[test]
 fn regex_slash_eq() {
     run_test_actions([
diff --git a/core/parser/src/lexer/tests.rs b/core/parser/src/lexer/tests.rs
index 9cebcae7..c0a7b6a0 100644
--- a/core/parser/src/lexer/tests.rs
+++ b/core/parser/src/lexer/tests.rs
@@ -253,6 +253,86 @@ fn check_punctuators() {
     expect_tokens(&mut lexer, &expected, interner);
 }
 
+#[test]
+fn check_punctuator_at_the_end() {
+    //TODO: maybe just use `strum` (`EnumIter`)?
+    //    * it is already in dependencies
+    let last_expected = [
+        Punctuator::Add,
+        Punctuator::And,
+        Punctuator::Arrow,
+        Punctuator::AssignAdd,
+        Punctuator::AssignAnd,
+        Punctuator::AssignBoolAnd,
+        Punctuator::AssignBoolOr,
+        Punctuator::AssignCoalesce,
+        // Punctuator::AssignDiv, : is unclosed regular expr
+        Punctuator::AssignLeftSh,
+        Punctuator::AssignMod,
+        Punctuator::AssignMul,
+        Punctuator::AssignOr,
+        Punctuator::AssignPow,
+        Punctuator::AssignRightSh,
+        Punctuator::AssignSub,
+        Punctuator::AssignURightSh,
+        Punctuator::AssignXor,
+        Punctuator::BoolAnd,
+        Punctuator::BoolOr,
+        Punctuator::Coalesce,
+        Punctuator::CloseBlock,
+        Punctuator::CloseBracket,
+        Punctuator::CloseParen,
+        Punctuator::Colon,
+        Punctuator::Comma,
+        Punctuator::Dec,
+        Punctuator::Div,
+        Punctuator::Dot,
+        Punctuator::Eq,
+        Punctuator::GreaterThan,
+        Punctuator::GreaterThanOrEq,
+        Punctuator::Inc,
+        Punctuator::LeftSh,
+        Punctuator::LessThan,
+        Punctuator::LessThanOrEq,
+        Punctuator::Mod,
+        Punctuator::Mul,
+        Punctuator::Neg,
+        Punctuator::Not,
+        Punctuator::NotEq,
+        Punctuator::OpenBlock,
+        Punctuator::OpenBracket,
+        Punctuator::OpenParen,
+        Punctuator::Optional,
+        Punctuator::Or,
+        Punctuator::Exp,
+        Punctuator::Question,
+        Punctuator::RightSh,
+        Punctuator::Semicolon,
+        Punctuator::Spread,
+        Punctuator::StrictEq,
+        Punctuator::StrictNotEq,
+        Punctuator::Sub,
+        Punctuator::URightSh,
+        Punctuator::Xor,
+    ];
+
+    for last in last_expected {
+        let s = format!("var a = b {}", last.as_str());
+        let mut lexer = Lexer::from(s.as_bytes());
+        let interner = &mut Interner::default();
+
+        let expected = [
+            TokenKind::Keyword((Keyword::Var, false)),
+            TokenKind::identifier(interner.get_or_intern_static("a", utf16!("a"))),
+            TokenKind::Punctuator(Punctuator::Assign),
+            TokenKind::identifier(interner.get_or_intern_static("b", utf16!("b"))),
+            TokenKind::Punctuator(last),
+        ];
+
+        expect_tokens(&mut lexer, &expected, interner);
+    }
+}
+
 #[test]
 fn check_keywords() {
     // https://tc39.es/ecma262/#sec-keywords