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#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) enum SemanticTargetKind {
34 External(String),
37 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
212fn 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 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}