// SPDX-License-Identifier: AGPL-3.0-or-later use crate::ast::Node; use crate::constants::{MAX_LINE_LENGTH, MAX_LINK_URL_LENGTH}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct Line { pub text: String, pub offset: usize, } pub fn split_lines(input: &str) -> Vec { if input.is_empty() { return Vec::new(); } let mut out = Vec::new(); let mut start = 0; for segment in input.split_inclusive('\n') { if out.len() >= crate::constants::MAX_LINES { break; } let text = segment.strip_suffix('\n').unwrap_or(segment); out.push(Line { text: bounded_line_text(text).to_owned(), offset: start, }); start += segment.len(); } if start < input.len() && out.len() < crate::constants::MAX_LINES { out.push(Line { text: bounded_line_text(&input[start..]).to_owned(), offset: start, }); } if out.len() == 1 && out[0].text.is_empty() { out.clear(); } out } pub fn trim_start(value: &str) -> &str { value.trim_start_matches([' ', '\t', '\r', '\n']) } pub fn trim_right(value: &str) -> &str { value.trim_end_matches([' ', '\t', '\r', '\n']) } pub fn trim(value: &str) -> &str { value.trim_matches([' ', '\t', '\r', '\n']) } pub fn bounded_utf8_prefix_length(value: &str, max_len: usize) -> usize { let mut end = value.len().min(max_len); while end > 0 && end < value.len() && !value.is_char_boundary(end) { end -= 1; } end } pub fn bounded_line_text(value: &str) -> &str { &value[..bounded_utf8_prefix_length(value, MAX_LINE_LENGTH)] } pub fn concat(left: &str, right: &str) -> String { if left.is_empty() { return right.to_owned(); } if right.is_empty() { return left.to_owned(); } let mut out = String::with_capacity(left.len() + right.len()); out.push_str(left); out.push_str(right); out } pub fn concat3(a: &str, b: &str, c: &str) -> String { let mut out = String::with_capacity(a.len() + b.len() + c.len()); out.push_str(a); out.push_str(b); out.push_str(c); out } pub fn append_text(nodes: &mut Vec, content: impl Into) { let content = content.into(); if content.is_empty() { return; } if let Some(Node::Text { content: previous }) = nodes.last_mut() { previous.push_str(&content); return; } nodes.push(Node::Text { content }); } pub fn line_as_text(lines: &[Line], current: usize) -> String { if current + 1 == lines.len() { lines[current].text.clone() } else { concat(&lines[current].text, "\n") } } pub fn lines_to_text(lines: &[Line]) -> String { let mut out = String::new(); for (index, line) in lines.iter().enumerate() { if index > 0 { out.push('\n'); } out.push_str(&line.text); } out } pub fn trim_line_window(lines: &mut [Line]) -> Vec { let mut start = 0; let mut end = lines.len(); while start < end && trim(&lines[start].text).is_empty() { start += 1; } while end > start && trim(&lines[end - 1].text).is_empty() { end -= 1; } if start >= end { return Vec::new(); } let mut out = lines[start..end].to_vec(); let first_trimmed_len = trim_start(&out[0].text).len(); let leading = out[0].text.len() - first_trimmed_len; out[0].offset += leading; out[0].text = trim_start(&out[0].text).to_owned(); let last = out.len() - 1; out[last].text = trim_right(&out[last].text).to_owned(); out } #[inline] pub fn starts_with(value: &str, prefix: &str) -> bool { value.as_bytes().starts_with(prefix.as_bytes()) } #[inline] pub fn ends_with(value: &[u8], suffix: &[u8]) -> bool { value.ends_with(suffix) } #[inline] pub fn is_digit(byte: u8) -> bool { byte.is_ascii_digit() } #[inline] pub fn is_alpha_numeric(byte: u8) -> bool { byte.is_ascii_alphanumeric() } pub fn is_digit_only(value: &str) -> bool { !value.is_empty() && value.bytes().all(is_digit) } pub fn is_whitespace(byte: u8) -> bool { matches!(byte, 0 | b'\t' | b'\n' | b'\r' | b' ') } pub fn has_visible_content(value: &str) -> bool { value.chars().any(|char| { !char.is_whitespace() && !matches!( char, '\0' | '\u{00ad}' | '\u{034f}' | '\u{061c}' | '\u{115f}' | '\u{1160}' | '\u{17b4}' | '\u{17b5}' | '\u{180e}' | '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{2069}' | '\u{2800}' | '\u{3164}' | '\u{fe00}'..='\u{fe0f}' | '\u{feff}' | '\u{ffa0}' | '\u{e0100}'..='\u{e01ef}' ) }) } pub fn is_punctuation(byte: u8) -> bool { (33..=47).contains(&byte) || (58..=64).contains(&byte) || (91..=96).contains(&byte) || (123..=126).contains(&byte) } pub fn is_escapable_character(byte: u8) -> bool { matches!( byte, b'[' | b']' | b'(' | b')' | b'\\' | b'*' | b'_' | b'~' | b'`' | b'@' | b'#' | b'-' | b'|' | b':' | b'<' | b'>' ) } pub fn byte_at(value: &str, index: usize) -> u8 { value.as_bytes().get(index).copied().unwrap_or(0) } pub fn advance_one(value: &str, index: usize) -> usize { if index >= value.len() { return 0; } if value.is_char_boundary(index) { value[index..].chars().next().map_or(1, char::len_utf8) } else { 1 } } pub fn find_from(value: &str, start: usize, needle: u8) -> Option { value.as_bytes()[start..] .iter() .position(|byte| *byte == needle) .map(|pos| start + pos) } pub fn bounded_url_end(value: &str, end: usize, prefix_len: usize) -> usize { bounded_utf8_prefix_length(&value[..end], prefix_len + MAX_LINK_URL_LENGTH) } pub fn remove_text_presentation(text: &str) -> String { if !text.contains('\u{fe0f}') { return text.to_owned(); } text.replace("™️", "™") .replace("©️", "©") .replace("®️", "®") } pub fn trim_start_newline_whitespace(value: &str) -> &str { value.trim_start_matches([' ', '\t', '\n', '\r']) } pub fn string_from_lossy(bytes: &[u8]) -> String { String::from_utf8_lossy(bytes).into_owned() }