1use 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
18pub 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#[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 if cfg!(windows) {
65 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 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); 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}