//! Common types for dealing with multi-line strings safely. //! //! There are two pairs of string types, similar to `String` / `str` and `PathBuf` / `Path`: //! * [`MultilineString`] and [`MultilineStr`] track the line ending at compile time. These are //! zero-cost wrappers over [`String`] and [`str`]. //! * [`AnyMultilineString`] and [`AnyMultilineStr`] track the line ending at runtime. This is more //! flexible, but incurs a space and ergonomic cost. //! //! Generally, prefer [`MultilineString`] and [`MultilineStr`] when working consistently with a set //! line format (for example, the editor buffer only uses `\n` for line endings). When reading //! external text, use [`AnyMultilineString`] and [`AnyMultilineStr`] unless normalizing to a set //! ending. //! //! ## Invariants //! All multiline string types guarantee that their backing string uses the specified line ending. //! They do not support mixed line endings. All constructors will either normalize to a single line //! ending or return an error. //! //! ## Creating Multiline Strings //! //! If you are managing line endings at runtime, use [`AnyMultilineString::infer`]. This will infer //! a line ending from the input text, normalizing it if necessary. //! //! To create a string with a guaranteed, statically-known ending, use either [`MultilineString::apply`] //! or [`MultilineStr::apply`]. The latter avoids allocating if the input string's line ending is already //! correct, but may borrow its input. //! //! If you already know the ending of a string (e.g. it's a literal), use [`MultilineStr::try_new`]. //! //! ## Converting Line Endings //! //! If you already have a multiline string type, and need to convert to a different line ending, use //! one of the following methods: //! //! * `to_format` for converting to a statically-known ending ([`MultilineStr::to_format`], //! [`AnyMultilineString::to_format`]) //! * [`AnyMultilineString::to_line_ending`] or [`AnyMultilineString::into_line_ending`] to convert //! to a line ending known at runtime use std::{ borrow::{Borrow, Cow}, fmt, marker::PhantomData, ops::Deref, }; use itertools::Itertools as _; use line_ending::LineEnding; use galaxy_core::platform::SessionPlatform; /// A line ending format. This is the compile-time equivalent to [`LineEnding`]. pub trait LineFormat { /// The `LineEnding` corresponding to this format. fn ending() -> LineEnding; /// Apply this line ending format to `s`, replacing any other line endings. fn apply_to(s: &str) -> String { // TODO: LineEnding::apply is pretty inefficient, it may be worth optimizing. Self::ending().apply(s) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LF; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CRLF; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CR; /// An owned multiline string. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MultilineString { _format: PhantomData, inner: String, } /// A borrowed multiline string. #[repr(transparent)] #[derive(Debug, PartialEq, Eq)] pub struct MultilineStr { _format: PhantomData, inner: str, } /// A string of multiline text, with an associated line ending. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AnyMultilineString { inner: String, line_ending: LineEnding, } /// A borrowed multiline string, with an associated line ending. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AnyMultilineStr<'a> { inner: &'a str, line_ending: LineEnding, } /// The error returned when a string did not have the expected line ending. #[derive(Debug, thiserror::Error)] #[error("Expected line ending {expected:?}, but was {actual:?}")] pub struct IncorrectLineEndingError { pub expected: LineEnding, pub actual: LineEnding, } impl LineFormat for LF { fn ending() -> LineEnding { LineEnding::LF } fn apply_to(s: &str) -> String { // As opposed to `apply`, `normalize` avoids an extra conversion pass. LineEnding::normalize(s) } } impl LineFormat for CRLF { fn ending() -> LineEnding { LineEnding::CRLF } } impl LineFormat for CR { fn ending() -> LineEnding { LineEnding::CR } fn apply_to(s: &str) -> String { // This avoids a normalization pass - we can use the same implementation as `normalize`, // but with a different replacement string. s.replace("\r\n", "\r").replace('\n', "\r") } } impl MultilineString { /// Create a new `MultilineString` that assumes the given line ending. The caller must /// guarantee that `s` already uses the given line ending - prefer [`Self::apply`] instead. #[inline] fn new_unchecked(s: impl Into) -> Self { Self { _format: PhantomData, inner: s.into(), } } /// Create a new `MultilineString` by unconditionally applying `F` to `s`. pub fn apply(s: impl AsRef) -> Self { Self::new_unchecked(F::apply_to(s.as_ref())) } /// Extract the underlying string, which is guaranteed to use `F` as its line ending. pub fn into_string(self) -> String { self.inner } } impl fmt::Display for MultilineString { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.inner.fmt(f) } } impl fmt::Display for MultilineStr { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { self.inner.fmt(f) } } impl MultilineStr { /// Create a new `MultilineStr` that assumes the given line ending. The caller must /// guarantee that `s` already uses the given line ending - prefer [`MultilineString::apply`] /// if the format is not known. #[inline] fn new_unchecked + ?Sized>(s: &S) -> &Self { // Safety: Because `MultilineStr` is `#repr(transparent)`, it has an identical in-memory // representation to `str`. Therefore, we can cast from `*str` to `*MultilineStr`, and // then to `&MultilineStr`, with the same lifetime as the original `&str`. // See https://users.rust-lang.org/t/creating-types-analoguous-to-path-and-pathbuf/117310. unsafe { &*(s.as_ref() as *const str as *const Self) } } /// Create a new `MultilineStr` from a string slice. This returns an error if the slice does /// not already use `F` for line endings, or if it contains mixed line endings. pub fn try_new + ?Sized>(s: &S) -> Result<&Self, IncorrectLineEndingError> { let s = s.as_ref(); let endings = evaluate_line_endings(s); match endings { // If there are no line endings, then they trivially match. TextLineEndings::SingleLine => Ok(MultilineStr::new_unchecked(s)), TextLineEndings::MultiLine { primary_ending, mixed_endings, } => { if primary_ending == F::ending() && !mixed_endings { Ok(MultilineStr::new_unchecked(s)) } else { Err(IncorrectLineEndingError { expected: F::ending(), actual: primary_ending, }) } } } } /// Create a `MultilineStr` or [`MultilineString`] from `s`, guaranteed to use `F` for line endings. /// /// If `s` already uses `F` for line endings, then no new allocation is needed and a [`Cow::Borrowed`] is returned. /// Otherwise, a new [`MultilineString`] is allocated with the converted text. pub fn apply + ?Sized>(s: &S) -> Cow<'_, Self> { let s = s.as_ref(); let endings = evaluate_line_endings(s); match endings { TextLineEndings::SingleLine => Cow::Borrowed(MultilineStr::new_unchecked(s)), TextLineEndings::MultiLine { primary_ending, mixed_endings, } => { if primary_ending == F::ending() && !mixed_endings { Cow::Borrowed(MultilineStr::new_unchecked(s)) } else { Cow::Owned(MultilineString::::apply(s)) } } } } /// Convert to an owned [`MultilineString`] with a new line ending, `G`. This must allocate in /// order to create a string with the new line ending. pub fn to_format(&self) -> MultilineString { let converted = G::apply_to(&self.inner); MultilineString::new_unchecked(converted) } /// Get the underlying string, which is guaranteed to use `F` as its line ending. pub fn as_str(&self) -> &str { &self.inner } /// Iterate over the lines of this string, split by `F`. pub fn lines(&self) -> impl Iterator { self.inner.split(F::ending().as_str()) } } impl ToOwned for MultilineStr { type Owned = MultilineString; fn to_owned(&self) -> Self::Owned { MultilineString { _format: PhantomData, inner: self.inner.to_owned(), } } } impl Deref for MultilineString { type Target = MultilineStr; #[inline] fn deref(&self) -> &Self::Target { MultilineStr::new_unchecked(self.inner.as_str()) } } impl Borrow> for MultilineString { #[inline] fn borrow(&self) -> &MultilineStr { self } } impl AsRef> for MultilineString { #[inline] fn as_ref(&self) -> &MultilineStr { self } } impl TryFrom for MultilineString { type Error = IncorrectLineEndingError; /// Attempt to convert an [`AnyMultilineString`] to a [`MultilineString`]. This fails if the /// line ending is not `F`. Use [`AnyMultilineString::to_format`] to infallibly convert line /// endings. fn try_from(value: AnyMultilineString) -> Result { if value.line_ending == F::ending() { Ok(MultilineString::new_unchecked(value.inner)) } else { Err(IncorrectLineEndingError { expected: F::ending(), actual: value.line_ending, }) } } } impl TryFrom for MultilineString { type Error = IncorrectLineEndingError; /// Attempt to convert a [`String`] to a [`MultilineString`]. This fails if the inferred line /// ending is not `F`. fn try_from(value: String) -> Result { AnyMultilineString::infer(value).try_into() } } impl<'a, F: LineFormat> TryFrom> for &'a MultilineStr { type Error = IncorrectLineEndingError; /// Attempt to convert an [`AnyMultilineStr`] to a [`MultilineStr`]. This fails if the /// line ending is not `F`. Use [`AnyMultilineString::to_format`] to infallibly convert line /// endings. fn try_from(value: AnyMultilineStr<'a>) -> Result { if value.line_ending == F::ending() { Ok(MultilineStr::new_unchecked(value.inner)) } else { Err(IncorrectLineEndingError { expected: F::ending(), actual: value.line_ending, }) } } } impl AnyMultilineString { /// Create a new `AnyMultilineString` with the given line ending. The caller must guarantee that `line_ending` /// is the line ending used in `text`. pub fn new_unchecked(text: impl Into, line_ending: LineEnding) -> Self { Self { inner: text.into(), line_ending, } } /// Create a new `AnyMultilineString` with the given text normalized via [`LineEnding::normalize`]. /// /// This converts all line endings to `\n`. pub fn normalize_to_linefeed(text: impl AsRef) -> Self { MultilineString::::apply(text).into() } /// Create a new `AnyMultilineString` from individual lines and the desired line ending. pub fn from_lines>(lines: I, line_ending: LineEnding) -> Self { let text = lines.into_iter().join(line_ending.as_str()); Self::new_unchecked(text, line_ending) } /// Create a new `AnyMultilineString` with the line ending inferred from the input text. pub fn infer(text: impl Into) -> Self { let mut text = text.into(); let endings = evaluate_line_endings(&text); // TODO: Figure out how to get a SessionPlatform here. let primary_ending = endings.primary_ending(None); if endings.is_mixed() { // If the text contains mixed line endings (uncommon), they must be normalized to a // single ending to uphold the `AnyMultilineString` contract. text = primary_ending.apply(&text); } Self::new_unchecked(text, primary_ending) } /// The underlying string. This is guaranteed to use [`Self::line_ending`] as its line ending. pub fn as_str(&self) -> &str { &self.inner } /// Extract the underlying string. This is guaranteed to use [`Self::line_ending`] as its line ending. pub fn into_string(self) -> String { self.inner } /// The configured line ending of this string. pub fn line_ending(&self) -> LineEnding { self.line_ending } /// Converts this text to use `ending`. This is similar to [`to_line_ending`], but consumes /// `self` for convenience if the original value is no longer needed. It will reuse the /// existing allocation if possible. pub fn into_line_ending(self, ending: LineEnding) -> Self { if self.line_ending == ending { self } else { let new_text = ending.apply(&self.inner); Self::new_unchecked(new_text, ending) } } /// Converts this text to use `ending`. /// /// This method returns a [`Cow<'_, Self>`]. If our line ending is already `ending`, it /// returns a reference to `self`. Otherwise, it allocates a new `AnyMultilineString` with the /// new line ending applied. pub fn to_line_ending(&self, ending: LineEnding) -> Cow<'_, Self> { if self.line_ending == ending { Cow::Borrowed(self) } else { let new_text = ending.apply(&self.inner); Cow::Owned(Self::new_unchecked(new_text, ending)) } } /// Converts this text to use `F` as its line ending. /// /// This method returns a [`Cow<'_, MultilineStr>`]. If our line ending is already `F`, it /// returns a reference to the string backing `self`. Otherwise, it allocates a new /// [`MultilineString`] with the new line ending applied. pub fn to_format(&self) -> Cow<'_, MultilineStr> { if self.line_ending == F::ending() { Cow::Borrowed(MultilineStr::new_unchecked(self.inner.as_str())) } else { Cow::Owned(MultilineString::::apply(self.inner.as_str())) } } /// Iterate over the lines of this string, split by [`Self::line_ending`]. pub fn lines(&self) -> impl Iterator { self.inner.split(self.line_ending.as_str()) } } impl From> for AnyMultilineString { fn from(value: MultilineString) -> Self { Self { inner: value.into_string(), line_ending: F::ending(), } } } impl<'a> AnyMultilineStr<'a> { pub fn new_unchecked + ?Sized>(text: &'a S, line_ending: LineEnding) -> Self { Self { inner: text.as_ref(), line_ending, } } /// Get the underlying string. This is guaranteed to use [`Self::line_ending`] as its line ending. pub fn as_str(&self) -> &str { self.inner } /// The configured line ending of this string. pub fn line_ending(&self) -> LineEnding { self.line_ending } } impl<'a, F: LineFormat> From<&'a MultilineStr> for AnyMultilineStr<'a> { fn from(value: &'a MultilineStr) -> Self { Self { inner: value.as_str(), line_ending: F::ending(), } } } /// Returns the line ending style to use for the input string. /// /// This is the most common line ending in the string. If there are no line endings, the platform /// default is used. pub fn infer_line_ending(text: &str, platform: Option<&SessionPlatform>) -> LineEnding { evaluate_line_endings(text).primary_ending(platform) } /// Results of analyzing the line endings in a string. enum TextLineEndings { /// The text has no line endings. SingleLine, /// The text contains at least one line ending. MultiLine { /// The most common line ending in the string. primary_ending: LineEnding, /// Whether the string contains a mix of line endings. mixed_endings: bool, }, } impl TextLineEndings { /// Whether or not the string contained a mix of line endings. fn is_mixed(&self) -> bool { match self { TextLineEndings::SingleLine => false, TextLineEndings::MultiLine { mixed_endings, .. } => *mixed_endings, } } /// The primary line ending of the string. For single-line strings, this is the platform default. #[allow(clippy::disallowed_methods)] fn primary_ending(&self, platform: Option<&SessionPlatform>) -> LineEnding { match self { TextLineEndings::SingleLine => platform .map_or_else(LineEnding::from_current_platform, |platform| { platform.default_line_ending() }), TextLineEndings::MultiLine { primary_ending, .. } => *primary_ending, } } } /// Evaluates the line endings present in a string. /// /// The primary line ending is the line ending type that occurred most often in a string. fn evaluate_line_endings(text: &str) -> TextLineEndings { // This is similar to LineEnding::from, but with platform-aware tie-breaking. let scores = LineEnding::score_mixed_types(text); // The score_mixed_types implementation guarantees a score for each line ending. let crlf_score = scores[&LineEnding::CRLF]; let lf_score = scores[&LineEnding::LF]; let cr_score = scores[&LineEnding::CR]; // Use the most-prevalent line ending, or the platform ending if there are no lines. // // In case of a tie, prefer LF over CRLF as Unix-style endings are overall more common. let max_score = crlf_score.max(lf_score).max(cr_score); if max_score == 0 { TextLineEndings::SingleLine } else if max_score == lf_score { TextLineEndings::MultiLine { primary_ending: LineEnding::LF, mixed_endings: crlf_score > 0 || cr_score > 0, } } else if max_score == crlf_score { TextLineEndings::MultiLine { primary_ending: LineEnding::CRLF, mixed_endings: lf_score > 0 || cr_score > 0, } } else { TextLineEndings::MultiLine { primary_ending: LineEnding::CR, mixed_endings: crlf_score > 0 || lf_score > 0, } } } #[cfg(test)] #[path = "multiline_tests.rs"] mod tests;