Skip to main content

pite_editor/
lsp.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! External Python language server over stdio (pyright-family).
3//!
4//! `pite-editor` never embeds a server: it spawns `pyright-langserver
5//! --stdio` (or `PITE_LSP_CMD`), syncs the code pane with
6//! `didOpen`/`didChange`, and surfaces completion, hover, signature
7//! help, and `publishDiagnostics`. The shipped `pite.pyi` stubs go on
8//! the server's path so `import pite` resolves. No DAP, no bundled binary.
9//!
10//! The protocol core is transport-agnostic: [`Bridge`] speaks JSON-RPC
11//! over any [`RpcTransport`]. Production uses [`StdioTransport`] (real
12//! child stdio); tests drive the same [`Bridge`] with a mock server.
13
14use std::collections::{HashMap, VecDeque};
15use std::io::{BufRead, BufReader, Write};
16use std::path::{Path, PathBuf};
17use std::process::{Child, Command, Stdio};
18use std::sync::mpsc::{self, Receiver};
19use std::time::{Duration, Instant};
20
21use anyhow::{Context, Result};
22use serde_json::Value;
23
24/// Documented prerequisite; keep in sync with `docs/lsp.md`.
25pub const PINNED_PYRIGHT: &str = "1.1.411";
26/// Default server command; [`ENV_CMD_OVERRIDE`] replaces it.
27pub const SERVER_CMD: &str = "pyright-langserver";
28/// Args for the default command.
29pub const SERVER_ARGS: &[&str] = &["--stdio"];
30/// Env override for the server command (a binary name or path).
31pub const ENV_CMD_OVERRIDE: &str = "PITE_LSP_CMD";
32/// Shipped stubs file; its parent dir goes on the server's `extraPaths`.
33pub const STUBS_FILE: &str = "pite.pyi";
34/// How long the background starter waits for `initialize`.
35const INIT_TIMEOUT: Duration = Duration::from_secs(20);
36
37/// Loud message when no server is installed. Editing continues without LSP.
38pub fn missing_server_message(cmd: &str) -> String {
39    format!(
40        "LSP unavailable: `{cmd}` not found on PATH. Install the pinned server \
41         (`pip install \"pyright=={PINNED_PYRIGHT}\"` ships `{SERVER_CMD}`) or point \
42         `{ENV_CMD_OVERRIDE}` at one. Editing continues without completion, hover, \
43         signature help, or server diagnostics."
44    )
45}
46
47/// Best-effort `file://` URI for a path. Falls back to the raw path text
48/// when the current dir is unknown; servers only need it as a document key.
49pub fn path_to_uri(path: &Path) -> String {
50    let abs = if path.is_absolute() {
51        path.to_path_buf()
52    } else {
53        std::env::current_dir()
54            .unwrap_or_else(|_| PathBuf::from("."))
55            .join(path)
56    };
57    uri_from_path_text(&abs.to_string_lossy())
58}
59
60/// Build a `file://` URI from an absolute path *string*, on any platform.
61/// Windows needs `file:///D:/dir/file.py` — forward slashes and a leading
62/// slash before the drive letter. Emitting `file://D:\dir\file.py` (naive
63/// concatenation) makes the server report the directory as nonexistent, so
64/// the conversion is explicit rather than string-pasted.
65pub fn uri_from_path_text(path: &str) -> String {
66    let normalized = path.replace('\\', "/");
67    let mut out = String::with_capacity(normalized.len() + 8);
68    out.push_str("file://");
69    if !normalized.starts_with('/') {
70        out.push('/');
71    }
72    for c in normalized.chars() {
73        match c {
74            ' ' => out.push_str("%20"),
75            '#' => out.push_str("%23"),
76            '?' => out.push_str("%3F"),
77            '%' => out.push_str("%25"),
78            other => out.push(other),
79        }
80    }
81    out
82}
83
84/// Byte offset of a char offset (egui cursors count chars).
85pub fn byte_offset_of_char(text: &str, char_offset: usize) -> usize {
86    text.char_indices()
87        .map(|(b, _)| b)
88        .nth(char_offset)
89        .unwrap_or(text.len())
90}
91
92/// `(line, character)` for an LSP position. Lines are 0-based;
93/// characters count UTF-16 code units per the protocol.
94pub fn offset_to_position(text: &str, byte_offset: usize) -> (u32, u32) {
95    let at = byte_offset.min(text.len());
96    let mut line = 0u32;
97    let mut units = 0u32;
98    for (b, c) in text.char_indices() {
99        if b >= at {
100            break;
101        }
102        if c == '\n' {
103            line += 1;
104            units = 0;
105        } else {
106            units += c.len_utf16() as u32;
107        }
108    }
109    (line, units)
110}
111
112/// Byte offset of the start of 1-based `line` (clamped to end of text).
113pub fn line_start_byte(text: &str, line: usize) -> usize {
114    if line <= 1 {
115        return 0;
116    }
117    let mut seen = 1usize;
118    for (b, c) in text.char_indices() {
119        if c == '\n' {
120            seen += 1;
121            if seen >= line {
122                return b + 1;
123            }
124        }
125    }
126    text.len()
127}
128
129/// Start byte offset of the identifier ending at `byte_offset`
130/// (letters, digits, `_`); completion replaces from here.
131pub fn word_start_before(text: &str, byte_offset: usize) -> usize {
132    let mut start = byte_offset.min(text.len());
133    while start > 0 {
134        match text[..start].chars().next_back() {
135            Some(c) if c.is_alphanumeric() || c == '_' => start -= c.len_utf8(),
136            _ => break,
137        }
138    }
139    start
140}
141
142/// End byte offset of the identifier starting at `byte_offset`
143/// (letters, digits, `_`); accepting mid-word replaces up to here.
144pub fn word_end_after(text: &str, byte_offset: usize) -> usize {
145    let mut end = byte_offset.min(text.len());
146    for c in text[end..].chars() {
147        if c.is_alphanumeric() || c == '_' {
148            end += c.len_utf8();
149        } else {
150            break;
151        }
152    }
153    end
154}
155
156/// First `major.minor.patch` in free-form `--version` output.
157pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
158    let mut digits = String::new();
159    for c in text.chars() {
160        if c.is_ascii_digit() || c == '.' {
161            digits.push(c);
162        } else if !digits.is_empty() {
163            break;
164        }
165    }
166    let mut parts = digits.split('.');
167    Some((
168        parts.next()?.parse().ok()?,
169        parts.next()?.parse().ok()?,
170        parts.next()?.parse().ok()?,
171    ))
172}
173
174/// Numeric triple comparison against the pin.
175pub fn version_at_least(have: (u64, u64, u64), want: (u64, u64, u64)) -> bool {
176    have >= want
177}
178
179fn pin_triple() -> (u64, u64, u64) {
180    let mut parts = PINNED_PYRIGHT.split('.');
181    (
182        parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
183        parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
184        parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
185    )
186}
187
188fn version_output(cmd: &str) -> Option<String> {
189    let out = Command::new(cmd).arg("--version").output().ok()?;
190    if !out.status.success() {
191        return None;
192    }
193    Some(String::from_utf8_lossy(&out.stdout).into_owned())
194}
195
196/// `pyright-langserver` speaks only stdio, so ask its `pyright` sibling.
197fn sibling_version(cmd: &str) -> Option<String> {
198    if Path::new(cmd).file_name().and_then(|s| s.to_str()) != Some(SERVER_CMD) {
199        return None;
200    }
201    let sibling = match Path::new(cmd).parent() {
202        Some(parent) if !parent.as_os_str().is_empty() => {
203            parent.join("pyright").to_string_lossy().into_owned()
204        }
205        _ => "pyright".to_string(),
206    };
207    version_output(&sibling)
208}
209
210/// Best-effort version probe: `(version, warning)`. Never fails; a missing
211/// binary surfaces later at spawn with the loud message.
212pub fn probe_server_version(cmd: &str) -> (Option<String>, Option<String>) {
213    let text = version_output(cmd).or_else(|| sibling_version(cmd));
214    let Some(text) = text else {
215        return (
216            None,
217            Some(format!(
218                "LSP: `{cmd} --version` failed; proceeding without a version check."
219            )),
220        );
221    };
222    match parse_version(&text) {
223        Some(have) => {
224            let label = format!("{}.{}.{}", have.0, have.1, have.2);
225            if version_at_least(have, pin_triple()) {
226                (Some(label), None)
227            } else {
228                (
229                    Some(label.clone()),
230                    Some(format!(
231                        "LSP: server is {label} but {PINNED_PYRIGHT} is pinned; \
232                         consider upgrading (`pip install \"pyright=={PINNED_PYRIGHT}\"`)."
233                    )),
234                )
235            }
236        }
237        None => (
238            None,
239            Some(format!(
240                "LSP: could not parse server version from {text:?}; proceeding."
241            )),
242        ),
243    }
244}
245
246/// Walk up from `start` looking for `python/pite/pite.pyi` (or a bare
247/// `pite.pyi`); return the dir containing it — what goes on `extraPaths`
248/// so `import pite` resolves to the shipped stubs.
249pub fn find_stubs_dir(start: &Path) -> Option<PathBuf> {
250    let mut dir = if start.is_file() {
251        start.parent()?.to_path_buf()
252    } else {
253        start.to_path_buf()
254    };
255    loop {
256        let nested = dir.join("python").join("pite").join(STUBS_FILE);
257        if nested.is_file() {
258            return Some(dir.join("python").join("pite"));
259        }
260        if dir.join(STUBS_FILE).is_file() {
261            return Some(dir);
262        }
263        if !dir.pop() {
264            return None;
265        }
266    }
267}
268
269/// Stubs baked into the editor binary (`python/pite/` at build time), so
270/// `import pite` resolves even where no checkout exists — installed bundles
271/// never shipped these files, and the walk-up search above finds nothing
272/// there. `pite.py` is the source sibling the checker needs beside `pite.pyi`
273/// (the real module is native via PyO3); types always come from the stubs.
274const EMBEDDED_STUBS: &str = include_str!("../python/pite.pyi");
275const EMBEDDED_SOURCE: &str = include_str!("../python/pite.py");
276
277/// Write the embedded stubs into `dir`, skipping files that already match.
278/// Returns the dir for `extraPaths`.
279pub fn write_stubs_to(dir: &Path) -> Result<PathBuf> {
280    for (name, contents) in [(STUBS_FILE, EMBEDDED_STUBS), ("pite.py", EMBEDDED_SOURCE)] {
281        let path = dir.join(name);
282        let current = std::fs::read_to_string(&path).unwrap_or_default();
283        if current != contents {
284            if let Some(parent) = path.parent() {
285                std::fs::create_dir_all(parent)?;
286            }
287            std::fs::write(&path, contents)?;
288        }
289    }
290    Ok(dir.to_path_buf())
291}
292
293/// Versioned scratch dir for the embedded stubs; the last resort behind
294/// `find_stubs_dir`, so a checkout's own stubs always win.
295pub fn materialized_stubs_dir() -> Option<PathBuf> {
296    let dir = std::env::temp_dir().join(format!("pite-{}", env!("CARGO_PKG_VERSION")));
297    write_stubs_to(&dir).ok()
298}
299
300/// Frame a JSON-RPC value with `Content-Length` headers.
301pub fn encode_message(body: &Value) -> Vec<u8> {
302    let text = serde_json::to_string(body).expect("LSP body serializes");
303    let mut out = format!("Content-Length: {}\r\n\r\n", text.len()).into_bytes();
304    out.extend_from_slice(text.as_bytes());
305    out
306}
307
308/// Parse `Content-Length`-framed messages from any buffered reader.
309pub struct FramingReader<R: BufRead> {
310    inner: R,
311}
312
313impl<R: BufRead> FramingReader<R> {
314    pub fn new(inner: R) -> Self {
315        Self { inner }
316    }
317
318    pub fn read_message(&mut self) -> Result<Value> {
319        let mut len: Option<usize> = None;
320        loop {
321            let mut line = String::new();
322            let n = self
323                .inner
324                .read_line(&mut line)
325                .context("server closed stdio")?;
326            if n == 0 {
327                anyhow::bail!("server closed stdio");
328            }
329            let line = line.trim();
330            if line.is_empty() {
331                break;
332            }
333            if let Some(rest) = line.strip_prefix("Content-Length:") {
334                len = Some(rest.trim().parse().context("bad Content-Length")?);
335            }
336        }
337        let len = len.context("message without Content-Length")?;
338        let mut buf = vec![0u8; len];
339        self.inner
340            .read_exact(&mut buf)
341            .context("server closed stdio")?;
342        serde_json::from_slice(&buf).context("server sent invalid JSON")
343    }
344}
345
346fn request(id: i64, method: &str, params: Value) -> Value {
347    serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
348}
349
350fn notification(method: &str, params: Value) -> Value {
351    serde_json::json!({"jsonrpc": "2.0", "method": method, "params": params})
352}
353
354/// `initialize` carrying the project root. Settings travel separately via
355/// `didChangeConfiguration` (and `workspace/configuration` answers).
356///
357/// `workspaceFolders` is mandatory, not decorative: a server that only sees
358/// `rootUri` falls back to a synthetic `<default workspace root>`, finds no
359/// files there, and then never re-analyzes an edited document — which is
360/// exactly what pyright logs when the root is missing.
361pub fn initialize_request(id: i64, root_uri: Option<&str>) -> Value {
362    let folders: Value = root_uri
363        .map(|uri| serde_json::json!([{"uri": uri, "name": "pite"}]))
364        .unwrap_or_else(|| serde_json::json!([]));
365    request(
366        id,
367        "initialize",
368        serde_json::json!({
369            "processId": std::process::id(),
370            "rootUri": root_uri,
371            "workspaceFolders": folders,
372            "capabilities": {
373                "workspace": {"configuration": true},
374                "textDocument": {"publishDiagnostics": {}},
375            },
376        }),
377    )
378}
379
380pub fn initialized_notification() -> Value {
381    notification("initialized", serde_json::json!({}))
382}
383
384/// `extraPaths` + basic type-checking mode so the shipped stubs resolve.
385pub fn did_change_configuration(stubs_dir: Option<&Path>) -> Value {
386    let extra: Vec<String> = stubs_dir
387        .map(|d| vec![d.to_string_lossy().into_owned()])
388        .unwrap_or_default();
389    notification(
390        "workspace/didChangeConfiguration",
391        serde_json::json!({"settings": {
392            "python": {"analysis": {
393                "extraPaths": extra,
394                "typeCheckingMode": "basic",
395            }},
396        }}),
397    )
398}
399
400/// Answer a server `workspace/configuration` pull. The answer is the
401/// requested SECTION VALUE (`result[0]` pairs with `items[0]`): for section
402/// `"python"` that is the `{"analysis": ...}` object itself — NOT wrapped in
403/// another `"python"` layer, which the server would read as a section with
404/// no `analysis` and silently drop (that exact shape starved `import pite`
405/// of its stubs). Anything else gets an empty object (defaults hold).
406pub fn configuration_response(id: i64, section: &str, stubs_dir: Option<&Path>) -> Value {
407    let extra: Vec<String> = stubs_dir
408        .map(|d| vec![d.to_string_lossy().into_owned()])
409        .unwrap_or_default();
410    let result = if section == "python" {
411        serde_json::json!({"analysis": {
412            "extraPaths": extra,
413            "typeCheckingMode": "basic",
414        }})
415    } else {
416        serde_json::json!({})
417    };
418    serde_json::json!({"jsonrpc": "2.0", "id": id, "result": [result]})
419}
420
421pub fn did_open_notification(uri: &str, text: &str) -> Value {
422    notification(
423        "textDocument/didOpen",
424        serde_json::json!({"textDocument": {
425            "uri": uri, "languageId": "python", "version": 1, "text": text,
426        }}),
427    )
428}
429
430pub fn did_change_notification(uri: &str, version: i32, text: &str) -> Value {
431    notification(
432        "textDocument/didChange",
433        serde_json::json!({"textDocument": {"uri": uri, "version": version},
434            "contentChanges": [{"text": text}]}),
435    )
436}
437
438fn position_params(uri: &str, line: u32, character: u32) -> Value {
439    serde_json::json!({"textDocument": {"uri": uri},
440        "position": {"line": line, "character": character}})
441}
442
443pub fn completion_request(id: i64, uri: &str, line: u32, character: u32) -> Value {
444    request(
445        id,
446        "textDocument/completion",
447        position_params(uri, line, character),
448    )
449}
450
451pub fn hover_request(id: i64, uri: &str, line: u32, character: u32) -> Value {
452    request(
453        id,
454        "textDocument/hover",
455        position_params(uri, line, character),
456    )
457}
458
459pub fn signature_request(id: i64, uri: &str, line: u32, character: u32) -> Value {
460    request(
461        id,
462        "textDocument/signatureHelp",
463        position_params(uri, line, character),
464    )
465}
466
467/// One server diagnostic (0-based line, protocol severity 1=error..4=hint).
468#[derive(Debug, Clone, PartialEq)]
469pub struct Diagnostic {
470    pub line: u32,
471    pub character: u32,
472    pub severity: u32,
473    pub message: String,
474}
475
476/// One completion candidate.
477#[derive(Debug, Clone, PartialEq)]
478pub struct CompletionItem {
479    pub label: String,
480    pub detail: String,
481}
482
483/// Parse `publishDiagnostics` params into diagnostics (empty when clean).
484pub fn parse_publish_diagnostics(params: &Value) -> Vec<Diagnostic> {
485    let Some(items) = params.get("diagnostics").and_then(Value::as_array) else {
486        return Vec::new();
487    };
488    items
489        .iter()
490        .map(|d| Diagnostic {
491            line: d
492                .pointer("/range/start/line")
493                .and_then(Value::as_u64)
494                .unwrap_or(0) as u32,
495            character: d
496                .pointer("/range/start/character")
497                .and_then(Value::as_u64)
498                .unwrap_or(0) as u32,
499            severity: d.get("severity").and_then(Value::as_u64).unwrap_or(1) as u32,
500            message: d
501                .get("message")
502                .and_then(Value::as_str)
503                .unwrap_or("")
504                .to_string(),
505        })
506        .collect()
507}
508
509/// Parse a completion result (array or `{items}`; null means no candidates).
510pub fn parse_completion_items(result: &Value) -> Vec<CompletionItem> {
511    let items = match result {
512        Value::Array(items) => items.clone(),
513        Value::Object(_) => result
514            .get("items")
515            .and_then(Value::as_array)
516            .cloned()
517            .unwrap_or_default(),
518        _ => Vec::new(),
519    };
520    items
521        .iter()
522        .map(|i| CompletionItem {
523            label: i
524                .get("label")
525                .and_then(Value::as_str)
526                .unwrap_or("")
527                .to_string(),
528            detail: i
529                .get("detail")
530                .and_then(Value::as_str)
531                .unwrap_or("")
532                .to_string(),
533        })
534        .filter(|i| !i.label.is_empty())
535        .collect()
536}
537
538/// Parse a hover result into plain text (markdown fenced content included).
539pub fn parse_hover(result: &Value) -> String {
540    let contents = match result {
541        Value::Null => return String::new(),
542        Value::Object(_) => result.get("contents").unwrap_or(&Value::Null),
543        other => other,
544    };
545    match contents {
546        Value::String(s) => s.clone(),
547        Value::Array(parts) => parts
548            .iter()
549            .filter_map(|p| {
550                p.get("value")
551                    .and_then(Value::as_str)
552                    .map(str::to_string)
553                    .or_else(|| p.as_str().map(str::to_string))
554            })
555            .collect::<Vec<_>>()
556            .join("\n"),
557        Value::Object(_) => contents
558            .get("value")
559            .and_then(Value::as_str)
560            .unwrap_or("")
561            .to_string(),
562        _ => String::new(),
563    }
564}
565
566/// Parse signature help into one line per overload.
567pub fn parse_signature_help(result: &Value) -> Vec<String> {
568    result
569        .get("signatures")
570        .and_then(Value::as_array)
571        .map(|sigs| {
572            sigs.iter()
573                .filter_map(|s| s.get("label").and_then(Value::as_str).map(str::to_string))
574                .collect()
575        })
576        .unwrap_or_default()
577}
578
579#[derive(Debug, Clone, Copy, PartialEq)]
580enum PendingKind {
581    Initialize,
582    Completion,
583    Hover,
584    Signature,
585}
586
587/// Byte transport for JSON-RPC values. Production speaks over child
588/// stdio; tests substitute a mock server speaking the same messages.
589pub trait RpcTransport: Send {
590    fn send(&mut self, body: &Value) -> Result<()>;
591    fn drain(&mut self) -> Vec<Value>;
592    fn alive(&mut self) -> bool;
593}
594
595/// Real child-stdio transport: writes go straight to the server's
596/// stdin, a reader thread forwards framed replies to a channel.
597pub struct StdioTransport {
598    stdin: std::process::ChildStdin,
599    inbox: Receiver<Value>,
600    dead: bool,
601}
602
603impl StdioTransport {
604    pub fn spawn(cmd: &str, args: &[&str]) -> Result<(Self, Child)> {
605        let mut child = Command::new(cmd)
606            .args(args)
607            .stdin(Stdio::piped())
608            .stdout(Stdio::piped())
609            .stderr(Stdio::null())
610            .spawn()
611            .map_err(|e| {
612                if e.kind() == std::io::ErrorKind::NotFound {
613                    anyhow::anyhow!(missing_server_message(cmd))
614                } else {
615                    anyhow::anyhow!("cannot start `{cmd}`: {e:#}")
616                }
617            })?;
618        let stdin = child.stdin.take().context("server stdin unavailable")?;
619        let stdout = child.stdout.take().context("server stdout unavailable")?;
620        let (tx, inbox) = mpsc::channel();
621        std::thread::spawn(move || {
622            let mut framing = FramingReader::new(BufReader::new(stdout));
623            while let Ok(msg) = framing.read_message() {
624                if tx.send(msg).is_err() {
625                    break;
626                }
627            }
628        });
629        Ok((
630            Self {
631                stdin,
632                inbox,
633                dead: false,
634            },
635            child,
636        ))
637    }
638}
639
640impl RpcTransport for StdioTransport {
641    fn send(&mut self, body: &Value) -> Result<()> {
642        let bytes = encode_message(body);
643        self.stdin.write_all(&bytes).context("server stdin closed")
644    }
645
646    fn drain(&mut self) -> Vec<Value> {
647        let mut out = Vec::new();
648        loop {
649            match self.inbox.try_recv() {
650                Ok(msg) => out.push(msg),
651                Err(mpsc::TryRecvError::Empty) => break,
652                Err(mpsc::TryRecvError::Disconnected) => {
653                    self.dead = true;
654                    break;
655                }
656            }
657        }
658        out
659    }
660
661    fn alive(&mut self) -> bool {
662        !self.dead
663    }
664}
665
666/// Protocol state above a transport: request ids, pending kinds, typed
667/// results, and per-document diagnostics.
668pub struct Bridge {
669    next_id: i64,
670    pending: HashMap<i64, PendingKind>,
671    responses: HashMap<i64, Value>,
672    versions: HashMap<String, i32>,
673    completions: VecDeque<(i64, Vec<CompletionItem>)>,
674    hovers: VecDeque<(i64, String)>,
675    signatures: VecDeque<(i64, Vec<String>)>,
676    notices: VecDeque<String>,
677    pub diagnostics: HashMap<String, Vec<Diagnostic>>,
678}
679
680impl Bridge {
681    pub fn new() -> Self {
682        Self {
683            next_id: 1,
684            pending: HashMap::new(),
685            responses: HashMap::new(),
686            versions: HashMap::new(),
687            completions: VecDeque::new(),
688            hovers: VecDeque::new(),
689            signatures: VecDeque::new(),
690            notices: VecDeque::new(),
691            diagnostics: HashMap::new(),
692        }
693    }
694
695    fn call(&mut self, tx: &mut dyn RpcTransport, kind: PendingKind, body: Value) -> Result<i64> {
696        let id = self.next_id;
697        self.next_id += 1;
698        tx.send(&body)?;
699        self.pending.insert(id, kind);
700        Ok(id)
701    }
702
703    pub fn send_initialize(
704        &mut self,
705        tx: &mut dyn RpcTransport,
706        root_uri: Option<&str>,
707    ) -> Result<i64> {
708        let id = self.next_id;
709        let body = initialize_request(id, root_uri);
710        self.call(tx, PendingKind::Initialize, body)
711    }
712
713    pub fn send_initialized(&mut self, tx: &mut dyn RpcTransport) -> Result<()> {
714        tx.send(&initialized_notification())
715    }
716
717    pub fn send_configuration(
718        &mut self,
719        tx: &mut dyn RpcTransport,
720        stubs: Option<&Path>,
721    ) -> Result<()> {
722        tx.send(&did_change_configuration(stubs))
723    }
724
725    pub fn did_open(&mut self, tx: &mut dyn RpcTransport, uri: &str, text: &str) -> Result<()> {
726        self.versions.insert(uri.to_string(), 1);
727        tx.send(&did_open_notification(uri, text))
728    }
729
730    pub fn did_change(&mut self, tx: &mut dyn RpcTransport, uri: &str, text: &str) -> Result<()> {
731        let version = self.versions.entry(uri.to_string()).or_insert(0);
732        *version += 1;
733        tx.send(&did_change_notification(uri, *version, text))
734    }
735
736    pub fn request_completion(
737        &mut self,
738        tx: &mut dyn RpcTransport,
739        uri: &str,
740        line: u32,
741        character: u32,
742    ) -> Result<i64> {
743        let id = self.next_id;
744        let body = completion_request(id, uri, line, character);
745        self.call(tx, PendingKind::Completion, body)
746    }
747
748    pub fn request_hover(
749        &mut self,
750        tx: &mut dyn RpcTransport,
751        uri: &str,
752        line: u32,
753        character: u32,
754    ) -> Result<i64> {
755        let id = self.next_id;
756        let body = hover_request(id, uri, line, character);
757        self.call(tx, PendingKind::Hover, body)
758    }
759
760    pub fn request_signature(
761        &mut self,
762        tx: &mut dyn RpcTransport,
763        uri: &str,
764        line: u32,
765        character: u32,
766    ) -> Result<i64> {
767        let id = self.next_id;
768        let body = signature_request(id, uri, line, character);
769        self.call(tx, PendingKind::Signature, body)
770    }
771
772    /// Route one incoming message: match responses to pending requests,
773    /// answer server requests, file diagnostics notifications.
774    pub fn ingest(&mut self, msg: Value, tx: &mut dyn RpcTransport, stubs: Option<&Path>) {
775        if let Some(id) = msg.get("id").and_then(Value::as_i64) {
776            if msg.get("method").is_some() {
777                self.answer_server_request(id, &msg, tx, stubs);
778            } else {
779                let result = msg.get("result").cloned().unwrap_or(Value::Null);
780                match self.pending.remove(&id) {
781                    Some(PendingKind::Completion) => {
782                        self.completions
783                            .push_back((id, parse_completion_items(&result)));
784                    }
785                    Some(PendingKind::Hover) => {
786                        self.hovers.push_back((id, parse_hover(&result)));
787                    }
788                    Some(PendingKind::Signature) => {
789                        self.signatures
790                            .push_back((id, parse_signature_help(&result)));
791                    }
792                    Some(PendingKind::Initialize) => {
793                        self.responses.insert(id, result);
794                    }
795                    None => {}
796                }
797            }
798            return;
799        }
800        let Some(method) = msg.get("method").and_then(Value::as_str) else {
801            return;
802        };
803        match method {
804            "textDocument/publishDiagnostics" => {
805                let params = msg.get("params").unwrap_or(&Value::Null);
806                let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
807                self.diagnostics
808                    .insert(uri.to_string(), parse_publish_diagnostics(params));
809            }
810            "window/showMessage" | "window/logMessage" => {
811                if let Some(text) = msg
812                    .get("params")
813                    .and_then(|p| p.get("message"))
814                    .and_then(Value::as_str)
815                {
816                    self.notices.push_back(text.to_string());
817                }
818            }
819            _ => {}
820        }
821    }
822
823    fn answer_server_request(
824        &mut self,
825        id: i64,
826        msg: &Value,
827        tx: &mut dyn RpcTransport,
828        stubs: Option<&Path>,
829    ) {
830        let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
831        let reply = match method {
832            "workspace/configuration" => {
833                let section = msg
834                    .pointer("/params/items/0/section")
835                    .and_then(Value::as_str)
836                    .unwrap_or("");
837                configuration_response(id, section, stubs)
838            }
839            _ => serde_json::json!({"jsonrpc": "2.0", "id": id, "result": null}),
840        };
841        let _ = tx.send(&reply);
842    }
843
844    /// Drain the transport into protocol state. Returns `false` once the
845    /// server is gone (caller reports loudly and drops the client).
846    pub fn poll(&mut self, tx: &mut dyn RpcTransport, stubs: Option<&Path>) -> bool {
847        for msg in tx.drain() {
848            self.ingest(msg, tx, stubs);
849        }
850        tx.alive()
851    }
852
853    /// Block (via transport polls) until a response id arrives or timeout.
854    /// Used only by the background starter for `initialize`.
855    pub fn await_response(
856        &mut self,
857        tx: &mut dyn RpcTransport,
858        id: i64,
859        stubs: Option<&Path>,
860        timeout: Duration,
861    ) -> Result<Value> {
862        let start = Instant::now();
863        loop {
864            for msg in tx.drain() {
865                self.ingest(msg, tx, stubs);
866            }
867            if let Some(result) = self.responses.remove(&id) {
868                return Ok(result);
869            }
870            if !tx.alive() {
871                anyhow::bail!("server exited during initialize");
872            }
873            if start.elapsed() > timeout {
874                anyhow::bail!("server did not answer initialize in time");
875            }
876            std::thread::sleep(Duration::from_millis(10));
877        }
878    }
879
880    pub fn take_completion(&mut self) -> Option<(i64, Vec<CompletionItem>)> {
881        self.completions.pop_front()
882    }
883
884    pub fn take_hover(&mut self) -> Option<(i64, String)> {
885        self.hovers.pop_front()
886    }
887
888    pub fn take_signature(&mut self) -> Option<(i64, Vec<String>)> {
889        self.signatures.pop_front()
890    }
891
892    pub fn take_notice(&mut self) -> Option<String> {
893        self.notices.pop_front()
894    }
895}
896
897impl Default for Bridge {
898    fn default() -> Self {
899        Self::new()
900    }
901}
902
903/// Owns the child process; killing on drop so no server outlives the editor.
904pub struct ChildGuard(Child);
905
906impl ChildGuard {
907    pub fn new(child: Child) -> Self {
908        Self(child)
909    }
910}
911
912impl Drop for ChildGuard {
913    fn drop(&mut self) {
914        let _ = self.0.kill();
915        let _ = self.0.wait();
916    }
917}
918
919/// A ready-to-use server connection for one editor session.
920pub struct LspClient {
921    bridge: Bridge,
922    transport: Box<dyn RpcTransport>,
923    _child: Option<ChildGuard>,
924    stubs_dir: Option<PathBuf>,
925    dead_reported: bool,
926    pub server_version: Option<String>,
927}
928
929impl LspClient {
930    fn new(
931        transport: Box<dyn RpcTransport>,
932        child: Option<ChildGuard>,
933        stubs_dir: Option<PathBuf>,
934        server_version: Option<String>,
935    ) -> Self {
936        Self {
937            bridge: Bridge::new(),
938            transport,
939            _child: child,
940            stubs_dir,
941            dead_reported: false,
942            server_version,
943        }
944    }
945
946    /// Poll the server; returns an error string once when it dies.
947    pub fn poll(&mut self) -> Option<String> {
948        let stubs = self.stubs_dir.clone();
949        if !self.bridge.poll(&mut *self.transport, stubs.as_deref()) && !self.dead_reported {
950            self.dead_reported = true;
951            return Some("LSP: language server exited; editing continues without it.".to_string());
952        }
953        None
954    }
955
956    pub fn did_open(&mut self, uri: &str, text: &str) -> Result<()> {
957        self.bridge.did_open(&mut *self.transport, uri, text)
958    }
959
960    pub fn did_change(&mut self, uri: &str, text: &str) -> Result<()> {
961        self.bridge.did_change(&mut *self.transport, uri, text)
962    }
963
964    pub fn request_completion(&mut self, uri: &str, line: u32, character: u32) -> Result<i64> {
965        self.bridge
966            .request_completion(&mut *self.transport, uri, line, character)
967    }
968
969    pub fn request_hover(&mut self, uri: &str, line: u32, character: u32) -> Result<i64> {
970        self.bridge
971            .request_hover(&mut *self.transport, uri, line, character)
972    }
973
974    pub fn request_signature(&mut self, uri: &str, line: u32, character: u32) -> Result<i64> {
975        self.bridge
976            .request_signature(&mut *self.transport, uri, line, character)
977    }
978
979    pub fn take_completion(&mut self) -> Option<(i64, Vec<CompletionItem>)> {
980        self.bridge.take_completion()
981    }
982
983    pub fn take_hover(&mut self) -> Option<(i64, String)> {
984        self.bridge.take_hover()
985    }
986
987    pub fn take_signature(&mut self) -> Option<(i64, Vec<String>)> {
988        self.bridge.take_signature()
989    }
990
991    pub fn take_notice(&mut self) -> Option<String> {
992        self.bridge.take_notice()
993    }
994
995    pub fn diagnostics_for_uri(&self, uri: &str) -> &[Diagnostic] {
996        self.bridge
997            .diagnostics
998            .get(uri)
999            .map(Vec::as_slice)
1000            .unwrap_or(&[])
1001    }
1002
1003    pub fn diagnostic_count(&self) -> usize {
1004        self.bridge.diagnostics.values().map(Vec::len).sum()
1005    }
1006}
1007
1008/// Starter outcome delivered to the UI thread.
1009pub enum LspOutcome {
1010    Ready {
1011        client: LspClient,
1012        warning: Option<String>,
1013    },
1014    Failed(String),
1015}
1016
1017/// Spawn the server in the background and run the `initialize` handshake
1018/// there (cold starts take seconds). The receiver gets exactly one outcome;
1019/// a missing binary fails fast and loudly, never silently.
1020pub fn spawn_lsp(root_dir: Option<PathBuf>, script: &Path) -> Receiver<LspOutcome> {
1021    let (tx, rx) = mpsc::channel();
1022    let script = script.to_path_buf();
1023    std::thread::spawn(move || {
1024        let outcome = start_blocking(root_dir.as_deref(), &script);
1025        let _ = tx.send(outcome);
1026    });
1027    rx
1028}
1029
1030fn start_blocking(root_dir: Option<&Path>, script: &Path) -> LspOutcome {
1031    let cmd = std::env::var(ENV_CMD_OVERRIDE).unwrap_or_else(|_| SERVER_CMD.to_string());
1032    let (server_version, mut warning) = probe_server_version(&cmd);
1033    let stubs_dir = root_dir
1034        .and_then(find_stubs_dir)
1035        .or_else(|| find_stubs_dir(script))
1036        .or_else(|| find_stubs_dir(Path::new(".")))
1037        .or_else(materialized_stubs_dir);
1038    if stubs_dir.is_none() {
1039        let w = "LSP: shipped pite.pyi stubs not found; `import pite` may not resolve.".to_string();
1040        warning = Some(match warning {
1041            Some(prev) => format!("{prev} {w}"),
1042            None => w,
1043        });
1044    }
1045    let (mut transport, child) = match StdioTransport::spawn(&cmd, SERVER_ARGS) {
1046        Ok(pair) => pair,
1047        Err(e) => return LspOutcome::Failed(format!("LSP unavailable: {e:#}")),
1048    };
1049    let root_uri = root_dir.map(path_to_uri);
1050    let mut bridge = Bridge::new();
1051    let init_id = match bridge.send_initialize(&mut transport, root_uri.as_deref()) {
1052        Ok(id) => id,
1053        Err(e) => return LspOutcome::Failed(format!("LSP: cannot talk to server: {e:#}")),
1054    };
1055    let stubs = stubs_dir.clone();
1056    match bridge.await_response(&mut transport, init_id, stubs.as_deref(), INIT_TIMEOUT) {
1057        Ok(_) => {}
1058        Err(e) => {
1059            return LspOutcome::Failed(format!(
1060                "LSP: initialize failed ({e:#}); editing continues without it."
1061            ))
1062        }
1063    }
1064    let stubs_ref = stubs_dir.clone();
1065    if bridge.send_initialized(&mut transport).is_err()
1066        || bridge
1067            .send_configuration(&mut transport, stubs_ref.as_deref())
1068            .is_err()
1069    {
1070        return LspOutcome::Failed("LSP: server went quiet after initialize.".to_string());
1071    }
1072    let mut client = LspClient::new(
1073        Box::new(transport),
1074        Some(ChildGuard::new(child)),
1075        stubs_dir,
1076        server_version,
1077    );
1078    client.bridge = bridge;
1079    LspOutcome::Ready { client, warning }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use std::io::Cursor;
1086
1087    /// In-test mock server: canned answers + diagnostics on open/change.
1088    struct MockServer {
1089        opened: HashMap<String, String>,
1090    }
1091
1092    impl MockServer {
1093        fn new() -> Self {
1094            Self {
1095                opened: HashMap::new(),
1096            }
1097        }
1098
1099        fn handle(&mut self, msg: &Value) -> Vec<Value> {
1100            let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
1101            if msg.get("id").is_some() {
1102                let id = msg.get("id").cloned().unwrap();
1103                let result = match method {
1104                    "initialize" => serde_json::json!({"capabilities": {}}),
1105                    "textDocument/completion" => serde_json::json!([
1106                        {"label": "position", "detail": "tuple[float, float]"},
1107                        {"label": "pressed", "detail": "def pressed"},
1108                    ]),
1109                    "textDocument/hover" => serde_json::json!({"contents": {
1110                        "kind": "markdown", "value": "**position**: tuple"}}),
1111                    "textDocument/signatureHelp" => serde_json::json!({"signatures": [
1112                        {"label": "play(path, volume)"},
1113                    ]}),
1114                    "shutdown" => Value::Null,
1115                    _ => Value::Null,
1116                };
1117                return vec![serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result})];
1118            }
1119            match method {
1120                "textDocument/didOpen" | "textDocument/didChange" => {
1121                    let doc = msg.get("params").and_then(|p| p.get("textDocument"));
1122                    let uri = doc
1123                        .and_then(|d| d.get("uri"))
1124                        .and_then(Value::as_str)
1125                        .unwrap_or("");
1126                    let text = if method.ends_with("didOpen") {
1127                        msg.pointer("/params/textDocument/text")
1128                            .and_then(Value::as_str)
1129                            .unwrap_or("")
1130                    } else {
1131                        msg.pointer("/params/contentChanges/0/text")
1132                            .and_then(Value::as_str)
1133                            .unwrap_or("")
1134                    };
1135                    self.opened.insert(uri.to_string(), text.to_string());
1136                    let diagnostics = if text.contains("oops") {
1137                        serde_json::json!([{"range": {"start": {"line": 0, "character": 0},
1138                            "end": {"line": 0, "character": 4}},
1139                            "severity": 1, "message": "mock: undefined name"}])
1140                    } else {
1141                        serde_json::json!([])
1142                    };
1143                    vec![serde_json::json!({"jsonrpc": "2.0",
1144                        "method": "textDocument/publishDiagnostics",
1145                        "params": {"uri": uri, "diagnostics": diagnostics}})]
1146                }
1147                _ => Vec::new(),
1148            }
1149        }
1150    }
1151
1152    struct MockTransport {
1153        pub outbox: Vec<Value>,
1154        inbox: VecDeque<Value>,
1155        server: MockServer,
1156    }
1157
1158    impl MockTransport {
1159        fn new() -> Self {
1160            Self {
1161                outbox: Vec::new(),
1162                inbox: VecDeque::new(),
1163                server: MockServer::new(),
1164            }
1165        }
1166    }
1167
1168    impl RpcTransport for MockTransport {
1169        fn send(&mut self, body: &Value) -> Result<()> {
1170            self.outbox.push(body.clone());
1171            self.inbox.extend(self.server.handle(body));
1172            Ok(())
1173        }
1174
1175        fn drain(&mut self) -> Vec<Value> {
1176            self.inbox.drain(..).collect()
1177        }
1178
1179        fn alive(&mut self) -> bool {
1180            true
1181        }
1182    }
1183
1184    #[test]
1185    fn framing_round_trip() {
1186        let body = serde_json::json!({"jsonrpc": "2.0", "id": 7, "method": "x", "params": {}});
1187        let bytes = encode_message(&body);
1188        assert!(bytes.starts_with(b"Content-Length: "));
1189        let mut reader = FramingReader::new(Cursor::new(bytes));
1190        assert_eq!(reader.read_message().unwrap(), body);
1191    }
1192
1193    #[test]
1194    fn framing_rejects_garbage_loudly() {
1195        let mut reader = FramingReader::new(Cursor::new(b"not a frame".to_vec()));
1196        assert!(reader.read_message().is_err());
1197        let mut reader = FramingReader::new(Cursor::new(b"Content-Length: 5\r\n\r\n###".to_vec()));
1198        assert!(reader.read_message().is_err());
1199    }
1200
1201    #[test]
1202    fn builders_carry_params() {
1203        let open = did_open_notification("file:///a.py", "import pite\n");
1204        assert_eq!(
1205            open.pointer("/params/textDocument/uri").unwrap(),
1206            "file:///a.py"
1207        );
1208        let change = did_change_notification("file:///a.py", 7, "x = 1\n");
1209        assert_eq!(
1210            change.pointer("/params/contentChanges/0/text").unwrap(),
1211            "x = 1\n"
1212        );
1213        assert_eq!(change.pointer("/params/textDocument/version").unwrap(), 7);
1214        let req = completion_request(3, "file:///a.py", 4, 9);
1215        assert_eq!(req.get("id").unwrap(), 3);
1216        assert_eq!(req.pointer("/params/position/line").unwrap(), 4);
1217        let init = initialize_request(1, Some("file:///proj"));
1218        assert_eq!(init.pointer("/params/rootUri").unwrap(), "file:///proj");
1219        assert_eq!(
1220            init.pointer("/params/workspaceFolders/0/uri").unwrap(),
1221            "file:///proj"
1222        );
1223        assert_eq!(
1224            init.pointer("/params/capabilities/workspace/configuration"),
1225            Some(&Value::Bool(true))
1226        );
1227        let bare = initialize_request(1, None);
1228        assert_eq!(
1229            bare.pointer("/params/workspaceFolders").unwrap(),
1230            &serde_json::json!([])
1231        );
1232        let cfg = did_change_configuration(Some(Path::new("/stubs")));
1233        assert_eq!(
1234            cfg.pointer("/params/settings/python/analysis/extraPaths/0")
1235                .unwrap(),
1236            "/stubs"
1237        );
1238    }
1239
1240    #[test]
1241    fn mock_round_trip_open_change_completion_diagnostics() {
1242        let mut bridge = Bridge::new();
1243        let mut tx = MockTransport::new();
1244        let uri = "file:///proj/scripts/player.py";
1245
1246        bridge
1247            .did_open(&mut tx, uri, "import pite\noops\n")
1248            .unwrap();
1249        assert!(bridge.poll(&mut tx, None));
1250        let diags = bridge.diagnostics.get(uri).cloned().unwrap_or_default();
1251        assert_eq!(diags.len(), 1);
1252        assert_eq!(diags[0].line, 0);
1253        assert!(diags[0].message.contains("undefined name"));
1254
1255        bridge.did_change(&mut tx, uri, "import pite\n").unwrap();
1256        assert!(bridge.poll(&mut tx, None));
1257        assert!(bridge
1258            .diagnostics
1259            .get(uri)
1260            .cloned()
1261            .unwrap_or_default()
1262            .is_empty());
1263        let versions: Vec<i64> = tx
1264            .outbox
1265            .iter()
1266            .filter(|m| m.get("method").and_then(Value::as_str) == Some("textDocument/didChange"))
1267            .filter_map(|m| {
1268                m.pointer("/params/textDocument/version")
1269                    .and_then(Value::as_i64)
1270            })
1271            .collect();
1272        assert_eq!(versions, vec![2]);
1273
1274        let id = bridge.request_completion(&mut tx, uri, 1, 2).unwrap();
1275        assert!(bridge.poll(&mut tx, None));
1276        let (got_id, items) = bridge.take_completion().expect("completion arrives");
1277        assert_eq!(got_id, id);
1278        assert_eq!(items.len(), 2);
1279        assert_eq!(items[0].label, "position");
1280
1281        bridge.request_hover(&mut tx, uri, 1, 2).unwrap();
1282        assert!(bridge.poll(&mut tx, None));
1283        let (_, hover) = bridge.take_hover().expect("hover arrives");
1284        assert!(hover.contains("position"));
1285
1286        bridge.request_signature(&mut tx, uri, 1, 2).unwrap();
1287        assert!(bridge.poll(&mut tx, None));
1288        let (_, sigs) = bridge.take_signature().expect("signature arrives");
1289        assert_eq!(sigs, vec!["play(path, volume)"]);
1290
1291        // Every request carried a JSON-RPC id; notifications did not.
1292        assert!(tx
1293            .outbox
1294            .iter()
1295            .any(|m| m.get("method").unwrap() == "textDocument/didOpen"));
1296    }
1297
1298    #[test]
1299    fn server_requests_get_answers() {
1300        let mut bridge = Bridge::new();
1301        let mut tx = MockTransport::new();
1302        bridge.ingest(
1303            serde_json::json!({"jsonrpc": "2.0", "id": 9,
1304                "method": "workspace/configuration",
1305                "params": {"items": [{"scopeUri": "file:///proj", "section": "python"}]}}),
1306            &mut tx,
1307            Some(Path::new("/stubs")),
1308        );
1309        let reply = tx.outbox.last().expect("server request answered");
1310        assert_eq!(reply.get("id").unwrap(), 9);
1311        assert_eq!(
1312            reply.pointer("/result/0/analysis/extraPaths/0").unwrap(),
1313            "/stubs"
1314        );
1315    }
1316
1317    #[test]
1318    fn missing_server_fails_loudly() {
1319        let err = StdioTransport::spawn("definitely-not-a-pite-lsp-server", SERVER_ARGS)
1320            .err()
1321            .expect("missing server must fail");
1322        let msg = format!("{err:#}");
1323        assert!(msg.contains("not found on PATH"), "got: {msg}");
1324        assert!(msg.contains(PINNED_PYRIGHT), "got: {msg}");
1325        assert!(msg.contains(ENV_CMD_OVERRIDE), "got: {msg}");
1326    }
1327
1328    #[test]
1329    fn version_parse_and_compare() {
1330        assert_eq!(parse_version("pyright 1.1.411"), Some((1, 1, 411)));
1331        assert_eq!(parse_version("1.1.398"), Some((1, 1, 398)));
1332        assert_eq!(parse_version("no version here"), None);
1333        assert!(version_at_least((1, 1, 411), (1, 1, 411)));
1334        assert!(version_at_least((1, 1, 412), (1, 1, 411)));
1335        assert!(!version_at_least((1, 1, 400), (1, 1, 411)));
1336    }
1337
1338    #[test]
1339    fn configuration_answers_section_values() {
1340        // `workspace/configuration` pulls `items[0].section`; the answer is
1341        // the section VALUE — a wrapped `{"python": ...}` reads as a section
1342        // with no `analysis` and is silently dropped by the server.
1343        let py = configuration_response(7, "python", Some(Path::new("/stubs")));
1344        assert_eq!(
1345            py.pointer("/result/0/analysis/extraPaths/0").unwrap(),
1346            "/stubs"
1347        );
1348        assert!(py.pointer("/result/0/python").is_none());
1349        let other = configuration_response(8, "pyright", Some(Path::new("/stubs")));
1350        assert_eq!(other.pointer("/result/0").unwrap(), &serde_json::json!({}));
1351    }
1352
1353    #[test]
1354    fn stubs_found_beside_layout() {
1355        let dir = std::env::temp_dir().join(format!("pite-lsp-stubs-{}", std::process::id()));
1356        let _ = std::fs::remove_dir_all(&dir);
1357        let stubs = dir.join("python").join("pite");
1358        std::fs::create_dir_all(&stubs).unwrap();
1359        std::fs::write(stubs.join(STUBS_FILE), "x: int\n").unwrap();
1360        let script = dir.join("scripts").join("a.py");
1361        assert_eq!(find_stubs_dir(&script), Some(stubs));
1362        assert!(find_stubs_dir(Path::new("/definitely/not/here")).is_none());
1363
1364        std::fs::remove_dir_all(&dir).ok();
1365    }
1366
1367    #[test]
1368    fn uris_are_well_formed_on_every_platform() {
1369        assert_eq!(
1370            uri_from_path_text("/home/dev/pite/scripts/a.py"),
1371            "file:///home/dev/pite/scripts/a.py"
1372        );
1373        // Windows: backslashes and drive letter need normalizing, else the
1374        // server reports the project directory as nonexistent.
1375        assert_eq!(
1376            uri_from_path_text(r"D:\Projects\Pite\pite\examples\minimal-2d"),
1377            "file:///D:/Projects/Pite/pite/examples/minimal-2d"
1378        );
1379        assert_eq!(
1380            uri_from_path_text("D:/Projects/Pite/pite/scripts/a.py"),
1381            "file:///D:/Projects/Pite/pite/scripts/a.py"
1382        );
1383        assert_eq!(
1384            uri_from_path_text("/home/dev/my game/a b.py"),
1385            "file:///home/dev/my%20game/a%20b.py"
1386        );
1387        assert_eq!(path_to_uri(Path::new("/tmp/a.py")), "file:///tmp/a.py");
1388    }
1389
1390    #[test]
1391    fn positions_and_word_starts() {
1392        let text = "ab\ncλd\n";
1393        assert_eq!(offset_to_position(text, 0), (0, 0));
1394        assert_eq!(offset_to_position(text, 3), (1, 0));
1395        // λ is one char but two UTF-16 units? No: U+03BB is one UTF-16 unit.
1396        assert_eq!(offset_to_position(text, 4), (1, 1));
1397        assert_eq!(byte_offset_of_char(text, 0), 0);
1398        assert_eq!(byte_offset_of_char(text, 4), 4);
1399        assert_eq!(word_start_before("self.position", 13), 5);
1400        assert_eq!(word_start_before("self.position", 4), 0);
1401        assert_eq!(word_start_before("x = 1", 4), 4);
1402        assert_eq!(word_start_before("x = 1", 5), 4);
1403        assert_eq!(word_end_after("self.position", 5), 13);
1404        assert_eq!(word_end_after("self.position", 0), 4);
1405        assert_eq!(word_end_after("x = 1", 4), 5);
1406        assert_eq!(word_end_after("x = 1", 5), 5);
1407    }
1408
1409    #[test]
1410    fn line_starts_land() {
1411        let text = "a\nbb\n\nc";
1412        assert_eq!(line_start_byte(text, 0), 0);
1413        assert_eq!(line_start_byte(text, 1), 0);
1414        assert_eq!(line_start_byte(text, 2), 2);
1415        assert_eq!(line_start_byte(text, 3), 5);
1416        assert_eq!(line_start_byte(text, 4), 6);
1417        assert_eq!(line_start_byte(text, 99), text.len());
1418    }
1419
1420    #[test]
1421    fn embedded_stubs_materialize_and_repair() {
1422        let dir = std::env::temp_dir().join(format!("pite-lsp-stubtest-{}", std::process::id()));
1423        let _ = std::fs::remove_dir_all(&dir);
1424        let back = write_stubs_to(&dir).unwrap();
1425        assert_eq!(back, dir);
1426        assert_eq!(
1427            std::fs::read_to_string(dir.join(STUBS_FILE)).unwrap(),
1428            EMBEDDED_STUBS
1429        );
1430        assert_eq!(
1431            std::fs::read_to_string(dir.join("pite.py")).unwrap(),
1432            EMBEDDED_SOURCE
1433        );
1434        std::fs::write(dir.join(STUBS_FILE), "stale").unwrap();
1435        write_stubs_to(&dir).unwrap();
1436        assert_eq!(
1437            std::fs::read_to_string(dir.join(STUBS_FILE)).unwrap(),
1438            EMBEDDED_STUBS
1439        );
1440        let _ = std::fs::remove_dir_all(&dir);
1441    }
1442
1443    #[test]
1444    fn null_results_are_empty() {
1445        assert!(parse_completion_items(&Value::Null).is_empty());
1446        assert!(parse_hover(&Value::Null).is_empty());
1447        assert!(parse_signature_help(&Value::Null).is_empty());
1448        assert!(parse_publish_diagnostics(&Value::Null).is_empty());
1449    }
1450
1451    #[cfg(unix)]
1452    #[test]
1453    fn framing_survives_real_fds() {
1454        use std::os::unix::net::UnixStream;
1455        let (a, b) = UnixStream::pair().unwrap();
1456        let body = serde_json::json!({"jsonrpc": "2.0", "method": "ping"});
1457        let bytes = encode_message(&body);
1458        let mut writer = a;
1459        writer.write_all(&bytes).unwrap();
1460        let mut reader = FramingReader::new(BufReader::new(b));
1461        assert_eq!(reader.read_message().unwrap(), body);
1462    }
1463
1464    /// Live-server smoke: real pyright over stdio on the dogfood script
1465    /// (open → completion, change → session still serving). Runs only with
1466    /// `PITE_LSP_SMOKE=1` (CI sets it after installing the pin); otherwise
1467    /// it skips so plain `cargo test` needs no server.
1468    #[test]
1469    fn live_pyright_smoke_open_completion_change() {
1470        if std::env::var("PITE_LSP_SMOKE").is_err() {
1471            eprintln!("skipped: set PITE_LSP_SMOKE=1 for the live pyright smoke");
1472            return;
1473        }
1474        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1475            .join("..")
1476            .join("examples")
1477            .join("minimal-2d");
1478        let script = root.join("scripts").join("player.py");
1479        let text = std::fs::read_to_string(&script).expect("dogfood script exists");
1480        let rx = spawn_lsp(Some(root), &script);
1481        let mut client = match rx
1482            .recv_timeout(Duration::from_secs(180))
1483            .expect("starter answers")
1484        {
1485            LspOutcome::Ready { client, warning } => {
1486                if let Some(w) = warning {
1487                    eprintln!("smoke warning: {w}");
1488                }
1489                assert_eq!(client.server_version.as_deref(), Some(PINNED_PYRIGHT));
1490                client
1491            }
1492            LspOutcome::Failed(msg) => panic!("smoke server failed: {msg}"),
1493        };
1494        let uri = path_to_uri(&script);
1495        client.did_open(&uri, &text).expect("didOpen sends");
1496
1497        // The server must adopt our project root: without `workspaceFolders`
1498        // it logs a synthetic `<default workspace root>`, finds no files, and
1499        // silently analyzes nothing.
1500        while let Some(notice) = client.take_notice() {
1501            assert!(
1502                !notice.contains("<default workspace root>"),
1503                "smoke: server ignored the project root: {notice}"
1504            );
1505        }
1506
1507        // Completion inside `self.` — proves the open + completion round-trip.
1508        let (dot_line, dot_col) = text
1509            .lines()
1510            .enumerate()
1511            .find_map(|(l, line)| {
1512                let col = line.find("self.")? + 5;
1513                Some((l as u32, col as u32))
1514            })
1515            .expect("smoke script contains `self.`");
1516        let found = smoke_await_completion(&mut client, &uri, dot_line, dot_col);
1517        assert!(!found.is_empty(), "smoke: completion came back empty");
1518        assert!(
1519            found.iter().any(|i| i.label == "position"),
1520            "smoke: expected `position`, got {:?}",
1521            found.iter().map(|i| &i.label).collect::<Vec<_>>()
1522        );
1523
1524        // Change, then prove the session still serves: `didChange` syncs the
1525        // server buffer only; the dogfood file on disk is never touched.
1526        let broken = format!("{text}\nundefined_smoke_name_xyz\n");
1527        client.did_change(&uri, &broken).expect("didChange sends");
1528        let after = smoke_await_completion(&mut client, &uri, dot_line, dot_col);
1529        assert!(
1530            after.iter().any(|i| i.label == "position"),
1531            "smoke: completion broke after didChange, got {:?}",
1532            after.iter().map(|i| &i.label).collect::<Vec<_>>()
1533        );
1534    }
1535
1536    /// Poll until the server answers a completion request, failing loudly if
1537    /// it dies or the request goes unanswered. Returns the labels' items.
1538    fn smoke_await_completion(
1539        client: &mut LspClient,
1540        uri: &str,
1541        line: u32,
1542        character: u32,
1543    ) -> Vec<CompletionItem> {
1544        client
1545            .request_completion(uri, line, character)
1546            .expect("completion sends");
1547        let deadline = Instant::now() + Duration::from_secs(120);
1548        loop {
1549            if let Some(msg) = client.poll() {
1550                panic!("smoke: server died ({msg})");
1551            }
1552            if let Some((_, items)) = client.take_completion() {
1553                return items;
1554            }
1555            if Instant::now() > deadline {
1556                panic!("smoke: no completion arrived");
1557            }
1558            std::thread::sleep(Duration::from_millis(100));
1559        }
1560    }
1561}