#[cfg(feature = "lsp")]
use tower_lsp_server::ls_types::{Position, Range};
use crate::source::Span;
#[cfg(feature = "lsp")]
pub fn span_to_range(span: &Span, source: &str) -> Range {
let start = offset_to_position(span.start.into(), source);
let end = offset_to_position(span.end.into(), source);
Range { start, end }
}
#[cfg(feature = "lsp")]
pub fn offset_to_position(offset: usize, source: &str) -> Position {
let mut line = 0u32;
let mut col = 0u32;
let bytes = source.as_bytes();
let mut i = 0;
while i < offset.min(source.len()) {
let b = bytes[i];
if b == b'\n' {
line += 1;
col = 0;
i += 1;
} else if b == b'\r' {
line += 1;
col = 0;
i += 1;
if i < offset.min(source.len()) && bytes[i] == b'\n' {
i += 1;
}
} else if b < 0x80 {
col += 1;
i += 1;
} else {
let ch = source[i..].chars().next().unwrap();
col += ch.len_utf16() as u32;
i += ch.len_utf8();
}
}
Position {
line,
character: col,
}
}
#[cfg(feature = "lsp")]
pub fn position_to_offset(pos: Position, source: &str) -> usize {
let mut current_line = 0u32;
let mut current_col = 0u32;
let bytes = source.as_bytes();
let mut i = 0;
while i < source.len() {
if current_line == pos.line && current_col == pos.character {
return i;
}
let b = bytes[i];
if b == b'\n' {
if current_line == pos.line {
return i; }
current_line += 1;
current_col = 0;
i += 1;
} else if b == b'\r' {
if current_line == pos.line {
return i; }
current_line += 1;
current_col = 0;
i += 1;
if i < source.len() && bytes[i] == b'\n' {
i += 1;
}
} else if b < 0x80 {
current_col += 1;
i += 1;
} else {
let ch = source[i..].chars().next().unwrap();
current_col += ch.len_utf16() as u32;
i += ch.len_utf8();
}
}
if current_line == pos.line && current_col == pos.character {
return i;
}
source.len()
}
#[cfg(not(feature = "lsp"))]
pub fn span_to_range(_span: &Span, _source: &str) -> (usize, usize, usize, usize) {
(0, 0, 0, 0)
}
#[cfg(not(feature = "lsp"))]
pub fn offset_to_position(_offset: usize, _source: &str) -> (u32, u32) {
(0, 0)
}
#[cfg(not(feature = "lsp"))]
pub fn position_to_offset(_line: u32, _character: u32, _source: &str) -> usize {
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::FileId;
#[test]
#[cfg(feature = "lsp")]
fn test_offset_to_position_first_line() {
let source = "hello world";
assert_eq!(
offset_to_position(0, source),
Position {
line: 0,
character: 0
}
);
assert_eq!(
offset_to_position(5, source),
Position {
line: 0,
character: 5
}
);
assert_eq!(
offset_to_position(11, source),
Position {
line: 0,
character: 11
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_to_position_multiline() {
let source = "line1\nline2\nline3";
assert_eq!(
offset_to_position(6, source),
Position {
line: 1,
character: 0
}
);
assert_eq!(
offset_to_position(12, source),
Position {
line: 2,
character: 0
}
);
assert_eq!(
offset_to_position(8, source),
Position {
line: 1,
character: 2
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_span_to_range() {
let source = "schema: nika/workflow@0.12\ntasks:";
let span = Span::new(FileId(0), 8, 26);
let range = span_to_range(&span, source);
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 8);
assert_eq!(range.end.line, 0);
assert_eq!(range.end.character, 26);
}
#[test]
#[cfg(feature = "lsp")]
fn test_span_to_range_multiline() {
let source = "tasks:\n - id: step1";
let span = Span::new(FileId(0), 10, 20);
let range = span_to_range(&span, source);
assert_eq!(range.start.line, 1);
assert_eq!(range.start.character, 3); }
#[test]
#[cfg(feature = "lsp")]
fn test_position_to_offset_first_line() {
let source = "hello world";
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 0
},
source
),
0
);
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 5
},
source
),
5
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_position_to_offset_multiline() {
let source = "line1\nline2\nline3";
assert_eq!(
position_to_offset(
Position {
line: 1,
character: 0
},
source
),
6
);
assert_eq!(
position_to_offset(
Position {
line: 2,
character: 0
},
source
),
12
);
assert_eq!(
position_to_offset(
Position {
line: 1,
character: 2
},
source
),
8
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_roundtrip_offset_position() {
let source = "schema: nika/workflow@0.12\ntasks:\n - id: step1";
for offset in [0, 5, 10, 27, 36, 46] {
if offset <= source.len() {
let pos = offset_to_position(offset, source);
let back = position_to_offset(pos, source);
assert_eq!(
back, offset,
"Roundtrip failed for offset {}: got {}",
offset, back
);
}
}
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_past_end() {
let source = "short";
let pos = offset_to_position(100, source);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 5);
}
#[test]
#[cfg(feature = "lsp")]
fn test_position_past_end() {
let source = "short";
let offset = position_to_offset(
Position {
line: 10,
character: 0,
},
source,
);
assert_eq!(offset, source.len());
}
#[test]
#[cfg(feature = "lsp")]
fn test_empty_source() {
let source = "";
assert_eq!(
offset_to_position(0, source),
Position {
line: 0,
character: 0
}
);
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 0
},
source
),
0
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_to_position_emoji() {
let source = "a🚀b";
assert_eq!(
offset_to_position(0, source),
Position {
line: 0,
character: 0
}
);
assert_eq!(
offset_to_position(1, source),
Position {
line: 0,
character: 1
}
);
assert_eq!(
offset_to_position(5, source),
Position {
line: 0,
character: 3
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_position_to_offset_emoji() {
let source = "a🚀b";
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 0
},
source
),
0
);
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 1
},
source
),
1
);
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 3
},
source
),
5
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_roundtrip_emoji() {
let source = "hello 🌍 world\nsecond 🎉 line";
for offset in [0, 5, 6, 10, 11, 15, 16, 22, 23, 24, 28, 29] {
if offset <= source.len() {
let pos = offset_to_position(offset, source);
let back = position_to_offset(pos, source);
assert_eq!(
back, offset,
"Roundtrip failed for offset {}: pos=({},{}), got {}",
offset, pos.line, pos.character, back
);
}
}
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_to_position_cjk_supplementary() {
let source = "x𠀀y";
assert_eq!(
offset_to_position(0, source),
Position {
line: 0,
character: 0
}
);
assert_eq!(
offset_to_position(1, source),
Position {
line: 0,
character: 1
}
);
assert_eq!(
offset_to_position(5, source),
Position {
line: 0,
character: 3
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_to_position_bmp_non_ascii() {
let source = "café";
assert_eq!(
offset_to_position(3, source),
Position {
line: 0,
character: 3
}
);
assert_eq!(
offset_to_position(5, source),
Position {
line: 0,
character: 4
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_greek_alpha() {
let source = "αβγ";
assert_eq!(
offset_to_position(4, source),
Position {
line: 0,
character: 2
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_emoji_multiline() {
let source = "line1 🎉\nline2 🌍";
let pos = offset_to_position(11, source);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 0);
}
#[test]
#[cfg(feature = "lsp")]
fn test_position_to_offset_after_emoji() {
let source = "🎉b";
let offset = position_to_offset(
Position {
line: 0,
character: 2,
},
source,
);
assert_eq!(offset, 4); }
#[test]
#[cfg(feature = "lsp")]
fn test_consecutive_emoji() {
let source = "🎉🌍🚀x";
let pos = offset_to_position(12, source);
assert_eq!(pos.character, 6);
}
#[test]
#[cfg(feature = "lsp")]
fn test_yaml_with_emoji_positions() {
let source = "prompt: \"Hello 🌍!\"\ntasks:";
let tasks_offset = source.find("tasks").unwrap();
let pos = offset_to_position(tasks_offset, source);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 0);
}
#[test]
#[cfg(feature = "lsp")]
fn test_offset_to_position_crlf() {
let source = "ab\r\ncd";
assert_eq!(
offset_to_position(0, source),
Position {
line: 0,
character: 0
}, );
assert_eq!(
offset_to_position(1, source),
Position {
line: 0,
character: 1
}, );
assert_eq!(
offset_to_position(2, source),
Position {
line: 0,
character: 2
}, );
assert_eq!(
offset_to_position(4, source),
Position {
line: 1,
character: 0
}, );
assert_eq!(
offset_to_position(5, source),
Position {
line: 1,
character: 1
}, );
}
#[test]
#[cfg(feature = "lsp")]
fn test_position_to_offset_crlf() {
let source = "ab\r\ncd";
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 0
},
source
),
0,
);
assert_eq!(
position_to_offset(
Position {
line: 0,
character: 2
},
source
),
2, );
assert_eq!(
position_to_offset(
Position {
line: 1,
character: 0
},
source
),
4, );
assert_eq!(
position_to_offset(
Position {
line: 1,
character: 1
},
source
),
5, );
}
#[test]
#[cfg(feature = "lsp")]
fn test_roundtrip_crlf() {
let source = "line1\r\nline2\r\nline3";
for offset in [0, 1, 4, 7, 8, 11, 14, 15, 18] {
if offset <= source.len() {
let pos = offset_to_position(offset, source);
let back = position_to_offset(pos, source);
assert_eq!(
back, offset,
"Roundtrip failed for offset {}: pos=({},{}), got {}",
offset, pos.line, pos.character, back
);
}
}
}
#[test]
#[cfg(feature = "lsp")]
fn test_isolated_cr_line_ending() {
let source = "abc\rdef";
assert_eq!(
offset_to_position(3, source),
Position {
line: 0,
character: 3
}, );
assert_eq!(
offset_to_position(4, source),
Position {
line: 1,
character: 0
}, );
assert_eq!(
offset_to_position(6, source),
Position {
line: 1,
character: 2
}, );
}
#[test]
#[cfg(feature = "lsp")]
fn test_position_to_offset_isolated_cr() {
let source = "abc\rdef";
assert_eq!(
position_to_offset(
Position {
line: 1,
character: 0
},
source
),
4, );
assert_eq!(
position_to_offset(
Position {
line: 1,
character: 2
},
source
),
6, );
}
#[test]
#[cfg(feature = "lsp")]
fn test_double_cr_before_lf() {
let source = "abc\r\r\ndef";
assert_eq!(
offset_to_position(4, source),
Position {
line: 1,
character: 0
}, );
assert_eq!(
offset_to_position(6, source),
Position {
line: 2,
character: 0
}, );
}
#[test]
#[cfg(feature = "lsp")]
fn test_cr_at_eof() {
let source = "abc\r";
assert_eq!(
offset_to_position(4, source),
Position {
line: 1,
character: 0
},
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_mixed_line_endings() {
let source = "a\nb\r\nc\rd";
assert_eq!(
offset_to_position(2, source),
Position {
line: 1,
character: 0
}, );
assert_eq!(
offset_to_position(5, source),
Position {
line: 2,
character: 0
}, );
assert_eq!(
offset_to_position(7, source),
Position {
line: 3,
character: 0
}, );
}
#[test]
#[cfg(feature = "lsp")]
fn test_roundtrip_isolated_cr() {
let source = "abc\rdef";
for offset in [0, 1, 2, 4, 5, 6] {
let pos = offset_to_position(offset, source);
let back = position_to_offset(pos, source);
assert_eq!(
back, offset,
"Roundtrip failed for offset {} in {:?}: pos=({},{}), got {}",
offset, source, pos.line, pos.character, back
);
}
}
#[test]
#[cfg(feature = "lsp")]
fn test_unicode_with_isolated_cr() {
let source = "caf\u{00e9}\rna\u{00ef}ve";
let pos = offset_to_position(6, source);
assert_eq!(
pos,
Position {
line: 1,
character: 0
}
);
}
#[test]
#[cfg(feature = "lsp")]
fn test_comprehensive_char_boundary_roundtrip() {
let source = "A\u{00e9}\u{20ac}\u{1f3b5}\nB\r\nC\rD";
let bytes = source.as_bytes();
let mut byte_offset = 0;
for ch in source.chars() {
let is_cr_in_crlf =
ch == '\r' && byte_offset + 1 < bytes.len() && bytes[byte_offset + 1] == b'\n';
let is_lf_in_crlf = ch == '\n' && byte_offset > 0 && bytes[byte_offset - 1] == b'\r';
if !is_cr_in_crlf && !is_lf_in_crlf {
let pos = offset_to_position(byte_offset, source);
let recovered = position_to_offset(pos, source);
assert_eq!(
recovered, byte_offset,
"Roundtrip failed at byte_offset={byte_offset} (char '{ch}'): \
pos=({},{}) recovered={recovered}",
pos.line, pos.character
);
}
byte_offset += ch.len_utf8();
}
let pos = offset_to_position(byte_offset, source);
let recovered = position_to_offset(pos, source);
assert_eq!(
recovered, byte_offset,
"Roundtrip failed at end-of-string offset={byte_offset}"
);
}
}