oxml_lsp/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 oxml. All rights reserved.
3
4//! Diagnostics for XML documents, in the shape an editor wants.
5//!
6//! The analysis lives here rather than in the binary so it can be
7//! tested without a language-server transport in the way. An editor
8//! integration is mostly plumbing; the part worth testing is what it
9//! decides to say.
10
11#![forbid(unsafe_code)]
12
13pub mod lsp;
14
15use std::collections::BTreeSet;
16
17/// How serious a diagnostic is, matching LSP's numbering.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Severity {
20 /// The document is broken.
21 Error = 1,
22 /// Legal, but probably not intended.
23 Warning = 2,
24 /// Informational.
25 Information = 3,
26 /// A gentle suggestion.
27 Hint = 4,
28}
29
30/// A position in a document, zero-based as LSP requires.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Position {
33 /// Zero-based line.
34 pub line: usize,
35 /// Zero-based Unicode scalar offset within the line. The LSP transport
36 /// converts this to UTF-16 code units; native callers retain scalars.
37 pub character: usize,
38}
39
40/// One diagnostic.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Diagnostic {
43 /// Where it starts.
44 pub start: Position,
45 /// Where it ends.
46 pub end: Position,
47 /// How serious.
48 pub severity: Severity,
49 /// What to tell the user.
50 pub message: String,
51 /// A stable identifier, so an editor can group or suppress.
52 pub code: &'static str,
53}
54
55/// Analyse a document and return everything worth reporting.
56///
57/// A well-formedness error stops the analysis: once the tree cannot be
58/// built there is nothing further to say, and guessing at structure
59/// past a parse failure produces cascades of diagnostics that all
60/// describe the same underlying mistake.
61#[must_use]
62pub fn analyse(source: &str) -> Vec<Diagnostic> {
63 let doc = match oxml::parse_with(source, oxml::Limits::strict()) {
64 Ok(doc) => doc,
65 Err(e) => {
66 let (line, column) = e.line_column(source);
67 // `line_column` is 1-based for humans; LSP is 0-based.
68 let start = Position {
69 line: line.saturating_sub(1),
70 character: column.saturating_sub(1),
71 };
72 return vec![Diagnostic {
73 start,
74 end: Position {
75 line: start.line,
76 character: start.character + 1,
77 },
78 severity: Severity::Error,
79 message: e.to_string(),
80 code: "not-well-formed",
81 }];
82 }
83 };
84
85 let mut out = Vec::new();
86 lint_document(&doc, source, &mut out);
87 out
88}
89
90fn lint_document(
91 doc: &oxml::Document,
92 source: &str,
93 out: &mut Vec<Diagnostic>,
94) {
95 // Duplicated ids are legal XML but almost always a mistake, and
96 // they break every tool that resolves references by id.
97 //
98 // Two things here used to make `analyse` quadratic, and an editor
99 // calls it on every keystroke. A benchmark caught it: 200 entries
100 // took 1.8 ms and 5,000 took **1,088 ms** -- twenty-six times the
101 // input for six hundred times the work.
102 //
103 // The set was a `Vec` searched linearly, which is O(n) per
104 // element. Worse, `at_line` scanned the whole source to find each
105 // id, in the branch where the id is *not* a duplicate -- so a
106 // document with five thousand distinct ids performed five
107 // thousand full-source scans and reported nothing at all.
108 //
109 // A `BTreeSet` fixes the first. The second is fixed by not asking
110 // where the id is until a duplicate proves it worth knowing.
111 let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
112
113 for id in doc.descendants() {
114 if !doc.is_element(id) {
115 continue;
116 }
117 if let Some(value) = doc.attribute(id, "id") {
118 if seen_ids.contains(value) {
119 // Both positions are computed only now, when there is
120 // a diagnostic to attach them to.
121 let at = locate(source, value);
122 let first = at.line;
123 out.push(Diagnostic {
124 start: at,
125 end: Position {
126 line: at.line,
127 character: at.character + value.chars().count(),
128 },
129 severity: Severity::Warning,
130 message: format!(
131 "duplicate id `{value}`; first used on line {}",
132 first + 1
133 ),
134 code: "duplicate-id",
135 });
136 } else {
137 let _ = seen_ids.insert(value);
138 }
139 }
140 }
141
142 // An empty element with no attributes carries no information, and
143 // is usually a leftover.
144 for id in doc.descendants() {
145 let Some(name) = doc.element_name(id) else {
146 continue;
147 };
148 if doc.children(id).is_empty()
149 && doc.attribute_nodes(id).is_empty()
150 && doc.parent(id) != Some(doc.root())
151 {
152 let at = locate(source, &format!("<{}", name.local));
153 out.push(Diagnostic {
154 start: at,
155 end: Position {
156 line: at.line,
157 character: at.character + name.local.chars().count() + 1,
158 },
159 severity: Severity::Hint,
160 message: format!(
161 "`{}` is empty and has no attributes",
162 name.local
163 ),
164 code: "empty-element",
165 });
166 }
167 }
168}
169
170/// Find where a fragment first appears.
171///
172/// The tree does not carry source spans, so positions are recovered by
173/// search. That is honest about its limits: for a repeated value it
174/// finds the first occurrence, which is why the duplicate-id message
175/// names the line rather than relying on the range alone.
176fn locate(source: &str, needle: &str) -> Position {
177 source.find(needle).map_or(
178 Position {
179 line: 0,
180 character: 0,
181 },
182 |offset| {
183 let before = &source[..offset];
184 Position {
185 line: before.matches('\n').count(),
186 character: before
187 .rsplit('\n')
188 .next()
189 .map_or(0, |l| l.chars().count()),
190 }
191 },
192 )
193}