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