ts_bridge/utils/
mod.rs

1//! =============================================================================
2//! Utility Helpers
3//! =============================================================================
4//!
5//! Range conversions, throttling/debouncing, and other helper utilities land
6//! here so both the protocol handlers and the RPC bridge can reuse them without
7//! reimplementing the same glue each time.
8
9use std::path::Path;
10use std::str::FromStr;
11
12use lsp_types::{CompletionItemKind, Location, LocationLink, Uri};
13use serde_json::Value;
14use url::Url;
15
16use crate::types::{Position, Range, TextDocumentContentChangeEvent, TextDocumentItem};
17
18/// Converts an LSP `Range` into the tsserver 1-based coordinate space.
19pub fn lsp_range_to_tsserver(range: &Range) -> TsserverRange {
20    TsserverRange {
21        start: lsp_position_to_tsserver(&range.start),
22        end: lsp_position_to_tsserver(&range.end),
23    }
24}
25
26pub fn lsp_position_to_tsserver(position: &Position) -> TsserverPosition {
27    TsserverPosition {
28        line: position.line + 1,
29        offset: position.character + 1,
30    }
31}
32
33/// Tsserver understands 1-based line/offset coordinates.
34#[derive(Debug, Clone, Copy)]
35pub struct TsserverPosition {
36    pub line: u32,
37    pub offset: u32,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub struct TsserverRange {
42    pub start: TsserverPosition,
43    pub end: TsserverPosition,
44}
45
46pub fn uri_to_file_path(uri: &str) -> Option<String> {
47    let parsed = Url::parse(uri).ok()?;
48    if let Ok(path) = parsed.to_file_path() {
49        return Some(path.to_string_lossy().into_owned());
50    }
51
52    if parsed.scheme() != "file" {
53        return None;
54    }
55
56    let mut path = parsed.path().to_string();
57    if path.is_empty() {
58        return None;
59    }
60
61    // When URLs omit a Windows drive letter (common in tests or WSL),
62    // `to_file_path` rejects the value. Fall back to the raw path so the
63    // tsserver payloads still receive stable `/workspace/foo.ts` strings.
64    if cfg!(windows) {
65        // Preserve leading "/" so expectations stay platform-independent.
66        path = path.replace('\\', "/");
67    }
68
69    Some(path)
70}
71
72pub fn file_path_to_uri(path: &str) -> Option<Uri> {
73    if path.starts_with("file://") {
74        return Uri::from_str(path).ok();
75    }
76
77    if let Ok(url) = Url::from_file_path(path) {
78        return Uri::from_str(url.as_str()).ok();
79    }
80
81    if Path::new(path).is_absolute() || path.starts_with('/') {
82        // Normalise to forward slashes so file URLs remain valid across OSes.
83        let sanitized = path.replace('\\', "/");
84        let formatted = format!("file://{sanitized}");
85        return Uri::from_str(&formatted).ok();
86    }
87
88    None
89}
90
91pub fn lsp_text_doc_to_tsserver_entry(
92    doc: &TextDocumentItem,
93    workspace_root: Option<&Path>,
94) -> serde_json::Value {
95    let file = uri_to_file_path(&doc.uri).unwrap_or_else(|| doc.uri.clone());
96    let script_kind = script_kind_from_language(doc.language_id.as_deref());
97    let mut entry = serde_json::json!({
98        "file": file,
99        "fileContent": doc.text,
100        "scriptKindName": script_kind,
101    });
102
103    if let Some(root) = workspace_root {
104        if let Some(obj) = entry.as_object_mut() {
105            obj.insert(
106                "projectRootPath".to_string(),
107                serde_json::json!(root.to_string_lossy().into_owned()),
108            );
109        }
110    }
111
112    entry
113}
114
115fn script_kind_from_language(lang: Option<&str>) -> &'static str {
116    match lang {
117        Some("javascript") => "JS",
118        Some("javascriptreact") => "JSX",
119        Some("typescriptreact") => "TSX",
120        Some("json") => "JSON",
121        _ => "TS",
122    }
123}
124
125pub fn tsserver_text_changes_from_edits(
126    edits: &[TextDocumentContentChangeEvent],
127) -> Vec<serde_json::Value> {
128    let mut changes = Vec::with_capacity(edits.len());
129    for change in edits.iter().rev() {
130        let Some(range) = &change.range else {
131            log::warn!(
132                "dropping textDocument/didChange edit without range; incremental sync is required"
133            );
134            continue;
135        };
136
137        let mut payload = serde_json::json!({ "newText": change.text });
138        let ts_range = lsp_range_to_tsserver(range);
139        if let Some(obj) = payload.as_object_mut() {
140            obj.insert(
141                "start".to_string(),
142                serde_json::json!({
143                    "line": ts_range.start.line,
144                    "offset": ts_range.start.offset
145                }),
146            );
147            obj.insert(
148                "end".to_string(),
149                serde_json::json!({
150                    "line": ts_range.end.line,
151                    "offset": ts_range.end.offset
152                }),
153            );
154        }
155        changes.push(payload);
156    }
157    changes
158}
159
160pub fn tsserver_position_value(value: &Value) -> Option<Position> {
161    let line = value.get("line")?.as_u64()? as u32;
162    let offset = value.get("offset")?.as_u64()? as u32;
163    Some(Position {
164        line: line.saturating_sub(1),
165        character: offset.saturating_sub(1),
166    })
167}
168
169pub fn tsserver_range_from_value(value: &Value) -> Option<Range> {
170    let start = tsserver_position_value(value.get("start")?)?;
171    let end = tsserver_position_value(value.get("end")?)?;
172    Some(Range { start, end })
173}
174
175pub fn tsserver_position_value_lsp(value: &Value) -> Option<lsp_types::Position> {
176    let pos = tsserver_position_value(value)?;
177    Some(lsp_types::Position {
178        line: pos.line,
179        character: pos.character,
180    })
181}
182
183pub fn tsserver_range_from_value_lsp(value: &Value) -> Option<lsp_types::Range> {
184    let start = tsserver_position_value_lsp(value.get("start")?)?;
185    let end = tsserver_position_value_lsp(value.get("end")?)?;
186    Some(lsp_types::Range { start, end })
187}
188
189pub fn tsserver_file_to_uri(path: &str) -> Option<Uri> {
190    if path.starts_with("zipfile://") {
191        Uri::from_str(path).ok()
192    } else {
193        file_path_to_uri(path)
194    }
195}
196
197pub fn tsserver_span_to_location(value: &Value) -> Option<Location> {
198    let file = value.get("file")?.as_str()?;
199    let uri = tsserver_file_to_uri(file)?;
200    let range = tsserver_range_from_value_lsp(value)?;
201    Some(Location { uri, range })
202}
203
204pub fn tsserver_span_to_location_link(
205    value: &Value,
206    origin: Option<lsp_types::Range>,
207) -> Option<LocationLink> {
208    let file = value.get("file")?.as_str()?;
209    let target_uri = tsserver_file_to_uri(file)?;
210    let target_selection_range = tsserver_range_from_value_lsp(value)?;
211    let target_range =
212        if let (Some(start), Some(end)) = (value.get("contextStart"), value.get("contextEnd")) {
213            tsserver_range_from_value_lsp(&serde_json::json!({ "start": start, "end": end }))
214                .unwrap_or_else(|| target_selection_range.clone())
215        } else {
216            target_selection_range.clone()
217        };
218
219    Some(LocationLink {
220        origin_selection_range: origin,
221        target_range,
222        target_selection_range,
223        target_uri,
224    })
225}
226
227pub fn completion_item_kind_from_tsserver(kind: Option<&str>) -> CompletionItemKind {
228    match kind {
229        Some("keyword") => CompletionItemKind::KEYWORD,
230        Some("script") | Some("module") | Some("external module name") => {
231            CompletionItemKind::MODULE
232        }
233        Some("class") | Some("local class") => CompletionItemKind::CLASS,
234        Some("interface") => CompletionItemKind::INTERFACE,
235        Some("type") | Some("type parameter") => CompletionItemKind::TYPE_PARAMETER,
236        Some("enum") => CompletionItemKind::ENUM,
237        Some("enum member") => CompletionItemKind::ENUM_MEMBER,
238        Some("var") | Some("local var") | Some("let") => CompletionItemKind::VARIABLE,
239        Some("function") | Some("local function") => CompletionItemKind::FUNCTION,
240        Some("method") => CompletionItemKind::METHOD,
241        Some("getter") | Some("setter") | Some("property") => CompletionItemKind::PROPERTY,
242        Some("constructor") => CompletionItemKind::CONSTRUCTOR,
243        Some("call") | Some("index") | Some("construct") => CompletionItemKind::METHOD,
244        Some("parameter") => CompletionItemKind::FIELD,
245        Some("primitive type") | Some("label") => CompletionItemKind::KEYWORD,
246        Some("alias") => CompletionItemKind::VARIABLE,
247        Some("const") => CompletionItemKind::CONSTANT,
248        Some("directory") => CompletionItemKind::FILE,
249        Some("string") => CompletionItemKind::CONSTANT,
250        _ => CompletionItemKind::TEXT,
251    }
252}
253
254pub fn completion_commit_characters(kind: CompletionItemKind) -> Option<Vec<String>> {
255    match kind {
256        CompletionItemKind::CLASS => Some(vec![".".into(), ",".into(), "(".into()]),
257        CompletionItemKind::CONSTANT => Some(vec![".".into(), "?".into()]),
258        CompletionItemKind::CONSTRUCTOR => Some(vec!["(".into()]),
259        CompletionItemKind::ENUM => Some(vec![".".into()]),
260        CompletionItemKind::FIELD => Some(vec![".".into(), "(".into()]),
261        CompletionItemKind::FUNCTION => Some(vec![".".into(), "(".into()]),
262        CompletionItemKind::INTERFACE => Some(vec![":".into(), ".".into()]),
263        CompletionItemKind::METHOD => Some(vec!["(".into()]),
264        CompletionItemKind::MODULE => Some(vec![".".into(), "?".into()]),
265        CompletionItemKind::PROPERTY => Some(vec![".".into(), "?".into()]),
266        CompletionItemKind::VARIABLE => Some(vec![".".into(), "?".into()]),
267        _ => None,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::types::{Position, Range, TextDocumentContentChangeEvent, TextDocumentItem};
275    use serde_json::json;
276
277    fn range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> Range {
278        Range {
279            start: Position {
280                line: start_line,
281                character: start_char,
282            },
283            end: Position {
284                line: end_line,
285                character: end_char,
286            },
287        }
288    }
289
290    #[test]
291    fn lsp_range_to_tsserver_is_one_based() {
292        let input = range(0, 0, 4, 15); // first line/column in LSP space
293        let converted = lsp_range_to_tsserver(&input);
294        assert_eq!(converted.start.line, 1);
295        assert_eq!(converted.start.offset, 1);
296        assert_eq!(converted.end.line, 5);
297        assert_eq!(converted.end.offset, 16);
298    }
299
300    #[test]
301    fn tsserver_text_changes_from_edits_skips_full_sync_edits() {
302        let edits = vec![
303            TextDocumentContentChangeEvent {
304                range: Some(range(1, 2, 1, 5)),
305                text: "foo".to_string(),
306            },
307            TextDocumentContentChangeEvent {
308                range: None,
309                text: "dropped".to_string(),
310            },
311        ];
312
313        let changes = tsserver_text_changes_from_edits(&edits);
314        assert_eq!(changes.len(), 1);
315        assert_eq!(
316            changes[0],
317            json!({
318                "newText": "foo",
319                "start": {"line": 2, "offset": 3},
320                "end": {"line": 2, "offset": 6}
321            })
322        );
323    }
324
325    #[test]
326    fn file_path_uri_roundtrip() {
327        let path = std::env::temp_dir().join("ts-bridge-test.ts");
328        let path_str = path.to_str().expect("temp path is valid utf-8");
329        let uri = file_path_to_uri(path_str).expect("path converts to URI");
330        let roundtrip = uri_to_file_path(uri.as_str()).expect("URI converts back to path");
331        assert_eq!(Path::new(&roundtrip), path);
332    }
333
334    #[test]
335    fn lsp_text_doc_to_tsserver_entry_sets_project_root() {
336        let doc = TextDocumentItem {
337            uri: "file:///tmp/sample.ts".to_string(),
338            language_id: Some("typescript".to_string()),
339            version: 1,
340            text: "const x = 1;".to_string(),
341        };
342        let root = Path::new("/tmp/project-root");
343        let entry = lsp_text_doc_to_tsserver_entry(&doc, Some(root));
344        assert_eq!(entry["file"], json!("/tmp/sample.ts"));
345        assert_eq!(entry["fileContent"], json!("const x = 1;"));
346        assert_eq!(entry["scriptKindName"], json!("TS"));
347        assert_eq!(
348            entry["projectRootPath"],
349            json!(root.to_string_lossy().to_string())
350        );
351    }
352}