Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+81
View File
@@ -0,0 +1,81 @@
use enum_iterator::Sequence;
use markdown_parser::weight::CustomWeight;
/// Header sizes for formatted text blocks.
#[derive(Eq, PartialEq, Clone, Copy, Debug, Hash, Sequence)]
pub enum BlockHeaderSize {
Header1,
Header2,
Header3,
Header4,
Header5,
Header6,
}
impl BlockHeaderSize {
/// Get font size multiplication ratio for this heading level.
pub fn font_size_multiplication_ratio(self) -> f32 {
// The WHATWG HTML living standard is a useful starting point for these, but we don't
// follow it exactly:
// https://html.spec.whatwg.org/multipage/rendering.html#sections-and-headings
match self {
Self::Header1 => 2.25,
Self::Header2 => 1.8,
Self::Header3 => 1.5,
Self::Header4 => 1.0,
Self::Header5 => 0.83,
Self::Header6 => 0.67,
}
}
/// Font weight for this heading level.
pub fn font_weight(self) -> Option<CustomWeight> {
match self {
Self::Header1 | Self::Header2 | Self::Header3 | Self::Header4 => {
Some(CustomWeight::Semibold)
}
Self::Header5 | Self::Header6 => None,
}
}
/// A text label for this heading, in the format `Heading $N`.
pub fn label(self) -> &'static str {
match self {
BlockHeaderSize::Header1 => "Heading 1",
BlockHeaderSize::Header2 => "Heading 2",
BlockHeaderSize::Header3 => "Heading 3",
BlockHeaderSize::Header4 => "Heading 4",
BlockHeaderSize::Header5 => "Heading 5",
BlockHeaderSize::Header6 => "Heading 6",
}
}
}
impl From<BlockHeaderSize> for usize {
fn from(header_size: BlockHeaderSize) -> Self {
match header_size {
BlockHeaderSize::Header1 => 1,
BlockHeaderSize::Header2 => 2,
BlockHeaderSize::Header3 => 3,
BlockHeaderSize::Header4 => 4,
BlockHeaderSize::Header5 => 5,
BlockHeaderSize::Header6 => 6,
}
}
}
impl TryFrom<usize> for BlockHeaderSize {
type Error = ();
fn try_from(header_size: usize) -> Result<Self, Self::Error> {
match header_size {
1 => Ok(BlockHeaderSize::Header1),
2 => Ok(BlockHeaderSize::Header2),
3 => Ok(BlockHeaderSize::Header3),
4 => Ok(BlockHeaderSize::Header4),
5 => Ok(BlockHeaderSize::Header5),
6 => Ok(BlockHeaderSize::Header6),
_ => Err(()),
}
}
}
+314
View File
@@ -0,0 +1,314 @@
use anyhow::{anyhow, Result};
use itertools::Itertools;
use string_offset::{ByteOffset, CharCounter, CharOffset};
use crate::event::ModifiersState;
use self::point::Point;
use self::word_boundaries::WordBoundaries;
pub mod header;
pub mod point;
pub mod word_boundaries;
pub mod words;
pub use header::BlockHeaderSize;
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
pub enum SelectionType {
#[default]
Simple,
Semantic,
Lines,
Rect,
}
impl SelectionType {
pub fn from_click_count(click_count: u32) -> Self {
match click_count {
0 => SelectionType::Simple,
1 => SelectionType::Simple,
2 => SelectionType::Semantic,
3 => SelectionType::Lines,
_ => SelectionType::Lines,
}
}
pub fn from_mouse_event(modifiers: ModifiersState, click_count: u32) -> Self {
let is_rect = if cfg!(target_os = "macos") {
modifiers.cmd && modifiers.alt
} else {
modifiers.ctrl && modifiers.alt
};
if is_rect {
return SelectionType::Rect;
}
SelectionType::from_click_count(click_count)
}
}
impl From<SelectionType> for IsRect {
fn from(selection_type: SelectionType) -> Self {
match selection_type {
SelectionType::Rect => IsRect::True,
_ => IsRect::False,
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
pub enum IsRect {
True,
#[default]
False,
}
#[derive(Copy, Clone, Debug, Default)]
pub enum SelectionDirection {
#[default]
Forward,
Backward,
}
/// A buffer of text characters. This trait acts as a base layer to implement text segmentation
/// on top of. Currently, it supports word navigation.
pub trait TextBuffer {
type Chars<'a>: Iterator<Item = char> + 'a
where
Self: 'a;
type CharsReverse<'a>: Iterator<Item = char> + 'a
where
Self: 'a;
/// Begin iterating over the characters at `offset`, continuing to the end of the buffer.
///
/// The maximum valid `offset` is the length of the buffer (not 1 less than the length). This
/// allows starting just after the last character.
fn chars_at(&self, offset: CharOffset) -> Result<Self::Chars<'_>>;
/// Begin iterating backwards over the characters from `offset` to the start of the buffer.
///
/// Note that this is _different_ from the semantics of `Iterator::rev`, which would instead
/// start at the very end of the buffer.
///
/// The maximum valid `offset` is the length of the buffer (not 1 less than the length). This
/// allows starting just after the last character.
fn chars_rev_at(&self, offset: CharOffset) -> Result<Self::CharsReverse<'_>>;
/// Converts a character offset to a buffer [`Point`], if it is in bounds.
fn to_point(&self, offset: CharOffset) -> Result<Point>;
/// Convert a point to its offset within the buffer.
fn to_offset(&self, point: Point) -> Result<CharOffset>;
/// Get an iterator of word starting points forward from the given offset
fn word_starts_from_offset<T: BufferIndex>(
&self,
position: T,
) -> Result<WordBoundaries<'_, Self>> {
let offset = position.to_char_offset(self)?;
Ok(WordBoundaries::forward_starts(
offset,
self.chars_at(offset)?,
self,
))
}
/// Get an iterator of word ending points forward from the given offset, excluding the current
/// location if it is a word boundary.
///
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
/// the 'word'), this will yield columns [8, 12, 18], the ends of `one`, `two`, and `three`,
/// but _excluding_ the initial position at the end of `word`.
fn word_ends_from_offset_exclusive<T: BufferIndex>(
&self,
position: T,
) -> Result<WordBoundaries<'_, Self>> {
let offset = position.to_char_offset(self)?;
Ok(WordBoundaries::forward_ends_exclusive(
offset,
self.chars_at(offset)?,
self,
))
}
/// Get an iterator of word ending points forward from the given offset, including the current
/// location if appropriate.
///
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
/// the 'word'), this will yield columns [4, 8, 12, 18], the ends of all four words,
/// _including_ the initial position at the end of `word`.
fn word_ends_from_offset_inclusive<T: BufferIndex>(
&self,
position: T,
) -> Result<WordBoundaries<'_, Self>> {
let offset = position.to_char_offset(self)?;
Ok(WordBoundaries::forward_ends_inclusive(
offset,
self.chars_at(offset)?,
self,
))
}
/// Get an iterator of word starting points backwards from the given offset, excluding the
/// current location if it is a word boundary.
///
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
/// the 'three'), this will yield columns [9, 5, 0], the starts of `two`, `one`, and `word`,
/// but _excluding_ the initial position at the start of `three`.
fn word_starts_backward_from_offset_exclusive<T: BufferIndex>(
&self,
position: T,
) -> Result<WordBoundaries<'_, Self>> {
let offset = position.to_char_offset(self)?;
Ok(WordBoundaries::backward_starts_exclusive(
offset,
self.chars_rev_at(offset)?,
self,
))
}
/// Get an iterator of word starting points backwards from the given offset, including the
/// current location if appropriate.
///
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
/// the 'three'), this will yield columns [13, 9, 5, 0], the starts of all four words,
/// _including_ the initial position at the start of `three`.
fn word_starts_backward_from_offset_inclusive<T: BufferIndex>(
&self,
position: T,
) -> Result<WordBoundaries<'_, Self>> {
let offset = position.to_char_offset(self)?;
Ok(WordBoundaries::backward_starts_inclusive(
offset,
self.chars_rev_at(offset)?,
self,
))
}
}
/// A type which can index into a text buffer.
pub trait BufferIndex {
fn to_char_offset<B: TextBuffer + ?Sized>(&self, buffer: &B) -> Result<CharOffset>;
}
impl BufferIndex for CharOffset {
fn to_char_offset<B: TextBuffer + ?Sized>(&self, _: &B) -> Result<CharOffset> {
Ok(*self)
}
}
impl BufferIndex for Point {
fn to_char_offset<B: TextBuffer + ?Sized>(&self, buffer: &B) -> Result<CharOffset> {
buffer.to_offset(*self)
}
}
impl TextBuffer for str {
type Chars<'a> = std::str::Chars<'a>;
type CharsReverse<'a> = std::iter::Rev<std::str::Chars<'a>>;
fn chars_at(&self, offset: CharOffset) -> Result<Self::Chars<'_>> {
let chars = self.chars().count();
if offset.as_usize() <= chars {
Ok(self.chars().dropping(offset.as_usize()))
} else {
Err(anyhow!(
"Offset {offset} out of bounds; char length is {chars}"
))
}
}
fn chars_rev_at(&self, offset: CharOffset) -> Result<Self::CharsReverse<'_>> {
let chars = self.chars().count();
if offset.as_usize() <= chars {
Ok(self.chars().rev().dropping(chars - offset.as_usize()))
} else {
Err(anyhow!(
"Offset {offset} out of bounds; char length is {chars}"
))
}
}
fn to_point(&self, offset: CharOffset) -> Result<Point> {
let chars = self.chars().count();
if offset.as_usize() <= chars {
Ok(Point::new(0, offset.as_usize() as u32))
} else {
Err(anyhow!(
"Offset {offset} out of bounds; char length is {chars}"
))
}
}
fn to_offset(&self, point: Point) -> Result<CharOffset> {
if point.row == 0 {
let chars = self.chars().count();
if (point.column as usize) <= chars {
Ok(CharOffset::from(point.column as usize))
} else {
Err(anyhow!(
"Column {} out of bounds; char length is {chars}",
point.column
))
}
} else {
Err(anyhow!(
"Row {} out of bounds; str only has 1 row",
point.row
))
}
}
}
/// Convert a slice of text into a `Vec` of UTF-8 bytes.
pub fn str_to_byte_vec(text: &str) -> Vec<u8> {
text.as_bytes().iter().cloned().collect_vec()
}
/// Slice a string by [`char`] offsets, rather than byte offsets.
///
/// The starting index is inclusive, while the ending index is exclusive.
pub fn char_slice(s: &str, start: usize, end: usize) -> Option<&str> {
if end < start {
return None;
}
if start == end {
return Some("");
}
let mut indices = s.char_indices();
let (start_index, _) = indices.nth(start)?;
// Why not just use `nth()` again? We need to distinguish between a `None` because `end`
// is out of bounds and a `None` because `end` is the end of the string.
// If/when Iterator::advance_by (https://github.com/rust-lang/rust/issues/77404) stabilizes,
// we should use that. In the meantime, this doesn't hurt performance because `nth()`
// also has to advance character-by-character.
for _ in start + 1..end {
indices.next()?;
}
let end_index = match indices.next() {
Some((index, _)) => index,
None => s.len(),
};
s.get(start_index..end_index)
}
pub fn count_chars_up_to_byte(text: &str, byte_offset: ByteOffset) -> Option<CharOffset> {
if byte_offset.as_usize() == text.len() {
return Some(CharOffset::from(text.chars().count()));
}
let mut counter = CharCounter::new(text);
counter.char_offset(byte_offset)
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,64 @@
use super::{
count_chars_up_to_byte,
point::Point,
{char_slice, BufferIndex, TextBuffer},
};
use super::str_to_byte_vec;
#[test]
fn test_str_to_byte_vec() {
assert_eq!(
str_to_byte_vec("foo bar"),
vec![0x66, 0x6f, 0x6f, 0x20, 0x62, 0x61, 0x72]
);
}
/// Test the [`str`] implementation of [`TextBuffer`], which we rely on in other unit tests.
#[test]
fn test_str_buffer() -> anyhow::Result<()> {
let buf = "Hello\nWorld!";
assert_eq!(buf.chars_at(2.into())?.collect::<String>(), "llo\nWorld!");
assert_eq!(buf.chars_rev_at(3.into())?.collect::<String>(), "leH");
// For simplicity, we do not wrap newlines into new rows.
assert_eq!(buf.to_point(7.into())?, Point::new(0, 7));
assert!(Point::new(1, 1).to_char_offset(buf).is_err());
assert_eq!(Point::new(0, 7).to_char_offset(buf)?, 7.into());
Ok(())
}
#[test]
fn test_char_slice() {
let has_nonbreaking_space = "A\u{a0}non-breaking space occupies 2 bytes in UTF-8";
assert_eq!(char_slice(has_nonbreaking_space, 0, 3), Some("A\u{a0}n"));
// This string has characters ['A', '❤', '\u{fe0f}', '\u{200d}', '🔥', 'b']
assert_eq!(char_slice("A❤️‍🔥b", 4, 5), Some("🔥"));
assert_eq!(char_slice("abc", 5, 10), None);
assert_eq!(char_slice("abc", 2, 0), None);
assert_eq!(char_slice("abc", 1, 4), None);
assert_eq!(char_slice("A string", 2, 4), Some("st"));
assert_eq!(char_slice("The end: 🫥??", 10, 12), Some("??"));
assert_eq!(char_slice("🫥", 0, 0), Some(""));
}
#[test]
fn test_char_counts_up_to_byte() {
let text = "abc🔥abc☄️abc😬";
assert_eq!(count_chars_up_to_byte(text, 0.into()), Some(0.into()));
assert_eq!(
count_chars_up_to_byte(text, "abc🔥".len().into()),
Some(4.into())
);
assert_eq!(
count_chars_up_to_byte(text, text.len().into()),
Some(text.chars().count().into())
);
}
+86
View File
@@ -0,0 +1,86 @@
use std::{
cmp::Ordering,
ops::{Add, AddAssign, Sub},
};
/// A point within a document. The exact coordinate system (whether or not rows
/// are soft-wrapped, what the unit for characters is) is unspecified.
#[derive(Clone, Copy, Default, Eq, PartialEq, Debug, Hash)]
pub struct Point {
pub row: u32,
pub column: u32,
}
impl Point {
pub fn new(row: u32, column: u32) -> Self {
Point { row, column }
}
pub fn zero() -> Self {
Point::new(0, 0)
}
pub fn is_zero(&self) -> bool {
self.row == 0 && self.column == 0
}
}
impl Add for Point {
type Output = Point;
fn add(self, other: Self) -> Self::Output {
if other.row == 0 {
Point::new(self.row, self.column + other.column)
} else {
Point::new(self.row + other.row, other.column)
}
}
}
impl Sub for Point {
type Output = Point;
fn sub(self, other: Self) -> Self::Output {
debug_assert!(other <= self);
if self.row == other.row {
Point::new(0, self.column - other.column)
} else {
Point::new(self.row - other.row, self.column)
}
}
}
impl AddAssign<Self> for Point {
fn add_assign(&mut self, other: Self) {
if other.row == 0 {
self.column += other.column;
} else {
self.row += other.row;
self.column = other.column;
}
}
}
impl PartialOrd for Point {
fn partial_cmp(&self, other: &Point) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Point {
#[cfg(target_pointer_width = "64")]
fn cmp(&self, other: &Point) -> Ordering {
let a = (self.row as usize) << 32 | self.column as usize;
let b = (other.row as usize) << 32 | other.column as usize;
a.cmp(&b)
}
#[cfg(target_pointer_width = "32")]
fn cmp(&self, other: &Point) -> Ordering {
match self.row.cmp(&other.row) {
Ordering::Equal => self.column.cmp(&other.column),
comparison => comparison,
}
}
}
@@ -0,0 +1,242 @@
use std::iter::Peekable;
use std::{borrow::Cow, collections::HashSet};
use itertools::Either;
use string_offset::CharOffset;
use super::point::Point;
use super::words::is_default_word_boundary;
use super::TextBuffer;
/// This enum configures how the WordBoundaries iterator defines a "word"
#[derive(Clone, Debug)]
pub enum WordBoundariesPolicy {
/// Break words on spaces and the characters specified in words::is_default_word_boundary
Default,
/// Break words on spaces plus a specific set of provided characters
Custom(HashSet<char>),
/// Break words only on ASCII whitespace
OnlyWhitespace,
}
#[derive(Clone, Copy)]
pub enum WordBoundariesApproach {
ForwardWordStarts,
ForwardWordEnds,
BackwardWordStarts,
}
/// Iterator that returns the edges of words from a given offset, based on the selected approach
pub struct WordBoundaries<'a, T: TextBuffer + ?Sized> {
offset: CharOffset,
chars: Peekable<Either<T::Chars<'a>, T::CharsReverse<'a>>>,
buffer: &'a T,
in_word: bool,
approach: WordBoundariesApproach,
policy: Cow<'a, WordBoundariesPolicy>,
done: bool,
}
impl<'a, T: TextBuffer + ?Sized> WordBoundaries<'a, T> {
pub fn with_policy(mut self, policy: impl Into<Cow<'a, WordBoundariesPolicy>>) -> Self {
self.policy = policy.into();
self
}
/// Create an iterator that will return the starts of words moving forwards
pub fn forward_starts(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
Self {
offset,
buffer,
chars: Either::Left(chars).peekable(),
in_word: true,
approach: WordBoundariesApproach::ForwardWordStarts,
policy: Cow::Owned(WordBoundariesPolicy::Default),
done: false,
}
}
/// Create an iterator that will return the ends of words moving forwards, exclusive of the
/// offset position.
///
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
/// the 'word'), this will yield columns [8, 12, 18], the ends of `one`, `two`, and `three`,
/// but _excluding_ the initial position at the end of `word`.
pub fn forward_ends_exclusive(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
Self {
offset,
buffer,
chars: Either::Left(chars).peekable(),
in_word: false,
approach: WordBoundariesApproach::ForwardWordEnds,
policy: Cow::Owned(WordBoundariesPolicy::Default),
done: false,
}
}
/// Create an iterator that will return the ends of words moving forwards, inclusive of the
/// offset position.
///
/// Example: For a buffer of "word one two three", with an offset of `4` (immediately after
/// the 'word'), this will yield columns [4, 8, 12, 18], the ends of all four words,
/// _including_ the initial position at the end of `word`.
pub fn forward_ends_inclusive(offset: CharOffset, chars: T::Chars<'a>, buffer: &'a T) -> Self {
Self {
offset,
buffer,
chars: Either::Left(chars).peekable(),
in_word: true,
approach: WordBoundariesApproach::ForwardWordEnds,
policy: Cow::Owned(WordBoundariesPolicy::Default),
done: false,
}
}
/// Create an iterator that will return the starts of words moving _backwards_, exclusive of
/// the offset position
///
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
/// the 'three'), this will yield columns [9, 5, 0], the starts of `two`, `one`, and `word`,
/// but _excluding_ the initial position at the start of `three`.
pub fn backward_starts_exclusive(
offset: CharOffset,
chars: T::CharsReverse<'a>,
buffer: &'a T,
) -> Self {
Self {
offset,
buffer,
chars: Either::Right(chars).peekable(),
in_word: false,
approach: WordBoundariesApproach::BackwardWordStarts,
policy: Cow::Owned(WordBoundariesPolicy::Default),
done: false,
}
}
/// Create an iterator that will return the starts of words moving _backwards_, inclusive of
/// the offset position
///
/// Example: For a buffer of "word one two three", with an offset of `13` (immediately before
/// the 'three'), this will yield columns [13, 9, 5, 0], the starts of all four words,
/// _including_ the initial position at the start of `three`.
pub fn backward_starts_inclusive(
offset: CharOffset,
chars: T::CharsReverse<'a>,
buffer: &'a T,
) -> Self {
Self {
offset,
buffer,
chars: Either::Right(chars).peekable(),
in_word: true,
approach: WordBoundariesApproach::BackwardWordStarts,
policy: Cow::Owned(WordBoundariesPolicy::Default),
done: false,
}
}
fn step(&mut self) {
self.chars.next();
match self.approach {
WordBoundariesApproach::ForwardWordStarts | WordBoundariesApproach::ForwardWordEnds => {
self.offset += 1;
}
WordBoundariesApproach::BackwardWordStarts => {
self.offset -= 1;
}
}
}
fn is_word_boundary(&self, c: char) -> bool {
match self.policy.as_ref() {
WordBoundariesPolicy::Default => is_default_word_boundary(c),
WordBoundariesPolicy::Custom(boundary_chars) => {
c.is_whitespace() || boundary_chars.contains(&c)
}
WordBoundariesPolicy::OnlyWhitespace => c.is_whitespace(),
}
}
}
impl<T: TextBuffer + ?Sized> Iterator for WordBoundaries<'_, T> {
type Item = Point;
fn next(&mut self) -> Option<Self::Item> {
while let Some(&c) = self.chars.peek() {
match self.approach {
// For forward word starts, we look for the transition from not in a word (i.e. in
// a separator) to in a word. That boundary is the start of a new word
WordBoundariesApproach::ForwardWordStarts => {
if self.in_word {
self.step();
if self.is_word_boundary(c) {
self.in_word = false;
}
} else if self.is_word_boundary(c) {
self.step();
} else {
// We are not in a word, but the next character _is_ in a word, so
// we've found the start of the next word. We mark ourselves as being
// in a word (for the next iteration), then return the point.
self.in_word = true;
return self.buffer.to_point(self.offset).ok();
}
}
// For forward word ends, we look for the transition from in a word to not in a
// word. That boundary is the end of the current word. We also look for the same
// boundary for backward starts, since going backwards the transition from in a
// word to not in a word represents the _beginning_ of the current word
WordBoundariesApproach::ForwardWordEnds
| WordBoundariesApproach::BackwardWordStarts => {
if self.in_word {
if self.is_word_boundary(c) {
// We are in a word, but the next character is _not_ in a word, so we
// have found the boundary. We mark ourselves as not being in a word,
// then return the point.
self.in_word = false;
return self.buffer.to_point(self.offset).ok();
} else {
self.step();
}
} else {
self.step();
if !self.is_word_boundary(c) {
self.in_word = true;
}
}
}
}
}
// We have consumed all of the characters in the given direction. However, we should also
// treat the end (or beginning if backward) of the buffer as a word boundary. We only want
// to return that once, however, so we mark ourselves as done afterwards.
if self.done {
None
} else {
self.done = true;
self.buffer.to_point(self.offset).ok()
}
}
}
impl From<WordBoundariesPolicy> for Cow<'_, WordBoundariesPolicy> {
fn from(policy: WordBoundariesPolicy) -> Self {
Cow::Owned(policy)
}
}
impl<'a> From<&'a WordBoundariesPolicy> for Cow<'a, WordBoundariesPolicy> {
fn from(policy: &'a WordBoundariesPolicy) -> Self {
Cow::Borrowed(policy)
}
}
#[cfg(test)]
#[path = "word_boundaries_tests.rs"]
mod tests;
@@ -0,0 +1,163 @@
use super::*;
#[test]
fn test_word_boundaries() {
let buffer = "test/c/ab/word_with_underscores {восибing}";
let starts: Vec<_> = buffer
.word_starts_from_offset(Point::zero())
.unwrap()
.collect();
assert_eq!(
starts,
[
Point::new(0, 5),
Point::new(0, 7),
Point::new(0, 10),
Point::new(0, 33),
Point::new(0, 42),
]
);
let ends: Vec<_> = buffer
.word_ends_from_offset_exclusive(Point::zero())
.unwrap()
.collect();
assert_eq!(
ends,
[
Point::new(0, 4),
Point::new(0, 6),
Point::new(0, 9),
Point::new(0, 31),
Point::new(0, 41),
Point::new(0, 42),
]
);
let starts_only_space: Vec<_> = buffer
.word_starts_from_offset(Point::zero())
.unwrap()
.with_policy(WordBoundariesPolicy::OnlyWhitespace)
.collect();
assert_eq!(starts_only_space, [Point::new(0, 32), Point::new(0, 42)]);
let ends_only_space: Vec<_> = buffer
.word_ends_from_offset_exclusive(Point::zero())
.unwrap()
.with_policy(WordBoundariesPolicy::OnlyWhitespace)
.collect();
assert_eq!(ends_only_space, [Point::new(0, 31), Point::new(0, 42)]);
let starts_custom: Vec<_> = buffer
.word_starts_from_offset(Point::zero())
.unwrap()
.with_policy(WordBoundariesPolicy::Custom(HashSet::from(['{', '}'])))
.collect();
assert_eq!(starts_custom, [Point::new(0, 33), Point::new(0, 42)]);
let ends_custom: Vec<_> = buffer
.word_ends_from_offset_exclusive(Point::zero())
.unwrap()
.with_policy(WordBoundariesPolicy::Custom(HashSet::from(['{', '}'])))
.collect();
assert_eq!(
ends_custom,
[Point::new(0, 31), Point::new(0, 41), Point::new(0, 42)]
);
let starts_reversed: Vec<_> = buffer
.word_starts_backward_from_offset_exclusive(Point::new(0, 42))
.unwrap()
.collect();
assert_eq!(
starts_reversed,
[
Point::new(0, 33),
Point::new(0, 10),
Point::new(0, 7),
Point::new(0, 5),
Point::new(0, 0),
]
);
let starts_mid: Vec<_> = buffer
.word_starts_from_offset(Point::new(0, 7))
.unwrap()
.collect();
assert_eq!(
starts_mid,
[Point::new(0, 10), Point::new(0, 33), Point::new(0, 42),]
);
let ends_mid: Vec<_> = buffer
.word_ends_from_offset_exclusive(Point::new(0, 6))
.unwrap()
.collect();
assert_eq!(
ends_mid,
[
Point::new(0, 9),
Point::new(0, 31),
Point::new(0, 41),
Point::new(0, 42),
]
);
let starts_reversed_mid: Vec<_> = buffer
.word_starts_backward_from_offset_exclusive(Point::new(0, 8))
.unwrap()
.collect();
assert_eq!(
starts_reversed_mid,
[Point::new(0, 7), Point::new(0, 5), Point::new(0, 0),]
);
let ends_inclusive: Vec<_> = buffer
.word_ends_from_offset_inclusive(Point::new(0, 6))
.unwrap()
.collect();
assert_eq!(
ends_inclusive,
[
Point::new(0, 6),
Point::new(0, 9),
Point::new(0, 31),
Point::new(0, 41),
Point::new(0, 42),
]
);
let starts_reversed_inclusive: Vec<_> = buffer
.word_starts_backward_from_offset_inclusive(Point::new(0, 10))
.unwrap()
.collect();
assert_eq!(
starts_reversed_inclusive,
[
Point::new(0, 10),
Point::new(0, 7),
Point::new(0, 5),
Point::new(0, 0),
]
);
}
#[test]
fn test_unicode_whitespace() {
// See https://en.wikipedia.org/wiki/Whitespace_character
let text = "first\tsecond\u{A0}third\u{2003}fourth";
let starts: Vec<_> = text
.word_starts_from_offset(Point::zero())
.unwrap()
.collect();
assert_eq!(
starts,
[
Point::new(0, 6),
Point::new(0, 13),
Point::new(0, 19),
Point::new(0, 25)
]
);
}
+44
View File
@@ -0,0 +1,44 @@
/// The default word-boundary characters.
pub const DEFAULT_WORD_BOUNDARY_CHARS: [char; 33] = [
'`', '~', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '=', '+', '[', '{', ']', '}',
'\\', '|', ';', ':', '\'', '"', ',', '.', '<', '>', '/', '?', '«', '»',
];
/// Default subword-boundary characters: basically just underscores for now (snake_case)
pub const SUBWORD_BOUNDARY_CHARS: [char; 1] = ['_'];
/// Split a string slice at the next word boundary, returning before and after the word boundary
///
/// The next word boundary is the transition from not in a word (i.e. in a separator) to
/// in a word. The first slice returned goes from start of the input slice to the word boundary.
/// The second slice returns goes from the start of the new word to the end of the input slice.
pub fn split_at_next_word_start(text: &str) -> (&str, &str) {
let mut in_word = true;
let mut byte_index = 0;
for c in text.chars() {
if in_word {
if is_default_word_boundary(c) {
in_word = false;
}
} else if !is_default_word_boundary(c) {
break;
}
byte_index += c.len_utf8();
}
text.split_at(byte_index)
}
/// Default logic for determining if a character is a word separator. Word separators are
/// whitespace or a specific set of punctuation characters.
pub fn is_default_word_boundary(c: char) -> bool {
c.is_whitespace() || DEFAULT_WORD_BOUNDARY_CHARS.contains(&c)
}
/// Logic for determining if a character is a subword separator.
/// Subword separators include all the default word separators
/// (whitespace or a specific set of punctuation characters)
/// and subword-specific separators (underscores, for snake_case).
pub fn is_subword_boundary_char(c: char) -> bool {
is_default_word_boundary(c) || SUBWORD_BOUNDARY_CHARS.contains(&c)
}