eure_document/
text.rs

1//! Text type unifying strings and code in Eure.
2//!
3//! This module provides the [`Text`] type which represents all text values in Eure,
4//! whether they originated from string syntax (`"..."`) or code syntax (`` `...` ``).
5
6use alloc::{borrow::Cow, string::String};
7use core::iter::Peekable;
8use thiserror::Error;
9
10/// Language tag for text values.
11///
12/// # Variants
13///
14/// - [`Plaintext`](Language::Plaintext): Explicitly plain text, from `"..."` string syntax.
15///   Use when the content is data/text, not code.
16///
17/// - [`Implicit`](Language::Implicit): No language specified, from `` `...` `` or
18///   ```` ``` ```` without a language tag. The language can be inferred from schema context.
19///
20/// - [`Other`](Language::Other): Explicit language tag, from `` rust`...` `` or
21///   ```` ```rust ```` syntax. Use when the language must be specified.
22///
23/// # Schema Validation
24///
25/// | Schema | `Plaintext` | `Implicit` | `Other("rust")` |
26/// |--------|-------------|------------|-----------------|
27/// | `.text` (any) | ✓ | ✓ | ✓ |
28/// | `.text.plaintext` | ✓ | ✓ (coerce) | ✗ |
29/// | `.text.rust` | ✗ | ✓ (coerce) | ✓ |
30///
31/// `Implicit` allows users to write `` `let a = 1;` `` when the schema
32/// already specifies `.text.rust`, without redundantly repeating the language.
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
34pub enum Language {
35    /// Explicitly plain text (from `"..."` syntax).
36    ///
37    /// This variant is rejected by schemas expecting a specific language like `.text.rust`.
38    /// Use this when the content is data/text, not code.
39    #[default]
40    Plaintext,
41    /// No language specified (from `` `...` `` without language tag).
42    ///
43    /// Can be coerced to match the schema's expected language. This allows users
44    /// to write `` `let a = 1;` `` when the schema already specifies `.text.rust`.
45    Implicit,
46    /// Explicit language tag (from `` lang`...` `` syntax).
47    ///
48    /// The string contains the language identifier (e.g., "rust", "sql", "email").
49    Other(String),
50}
51
52impl Language {
53    /// Create a Language from a string.
54    ///
55    /// - Empty string or "plaintext" → [`Plaintext`](Language::Plaintext)
56    /// - Other strings → [`Other`](Language::Other)
57    ///
58    /// Note: This does NOT produce [`Implicit`](Language::Implicit). Use `Language::Implicit`
59    /// directly when parsing code syntax without a language tag.
60    pub fn new(s: impl Into<String>) -> Self {
61        let s = s.into();
62        if s == "plaintext" || s.is_empty() {
63            Language::Plaintext
64        } else {
65            Language::Other(s)
66        }
67    }
68
69    /// Returns the language as a string slice, or `None` for [`Implicit`](Language::Implicit).
70    pub fn as_str(&self) -> Option<&str> {
71        match self {
72            Language::Plaintext => Some("plaintext"),
73            Language::Implicit => None,
74            Language::Other(s) => Some(s),
75        }
76    }
77
78    /// Returns true if this is the [`Plaintext`](Language::Plaintext) variant.
79    pub fn is_plaintext(&self) -> bool {
80        matches!(self, Language::Plaintext)
81    }
82
83    /// Returns true if this is the [`Implicit`](Language::Implicit) variant.
84    pub fn is_implicit(&self) -> bool {
85        matches!(self, Language::Implicit)
86    }
87
88    /// Returns true if this language can be coerced to the expected language.
89    ///
90    /// # Coercion Rules
91    ///
92    /// - `Implicit` can be coerced to any language (it's "infer from schema")
93    /// - Any language matches an `Implicit` expectation (schema says "any")
94    /// - Otherwise, languages must match exactly
95    pub fn is_compatible_with(&self, expected: &Language) -> bool {
96        match (self, expected) {
97            (_, Language::Implicit) => true, // Any matches implicit expectation
98            (Language::Implicit, _) => true, // Implicit can be coerced to anything
99            (a, b) => a == b,                // Otherwise must match exactly
100        }
101    }
102}
103
104/// Hint for serialization: which syntax was used to parse this text.
105///
106/// This hint allows round-tripping to preserve the original syntax when possible.
107/// The generic variants (`Inline`, `Block`) let the serializer pick the best syntax
108/// when the exact form doesn't matter.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub enum SyntaxHint {
111    /// String syntax: `"..."`
112    Quoted,
113    /// Generic inline code (serializer picks between Inline1/Inline2)
114    Inline,
115    /// Single backtick inline: `` `...` ``
116    Inline1,
117    /// Double backtick inline: ``` ``...`` ```
118    Inline2,
119    /// Generic block code (serializer picks backtick count)
120    Block,
121    /// Triple backtick block: ```` ```...``` ````
122    Block3,
123    /// Quadruple backtick block: ````` ````...```` `````
124    Block4,
125    /// Quintuple backtick block
126    Block5,
127    /// Sextuple backtick block
128    Block6,
129}
130
131impl SyntaxHint {
132    /// Returns true if this is a quoted string syntax.
133    pub fn is_quoted(&self) -> bool {
134        matches!(self, SyntaxHint::Quoted)
135    }
136
137    /// Returns true if this is any inline code syntax.
138    pub fn is_inline(&self) -> bool {
139        matches!(
140            self,
141            SyntaxHint::Inline | SyntaxHint::Inline1 | SyntaxHint::Inline2
142        )
143    }
144
145    /// Returns true if this is any block code syntax.
146    pub fn is_block(&self) -> bool {
147        matches!(
148            self,
149            SyntaxHint::Block
150                | SyntaxHint::Block3
151                | SyntaxHint::Block4
152                | SyntaxHint::Block5
153                | SyntaxHint::Block6
154        )
155    }
156}
157
158/// A text value in Eure, unifying strings and code.
159///
160/// # Overview
161///
162/// `Text` represents all text values in Eure, regardless of whether they were
163/// written using string syntax (`"..."`) or code syntax (`` `...` ``). This
164/// unification simplifies the data model while preserving the semantic distinction
165/// through the [`language`](Text::language) field.
166///
167/// # Syntax Mapping
168///
169/// | Syntax | Language | SyntaxHint |
170/// |--------|----------|------------|
171/// | `"hello"` | `Plaintext` | `Quoted` |
172/// | `` `hello` `` | `Implicit` | `Inline1` |
173/// | ``` ``hello`` ``` | `Implicit` | `Inline2` |
174/// | `` sql`SELECT` `` | `Other("sql")` | `Inline1` |
175/// | ```` ``` ```` (no lang) | `Implicit` | `Block3` |
176/// | ```` ```rust ```` | `Other("rust")` | `Block3` |
177///
178/// # Key Distinction
179///
180/// - `"..."` → `Plaintext` (explicit: "this is text, not code")
181/// - `` `...` `` without lang → `Implicit` (code, language inferred from schema)
182/// - `` lang`...` `` → `Other(lang)` (code with explicit language)
183#[derive(Debug, Clone, PartialEq)]
184pub struct Text {
185    /// The text content.
186    pub content: String,
187    /// The language tag for this text.
188    pub language: Language,
189    /// Hint for serialization about the original syntax.
190    pub syntax_hint: Option<SyntaxHint>,
191}
192
193impl Text {
194    /// Create a new text value.
195    pub fn new(content: impl Into<String>, language: Language) -> Self {
196        Self {
197            content: content.into(),
198            language,
199            syntax_hint: None,
200        }
201    }
202
203    /// Create a new text value with a syntax hint.
204    ///
205    /// For block syntax hints, automatically ensures trailing newline.
206    pub fn with_syntax_hint(
207        content: impl Into<String>,
208        language: Language,
209        syntax_hint: SyntaxHint,
210    ) -> Self {
211        let mut content = content.into();
212        if syntax_hint.is_block() && !content.ends_with('\n') {
213            content.push('\n');
214        }
215        Self {
216            content,
217            language,
218            syntax_hint: Some(syntax_hint),
219        }
220    }
221
222    /// Create a plaintext value (from `"..."` syntax).
223    pub fn plaintext(content: impl Into<String>) -> Self {
224        Self {
225            content: content.into(),
226            language: Language::Plaintext,
227            syntax_hint: Some(SyntaxHint::Quoted),
228        }
229    }
230
231    /// Create an inline code value with implicit language (from `` `...` `` syntax).
232    pub fn inline_implicit(content: impl Into<String>) -> Self {
233        Self {
234            content: content.into(),
235            language: Language::Implicit,
236            syntax_hint: Some(SyntaxHint::Inline1),
237        }
238    }
239
240    /// Create an inline code value with explicit language (from `` lang`...` `` syntax).
241    pub fn inline(content: impl Into<String>, language: impl Into<String>) -> Self {
242        Self {
243            content: content.into(),
244            language: Language::new(language),
245            syntax_hint: Some(SyntaxHint::Inline1),
246        }
247    }
248
249    /// Create a block code value with implicit language (from ```` ``` ```` syntax without lang).
250    pub fn block_implicit(content: impl Into<String>) -> Self {
251        let mut content = content.into();
252        if !content.ends_with('\n') {
253            content.push('\n');
254        }
255        Self {
256            content,
257            language: Language::Implicit,
258            syntax_hint: Some(SyntaxHint::Block3),
259        }
260    }
261
262    /// Create a block code value with explicit language.
263    pub fn block(content: impl Into<String>, language: impl Into<String>) -> Self {
264        let mut content = content.into();
265        if !content.ends_with('\n') {
266            content.push('\n');
267        }
268        Self {
269            content,
270            language: Language::new(language),
271            syntax_hint: Some(SyntaxHint::Block3),
272        }
273    }
274
275    /// Create a block code value without adding a trailing newline. This must be used only when performing convertion to eure from another data format.
276    pub fn block_without_trailing_newline(
277        content: impl Into<String>,
278        language: impl Into<String>,
279    ) -> Self {
280        Self {
281            content: content.into(),
282            language: Language::new(language),
283            syntax_hint: Some(SyntaxHint::Block3),
284        }
285    }
286
287    /// Returns the content as a string slice.
288    pub fn as_str(&self) -> &str {
289        &self.content
290    }
291}
292
293/// Errors that can occur when parsing text.
294#[derive(Debug, PartialEq, Clone, Error)]
295pub enum TextParseError {
296    /// Invalid escape sequence encountered.
297    #[error("Invalid escape sequence: {0}")]
298    InvalidEscapeSequence(char),
299    /// Unexpected end of string after escape character.
300    #[error("Invalid end of string after escape")]
301    InvalidEndOfStringAfterEscape,
302    /// Invalid Unicode code point in escape sequence.
303    #[error("Invalid unicode code point: {0}")]
304    InvalidUnicodeCodePoint(u32),
305    /// Newline found in text binding (only single line allowed).
306    #[error("Newline in text binding")]
307    NewlineInTextBinding,
308    /// Invalid indent in code block.
309    #[error(
310        "Invalid indent on code block at line {line}: actual {actual_indent} to be indented more than {expected_indent}"
311    )]
312    IndentError {
313        line: usize,
314        actual_indent: usize,
315        expected_indent: usize,
316    },
317}
318
319impl Text {
320    /// Parse a quoted string like `"hello world"` into a Text value.
321    ///
322    /// Handles escape sequences: `\\`, `\"`, `\'`, `\n`, `\r`, `\t`, `\0`, `\u{...}`.
323    pub fn parse_quoted_string(s: &str) -> Result<Self, TextParseError> {
324        let content = parse_escape_sequences(s)?;
325        Ok(Text::plaintext(content))
326    }
327
328    /// Parse a text binding content (after the colon) like `: hello world\n`.
329    ///
330    /// Strips trailing newline and trims whitespace.
331    pub fn parse_text_binding(s: &str) -> Result<Self, TextParseError> {
332        let stripped = s.strip_suffix('\n').unwrap_or(s);
333        let stripped = stripped.strip_suffix('\r').unwrap_or(stripped);
334        if stripped.contains(['\r', '\n']) {
335            return Err(TextParseError::NewlineInTextBinding);
336        }
337        let content = parse_escape_sequences(stripped.trim())?;
338        Ok(Text::plaintext(content))
339    }
340
341    /// Parse an indented code block, removing base indentation.
342    pub fn parse_indented_block(
343        language: Language,
344        content: String,
345        base_indent: usize,
346        syntax_hint: SyntaxHint,
347    ) -> Result<Self, TextParseError> {
348        let total_lines = content.lines().count();
349        let expected_whitespace_removals = base_indent * total_lines;
350        let mut result = String::with_capacity(content.len() - expected_whitespace_removals);
351
352        for (line_number, line) in content.lines().enumerate() {
353            // Empty lines (including whitespace-only lines) are allowed and don't need to match the indent
354            if line.trim_start().is_empty() {
355                result.push('\n');
356                continue;
357            }
358
359            let actual_indent = line
360                .chars()
361                .take_while(|c| *c == ' ')
362                .take(base_indent)
363                .count();
364            if actual_indent < base_indent {
365                return Err(TextParseError::IndentError {
366                    line: line_number + 1,
367                    actual_indent,
368                    expected_indent: base_indent,
369                });
370            }
371            // Remove the base indent from the line
372            result.push_str(&line[base_indent..]);
373            result.push('\n');
374        }
375
376        Ok(Self {
377            content: result,
378            language,
379            syntax_hint: Some(syntax_hint),
380        })
381    }
382}
383
384/// Parse escape sequences in a string.
385fn parse_escape_sequences(s: &str) -> Result<String, TextParseError> {
386    let mut result = String::with_capacity(s.len());
387    let mut chars = s.chars().peekable();
388
389    fn parse_unicode_escape(
390        chars: &mut Peekable<impl Iterator<Item = char>>,
391    ) -> Result<char, TextParseError> {
392        match chars.next() {
393            Some('{') => {}
394            Some(ch) => return Err(TextParseError::InvalidEscapeSequence(ch)),
395            None => return Err(TextParseError::InvalidEndOfStringAfterEscape),
396        }
397
398        let mut count = 0;
399        let mut code_point = 0;
400        while let Some(ch) = chars.peek()
401            && count < 6
402        // max 6 hex digits
403        {
404            if let Some(digit) = match ch {
405                '0'..='9' => Some(*ch as u32 - '0' as u32),
406                'a'..='f' => Some(*ch as u32 - 'a' as u32 + 10),
407                'A'..='F' => Some(*ch as u32 - 'A' as u32 + 10),
408                '_' | '-' => None,
409                _ => break,
410            } {
411                code_point = code_point * 16 + digit;
412                count += 1;
413            }
414            chars.next();
415        }
416
417        let Some(result) = core::char::from_u32(code_point) else {
418            return Err(TextParseError::InvalidUnicodeCodePoint(code_point));
419        };
420
421        match chars.next() {
422            Some('}') => {}
423            Some(ch) => return Err(TextParseError::InvalidEscapeSequence(ch)),
424            None => return Err(TextParseError::InvalidEndOfStringAfterEscape),
425        }
426
427        Ok(result)
428    }
429
430    while let Some(ch) = chars.next() {
431        match ch {
432            '\\' => match chars.next() {
433                Some('\\') => result.push('\\'),
434                Some('"') => result.push('"'),
435                Some('\'') => result.push('\''),
436                Some('n') => result.push('\n'),
437                Some('r') => result.push('\r'),
438                Some('t') => result.push('\t'),
439                Some('0') => result.push('\0'),
440                Some('u') => result.push(parse_unicode_escape(&mut chars)?),
441                Some(ch) => return Err(TextParseError::InvalidEscapeSequence(ch)),
442                None => return Err(TextParseError::InvalidEndOfStringAfterEscape),
443            },
444            _ => result.push(ch),
445        }
446    }
447
448    Ok(result)
449}
450
451// Re-export for backwards compatibility during transition
452pub use TextParseError as EureStringError;
453
454/// Backwards-compatible type alias for EureString.
455///
456/// **Deprecated**: Use [`Text`] instead.
457pub type EureString = Cow<'static, str>;
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_language_new_plaintext() {
465        assert_eq!(Language::new("plaintext"), Language::Plaintext);
466        assert_eq!(Language::new(""), Language::Plaintext);
467    }
468
469    #[test]
470    fn test_language_new_other() {
471        assert_eq!(Language::new("rust"), Language::Other("rust".into()));
472        assert_eq!(Language::new("sql"), Language::Other("sql".into()));
473    }
474
475    #[test]
476    fn test_language_as_str() {
477        assert_eq!(Language::Plaintext.as_str(), Some("plaintext"));
478        assert_eq!(Language::Implicit.as_str(), None);
479        assert_eq!(Language::Other("rust".into()).as_str(), Some("rust"));
480    }
481
482    #[test]
483    fn test_language_compatibility() {
484        // Implicit is compatible with everything
485        assert!(Language::Implicit.is_compatible_with(&Language::Plaintext));
486        assert!(Language::Implicit.is_compatible_with(&Language::Other("rust".into())));
487
488        // Everything is compatible with Implicit expectation
489        assert!(Language::Plaintext.is_compatible_with(&Language::Implicit));
490        assert!(Language::Other("rust".into()).is_compatible_with(&Language::Implicit));
491
492        // Same languages are compatible
493        assert!(Language::Plaintext.is_compatible_with(&Language::Plaintext));
494        assert!(Language::Other("rust".into()).is_compatible_with(&Language::Other("rust".into())));
495
496        // Different explicit languages are not compatible
497        assert!(!Language::Plaintext.is_compatible_with(&Language::Other("rust".into())));
498        assert!(!Language::Other("rust".into()).is_compatible_with(&Language::Plaintext));
499        assert!(!Language::Other("rust".into()).is_compatible_with(&Language::Other("sql".into())));
500    }
501
502    #[test]
503    fn test_text_plaintext() {
504        let text = Text::plaintext("hello");
505        assert_eq!(text.content, "hello");
506        assert_eq!(text.language, Language::Plaintext);
507        assert_eq!(text.syntax_hint, Some(SyntaxHint::Quoted));
508    }
509
510    #[test]
511    fn test_text_inline_implicit() {
512        let text = Text::inline_implicit("let a = 1");
513        assert_eq!(text.content, "let a = 1");
514        assert_eq!(text.language, Language::Implicit);
515        assert_eq!(text.syntax_hint, Some(SyntaxHint::Inline1));
516    }
517
518    #[test]
519    fn test_text_inline_with_language() {
520        let text = Text::inline("SELECT *", "sql");
521        assert_eq!(text.content, "SELECT *");
522        assert_eq!(text.language, Language::Other("sql".into()));
523        assert_eq!(text.syntax_hint, Some(SyntaxHint::Inline1));
524    }
525
526    #[test]
527    fn test_text_block_implicit() {
528        let text = Text::block_implicit("fn main() {}");
529        assert_eq!(text.content, "fn main() {}\n");
530        assert_eq!(text.language, Language::Implicit);
531        assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
532    }
533
534    #[test]
535    fn test_text_block_with_language() {
536        let text = Text::block("fn main() {}", "rust");
537        assert_eq!(text.content, "fn main() {}\n");
538        assert_eq!(text.language, Language::Other("rust".into()));
539        assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
540    }
541
542    #[test]
543    fn test_parse_quoted_string() {
544        let text = Text::parse_quoted_string("hello\\nworld").unwrap();
545        assert_eq!(text.content, "hello\nworld");
546        assert_eq!(text.language, Language::Plaintext);
547    }
548
549    #[test]
550    fn test_parse_text_binding() {
551        let text = Text::parse_text_binding("  hello world  \n").unwrap();
552        assert_eq!(text.content, "hello world");
553        assert_eq!(text.language, Language::Plaintext);
554    }
555
556    #[test]
557    fn test_syntax_hint_is_quoted() {
558        assert!(SyntaxHint::Quoted.is_quoted());
559        assert!(!SyntaxHint::Inline1.is_quoted());
560        assert!(!SyntaxHint::Block3.is_quoted());
561    }
562
563    #[test]
564    fn test_syntax_hint_is_inline() {
565        assert!(SyntaxHint::Inline.is_inline());
566        assert!(SyntaxHint::Inline1.is_inline());
567        assert!(SyntaxHint::Inline2.is_inline());
568        assert!(!SyntaxHint::Quoted.is_inline());
569        assert!(!SyntaxHint::Block3.is_inline());
570    }
571
572    #[test]
573    fn test_syntax_hint_is_block() {
574        assert!(SyntaxHint::Block.is_block());
575        assert!(SyntaxHint::Block3.is_block());
576        assert!(SyntaxHint::Block4.is_block());
577        assert!(SyntaxHint::Block5.is_block());
578        assert!(SyntaxHint::Block6.is_block());
579        assert!(!SyntaxHint::Quoted.is_block());
580        assert!(!SyntaxHint::Inline1.is_block());
581    }
582
583    mod parse_indented_block_tests {
584        use super::*;
585        use alloc::string::ToString;
586
587        #[test]
588        fn test_parse_indented_block_single_line() {
589            let content = "    hello".to_string();
590            let result = Text::parse_indented_block(
591                Language::Other("text".into()),
592                content,
593                4,
594                SyntaxHint::Block3,
595            )
596            .unwrap();
597            assert_eq!(result.language, Language::Other("text".into()));
598            assert_eq!(result.content, "hello\n");
599        }
600
601        #[test]
602        fn test_parse_indented_block_multiple_lines() {
603            let content = "    line1\n    line2\n    line3".to_string();
604            let result = Text::parse_indented_block(
605                Language::Other("text".into()),
606                content,
607                4,
608                SyntaxHint::Block3,
609            )
610            .unwrap();
611            assert_eq!(result.content, "line1\nline2\nline3\n");
612        }
613
614        #[test]
615        fn test_parse_indented_block_with_empty_lines() {
616            let content = "    line1\n    \n    line2".to_string();
617            let result = Text::parse_indented_block(
618                Language::Other("text".into()),
619                content,
620                4,
621                SyntaxHint::Block3,
622            )
623            .unwrap();
624            assert_eq!(result.content, "line1\n\nline2\n");
625        }
626
627        #[test]
628        fn test_parse_indented_block_whitespace_only_line() {
629            let content = "    line1\n        \n    line2".to_string();
630            let result = Text::parse_indented_block(
631                Language::Other("text".into()),
632                content,
633                3,
634                SyntaxHint::Block3,
635            )
636            .unwrap();
637            assert_eq!(result.content, " line1\n\n line2\n");
638        }
639
640        #[test]
641        fn test_parse_indented_block_empty_content() {
642            let content = "    ".to_string();
643            let result = Text::parse_indented_block(
644                Language::Other("text".into()),
645                content,
646                4,
647                SyntaxHint::Block3,
648            )
649            .unwrap();
650            assert_eq!(result.content, "\n");
651        }
652
653        #[test]
654        fn test_parse_indented_block_implicit_language() {
655            let content = "    hello".to_string();
656            let result =
657                Text::parse_indented_block(Language::Implicit, content, 4, SyntaxHint::Block3)
658                    .unwrap();
659            assert_eq!(result.language, Language::Implicit);
660            assert_eq!(result.content, "hello\n");
661        }
662
663        #[test]
664        fn test_parse_indented_block_insufficient_indent() {
665            let content = "    line1\n  line2".to_string();
666            let result = Text::parse_indented_block(
667                Language::Other("text".into()),
668                content,
669                4,
670                SyntaxHint::Block3,
671            );
672            assert_eq!(
673                result,
674                Err(TextParseError::IndentError {
675                    line: 2,
676                    actual_indent: 2,
677                    expected_indent: 4,
678                })
679            );
680        }
681
682        #[test]
683        fn test_parse_indented_block_no_indent() {
684            let content = "line1\n    line2".to_string();
685            let result = Text::parse_indented_block(
686                Language::Other("text".into()),
687                content,
688                4,
689                SyntaxHint::Block3,
690            );
691            assert_eq!(
692                result,
693                Err(TextParseError::IndentError {
694                    line: 1,
695                    actual_indent: 0,
696                    expected_indent: 4,
697                })
698            );
699        }
700
701        #[test]
702        fn test_parse_indented_block_empty_string() {
703            let content = String::new();
704            let result = Text::parse_indented_block(
705                Language::Other("text".into()),
706                content,
707                4,
708                SyntaxHint::Block3,
709            );
710            assert!(result.is_ok());
711        }
712
713        #[test]
714        fn test_parse_indented_block_zero_indent() {
715            let content = "line1\nline2".to_string();
716            let result = Text::parse_indented_block(
717                Language::Other("text".into()),
718                content,
719                0,
720                SyntaxHint::Block3,
721            )
722            .unwrap();
723            assert_eq!(result.content, "line1\nline2\n");
724        }
725
726        #[test]
727        fn test_parse_indented_block_empty_line_only() {
728            let content = "    \n    ".to_string();
729            let result = Text::parse_indented_block(
730                Language::Other("text".into()),
731                content,
732                4,
733                SyntaxHint::Block3,
734            )
735            .unwrap();
736            assert_eq!(result.content, "\n\n");
737        }
738
739        #[test]
740        fn test_parse_indented_block_whitespace_only_line_insufficient_indent() {
741            let content = "    line1\n  \n    line2".to_string();
742            let result = Text::parse_indented_block(
743                Language::Other("text".into()),
744                content,
745                4,
746                SyntaxHint::Block3,
747            )
748            .unwrap();
749            // Whitespace-only lines are treated as empty and don't need to match indent
750            assert_eq!(result.content, "line1\n\nline2\n");
751        }
752
753        #[test]
754        fn test_parse_indented_block_whitespace_only_line_no_indent() {
755            let content = "    line1\n\n    line2".to_string();
756            let result = Text::parse_indented_block(
757                Language::Other("text".into()),
758                content,
759                3,
760                SyntaxHint::Block3,
761            )
762            .unwrap();
763            // Empty line (no whitespace) should be preserved
764            assert_eq!(result.content, " line1\n\n line2\n");
765        }
766    }
767}