Skip to main content

gobby_code/index/
semantic.rs

1use std::collections::HashSet;
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4use std::process::{Child, ChildStdin, Command, Stdio};
5use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
6use std::thread::{self, JoinHandle};
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, anyhow, bail};
10use serde_json::{Value, json};
11
12const CLANGD_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
13
14#[derive(Debug, Clone)]
15pub(crate) struct SemanticCallRequest<'a> {
16    pub(crate) language: &'a str,
17    pub(crate) file_path: &'a Path,
18    pub(crate) root_path: &'a Path,
19    pub(crate) source: &'a [u8],
20    pub(crate) callee_name: &'a str,
21    pub(crate) line: usize,
22    pub(crate) column: usize,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) struct SemanticCallTarget {
27    pub(crate) callee_name: String,
28    pub(crate) external_module: String,
29}
30
31pub(crate) trait SemanticCallResolver {
32    fn resolve(
33        &mut self,
34        request: &SemanticCallRequest<'_>,
35    ) -> anyhow::Result<Option<SemanticCallTarget>>;
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub(crate) struct DefinitionLocation {
40    pub(crate) path: PathBuf,
41}
42
43pub(crate) fn create_cpp_semantic_resolver(
44    root_path: &Path,
45    require_cpp_semantics: bool,
46) -> anyhow::Result<Option<Box<dyn SemanticCallResolver>>> {
47    let strict = require_cpp_semantics || env_flag("GCODE_REQUIRE_CPP_SEMANTICS");
48    let compile_commands_dir = discover_compile_commands_dir(root_path);
49    let Some(compile_commands_dir) = compile_commands_dir else {
50        if strict {
51            bail!(
52                "C/C++ semantic indexing requires compile_commands.json; set GCODE_COMPILE_COMMANDS_DIR or generate one"
53            );
54        }
55        return Ok(None);
56    };
57
58    let clangd = resolve_clangd_command();
59    let Some(clangd) = clangd else {
60        if strict {
61            bail!("C/C++ semantic indexing requires clangd; set GCODE_CLANGD or install clangd");
62        }
63        return Ok(None);
64    };
65
66    match ClangdResolver::start(root_path, &compile_commands_dir, &clangd) {
67        Ok(resolver) => Ok(Some(Box::new(resolver))),
68        Err(err) if strict => Err(err),
69        Err(_) => Ok(None),
70    }
71}
72
73pub(crate) fn discover_compile_commands_dir(root_path: &Path) -> Option<PathBuf> {
74    if let Ok(override_dir) = std::env::var("GCODE_COMPILE_COMMANDS_DIR") {
75        let dir = PathBuf::from(override_dir);
76        if dir.join("compile_commands.json").is_file() {
77            return Some(dir);
78        }
79        return None;
80    }
81
82    [
83        root_path.to_path_buf(),
84        root_path.join("build"),
85        root_path.join("cmake-build-debug"),
86        root_path.join("cmake-build-release"),
87        root_path.join("out").join("build"),
88    ]
89    .into_iter()
90    .find(|dir| dir.join("compile_commands.json").is_file())
91}
92
93pub(crate) fn classify_definition(
94    root_path: &Path,
95    source: &[u8],
96    callee_name: &str,
97    locations: &[DefinitionLocation],
98) -> Option<SemanticCallTarget> {
99    if locations.len() != 1 || source_defines_macro(source, callee_name) {
100        return None;
101    }
102    let declaration_path = &locations[0].path;
103    if path_is_inside_root(declaration_path, root_path) {
104        return None;
105    }
106    Some(SemanticCallTarget {
107        callee_name: callee_name.to_string(),
108        external_module: declaration_path.to_string_lossy().to_string(),
109    })
110}
111
112pub(crate) fn locations_from_lsp_response(response: &Value) -> Vec<DefinitionLocation> {
113    let Some(result) = response.get("result") else {
114        return Vec::new();
115    };
116    if result.is_null() {
117        return Vec::new();
118    }
119    if let Some(items) = result.as_array() {
120        return items.iter().filter_map(location_from_lsp_value).collect();
121    }
122    location_from_lsp_value(result).into_iter().collect()
123}
124
125fn location_from_lsp_value(value: &Value) -> Option<DefinitionLocation> {
126    let uri = value
127        .get("uri")
128        .or_else(|| value.get("targetUri"))
129        .and_then(|value| value.as_str())?;
130    Some(DefinitionLocation {
131        path: file_uri_to_path(uri)?,
132    })
133}
134
135fn source_defines_macro(source: &[u8], callee_name: &str) -> bool {
136    let text = String::from_utf8_lossy(source);
137    logical_source_lines(&text)
138        .iter()
139        .filter_map(|line| macro_definition_name(line))
140        .any(|macro_name| macro_name == callee_name)
141}
142
143fn logical_source_lines(text: &str) -> Vec<String> {
144    let mut logical_lines = Vec::new();
145    let mut current = String::new();
146
147    for line in text.lines() {
148        let trimmed = line.trim_end();
149        if let Some(continued) = trimmed.strip_suffix('\\') {
150            current.push_str(continued);
151            continue;
152        }
153
154        current.push_str(line);
155        logical_lines.push(std::mem::take(&mut current));
156    }
157
158    if !current.is_empty() {
159        logical_lines.push(current);
160    }
161
162    logical_lines
163}
164
165fn macro_definition_name(line: &str) -> Option<&str> {
166    let rest = line.trim_start().strip_prefix('#')?.trim_start();
167    let rest = rest.strip_prefix("define")?;
168    if !rest.chars().next().is_some_and(char::is_whitespace) {
169        return None;
170    }
171
172    let rest = rest.trim_start();
173    let mut chars = rest.char_indices();
174    let (_, first) = chars.next()?;
175    if !(first == '_' || first.is_ascii_alphabetic()) {
176        return None;
177    }
178
179    let mut end = first.len_utf8();
180    for (idx, ch) in chars {
181        if ch == '_' || ch.is_ascii_alphanumeric() {
182            end = idx + ch.len_utf8();
183        } else {
184            break;
185        }
186    }
187
188    let after_name = &rest[end..];
189    if after_name
190        .chars()
191        .next()
192        .is_none_or(|ch| ch == '(' || ch.is_whitespace())
193    {
194        Some(&rest[..end])
195    } else {
196        None
197    }
198}
199
200fn path_is_inside_root(path: &Path, root_path: &Path) -> bool {
201    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
202    let canonical_root = root_path
203        .canonicalize()
204        .unwrap_or_else(|_| root_path.to_path_buf());
205    canonical_path.starts_with(canonical_root)
206}
207
208fn resolve_clangd_command() -> Option<String> {
209    if let Ok(command) = std::env::var("GCODE_CLANGD")
210        && !command.trim().is_empty()
211    {
212        return Some(command);
213    }
214    find_executable_in_path("clangd").map(|path| path.to_string_lossy().to_string())
215}
216
217fn parse_clangd_command(command: &str) -> anyhow::Result<Vec<String>> {
218    let parts = shlex::split(command).ok_or_else(|| anyhow!("empty clangd command"))?;
219    if parts.is_empty() {
220        bail!("empty clangd command");
221    }
222    Ok(parts)
223}
224
225#[cfg(not(windows))]
226fn find_executable_in_path(name: &str) -> Option<PathBuf> {
227    let path = std::env::var_os("PATH")?;
228    std::env::split_paths(&path)
229        .map(|dir| dir.join(name))
230        .find(|path| path.is_file())
231}
232
233#[cfg(windows)]
234fn find_executable_in_path(name: &str) -> Option<PathBuf> {
235    let path = std::env::var_os("PATH")?;
236    let candidates = executable_name_candidates(name);
237    for dir in std::env::split_paths(&path) {
238        for candidate in &candidates {
239            let path = dir.join(candidate);
240            if path.is_file() {
241                return Some(path);
242            }
243        }
244    }
245    None
246}
247
248#[cfg(windows)]
249fn executable_name_candidates(name: &str) -> Vec<PathBuf> {
250    if Path::new(name).extension().is_some() {
251        return vec![PathBuf::from(name)];
252    }
253
254    let mut candidates = vec![PathBuf::from(name)];
255    if let Some(pathext) = std::env::var_os("PATHEXT") {
256        for ext in pathext.to_string_lossy().split(';') {
257            let ext = ext.trim();
258            if ext.is_empty() {
259                continue;
260            }
261            let ext = if ext.starts_with('.') {
262                ext.to_string()
263            } else {
264                format!(".{ext}")
265            };
266            candidates.push(PathBuf::from(format!("{name}{ext}")));
267        }
268    }
269    candidates
270}
271
272fn env_flag(name: &str) -> bool {
273    std::env::var(name)
274        .ok()
275        .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "on"))
276        .unwrap_or(false)
277}
278
279fn path_to_uri(path: &Path) -> String {
280    let path = path.to_string_lossy();
281    #[cfg(windows)]
282    let path = path.replace('\\', "/");
283    #[cfg(not(windows))]
284    let path = path.into_owned();
285
286    let encoded = path
287        .split('/')
288        .enumerate()
289        .map(|(idx, part)| {
290            if idx == 0 && is_windows_drive_prefix(part) {
291                part.to_string()
292            } else {
293                urlencoding::encode(part).into_owned()
294            }
295        })
296        .collect::<Vec<_>>()
297        .join("/");
298    if encoded.starts_with("//") {
299        format!("file:{encoded}")
300    } else if is_windows_drive_path(&encoded) {
301        format!("file:///{encoded}")
302    } else {
303        format!("file://{encoded}")
304    }
305}
306
307fn is_windows_drive_prefix(part: &str) -> bool {
308    let bytes = part.as_bytes();
309    bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
310}
311
312fn is_windows_drive_path(path: &str) -> bool {
313    path.get(..2).is_some_and(is_windows_drive_prefix)
314}
315
316fn file_uri_to_path(uri: &str) -> Option<PathBuf> {
317    let rest = uri.strip_prefix("file://")?;
318    let decoded = urlencoding::decode(rest).ok()?;
319    let mut path = decoded.into_owned();
320    if cfg!(windows) {
321        let bytes = path.as_bytes();
322        if bytes.len() >= 3
323            && bytes[0] == b'/'
324            && bytes[1].is_ascii_alphabetic()
325            && bytes[2] == b':'
326        {
327            path.remove(0);
328        }
329    }
330    Some(PathBuf::from(path))
331}
332
333struct ClangdResolver {
334    child: Child,
335    stdin: ChildStdin,
336    response_rx: Receiver<anyhow::Result<Value>>,
337    reader_handle: Option<JoinHandle<()>>,
338    next_id: u64,
339    root_path: PathBuf,
340    open_files: HashSet<PathBuf>,
341}
342
343impl ClangdResolver {
344    fn start(root_path: &Path, compile_commands_dir: &Path, clangd: &str) -> anyhow::Result<Self> {
345        let parts = parse_clangd_command(clangd)?;
346        let (program, args) = parts
347            .split_first()
348            .ok_or_else(|| anyhow!("empty clangd command"))?;
349        let mut command = Command::new(program);
350        command.args(args);
351        command.arg(format!(
352            "--compile-commands-dir={}",
353            compile_commands_dir.display()
354        ));
355        command.arg("--background-index=false");
356        command.stdin(Stdio::piped());
357        command.stdout(Stdio::piped());
358        command.stderr(Stdio::null());
359        let mut child = command.spawn().context("start clangd")?;
360        let stdin = child.stdin.take().context("open clangd stdin")?;
361        let stdout = child.stdout.take().context("open clangd stdout")?;
362        let (response_rx, reader_handle) = spawn_clangd_stdout_reader(stdout);
363        let mut resolver = Self {
364            child,
365            stdin,
366            response_rx,
367            reader_handle: Some(reader_handle),
368            next_id: 1,
369            root_path: root_path.to_path_buf(),
370            open_files: HashSet::new(),
371        };
372        resolver.initialize()?;
373        Ok(resolver)
374    }
375
376    fn initialize(&mut self) -> anyhow::Result<()> {
377        let id = self.send_request(
378            "initialize",
379            json!({
380                "processId": Value::Null,
381                "rootUri": path_to_uri(&self.root_path),
382                "capabilities": {}
383            }),
384        )?;
385        self.read_response(id)?;
386        self.send_notification("initialized", json!({}))?;
387        Ok(())
388    }
389
390    fn ensure_open(&mut self, request: &SemanticCallRequest<'_>) -> anyhow::Result<()> {
391        if self.open_files.contains(request.file_path) {
392            return Ok(());
393        }
394        let text = String::from_utf8_lossy(request.source).to_string();
395        self.send_notification(
396            "textDocument/didOpen",
397            json!({
398                "textDocument": {
399                    "uri": path_to_uri(request.file_path),
400                    "languageId": request.language,
401                    "version": 1,
402                    "text": text
403                }
404            }),
405        )?;
406        self.open_files.insert(request.file_path.to_path_buf());
407        Ok(())
408    }
409
410    fn close_open_files(&mut self) -> anyhow::Result<()> {
411        let paths: Vec<PathBuf> = self.open_files.iter().cloned().collect();
412        let mut first_error = None;
413
414        for path in paths {
415            let result = self.send_notification(
416                "textDocument/didClose",
417                json!({
418                    "textDocument": {
419                        "uri": path_to_uri(&path)
420                    }
421                }),
422            );
423            match result {
424                Ok(()) => {
425                    self.open_files.remove(&path);
426                }
427                Err(err) if first_error.is_none() => {
428                    first_error = Some(err);
429                }
430                Err(_) => {}
431            }
432        }
433
434        match first_error {
435            Some(err) => Err(err),
436            None => Ok(()),
437        }
438    }
439
440    fn send_request(&mut self, method: &str, params: Value) -> anyhow::Result<u64> {
441        let id = self.next_id;
442        self.next_id += 1;
443        self.write_message(json!({
444            "jsonrpc": "2.0",
445            "id": id,
446            "method": method,
447            "params": params
448        }))?;
449        Ok(id)
450    }
451
452    fn send_notification(&mut self, method: &str, params: Value) -> anyhow::Result<()> {
453        self.write_message(json!({
454            "jsonrpc": "2.0",
455            "method": method,
456            "params": params
457        }))
458    }
459
460    fn write_message(&mut self, value: Value) -> anyhow::Result<()> {
461        let body = value.to_string();
462        write!(self.stdin, "Content-Length: {}\r\n\r\n{}", body.len(), body)?;
463        self.stdin.flush()?;
464        Ok(())
465    }
466
467    fn read_response(&mut self, id: u64) -> anyhow::Result<Value> {
468        read_response_from_channel(&self.response_rx, id, CLANGD_RESPONSE_TIMEOUT)
469    }
470}
471
472impl Drop for ClangdResolver {
473    fn drop(&mut self) {
474        let _ = self.child.kill();
475        let _ = self.child.wait();
476        if let Some(handle) = self.reader_handle.take() {
477            let _ = handle.join();
478        }
479    }
480}
481
482impl SemanticCallResolver for ClangdResolver {
483    fn resolve(
484        &mut self,
485        request: &SemanticCallRequest<'_>,
486    ) -> anyhow::Result<Option<SemanticCallTarget>> {
487        if !matches!(request.language, "c" | "cpp") {
488            return Ok(None);
489        }
490        let result = (|| -> anyhow::Result<Option<SemanticCallTarget>> {
491            self.ensure_open(request).context("open clangd document")?;
492            let id = self
493                .send_request(
494                    "textDocument/definition",
495                    json!({
496                        "textDocument": { "uri": path_to_uri(request.file_path) },
497                        "position": {
498                            "line": request.line.saturating_sub(1),
499                            "character": request.column,
500                        }
501                    }),
502                )
503                .context("send clangd definition request")?;
504            let response = self
505                .read_response(id)
506                .context("read clangd definition response")?;
507            let locations = locations_from_lsp_response(&response);
508            Ok(classify_definition(
509                request.root_path,
510                request.source,
511                request.callee_name,
512                &locations,
513            ))
514        })();
515        let resolved = result?;
516        self.close_open_files().context("close clangd open files")?;
517        Ok(resolved)
518    }
519}
520
521fn spawn_clangd_stdout_reader(
522    stdout: std::process::ChildStdout,
523) -> (Receiver<anyhow::Result<Value>>, JoinHandle<()>) {
524    let (tx, rx) = mpsc::channel();
525    let handle = thread::spawn(move || read_clangd_stdout(BufReader::new(stdout), tx));
526    (rx, handle)
527}
528
529fn read_clangd_stdout(mut reader: impl BufRead, tx: Sender<anyhow::Result<Value>>) {
530    loop {
531        match read_json_rpc_message(&mut reader) {
532            Ok(Some(response)) => {
533                if tx.send(Ok(response)).is_err() {
534                    break;
535                }
536            }
537            Ok(None) => {
538                let _ = tx.send(Err(anyhow!("clangd closed stdout")));
539                break;
540            }
541            Err(err) => {
542                let _ = tx.send(Err(err));
543                break;
544            }
545        }
546    }
547}
548
549fn read_json_rpc_message(reader: &mut impl BufRead) -> anyhow::Result<Option<Value>> {
550    let mut content_length = None;
551    loop {
552        let mut header = String::new();
553        let read = reader.read_line(&mut header)?;
554        if read == 0 {
555            return Ok(None);
556        }
557        let header = header.trim_end_matches(['\r', '\n']);
558        if header.is_empty() {
559            break;
560        }
561        if let Some(value) = header.strip_prefix("Content-Length:") {
562            content_length = Some(value.trim().parse::<usize>()?);
563        }
564    }
565
566    let len = content_length.context("missing clangd Content-Length header")?;
567    let mut body = vec![0u8; len];
568    reader.read_exact(&mut body)?;
569    let response = serde_json::from_slice(&body)?;
570    Ok(Some(response))
571}
572
573fn read_response_from_channel(
574    rx: &Receiver<anyhow::Result<Value>>,
575    id: u64,
576    timeout: Duration,
577) -> anyhow::Result<Value> {
578    let started = Instant::now();
579    let deadline = started + timeout;
580
581    loop {
582        let now = Instant::now();
583        if now >= deadline {
584            bail!(
585                "clangd response timeout after {}",
586                format_clangd_timeout(timeout)
587            );
588        }
589        match rx.recv_timeout(deadline.saturating_duration_since(now)) {
590            Ok(Ok(response)) => {
591                if response.get("id").and_then(|value| value.as_u64()) == Some(id) {
592                    return Ok(response);
593                }
594            }
595            Ok(Err(err)) => return Err(err),
596            Err(RecvTimeoutError::Timeout) => {
597                bail!(
598                    "clangd response timeout after {}",
599                    format_clangd_timeout(timeout)
600                );
601            }
602            Err(RecvTimeoutError::Disconnected) => bail!("clangd closed stdout"),
603        }
604    }
605}
606
607fn format_clangd_timeout(timeout: Duration) -> String {
608    if timeout.as_nanos().is_multiple_of(1_000_000_000) {
609        format!("{}s", timeout.as_secs())
610    } else if timeout.as_nanos().is_multiple_of(1_000_000) {
611        format!("{}ms", timeout.as_millis())
612    } else {
613        format!("{timeout:?}")
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use std::fs;
620
621    use tempfile::TempDir;
622
623    use super::*;
624
625    #[test]
626    fn discovers_compile_commands_in_root_and_build_dirs() {
627        let tempdir = TempDir::new().expect("tempdir");
628        assert!(discover_compile_commands_dir(tempdir.path()).is_none());
629        let build = tempdir.path().join("build");
630        fs::create_dir_all(&build).expect("build dir");
631        fs::write(build.join("compile_commands.json"), "[]").expect("compile db");
632        assert_eq!(discover_compile_commands_dir(tempdir.path()), Some(build));
633    }
634
635    #[test]
636    fn parses_lsp_location_and_location_link_results() {
637        let response = json!({
638            "id": 1,
639            "result": [
640                { "uri": "file:///usr/include/stdio.h", "range": {} },
641                { "targetUri": "file:///opt/pkg/include/foo.hpp", "targetRange": {} }
642            ]
643        });
644        let locations = locations_from_lsp_response(&response);
645        assert_eq!(locations.len(), 2);
646        assert_eq!(locations[0].path, PathBuf::from("/usr/include/stdio.h"));
647        assert_eq!(locations[1].path, PathBuf::from("/opt/pkg/include/foo.hpp"));
648    }
649
650    #[test]
651    fn parses_quoted_clangd_command_arguments() {
652        let parts =
653            parse_clangd_command(r#""/tmp/tool dir/clangd" --query-driver="/usr/bin/cc *""#)
654                .expect("clangd argv");
655
656        assert_eq!(
657            parts,
658            vec!["/tmp/tool dir/clangd", "--query-driver=/usr/bin/cc *"]
659        );
660    }
661
662    #[test]
663    fn rejects_empty_and_invalid_clangd_commands() {
664        for command in ["", "   ", "clangd \"unterminated"] {
665            let err = parse_clangd_command(command).expect_err("invalid clangd command");
666            assert_eq!(err.to_string(), "empty clangd command");
667        }
668    }
669
670    #[test]
671    fn channel_response_wait_times_out() {
672        let (_tx, rx) = std::sync::mpsc::channel();
673        let err = read_response_from_channel(&rx, 42, Duration::from_millis(1))
674            .expect_err("clangd response timeout");
675
676        assert_eq!(err.to_string(), "clangd response timeout after 1ms");
677    }
678
679    #[test]
680    fn classifies_single_definition_outside_project_as_external() {
681        let tempdir = TempDir::new().expect("tempdir");
682        let external = TempDir::new().expect("external tempdir");
683        let header = external.path().join("vendor.h");
684        fs::write(&header, "void vendor_call();").expect("header");
685        let target = classify_definition(
686            tempdir.path(),
687            b"void run() { vendor_call(); }",
688            "vendor_call",
689            &[DefinitionLocation { path: header }],
690        )
691        .expect("external target");
692
693        assert_eq!(target.callee_name, "vendor_call");
694        assert!(target.external_module.ends_with("vendor.h"));
695    }
696
697    #[test]
698    fn leaves_local_empty_multiple_and_macro_definitions_unresolved() {
699        let tempdir = TempDir::new().expect("tempdir");
700        let local = tempdir.path().join("local.h");
701        fs::write(&local, "void local_call();").expect("local header");
702        let external = TempDir::new()
703            .expect("external tempdir")
704            .path()
705            .join("vendor.h");
706
707        assert!(
708            classify_definition(
709                tempdir.path(),
710                b"void run() { local_call(); }",
711                "local_call",
712                &[DefinitionLocation { path: local }]
713            )
714            .is_none()
715        );
716        assert!(classify_definition(tempdir.path(), b"", "missing", &[]).is_none());
717        assert!(
718            classify_definition(
719                tempdir.path(),
720                b"",
721                "ambiguous",
722                &[
723                    DefinitionLocation {
724                        path: PathBuf::from("/usr/include/a.h")
725                    },
726                    DefinitionLocation {
727                        path: PathBuf::from("/usr/include/b.h")
728                    }
729                ]
730            )
731            .is_none()
732        );
733        assert!(
734            classify_definition(
735                tempdir.path(),
736                b"#define printf my_printf\nvoid run() { printf(\"x\"); }",
737                "printf",
738                &[DefinitionLocation { path: external }]
739            )
740            .is_none()
741        );
742    }
743
744    #[test]
745    fn detects_function_like_and_backslash_continued_macros() {
746        assert!(source_defines_macro(
747            b"#define trace(value) log(value)\nvoid run() { trace(1); }",
748            "trace"
749        ));
750        assert!(source_defines_macro(
751            b"#define \\\ntrace(value) \\\nlog(value)\nvoid run() { trace(1); }",
752            "trace"
753        ));
754        assert!(source_defines_macro(
755            b"# define spaced(value) log(value)\nvoid run() { spaced(1); }",
756            "spaced"
757        ));
758        assert!(!source_defines_macro(
759            b"#define trace_wrapper(value) trace(value)",
760            "trace"
761        ));
762        assert!(!source_defines_macro(b"# defined trace(value)", "trace"));
763    }
764
765    #[test]
766    #[cfg(not(windows))]
767    fn path_to_uri_encodes_absolute_path_components() {
768        let uri = path_to_uri(Path::new("/tmp/gobby uri/a b/c#d.rs"));
769
770        assert_eq!(uri, "file:///tmp/gobby%20uri/a%20b/c%23d.rs");
771    }
772
773    #[test]
774    #[cfg(windows)]
775    fn path_to_uri_preserves_windows_drive_prefix() {
776        let uri = path_to_uri(Path::new(r"C:\Users\Josh\gobby uri\a#b.rs"));
777
778        assert_eq!(uri, "file:///C:/Users/Josh/gobby%20uri/a%23b.rs");
779    }
780
781    #[test]
782    #[cfg(windows)]
783    fn file_uri_to_path_strips_windows_drive_leading_slash() {
784        let path =
785            file_uri_to_path("file:///C:/Users/Josh/gobby%20uri/a%23b.rs").expect("file uri path");
786
787        assert_eq!(path, PathBuf::from(r"C:/Users/Josh/gobby uri/a#b.rs"));
788    }
789
790    #[test]
791    #[cfg(not(windows))]
792    fn file_uri_to_path_keeps_decoded_path_on_non_windows() {
793        let path =
794            file_uri_to_path("file:///C:/Users/Josh/gobby%20uri/a%23b.rs").expect("file uri path");
795
796        assert_eq!(path, PathBuf::from("/C:/Users/Josh/gobby uri/a#b.rs"));
797    }
798
799    #[test]
800    #[cfg(windows)]
801    #[serial_test::serial]
802    fn find_executable_in_path_honors_pathext_on_windows() {
803        let tempdir = TempDir::new().expect("tempdir");
804        let exe = tempdir.path().join("clangd.CMD");
805        fs::write(&exe, "").expect("fake executable");
806        let old_path = std::env::var_os("PATH");
807        let old_pathext = std::env::var_os("PATHEXT");
808
809        unsafe {
810            std::env::set_var("PATH", tempdir.path());
811            std::env::set_var("PATHEXT", ".COM;.EXE;.CMD");
812        }
813        let found = find_executable_in_path("clangd");
814        unsafe {
815            match old_path {
816                Some(value) => std::env::set_var("PATH", value),
817                None => std::env::remove_var("PATH"),
818            }
819            match old_pathext {
820                Some(value) => std::env::set_var("PATHEXT", value),
821                None => std::env::remove_var("PATHEXT"),
822            }
823        }
824
825        assert_eq!(found.as_deref(), Some(exe.as_path()));
826    }
827
828    #[test]
829    fn optional_clangd_integration_resolves_external_definition() {
830        if std::env::var("GCODE_TEST_CLANGD").ok().as_deref() != Some("1") {
831            return;
832        }
833        let Some(clangd) = resolve_clangd_command() else {
834            panic!("GCODE_TEST_CLANGD=1 requires clangd");
835        };
836        let tempdir = TempDir::new().expect("tempdir");
837        let source_dir = tempdir.path().join("src");
838        fs::create_dir_all(&source_dir).expect("source dir");
839        let source_path = source_dir.join("main.c");
840        let source = b"#include <stdio.h>\nvoid run(void) {\n    printf(\"x\");\n}\n";
841        fs::write(&source_path, source).expect("source");
842        let compile_db = format!(
843            r#"[{{"directory":"{}","command":"cc -c {}","file":"{}"}}]"#,
844            tempdir.path().display(),
845            source_path.display(),
846            source_path.display()
847        );
848        fs::write(tempdir.path().join("compile_commands.json"), compile_db).expect("compile db");
849
850        let mut resolver =
851            ClangdResolver::start(tempdir.path(), tempdir.path(), &clangd).expect("clangd");
852        let target = resolver
853            .resolve(&SemanticCallRequest {
854                language: "c",
855                file_path: &source_path,
856                root_path: tempdir.path(),
857                source,
858                callee_name: "printf",
859                line: 3,
860                column: 4,
861            })
862            .expect("resolve external definition");
863        assert!(target.is_some());
864    }
865}