use std::path::{Path, PathBuf};
use serde_json::{Value, json};
pub fn column(line: &str, byte: usize) -> u32 {
let byte = floor_char_boundary(line, byte);
line[..byte].encode_utf16().count() as u32
}
pub fn byte_at(line: &str, column: u32) -> usize {
let mut units = 0u32;
for (byte, character) in line.char_indices() {
if units >= column {
return byte;
}
units += character.len_utf16() as u32;
}
line.len()
}
fn floor_char_boundary(line: &str, byte: usize) -> usize {
let mut byte = byte.min(line.len());
while !line.is_char_boundary(byte) {
byte -= 1;
}
byte
}
pub fn range(lines: &[&str], index: usize, from: usize, to: usize) -> Value {
let line = lines.get(index).copied().unwrap_or("");
json!({
"start": {"line": index, "character": column(line, from)},
"end": {"line": index, "character": column(line, to)},
})
}
pub fn whole_line(index: usize, line: &str) -> Value {
json!({
"start": {"line": index, "character": 0},
"end": {"line": index, "character": column(line, line.len())},
})
}
pub fn lines_range(lines: &[&str], from: usize, to: usize) -> Value {
let last = lines.get(to).copied().unwrap_or("");
json!({
"start": {"line": from, "character": 0},
"end": {"line": to, "character": column(last, last.len())},
})
}
pub fn content_range(lines: &[&str], index: usize) -> Value {
let line = lines.get(index).copied().unwrap_or("");
let start = line.len() - line.trim_start().len();
range(lines, index, start, line.trim_end().len().max(start))
}
pub fn split_lines(text: &str) -> Vec<&str> {
text.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
.collect()
}
pub fn comment_at(line: &str) -> usize {
let mut quoted = false;
let bytes = line.as_bytes();
for (index, &byte) in bytes.iter().enumerate() {
match byte {
b'"' => quoted = !quoted,
b'-' if !quoted && bytes.get(index + 1) == Some(&b'-') => return index,
_ => {}
}
}
line.len()
}
pub fn path_of(uri: &str) -> Option<PathBuf> {
let encoded = uri.strip_prefix("file://")?.as_bytes();
let mut decoded = Vec::with_capacity(encoded.len());
let mut index = 0;
while index < encoded.len() {
let byte = encoded[index];
if byte == b'%' && index + 2 < encoded.len() {
let hex = std::str::from_utf8(&encoded[index + 1..index + 3]).ok()?;
decoded.push(u8::from_str_radix(hex, 16).ok()?);
index += 3;
} else {
decoded.push(byte);
index += 1;
}
}
Some(PathBuf::from(String::from_utf8(decoded).ok()?))
}
pub fn uri_of(path: &Path) -> String {
let mut uri = String::from("file://");
for byte in path.to_string_lossy().bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
uri.push(byte as char)
}
_ => uri.push_str(&format!("%{byte:02X}")),
}
}
uri
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn columns_count_utf16_units() {
let line = "é x";
assert_eq!(column(line, 3), 2, "é is two bytes but one unit");
assert_eq!(byte_at(line, 2), 3);
}
#[test]
fn uris_round_trip() {
let path = Path::new("/tmp/a b/01-sleepless.rt");
let uri = uri_of(path);
assert_eq!(uri, "file:///tmp/a%20b/01-sleepless.rt");
assert_eq!(path_of(&uri).as_deref(), Some(path));
}
#[test]
fn comments_inside_notation_are_not_comments() {
assert_eq!(comment_at("a sine \"0 -- 1\" -- here"), 16);
}
}