Skip to main content

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}