use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use tower_lsp::jsonrpc::Result as RpcResult;
use tower_lsp::lsp_types::{
Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializeParams, InitializeResult,
InitializedParams, MessageType, NumberOrString, Position, Range, ServerCapabilities,
ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind, Url,
};
use tower_lsp::{Client, LanguageServer, LspService, Server};
use crate::args::LspArgs;
use crate::checker::check_bytes;
use crate::config::{Config, find_config_path};
use crate::diagnostic::{Diagnostic as PoDiagnostic, Severity as PoSeverity};
struct Backend {
client: Client,
documents: RwLock<HashMap<Url, String>>,
}
impl Backend {
fn new(client: Client) -> Self {
Self {
client,
documents: RwLock::new(HashMap::new()),
}
}
async fn upsert_and_publish(&self, uri: Url, text: String, include_disk_rules: bool) {
let diagnostics = analyze(&uri, &text, include_disk_rules);
if let Ok(mut documents) = self.documents.write() {
documents.insert(uri.clone(), text);
}
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _params: InitializeParams) -> RpcResult<InitializeResult> {
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
..ServerCapabilities::default()
},
server_info: Some(ServerInfo {
name: "poexam".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
})
}
async fn initialized(&self, _params: InitializedParams) {
self.client
.log_message(MessageType::INFO, "poexam language server initialized")
.await;
}
async fn shutdown(&self) -> RpcResult<()> {
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
self.upsert_and_publish(params.text_document.uri, params.text_document.text, true)
.await;
}
async fn did_change(&self, mut params: DidChangeTextDocumentParams) {
if let Some(change) = params.content_changes.pop() {
self.upsert_and_publish(params.text_document.uri, change.text, false)
.await;
}
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
let uri = params.text_document.uri;
let text = self
.documents
.read()
.ok()
.and_then(|documents| documents.get(&uri).cloned());
if let Some(text) = text {
let diagnostics = analyze(&uri, &text, true);
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri;
if let Ok(mut documents) = self.documents.write() {
documents.remove(&uri);
}
self.client.publish_diagnostics(uri, Vec::new(), None).await;
}
}
const DISK_CONTENT_RULES: &[&str] = &["compilation"];
fn analyze(uri: &Url, text: &str, include_disk_rules: bool) -> Vec<Diagnostic> {
let path = uri
.to_file_path()
.unwrap_or_else(|()| PathBuf::from("untitled.po"));
let file_lines: Vec<&str> = text.lines().collect();
let mut config = match load_config(&path) {
Ok(config) => config,
Err(message) => return vec![config_error_diagnostic(&message, &file_lines)],
};
apply_buffer_rule_policy(&mut config, include_disk_rules);
check_bytes(text.as_bytes(), &path, config)
.iter()
.map(|diag| to_lsp_diagnostic(diag, &file_lines))
.collect()
}
fn load_config(path: &Path) -> Result<Config, String> {
let Some(config_path) = find_config_path(path) else {
return Ok(Config::default());
};
match Config::new(Some(&config_path)) {
Ok(mut config) => {
config.resolve_relative_paths();
Ok(config)
}
Err(err) => Err(format!(
"invalid config file (path: {}): {err}",
config_path.display()
)),
}
}
fn config_error_diagnostic(message: &str, file_lines: &[&str]) -> Diagnostic {
Diagnostic {
range: whole_line_range(0, file_lines),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("config-error".to_string())),
source: Some("poexam".to_string()),
message: message.to_string(),
..Diagnostic::default()
}
}
fn apply_buffer_rule_policy(config: &mut Config, include_disk_rules: bool) {
if !include_disk_rules {
for rule in DISK_CONTENT_RULES {
config.check.ignore.push((*rule).to_string());
}
}
}
fn to_lsp_diagnostic(diag: &PoDiagnostic, file_lines: &[&str]) -> Diagnostic {
Diagnostic {
range: diagnostic_range(diag, file_lines),
severity: Some(to_lsp_severity(diag.severity)),
code: Some(NumberOrString::String(diag.rule.to_string())),
source: Some("poexam".to_string()),
message: diag.build_message().into_owned(),
..Diagnostic::default()
}
}
fn diagnostic_range(diag: &PoDiagnostic, file_lines: &[&str]) -> Range {
if let Some(range) = highlighted_range(diag, file_lines) {
return range;
}
let line_index = diag
.lines
.iter()
.rev()
.map(|line| line.line_number)
.find(|&number| number > 0)
.map_or(0, |number| number - 1);
whole_line_range(line_index, file_lines)
}
fn highlighted_range(diag: &PoDiagnostic, file_lines: &[&str]) -> Option<Range> {
let line = diag
.lines
.iter()
.rev()
.find(|line| line.line_number > 0 && line.highlights.iter().any(|(s, e)| e > s))?;
let line_index = line.line_number - 1;
let file_line = file_lines.get(line_index)?;
let span_start = line
.highlights
.iter()
.filter(|(s, e)| e > s)
.map(|(s, _)| *s)
.min()?;
let span_end = line
.highlights
.iter()
.filter(|(s, e)| e > s)
.map(|(_, e)| *e)
.max()?;
let content_start = file_line.find('"')? + 1;
if !file_line
.get(content_start..)?
.starts_with(line.message.as_str())
{
return None;
}
let row = u32::try_from(line_index).unwrap_or(u32::MAX);
let start = utf16_col(file_line, content_start + span_start);
let end = utf16_col(file_line, content_start + span_end);
Some(Range::new(
Position::new(row, start),
Position::new(row, end),
))
}
fn whole_line_range(line_index: usize, file_lines: &[&str]) -> Range {
let row = u32::try_from(line_index).unwrap_or(u32::MAX);
let end_column = file_lines
.get(line_index)
.map_or(0, |text| line_len_utf16(text));
Range::new(Position::new(row, 0), Position::new(row, end_column))
}
fn utf16_col(line: &str, byte: usize) -> u32 {
let prefix = line.get(..byte).unwrap_or(line);
u32::try_from(prefix.encode_utf16().count()).unwrap_or(u32::MAX)
}
fn line_len_utf16(line: &str) -> u32 {
u32::try_from(line.encode_utf16().count()).unwrap_or(u32::MAX)
}
fn to_lsp_severity(severity: PoSeverity) -> DiagnosticSeverity {
match severity {
PoSeverity::Error => DiagnosticSeverity::ERROR,
PoSeverity::Warning => DiagnosticSeverity::WARNING,
PoSeverity::Info => DiagnosticSeverity::INFORMATION,
}
}
pub fn run_lsp(_args: &LspArgs) -> i32 {
let runtime = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(err) => {
eprintln!("poexam: failed to start the LSP runtime: {err}");
return 1;
}
};
runtime.block_on(async {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(Backend::new);
Server::new(stdin, stdout, socket).serve(service).await;
});
0
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::*;
use crate::diagnostic::DiagnosticLine;
fn config_select(select: &[&str]) -> Config {
let mut config = Config::default();
config.check.select = select.iter().map(|s| (*s).to_string()).collect();
config
}
fn po_diag(lines: &[(usize, &str)]) -> PoDiagnostic {
PoDiagnostic {
path: PathBuf::from("fr.po"),
rule: "blank",
severity: PoSeverity::Warning,
message: "blank translation".into(),
lines: lines
.iter()
.map(|(number, message)| DiagnosticLine {
line_number: *number,
message: (*message).to_string(),
highlights: vec![],
})
.collect(),
misspelled_words: std::collections::HashSet::new(),
fix: None,
}
}
#[test]
fn test_to_lsp_severity_maps_all_levels() {
assert_eq!(
to_lsp_severity(PoSeverity::Error),
DiagnosticSeverity::ERROR
);
assert_eq!(
to_lsp_severity(PoSeverity::Warning),
DiagnosticSeverity::WARNING
);
assert_eq!(
to_lsp_severity(PoSeverity::Info),
DiagnosticSeverity::INFORMATION
);
}
#[test]
fn test_diagnostic_range_anchors_on_last_numbered_line_in_utf16() {
let diag = po_diag(&[(6, "source"), (0, ""), (8, "translation")]);
let file_lines = ["a", "b", "c", "d", "e", "msgid \"x\"", "", "msgstr \"😀\""];
let range = diagnostic_range(&diag, &file_lines);
assert_eq!(range.start, Position::new(7, 0));
assert_eq!(range.end, Position::new(7, 11));
}
#[test]
fn test_diagnostic_range_no_numbered_line_defaults_to_first_line() {
let diag = po_diag(&[(0, "kw")]);
let file_lines = ["hello"];
let range = diagnostic_range(&diag, &file_lines);
assert_eq!(range.start, Position::new(0, 0));
assert_eq!(range.end, Position::new(0, 5));
}
#[test]
fn test_diagnostic_range_line_beyond_buffer_yields_zero_width() {
let diag = po_diag(&[(99, "x")]);
let file_lines = ["only one line"];
let range = diagnostic_range(&diag, &file_lines);
assert_eq!(range.start, Position::new(98, 0));
assert_eq!(range.end, Position::new(98, 0));
}
#[test]
fn test_diagnostic_range_uses_highlight_span_on_value() {
let mut diag = po_diag(&[(6, "hello"), (0, ""), (8, "bonjour ")]);
diag.lines[2].highlights = vec![(7, 8)];
let file_lines = [
"a",
"b",
"c",
"d",
"e",
"msgid \"hello\"",
"",
"msgstr \"bonjour \"",
];
let range = diagnostic_range(&diag, &file_lines);
assert_eq!(range.start, Position::new(7, 15));
assert_eq!(range.end, Position::new(7, 16));
}
#[test]
fn test_diagnostic_range_falls_back_to_whole_line_for_escaped_value() {
let mut diag = po_diag(&[(1, "a\nb")]);
diag.lines[0].highlights = vec![(0, 3)];
let raw = "msgstr \"a\\nb\"";
let file_lines = [raw];
let range = diagnostic_range(&diag, &file_lines);
assert_eq!(range.start, Position::new(0, 0));
assert_eq!(range.end, Position::new(0, line_len_utf16(raw)));
}
#[test]
fn test_to_lsp_diagnostic_sets_rule_source_and_message() {
let diag = po_diag(&[(2, "x")]);
let file_lines = ["l1", "msgstr \"\""];
let lsp = to_lsp_diagnostic(&diag, &file_lines);
assert_eq!(lsp.source.as_deref(), Some("poexam"));
assert_eq!(lsp.code, Some(NumberOrString::String("blank".to_string())));
assert_eq!(lsp.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(lsp.message, "blank translation");
assert_eq!(lsp.range.start, Position::new(1, 0));
}
#[test]
fn test_apply_buffer_rule_policy_skips_disk_rules_when_dirty() {
let mut config = Config::default();
apply_buffer_rule_policy(&mut config, false);
assert!(config.check.ignore.iter().any(|rule| rule == "compilation"));
}
#[test]
fn test_apply_buffer_rule_policy_keeps_disk_rules_when_clean() {
let mut config = Config::default();
apply_buffer_rule_policy(&mut config, true);
assert!(config.check.ignore.is_empty());
}
fn tmp_repo(label: &str, config: Option<&str>) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::TempDir::with_prefix(format!("poexam-lsp-{label}-"))
.expect("create temp dir");
if let Some(content) = config {
std::fs::write(tmp.path().join("poexam.toml"), content).expect("write config");
}
let po = tmp.path().join("fr.po");
std::fs::write(&po, "msgid \"\"\nmsgstr \"\"\n").expect("write po file");
(tmp, po)
}
#[test]
fn test_load_config_invalid_config_yields_error_message() {
let (_tmp, po) = tmp_repo("bad-cfg", Some("[check]\nshort_factor = 1\n"));
let message = load_config(&po).expect_err("invalid config is an error");
assert!(message.contains("invalid config file"));
assert!(message.contains("check.short_factor"));
}
#[test]
fn test_config_error_diagnostic_anchors_on_non_empty_first_line() {
let file_lines = ["msgid \"x\"", "msgstr \"y\""];
let diag = config_error_diagnostic("invalid config file (path: x): boom", &file_lines);
assert_eq!(
diag.code,
Some(NumberOrString::String("config-error".to_string()))
);
assert_eq!(diag.severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(diag.range.start, Position::new(0, 0));
assert!(
diag.range.end.character > 0,
"config-error range must be non-empty so editors render it, got {:?}",
diag.range,
);
}
#[test]
fn test_load_config_no_config_file_yields_defaults() {
let (_tmp, po) = tmp_repo("no-cfg", None);
let config = load_config(&po).expect("defaults when no config found");
assert_eq!(config.check.select, vec!["default".to_string()]);
}
#[test]
fn test_load_config_valid_config_is_applied() {
let (_tmp, po) = tmp_repo(
"valid-cfg",
Some("[check]\nselect = [\"whitespace-end\"]\n"),
);
let config = load_config(&po).expect("valid config loads");
assert_eq!(config.check.select, vec!["whitespace-end".to_string()]);
}
#[test]
fn test_analyze_invalid_config_returns_only_config_error() {
let (_tmp, po) = tmp_repo("analyze-bad", Some("[check]\nlong_factor = 0\n"));
let uri = Url::from_file_path(&po).expect("file url");
let diags = analyze(&uri, "msgid \"x\"\nmsgstr \"y \"\n", true);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].code,
Some(NumberOrString::String("config-error".to_string()))
);
assert!(diags[0].range.end.character > 0);
}
#[test]
fn test_dirty_buffer_skips_compilation_rule() {
let mut config = config_select(&["compilation"]);
apply_buffer_rule_policy(&mut config, false);
let diags = check_bytes(
b"msgid \"\"\nmsgstr \"\"\n",
Path::new("/no/such/fr.po"),
config,
);
assert!(diags.iter().all(|d| d.rule != "compilation"));
}
#[test]
fn test_clean_buffer_runs_compilation_rule() {
let mut config = config_select(&["compilation"]);
apply_buffer_rule_policy(&mut config, true);
let diags = check_bytes(
b"msgid \"\"\nmsgstr \"\"\n",
Path::new("/no/such/fr.po"),
config,
);
assert!(diags.iter().any(|d| d.rule == "compilation"));
}
}