Skip to main content

perl_position_tracking/
wire.rs

1//! LSP wire types and conversion helpers.
2//!
3//! This module defines the protocol-facing equivalents of internal span/position
4//! types. The wire types use:
5//!
6//! - 0-based line indexes
7//! - UTF-16 code unit character offsets (per LSP)
8//!
9//! Use [`WirePosition::from_byte_offset`] and [`WirePosition::to_byte_offset`]
10//! to convert between parser byte offsets and LSP-compatible coordinates.
11use crate::{offset_to_utf16_line_col, utf16_line_col_to_offset};
12use serde::{Deserialize, Serialize};
13
14/// A protocol-facing LSP position.
15///
16/// Both fields are 0-based. `character` is measured in UTF-16 code units.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18pub struct WirePosition {
19    /// Zero-based line number.
20    pub line: u32,
21    /// Zero-based UTF-16 code-unit offset within the line.
22    pub character: u32,
23}
24impl WirePosition {
25    /// Creates a new wire position from explicit line and character values.
26    pub fn new(line: u32, character: u32) -> Self {
27        Self { line, character }
28    }
29
30    /// Converts a byte offset in `source` into an LSP wire position.
31    pub fn from_byte_offset(source: &str, byte_offset: usize) -> Self {
32        let (line, character) = offset_to_utf16_line_col(source, byte_offset);
33        Self { line, character }
34    }
35
36    /// Converts this LSP wire position back into a byte offset in `source`.
37    pub fn to_byte_offset(&self, source: &str) -> usize {
38        utf16_line_col_to_offset(source, self.line, self.character)
39    }
40}
41
42/// A protocol-facing LSP range with inclusive start and exclusive end.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44pub struct WireRange {
45    /// Start position of the range (inclusive).
46    pub start: WirePosition,
47    /// End position of the range (exclusive).
48    pub end: WirePosition,
49}
50impl WireRange {
51    /// Creates a new range from start and end positions.
52    pub fn new(start: WirePosition, end: WirePosition) -> Self {
53        Self { start, end }
54    }
55
56    /// Builds a wire range from start/end byte offsets in `source`.
57    pub fn from_byte_offsets(source: &str, start_byte: usize, end_byte: usize) -> Self {
58        Self {
59            start: WirePosition::from_byte_offset(source, start_byte),
60            end: WirePosition::from_byte_offset(source, end_byte),
61        }
62    }
63
64    /// Creates an empty (cursor) range at `pos`.
65    pub fn empty(pos: WirePosition) -> Self {
66        Self { start: pos, end: pos }
67    }
68
69    /// Creates a range that spans the full document.
70    pub fn whole_document(source: &str) -> Self {
71        Self {
72            start: WirePosition::new(0, 0),
73            end: WirePosition::from_byte_offset(source, source.len()),
74        }
75    }
76}
77
78/// A protocol-facing location that combines a URI and a range.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct WireLocation {
81    /// Document URI.
82    pub uri: String,
83    /// Range within the referenced document.
84    pub range: WireRange,
85}
86impl WireLocation {
87    /// Creates a new wire location.
88    pub fn new(uri: String, range: WireRange) -> Self {
89        Self { uri, range }
90    }
91}
92#[cfg(feature = "lsp-compat")]
93impl From<WirePosition> for lsp_types::Position {
94    fn from(p: WirePosition) -> Self {
95        Self { line: p.line, character: p.character }
96    }
97}
98#[cfg(feature = "lsp-compat")]
99impl From<lsp_types::Position> for WirePosition {
100    fn from(p: lsp_types::Position) -> Self {
101        Self { line: p.line, character: p.character }
102    }
103}
104#[cfg(feature = "lsp-compat")]
105impl From<WireRange> for lsp_types::Range {
106    fn from(r: WireRange) -> Self {
107        Self { start: r.start.into(), end: r.end.into() }
108    }
109}
110#[cfg(feature = "lsp-compat")]
111impl From<lsp_types::Range> for WireRange {
112    fn from(r: lsp_types::Range) -> Self {
113        Self { start: r.start.into(), end: r.end.into() }
114    }
115}
116#[cfg(feature = "lsp-compat")]
117fn fallback_lsp_uri() -> lsp_types::Uri {
118    for candidate in ["file:///unknown", "file:///", "about:blank", "urn:perl-lsp:unknown"] {
119        if let Ok(uri) = candidate.parse::<lsp_types::Uri>() {
120            return uri;
121        }
122    }
123
124    // Last-resort fallback that avoids panicking if URI parser behavior changes unexpectedly.
125    let mut suffix = 0usize;
126    loop {
127        let candidate = format!("http://localhost/{suffix}");
128        if let Ok(uri) = candidate.parse::<lsp_types::Uri>() {
129            return uri;
130        }
131        suffix = suffix.saturating_add(1);
132    }
133}
134
135#[cfg(feature = "lsp-compat")]
136impl From<WireLocation> for lsp_types::Location {
137    fn from(l: WireLocation) -> Self {
138        let uri = match l.uri.parse::<lsp_types::Uri>() {
139            Ok(u) => u,
140            Err(_) => fallback_lsp_uri(),
141        };
142        Self { uri, range: l.range.into() }
143    }
144}
145
146#[cfg(all(test, feature = "lsp-compat"))]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn wire_location_to_lsp_location_preserves_valid_uri() {
152        let wire_location = WireLocation::new(
153            "file:///tmp/example.pl".to_string(),
154            WireRange::new(WirePosition::new(1, 2), WirePosition::new(3, 4)),
155        );
156
157        let location: lsp_types::Location = wire_location.into();
158
159        assert_eq!(location.uri.as_str(), "file:///tmp/example.pl");
160        assert_eq!(location.range.start.line, 1);
161        assert_eq!(location.range.start.character, 2);
162        assert_eq!(location.range.end.line, 3);
163        assert_eq!(location.range.end.character, 4);
164    }
165
166    #[test]
167    fn wire_location_to_lsp_location_uses_fallback_for_invalid_uri() {
168        let wire_location = WireLocation::new(
169            "not a uri".to_string(),
170            WireRange::new(WirePosition::new(0, 0), WirePosition::new(0, 1)),
171        );
172
173        let location: lsp_types::Location = wire_location.into();
174
175        assert_ne!(location.uri.as_str(), "not a uri");
176        assert!(!location.uri.as_str().is_empty());
177        assert_eq!(location.range.start.line, 0);
178        assert_eq!(location.range.end.character, 1);
179    }
180}