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}