use lsp_types::Position;
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
#[derive(Debug, Clone)]
pub struct LineIndex {
line_starts: Vec<usize>,
len: usize,
}
impl LineIndex {
pub fn new(source: &str) -> Self {
let mut line_starts = vec![0];
for (i, ch) in source.char_indices() {
if ch == '\n' {
line_starts.push(i + 1); }
}
Self {
line_starts,
len: source.len(),
}
}
pub fn offset_to_position(&self, offset: usize) -> (u32, u32) {
let offset = offset.min(self.len);
let line = match self.line_starts.binary_search(&offset) {
Ok(line) => line, Err(line) => line.saturating_sub(1), };
let line_start = self.line_starts[line];
let col = offset - line_start;
(line as u32, col as u32)
}
pub fn position_to_offset(&self, line: u32, col: u32) -> Option<usize> {
let line = line as usize;
if line >= self.line_starts.len() {
return None;
}
let line_start = self.line_starts[line];
let offset = line_start + col as usize;
if offset <= self.len {
Some(offset)
} else {
None
}
}
pub fn line_count(&self) -> usize {
self.line_starts.len()
}
}
pub fn byte_offset_to_position(source: &str, offset: usize) -> (u32, u32) {
let mut line = 0u32;
let mut col = 0u32;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 0;
} else {
col += 1;
}
}
(line, col)
}
pub fn get_word_at_position(line: &str, col: usize) -> Option<(String, usize, usize)> {
if col > line.len() {
return None;
}
let chars: Vec<char> = line.chars().collect();
let mut start = col;
while start > 0 && is_word_char(chars.get(start - 1).copied().unwrap_or(' ')) {
start -= 1;
}
let mut end = col;
while end < chars.len() && is_word_char(chars[end]) {
end += 1;
}
if start == end {
return None;
}
let word: String = chars[start..end].iter().collect();
Some((word, start, end))
}
pub fn get_word_at_source_position(source: &str, position: Position) -> Option<String> {
let line = source.lines().nth(position.line as usize)?;
let col = position.character as usize;
let byte_col = line
.char_indices()
.nth(col)
.map(|(i, _)| i)
.unwrap_or(line.len());
if byte_col > line.len() {
return None;
}
let chars: Vec<char> = line.chars().collect();
let mut start = col.min(chars.len());
while start > 0 && is_word_char(chars.get(start - 1).copied().unwrap_or(' ')) {
start -= 1;
}
let mut end = col.min(chars.len());
while end < chars.len() && is_word_char(chars[end]) {
end += 1;
}
if start == end {
return None;
}
Some(chars[start..end].iter().collect())
}
pub fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == ':' || c == '-' || c == '_'
}
pub fn is_account_like(s: &str) -> bool {
s.contains(':')
&& (s.starts_with("Assets")
|| s.starts_with("Liabilities")
|| s.starts_with("Equity")
|| s.starts_with("Income")
|| s.starts_with("Expenses"))
}
pub fn is_account_type(s: &str) -> bool {
matches!(
s,
"Assets" | "Liabilities" | "Equity" | "Income" | "Expenses"
)
}
pub fn is_currency_like_simple(s: &str) -> bool {
s.len() >= 2
&& s.len() <= 5
&& s.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
}
pub fn is_currency_like(s: &str, parse_result: &ParseResult) -> bool {
if !s.chars().all(|c| c.is_uppercase() || c.is_numeric()) || s.len() < 2 || s.len() > 24 {
return false;
}
for spanned in &parse_result.directives {
match &spanned.value {
Directive::Commodity(comm) => {
if comm.currency.as_ref() == s {
return true;
}
}
Directive::Open(open) => {
for curr in &open.currencies {
if curr.as_ref() == s {
return true;
}
}
}
Directive::Balance(bal) => {
if bal.amount.currency.as_ref() == s {
return true;
}
}
Directive::Transaction(txn) => {
for posting in &txn.postings {
if let Some(units) = &posting.units
&& let Some(currency) = units.currency()
&& currency == s
{
return true;
}
if let Some(cost) = &posting.cost
&& let Some(currency) = &cost.currency
&& currency.as_ref() == s
{
return true;
}
if let Some(price) = &posting.price
&& let Some(amount) = price.amount()
&& amount.currency.as_ref() == s
{
return true;
}
}
}
Directive::Price(price) => {
if price.currency.as_ref() == s || price.amount.currency.as_ref() == s {
return true;
}
}
_ => {}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_line_index_basic() {
let source = "line1\nline2\nline3";
let index = LineIndex::new(source);
assert_eq!(index.offset_to_position(0), (0, 0));
assert_eq!(index.offset_to_position(5), (0, 5));
assert_eq!(index.offset_to_position(6), (1, 0));
assert_eq!(index.offset_to_position(10), (1, 4));
assert_eq!(index.offset_to_position(12), (2, 0));
assert_eq!(index.offset_to_position(17), (2, 5));
assert_eq!(index.line_count(), 3);
}
#[test]
fn test_line_index_empty() {
let index = LineIndex::new("");
assert_eq!(index.offset_to_position(0), (0, 0));
assert_eq!(index.line_count(), 1);
}
#[test]
fn test_line_index_single_line() {
let index = LineIndex::new("hello world");
assert_eq!(index.offset_to_position(0), (0, 0));
assert_eq!(index.offset_to_position(5), (0, 5));
assert_eq!(index.offset_to_position(11), (0, 11));
assert_eq!(index.line_count(), 1);
}
#[test]
fn test_line_index_trailing_newline() {
let source = "line1\nline2\n";
let index = LineIndex::new(source);
assert_eq!(index.offset_to_position(11), (1, 5));
assert_eq!(index.offset_to_position(12), (2, 0)); assert_eq!(index.line_count(), 3);
}
#[test]
fn test_line_index_position_to_offset() {
let source = "line1\nline2\nline3";
let index = LineIndex::new(source);
assert_eq!(index.position_to_offset(0, 0), Some(0));
assert_eq!(index.position_to_offset(0, 5), Some(5));
assert_eq!(index.position_to_offset(1, 0), Some(6));
assert_eq!(index.position_to_offset(1, 4), Some(10));
assert_eq!(index.position_to_offset(2, 0), Some(12));
assert_eq!(index.position_to_offset(3, 0), None);
assert_eq!(index.position_to_offset(0, 100), None);
}
#[test]
fn test_line_index_matches_naive() {
let source = "2024-01-01 open Assets:Bank USD\n2024-01-15 * \"Coffee\"\n Assets:Bank -5.00 USD\n Expenses:Food\n";
let index = LineIndex::new(source);
for offset in 0..source.len() {
let naive = byte_offset_to_position(source, offset);
let indexed = index.offset_to_position(offset);
assert_eq!(naive, indexed, "Mismatch at offset {}", offset);
}
}
#[test]
fn test_byte_offset_to_position() {
let source = "line1\nline2\nline3";
assert_eq!(byte_offset_to_position(source, 0), (0, 0));
assert_eq!(byte_offset_to_position(source, 5), (0, 5));
assert_eq!(byte_offset_to_position(source, 6), (1, 0));
assert_eq!(byte_offset_to_position(source, 10), (1, 4));
}
#[test]
fn test_get_word_at_position() {
let line = " Assets:Bank -100.00 USD";
let result = get_word_at_position(line, 5);
assert!(result.is_some());
let (word, start, end) = result.unwrap();
assert_eq!(word, "Assets:Bank");
assert_eq!(start, 2);
assert_eq!(end, 13);
let result = get_word_at_position(line, 24);
assert!(result.is_some());
let (word, _, _) = result.unwrap();
assert_eq!(word, "USD");
}
#[test]
fn test_is_account_like() {
assert!(is_account_like("Assets:Bank"));
assert!(is_account_like("Expenses:Food:Groceries"));
assert!(!is_account_like("USD"));
assert!(!is_account_like("Bank"));
assert!(!is_account_like("Random:Thing"));
}
#[test]
fn test_is_account_type() {
assert!(is_account_type("Assets"));
assert!(is_account_type("Liabilities"));
assert!(is_account_type("Income"));
assert!(!is_account_type("Bank"));
assert!(!is_account_type("assets"));
}
#[test]
fn test_is_currency_like_simple() {
assert!(is_currency_like_simple("USD"));
assert!(is_currency_like_simple("EUR"));
assert!(is_currency_like_simple("BTC"));
assert!(!is_currency_like_simple("usd"));
assert!(!is_currency_like_simple("U"));
assert!(!is_currency_like_simple("TOOLONGCURRENCY"));
}
#[test]
fn test_is_word_char() {
assert!(is_word_char('a'));
assert!(is_word_char('Z'));
assert!(is_word_char('0'));
assert!(is_word_char(':'));
assert!(is_word_char('-'));
assert!(is_word_char('_'));
assert!(!is_word_char(' '));
assert!(!is_word_char('"'));
}
}