use anyhow::{bail, Context, Result};
use lsp_types::{Diagnostic, DiagnosticSeverity, Range, Url};
pub fn diags(uri: &Url, text: &str, proto_paths: &[std::path::PathBuf]) -> Result<Vec<Diagnostic>> {
if uri.scheme() != "file" {
bail!("Unsupported URI scheme {uri}");
}
let Ok(path) = uri.to_file_path() else {
bail!("Failed to normalize URI path: {uri}");
};
let mut cmd = std::process::Command::new("protoc");
cmd
.args(["-o", if cfg!(windows) { "NUL" } else { "/dev/null" }])
.args(
proto_paths
.iter()
.filter_map(|p| {
p.to_str().or_else(|| {
log::warn!("Non-unicode path: {p:?}");
None
})
})
.map(|p| "-I".to_string() + p),
)
.arg(
path.to_str()
.with_context(|| format!("Non-unicode path: {path:?}"))?,
);
log::debug!("Running protoc: {cmd:?}");
let output = cmd.output()?;
log::debug!("Protoc exited: {output:?}");
let stderr = std::str::from_utf8(output.stderr.as_slice())?;
Ok(stderr.lines().filter_map(|l| parse_diag(l, text)).collect())
}
fn parse_diag(diag: &str, file_contents: &str) -> Option<lsp_types::Diagnostic> {
log::debug!("Parsing diagnostic {diag}");
let (_, rest) = diag.split_once(".proto:")?;
let (linestr, rest) = rest.split_once(':')?;
let (_, msg) = rest.split_once(':')?;
let msg = msg.trim().trim_end_matches(".");
log::debug!("Parsing msg {msg}");
let (msg, severity) = match msg.strip_prefix("warning: ") {
Some(msg) => (msg, DiagnosticSeverity::WARNING),
None => (msg, DiagnosticSeverity::ERROR),
};
let lineno = linestr.parse::<u32>().unwrap() - 1;
let line = file_contents.lines().nth(lineno.try_into().ok()?)?;
let start_byte = line.find(|c: char| !c.is_whitespace()).unwrap_or(0);
let end_byte = line
.rfind(|c: char| !c.is_whitespace())
.map(|c| c + 1) .unwrap_or(line.len());
let start = line[..start_byte].encode_utf16().count();
let end = start + line[start_byte..end_byte].encode_utf16().count();
Some(lsp_types::Diagnostic {
range: Range {
start: lsp_types::Position {
line: lineno,
character: start.try_into().ok()?,
},
end: lsp_types::Position {
line: lineno,
character: end.try_into().ok()?,
},
},
severity: Some(severity),
source: Some(String::from("pbls")),
message: msg.trim().into(),
..Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn proto(tmp: &tempfile::TempDir, path: &str, lines: &[&str]) -> (Url, String) {
let path = tmp.path().join(path);
let text = lines.join("\n") + "\n";
std::fs::write(&path, &text).unwrap();
(Url::from_file_path(path).unwrap(), text)
}
#[test]
fn test_errors() {
let _ = env_logger::builder().is_test(true).try_init();
let tmp = tempfile::tempdir().unwrap();
let (uri, text) = proto(
&tmp,
"foo.proto",
&[
"syntax = \"proto3\";",
"message Foo {",
"int i = 1;",
"uint32 u = 1;",
"}",
],
);
let diags = diags(&uri, &text, &vec![tmp.path().to_path_buf()]).unwrap();
assert_eq!(diags.len(), 2);
assert_eq!(
diags[0],
Diagnostic {
range: Range {
start: lsp_types::Position {
line: 2,
character: 0,
},
end: lsp_types::Position {
line: 2,
character: 10,
},
},
severity: Some(DiagnosticSeverity::ERROR),
source: Some("pbls".into()),
message: "\"int\" is not defined".into(),
..Default::default()
},
);
assert_eq!(
diags[1].range,
Range {
start: lsp_types::Position {
line: 3,
character: 0,
},
end: lsp_types::Position {
line: 3,
character: 13,
},
},
);
assert_eq!(diags[1].severity, Some(DiagnosticSeverity::ERROR));
assert!(
diags[1]
.message
.starts_with("Field number 1 has already been used in \"Foo\" by field \"i\""),
"unexpected message: {}",
diags[1].message,
);
}
#[test]
fn test_warnings() {
let _ = env_logger::builder().is_test(true).try_init();
let tmp = tempfile::tempdir().unwrap();
proto(&tmp, "bar.proto", &["syntax = \"proto3\";"]);
let (uri, text) = proto(
&tmp,
"foo.proto",
&["syntax = \"proto3\";", "import \"bar.proto\";"],
);
let diags = diags(&uri, &text, &vec![tmp.path().to_path_buf()]).unwrap();
assert_eq!(
diags,
vec![Diagnostic {
range: Range {
start: lsp_types::Position {
line: 1,
character: 0,
},
end: lsp_types::Position {
line: 1,
character: 19,
},
},
severity: Some(DiagnosticSeverity::WARNING),
source: Some("pbls".into()),
message: "Import bar.proto is unused".into(),
..Default::default()
},]
);
}
}