Repository: dprint/dprint-plugin-typescript (rust)

Issue:
Unstable formatting on oddly-spaced block comment
**Describe the bug**

dprint-plugin-typescript version: 0.84.0

**Input Code**

```ts
/* * * * * * * * * * *
 *  Native Promises  *
 * * * * * * * * * * */
```

**Expected Output**

Not sure 😄 

**Actual Output**

```
Message: Formatting not stable. Bailed after 5 tries. This indicates a bug in the plugin where it formats the file differently each time.
```


Pull request description:
fix: ensure stable formatting for comment with spaced asterisks
Closes #511

Patch:
diff --git a/src/generation/generate.rs b/src/generation/generate.rs
index ae5fd32..edfbf56 100644
--- a/src/generation/generate.rs
+++ b/src/generation/generate.rs
@@ -6468,6 +6468,7 @@ fn gen_comment(comment: &Comment, context: &mut Context) -> Option<PrintItems> {
 }
 
 fn gen_js_doc_or_multiline_block(comment: &Comment, _context: &mut Context) -> PrintItems {
+  debug_assert_eq!(comment.kind, CommentKind::Block);
   let is_js_doc = comment.text.starts_with('*');
   return lines_to_print_items(is_js_doc, build_lines(comment));
 
@@ -6475,9 +6476,15 @@ fn gen_js_doc_or_multiline_block(comment: &Comment, _context: &mut Context) -> P
     let mut lines: Vec<&str> = Vec::new();
 
     for line in utils::split_lines(&comment.text) {
-      let line = line[get_line_start_index(line)..].trim_end();
-      if !line.is_empty() || !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
-        lines.push(line);
+      let text = if line.line_index == 0 && !line.text.starts_with('*') {
+        line.text
+      } else {
+        &line.text[get_line_start_index(line.text)..]
+      };
+      let text = if line.is_last && !text.trim().is_empty() { text } else { text.trim_end() };
+
+      if !text.is_empty() || !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
+        lines.push(text);
       }
     }
 
@@ -6528,7 +6535,7 @@ fn gen_js_doc_or_multiline_block(comment: &Comment, _context: &mut Context) -> P
 
     items.push_str(if lines.len() > 1 && lines.last().map(|l| l.is_empty()).unwrap_or(false) {
       "/"
-    } else if lines.len() > 1 && lines.last().map(|l| l.ends_with('*')).unwrap_or(false) {
+    } else if lines.len() > 1 && lines.last().map(|l| l.ends_with('*') || l.ends_with(' ')).unwrap_or(false) {
       "*/"
     } else {
       " */"
diff --git a/src/utils/string_utils.rs b/src/utils/string_utils.rs
index 4b7a442..72cdca1 100644
--- a/src/utils/string_utils.rs
+++ b/src/utils/string_utils.rs
@@ -80,21 +80,52 @@ pub fn is_not_empty_and_only_spaces(text: &str) -> bool {
   true
 }
 
+#[derive(Debug, PartialEq, Eq)]
+pub struct SplitLinesItem<'a> {
+  pub text: &'a str,
+  pub line_index: usize,
+  pub is_last: bool,
+}
+
 pub struct SplitLinesIterator<'a> {
+  text: &'a str,
   inner: std::str::Split<'a, char>,
+  line_index: usize,
+  byte_index: usize,
 }
 
 impl<'a> Iterator for SplitLinesIterator<'a> {
-  type Item = &'a str;
+  type Item = SplitLinesItem<'a>;
 
   fn next(&mut self) -> Option<Self::Item> {
+    let line_index = self.line_index;
     let line = self.inner.next();
-    line.map(|line| line.strip_suffix('\r').unwrap_or(line))
+    match line {
+      Some(line) => {
+        if self.line_index == 0 {
+          self.byte_index += line.len();
+        } else {
+          self.byte_index += 1 + line.len();
+        }
+        self.line_index += 1;
+        Some(SplitLinesItem {
+          is_last: self.byte_index == self.text.len(),
+          line_index,
+          text: line.strip_suffix('\r').unwrap_or(line),
+        })
+      }
+      None => None,
+    }
   }
 }
 
 pub fn split_lines(text: &str) -> SplitLinesIterator<'_> {
-  SplitLinesIterator { inner: text.split('\n') }
+  SplitLinesIterator {
+    inner: text.split('\n'),
+    line_index: 0,
+    text,
+    byte_index: 0,
+  }
 }
 
 #[cfg(test)]
@@ -105,13 +136,57 @@ mod test {
   fn split_lines_empty_last_line() {
     let text = "a\r\nb\nc\r\n";
     let lines = split_lines(text).collect::<Vec<_>>();
-    assert_eq!(lines, vec!["a", "b", "c", ""]); // includes last line
+    assert_eq!(
+      lines,
+      vec![
+        SplitLinesItem {
+          text: "a",
+          is_last: false,
+          line_index: 0,
+        },
+        SplitLinesItem {
+          text: "b",
+          is_last: false,
+          line_index: 1,
+        },
+        SplitLinesItem {
+          text: "c",
+          is_last: false,
+          line_index: 2
+        },
+        // includes last line
+        SplitLinesItem {
+          text: "",
+          is_last: true,
+          line_index: 3,
+        }
+      ]
+    );
   }
 
   #[test]
   fn split_lines_non_empty_last_line() {
     let text = "a \n   b\nc";
     let lines = split_lines(text).collect::<Vec<_>>();
-    assert_eq!(lines, vec!["a ", "   b", "c"]);
+    assert_eq!(
+      lines,
+      vec![
+        SplitLinesItem {
+          text: "a ",
+          line_index: 0,
+          is_last: false,
+        },
+        SplitLinesItem {
+          text: "   b",
+          line_index: 1,
+          is_last: false,
+        },
+        SplitLinesItem {
+          text: "c",
+          line_index: 2,
+          is_last: true,
+        }
+      ]
+    );
   }
 }


Test patch:
diff --git a/tests/specs/issues/issue0511.txt b/tests/specs/issues/issue0511.txt
new file mode 100644
index 0000000..5848a14
--- /dev/null
+++ b/tests/specs/issues/issue0511.txt
@@ -0,0 +1,11 @@
+== should be stable ==
+/* * * * * * * * * * *
+ *  Some Text Here1  *
+ * * * * * * * * * * */
+const test = 5;
+
+[expect]
+/* * * * * * * * * * *
+ *  Some Text Here1  *
+ * * * * * * * * * * */
+const test = 5;