Skip to main content

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