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