Repository: biomejs/biome (language: rust)

Issue:
🐛 biome lint panics when encountering multi-byte characters
### Environment information

```block
CLI:
  Version:                      1.9.3
  Color support:                true

Platform:
  CPU Architecture:             x86_64
  OS:                           linux

Environment:
  BIOME_LOG_PATH:               unset
  BIOME_LOG_PREFIX_NAME:        unset
  BIOME_CONFIG_PATH:            unset
  NO_COLOR:                     unset
  TERM:                         "xterm-256color"
  JS_RUNTIME_VERSION:           "v18.17.1"
  JS_RUNTIME_NAME:              "node"
  NODE_PACKAGE_MANAGER:         "bun/1.1.29"

Biome Configuration:
  Status:                       Loaded successfully
  Formatter disabled:           false
  Linter disabled:              false
  Organize imports disabled:    false
  VCS disabled:                 true

Workspace:
  Open Documents:               0
```


### What happened?

When running `biome lint` on a JavaScript file containing (only) the expression `a + '€'`, Biome encounters an internal panic. The error message indicates an issue with byte indexing for the Euro symbol (€), which uses more than one byte. Here is the exact error:

```
Biome encountered an unexpected error
Source Location: crates/biome_js_factory/src/utils.rs:34:36
Thread Name: biome::worker_1
Message: byte index 1 is not a char boundary; it is inside '€' (bytes 0..3) of `€`
```

The original code that triggered the error was `return v.toFixed(2).replace('.', ',') + ' €';` which I was able to trim down to just `a + '€'` in a separate file while still producing the same error.

If I change the original code to use template literals instead, no panic happens:

    `${v.toFixed(2).replace('.', ',')} €`

### Expected result

Biome should correctly parse and lint the file without crashing, even when handling non-ASCII characters like the Euro symbol.

### Code of Conduct

- [X] I agree to follow Biome's Code of Conduct


Discussion / hints:
Can you provide a playground link or a reproduction repo?
@dyc3 This is easily reproducible by running the following in an empty directory:

```
bun init
bun add --dev --exact @biomejs/biome
echo "a + '€'" > index.ts
bunx biome lint index.ts
Biome encountered an unexpected error

This is a bug in Biome, not an error in your code, and we would appreciate it if you could report it to https://github.com/biomejs/biome/issues/ along with the following information to help us fixing the issue:

Source Location: crates/biome_js_factory/src/utils.rs:34:36
Thread Name: biome::worker_0
Message: byte index 1 is not a char boundary; it is inside '€' (bytes 0..3) of `€`

index.ts internalError/panic  INTERNAL  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ✖ processing panicked: byte index 1 is not a char boundary; it is inside '€' (bytes 0..3) of `€`

  ⚠ This diagnostic was derived from an internal Biome error. Potential bug, please report it if necessary.


Checked 0 files in 203ms. No fixes applied.
internalError/io ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  ✖ No files were processed in the specified paths.
```

The same happens when ran via npm.

Running in WSL2 on Ubuntu 22.04.4. My rustc version is 1.81.0 if that matters.
It appears the issue was introduced in a [recent commit](https://github.com/biomejs/biome/commit/cc6d34f88a395a4ac35b0d5c9699fd07c5c3b87a) where the string iteration was changed from using char_indices() to bytes().enumerate(). The change to processing strings as raw bytes caused the error, as bytes().enumerate() does not respect UTF-8 character boundaries. This leads to panics when the code attempts to slice strings containing multi-byte characters, like `€`, at invalid byte offsets.

To handle multi-byte UTF-8 characters safely, the file should be reverted to use char_indices() instead of bytes().enumerate(). This ensures that the string is processed as valid UTF-8 characters, preventing the panic from occurring.

The relevant code change would be:

    let mut iter = unescaped_string.char_indices();

Instead of:

    let mut iter = unescaped_string.bytes().enumerate();

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09de48b76e0d..a352995ed9cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,20 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b
 
 ### Linter
 
+### Bug fixes
+
+- Biome no longer crashes when it encounters a string that contain a multibyte character ([#4181](https://github.com/biomejs/biome/issues/4181)).
+
+  This fixes a regression introduced in Biome 1.9.3
+  The regression affected the following linter rules:
+
+  - nursery/useSortedClasses
+  - nursery/useTrimStartEnd
+  - style/useTemplate
+  - suspicious/noMisleadingCharacterClass
+
+  Contributed by @Conaclos
+
 ### Parser
 
 ## v1.9.3 (2024-10-01)
diff --git a/crates/biome_js_factory/src/utils.rs b/crates/biome_js_factory/src/utils.rs
index e47e71cabd0b..0e1ffd69f556 100644
--- a/crates/biome_js_factory/src/utils.rs
+++ b/crates/biome_js_factory/src/utils.rs
@@ -31,7 +31,7 @@ pub fn escape<'a>(
             iter.next();
         } else {
             for candidate in needs_escaping {
-                if unescaped_string[idx..].starts_with(candidate) {
+                if unescaped_string.as_bytes()[idx..].starts_with(candidate.as_bytes()) {
                     if escaped.is_empty() {
                         escaped = String::with_capacity(unescaped_string.len() * 2 - idx);
                     }
@@ -70,6 +70,7 @@ mod tests {
             escape("abc ${} ${} bca", &["${"], b'\\'),
             r"abc \${} \${} bca"
         );
+        assert_eq!(escape("€", &["'"], b'\\'), "€");
 
         assert_eq!(escape(r"\`", &["`"], b'\\'), r"\`");
         assert_eq!(escape(r"\${}", &["${"], b'\\'), r"\${}");
@@ -77,6 +78,8 @@ mod tests {
         assert_eq!(escape(r"\\${}", &["${"], b'\\'), r"\\\${}");
         assert_eq!(escape(r"\\\`", &["`"], b'\\'), r"\\\`");
         assert_eq!(escape(r"\\\${}", &["${"], b'\\'), r"\\\${}");
+        assert_eq!(escape("€", &["€"], b'\\'), r"\€");
+        assert_eq!(escape("😀€", &["€"], b'\\'), r"😀\€");
 
         assert_eq!(escape("abc", &["${", "`"], b'\\'), "abc");
         assert_eq!(escape("${} `", &["${", "`"], b'\\'), r"\${} \`");


Test patch: