use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use lanekeep_core::{Severity, Violation};
use serde_json::{Value, json};
mod severity {
pub(super) const ERROR: u8 = 1;
pub(super) const WARNING: u8 = 2;
}
#[must_use]
pub fn capabilities() -> Value {
json!({
"capabilities": {
"textDocumentSync": {
"openClose": true,
"change": 1,
"save": { "includeText": false },
},
},
"serverInfo": {
"name": "lanekeep",
"version": env!("CARGO_PKG_VERSION"),
},
})
}
#[must_use]
pub fn to_range(violation: &Violation) -> Value {
let line = violation.location.position.line.saturating_sub(1);
let character = violation.location.position.column.saturating_sub(1);
json!({
"start": { "line": line, "character": character },
"end": { "line": line, "character": character + 1 },
})
}
#[must_use]
pub fn to_diagnostic(violation: &Violation) -> Value {
json!({
"range": to_range(violation),
"severity": match violation.severity {
Severity::Error => severity::ERROR,
_ => severity::WARNING,
},
"source": "lanekeep",
"code": violation.rule_id.to_string(),
"message": format!("{}\n{}", violation.message, violation.remediation),
})
}
#[must_use]
pub fn by_file(root: &Path, violations: &[Violation]) -> BTreeMap<PathBuf, Vec<Value>> {
let mut grouped: BTreeMap<PathBuf, Vec<Value>> = BTreeMap::new();
for violation in violations {
grouped
.entry(root.join(violation.location.file.as_str()))
.or_default()
.push(to_diagnostic(violation));
}
grouped
}
#[must_use]
pub fn path_from_uri(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let rest = if rest.len() > 2
&& rest.starts_with('/')
&& rest.as_bytes()[2] == b':'
&& rest.as_bytes()[1].is_ascii_alphabetic()
{
&rest[1..]
} else {
rest
};
Some(PathBuf::from(percent_decode(rest)))
}
#[must_use]
pub fn uri_from_path(path: &Path) -> String {
let text = path.to_string_lossy().replace('\\', "/");
let text = if text.starts_with('/') {
text
} else {
format!("/{text}")
};
format!("file://{}", percent_encode(&text))
}
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok();
if let Some(byte) = hex.and_then(|hex| u8::from_str_radix(hex, 16).ok()) {
out.push(byte);
index += 3;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn percent_encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
out.push(byte as char);
}
other => {
use std::fmt::Write as _;
let _ = write!(out, "%{other:02X}");
}
}
}
out
}
#[cfg(test)]
mod tests {
use lanekeep_core::{FilePath, Location, Position, RuleId};
use super::*;
fn violation(line: u32, column: u32, severity: Severity) -> Violation {
Violation {
rule_id: "local/example".parse::<RuleId>().expect("valid"),
location: Location::new(FilePath::new("src/a.ts"), Position::new(line, column)),
message: "something".to_owned(),
remediation: "do this".to_owned(),
severity,
fix: None,
}
}
#[test]
fn positions_convert_from_one_based_to_zero_based() {
let range = to_range(&violation(9, 5, Severity::Error));
assert_eq!(range["start"]["line"], 8);
assert_eq!(range["start"]["character"], 4);
}
#[test]
fn the_first_line_and_column_do_not_underflow() {
let range = to_range(&violation(1, 1, Severity::Error));
assert_eq!(range["start"]["line"], 0);
assert_eq!(range["start"]["character"], 0);
assert_eq!(range["end"]["character"], 1);
}
#[test]
fn severity_maps_to_the_lsp_numbers() {
assert_eq!(
to_diagnostic(&violation(1, 1, Severity::Error))["severity"],
severity::ERROR
);
assert_eq!(
to_diagnostic(&violation(1, 1, Severity::Warn))["severity"],
severity::WARNING
);
}
#[test]
fn a_diagnostic_carries_the_rule_id_and_both_lines() {
let diagnostic = to_diagnostic(&violation(1, 1, Severity::Error));
assert_eq!(diagnostic["code"], "local/example");
assert_eq!(diagnostic["source"], "lanekeep");
let message = diagnostic["message"].as_str().expect("a string");
assert!(message.contains("something"), "{message}");
assert!(message.contains("do this"), "{message}");
}
#[test]
fn violations_group_by_file_as_absolute_paths() {
let root = Path::new("/project");
let grouped = by_file(
root,
&[
violation(1, 1, Severity::Error),
violation(2, 1, Severity::Warn),
],
);
assert_eq!(grouped.len(), 1);
assert_eq!(
grouped.keys().next().expect("one file"),
Path::new("/project/src/a.ts")
);
assert_eq!(grouped.values().next().expect("one file").len(), 2);
}
#[test]
fn a_uri_round_trips_through_a_path() {
for path in ["/project/src/a.ts", "/project/with space/b.ts"] {
let uri = uri_from_path(Path::new(path));
assert_eq!(
path_from_uri(&uri).as_deref(),
Some(Path::new(path)),
"{uri}"
);
}
}
#[test]
fn a_space_is_percent_encoded_and_decoded() {
assert_eq!(uri_from_path(Path::new("/a b/c.ts")), "file:///a%20b/c.ts");
assert_eq!(
path_from_uri("file:///a%20b/c.ts").as_deref(),
Some(Path::new("/a b/c.ts"))
);
}
#[test]
fn a_windows_uri_drops_the_slash_before_the_drive_letter() {
assert_eq!(
path_from_uri("file:///C:/project/a.ts").as_deref(),
Some(Path::new("C:/project/a.ts"))
);
}
#[test]
fn a_malformed_escape_is_left_alone_rather_than_dropped() {
assert_eq!(
path_from_uri("file:///a%zz/b.ts").as_deref(),
Some(Path::new("/a%zz/b.ts"))
);
}
#[test]
fn a_non_file_uri_is_refused() {
assert!(path_from_uri("untitled:Untitled-1").is_none());
assert!(path_from_uri("https://example.com/a.ts").is_none());
}
#[test]
fn capabilities_announce_open_and_save() {
let announced = capabilities();
let sync = &announced["capabilities"]["textDocumentSync"];
assert_eq!(sync["openClose"], true);
assert!(sync["save"].is_object());
assert_eq!(announced["serverInfo"]["name"], "lanekeep");
}
}