Skip to main content

solidity_language_server/
utils.rs

1use lintspec_core::textindex::{TextIndex, compute_indices};
2use serde_json::Value;
3use std::sync::OnceLock;
4use tower_lsp::lsp_types::{Position, PositionEncodingKind};
5
6// ---------------------------------------------------------------------------
7// Position Encoding
8// ---------------------------------------------------------------------------
9
10static ENCODING: OnceLock<PositionEncoding> = OnceLock::new();
11
12/// Store the negotiated encoding.  Called exactly once from the LSP
13/// `initialize` handler.  Subsequent calls are silently ignored.
14pub fn set_encoding(enc: PositionEncoding) {
15    let _ = ENCODING.set(enc);
16}
17
18/// Read the negotiated encoding (falls back to UTF-16 if never set).
19pub fn encoding() -> PositionEncoding {
20    ENCODING.get().copied().unwrap_or_default()
21}
22
23/// How the LSP client counts column offsets within a line.
24///
25/// Set once during `initialize()` via [`set_encoding`] and read implicitly by
26/// [`byte_offset_to_position`] and [`position_to_byte_offset`].  All other
27/// modules are encoding-agnostic — they never need to know or pass this value.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum PositionEncoding {
30    /// Column = number of bytes from the start of the line (UTF-8 code units).
31    Utf8,
32    /// Column = number of UTF-16 code units from the start of the line.
33    /// This is the **mandatory default** per the LSP specification.
34    #[default]
35    Utf16,
36}
37
38impl PositionEncoding {
39    /// Pick the best encoding from the set the client advertises.
40    ///
41    /// Preference: UTF-8 if supported, otherwise UTF-16 (the mandatory fallback).
42    pub fn negotiate(client_encodings: Option<&[PositionEncodingKind]>) -> Self {
43        let Some(encodings) = client_encodings else {
44            return Self::default();
45        };
46        if encodings.contains(&PositionEncodingKind::UTF8) {
47            PositionEncoding::Utf8
48        } else {
49            PositionEncoding::Utf16
50        }
51    }
52}
53
54impl From<PositionEncoding> for PositionEncodingKind {
55    fn from(value: PositionEncoding) -> Self {
56        match value {
57            PositionEncoding::Utf8 => PositionEncodingKind::UTF8,
58            PositionEncoding::Utf16 => PositionEncodingKind::UTF16,
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Byte-offset to LSP Position conversion
65// ---------------------------------------------------------------------------
66
67/// Convert a tree-sitter node boundary into a [`Position`], without rescanning
68/// the file.
69///
70/// [`byte_offset_to_position`] walks from byte 0 on every call, which is fine
71/// once per request but quadratic when a whole syntax tree is converted node by
72/// node. Tree-sitter already reports `row` and a `byte_column` measured from the
73/// start of that row, so the line begins at `byte_offset - byte_column` and only
74/// that prefix has to be measured.
75///
76/// Taking `row` from tree-sitter also keeps the result in the same line model as
77/// the tree being walked; `TextIndex` treats U+2028/U+2029 as line terminators
78/// and tree-sitter does not, so deriving the row here instead would put symbols
79/// on a different line than the node they came from.
80pub fn byte_column_to_position(
81    source: &str,
82    row: usize,
83    byte_column: usize,
84    byte_offset: usize,
85) -> Position {
86    let column = match encoding() {
87        PositionEncoding::Utf8 => byte_column as u32,
88        PositionEncoding::Utf16 => {
89            let line_start = byte_offset.saturating_sub(byte_column);
90            source
91                .get(line_start..byte_offset)
92                .map_or(byte_column as u32, |prefix| {
93                    prefix.encode_utf16().count() as u32
94                })
95        }
96    };
97    Position::new(row as u32, column)
98}
99
100/// Convert a byte offset in `source` to a [`Position`] whose column unit depends
101/// on the negotiated [`PositionEncoding`].
102pub fn byte_offset_to_position(source: &str, byte_offset: usize) -> Position {
103    if source.is_empty() {
104        return Position::new(0, 0);
105    }
106
107    let idx = if byte_offset >= source.len() {
108        // Offset is at or past the end of source — walk the entire string.
109        // `compute_indices` only handles offsets that fall within the source.
110        let mut ti = TextIndex::ZERO;
111        let mut chars = source.chars().peekable();
112        while let Some(c) = chars.next() {
113            ti.advance(c, chars.peek());
114        }
115        ti
116    } else {
117        // SIMD-accelerated lookup for offsets within the source.
118        let indices = compute_indices(source, &[byte_offset]);
119        match indices.first() {
120            Some(ti) => *ti,
121            None => return Position::new(0, 0),
122        }
123    };
124
125    Position {
126        line: idx.line,
127        character: match encoding() {
128            PositionEncoding::Utf8 => idx.col_utf8,
129            PositionEncoding::Utf16 => idx.col_utf16,
130        },
131    }
132}
133
134/// Convert an LSP [`Position`] position back to a byte offset, where
135/// `character` is interpreted according to the negotiated [`PositionEncoding`].
136///
137/// Uses a single SIMD-accelerated pass with [`compute_indices`] to build a
138/// coarse index of the file at 128-byte intervals, then does a short linear
139/// walk (at most 128 bytes) with [`TextIndex::advance`] to find the exact
140/// byte offset.
141pub fn position_to_byte_offset(source: &str, pos: Position) -> usize {
142    if source.is_empty() {
143        return 0;
144    }
145
146    let enc = encoding();
147
148    // 1. Build chunk offsets at 128-byte intervals across the source.
149    let chunk_offsets: Vec<usize> = (0..source.len()).step_by(128).collect();
150
151    // 2. Single SIMD-accelerated pass — compute TextIndex for every chunk.
152    let chunk_indices = compute_indices(source, &chunk_offsets);
153
154    // 3. Find the last chunk that is still at or before the target position.
155    //    Multiple chunks can fall on the same line, so we must also check
156    //    the column to avoid starting past the target.
157    let start = chunk_indices
158        .iter()
159        .take_while(|ti| {
160            if ti.line < pos.line {
161                return true;
162            }
163            if ti.line == pos.line {
164                let col = match enc {
165                    PositionEncoding::Utf8 => ti.col_utf8,
166                    PositionEncoding::Utf16 => ti.col_utf16,
167                };
168                return col <= pos.character;
169            }
170            false
171        })
172        .last()
173        .copied()
174        .unwrap_or(TextIndex::ZERO);
175
176    // 4. Linear walk from `start` (at most ~128 bytes) to the exact position.
177    let mut idx = start;
178    let mut chars = source[idx.utf8..].chars().peekable();
179
180    while let Some(c) = chars.next() {
181        let col = match enc {
182            PositionEncoding::Utf8 => idx.col_utf8,
183            PositionEncoding::Utf16 => idx.col_utf16,
184        };
185        if idx.line >= pos.line && col >= pos.character {
186            return idx.utf8;
187        }
188        if idx.line == pos.line && c == '\n' {
189            return idx.utf8; // clamp to end of line
190        }
191        idx.advance(c, chars.peek());
192    }
193    source.len() // position past end of source
194}
195
196// ---------------------------------------------------------------------------
197// Identifier validation
198// ---------------------------------------------------------------------------
199
200/// Check whether `name` is a valid Solidity identifier
201pub fn is_valid_solidity_identifier(name: &str) -> bool {
202    let mut chars = name.chars();
203    let Some(first) = chars.next() else {
204        return false;
205    };
206    if !first.is_ascii_alphabetic() && first != '_' && first != '$' {
207        return false;
208    }
209    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') {
210        return false;
211    }
212    if SOLIDITY_KEYWORDS.contains(&name) {
213        return false;
214    }
215    if is_numeric_type_keyword(name) {
216        return false;
217    }
218    true
219}
220
221/// Keywords that are not allowed as identifiers in Solidity.
222///
223/// The grammar permits only 7 keywords as identifiers:
224/// `from`, `error`, `revert`, `global`, `transient`, `layout`, `at`.
225/// Everything else listed in the lexer is blacklisted here.
226const SOLIDITY_KEYWORDS: &[&str] = &[
227    // Active keywords
228    "abstract",
229    "address",
230    "anonymous",
231    "as",
232    "assembly",
233    "bool",
234    "break",
235    "bytes",
236    "calldata",
237    "catch",
238    "constant",
239    "constructor",
240    "continue",
241    "contract",
242    "delete",
243    "do",
244    "else",
245    "emit",
246    "enum",
247    "event",
248    "external",
249    "fallback",
250    "false",
251    "fixed",
252    "for",
253    "function",
254    "hex",
255    "if",
256    "immutable",
257    "import",
258    "indexed",
259    "interface",
260    "internal",
261    "is",
262    "library",
263    "mapping",
264    "memory",
265    "modifier",
266    "new",
267    "override",
268    "payable",
269    "pragma",
270    "private",
271    "public",
272    "pure",
273    "receive",
274    "return",
275    "returns",
276    "storage",
277    "string",
278    "struct",
279    "true",
280    "try",
281    "type",
282    "ufixed",
283    "unchecked",
284    "unicode",
285    "using",
286    "view",
287    "virtual",
288    "while",
289    // Reserved keywords (future use)
290    "after",
291    "alias",
292    "apply",
293    "auto",
294    "byte",
295    "case",
296    "copyof",
297    "default",
298    "define",
299    "final",
300    "implements",
301    "in",
302    "inline",
303    "let",
304    "macro",
305    "match",
306    "mutable",
307    "null",
308    "of",
309    "partial",
310    "promise",
311    "reference",
312    "relocatable",
313    "sealed",
314    "sizeof",
315    "static",
316    "supports",
317    "switch",
318    "typedef",
319    "typeof",
320    "var",
321];
322
323/// Check whether `name` is a numeric-type keyword: `int<N>`, `uint<N>`, or `bytes<N>`.
324fn is_numeric_type_keyword(name: &str) -> bool {
325    if let Some(suffix) = name
326        .strip_prefix("uint")
327        .or_else(|| name.strip_prefix("int"))
328    {
329        if suffix.is_empty() {
330            return true;
331        }
332        let Ok(n) = suffix.parse::<u16>() else {
333            return false;
334        };
335        return (8..=256).contains(&n) && n % 8 == 0;
336    }
337    if let Some(suffix) = name.strip_prefix("bytes") {
338        // bare "bytes" is in SOLIDITY_KEYWORDS; only "bytes1"–"bytes32" are handled here
339        let Ok(n) = suffix.parse::<u16>() else {
340            return false;
341        };
342        return (1..=32).contains(&n);
343    }
344    false
345}
346
347// ---------------------------------------------------------------------------
348// JSON AST helpers
349// ---------------------------------------------------------------------------
350
351/// Push a JSON value onto `stack` if the field `key` is an object or array.
352///
353/// Used during AST walks that iterate over child nodes without full
354/// typed deserialization. Objects are pushed as-is; arrays are flattened
355/// so each element is pushed individually.
356pub fn push_if_node_or_array<'a>(tree: &'a Value, key: &str, stack: &mut Vec<&'a Value>) {
357    if let Some(value) = tree.get(key) {
358        match value {
359            Value::Array(arr) => stack.extend(arr),
360            Value::Object(_) => stack.push(value),
361            _ => {}
362        }
363    }
364}