use alloc::{borrow::Cow, string::String, vec::Vec};
use core::iter::Peekable;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum Language {
#[default]
Plaintext,
Implicit,
Other(Cow<'static, str>),
}
impl Language {
pub fn new(s: impl Into<Cow<'static, str>>) -> Self {
let s = s.into();
if s == "plaintext" || s.is_empty() {
Language::Plaintext
} else {
Language::Other(s)
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Language::Plaintext => Some("plaintext"),
Language::Implicit => None,
Language::Other(s) => Some(s.as_ref()),
}
}
pub fn is_plaintext(&self) -> bool {
matches!(self, Language::Plaintext)
}
pub fn is_implicit(&self) -> bool {
matches!(self, Language::Implicit)
}
pub fn is_compatible_with(&self, expected: &Language) -> bool {
match (self, expected) {
(_, Language::Implicit) => true, (Language::Implicit, _) => true, (a, b) => a == b, }
}
pub fn is_other(&self, arg: &str) -> bool {
match self {
Language::Other(s) => s == arg,
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SyntaxHint {
Str,
LitStr,
LitStr1,
LitStr2,
LitStr3,
Inline,
Inline1,
Delim1,
Delim2,
Delim3,
Block,
Block3,
Block4,
Block5,
Block6,
}
impl SyntaxHint {
pub fn is_string(&self) -> bool {
matches!(
self,
SyntaxHint::Str
| SyntaxHint::LitStr
| SyntaxHint::LitStr1
| SyntaxHint::LitStr2
| SyntaxHint::LitStr3
)
}
pub fn is_escaped_string(&self) -> bool {
matches!(self, SyntaxHint::Str)
}
pub fn is_literal_string(&self) -> bool {
matches!(
self,
SyntaxHint::LitStr | SyntaxHint::LitStr1 | SyntaxHint::LitStr2 | SyntaxHint::LitStr3
)
}
pub fn is_inline(&self) -> bool {
matches!(
self,
SyntaxHint::Inline
| SyntaxHint::Inline1
| SyntaxHint::Delim1
| SyntaxHint::Delim2
| SyntaxHint::Delim3
)
}
pub fn is_block(&self) -> bool {
matches!(
self,
SyntaxHint::Block
| SyntaxHint::Block3
| SyntaxHint::Block4
| SyntaxHint::Block5
| SyntaxHint::Block6
)
}
}
#[derive(Debug, Clone)]
pub struct Text {
pub content: String,
pub language: Language,
pub syntax_hint: Option<SyntaxHint>,
}
impl PartialEq for Text {
fn eq(&self, other: &Self) -> bool {
self.content == other.content && self.language == other.language
}
}
impl Text {
pub fn new(content: impl Into<String>, language: Language) -> Self {
Self {
content: content.into(),
language,
syntax_hint: None,
}
}
pub fn with_syntax_hint(
content: impl Into<String>,
language: Language,
syntax_hint: SyntaxHint,
) -> Self {
let mut content = content.into();
if syntax_hint.is_block() && !content.ends_with('\n') {
content.push('\n');
}
Self {
content,
language,
syntax_hint: Some(syntax_hint),
}
}
pub fn plaintext(content: impl Into<String>) -> Self {
Self {
content: content.into(),
language: Language::Plaintext,
syntax_hint: Some(SyntaxHint::Str),
}
}
pub fn inline_implicit(content: impl Into<String>) -> Self {
Self {
content: content.into(),
language: Language::Implicit,
syntax_hint: Some(SyntaxHint::Inline1),
}
}
pub fn inline(content: impl Into<String>, language: impl Into<Cow<'static, str>>) -> Self {
Self {
content: content.into(),
language: Language::new(language),
syntax_hint: Some(SyntaxHint::Inline1),
}
}
pub fn block_implicit(content: impl Into<String>) -> Self {
let mut content = content.into();
if !content.ends_with('\n') {
content.push('\n');
}
Self {
content,
language: Language::Implicit,
syntax_hint: Some(SyntaxHint::Block3),
}
}
pub fn block(content: impl Into<String>, language: impl Into<Cow<'static, str>>) -> Self {
let mut content = content.into();
if !content.ends_with('\n') {
content.push('\n');
}
Self {
content,
language: Language::new(language),
syntax_hint: Some(SyntaxHint::Block3),
}
}
pub fn block_without_trailing_newline(
content: impl Into<String>,
language: impl Into<Cow<'static, str>>,
) -> Self {
Self {
content: content.into(),
language: Language::new(language),
syntax_hint: Some(SyntaxHint::Block3),
}
}
pub fn as_str(&self) -> &str {
&self.content
}
}
#[derive(Debug, PartialEq, Eq, Clone, Error)]
pub enum TextParseError {
#[error("Invalid escape sequence: {0}")]
InvalidEscapeSequence(char),
#[error("Invalid end of string after escape")]
InvalidEndOfStringAfterEscape,
#[error("Invalid unicode code point: {0}")]
InvalidUnicodeCodePoint(u32),
#[error("Newline in text binding")]
NewlineInTextBinding,
#[error(
"Invalid indent on code block at line {line}: actual {actual_indent} to be indented more than {expected_indent}"
)]
IndentError {
line: usize,
actual_indent: usize,
expected_indent: usize,
},
}
impl Text {
pub fn parse_quoted_string(s: &str) -> Result<Self, TextParseError> {
let content = parse_escape_sequences(s)?;
Ok(Text::plaintext(content))
}
pub fn parse_text_binding(s: &str) -> Result<Self, TextParseError> {
let stripped = s.strip_suffix('\n').unwrap_or(s);
let stripped = stripped.strip_suffix('\r').unwrap_or(stripped);
if stripped.contains(['\r', '\n']) {
return Err(TextParseError::NewlineInTextBinding);
}
let content = String::from(stripped.trim());
Ok(Text::plaintext(content))
}
pub fn parse_indented_block(
language: Language,
content: String,
syntax_hint: SyntaxHint,
) -> Result<Self, TextParseError> {
let base_indent = if let Some(last_newline_pos) = content.rfind('\n') {
let trailing = &content[last_newline_pos + 1..];
if trailing.chars().all(|c| c == ' ') {
trailing.len()
} else {
0
}
} else {
0
};
let lines: Vec<&str> = content.lines().collect();
let line_count = if base_indent > 0 && !content.ends_with('\n') && lines.len() > 1 {
lines.len() - 1
} else {
lines.len()
};
let expected_whitespace_removals = base_indent * line_count;
let mut result = String::with_capacity(content.len() - expected_whitespace_removals);
for (line_number, line) in lines.iter().take(line_count).enumerate() {
if line.trim_start().is_empty() {
result.push('\n');
continue;
}
let actual_indent = line
.chars()
.take_while(|c| *c == ' ')
.take(base_indent)
.count();
if actual_indent < base_indent {
return Err(TextParseError::IndentError {
line: line_number + 1,
actual_indent,
expected_indent: base_indent,
});
}
result.push_str(&line[base_indent..]);
result.push('\n');
}
Ok(Self {
content: result,
language,
syntax_hint: Some(syntax_hint),
})
}
}
fn parse_escape_sequences(s: &str) -> Result<String, TextParseError> {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
fn parse_unicode_escape(
chars: &mut Peekable<impl Iterator<Item = char>>,
) -> Result<char, TextParseError> {
match chars.next() {
Some('{') => {}
Some(ch) => return Err(TextParseError::InvalidEscapeSequence(ch)),
None => return Err(TextParseError::InvalidEndOfStringAfterEscape),
}
let mut count = 0;
let mut code_point = 0;
while let Some(ch) = chars.peek()
&& count < 6
{
if let Some(digit) = match ch {
'0'..='9' => Some(*ch as u32 - '0' as u32),
'a'..='f' => Some(*ch as u32 - 'a' as u32 + 10),
'A'..='F' => Some(*ch as u32 - 'A' as u32 + 10),
'_' | '-' => None,
_ => break,
} {
code_point = code_point * 16 + digit;
count += 1;
}
chars.next();
}
let Some(result) = core::char::from_u32(code_point) else {
return Err(TextParseError::InvalidUnicodeCodePoint(code_point));
};
match chars.next() {
Some('}') => {}
Some(ch) => return Err(TextParseError::InvalidEscapeSequence(ch)),
None => return Err(TextParseError::InvalidEndOfStringAfterEscape),
}
Ok(result)
}
while let Some(ch) = chars.next() {
match ch {
'\\' => match chars.next() {
Some('\\') => result.push('\\'),
Some('"') => result.push('"'),
Some('\'') => result.push('\''),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('0') => result.push('\0'),
Some('u') => result.push(parse_unicode_escape(&mut chars)?),
Some(ch) => return Err(TextParseError::InvalidEscapeSequence(ch)),
None => return Err(TextParseError::InvalidEndOfStringAfterEscape),
},
_ => result.push(ch),
}
}
Ok(result)
}
pub use TextParseError as EureStringError;
pub type EureString = Cow<'static, str>;
#[cfg(test)]
mod tests {
extern crate alloc;
use super::*;
use alloc::format;
#[test]
fn test_language_new_plaintext() {
assert_eq!(Language::new("plaintext"), Language::Plaintext);
assert_eq!(Language::new(""), Language::Plaintext);
}
#[test]
fn test_language_new_other() {
assert_eq!(Language::new("rust"), Language::Other("rust".into()));
assert_eq!(Language::new("sql"), Language::Other("sql".into()));
}
#[test]
fn test_language_as_str() {
assert_eq!(Language::Plaintext.as_str(), Some("plaintext"));
assert_eq!(Language::Implicit.as_str(), None);
assert_eq!(Language::Other("rust".into()).as_str(), Some("rust"));
}
#[test]
fn test_language_compatibility() {
assert!(Language::Implicit.is_compatible_with(&Language::Plaintext));
assert!(Language::Implicit.is_compatible_with(&Language::Other("rust".into())));
assert!(Language::Plaintext.is_compatible_with(&Language::Implicit));
assert!(Language::Other("rust".into()).is_compatible_with(&Language::Implicit));
assert!(Language::Plaintext.is_compatible_with(&Language::Plaintext));
assert!(Language::Other("rust".into()).is_compatible_with(&Language::Other("rust".into())));
assert!(!Language::Plaintext.is_compatible_with(&Language::Other("rust".into())));
assert!(!Language::Other("rust".into()).is_compatible_with(&Language::Plaintext));
assert!(!Language::Other("rust".into()).is_compatible_with(&Language::Other("sql".into())));
}
#[test]
fn test_text_plaintext() {
let text = Text::plaintext("hello");
assert_eq!(text.content, "hello");
assert_eq!(text.language, Language::Plaintext);
assert_eq!(text.syntax_hint, Some(SyntaxHint::Str));
}
#[test]
fn test_text_inline_implicit() {
let text = Text::inline_implicit("let a = 1");
assert_eq!(text.content, "let a = 1");
assert_eq!(text.language, Language::Implicit);
assert_eq!(text.syntax_hint, Some(SyntaxHint::Inline1));
}
#[test]
fn test_text_inline_with_language() {
let text = Text::inline("SELECT *", "sql");
assert_eq!(text.content, "SELECT *");
assert_eq!(text.language, Language::Other("sql".into()));
assert_eq!(text.syntax_hint, Some(SyntaxHint::Inline1));
}
#[test]
fn test_text_block_implicit() {
let text = Text::block_implicit("fn main() {}");
assert_eq!(text.content, "fn main() {}\n");
assert_eq!(text.language, Language::Implicit);
assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
}
#[test]
fn test_text_block_with_language() {
let text = Text::block("fn main() {}", "rust");
assert_eq!(text.content, "fn main() {}\n");
assert_eq!(text.language, Language::Other("rust".into()));
assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
}
#[test]
fn test_parse_quoted_string() {
let text = Text::parse_quoted_string("hello\\nworld").unwrap();
assert_eq!(text.content, "hello\nworld");
assert_eq!(text.language, Language::Plaintext);
}
#[test]
fn test_parse_text_binding() {
let text = Text::parse_text_binding(" hello world \n").unwrap();
assert_eq!(text.content, "hello world");
assert_eq!(text.language, Language::Plaintext);
}
#[test]
fn test_parse_text_binding_raw_backslashes() {
let text = Text::parse_text_binding(" \\b\\w+\\b \n").unwrap();
assert_eq!(text.content, "\\b\\w+\\b");
assert_eq!(text.language, Language::Plaintext);
}
#[test]
fn test_parse_text_binding_literal_backslash_n() {
let text = Text::parse_text_binding(" line1\\nline2 \n").unwrap();
assert_eq!(text.content, "line1\\nline2");
assert_eq!(text.language, Language::Plaintext);
}
#[test]
fn test_parse_text_binding_windows_path() {
let text = Text::parse_text_binding(" C:\\Users\\name\\file.txt \n").unwrap();
assert_eq!(text.content, "C:\\Users\\name\\file.txt");
}
#[test]
fn test_parse_text_binding_double_backslash() {
let text = Text::parse_text_binding(" \\\\ \n").unwrap();
assert_eq!(text.content, "\\\\");
}
#[test]
fn test_syntax_hint_is_string() {
assert!(SyntaxHint::Str.is_string());
assert!(SyntaxHint::LitStr.is_string());
assert!(SyntaxHint::LitStr1.is_string());
assert!(SyntaxHint::LitStr2.is_string());
assert!(SyntaxHint::LitStr3.is_string());
assert!(!SyntaxHint::Inline1.is_string());
assert!(!SyntaxHint::Block3.is_string());
}
#[test]
fn test_syntax_hint_is_escaped_string() {
assert!(SyntaxHint::Str.is_escaped_string());
assert!(!SyntaxHint::LitStr.is_escaped_string());
assert!(!SyntaxHint::Inline1.is_escaped_string());
}
#[test]
fn test_syntax_hint_is_literal_string() {
assert!(SyntaxHint::LitStr.is_literal_string());
assert!(SyntaxHint::LitStr1.is_literal_string());
assert!(SyntaxHint::LitStr2.is_literal_string());
assert!(SyntaxHint::LitStr3.is_literal_string());
assert!(!SyntaxHint::Str.is_literal_string());
assert!(!SyntaxHint::Inline1.is_literal_string());
}
#[test]
fn test_syntax_hint_is_inline() {
assert!(SyntaxHint::Inline.is_inline());
assert!(SyntaxHint::Inline1.is_inline());
assert!(SyntaxHint::Delim1.is_inline());
assert!(SyntaxHint::Delim2.is_inline());
assert!(SyntaxHint::Delim3.is_inline());
assert!(!SyntaxHint::Str.is_inline());
assert!(!SyntaxHint::Block3.is_inline());
}
#[test]
fn test_syntax_hint_is_block() {
assert!(SyntaxHint::Block.is_block());
assert!(SyntaxHint::Block3.is_block());
assert!(SyntaxHint::Block4.is_block());
assert!(SyntaxHint::Block5.is_block());
assert!(SyntaxHint::Block6.is_block());
assert!(!SyntaxHint::Str.is_block());
assert!(!SyntaxHint::Inline1.is_block());
}
mod parse_indented_block_tests {
use super::*;
use alloc::string::ToString;
#[test]
fn test_parse_indented_block_single_line() {
let content = " hello\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.language, Language::Other("text".into()));
assert_eq!(result.content, "hello\n");
}
#[test]
fn test_parse_indented_block_multiple_lines() {
let content = " line1\n line2\n line3\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, "line1\nline2\nline3\n");
}
#[test]
fn test_parse_indented_block_with_empty_lines() {
let content = " line1\n\n line2\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, "line1\n\nline2\n");
}
#[test]
fn test_parse_indented_block_whitespace_only_line() {
let content = " line1\n \n line2\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, " line1\n\n line2\n");
}
#[test]
fn test_parse_indented_block_empty_content() {
let content = " ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, "\n");
}
#[test]
fn test_parse_indented_block_implicit_language() {
let content = " hello\n ".to_string();
let result =
Text::parse_indented_block(Language::Implicit, content, SyntaxHint::Block3)
.unwrap();
assert_eq!(result.language, Language::Implicit);
assert_eq!(result.content, "hello\n");
}
#[test]
fn test_parse_indented_block_insufficient_indent() {
let content = " line1\n line2\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
);
assert_eq!(
result,
Err(TextParseError::IndentError {
line: 2,
actual_indent: 2,
expected_indent: 4,
})
);
}
#[test]
fn test_parse_indented_block_no_indent() {
let content = "line1\n line2\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
);
assert_eq!(
result,
Err(TextParseError::IndentError {
line: 1,
actual_indent: 0,
expected_indent: 4,
})
);
}
#[test]
fn test_parse_indented_block_empty_string() {
let content = String::new();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
);
assert!(result.is_ok());
}
#[test]
fn test_parse_indented_block_zero_indent() {
let content = "line1\nline2\n".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, "line1\nline2\n");
}
#[test]
fn test_parse_indented_block_empty_line_only() {
let content = " \n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, "\n");
}
#[test]
fn test_parse_indented_block_whitespace_only_line_insufficient_indent() {
let content = " line1\n \n line2\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, "line1\n\nline2\n");
}
#[test]
fn test_parse_indented_block_whitespace_only_line_no_indent() {
let content = " line1\n\n line2\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
)
.unwrap();
assert_eq!(result.content, " line1\n\n line2\n");
}
#[test]
fn test_parse_quoted_string_escape_sequences() {
let cases = [
("\\n", "\n"),
("\\r", "\r"),
("\\t", "\t"),
("\\0", "\0"),
("\\\\", "\\"),
("\\\"", "\""),
("\\'", "'"),
("\\u{0041}", "A"),
("\\u{3042}", "あ"),
];
for (input, expected) in cases {
let result = Text::parse_quoted_string(input);
assert!(result.is_ok(), "Failed to parse: {:?}", input);
assert_eq!(
result.unwrap().content,
expected,
"Mismatch for: {:?}",
input
);
}
}
#[test]
fn test_parse_quoted_string_invalid_unicode_escapes() {
let result = Text::parse_quoted_string("\\u{0041");
assert!(result.is_err(), "Should fail for missing closing brace");
let result = Text::parse_quoted_string("\\u{ZZZZ}");
assert!(result.is_err(), "Should fail for invalid hex");
let result = Text::parse_quoted_string("\\u{110000}");
assert!(result.is_err(), "Should fail for out of range codepoint");
let result = Text::parse_quoted_string("\\u0041}");
assert!(result.is_err(), "Should fail for missing opening brace");
}
#[test]
fn test_parse_text_binding_preserves_backslashes() {
let inputs = [
("\\n", "\\n"),
("\\t", "\\t"),
("C:\\Users\\test", "C:\\Users\\test"),
("\\b\\w+\\b", "\\b\\w+\\b"),
];
for (input, expected) in inputs {
let with_newline = format!("{}\n", input);
let result = Text::parse_text_binding(&with_newline);
assert!(result.is_ok(), "Failed to parse: {:?}", input);
assert_eq!(result.unwrap().content, expected);
}
}
#[test]
fn test_parse_text_binding_trims_tabs_and_mixed_whitespace() {
let result = Text::parse_text_binding("\thello\t\n");
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "hello");
let result = Text::parse_text_binding(" \thello\t \n");
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "hello");
let result = Text::parse_text_binding("\t\thello world\t\t\n");
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "hello world");
}
#[test]
fn test_language_new_plaintext_variants() {
assert_eq!(Language::new("plaintext"), Language::Plaintext);
assert_eq!(Language::new(""), Language::Plaintext);
}
#[test]
fn test_empty_content_handling() {
let text = Text::plaintext("");
assert_eq!(text.content, "");
let text = Text::inline_implicit("");
assert_eq!(text.content, "");
let text = Text::block_implicit("");
assert_eq!(text.content, "\n");
let text = Text::block("", "rust");
assert_eq!(text.content, "\n"); }
#[test]
fn test_parse_indented_block_with_tabs() {
let content = "\tline1\n\tline2\n\t".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
);
assert!(result.is_ok() || result.is_err());
let content = " line\twith\ttabs\n ".to_string();
let result = Text::parse_indented_block(
Language::Other("text".into()),
content,
SyntaxHint::Block3,
);
assert!(result.is_ok());
let text = result.unwrap();
assert_eq!(text.content, "line\twith\ttabs\n");
}
}
}
#[cfg(test)]
mod proptests {
extern crate std;
use super::*;
use alloc::vec;
use proptest::prelude::*;
use std::format;
use std::string::String;
use std::vec::Vec;
fn arb_language() -> impl Strategy<Value = Language> {
prop_oneof![
Just(Language::Plaintext),
Just(Language::Implicit),
Just(Language::Other("rust".into())),
Just(Language::Other("sql".into())),
Just(Language::Other("python".into())),
Just(Language::Other("javascript".into())),
"[a-z][a-z0-9_-]{0,15}".prop_map(|s| Language::Other(s.into())),
]
}
fn arb_syntax_hint() -> impl Strategy<Value = SyntaxHint> {
prop_oneof![
Just(SyntaxHint::Str),
Just(SyntaxHint::LitStr),
Just(SyntaxHint::LitStr1),
Just(SyntaxHint::LitStr2),
Just(SyntaxHint::LitStr3),
Just(SyntaxHint::Inline),
Just(SyntaxHint::Inline1),
Just(SyntaxHint::Delim1),
Just(SyntaxHint::Delim2),
Just(SyntaxHint::Delim3),
Just(SyntaxHint::Block),
Just(SyntaxHint::Block3),
Just(SyntaxHint::Block4),
Just(SyntaxHint::Block5),
Just(SyntaxHint::Block6),
]
}
fn arb_text_content() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
prop::char::range(' ', '~'),
Just('日'),
Just('本'),
Just('語'),
Just('α'),
Just('β'),
Just('γ'),
Just('é'),
Just('ñ'),
Just('ü'),
],
0..100,
)
.prop_map(|chars| chars.into_iter().collect())
}
fn arb_simple_text_content() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
prop::char::range(' ', '!'), prop::char::range('#', '&'), prop::char::range('(', '['), prop::char::range(']', '~'), ],
0..50,
)
.prop_map(|chars| chars.into_iter().collect())
}
fn arb_single_line_content() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
prop::char::range(' ', '~'),
],
0..50,
)
.prop_map(|chars| chars.into_iter().collect())
}
proptest! {
#[test]
fn plaintext_constructor_sets_correct_fields(content in arb_text_content()) {
let text = Text::plaintext(content.clone());
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, Language::Plaintext);
prop_assert_eq!(text.syntax_hint, Some(SyntaxHint::Str));
}
#[test]
fn inline_implicit_constructor_sets_correct_fields(content in arb_text_content()) {
let text = Text::inline_implicit(content.clone());
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, Language::Implicit);
prop_assert_eq!(text.syntax_hint, Some(SyntaxHint::Inline1));
}
#[test]
fn inline_constructor_sets_correct_fields(
content in arb_text_content(),
lang in "[a-z][a-z0-9]{0,10}",
) {
let text = Text::inline(content.clone(), lang.clone());
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, Language::new(lang));
prop_assert_eq!(text.syntax_hint, Some(SyntaxHint::Inline1));
}
#[test]
fn block_implicit_adds_trailing_newline(content in arb_text_content()) {
let text = Text::block_implicit(content.clone());
prop_assert!(text.content.ends_with('\n'), "Block content should end with newline");
prop_assert_eq!(text.language, Language::Implicit);
prop_assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
}
#[test]
fn block_implicit_no_double_newline(content in arb_text_content()) {
let content_with_newline = format!("{}\n", content);
let text = Text::block_implicit(content_with_newline.clone());
prop_assert_eq!(&text.content, &content_with_newline);
prop_assert!(!text.content.ends_with("\n\n") || content.ends_with('\n'),
"Should not add extra newline when already present");
}
#[test]
fn block_adds_trailing_newline(
content in arb_text_content(),
lang in "[a-z][a-z0-9]{0,10}",
) {
let text = Text::block(content.clone(), lang.clone());
prop_assert!(text.content.ends_with('\n'), "Block content should end with newline");
prop_assert_eq!(text.language, Language::new(lang));
prop_assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
}
#[test]
fn block_without_trailing_newline_preserves_content(
content in arb_text_content(),
lang in "[a-z][a-z0-9]{0,10}",
) {
let text = Text::block_without_trailing_newline(content.clone(), lang.clone());
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, Language::new(lang));
prop_assert_eq!(text.syntax_hint, Some(SyntaxHint::Block3));
}
#[test]
fn new_preserves_content(
content in arb_text_content(),
language in arb_language(),
) {
let text = Text::new(content.clone(), language.clone());
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, language);
prop_assert_eq!(text.syntax_hint, None);
}
#[test]
fn with_syntax_hint_adds_newline_for_block(
content in arb_text_content(),
language in arb_language(),
hint in prop_oneof![
Just(SyntaxHint::Block),
Just(SyntaxHint::Block3),
Just(SyntaxHint::Block4),
Just(SyntaxHint::Block5),
Just(SyntaxHint::Block6),
],
) {
let text = Text::with_syntax_hint(content.clone(), language.clone(), hint);
prop_assert!(text.content.ends_with('\n'), "Block content should end with newline");
prop_assert_eq!(text.language, language);
prop_assert_eq!(text.syntax_hint, Some(hint));
}
#[test]
fn with_syntax_hint_preserves_content_for_non_block(
content in arb_text_content(),
language in arb_language(),
hint in prop_oneof![
Just(SyntaxHint::Str),
Just(SyntaxHint::LitStr),
Just(SyntaxHint::Inline1),
Just(SyntaxHint::Delim1),
],
) {
let text = Text::with_syntax_hint(content.clone(), language.clone(), hint);
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, language);
prop_assert_eq!(text.syntax_hint, Some(hint));
}
}
proptest! {
#[test]
fn equality_ignores_syntax_hint(
content in arb_text_content(),
language in arb_language(),
hint1 in arb_syntax_hint(),
hint2 in arb_syntax_hint(),
) {
let text1 = Text {
content: content.clone(),
language: language.clone(),
syntax_hint: Some(hint1),
};
let text2 = Text {
content: content.clone(),
language: language.clone(),
syntax_hint: Some(hint2),
};
prop_assert_eq!(text1, text2, "Equality should ignore syntax_hint");
}
#[test]
fn equality_compares_content(
content1 in arb_text_content(),
content2 in arb_text_content(),
language in arb_language(),
) {
let text1 = Text::new(content1.clone(), language.clone());
let text2 = Text::new(content2.clone(), language.clone());
if content1 == content2 {
prop_assert_eq!(text1, text2);
} else {
prop_assert_ne!(text1, text2);
}
}
#[test]
fn equality_compares_language(
content in arb_text_content(),
lang1 in arb_language(),
lang2 in arb_language(),
) {
let text1 = Text::new(content.clone(), lang1.clone());
let text2 = Text::new(content.clone(), lang2.clone());
if lang1 == lang2 {
prop_assert_eq!(text1, text2);
} else {
prop_assert_ne!(text1, text2);
}
}
}
proptest! {
#[test]
fn language_new_other(lang in "[a-z][a-z0-9]{1,15}") {
if lang != "plaintext" {
let result = Language::new(lang.clone());
prop_assert_eq!(result, Language::Other(lang.into()));
}
}
#[test]
fn implicit_is_compatible_with_all(lang in arb_language()) {
prop_assert!(Language::Implicit.is_compatible_with(&lang),
"Implicit should be compatible with {:?}", lang);
}
#[test]
fn all_compatible_with_implicit(lang in arb_language()) {
prop_assert!(lang.is_compatible_with(&Language::Implicit),
"{:?} should be compatible with Implicit", lang);
}
#[test]
fn same_language_compatible(lang in arb_language()) {
prop_assert!(lang.is_compatible_with(&lang),
"{:?} should be compatible with itself", lang);
}
#[test]
fn language_as_str_correct(lang in arb_language()) {
match &lang {
Language::Plaintext => prop_assert_eq!(lang.as_str(), Some("plaintext")),
Language::Implicit => prop_assert_eq!(lang.as_str(), None),
Language::Other(s) => prop_assert_eq!(lang.as_str(), Some(s.as_ref())),
}
}
}
proptest! {
#[test]
fn syntax_hint_classification_consistency(hint in arb_syntax_hint()) {
let is_str = hint.is_string();
let is_inline = hint.is_inline();
let is_block = hint.is_block();
if is_inline {
prop_assert!(!is_str, "Inline hints should not be strings");
prop_assert!(!is_block, "Inline hints should not be blocks");
}
if is_block {
prop_assert!(!is_str, "Block hints should not be strings");
prop_assert!(!is_inline, "Block hints should not be inline");
}
if is_str {
prop_assert!(!is_inline, "String hints should not be inline");
prop_assert!(!is_block, "String hints should not be blocks");
}
}
#[test]
fn syntax_hint_belongs_to_one_category(hint in arb_syntax_hint()) {
let categories = [
hint.is_string(),
hint.is_inline(),
hint.is_block(),
];
let count = categories.iter().filter(|&&b| b).count();
prop_assert_eq!(count, 1, "Each hint should belong to exactly one category: {:?}", hint);
}
}
proptest! {
#[test]
fn parse_quoted_string_simple_roundtrip(content in arb_simple_text_content()) {
let text = Text::parse_quoted_string(&content);
prop_assert!(text.is_ok(), "Failed to parse simple content: {:?}", content);
let text = text.unwrap();
prop_assert_eq!(text.content, content);
prop_assert_eq!(text.language, Language::Plaintext);
}
#[test]
fn parse_quoted_string_invalid_escape(c in prop::char::range('a', 'z').prop_filter(
"not a valid escape",
|c| !matches!(*c, 'n' | 'r' | 't' | '0' | 'u')
)) {
let input = format!("\\{}", c);
let result = Text::parse_quoted_string(&input);
prop_assert!(result.is_err(), "Should fail for invalid escape: {:?}", input);
match result {
Err(TextParseError::InvalidEscapeSequence(ch)) => {
prop_assert_eq!(ch, c, "Error should report the invalid char");
}
other => {
prop_assert!(false, "Expected InvalidEscapeSequence, got {:?}", other);
}
}
}
#[test]
fn parse_text_binding_trims_correctly(
leading_space in "[ \t]{0,10}",
content in arb_single_line_content().prop_filter("no whitespace only", |s| !s.trim().is_empty()),
trailing_space in "[ \t]{0,10}",
) {
let input = format!("{}{}{}\n", leading_space, content, trailing_space);
let result = Text::parse_text_binding(&input);
prop_assert!(result.is_ok(), "Failed to parse: {:?}", input);
let text = result.unwrap();
prop_assert_eq!(text.content, content.trim());
prop_assert_eq!(text.language, Language::Plaintext);
}
#[test]
fn parse_text_binding_rejects_embedded_newlines(
before in arb_single_line_content(),
after in arb_single_line_content(),
) {
let input = format!("{}\n{}\n", before, after);
let result = Text::parse_text_binding(&input);
prop_assert!(matches!(result, Err(TextParseError::NewlineInTextBinding)),
"Should reject embedded newlines: {:?}", input);
}
#[test]
fn as_str_returns_content(content in arb_text_content(), language in arb_language()) {
let text = Text::new(content.clone(), language);
prop_assert_eq!(text.as_str(), content.as_str());
}
}
proptest! {
#[test]
fn parse_indented_block_removes_base_indent(
lines in proptest::collection::vec("[!-~]+", 1..10),
indent in 0usize..8,
) {
let indent_str: String = " ".repeat(indent);
let mut content = String::new();
for line in &lines {
content.push_str(&indent_str);
content.push_str(line);
content.push('\n');
}
content.push_str(&indent_str);
let result = Text::parse_indented_block(
Language::Implicit,
content,
SyntaxHint::Block3,
);
prop_assert!(result.is_ok(), "Failed to parse indented block");
let text = result.unwrap();
let result_lines: Vec<&str> = text.content.lines().collect();
prop_assert_eq!(result_lines.len(), lines.len(),
"Line count should match: {:?} vs {:?}", result_lines, lines);
for (i, (result_line, orig_line)) in result_lines.iter().zip(lines.iter()).enumerate() {
prop_assert_eq!(*result_line, orig_line.as_str(),
"Line {} should have indent removed", i);
}
}
#[test]
fn parse_indented_block_preserves_empty_lines(
line1 in arb_single_line_content(),
line2 in arb_single_line_content(),
indent in 2usize..6,
) {
let indent_str: String = " ".repeat(indent);
let content = format!(
"{}{}\n\n{}{}\n{}",
indent_str, line1,
indent_str, line2,
indent_str
);
let result = Text::parse_indented_block(
Language::Implicit,
content,
SyntaxHint::Block3,
);
prop_assert!(result.is_ok(), "Failed to parse");
let text = result.unwrap();
let expected_line1 = if line1.trim().is_empty() { "" } else { line1.as_str() };
let expected_line2 = if line2.trim().is_empty() { "" } else { line2.as_str() };
let expected = format!("{}\n\n{}\n", expected_line1, expected_line2);
prop_assert_eq!(text.content, expected);
}
#[test]
fn parse_indented_block_error_on_insufficient_indent(
line1 in arb_single_line_content().prop_filter("non-empty", |s| !s.is_empty()),
line2 in "[!-~]{1,20}", base_indent in 4usize..8,
bad_indent in 0usize..4,
) {
prop_assume!(bad_indent < base_indent);
let base_str: String = " ".repeat(base_indent);
let bad_str: String = " ".repeat(bad_indent);
let content = format!(
"{}{}\n{}{}\n{}",
base_str, line1,
bad_str, line2, base_str
);
let result = Text::parse_indented_block(
Language::Implicit,
content,
SyntaxHint::Block3,
);
match result {
Err(TextParseError::IndentError { line: 2, actual_indent, expected_indent }) => {
prop_assert_eq!(actual_indent, bad_indent);
prop_assert_eq!(expected_indent, base_indent);
}
other => {
prop_assert!(false, "Expected IndentError for line 2, got {:?}", other);
}
}
}
#[test]
fn parse_indented_block_zero_indent(lines in proptest::collection::vec("[!-~]+", 1..10)) {
let mut content = String::new();
for line in &lines {
content.push_str(line);
content.push('\n');
}
let result = Text::parse_indented_block(
Language::Other("test".into()),
content.clone(),
SyntaxHint::Block3,
);
prop_assert!(result.is_ok(), "Failed to parse zero-indent block");
let text = result.unwrap();
let expected_lines: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let result_lines: Vec<&str> = text.content.lines().collect();
prop_assert_eq!(result_lines, expected_lines);
}
#[test]
fn parse_indented_block_preserves_metadata(
line in arb_single_line_content(),
language in arb_language(),
hint in prop_oneof![
Just(SyntaxHint::Block3),
Just(SyntaxHint::Block4),
Just(SyntaxHint::Block5),
Just(SyntaxHint::Block6),
],
) {
let content = format!("{}\n", line);
let result = Text::parse_indented_block(language.clone(), content, hint);
prop_assert!(result.is_ok());
let text = result.unwrap();
prop_assert_eq!(text.language, language);
prop_assert_eq!(text.syntax_hint, Some(hint));
}
}
proptest! {
#[test]
fn unicode_content_preserved(content in "[\u{0080}-\u{FFFF}]{1,50}") {
let text = Text::plaintext(content.clone());
prop_assert_eq!(&text.content, &content);
let text = Text::inline_implicit(content.clone());
prop_assert_eq!(&text.content, &content);
}
#[test]
fn whitespace_only_content(spaces in "[ \t]{1,20}") {
let text = Text::plaintext(spaces.clone());
prop_assert_eq!(&text.content, &spaces);
let input = format!("{}\n", spaces);
let result = Text::parse_text_binding(&input);
prop_assert!(result.is_ok());
prop_assert_eq!(result.unwrap().content, "");
}
}
}