use tower_lsp_server::ls_types::*;
use crate::linter;
use crate::linter::Severity as PanacheSeverity;
pub(crate) type ByteEditRange = (usize, usize);
pub(crate) type AppliedEditChange = (String, ByteEditRange, ByteEditRange);
pub(crate) fn position_to_offset(text: &str, position: Position) -> Option<usize> {
let mut offset = 0;
let mut current_line = 0;
let bytes = text.as_bytes();
for line in text.lines() {
if current_line == position.line {
let mut utf16_offset = 0;
for (byte_idx, ch) in line.char_indices() {
if utf16_offset >= position.character as usize {
return Some(offset + byte_idx);
}
utf16_offset += ch.len_utf16();
}
return Some(offset + line.len());
}
let line_end_offset = offset + line.len();
let line_ending_len = if line_end_offset + 1 < text.len()
&& bytes[line_end_offset] == b'\r'
&& bytes[line_end_offset + 1] == b'\n'
{
2 } else if line_end_offset < text.len() && bytes[line_end_offset] == b'\n' {
1 } else {
0 };
offset += line.len() + line_ending_len;
current_line += 1;
}
if current_line == position.line {
return Some(offset);
}
None
}
pub(crate) fn offset_to_position(text: &str, offset: usize) -> Position {
let mut line = 0;
let mut character = 0;
let mut current_offset = 0;
let bytes = text.as_bytes();
for text_line in text.lines() {
if current_offset + text_line.len() >= offset {
let line_offset = offset - current_offset;
character = text_line
.char_indices()
.take_while(|(byte_idx, _)| *byte_idx < line_offset)
.map(|(_, c)| c.len_utf16())
.sum::<usize>() as u32;
break;
}
let line_end_offset = current_offset + text_line.len();
let line_ending_len = if line_end_offset + 1 < text.len()
&& bytes[line_end_offset] == b'\r'
&& bytes[line_end_offset + 1] == b'\n'
{
2 } else if line_end_offset < text.len() && bytes[line_end_offset] == b'\n' {
1 } else {
0 };
current_offset += text_line.len() + line_ending_len;
line += 1;
}
Position {
line: line as u32,
character,
}
}
pub(crate) fn convert_diagnostic(diag: &linter::Diagnostic, text: &str) -> Diagnostic {
let start = offset_to_position(text, diag.location.range.start().into());
let end = offset_to_position(text, diag.location.range.end().into());
let severity = match diag.severity {
PanacheSeverity::Error => DiagnosticSeverity::ERROR,
PanacheSeverity::Warning => DiagnosticSeverity::WARNING,
PanacheSeverity::Info => DiagnosticSeverity::INFORMATION,
};
Diagnostic {
range: Range { start, end },
severity: Some(severity),
code: Some(NumberOrString::String(diag.code.clone())),
source: Some("panache".to_string()),
message: if diag.notes.is_empty() {
diag.message.clone()
} else {
let mut message = diag.message.clone();
for note in &diag.notes {
message.push('\n');
match note.kind {
linter::DiagnosticNoteKind::Note => message.push_str("note: "),
linter::DiagnosticNoteKind::Help => message.push_str("help: "),
}
message.push_str(¬e.message);
}
message
},
..Default::default()
}
}
pub(crate) fn apply_content_change(text: &str, change: &TextDocumentContentChangeEvent) -> String {
match &change.range {
Some(range) => {
let start_offset = position_to_offset(text, range.start).unwrap_or(0);
let end_offset = position_to_offset(text, range.end).unwrap_or(text.len());
let mut result =
String::with_capacity(text.len() - (end_offset - start_offset) + change.text.len());
result.push_str(&text[..start_offset]);
result.push_str(&change.text);
result.push_str(&text[end_offset..]);
result
}
None => {
change.text.clone()
}
}
}
pub(crate) fn apply_content_change_with_edit_ranges(
text: &str,
change: &TextDocumentContentChangeEvent,
) -> Option<AppliedEditChange> {
let range = change.range?;
let start_offset = position_to_offset(text, range.start)?;
let end_offset = position_to_offset(text, range.end)?;
if start_offset > end_offset || end_offset > text.len() {
return None;
}
let mut result =
String::with_capacity(text.len() - (end_offset - start_offset) + change.text.len());
result.push_str(&text[..start_offset]);
result.push_str(&change.text);
result.push_str(&text[end_offset..]);
let new_end = start_offset + change.text.len();
Some((result, (start_offset, end_offset), (start_offset, new_end)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offset_to_position_simple() {
let text = "hello\nworld\n";
let pos = offset_to_position(text, 0);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 0);
let pos = offset_to_position(text, 3);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 3);
let pos = offset_to_position(text, 6);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 0);
let pos = offset_to_position(text, 9);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 3);
}
#[test]
fn test_offset_to_position_utf16() {
let text = "café\n";
let pos = offset_to_position(text, 0);
assert_eq!(pos.character, 0);
let pos = offset_to_position(text, 3);
assert_eq!(pos.character, 3);
let pos = offset_to_position(text, 5);
assert_eq!(pos.character, 4);
}
#[test]
fn test_offset_to_position_emoji() {
let text = "hi👋\n";
let pos = offset_to_position(text, 2);
assert_eq!(pos.character, 2);
let pos = offset_to_position(text, 6);
assert_eq!(pos.character, 4);
}
#[test]
fn test_convert_diagnostic_basic() {
use crate::linter::diagnostics::{
Diagnostic as PanacheDiagnostic, DiagnosticOrigin, Location, Severity,
};
use rowan::TextRange;
let text = "# H1\n\n### H3\n";
let diag = PanacheDiagnostic {
severity: Severity::Warning,
location: Location {
line: 3,
column: 1,
range: TextRange::new(7.into(), 14.into()),
},
message: "Heading level skipped from h1 to h3".to_string(),
code: "heading-hierarchy".to_string(),
origin: DiagnosticOrigin::BuiltIn,
notes: Vec::new(),
fix: None,
};
let lsp_diag = convert_diagnostic(&diag, text);
assert_eq!(lsp_diag.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(
lsp_diag.code,
Some(NumberOrString::String("heading-hierarchy".to_string()))
);
assert_eq!(lsp_diag.source, Some("panache".to_string()));
assert!(lsp_diag.message.contains("h1 to h3"));
assert_eq!(lsp_diag.range.start.line, 2); }
#[test]
fn test_convert_diagnostic_severity() {
use crate::linter::diagnostics::{
Diagnostic as PanacheDiagnostic, DiagnosticOrigin, Location, Severity,
};
use rowan::TextRange;
let text = "test\n";
let error_diag = PanacheDiagnostic {
severity: Severity::Error,
location: Location {
line: 1,
column: 1,
range: TextRange::new(0.into(), 4.into()),
},
message: "Error".to_string(),
code: "test-error".to_string(),
origin: DiagnosticOrigin::BuiltIn,
notes: Vec::new(),
fix: None,
};
let lsp_diag = convert_diagnostic(&error_diag, text);
assert_eq!(lsp_diag.severity, Some(DiagnosticSeverity::ERROR));
let info_diag = PanacheDiagnostic {
severity: Severity::Info,
location: Location {
line: 1,
column: 1,
range: TextRange::new(0.into(), 4.into()),
},
message: "Info".to_string(),
code: "test-info".to_string(),
origin: DiagnosticOrigin::BuiltIn,
notes: Vec::new(),
fix: None,
};
let lsp_diag = convert_diagnostic(&info_diag, text);
assert_eq!(lsp_diag.severity, Some(DiagnosticSeverity::INFORMATION));
}
#[test]
fn test_position_to_offset_simple() {
let text = "hello\nworld\n";
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 0
}
),
Some(0)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 3
}
),
Some(3)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 5
}
),
Some(5)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 1,
character: 0
}
),
Some(6)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 1,
character: 3
}
),
Some(9)
);
}
#[test]
fn test_position_to_offset_utf8() {
let text = "café\nworld\n";
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 0
}
),
Some(0)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 1
}
),
Some(1)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 2
}
),
Some(2)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 3
}
),
Some(3)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 4
}
),
Some(5)
);
}
#[test]
fn test_position_to_offset_emoji() {
let text = "hi👋\n";
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 2
}
),
Some(2)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 4
}
),
Some(6)
);
}
#[test]
fn test_apply_content_change_insert() {
let text = "hello world";
let change = TextDocumentContentChangeEvent {
range: Some(Range {
start: Position {
line: 0,
character: 6,
},
end: Position {
line: 0,
character: 6,
},
}),
range_length: None,
text: "beautiful ".to_string(),
};
assert_eq!(apply_content_change(text, &change), "hello beautiful world");
}
#[test]
fn test_apply_content_change_delete() {
let text = "hello beautiful world";
let change = TextDocumentContentChangeEvent {
range: Some(Range {
start: Position {
line: 0,
character: 5,
},
end: Position {
line: 0,
character: 15,
},
}),
range_length: None,
text: String::new(),
};
assert_eq!(apply_content_change(text, &change), "hello world");
}
#[test]
fn test_apply_content_change_replace() {
let text = "hello world";
let change = TextDocumentContentChangeEvent {
range: Some(Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 5,
},
}),
range_length: None,
text: "goodbye".to_string(),
};
assert_eq!(apply_content_change(text, &change), "goodbye world");
}
#[test]
fn test_apply_content_change_full_document() {
let text = "old content";
let change = TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "new content".to_string(),
};
assert_eq!(apply_content_change(text, &change), "new content");
}
#[test]
fn test_apply_content_change_multiline() {
let text = "line1\nline2\nline3";
let change = TextDocumentContentChangeEvent {
range: Some(Range {
start: Position {
line: 1,
character: 2,
},
end: Position {
line: 2,
character: 2,
},
}),
range_length: None,
text: "NEW\nLINE".to_string(),
};
assert_eq!(apply_content_change(text, &change), "line1\nliNEW\nLINEne3");
}
#[test]
fn test_position_to_offset_crlf() {
let text = "hello\r\nworld\r\n";
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 0
}
),
Some(0)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 0,
character: 3
}
),
Some(3)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 1,
character: 0
}
),
Some(7)
);
assert_eq!(
position_to_offset(
text,
Position {
line: 1,
character: 3
}
),
Some(10)
);
}
#[test]
fn test_offset_to_position_crlf() {
let text = "hello\r\nworld\r\n";
let pos = offset_to_position(text, 0);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 0);
let pos = offset_to_position(text, 3);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 3);
let pos = offset_to_position(text, 7);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 0);
let pos = offset_to_position(text, 10);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 3);
}
#[test]
fn test_offset_to_position_inside_multibyte_char() {
let text = "ä\n";
let pos = offset_to_position(text, 1);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 1);
}
#[test]
fn test_offset_to_position_inside_multibyte_char_crlf() {
let text = "åäö\r\nnext\r\n";
let pos = offset_to_position(text, 1);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 1);
let pos = offset_to_position(text, 5);
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 3);
let pos = offset_to_position(text, 8);
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 0);
}
}